one-design-next 0.0.113 → 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.
- package/dist/_genui-types.d.ts +42 -3
- package/dist/_genui-types.js +19 -3
- package/dist/collapse/primitive.d.ts +1 -1
- package/dist/composer/caret.d.ts +74 -0
- package/dist/composer/caret.js +207 -1
- package/dist/composer/editor.d.ts +4 -3
- package/dist/composer/editor.js +292 -18
- package/dist/composer/hooks/useChipManager.d.ts +10 -2
- package/dist/composer/hooks/useChipManager.js +47 -14
- package/dist/composer/index.d.ts +6 -0
- package/dist/composer/index.js +35 -6
- package/dist/composer/inline-ref.d.ts +6 -1
- package/dist/composer/inline-ref.js +6 -1
- package/dist/composer/segments.d.ts +32 -4
- package/dist/composer/segments.js +86 -5
- package/dist/composer/style/index.css +22 -0
- package/dist/composer/utils.d.ts +21 -1
- package/dist/composer/utils.js +47 -3
- package/dist/icon/types.d.ts +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/user-bubble/render-segments.d.ts +1 -1
- package/dist/user-bubble/render-segments.js +11 -2
- package/dist/user-bubble/style/index.css +8 -0
- package/package.json +1 -1
package/dist/composer/editor.js
CHANGED
|
@@ -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 {
|
|
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(
|
|
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
|
|
422
|
-
|
|
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
|
-
|
|
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
|
|
657
|
-
if (
|
|
873
|
+
var _sel2 = window.getSelection();
|
|
874
|
+
if (_sel2 && _sel2.rangeCount > 0) {
|
|
658
875
|
var _editorRef$current3;
|
|
659
|
-
var
|
|
660
|
-
if (
|
|
661
|
-
var target = resolveCaretMove(
|
|
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
|
-
|
|
670
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
133
|
-
|
|
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
|
-
|
|
264
|
-
|
|
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) {
|
package/dist/composer/index.d.ts
CHANGED
|
@@ -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
|
* **返回值语义**:
|