mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-06 01:14:36 +08:00
fix(canvas): resolve text node editing issues and improve textarea rendering
This commit is contained in:
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
+ [新增] 新增图片切图功能。
|
+ [新增] 新增图片切图功能。
|
||||||
+ [新增] 支持webdav同步数据。
|
+ [新增] 支持webdav同步数据。
|
||||||
|
+ [修复] 修复画布文字节点错误问题。
|
||||||
|
|
||||||
## v0.2.4 - 2026-06-04
|
## v0.2.4 - 2026-06-04
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ description: 当前版本已实现但仍需人工验证的变更项
|
|||||||
|
|
||||||
# 待测试
|
# 待测试
|
||||||
|
|
||||||
|
- 修复画布文字节点编辑时双击进入编辑、全选文本出现重影和错位的问题;文字节点内容编辑框改为使用原生 textarea 文本渲染,仍保留 `@` 资源候选插入,需要验证编辑态选区、展示态文字换行和右上角“生图”按钮避让都正常。
|
||||||
- 配置弹窗新增 WebDAV 同步配置,可填写 WebDAV 地址、远程目录、用户名和密码/应用密码,并选择“前端直连”或“Next.js 转发”;远端会按 `canvas/`、`assets/`、`image-workbench/`、`video-workbench/` 四个业务目录分别写入 `manifest.json` 清单和 `files/` 媒体目录,会合并画布项目、我的素材、生图/视频生成记录和引用到的本地媒体文件,四个业务分区会并发同步,并在同步中显示读取远端、检查媒体、上传新增媒体、上传清单等阶段和文件计数进度条;WebDAV 请求会做超时提示,目录已存在但 `MKCOL` 返回 `423 Locked` 时会复查目录存在后继续同步,需要在支持 CORS 的 NAS/WebDAV 服务和不支持 CORS 的 Koofr 转发模式下验证首次上传、第二台设备拉取合并、再次同步和认证失败提示。
|
- 配置弹窗新增 WebDAV 同步配置,可填写 WebDAV 地址、远程目录、用户名和密码/应用密码,并选择“前端直连”或“Next.js 转发”;远端会按 `canvas/`、`assets/`、`image-workbench/`、`video-workbench/` 四个业务目录分别写入 `manifest.json` 清单和 `files/` 媒体目录,会合并画布项目、我的素材、生图/视频生成记录和引用到的本地媒体文件,四个业务分区会并发同步,并在同步中显示读取远端、检查媒体、上传新增媒体、上传清单等阶段和文件计数进度条;WebDAV 请求会做超时提示,目录已存在但 `MKCOL` 返回 `423 Locked` 时会复查目录存在后继续同步,需要在支持 CORS 的 NAS/WebDAV 服务和不支持 CORS 的 Koofr 转发模式下验证首次上传、第二台设备拉取合并、再次同步和认证失败提示。
|
||||||
- 图片节点悬浮工具栏新增“切图”入口;点击后可输入行数和列数,预览切分网格,并在确认后把原图切成对应数量的图片子节点,按原图网格排列到画布右侧且自动与原图连线。
|
- 图片节点悬浮工具栏新增“切图”入口;点击后可输入行数和列数,预览切分网格,并在确认后把原图切成对应数量的图片子节点,按原图网格排列到画布右侧且自动与原图连线。
|
||||||
- 画布生成配置节点的“生成配置/组装提示词”改为在节点下方打开独立输入浮层;浮层支持输入 `@` 从已连接图片、文本、视频、音频中选择引用,引用会以图片缩略图或文本标记展示,图片引用可放大预览;生成时再按当前引用解析为实际素材编号,不再提供或读取输入排序。
|
- 画布生成配置节点的“生成配置/组装提示词”改为在节点下方打开独立输入浮层;浮层支持输入 `@` 从已连接图片、文本、视频、音频中选择引用,引用会以图片缩略图或文本标记展示,图片引用可放大预览;生成时再按当前引用解析为实际素材编号,不再提供或读取输入排序。
|
||||||
|
|||||||
@@ -391,6 +391,9 @@ function UnknownNodeContent({ theme }: Pick<NodeContentRendererProps, "theme">)
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TextContent({ node, theme, isEditingContent, textareaRef, mentionReferences, onContentChange, onStopEditing, onGenerateImage }: NodeContentRendererProps) {
|
function TextContent({ node, theme, isEditingContent, textareaRef, mentionReferences, onContentChange, onStopEditing, onGenerateImage }: NodeContentRendererProps) {
|
||||||
|
const fontSize = node.metadata?.fontSize || 14;
|
||||||
|
const textStyle = { fontSize: `${fontSize}px`, lineHeight: `${Math.round(fontSize * 1.65)}px`, color: theme.node.text, boxSizing: "border-box" } as React.CSSProperties;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col overflow-hidden pt-8">
|
<div className="flex h-full w-full flex-col overflow-hidden pt-8">
|
||||||
<button
|
<button
|
||||||
@@ -412,10 +415,11 @@ function TextContent({ node, theme, isEditingContent, textareaRef, mentionRefere
|
|||||||
{isEditingContent ? (
|
{isEditingContent ? (
|
||||||
<CanvasResourceMentionTextarea
|
<CanvasResourceMentionTextarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
className="thin-scrollbar block h-full w-full resize-none overflow-y-auto whitespace-pre-wrap break-words border-none bg-transparent pl-4 pr-14 pt-0 pb-4 m-0 font-mono leading-relaxed outline-none select-text appearance-none"
|
className="thin-scrollbar block h-full w-full resize-none overflow-y-auto whitespace-pre-wrap break-words border-none bg-transparent pl-4 pr-14 pt-0 pb-4 m-0 font-mono outline-none select-text appearance-none"
|
||||||
style={{ fontSize: `${node.metadata?.fontSize || 14}px`, color: theme.node.text }}
|
style={textStyle}
|
||||||
value={node.metadata?.content || ""}
|
value={node.metadata?.content || ""}
|
||||||
references={mentionReferences}
|
references={mentionReferences}
|
||||||
|
highlightLabels={false}
|
||||||
onChange={(value) => onContentChange(node.id, value)}
|
onChange={(value) => onContentChange(node.id, value)}
|
||||||
onBlur={onStopEditing}
|
onBlur={onStopEditing}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
@@ -427,8 +431,8 @@ function TextContent({ node, theme, isEditingContent, textareaRef, mentionRefere
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="thin-scrollbar block h-full w-full overflow-y-auto whitespace-pre-wrap break-words bg-transparent pl-4 pr-14 pt-0 pb-4 font-mono leading-relaxed"
|
className="thin-scrollbar block h-full w-full overflow-y-auto whitespace-pre-wrap break-words bg-transparent pl-4 pr-14 pt-0 pb-4 font-mono"
|
||||||
style={{ fontSize: `${node.metadata?.fontSize || 14}px`, color: theme.node.text }}
|
style={textStyle}
|
||||||
onWheel={(event) => event.stopPropagation()}
|
onWheel={(event) => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
{node.metadata?.content || <span style={{ color: theme.node.placeholder }}>双击编辑文字</span>}
|
{node.metadata?.content || <span style={{ color: theme.node.placeholder }}>双击编辑文字</span>}
|
||||||
|
|||||||
@@ -20,14 +20,16 @@ type Props = Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "onChange" | "val
|
|||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
onSubmit?: () => void;
|
onSubmit?: () => void;
|
||||||
containerClassName?: string;
|
containerClassName?: string;
|
||||||
|
highlightLabels?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Props>(function CanvasResourceMentionTextarea({ value, references, onChange, onSubmit, onKeyDown, className, containerClassName, style, ...props }, forwardedRef) {
|
export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Props>(function CanvasResourceMentionTextarea({ value, references, onChange, onSubmit, onKeyDown, className, containerClassName, style, highlightLabels = true, ...props }, forwardedRef) {
|
||||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
const overlayRef = useRef<HTMLDivElement | null>(null);
|
const overlayRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [mention, setMention] = useState<MentionState | null>(null);
|
const [mention, setMention] = useState<MentionState | null>(null);
|
||||||
const [activeIndex, setActiveIndex] = useState(0);
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
|
const [hasSelection, setHasSelection] = useState(false);
|
||||||
const candidates = useMemo(() => {
|
const candidates = useMemo(() => {
|
||||||
if (!mention) return [];
|
if (!mention) return [];
|
||||||
const query = mention.query.trim().toLowerCase();
|
const query = mention.query.trim().toLowerCase();
|
||||||
@@ -35,7 +37,7 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
|
|||||||
if (!query) return activeReferences;
|
if (!query) return activeReferences;
|
||||||
return activeReferences.filter((item) => `${item.label} ${item.title} ${item.kind} ${item.text || ""}`.toLowerCase().includes(query));
|
return activeReferences.filter((item) => `${item.label} ${item.title} ${item.kind} ${item.text || ""}`.toLowerCase().includes(query));
|
||||||
}, [mention, references]);
|
}, [mention, references]);
|
||||||
const activeLabels = useMemo(() => Array.from(new Set(references.filter((item) => item.active).map((item) => item.label))).sort((a, b) => b.length - a.length), [references]);
|
const activeLabels = useMemo(() => (highlightLabels ? Array.from(new Set(references.filter((item) => item.active).map((item) => item.label))).sort((a, b) => b.length - a.length) : []), [highlightLabels, references]);
|
||||||
|
|
||||||
const updateValue = (next: string, selectionStart?: number) => {
|
const updateValue = (next: string, selectionStart?: number) => {
|
||||||
onChange(next);
|
onChange(next);
|
||||||
@@ -78,17 +80,23 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
|
|||||||
overlayRef.current.scrollLeft = textareaRef.current.scrollLeft;
|
overlayRef.current.scrollLeft = textareaRef.current.scrollLeft;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateSelectionState = () => {
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
setHasSelection(Boolean(textarea && textarea.selectionStart !== textarea.selectionEnd));
|
||||||
|
};
|
||||||
|
|
||||||
|
const showOverlay = Boolean(activeLabels.length && !hasSelection);
|
||||||
const mergedStyle = {
|
const mergedStyle = {
|
||||||
...(style || {}),
|
...(style || {}),
|
||||||
color: activeLabels.length ? "transparent" : style?.color,
|
color: showOverlay ? "transparent" : style?.color,
|
||||||
caretColor: style?.color || theme.node.text,
|
caretColor: style?.color || theme.node.text,
|
||||||
...(activeLabels.length ? { background: "transparent", backgroundColor: "transparent" } : {}),
|
...(showOverlay ? { background: "transparent", backgroundColor: "transparent" } : {}),
|
||||||
} as CSSProperties;
|
} 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} references={candidates} activeIndex={Math.min(activeIndex, candidates.length - 1)} theme={theme} onSelect={insertReference} /> : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`relative h-full w-full ${containerClassName || ""}`}>
|
<div className={`relative h-full w-full ${containerClassName || ""}`}>
|
||||||
{activeLabels.length ? (
|
{showOverlay ? (
|
||||||
<div ref={overlayRef} className={`${className || ""} pointer-events-none absolute inset-0 overflow-hidden whitespace-pre-wrap break-words`} style={{ ...style, color: theme.node.text }}>
|
<div ref={overlayRef} className={`${className || ""} pointer-events-none absolute inset-0 overflow-hidden whitespace-pre-wrap break-words`} style={{ ...style, color: theme.node.text }}>
|
||||||
<MentionHighlightText value={value || props.placeholder?.toString() || ""} labels={activeLabels} placeholder={!value} />
|
<MentionHighlightText value={value || props.placeholder?.toString() || ""} labels={activeLabels} placeholder={!value} />
|
||||||
</div>
|
</div>
|
||||||
@@ -107,7 +115,22 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
|
|||||||
const next = event.target.value;
|
const next = event.target.value;
|
||||||
onChange(next);
|
onChange(next);
|
||||||
syncMention(next, event.target.selectionStart);
|
syncMention(next, event.target.selectionStart);
|
||||||
requestAnimationFrame(syncOverlayScroll);
|
requestAnimationFrame(() => {
|
||||||
|
syncOverlayScroll();
|
||||||
|
updateSelectionState();
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onSelect={(event) => {
|
||||||
|
updateSelectionState();
|
||||||
|
props.onSelect?.(event);
|
||||||
|
}}
|
||||||
|
onKeyUp={(event) => {
|
||||||
|
updateSelectionState();
|
||||||
|
props.onKeyUp?.(event);
|
||||||
|
}}
|
||||||
|
onPointerUp={(event) => {
|
||||||
|
updateSelectionState();
|
||||||
|
props.onPointerUp?.(event);
|
||||||
}}
|
}}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (mention && candidates.length) {
|
if (mention && candidates.length) {
|
||||||
@@ -144,6 +167,7 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
|
|||||||
props.onScroll?.(event);
|
props.onScroll?.(event);
|
||||||
}}
|
}}
|
||||||
onBlur={(event) => {
|
onBlur={(event) => {
|
||||||
|
setHasSelection(false);
|
||||||
window.setTimeout(closeMention, 120);
|
window.setTimeout(closeMention, 120);
|
||||||
props.onBlur?.(event);
|
props.onBlur?.(event);
|
||||||
}}
|
}}
|
||||||
|
|||||||
Reference in New Issue
Block a user