one-design-next 0.0.112 → 0.0.114

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.
@@ -23,12 +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 { findChipHostBeforeCaret, mergeLineIntoPreviousAtCaretStart, normalizeCollapsedCaretAtChipBoundary, resolveCaretMove, splitLineAt } from "./caret";
26
+ import { caretBeforeHintIn, caretRangeFromPoint, findChipHostBeforeCaret, 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
- import { createLineRoot, createLineRootWithText, ensureLineRoots, getPlainText, isEditorEmpty, LINE_ATTR_NAME, normalizeEditorDom, probeTrigger } from "./utils";
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
33
 
33
34
  /** Composer editor 触发态对外信号——告诉父组件该弹浮层选 skill 了。 */
34
35
 
@@ -39,6 +40,11 @@ var sameProbe = function sameProbe(a, b) {
39
40
  return a.textNode === b.textNode && a.triggerOffset === b.triggerOffset && a.caretOffset === b.caretOffset && a.trigger === b.trigger;
40
41
  };
41
42
 
43
+ /** 归一化结果是否与当前 caret 完全一致(同节点同偏移)。 */
44
+ var isSameCaretPosition = function isSameCaretPosition(range, position) {
45
+ return range.startContainer === position.node && range.startOffset === position.offset;
46
+ };
47
+
42
48
  /**
43
49
  * 让 caret 始终保持在 viewport(ScrollArea 的可视区)内。
44
50
  *
@@ -237,11 +243,124 @@ export var ComposerEditor = /*#__PURE__*/forwardRef(function ComposerEditor(_ref
237
243
  }
238
244
  };
239
245
  }, []);
