{
editorRef.current = element;
- renderPromptEditor(element, metadata.prompt || "", mentionSources);
+ renderPromptEditor(element, value, mentionSources);
}}
className="thin-scrollbar min-h-36 w-full overflow-auto whitespace-pre-wrap break-words rounded-2xl border border-zinc-200 bg-white px-3 py-2 text-sm leading-6 text-zinc-950 shadow-xs outline-none transition-colors focus:border-zinc-400"
contentEditable
@@ -384,7 +381,7 @@ function PromptFields({
tabIndex={0}
onInput={handleEditorInput}
onKeyDown={(event) => handleEditorKeyDown(event, handleEditorInput)}
- onKeyUp={(event) => updateMentionState(serializePromptEditor(event.currentTarget))}
+ onKeyUp={(event) => updateMentionState(event.currentTarget)}
onPaste={pastePlainText}
onFocus={() => {
if (!editorRef.current?.textContent?.trim()) {
@@ -395,18 +392,16 @@ function PromptFields({
window.setTimeout(() => setMentionState(null), 120);
}}
/>
- {!(metadata.prompt || "").trim() ? (
+ {!value.trim() ? (
- {mentionSources.length
- ? "输入提示词,键入 @ 引用已连接素材"
- : "输入提示词"}
+ {placeholder}
) : null}
- 已连接素材
+ 已连接素材/文本
{filteredMentionSources.map((source) => (
@@ -426,6 +421,8 @@ function PromptFields({
className="h-full w-full object-cover"
src={source.imageUrl}
/>
+ ) : source.kind === "prompt" ? (
+
) : (
)}
@@ -450,7 +447,7 @@ function PromptFields({
{mentionSources.length ? (
-
可引用的上游素材
+
可引用的上游素材/文本
{mentionSources.map((source) => (
+ ) : source.kind === "prompt" ? (
+
) : (
)}
@@ -475,12 +474,12 @@ function PromptFields({
))}
- 只允许引用已连接到当前提示词节点的图片素材。
+ {mentionHelp}
) : (
- 当前提示词节点还没有上游图片素材,连接图片节点后即可使用 `[图1](canvas-image://...)` 引用图片。
+ {emptyState}
)}
@@ -528,6 +527,9 @@ function createMentionCard(source: PromptMentionSource) {
image.className = "h-full w-full object-cover";
image.src = source.imageUrl;
imageWrap.append(image);
+ } else if (source.kind === "prompt") {
+ imageWrap.textContent = "文";
+ imageWrap.className += " text-[10px] font-semibold text-zinc-600";
}
const copy = document.createElement("span");
@@ -543,20 +545,24 @@ function createMentionCard(source: PromptMentionSource) {
}
function serializePromptEditor(element: HTMLDivElement) {
+ return serializePromptNode(element);
+}
+
+function serializePromptNode(node: Node): string {
let result = "";
- for (const node of element.childNodes) {
- if (node.nodeType === Node.TEXT_NODE) {
- result += node.textContent || "";
+ for (const child of node.childNodes) {
+ if (child.nodeType === Node.TEXT_NODE) {
+ result += child.textContent || "";
continue;
}
- if (node instanceof HTMLElement) {
- if (node.dataset.mentionToken) {
- result += node.dataset.mentionToken;
+ if (child instanceof HTMLElement) {
+ if (child.dataset.mentionToken) {
+ result += child.dataset.mentionToken;
continue;
}
- result += node.innerText || node.textContent || "";
+ result += serializePromptNode(child);
}
}
@@ -565,32 +571,124 @@ function serializePromptEditor(element: HTMLDivElement) {
function replaceActiveMentionQuery(
element: HTMLDivElement,
- query: string,
source: PromptMentionSource,
) {
element.focus();
+ const activeQuery = getActiveMentionQuery(element);
const selection = window.getSelection();
- const textNode = selection?.anchorNode;
- if (!selection || !textNode || textNode.nodeType !== Node.TEXT_NODE) {
+ const textNode = activeQuery?.textNode;
+ if (!selection || !activeQuery || !textNode) {
element.append(createMentionCard(source), document.createTextNode(" "));
placeCaretAtEnd(element);
return;
}
const text = textNode.textContent || "";
- const cursor = selection.anchorOffset;
- const replaceStart = Math.max(0, cursor - query.length - 1);
- const before = text.slice(0, replaceStart);
- const after = text.slice(cursor);
+ const before = text.slice(0, activeQuery.start);
+ const after = text.slice(activeQuery.end);
const parent = textNode.parentNode;
if (!parent) return;
const fragment = document.createDocumentFragment();
if (before) fragment.append(document.createTextNode(before));
- fragment.append(createMentionCard(source), document.createTextNode(" "));
+ const card = createMentionCard(source);
+ const spacer = document.createTextNode(" ");
+ fragment.append(card, spacer);
if (after) fragment.append(document.createTextNode(after));
parent.replaceChild(fragment, textNode);
- placeCaretAtEnd(element);
+ placeCaretAfter(spacer);
+}
+
+function getActiveMentionQuery(element: HTMLDivElement) {
+ const selection = window.getSelection();
+ if (!selection || selection.rangeCount === 0 || !selection.isCollapsed) {
+ return null;
+ }
+ if (!element.contains(selection.anchorNode)) return null;
+
+ let textNode =
+ selection.anchorNode?.nodeType === Node.TEXT_NODE
+ ? selection.anchorNode
+ : null;
+ let offset = selection.anchorOffset;
+
+ if (!textNode && selection.anchorNode instanceof HTMLElement) {
+ const child = selection.anchorNode.childNodes.item(
+ Math.max(0, selection.anchorOffset - 1),
+ );
+ if (child?.nodeType === Node.TEXT_NODE) {
+ textNode = child;
+ offset = child.textContent?.length ?? 0;
+ }
+ }
+
+ if (!textNode || textNode.nodeType !== Node.TEXT_NODE) return null;
+
+ const text = textNode.textContent || "";
+ const beforeCursor = text.slice(0, offset);
+ const match = /(^|\s)@([^\s@]*)$/.exec(beforeCursor);
+ if (!match) return null;
+
+ return {
+ textNode,
+ query: match[2],
+ start: beforeCursor.length - match[2].length - 1,
+ end: offset,
+ };
+}
+
+function normalizePromptEditorAliases(
+ element: HTMLDivElement,
+ mentionSources: PromptMentionSource[],
+) {
+ const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
+ const textNodes: Text[] = [];
+ let current = walker.nextNode();
+ while (current) {
+ if (!current.parentElement?.closest("[data-mention-token]")) {
+ textNodes.push(current as Text);
+ }
+ current = walker.nextNode();
+ }
+
+ for (const textNode of textNodes) {
+ replaceMentionAliasesInTextNode(textNode, mentionSources);
+ }
+}
+
+function replaceMentionAliasesInTextNode(
+ textNode: Text,
+ mentionSources: PromptMentionSource[],
+) {
+ const text = textNode.textContent || "";
+ const sortedSources = [...mentionSources].sort(
+ (left, right) => right.alias.length - left.alias.length,
+ );
+ let cursor = 0;
+ let changed = false;
+
+ const fragment = document.createDocumentFragment();
+ while (cursor < text.length) {
+ const matchedSource = sortedSources.find((source) =>
+ text.startsWith(source.alias, cursor),
+ );
+ if (!matchedSource) {
+ const nextAliasIndex = sortedSources.reduce((nextIndex, source) => {
+ const index = text.indexOf(source.alias, cursor + 1);
+ return index === -1 ? nextIndex : Math.min(nextIndex, index);
+ }, text.length);
+ fragment.append(document.createTextNode(text.slice(cursor, nextAliasIndex)));
+ cursor = nextAliasIndex;
+ continue;
+ }
+
+ fragment.append(createMentionCard(matchedSource));
+ cursor += matchedSource.alias.length;
+ changed = true;
+ }
+
+ if (!changed) return;
+ textNode.replaceWith(fragment);
}
function handleEditorKeyDown(
@@ -632,12 +730,22 @@ function placeCaretAtEnd(element: HTMLElement | null) {
selection?.addRange(range);
}
+function placeCaretAfter(node: Node) {
+ const range = document.createRange();
+ range.setStartAfter(node);
+ range.setEndAfter(node);
+ const selection = window.getSelection();
+ selection?.removeAllRanges();
+ selection?.addRange(range);
+}
function ConfigFields({
node,
+ onGenerateNode,
onPatchNode,
}: {
node: CanvasNode;
+ onGenerateNode: (id: string) => void;
onPatchNode: (id: string, patch: Partial
) => void;
}) {
const metadata = node.metadata as CanvasConfigNodeMetadata;
@@ -711,6 +819,7 @@ function ConfigFields({
@@ -719,10 +828,12 @@ function ConfigFields({
}
function ImageFields({
+ mentionSources,
node,
onPatchNode,
onReplaceNodeImage,
}: {
+ mentionSources: PromptMentionSource[];
node: CanvasNode;
onPatchNode: (id: string, patch: Partial) => void;
onReplaceNodeImage: (nodeId: string, file: File) => void;
@@ -734,11 +845,17 @@ function ImageFields({
return (
<>
-
diff --git a/src/components/canvas/canvas-node.tsx b/src/components/canvas/canvas-node.tsx
index 3ebb567..a5ba924 100644
--- a/src/components/canvas/canvas-node.tsx
+++ b/src/components/canvas/canvas-node.tsx
@@ -19,6 +19,7 @@ import {
Maximize2,
Minimize2,
Minus,
+ Pencil,
Plus,
Sparkles,
Trash2,
@@ -27,6 +28,7 @@ import {
} from "lucide-react";
import { PromptMentionPreview } from "@/components/canvas/prompt-mention-preview";
import type { PromptMentionSource } from "@/lib/canvas/prompt-mentions";
+import { getConfigNodeSize } from "@/lib/canvas/node-factory";
import type {
CanvasConfigNodeMetadata,
CanvasImageNodeMetadata,
@@ -43,10 +45,11 @@ type CanvasNodeProps = {
selected: boolean;
related: boolean;
imageReferenceLabel?: string;
+ promptReferenceLabel?: string;
activeConnecting: boolean;
connectionTarget: boolean;
promptMentionSources?: PromptMentionSource[];
- onSelect: (id: string) => void;
+ onSelect: (id: string, additive?: boolean) => void;
onDragStart: (event: ReactMouseEvent, node: CanvasNodeType) => void;
onConnectStart: (id: string) => void;
onConnectEnd: (id: string) => void;
@@ -62,6 +65,7 @@ export function CanvasNode({
selected,
related,
imageReferenceLabel,
+ promptReferenceLabel,
activeConnecting,
connectionTarget,
promptMentionSources = [],
@@ -80,7 +84,27 @@ export function CanvasNode({
} | null>(null);
const imageMetadata =
node.type === "image" ? (node.metadata as CanvasImageNodeMetadata) : null;
+ const configDimensions = useMemo(
+ () =>
+ node.type === "config"
+ ? getConfigNodeSize(node.title, node.metadata as CanvasConfigNodeMetadata)
+ : null,
+ [node.metadata, node.title, node.type],
+ );
const canShowImageToolbar = node.type === "image" && Boolean(imageMetadata?.imageUrl);
+ const canShowPromptToolbar = node.type === "prompt";
+ const canShowConfigToolbar = node.type === "config";
+
+ useEffect(() => {
+ if (!configDimensions) return;
+ if (
+ node.width === configDimensions.width &&
+ node.height === configDimensions.height
+ ) {
+ return;
+ }
+ onResize(node.id, configDimensions.width, configDimensions.height);
+ }, [configDimensions, node.height, node.id, node.width, onResize]);
return (
<>
@@ -95,13 +119,13 @@ export function CanvasNode({
)}
style={{
transform: `translate(${node.position.x}px, ${node.position.y}px)`,
- width: node.width,
- height: node.height,
+ width: configDimensions?.width ?? node.width,
+ height: configDimensions?.height ?? node.height,
}}
onMouseDown={(event) => onDragStart(event, node)}
onClick={(event) => {
event.stopPropagation();
- onSelect(node.id);
+ onSelect(node.id, event.shiftKey || event.ctrlKey || event.metaKey);
if (connectionTarget) onConnectEnd(node.id);
}}
>
@@ -112,12 +136,14 @@ export function CanvasNode({
onDelete={onDelete}
onGenerate={onGenerate}
onSelect={onSelect}
+ promptReferenceLabel={promptReferenceLabel}
/>
) : null}
setPreviewImage(image)}
+ onGenerate={onGenerate}
onSelect={onSelect}
promptMentionSources={promptMentionSources}
/>
@@ -135,6 +161,23 @@ export function CanvasNode({
onSelect={onSelect}
/>
) : null}
+ {canShowPromptToolbar ? (
+
+ ) : null}
+ {canShowConfigToolbar ? (
+
+ ) : null}
void;
onGenerate: (id: string) => void;
onSelect: (id: string) => void;
+ promptReferenceLabel?: string;
}) {
return (
@@ -193,33 +238,40 @@ function NodeHeader({