52 lines
4.0 KiB
TypeScript
52 lines
4.0 KiB
TypeScript
/**
|
|
* 卡片与纯文本共用的正文编辑交互。展示态用覆盖层接收选择/双击,编辑态让textarea接收原生文本操作。
|
|
* 正文直接来自data.text,每次输入交给actions.edit;本组件只保存是否正在编辑,不另存正文副本。
|
|
*/
|
|
"use client";
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
import type { CanvasNodeData } from "@/lib/canvas-elements";
|
|
import {useScopedNodeEditing} from "./node-editing";
|
|
import { textAppearance } from "./text-appearance";
|
|
import styles from "./canvas.module.css";
|
|
|
|
/** Single clicks select; native double-click timing controls entry into editing. */
|
|
/** plain选择纯文本样式、标签和占位文案;非plain按kind区分脚本/笔记占位。
|
|
* 对外动作经过渲染目标身份检查,具体文本事务与history分组由Provider实现。 */
|
|
export function CanvasTextEditor({ id, data, selected, plain = false }: { id: string; data: CanvasNodeData; selected: boolean; plain?: boolean }) {
|
|
const actions = useScopedNodeEditing(id,data.__headlessTarget);
|
|
const [editing, setEditing] = useState(false);
|
|
const textarea = useRef<HTMLTextAreaElement>(null);
|
|
// 按下位置和移动标记只用于区分拖动与双击;移动超过4px后不因双击事件进入编辑。
|
|
const press = useRef({ x: 0, y: 0, moved: false });
|
|
// 失去选择退出本地编辑态;此effect不直接调用endEditing,正常输入框blur另走结束回调。
|
|
useEffect(() => { if (!selected) setEditing(false); }, [selected]);
|
|
// 进入编辑后聚焦到文本末尾,并禁止浏览器自动滚动,以免画布视口跳动。
|
|
useEffect(() => {
|
|
if (!editing) return;
|
|
const element = textarea.current;
|
|
element?.focus({ preventScroll: true });
|
|
element?.setSelectionRange(element.value.length, element.value.length);
|
|
}, [editing]);
|
|
/** 通知控制器开始文本编辑会话,再开启本地输入态;真正可写还要求节点保持选中。 */
|
|
function begin() { actions.begin(id); setEditing(true); }
|
|
// 同时满足editing与selected才允许输入;展示时textarea只读且不进入Tab顺序。
|
|
const active = editing && selected;
|
|
const label = plain ? "纯文本" : data.title;
|
|
// 输入最多100000字符;onChange立即发动作,blur退出并通知endEditing。Escape只结束编辑,不回滚已输入文本。
|
|
// 非编辑态覆盖层不接管单击逻辑,让外层画布选择/拖动;双击使用浏览器原生时序并按移动标记过滤。
|
|
// 键盘Enter/F2仅在已选中时进入;双击未选中节点会先发select,再开始编辑。
|
|
return <div className={`${styles.textEditor} ${active ? "nodrag" : ""}`}>
|
|
<textarea ref={textarea} style={textAppearance(data)} className={`${plain ? styles.plainText : styles.cardText} nodrag nowheel`}
|
|
aria-label={label} readOnly={!active} tabIndex={active ? 0 : -1}
|
|
placeholder={plain ? "写下文字…" : data.kind === "script" ? "写下你的脚本…" : "留住一个想法…"}
|
|
value={data.text} maxLength={100000} onChange={(event) => actions.edit(id, event.target.value)}
|
|
onBlur={() => { setEditing(false); actions.endEditing?.(id); }} onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); setEditing(false); event.currentTarget.blur(); } }} />
|
|
{!active && <div className={styles.textSelectLayer} role="button" tabIndex={selected ? 0 : -1} aria-label={`单击选中,双击编辑${label}`}
|
|
onPointerDown={(event) => { press.current = { x: event.clientX, y: event.clientY, moved: false }; }}
|
|
onPointerMove={(event) => { if (Math.hypot(event.clientX - press.current.x, event.clientY - press.current.y) > 4) press.current.moved = true; }}
|
|
onDoubleClick={(event) => { if (!press.current.moved) { event.preventDefault(); event.stopPropagation(); if (!selected) actions.select(id); begin(); } }}
|
|
onKeyDown={(event) => { if (selected && (event.key === "Enter" || event.key === "F2")) { event.preventDefault(); event.stopPropagation(); begin(); } }} />}
|
|
</div>;
|
|
}
|