246
+
247
+ /* chips 快照读取器:fireChange 声明在 useChipManager 之前(后者又依赖 fireChange
248
+ * 当 onAfterMutate),用 ref 打破声明顺序,与 recomputeTriggerRef 同一套做法。 */
249
+ var getChipsRef = useRef(function () {
250
+ return [];
251
+ });
252
+
253
+ /* ---- 派生 hint:imperative DOM 同步 ----
254
+ * hint 必须是 chip host 的**兄弟节点**,不能塞在 host 内部:host 是
255
+ * contentEditable=false 的原子节点,塞进去后「chip 之后、hint 之前」这个位置
256
+ * 就不存在了,caret 只能落在整个 host 之后(= 提示文本末尾)。
257
+ *
258
+ * 它不归 React 管(editor 的 children 由 contenteditable 自治),由 fireChange
259
+ * 按结构谓词增删/挪位;`getPlainText` 会整棵子树跳过,所以不进 value / 剪贴板。 */
260
+ var hintNodeRef = useRef(null);
261
+ var removeHintNode = useCallback(function () {
262
+ var _hintNodeRef$current;
263
+ (_hintNodeRef$current = hintNodeRef.current) === null || _hintNodeRef$current === void 0 || _hintNodeRef$current.remove();
264
+ hintNodeRef.current = null;
265
+ }, []);
266
+ var syncHintNode = useCallback(function (target) {
267
+ var _Array$from$find, _blankTail;
268
+ var el = editorRef.current;
269
+ var host = el && target ? (_Array$from$find = Array.from(el.querySelectorAll('[data-mention-id]')).find(function (node) {
270
+ return node.dataset.mentionId === target.chipId;
271
+ })) !== null && _Array$from$find !== void 0 ? _Array$from$find : null : null;
272
+ if (!target || !host || !host.parentNode) {
273
+ removeHintNode();
274
+ return;
275
+ }
276
+ var node = hintNodeRef.current;
277
+ if (!node || !node.isConnected) {
278
+ node = document.createElement('span');
279
+ node.setAttribute(COMPOSER_HINT_ATTR, '');
280
+ node.contentEditable = 'false';
281
+ hintNodeRef.current = node;
282
+ }
283
+ if (node.textContent !== target.text) node.textContent = target.text;
284
+
285
+ /* 落位:命中 chip 之后、**紧随其后的不可见锚点文本(ZWSP)之后**。
286
+ *
287
+ * 谓词要求 chip 之后没有任何内容,所以这里能走过去的只有不进 value 的
288
+ * 锚点文本(caret.ts / insertChip 在 chip 与 hint 之间塞的 ZWSP)。hint
289
+ * 若插在锚点前面,caret 就只剩「元素偏移」一个落点,每次输入后还会被
290
+ * 拽回去重排;排在锚点之后,caret 落在真实的文本节点里,位置稳定。
291
+ * 注意只跳过「纯不可见字符」的文本:空格 / 换行是内容(谓词会让 hint
292
+ * 直接消失),不能把 hint 推到它们后面。 */
293
+ var parent = host.parentNode;
294
+ var blankTail = null;
295
+ var cur = host.nextSibling;
296
+ while (cur) {
297
+ var _cur$textContent;
298
+ // 跳过 hint 自身:上一轮若把它排到了锚点前面,这里顺手挪回末尾
299
+ if (cur === node) {
300
+ cur = cur.nextSibling;
301
+ continue;
302
+ }
303
+ if (cur.nodeType === Node.TEXT_NODE && stripInvisibleChars((_cur$textContent = cur.textContent) !== null && _cur$textContent !== void 0 ? _cur$textContent : '') === '') {
304
+ blankTail = cur;
305
+ cur = cur.nextSibling;
306
+ continue;
307
+ }
308
+ break;
309
+ }
310
+ /* 没有现成锚点时必须补一个:草稿还原(defaultValue + defaultChips)走的
311
+ * 是 insertSerializedAtRange,不会像 insertChip 那样在 chip 后塞 ZWSP,
312
+ * 于是 chip 与 hint 之间没有任何可编辑文本节点。此时用户第一次点击 hint,
313
+ * 原生 caret 无处可落,Chrome 会先把它放进 hint 内部(首帧画在提示文案
314
+ * 开头),下一拍才被 selectionchange 拉回来 —— 也就是「进页面第一次点
315
+ * hint,光标先闪一下」。垫上锚点后原生落点直接命中它。
316
+ * 锚点不进 value(stripInvisibleChars / getPlainText 都会剔除)。 */
317
+ if (!blankTail) {
318
+ var caretAnchor = document.createTextNode(ZWSP);
319
+ parent.insertBefore(caretAnchor, host.nextSibling);
320
+ blankTail = caretAnchor;
321
+ }
322
+ var anchor = (_blankTail = blankTail) !== null && _blankTail !== void 0 ? _blankTail : host;
323
+ if (node.parentNode !== parent || node.previousSibling !== anchor) {
324
+ parent.insertBefore(node, anchor.nextSibling);
325
+ }
326
+ }, [removeHintNode]);
327
+
328
+ /** 把 caret 直接落到 hint 之前(= chip 右侧);没有 hint 时不动。 */
329
+ var placeCaretBeforeHint = useCallback(function () {
330
+ var el = editorRef.current;
331
+ var target = el ? caretBeforeHintIn(el) : null;
332
+ var sel = window.getSelection();
333
+ if (!target || !sel) return false;
334
+ var range = document.createRange();
335
+ range.setStart(target.node, target.offset);
336
+ range.collapse(true);
337
+ sel.removeAllRanges();
338
+ sel.addRange(range);
339
+ lastEditorRangeRef.current = range.cloneRange();
340
+ return true;
341
+ }, []);
342
+
343
+ /** hint 之后不是合法插入点:把跑过去的 caret 拉回 hint 之前(chip 右侧)。 */
344
+ var pullCaretBeforeHint = useCallback(function () {
345
+ var el = editorRef.current;
346
+ var sel = window.getSelection();
347
+ if (!el || !sel || sel.rangeCount !== 1 || !sel.isCollapsed) return;
348
+ if (!el.contains(sel.anchorNode)) return;
349
+ if (!normalizeCollapsedCaretBeforeHint(sel.getRangeAt(0), el)) return;
350
+ placeCaretBeforeHint();
351
+ }, [placeCaretBeforeHint]);
240
352
  var fireChange = useCallback(function () {
241
353
  var el = editorRef.current;
242
354
  if (!el) return;
243
355
  // normalizeEditorDom 内部已 ensureLineRoots(commit-2),此处不必重复兜底
244
356
  normalizeEditorDom(el);
357
+ var chips = getChipsRef.current();
358
+ var raw = getPlainText(el);
359
+ /* 派生 hint 与 value 同源同刻重算:结构谓词只依赖 raw + chips(换行也算
360
+ * 内容,所以按 Enter 后 hint 自动消失)。必须先落 DOM 再算 empty / 高度——
361
+ * 否则「chip 被删」时残留的 hint 文本会让 placeholder 判定与 lite 升格
362
+ * 判定晚一拍。 */
363
+ syncHintNode(resolveHintTarget(raw, chips));
245
364
  var showPlaceholder = shouldShowPlaceholder(el);
246
365
  if (forceNonEmptyOnceRef.current && showPlaceholder) {
247
366
  setEmpty(false);
@@ -249,11 +368,12 @@ export var ComposerEditor = /*#__PURE__*/forwardRef(function ComposerEditor(_ref
249
368
  setEmpty(showPlaceholder);
250
369
  }
251
370
  forceNonEmptyOnceRef.current = false;
252
- onChange === null || onChange === void 0 || onChange(getPlainText(el));
371
+ onChange === null || onChange === void 0 || onChange(raw);
372
+ pullCaretBeforeHint();
253
373
  scheduleControlledSync();
254
374
  // editor.height 锁定时 RO 不触发,主动同步一次,确保升格判定能拿到 scrollHeight
255
375
  syncHeight();
256
- }, [onChange, scheduleControlledSync, shouldShowPlaceholder, syncHeight]);
376
+ }, [onChange, pullCaretBeforeHint, scheduleControlledSync, shouldShowPlaceholder, syncHeight, syncHintNode]);
257
377
 
