mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-24 15:24:06 +08:00
feat(canvas-node): enhance input area styling and improve cursor visibility in prompt panel
This commit is contained in:
@@ -3,6 +3,8 @@
|
||||
## Unreleased
|
||||
|
||||
+ [新增] 图像设置新增「透明背景」开关,开启后生成无背景的透明图像。
|
||||
+ [修复] 画布节点提示词输入框补上悬停文本光标。
|
||||
+ [优化] 画布节点输入区域移除灰色底色与边框、美化样式。
|
||||
|
||||
## v0.8.1 - 2026-07-16
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ export function CanvasConfigComposer({ value, inputs, onChange, onClose }: Canva
|
||||
</div>
|
||||
<Button size="small" type="text" className="!h-7 !w-7 !min-w-7 !p-0" icon={<X className="size-3.5" />} onClick={onClose} />
|
||||
</div>
|
||||
<div className="relative rounded-xl border" style={{ background: theme.node.fill, borderColor: theme.node.stroke }}>
|
||||
<div className="relative rounded-xl">
|
||||
{!value.trim() ? <div className="pointer-events-none absolute left-3 top-2 text-sm leading-7" style={{ color: theme.node.placeholder }}>输入提示词,按 @ 引用连接的图片或文本</div> : null}
|
||||
<div
|
||||
ref={editorRef}
|
||||
|
||||
@@ -68,8 +68,8 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
references={mentionReferences}
|
||||
onChange={updatePrompt}
|
||||
onSubmit={submit}
|
||||
className="thin-scrollbar h-24 w-full resize-none rounded-xl border px-3 py-2 text-sm leading-5 outline-none"
|
||||
style={{ background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text }}
|
||||
className="thin-scrollbar h-40 w-full cursor-text resize-none rounded-xl px-3 py-2 text-sm leading-5 outline-none"
|
||||
style={{ background: "transparent", color: theme.node.text }}
|
||||
placeholder={promptPlaceholder(mode, hasImageContent, hasTextContent)}
|
||||
/>
|
||||
|
||||
@@ -78,7 +78,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
<CanvasPromptLibrary onSelect={updatePrompt} />
|
||||
{mode === "image" ? (
|
||||
<>
|
||||
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="image" onMissingConfig={() => openConfigDialog(true)} />
|
||||
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="image" onMissingConfig={() => openConfigDialog(true)} className="max-w-[190px]" />
|
||||
<CanvasImageSettingsPopover
|
||||
config={config}
|
||||
placement="topLeft"
|
||||
@@ -90,16 +90,16 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
</>
|
||||
) : mode === "video" ? (
|
||||
<>
|
||||
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="video" onMissingConfig={() => openConfigDialog(true)} />
|
||||
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="video" onMissingConfig={() => openConfigDialog(true)} className="max-w-[190px]" />
|
||||
<CanvasVideoSettingsPopover config={config} buttonClassName="!h-10 !max-w-[170px] !justify-start !rounded-full !px-3" onConfigChange={(key, value) => onConfigChange(node.id, videoConfigPatch(key, value))} />
|
||||
</>
|
||||
) : mode === "audio" ? (
|
||||
<>
|
||||
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="audio" onMissingConfig={() => openConfigDialog(true)} />
|
||||
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="audio" onMissingConfig={() => openConfigDialog(true)} className="max-w-[190px]" />
|
||||
<CanvasAudioSettingsPopover config={config} buttonClassName="!h-10 !max-w-[170px] !justify-start !rounded-full !px-3" onConfigChange={(key, value) => onConfigChange(node.id, audioConfigPatch(key, value))} />
|
||||
</>
|
||||
) : (
|
||||
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="text" onMissingConfig={() => openConfigDialog(true)} />
|
||||
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="text" onMissingConfig={() => openConfigDialog(true)} className="max-w-[190px]" />
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
|
||||
@@ -427,7 +427,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
{!isGroup ? <ConnectionHandleDot side="left" visible={hovered || isSelected || isConnecting} onMouseDown={(event) => onConnectStart(event, data.id, "target")} /> : null}
|
||||
{!isGroup ? <ConnectionHandleDot side="right" visible={(definition?.hasSourceHandle ?? true) && data.type !== CanvasNodeType.Config && (hovered || isSelected || isConnecting)} onMouseDown={(event) => onConnectStart(event, data.id, "source")} /> : null}
|
||||
|
||||
{showPanel && !isGroup && renderPanel ? <div className="absolute left-1/2 top-full z-[70] w-[500px] -translate-x-1/2 pt-4">{renderPanel(data)}</div> : null}
|
||||
{showPanel && !isGroup && renderPanel ? <div className="absolute left-1/2 top-full z-[70] w-[600px] -translate-x-1/2 pt-4">{renderPanel(data)}</div> : null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -89,9 +89,11 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
|
||||
...(style || {}),
|
||||
color: showOverlay ? "transparent" : style?.color,
|
||||
caretColor: style?.color || theme.node.text,
|
||||
...(showOverlay ? { background: "transparent", backgroundColor: "transparent" } : {}),
|
||||
cursor: "text",
|
||||
// showOverlay 时高亮 div 覆盖在 textarea 上,若不把 textarea 提到上层,原生插入光标(caret)会被盖住看不见
|
||||
...(showOverlay ? { position: "relative", zIndex: 1, background: "transparent", backgroundColor: "transparent" } : {}),
|
||||
} as CSSProperties;
|
||||
const menu = mention && candidates.length && textareaRef.current ? <MentionMenu textarea={textareaRef.current} references={candidates} activeIndex={Math.min(activeIndex, candidates.length - 1)} theme={theme} onSelect={insertReference} /> : null;
|
||||
const menu = mention && candidates.length && textareaRef.current ? <MentionMenu textarea={textareaRef.current} caretIndex={mention.start} references={candidates} activeIndex={Math.min(activeIndex, candidates.length - 1)} theme={theme} onSelect={insertReference} /> : null;
|
||||
|
||||
return (
|
||||
<div className={`relative h-full w-full ${containerClassName || ""}`}>
|
||||
@@ -136,6 +138,18 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
|
||||
onKeyDown?.(event);
|
||||
return;
|
||||
}
|
||||
if ((event.key === "Backspace" || event.key === "Delete") && !mention) {
|
||||
const el = textareaRef.current;
|
||||
if (el && el.selectionStart === el.selectionEnd) {
|
||||
const result = deleteAdjacentLabel(value, el.selectionStart, event.key === "Backspace" ? "backward" : "forward", activeLabels);
|
||||
if (result) {
|
||||
event.preventDefault();
|
||||
updateValue(result.value, result.caret);
|
||||
requestAnimationFrame(updateSelectionState);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (mention && candidates.length) {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
@@ -199,16 +213,24 @@ function MentionHighlightText({ value, labels, placeholder }: { value: string; l
|
||||
);
|
||||
}
|
||||
|
||||
function MentionMenu({ textarea, references, activeIndex, theme, onSelect }: { textarea: HTMLTextAreaElement; references: CanvasResourceReference[]; activeIndex: number; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onSelect: (reference: CanvasResourceReference) => void }) {
|
||||
function MentionMenu({ textarea, caretIndex, references, activeIndex, theme, onSelect }: { textarea: HTMLTextAreaElement; caretIndex: number; references: CanvasResourceReference[]; activeIndex: number; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onSelect: (reference: CanvasResourceReference) => void }) {
|
||||
const selectedRef = useRef(false);
|
||||
const rect = textarea.getBoundingClientRect();
|
||||
const boundary = textarea.closest(".ant-modal-content")?.getBoundingClientRect() || { left: 8, top: 8, right: window.innerWidth - 8, bottom: window.innerHeight - 8 };
|
||||
const menuWidth = 256;
|
||||
const maxMenuHeight = 224;
|
||||
const gap = 6;
|
||||
const left = clamp(rect.left, boundary.left + 8, boundary.right - menuWidth - 8);
|
||||
const showAbove = rect.bottom + gap + maxMenuHeight > boundary.bottom && rect.top - gap - maxMenuHeight >= boundary.top;
|
||||
const top = clamp(showAbove ? rect.top - gap - maxMenuHeight : rect.bottom + gap, boundary.top + 8, boundary.bottom - maxMenuHeight - 8);
|
||||
// 菜单锚定到 @ 所在的光标像素位置(而非 textarea 底边),避免输入框较高时菜单离 @ 太远。
|
||||
// 画布可能被缩放,rect 是缩放后坐标,而镜像测量得到的是布局坐标,需按 scale 换算。
|
||||
const scale = textarea.offsetWidth ? rect.width / textarea.offsetWidth : 1;
|
||||
const computed = window.getComputedStyle(textarea);
|
||||
const lineHeight = (parseFloat(computed.lineHeight) || parseFloat(computed.fontSize) * 1.4 || 20) * scale;
|
||||
const caret = getCaretPoint(textarea, caretIndex);
|
||||
const caretLeft = rect.left + (caret.left - textarea.scrollLeft) * scale;
|
||||
const caretTop = rect.top + (caret.top - textarea.scrollTop) * scale;
|
||||
const left = clamp(caretLeft, boundary.left + 8, boundary.right - menuWidth - 8);
|
||||
const showAbove = caretTop + lineHeight + gap + maxMenuHeight > boundary.bottom && caretTop - gap - maxMenuHeight >= boundary.top;
|
||||
const top = clamp(showAbove ? caretTop - gap - maxMenuHeight : caretTop + lineHeight + gap, boundary.top + 8, boundary.bottom - maxMenuHeight - 8);
|
||||
|
||||
const stopCanvasInteraction = (event: PointerEvent | MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
@@ -273,6 +295,58 @@ function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
// 通过镜像 div 复刻 textarea 的排版,测出第 index 个字符处光标相对 textarea 的像素坐标(布局尺度,未含缩放)。
|
||||
const MIRROR_STYLE_PROPS = ["boxSizing", "width", "paddingTop", "paddingRight", "paddingBottom", "paddingLeft", "borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth", "fontStyle", "fontVariant", "fontWeight", "fontStretch", "fontSize", "lineHeight", "fontFamily", "textAlign", "textIndent", "letterSpacing", "wordSpacing", "tabSize", "textTransform"] as const;
|
||||
|
||||
function getCaretPoint(textarea: HTMLTextAreaElement, index: number) {
|
||||
const computed = window.getComputedStyle(textarea);
|
||||
const mirror = document.createElement("div");
|
||||
const style = mirror.style;
|
||||
style.position = "absolute";
|
||||
style.top = "0";
|
||||
style.left = "-9999px";
|
||||
style.visibility = "hidden";
|
||||
style.whiteSpace = "pre-wrap";
|
||||
style.overflowWrap = "break-word";
|
||||
for (const prop of MIRROR_STYLE_PROPS) style[prop] = computed[prop];
|
||||
mirror.textContent = textarea.value.slice(0, index);
|
||||
const marker = document.createElement("span");
|
||||
marker.textContent = textarea.value.slice(index) || ".";
|
||||
mirror.appendChild(marker);
|
||||
document.body.appendChild(mirror);
|
||||
const point = { left: marker.offsetLeft, top: marker.offsetTop };
|
||||
document.body.removeChild(mirror);
|
||||
return point;
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
// 纯 textarea 无法做真正的原子 token,这里在删除时把整个引用 label 当作一个整体一次删掉,
|
||||
// 避免逐字删除把「图片1」删成「图片」。labels 需按长度降序传入以优先匹配更长的 label。
|
||||
function deleteAdjacentLabel(value: string, caret: number, direction: "backward" | "forward", labels: string[]): { value: string; caret: number } | null {
|
||||
if (direction === "backward") {
|
||||
const before = value.slice(0, caret);
|
||||
for (const label of labels) {
|
||||
if (!label) continue;
|
||||
const match = new RegExp(`(^|\\s)(${escapeRegExp(label)})\\s*$`).exec(before);
|
||||
if (match) {
|
||||
const start = match.index + match[1].length; // label 起始位置(保留前面的分隔空格)
|
||||
return { value: value.slice(0, start) + value.slice(caret), caret: start };
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 向后删除:光标前须是行首或空白,避免切进其它文字中间
|
||||
if (caret > 0 && !/\s/.test(value[caret - 1])) return null;
|
||||
const after = value.slice(caret);
|
||||
for (const label of labels) {
|
||||
if (!label) continue;
|
||||
const match = new RegExp(`^(\\s*)(${escapeRegExp(label)})(?=\\s|$)`).exec(after);
|
||||
if (match) {
|
||||
return { value: value.slice(0, caret) + value.slice(caret + match[0].length), caret };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines
|
||||
onCanvasDeselect?.();
|
||||
}
|
||||
panState.current.isPanning = false;
|
||||
document.body.style.cursor = "default";
|
||||
document.body.style.cursor = "";
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
|
||||
Reference in New Issue
Block a user