xiaolongmr commited on
Commit ·
e510d0d
1
Parent(s): 0d06d34
提示词引用参考图:@ 随手可唤出 + 手打「图N」自动补空格
Browse files- @ 候选菜单改成光标前有 @ 就唤出,不再要求前面是空格或行首。
参考项目 infinite-canvas 用的是 (^|\s)@,这里放宽成 @([^\s@]*)$,
「把@」「中间@」都能直接唤出,插入位置仍按 @ 的起点算。
- 手动打完「图N」后自动补一个空格,跟 @ 选中插入的「图N 」一致,
接着写文字不会和标签粘在一起。
· 参考图 ≥10 张时「图1」可能是「图12」的前缀,此时等用户打完数字再补,
避免把「图12」拆成「图1 2」。
· 标签后面已经紧跟汉字(如「图2的」)时在标签与汉字之间插入,不影响语义。
· 越界编号(图9 而只有 2 张图)不补空格,保持纯文本。
· 组字期间(isComposing)不插空格,避免打断 IME 的拼音串。
- 真机验证 artifacts/prompt-refs/at-check.sh:
把@ / 中间@ / @ / 已有文字后面@ 四种都能唤出候选;
用图1 → 用图1 ;用图2的 → 用图2 的;用图9 不动;@城市 只剩图2。
web/src/components/prompt-composer.tsx
CHANGED
|
@@ -429,6 +429,31 @@ function escapeRegExp(value: string) {
|
|
| 429 |
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
| 430 |
}
|
| 431 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 432 |
/**
|
| 433 |
* 退格 / 删除把「图1」当成一个整体删掉。
|
| 434 |
*
|
|
@@ -590,12 +615,13 @@ export const PromptComposerTextarea = forwardRef<HTMLTextAreaElement, PromptComp
|
|
| 590 |
setMentionActiveIndex(next);
|
| 591 |
};
|
| 592 |
|
| 593 |
-
/** 光标
|
| 594 |
const syncMention = (nextValue: string, cursor: number) => {
|
| 595 |
let next: MentionState | null = null;
|
| 596 |
if (referenceList.length > 0) {
|
| 597 |
-
|
| 598 |
-
|
|
|
|
| 599 |
}
|
| 600 |
const current = mentionRef.current;
|
| 601 |
if (!next && !current) return;
|
|
@@ -722,8 +748,23 @@ export const PromptComposerTextarea = forwardRef<HTMLTextAreaElement, PromptComp
|
|
| 722 |
style={style}
|
| 723 |
onChange={(event) => {
|
| 724 |
const next = event.target.value;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 725 |
onValueChange(next);
|
| 726 |
-
syncMention(next,
|
| 727 |
}}
|
| 728 |
onPaste={onPaste}
|
| 729 |
onCompositionStart={() => setIsComposing(true)}
|
|
|
|
| 429 |
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
| 430 |
}
|
| 431 |
|
| 432 |
+
/**
|
| 433 |
+
* 手动打完「图N」后自动补一个空格,跟 @ 选中插入的「图N 」保持一致,
|
| 434 |
+
* 不然接着写文字会粘成一团。
|
| 435 |
+
*
|
| 436 |
+
* 只在标签没有歧义时补:参考图超过 9 张时「图1」可能只是「图12」的前缀,
|
| 437 |
+
* 得等用户把数字打完。1~9 张的时候不存在这个问题,直接补。
|
| 438 |
+
*/
|
| 439 |
+
function autoSpaceAfterReferenceLabel(
|
| 440 |
+
value: string,
|
| 441 |
+
caret: number,
|
| 442 |
+
labels: string[],
|
| 443 |
+
): { value: string; caret: number } | null {
|
| 444 |
+
const before = value.slice(0, caret);
|
| 445 |
+
const after = value.slice(caret);
|
| 446 |
+
if (/^\s/.test(after)) return null; // 后面已经有空白了
|
| 447 |
+
// labels 已按长度倒序,先用长的匹配,避免「图12」被拆成「图1」+「2」。
|
| 448 |
+
const label = labels.find((item) => item && before.endsWith(item));
|
| 449 |
+
if (!label) return null;
|
| 450 |
+
// 还有更长的标签以它开头 → 数字没打完,先别动。
|
| 451 |
+
if (labels.some((other) => other !== label && other.length > label.length && other.startsWith(label))) {
|
| 452 |
+
return null;
|
| 453 |
+
}
|
| 454 |
+
return { value: `${before} ${after}`, caret: caret + 1 };
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
/**
|
| 458 |
* 退格 / 删除把「图1」当成一个整体删掉。
|
| 459 |
*
|
|
|
|
| 615 |
setMentionActiveIndex(next);
|
| 616 |
};
|
| 617 |
|
| 618 |
+
/** 光标前有一段「@xxx」时,才认为在挑参考图。前面不需要空格(行首/词中都能唤出)。 */
|
| 619 |
const syncMention = (nextValue: string, cursor: number) => {
|
| 620 |
let next: MentionState | null = null;
|
| 621 |
if (referenceList.length > 0) {
|
| 622 |
+
// 只认紧贴光标的这一段:@ 后面连续的非空白非 @ 字符当作搜索词。
|
| 623 |
+
const match = /@([^\s@]*)$/.exec(nextValue.slice(0, cursor));
|
| 624 |
+
if (match) next = { start: cursor - match[1].length - 1, query: match[1] };
|
| 625 |
}
|
| 626 |
const current = mentionRef.current;
|
| 627 |
if (!next && !current) return;
|
|
|
|
| 748 |
style={style}
|
| 749 |
onChange={(event) => {
|
| 750 |
const next = event.target.value;
|
| 751 |
+
const caret = event.target.selectionStart;
|
| 752 |
+
// 组字期间不要插空格:IME 中间的拼音串会被打断。
|
| 753 |
+
const spaced = isComposing || referenceLabels.length === 0
|
| 754 |
+
? null
|
| 755 |
+
: autoSpaceAfterReferenceLabel(next, caret, referenceLabels);
|
| 756 |
+
if (spaced) {
|
| 757 |
+
onValueChange(spaced.value);
|
| 758 |
+
syncMention(spaced.value, spaced.caret);
|
| 759 |
+
requestAnimationFrame(() => {
|
| 760 |
+
const element = textareaElementRef.current;
|
| 761 |
+
element?.focus();
|
| 762 |
+
element?.setSelectionRange(spaced.caret, spaced.caret);
|
| 763 |
+
});
|
| 764 |
+
return;
|
| 765 |
+
}
|
| 766 |
onValueChange(next);
|
| 767 |
+
syncMention(next, caret);
|
| 768 |
}}
|
| 769 |
onPaste={onPaste}
|
| 770 |
onCompositionStart={() => setIsComposing(true)}
|