258
378
  /* ─────────────────────────────────────
259
379
  * chip 管理
@@ -272,6 +392,7 @@ export var ComposerEditor = /*#__PURE__*/forwardRef(function ComposerEditor(_ref
272
392
  getChipHost = _useChipManager.getChipHost,
273
393
  getChips = _useChipManager.getChips,
274
394
  resetChips = _useChipManager.resetChips;
395
+ getChipsRef.current = getChips;
275
396
  useChipSelectionMarker(editorRef, chips);
276
397
  var insertSerializedAtRangeRef = useRef(insertSerializedAtRange);
277
398
  insertSerializedAtRangeRef.current = insertSerializedAtRange;
@@ -305,6 +426,50 @@ export var ComposerEditor = /*#__PURE__*/forwardRef(function ComposerEditor(_ref
305
426
  }, [triggers, onTriggerChange, probeToInfo]);
306
427
  recomputeTriggerRef.current = recomputeTrigger;
307
428
 
429
+ /* ─────────────────────────────────────
430
+ * Tab 采纳 hint
431
+ *
432
+ * hint 只是结构投影(不进 value);caret 恰好停在 chip 与 hint 之间
433
+ * (= 内容末尾)时按 Tab,表示用户接受这段模板,于是把提示文案落成正文:
434
+ * chip 之后出现内容 → 结构谓词不再命中 → 灰字自动移除,onChange 抛出的
435
+ * value 里带上这段文案,与用户自己敲进去的效果一致。
436
+ * ───────────────────────────────────── */
437
+ var acceptHintAsText = useCallback(function () {
438
+ var _hint$textContent;
439
+ var el = editorRef.current;
440
+ var hint = hintNodeRef.current;
441
+ var sel = window.getSelection();
442
+ if (!el || !hint || !hint.isConnected || !sel || sel.rangeCount === 0) {
443
+ return false;
444
+ }
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
+ var text = (_hint$textContent = hint.textContent) !== null && _hint$textContent !== void 0 ? _hint$textContent : '';
451
+ if (!text) return false;
452
+
453
+ /* 多行模板按行模型展开(与粘贴纯文本同一路径);单行直接插文本节点。 */
454
+ if (text.includes('\n')) {
455
+ insertSerializedAtRange(text, [], range, el);
456
+ } else {
457
+ var node = document.createTextNode(text);
458
+ range.insertNode(node);
459
+ var caret = document.createRange();
460
+ /* 落点写进文本节点内部(而不是 setStartAfter 的元素偏移),后续输入
461
+ * 直接在同一节点里续写,caret 也更稳定。 */
462
+ caret.setStart(node, text.length);
463
+ caret.collapse(true);
464
+ sel.removeAllRanges();
465
+ sel.addRange(caret);
466
+ }
467
+ fireChange();
468
+ recomputeTrigger();
469
+ requestScrollCaretIntoView();
470
+ return true;
471
+ }, [fireChange, insertSerializedAtRange, recomputeTrigger, requestScrollCaretIntoView]);
472
+
308
473
  /* ─────────────────────────────────────
309
474
  * ref 命令式 API
310
475
  * ───────────────────────────────────── */
@@ -323,6 +488,7 @@ export var ComposerEditor = /*#__PURE__*/forwardRef(function ComposerEditor(_ref
323
488
  if (!el) return;
324
489
  el.innerHTML = '';
325
490
  el.appendChild(createLineRoot());
491
+ removeHintNode();
326
492
  resetChips();
327
493
  setEmpty(true);
328
494
  if (triggerRef.current) {
@@ -418,8 +584,15 @@ export var ComposerEditor = /*#__PURE__*/forwardRef(function ComposerEditor(_ref
418
584
  }
419
585
  var range = sel.getRangeAt(0);
420
586
  if (range.collapsed) {
421
- var normalized = normalizeCollapsedCaretAtChipBoundary(range, el);
422
- if (normalized) {
587
+ var _normalizeCollapsedCa;
588
+ /* hint 之后不是合法插入点(点击行尾 / End / 方向键跑到提示文案后面),
589
+ * 优先拉回 hint 左侧;其余 chip 边界仍走原规范化。 */
590
+ var normalized = (_normalizeCollapsedCa = normalizeCollapsedCaretBeforeHint(range, el)) !== null && _normalizeCollapsedCa !== void 0 ? _normalizeCollapsedCa : normalizeCollapsedCaretAtChipBoundary(range, el);
591
+ /* 归一化到「原地」时不能再回写:removeAllRanges + addRange 会再次派发
592
+ * selectionchange,形成「归一化 → 回写 → 再归一化」的死循环。
593
+ * chip 后只剩 ZWSP 锚点(hint 场景的常态)时归一化结果必然等于当前
594
+ * caret,正是这条循环最容易命中的地方。 */
595
+ if (normalized && !isSameCaretPosition(range, normalized)) {
423
596
  var r = document.createRange();
424
597
  r.setStart(normalized.node, normalized.offset);
425
598
  r.collapse(true);
@@ -458,7 +631,12 @@ export var ComposerEditor = /*#__PURE__*/forwardRef(function ComposerEditor(_ref
458
631
  var range = document.createRange();
459
632
  range.selectNodeContents(firstLine);
460
633
  range.collapse(true);
461
- insertSerializedAtRangeRef.current(initial, initialChips, range, el);
634
+ /* 首屏还原不是用户操作:不要在这里 focus。否则挂载时浏览器会把
635
+ * 页面滚动到该编辑器(多个 demo / 多处草稿还原时,最后聚焦的那个
636
+ * 会决定滚动落点),用户看到的就是「一进页面就跳位置」。 */
637
+ insertSerializedAtRangeRef.current(initial, initialChips, range, el, {
638
+ focus: false
639
+ });
462
640
  }
463
641
  } else {
464
642
  var lines = (initial !== null && initial !== void 0 ? initial : '').split('\n');
@@ -528,11 +706,13 @@ export var ComposerEditor = /*#__PURE__*/forwardRef(function ComposerEditor(_ref
528
706
  var line = _step2.value;
529
707
  el.appendChild(createLineRootWithText(line));
530
708
  }
709
+ // 重建后没有任何 chip,hint 一并清掉(外部 value 只按纯文本还原)
531
710
  } catch (err) {
532
711
  _iterator2.e(err);
533
712
  } finally {
534
713
  _iterator2.f();
535
714
  }
715
+ removeHintNode();
536
716
  resetChips();
537
717
  setEmpty(shouldShowPlaceholder(el));
538
718
  if (triggerRef.current) {
@@ -569,7 +749,10 @@ export var ComposerEditor = /*#__PURE__*/forwardRef(function ComposerEditor(_ref
569
749
  isComposingRef.current = true;
570
750
  // 合成期一定会有候选字符出现,立刻收 placeholder(输入事件会进一步刷新)
571
751
  setEmpty(false);
572
- }, []);
752
+ /* 合成期 hint 先摘掉:候选串会插在 chip/hint 之间,留着灰字会和候选文字挤在
753
+ * 一起且语义已经失效(用户开始自己写内容了)。compositionEnd 会按谓词重算。 */
754
+ removeHintNode();
755
+ }, [removeHintNode]);
573
756
  var handleCompositionEnd = useCallback(function () {
574
757
  isComposingRef.current = false;
575
758
  var el = editorRef.current;
@@ -620,10 +803,19 @@ export var ComposerEditor = /*#__PURE__*/forwardRef(function ComposerEditor(_ref
620
803
  /* 触发态优先:activeTrigger 时把导航键先吐给父组件(驱动浮层选择)。
621
804
  * 父返回 true 表示已处理,editor 直接结束本次 keydown,不再走任何
622
805
  * 自身逻辑(避免 Enter 又触发 onPressEnter / 插换行)。 */
623
- if (triggerRef.current && onTriggerKeyDown && (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' && !e.shiftKey || e.key === 'Escape' || e.key === 'Tab')) {
806
+ if (triggerRef.current && onTriggerKeyDown && (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' && !e.shiftKey || e.key === 'Escape' || e.key === 'Tab' && !e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey)) {
624
807
  if (onTriggerKeyDown(e) === true) return;
625
808
  }
626
809
 
810
+ /* Tab 采纳 hint:caret 停在 chip 与派生 hint 之间(内容末尾)时,把提示
811
+ * 文案落成正文。浮层展开时 Tab 已被父组件优先用于选中技能(上面已 return);
812
+ * 走到这里说明浮层没开 / 没有命中,才轮到 hint 采纳。带修饰键的
813
+ * Tab(Shift+Tab 反向焦点、Ctrl/Cmd+Tab 切窗口)不拦,保持浏览器默认。 */
814
+ if (e.key === 'Tab' && !e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey && acceptHintAsText()) {
815
+ e.preventDefault();
816
+ return;
817
+ }
818
+
627
819
  // Backspace:chip 边界——空 text 在 chip 后先退回 chip 前;chip 前无正文再整块删
628
820
  if (e.key === 'Backspace') {
629
821
  var el = editorRef.current;
@@ -651,14 +843,39 @@ export var ComposerEditor = /*#__PURE__*/forwardRef(function ComposerEditor(_ref
651
843
  }
652
844
  }
653
845
 
846
+ /* hint 之后不是合法插入点,而 ArrowRight / ArrowDown / End 原生的下一跳都会先跨
847
+ * 过去、再由 selectionchange 拉回——视觉上闪一帧。这里在按键阶段直接拦下,
848
+ * 把 caret 落到 hint 之前(那时它就是唯一合法的落点)。 */
849
+ if (!e.shiftKey && (e.key === 'ArrowRight' || e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'End')) {
850
+ var _sel = window.getSelection();
851
+ var _el = editorRef.current;
852
+ var _range = _sel && _sel.rangeCount > 0 ? _sel.getRangeAt(0) : null;
853
+ if (_sel && _el && _range && _range.collapsed && _el.contains(_range.startContainer)) {
854
+ /* Ctrl/Cmd+End 会跳到整篇末尾(= hint 之后),与普通 End 同样拦下 */
855
+ var isDocEnd = e.key === 'End' && (e.ctrlKey || e.metaKey);
856
+ var isVertical = e.key === 'ArrowDown' || e.key === 'ArrowUp';
857
+ var crossesHint = e.key === 'ArrowRight' && isCaretBeforeHint(_range, _el) || isVertical && wouldArrowCrossHint(_range, _el, e.key === 'ArrowDown' ? 'down' : 'up') || e.key === 'End' && (isDocEnd || isCaretOnHintLine(_range, _el));
858
+ if (crossesHint) {
859
+ e.preventDefault();
860
+ /* ↓ 若本来就在 hint 所在行,则「无处可去」→ 原地不动;
861
+ * 其余情况统一落到 hint 之前(那里才是唯一合法插入点)。 */
862
+ if (!(e.key === 'ArrowDown' && isCaretOnHintLine(_range, _el))) {
863
+ placeCaretBeforeHint();
864
+ }
865
+ recomputeTrigger();
866
+ return;
867
+ }
868
+ }
869
+ }
870
+
654
871
  // ArrowLeft / ArrowRight:跨越 chip 边界时一次到位(绕开 ZWSP 锚点造成的"卡一格")
655
872
  if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && !e.shiftKey) {
656
- var _sel = window.getSelection();
657
- if (_sel && _sel.rangeCount > 0) {
873
+ var _sel2 = window.getSelection();
874
+ if (_sel2 && _sel2.rangeCount > 0) {
658
875
  var _editorRef$current3;
659
- var _range = _sel.getRangeAt(0);
660
- if (_range.collapsed && (_editorRef$current3 = editorRef.current) !== null && _editorRef$current3 !== void 0 && _editorRef$current3.contains(_range.startContainer)) {
661
- var target = resolveCaretMove(_range, e.key === 'ArrowLeft' ? 'left' : 'right', editorRef.current);
876
+ var _range2 = _sel2.getRangeAt(0);
877
+ if (_range2.collapsed && (_editorRef$current3 = editorRef.current) !== null && _editorRef$current3 !== void 0 && _editorRef$current3.contains(_range2.startContainer)) {
878
+ var target = resolveCaretMove(_range2, e.key === 'ArrowLeft' ? 'left' : 'right', editorRef.current);
662
879
  if (target) {
663
880
  e.preventDefault();
664
881
  var r = document.createRange();
@@ -666,8 +883,8 @@ export var ComposerEditor = /*#__PURE__*/forwardRef(function ComposerEditor(_ref
666
883
  r.collapse(true);
667
884
  // 本次已在 keydown 同步完成 caret 落位:跳过下一次 selectionchange 重算。
668
885
  suppressSelectionChangeOnceRef.current = true;
669
- _sel.removeAllRanges();
670
- _sel.addRange(r);
886
+ _sel2.removeAllRanges();
887
+ _sel2.addRange(r);
671
888
  lastEditorRangeRef.current = r.cloneRange();
672
889
  recomputeTrigger();
673
890
  return;
@@ -685,10 +902,66 @@ export var ComposerEditor = /*#__PURE__*/forwardRef(function ComposerEditor(_ref
685
902
  e.preventDefault();
686
903
  insertLineBreak();
687
904
  }
688
- }, [inputModality, onPressEnter, insertLineBreak, removeChip, onTriggerKeyDown, fireChange, recomputeTrigger]);
905
+ }, [inputModality, onPressEnter, insertLineBreak, placeCaretBeforeHint, removeChip, onTriggerKeyDown, acceptHintAsText, fireChange, recomputeTrigger]);
689
906
  var handlePointerInput = useCallback(function () {
690
907
  if (inputModality !== 'pointer') setInputModality('pointer');
691
908
  }, [inputModality]);
909
+
910
+ /**
911
+ * mouseup 兜底:极少数路径(多击 / 浏览器差异)caret 可能在 mousedown 之后才
912
+ * 落位。这里再校一次,仍然在下一帧绘制之前完成,不会留下闪帧。
913
+ */
914
+ var handleEditorMouseUp = useCallback(function () {
915
+ pullCaretBeforeHint();
916
+ }, [pullCaretBeforeHint]);
917
+
918
+ /**
919
+ * 点击落在 hint 之后(含点在提示文案自己身上)时,浏览器会先把 caret 放到
920
+ * 提示文案末尾,下一帧才被 selectionchange 拉回来 —— 视觉上就是「闪一下」。
921
+ * 这里用点击坐标预判原生落点,命中就 preventDefault 并自己落 caret,
922
+ * 连那一帧都不给。
923
+ *
924
+ * 注意不要按 `e.detail` 放行多击:连点(双击 / 三击)时 `detail` 会变成 2、3,
925
+ * 一旦放行,第二次点击就又会先落到 hint 之后(高频点击复现的原因)。
926
+ * 该区域本来就没有可选文本(hint 是 user-select:none 的派生展示),
927
+ * 双击选词/三击整行在这里没有语义,拦掉是安全的。
928
+ */
929
+ var handleEditorMouseDown = useCallback(function (e) {
930
+ handlePointerInput();
931
+ if (e.button !== 0 || disabled) return;
932
+ // shift+click 是扩选,不劫持
933
+ if (e.shiftKey) return;
934
+ var el = editorRef.current;
935
+ var hint = hintNodeRef.current;
936
+ if (!el || !hint || !hint.isConnected) return;
937
+
938
+ /* 两条命中判据:
939
+ * 1. 坐标直接落在 hint 的视觉行上 —— 原生落点不可控(可能进 hint 内部、
940
+ * 行首、甚至 hint 之后),只有坐标是确定的;
941
+ * 2. 原生落点解析在 hint 之后(点在同一行右侧留白等处)。
942
+ * 命中任一条都接管,别让原生 caret 先画一帧。 */
943
+ var onHint = isPointOnHint(e.clientX, e.clientY, hint);
944
+ if (!onHint) {
945
+ var point = caretRangeFromPoint(e.clientX, e.clientY);
946
+ if (!point) return;
947
+ if (!isPositionAfterHint(point.startContainer, point.startOffset, hint)) {
948
+ return;
949
+ }
950
+ /* chip 之后可能有空行(用户按 Enter 留了一行又把内容删光):点在那
951
+ * 些行上要正常落 caret,不能被拽回 hint 所在行。pointLine 为空 =
952
+ * 点在行留白 / 编辑器 padding 上,这种仍按「末尾」处理。 */
953
+ var pointLine = getCurrentLine(point.startContainer, el);
954
+ if (pointLine && pointLine !== hint.parentElement) return;
955
+ }
956
+ e.preventDefault();
957
+ /* 先 focus 再写 selection:未聚焦时写进去的 range 会被 focus 的默认行为
958
+ * 覆盖(首次点击恰好是这种情况)。 */
959
+ el.focus({
960
+ preventScroll: true
961
+ });
962
+ placeCaretBeforeHint();
963
+ requestScrollCaretIntoView();
964
+ }, [disabled, handlePointerInput, placeCaretBeforeHint, requestScrollCaretIntoView]);
692
965
  var writeClipboardPayload = useCallback(function (e, payload) {
693
966
  if (!payload) return false;
694
967
  e.clipboardData.setData(COMPOSER_CLIPBOARD_MIME, JSON.stringify(payload));
@@ -817,7 +1090,8 @@ export var ComposerEditor = /*#__PURE__*/forwardRef(function ComposerEditor(_ref
817
1090
  onCompositionEnd: handleCompositionEnd,
818
1091
  onKeyDown: handleKeyDown,
819
1092
  onMouseMove: handlePointerInput,
820
- onMouseDown: handlePointerInput,
1093
+ onMouseDown: handleEditorMouseDown,
1094
+ onMouseUp: handleEditorMouseUp,
821
1095
  onPointerMove: handlePointerInput,
822
1096
  onCopy: handleCopy,
823
1097
  onCut: handleCut,
@@ -29,8 +29,16 @@ export interface UseChipManagerReturn {
29
29
  insertChip: (data: ChipData, editor: HTMLElement, preferredRange?: Range | null) => void;
30
30
  /** 在指定 range 处插入 chip(调用方负责 deleteContents)。 */
31
31
  insertChipAtRange: (data: ChipData, range: Range, editor: HTMLElement) => HTMLSpanElement;
32
- /** 将含 marker 的 raw value + chips 插入 range(粘贴用)。 */
33
- insertSerializedAtRange: (value: string, chips: ChipData[], range: Range, editor: HTMLElement) => void;
32
+ /** 将含 marker 的 raw value + chips 插入 range(粘贴 / 初始化还原用)。 */
33
+ insertSerializedAtRange: (value: string, chips: ChipData[], range: Range, editor: HTMLElement,
34
+ /**
35
+ * `focus: false` 时插入后不把焦点交给编辑器。
36
+ * 首屏还原 defaultValue / defaultChips 必须传 false:默认 focus() 会把
37
+ * 页面滚动到编辑器(浏览器「聚焦元素进入视口」),导致进页面就跳位置。
38
+ */
39
+ options?: {
40
+ focus?: boolean;
41
+ }) => void;
34
42
  /** 移除已从 DOM 摘下的 chip 状态(cut / 选区删除后)。 */
35
43
  syncChipsAfterDomMutation: (editor: HTMLElement) => void;
36
44
  /** 按 chip id 移除。 */
@@ -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 } from "../utils";
19
+ import { createLineRoot, ensureLineRoots, forEachMarkerSegment, getCurrentLineRoot, LINE_ATTR_NAME, stripInvisibleChars, ZWSP } from "../utils";
20
20
 
21
21
  /** chip 在 React 状态中的形态:data + 对应的 DOM host。 */
22
22
 
@@ -27,6 +27,24 @@ 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
+ }
30
48
  function createCaretAnchorAtBoundary(parent, offset) {
31
49
  var _parent$childNodes, _parent$childNodes$sa;
32
50
  var len = parent.childNodes.length;
@@ -120,17 +138,27 @@ onAfterMutate) {
120
138
  var newRange = document.createRange();
121
139
  var parent = host.parentNode;
122
140
  if (parent) {
123
- var _nextText$textContent, _nextText$textContent2;
141
+ var _next$textContent, _nextText$textContent, _stripInvisibleChars$;
142
+ /* insertNode 在文本末尾切一刀会留下空文本节点,它等同于「后面没内容」,
143
+ * 不能当成「后面有正文」(否则行尾 chip 的 hint 永远不出现)。
144
+ * 纯 ZWSP 锚点文本(caret 落点)同理——它不是正文。 */
124
145
  var next = host.nextSibling;
125
- var nextText = (next === null || next === void 0 ? void 0 : next.nodeType) === Node.TEXT_NODE ? next : null;
126
- var leading = (_nextText$textContent = nextText === null || nextText === void 0 || (_nextText$textContent2 = nextText.textContent) === null || _nextText$textContent2 === void 0 ? void 0 : _nextText$textContent2[0]) !== null && _nextText$textContent !== void 0 ? _nextText$textContent : '';
127
- if (!nextText || !/\s/.test(leading)) {
128
- var spacer = document.createTextNode(' ');
129
- parent.insertBefore(spacer, next);
130
- newRange.setStart(spacer, 1);
146
+ var nextText = (next === null || next === void 0 ? void 0 : next.nodeType) === Node.TEXT_NODE && ((_next$textContent = next.textContent) !== null && _next$textContent !== void 0 ? _next$textContent : '') !== '' ? next : null;
147
+ var nextRaw = (_nextText$textContent = nextText === null || nextText === void 0 ? void 0 : nextText.textContent) !== null && _nextText$textContent !== void 0 ? _nextText$textContent : '';
148
+ /* 跳过零宽锚点前缀后的第一个字符;空格也算用户已有的内容。 */
149
+ var firstContentChar = (_stripInvisibleChars$ = stripInvisibleChars(nextRaw)[0]) !== null && _stripInvisibleChars$ !== void 0 ? _stripInvisibleChars$ : '';
150
+
151
+ /* 插入 chip 从不往内容里写空格:补出来的空格会进 value / segments,
152
+ * 变成用户没输入过的字符。间隔由 chip 自身的 padding(0 4px)提供。
153
+ * - 后面紧跟正文:caret 直接落到正文起点
154
+ * - 后面是另一个 chip / 行尾 / 只剩派生 hint / 只有 ZWSP 锚点:垫一个
155
+ * 不进 value 的 ZWSP 锚点给 caret 落位 */
156
+ if (nextText && firstContentChar) {
157
+ newRange.setStart(nextText, nextRaw.indexOf(firstContentChar));
131
158
  } else {
132
- var _nextText$textContent3;
133
- newRange.setStart(nextText, Math.min(1, ((_nextText$textContent3 = nextText.textContent) !== null && _nextText$textContent3 !== void 0 ? _nextText$textContent3 : '').length));
159
+ var _anchor$textContent;
160
+ var anchor = ensureCaretAnchorAfter(host);
161
+ newRange.setStart(anchor, ((_anchor$textContent = anchor.textContent) !== null && _anchor$textContent !== void 0 ? _anchor$textContent : '').length);
134
162
  }
135
163
  } else {
136
164
  newRange.setStartAfter(host);
@@ -143,7 +171,7 @@ onAfterMutate) {
143
171
  });
144
172
  onAfterMutate === null || onAfterMutate === void 0 || onAfterMutate();
145
173
  }, [insertChipAtRange, onAfterMutate]);
146
- var insertSerializedAtRange = useCallback(function (value, chipList, range, editor) {
174
+ var insertSerializedAtRange = useCallback(function (value, chipList, range, editor, options) {
147
175
  var chipById = new Map(chipList.map(function (c) {
148
176
  return [c.id, c];
149
177
  }));
@@ -260,9 +288,14 @@ onAfterMutate) {
260
288
  chipsRef.current = next;
261
289
  setChips(next);
262
290
  }
263
- requestAnimationFrame(function () {
264
- return editor.focus();
265
- });
291
+
292
+ /* 默认保持原行为(粘贴 / Tab 采纳都发生在已聚焦的编辑器里,focus 是幂等的)。
293
+ * 首屏还原内容必须显式关掉:mount 期间 focus() 会连带触发页面滚动。 */
294
+ if ((options === null || options === void 0 ? void 0 : options.focus) !== false) {
295
+ requestAnimationFrame(function () {
296
+ return editor.focus();
297
+ });
298
+ }
266
299
  onAfterMutate === null || onAfterMutate === void 0 || onAfterMutate();
267
300
  }, [onAfterMutate]);
268
301
  var syncChipsAfterDomMutation = useCallback(function (editor) {
@@ -76,6 +76,12 @@ export interface ComposerProps {
76
76
  * `segments`:有序内容 IR,业务按数组顺序自定义拼后端。
77
77
  * `meta`:附件 / 联网搜索等非正文数据;为空时不传。
78
78
  *
79
+ * **派生 `hint` 段**:当最后一个 chip 是带 `SkillItem.hint` 的 skill、且其后
80
+ * 一个字符都没有(空格 / 换行都算内容)时,该提示文案会作为
81
+ * `{ type: 'hint', data: { text } }` 出现在 segments 末尾——与输入框里那段
82
+ * 灰字同源同谓词,不会漂移。不需要它的业务直接 filter / `isHintSegment`
83
+ * 判一下即可。
84
+ *
79
85
  * 兼容旧链路(需要单串文本):业务可调 `segmentsToReadableText(segments)`。
80
86
  *
81
87
  * **返回值语义**: