docs(canvas): explain text tools and geometry gesture lifecycles
This commit is contained in:
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* 从节点连接点发起的分支类型选择菜单。Portal挂在body,使用屏幕坐标定位;选择结果交给choose创建内容与连线。
|
||||
*/
|
||||
"use client";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
@@ -6,10 +9,14 @@ import { FileText, ImagePlus, StickyNote, X } from "lucide-react";
|
||||
import type { XYPosition } from "@xyflow/react";
|
||||
import type { NodeDirection } from "@/lib/canvas";
|
||||
import styles from "./canvas.module.css";
|
||||
/** id/direction标识发起节点和边,point是菜单屏幕位置,at可由调用方保存拟创建位置;菜单不解释at。 */
|
||||
export type BranchRequest = { id: string; direction: NodeDirection; point: XYPosition; at?: XYPosition };
|
||||
/** request为空时不显示,类型固定为文本/图片/脚本;这里只派发选择,不执行上传或文档修改。 */
|
||||
export function BranchMenu({ request, close, choose }: { request: BranchRequest | null; close: () => void; choose: (kind: "text" | "image" | "script") => void }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const reduce = useReducedMotion();
|
||||
// 菜单打开后保存原焦点并聚焦首个类型按钮;点击外部关闭,Escape关闭且恢复原焦点。
|
||||
// 仅在request存在时安装全局监听,变化/卸载时拆除;普通选择或外部关闭不在此强制恢复焦点。
|
||||
useEffect(() => {
|
||||
if (!request) return;
|
||||
const focus = document.activeElement as HTMLElement | null;
|
||||
@@ -19,6 +26,8 @@ export function BranchMenu({ request, close, choose }: { request: BranchRequest
|
||||
window.addEventListener("pointerdown", outside); window.addEventListener("keydown", escape, true);
|
||||
return () => { window.removeEventListener("pointerdown", outside); window.removeEventListener("keydown", escape, true); };
|
||||
}, [request, close]);
|
||||
// 无document时不创建Portal。菜单位置按预设宽高限制在视口边距内;上下键循环所有按钮(含关闭)。
|
||||
// 减少动态效果偏好禁用位移/缩放动画;退出动画仍通过AnimatePresence管理。
|
||||
if (typeof document === "undefined") return null;
|
||||
return createPortal(<AnimatePresence>{request && <motion.div ref={ref} key={request.id + request.direction} initial={{ opacity: 0, scale: reduce ? 1 : 0.96, y: reduce ? 0 : 4 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: reduce ? 1 : 0.96 }} transition={{ duration: reduce ? 0 : 0.14 }} role="dialog" aria-label="选择连接节点类型" className={styles.branchMenu} style={{ left: Math.max(12, Math.min(request.point.x + 12, window.innerWidth - 222)), top: Math.max(12, Math.min(request.point.y + 12, window.innerHeight - 208)) }}
|
||||
onKeyDown={(event) => { event.stopPropagation(); if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); const buttons = Array.from(ref.current?.querySelectorAll<HTMLButtonElement>("button") || []); const index = buttons.indexOf(document.activeElement as HTMLButtonElement); buttons[(index + (event.key === "ArrowDown" ? 1 : -1) + buttons.length) % buttons.length]?.focus(); } }}>
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 画布连线显示与贝塞尔链编辑。根据端点生成路径,叠加箭头、选中轮廓和动画,选中时提供曲线点及控制柄。
|
||||
* 指针拖动通过租约预览后提交;旧接口保留transient patch兼容路径。组件不自行同步服务端或管理撤销栈。
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { useContext, useEffect, useRef, useState, type PointerEvent } from "react";
|
||||
@@ -15,11 +19,15 @@ import { EdgeToolbarControls } from "./edge-toolbar";
|
||||
import {ownsPointerCapture,acquirePointerCapture,releasePointerCapture} from "@/lib/canvas-bindings/pointer-capture";
|
||||
import styles from "./canvas.module.css";
|
||||
|
||||
/** 旧节点用绝对位置替换局部position后,交compatConnector计算旋转/缩放等兼容端点。 */
|
||||
function connector(node:InternalNode<CanvasNode>,handle:string|null|undefined,fallback:ConnectorSide):Connector{return compatConnector({...node,position:node.internals.positionAbsolute},handle,fallback);}
|
||||
/** 新投影已经给出端点坐标,只需按连接边补朝外法向;避免再应用一遍旧节点几何变换。 */
|
||||
function nativeConnector(x:number,y:number,side:ConnectorSide):Connector{
|
||||
const normals={top:{x:0,y:-1},right:{x:1,y:0},bottom:{x:0,y:1},left:{x:-1,y:0}};
|
||||
return {point:{x,y},normal:normals[side]};
|
||||
}
|
||||
/** 生成可复用SVG端部标记;none不绘制,开放箭头不填充,其余按filled使用线色或画布底色。
|
||||
* userSpaceOnUse使大小不随线宽缩放,auto-start-reverse支持起终两端朝向。 */
|
||||
function Marker({ id, value, color }: { id: string; value?: EdgeMarker; color: string }) {
|
||||
const type = value?.type || "none";
|
||||
if (type === "none") return null;
|
||||
@@ -27,6 +35,7 @@ function Marker({ id, value, color }: { id: string; value?: EdgeMarker; color: s
|
||||
<path d={type === "triangle" ? "M1 1L11 6L1 11Z" : type === "open-arrow" ? "M1 1L11 6L1 11" : type === "diamond" ? "M1 6L6 1L11 6L6 11Z" : type === "square" ? "M1 1H11V11H1Z" : "M11 6a5 5 0 1 0-10 0a5 5 0 1 0 10 0"} fill={type === "open-arrow" ? "none" : value?.filled !== false ? color : "#fffefb"} stroke={color} strokeWidth={1.2} />
|
||||
</marker>;
|
||||
}
|
||||
/** 读取两端内部节点、视口与编辑上下文;selectedKnot仅是当前控件选择,不进入连线数据。 */
|
||||
export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
|
||||
const source = useInternalNode<CanvasNode>(props.source), target = useInternalNode<CanvasNode>(props.target);
|
||||
const editing = useContext(EdgeEditing);
|
||||
@@ -34,37 +43,49 @@ export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
|
||||
const { zoom } = useViewport();
|
||||
const reduce = useReducedMotion();
|
||||
const [selectedKnot, setSelectedKnot] = useState<string | null>(null);
|
||||
// 手势记录控制点id/part与捕获目标;事件监听通过AbortController统一拆除,lease可缺省以兼容旧编辑器。
|
||||
const gesture = useRef<{ capture: Element; controller: AbortController; pointerId: number; lease?: EditingLease<NonNullable<CanvasEdgeModel['data']>['path']>; id: string; part: "position" | "in" | "out" | "startControl" | "endControl" } | null>(null);
|
||||
// 原生监听通过ref使用最新move/stop/cancel,避免投影更新后继续用过期path或端点闭包。
|
||||
const pointerHandlers = useRef<{ move(event: PointerEvent<SVGCircleElement>): void; stop(event: PointerEvent<SVGCircleElement>): void; cancel(event: PointerEvent<SVGCircleElement>): void } | null>(null);
|
||||
// 只挂载一次的失焦/卸载监听经ref调用最新cancel,释放未完成手势。
|
||||
const interrupt=useRef(()=>{});interrupt.current=()=>cancel();
|
||||
useEffect(()=>{const blur=()=>interrupt.current();window.addEventListener('blur',blur);return()=>{window.removeEventListener('blur',blur);interrupt.current();};},[]);
|
||||
// 任一端点节点未就绪就不绘制;新版端点使用ReactFlow计算结果,旧版由兼容几何计算。
|
||||
if (!source || !target) return null;
|
||||
const from = (source.data as ProjectionNode['data']).__headlessRecord ? nativeConnector(props.sourceX,props.sourceY,props.sourcePosition) : connector(source, props.sourceHandleId, "right"), to = (target.data as ProjectionNode['data']).__headlessRecord ? nativeConnector(props.targetX,props.targetY,props.targetPosition) : connector(target, props.targetHandleId, "left");
|
||||
const data = props.data || {}, appearance = data.appearance || {}, animation = data.animation || {};
|
||||
// 贝塞尔链生成可绘制path;工具栏锚点取中间一段的t=0.5,属于近似中心而非整条曲线的弧长中点。
|
||||
const segments = curveSegments(from, to, data.path), path = curvePath(segments);
|
||||
const midpoint = cubicPoint(segments[Math.floor(segments.length / 2)], .5);
|
||||
const color = appearance.color || "#aa968b", width = appearance.width || 1.5;
|
||||
// 清洗边ID供SVG marker引用;动画尊重减少动态效果,executing模式只在active集合含本边时播放。
|
||||
const markerId = `edge-${props.id.replace(/[^a-zA-Z0-9_-]/g, "_")}`;
|
||||
const animated = !reduce && (animation.mode === "always" || animation.mode === "executing" && editing.active.has(props.id));
|
||||
const knots = data.path?.knots || [];
|
||||
/** 最多64个中间曲线点;insertCurveKnot按给定点或默认位置拆分曲线,提交后选择新点。 */
|
||||
function add(point?: { x: number; y: number }) {
|
||||
if (knots.length >= 64) return;
|
||||
const next = insertCurveKnot(from, to, data.path, point);
|
||||
editing.patch(props.id, { ...data, path: next.path }); setSelectedKnot(next.knot.id);
|
||||
}
|
||||
/** 仅未处理的主指针左键可开始。已有手势不被重复接管,另一指针到来先取消原手势。
|
||||
* 捕获优先放在稳定的画布surface上,避免投影替换SVG控制点后丢掉后续事件。 */
|
||||
function start(event: PointerEvent<SVGCircleElement>, id: string, part: NonNullable<typeof gesture.current>["part"]) {
|
||||
if(event.button!==0||event.defaultPrevented)return;if(gesture.current){if(gesture.current.pointerId!==event.pointerId)cancel();return;}if(event.isPrimary===false)return;
|
||||
// The SVG control can be replaced by a projection update. The view surface survives it.
|
||||
const capture = event.currentTarget.closest('[data-headless-canvas]') ?? event.currentTarget.closest('.react-flow') ?? event.currentTarget;
|
||||
// begin返回false拒绝;返回对象则保存租约,其余返回值走旧接口,控制点选择仍是组件局部状态。
|
||||
const lease = editing.begin(props.id); if (lease === false) return;
|
||||
const controller = new AbortController();
|
||||
gesture.current = { id, part, capture, controller, pointerId: event.pointerId, lease: lease && typeof lease === 'object' ? lease : undefined }; setSelectedKnot(id);
|
||||
/** 原生监听先按pointerId过滤,再交当前处理器;取消和lostpointercapture仅在手势仍存在时处理。 */
|
||||
const owned = (input: Event) => (input as globalThis.PointerEvent).pointerId === event.pointerId;
|
||||
capture.addEventListener('pointermove', input => { if (owned(input)) pointerHandlers.current?.move(input as unknown as PointerEvent<SVGCircleElement>); }, { signal: controller.signal });
|
||||
capture.addEventListener('pointerup', input => { if (owned(input)) pointerHandlers.current?.stop(input as unknown as PointerEvent<SVGCircleElement>); }, { signal: controller.signal });
|
||||
for (const name of ['pointercancel', 'lostpointercapture']) capture.addEventListener(name, input => { if (owned(input) && gesture.current) pointerHandlers.current?.cancel(input as unknown as PointerEvent<SVGCircleElement>); }, { signal: controller.signal });
|
||||
if(!acquirePointerCapture(capture,event.pointerId)){cancel();return;}event.preventDefault();event.stopPropagation();
|
||||
}
|
||||
/** 屏幕点转画布点,并克隆当前path后更新选中部分;只接受当前捕获指针的事件。 */
|
||||
function move(event: PointerEvent<SVGCircleElement>) {
|
||||
const current = gesture.current;
|
||||
if(!current)return;event.stopPropagation();
|
||||
@@ -72,15 +93,18 @@ export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
|
||||
if(!current)return;
|
||||
const point = flow.screenToFlowPosition({ x: event.clientX, y: event.clientY });
|
||||
const next = structuredClone(data.path || { type: "bezier-chain" as const, knots: [] });
|
||||
// 端部控制柄保存相对端点的向量;中间点位置为绝对画布点,中间控制柄保存相对knot.position的向量。
|
||||
if (current.part === "startControl" || current.part === "endControl") next[current.part] = minus(point, current.part === "startControl" ? from.point : to.point);
|
||||
else {
|
||||
const knot = next.knots.find((knot) => knot.id === current.id);
|
||||
if (!knot) return;
|
||||
if (current.part === "position") knot.position = point;
|
||||
else {
|
||||
// corner两柄独立;smooth保持另一柄长度且方向共线反向;symmetric两柄长度也相同。
|
||||
const vector = minus(point, knot.position), other = current.part === "in" ? "out" : "in";
|
||||
const length = Math.hypot(knot[other].x, knot[other].y), nextLength = Math.hypot(vector.x, vector.y);
|
||||
knot[current.part] = vector;
|
||||
// Shift把该曲线点模式改为symmetric,这个mode会随本次path修改一起提交。
|
||||
if (event.shiftKey) knot.mode = "symmetric";
|
||||
if (knot.mode !== "corner") {
|
||||
const scale = knot.mode === "symmetric" ? 1 : nextLength ? length / nextLength : 0;
|
||||
@@ -88,14 +112,17 @@ export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 优先本次租约的preview;旧版以transient=true派发path修改,结束时另行finish/cancel。
|
||||
if (current.lease) current.lease.preview(next); else editing.patch(props.id, { ...data, path: next }, true);
|
||||
}
|
||||
/** 抬起前再处理一次最后位置,随后清引用、拆监听、提交并释放捕获,避免释放事件重复结束。 */
|
||||
function stop(event: PointerEvent<SVGCircleElement>) {
|
||||
if(!gesture.current)return;event.stopPropagation();
|
||||
if (!ownsPointerCapture(gesture.current,event)) return; const current = gesture.current!; move(event); gesture.current = null; current.controller.abort();
|
||||
if (current.lease) current.lease.commit(); else editing.finish?.();
|
||||
releasePointerCapture(current.capture,current.pointerId);
|
||||
}
|
||||
/** 事件取消需匹配捕获;无参数用于失焦/卸载。先清引用和监听,再取消预览并释放捕获。 */
|
||||
function cancel(event?:{pointerId:number;type?:string;target?:unknown;stopPropagation?():void}) {
|
||||
if(event&&gesture.current)event.stopPropagation?.();
|
||||
if(event&&!ownsPointerCapture(gesture.current,event))return;
|
||||
@@ -105,7 +132,10 @@ export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
|
||||
if (current.lease) current.lease.cancel(); else editing.cancel?.();
|
||||
releasePointerCapture(current.capture,current.pointerId);
|
||||
}
|
||||
// 每次渲染刷新原生事件转发目标;控制点自身也挂React事件,所有处理器均检查手势/捕获归属。
|
||||
pointerHandlers.current = { move, stop, cancel };
|
||||
/** 控制点屏幕半径与描边按zoom反向补偿。点击选择点,指针可拖;键盘仅移动曲线点位置或端部柄。
|
||||
* 方向键一步1单位、Shift为10单位;中间in/out柄的键盘分支直接返回,不修改曲线。 */
|
||||
const control = (point: { x: number; y: number }, id: string, part: NonNullable<typeof gesture.current>["part"], label: string) => <circle key={part + id} cx={point.x} cy={point.y} r={(part === "position" ? 5 : 3.5) / zoom} className={styles.curveControl} fill={part === "position" ? "#fffefb" : "#a47764"} stroke="#a47764" strokeWidth={1 / zoom} role="button" aria-label={label} tabIndex={0}
|
||||
onPointerDown={(event) => start(event, id, part)} onPointerMove={move} onPointerUp={stop} onPointerCancel={cancel} onLostPointerCapture={(event) => { if (gesture.current) cancel(event); }} onClick={(event) => { event.stopPropagation(); setSelectedKnot(id); }}
|
||||
onKeyDown={(event) => {
|
||||
@@ -117,6 +147,9 @@ export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
|
||||
editing.patch(props.id, { ...data, path: next });
|
||||
}
|
||||
}} />;
|
||||
// 双击路径按点击位置插点;选中描边和动画不接收指针,BaseEdge提供较宽的交互区域。
|
||||
// flow用SVG animateMotion沿path运行;dash/pulse使用CSS动画,方向与时长来自连线数据。
|
||||
// 选中时绘制工具栏、所有knot位置和首尾柄,仅被选中knot显示自己的in/out柄。
|
||||
return <g onDoubleClick={(event) => { event.preventDefault(); event.stopPropagation(); add(flow.screenToFlowPosition({ x: event.clientX, y: event.clientY })); }}>
|
||||
<defs><Marker id={markerId + "-head"} value={appearance.head} color={color} /><Marker id={markerId + "-tail"} value={appearance.tail} color={color} /></defs>
|
||||
{props.selected && <path d={path} fill="none" stroke="#a47764" strokeOpacity={.18} strokeWidth={width + 7 / zoom} pointerEvents="none" />}
|
||||
@@ -124,6 +157,7 @@ export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
|
||||
{animated && (animation.effect === "flow" || !animation.effect ? <g pointerEvents="none"><circle r={Math.max(3, width * 1.5)} fill={animation.color || "#fff"}><animateMotion path={path} dur={`${animation.durationMs || 1600}ms`} repeatCount="indefinite" keyPoints={animation.direction === "reverse" ? "1;0" : "0;1"} keyTimes="0;1" calcMode="linear" /></circle></g> : <path d={path} fill="none" pointerEvents="none" stroke={animation.color || "#fff"} strokeWidth={width + 1} strokeDasharray={animation.effect === "dash" ? "6 14" : undefined} className={animation.effect === "dash" ? styles.edgeDashAnimation : styles.edgePulseAnimation} style={{ animationDuration: `${animation.durationMs || 1600}ms`, animationDirection: animation.direction === "reverse" ? "reverse" : "normal" }} />)}
|
||||
{props.selected && <>
|
||||
<EdgeToolbarControls id={props.id} x={midpoint.x} y={midpoint.y - 24 / zoom} data={data} change={(data) => editing.patch(props.id, data)} count={knots.length} knotMode={(knots.find((knot) => knot.id === selectedKnot) || knots.at(-1))?.mode} changeKnotMode={(mode) => {
|
||||
// 工具栏模式应用于选中knot,找不到时退到最后一点;平滑/镜像优先以out柄为方向基准重算in柄。
|
||||
const next = structuredClone(data.path!); const knot = next.knots.find((knot) => knot.id === selectedKnot) || next.knots.at(-1); if (!knot) return;
|
||||
knot.mode = mode; if (mode !== "corner") {
|
||||
if (!Math.hypot(knot.out.x, knot.out.y)) knot.out = { x: -knot.in.x, y: -knot.in.y };
|
||||
@@ -141,6 +175,8 @@ export function CanvasEdge(props: EdgeProps<CanvasEdgeModel>) {
|
||||
</>}
|
||||
</g>;
|
||||
}
|
||||
/** 连接拖动中的临时预览线。两端命中Handle时使用兼容连接点,尚未命中时以指针位置作终点并反转起点法向。
|
||||
* 只绘制单段connectorBezier,不创建或保存实际连线;调用方样式可覆盖默认线色/线宽。 */
|
||||
export function CanvasConnectionLine(props: ConnectionLineComponentProps<CanvasNode>) {
|
||||
const from = connector(props.fromNode, props.fromHandle.id, "right");
|
||||
const to = props.toNode && props.toHandle ? connector(props.toNode, props.toHandle.id, "left") : { point: { x: props.toX, y: props.toY }, normal: { x: -from.normal.x, y: -from.normal.y } };
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 卡片与纯文本共用的正文编辑交互。展示态用覆盖层接收选择/双击,编辑态让textarea接收原生文本操作。
|
||||
* 正文直接来自data.text,每次输入交给actions.edit;本组件只保存是否正在编辑,不另存正文副本。
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
@@ -7,21 +11,31 @@ 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}
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
/**
|
||||
* 连线的受控样式与曲线工具栏。接收当前data及回调,编辑外观、动画、传播开关和曲线点,不直接持有文档状态。
|
||||
*/
|
||||
"use client";
|
||||
import { EdgeToolbar } from "@xyflow/react";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import type { EdgeData, EdgeMarker, MarkerShape } from "@/lib/canvas-v2";
|
||||
import styles from "./canvas.module.css";
|
||||
|
||||
/** x/y是工具栏锚点;count与knotMode来自外层曲线编辑器。add/remove/changeKnotMode委托外层选择具体曲线点。
|
||||
* EdgeToolbar只负责屏幕定位,外层仅在选中连线时挂载本组件。 */
|
||||
export function EdgeToolbarControls({ id, x, y, data, change, add, remove, count, knotMode, changeKnotMode }: { id: string; x: number; y: number; data: EdgeData; change: (data: EdgeData) => void; add: () => void; remove: () => void; count: number; knotMode?: string; changeKnotMode: (mode: "corner" | "smooth" | "symmetric") => void }) {
|
||||
const a = data.appearance || {}, animation = data.animation || {};
|
||||
/** 合并appearance子对象并保留其它连线数据;箭头/箭尾各自更新时仍需在事件内保留其嵌套字段。 */
|
||||
const appearance = (patch: NonNullable<EdgeData["appearance"]>) => change({ ...data, appearance: { ...a, ...patch } });
|
||||
/** 动画属性单独合并,调整一个设置不覆盖已有方向、效果等其它字段。 */
|
||||
const animate = (patch: NonNullable<EdgeData["animation"]>) => change({ ...data, animation: { ...animation, ...patch } });
|
||||
/** 箭头与箭尾共用的六种形状;实际SVG绘制由CanvasEdge中的Marker实现。 */
|
||||
const markers: [MarkerShape, string][] = [["none", "无"], ["open-arrow", "开放箭头"], ["triangle", "三角箭头"], ["circle", "圆形"], ["diamond", "菱形"], ["square", "方形"]];
|
||||
// 宽度校验0.5—20,端部尺寸4—32,动画时长250—30000ms;透明度范围由range控件给出。
|
||||
// head/tail分别合并type/size/filled;传播开关将execution替换为当前enabled对象。
|
||||
// 无曲线点时禁用模式和删除,达到64点时禁用添加;外层仍负责相应操作的实际合法性。
|
||||
// 所有指针/键盘冒泡止于工具栏,避免颜色或输入操作同时触发画布编辑。
|
||||
return <EdgeToolbar alignY="bottom" edgeId={id} x={x} y={y} isVisible style={{ zIndex: 115, pointerEvents: "auto" }}><div role="toolbar" aria-label="连线编辑" className={`${styles.edgeToolbar} nodrag nopan nowheel`}
|
||||
onPointerDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()}>
|
||||
<label>颜色<input aria-label="连线颜色" type="color" value={a.color || "#aa968b"} onChange={(event) => appearance({ color: event.target.value })} /></label>
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
/**
|
||||
* 图标目录数据与搜索入口。JSON保存名称、中文标签、关键词及分类等检索元数据;SVG路径另由图标加载器取得。
|
||||
*/
|
||||
import records from "./icon-catalog-data.json" with { type: "json" };
|
||||
|
||||
/** 分类展示顺序;common/all/other是搜索时有特殊含义的选项,其余按categories成员匹配。 */
|
||||
export const iconCategories = [
|
||||
["common", "常用"], ["all", "全部"], ["arrows", "箭头与导航"], ["people", "人物与表情"], ["office", "文件与办公"], ["media", "媒体与设计"], ["tech", "科技与设备"], ["business", "图表与商业"], ["nature", "自然与天气"], ["travel", "出行与地点"], ["shapes", "形状与符号"], ["other", "其他"],
|
||||
] as const;
|
||||
/** 导出静态记录数组供选择器统计与筛选,不在运行时下载整套图标路径。 */
|
||||
export const iconCatalog = records;
|
||||
/** 按图标name建Map供调用方直接定位记录;与目录共享记录对象,不复制或冻结内容。 */
|
||||
export const iconsById = new Map(iconCatalog.map((icon) => [icon.name, icon]));
|
||||
/** 来源过滤支持全部、Lucide和Tabler;分类与来源是独立条件。 */
|
||||
export type IconSource = "all" | "lucide" | "tabler";
|
||||
/** 去掉首尾空白、连字符、下划线及空白并转小写,再在名称+标签+关键词中做子串匹配。
|
||||
* 筛选同时满足来源和分类;common看common标记,other指没有分类,all跳过对应限制。
|
||||
* 保留目录原顺序,不计算相关性分数,也不限制结果数量;虚拟列表由调用方实现。 */
|
||||
export function searchIcons(query: string, category = "all", source: IconSource = "all") {
|
||||
const needle = query.trim().toLowerCase().replace(/[-_\s]/g, "");
|
||||
return iconCatalog.filter((icon) => (source === "all" || icon.source === source)
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 图标选择面板。按来源、分类与文本查询筛选目录,使用固定五列虚拟列表只挂载视窗附近的图标按钮。
|
||||
* 仅保存搜索/滚动/焦点状态;choose把名称和标签交给画布创建节点,实际SVG按需加载。
|
||||
*/
|
||||
"use client";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
@@ -7,7 +11,9 @@ import { CanvasIcon } from "./canvas-icon";
|
||||
import { iconCatalog, iconCategories, searchIcons, type IconSource } from "./icon-catalog";
|
||||
import styles from "./canvas.module.css";
|
||||
|
||||
/** 固定网格参数:每行五项、每行48px;占位总高与绝对定位必须使用同一组参数。 */
|
||||
const columns = 5, rowHeight = 48;
|
||||
/** 通过Portal挂载到body,调用方在浏览器中打开;Escape或关闭按钮只触发close。 */
|
||||
export function IconPicker({ close, choose }: { close: () => void; choose: (name: string, label: string) => void }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [category, setCategory] = useState("common");
|
||||
@@ -18,7 +24,9 @@ export function IconPicker({ close, choose }: { close: () => void; choose: (name
|
||||
const search = useRef<HTMLInputElement>(null);
|
||||
const grid = useRef<HTMLDivElement>(null);
|
||||
const reduce = useReducedMotion();
|
||||
// 有非空查询时搜索当前来源的全部分类;无查询才使用分类按钮值,useMemo避免无关渲染重复过滤。
|
||||
const matches = useMemo(() => searchIcons(query, query.trim() ? "all" : category, source), [query, category, source]);
|
||||
// 挂载时聚焦搜索框并观察列表实际高度;卸载断开观察器,尝试把焦点还给打开前的元素。
|
||||
useEffect(() => {
|
||||
const previous = document.activeElement as HTMLElement | null;
|
||||
search.current?.focus();
|
||||
@@ -26,11 +34,15 @@ export function IconPicker({ close, choose }: { close: () => void; choose: (name
|
||||
if (grid.current) observer.observe(grid.current);
|
||||
return () => { observer.disconnect(); previous?.focus(); };
|
||||
}, []);
|
||||
// 任一筛选条件变化都回到列表顶部与首项焦点索引,避免沿用上次结果中的越界位置。
|
||||
useEffect(() => { if (grid.current) grid.current.scrollTop = 0; setScrollTop(0); setFocused(0); }, [query, category, source]);
|
||||
// 根据总行数撑起滚动区域,上下各多挂载一行缓冲;slice只取可见范围的目录项。
|
||||
const rows = Math.ceil(matches.length / columns);
|
||||
const firstRow = Math.max(0, Math.floor(scrollTop / rowHeight) - 1);
|
||||
const lastRow = Math.min(rows, Math.ceil((scrollTop + height) / rowHeight) + 1);
|
||||
const visible = matches.slice(firstRow * columns, lastRow * columns);
|
||||
/** 先钳制目标索引,再按行把目标滚入视口;等下一帧虚拟按钮挂载后调用focus。
|
||||
* 空结果没有可聚焦按钮,最终可选链自然不执行focus;不凭空创建占位项目。 */
|
||||
function focusIcon(index: number) {
|
||||
const next = Math.max(0, Math.min(matches.length - 1, index));
|
||||
setFocused(next);
|
||||
@@ -42,6 +54,9 @@ export function IconPicker({ close, choose }: { close: () => void; choose: (name
|
||||
}
|
||||
requestAnimationFrame(() => grid.current?.querySelector<HTMLButtonElement>(`[data-index="${next}"]`)?.focus());
|
||||
}
|
||||
// 分类点击同时清空搜索,来源选择保留查询;网格按钮按全局结果索引绝对定位,不按切片局部索引。
|
||||
// 左右移动一项、上下移动一行,Home/End跳至结果首尾;只有当前focused项进入Tab顺序。
|
||||
// 每个按钮由CanvasIcon加载路径,页脚结果数来自筛选数组;choose后是否关闭由调用方决定。
|
||||
return createPortal(<motion.aside initial={{ opacity: 0, x: reduce ? 0 : -12 }} animate={{ opacity: 1, x: 0 }} transition={{ duration: reduce ? 0 : 0.16 }} className={styles.iconPicker} role="dialog" aria-label="添加图标" onKeyDown={(event) => { event.stopPropagation(); if (event.key === "Escape") close(); }}>
|
||||
<header><strong>图标</strong><button aria-label="关闭图标面板" onClick={close}><X size={16} /></button></header>
|
||||
<label className={styles.iconSource}>图标库<select aria-label="图标库" value={source} onChange={(event) => setSource(event.target.value as IconSource)}><option value="all">全部图标库</option><option value="lucide">Lucide · 2313</option><option value="tabler">Tabler · 24 个试用</option></select></label>
|
||||
@@ -49,6 +64,7 @@ export function IconPicker({ close, choose }: { close: () => void; choose: (name
|
||||
<div className={styles.iconCategories} aria-label="图标分类">{iconCategories.map(([id, label]) => <button key={id} aria-pressed={!query.trim() && category === id} onClick={() => { setCategory(id); setQuery(""); }}>{label}</button>)}</div>
|
||||
<div ref={grid} className={styles.virtualIconGrid} aria-label="图标列表" onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}>
|
||||
<div style={{ height: rows * rowHeight, position: "relative" }}>{visible.map(({ name, label, source }, offset) => {
|
||||
// 加回首行偏移取得完整结果索引,保证滚动后的定位、Tab状态与键盘导航仍对应同一条记录。
|
||||
const index = firstRow * columns + offset;
|
||||
return <button key={name} type="button" data-index={index} tabIndex={focused === index ? 0 : -1} title={`${label} · ${source === "tabler" ? "Tabler" : "Lucide"}`} aria-label={`添加${label}图标`} style={{ position: "absolute", width: "20%", height: rowHeight, left: `${index % columns * 20}%`, top: Math.floor(index / columns) * rowHeight }} onFocus={() => setFocused(index)} onClick={() => choose(name, label)}
|
||||
onKeyDown={(event) => { const delta = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: columns, ArrowUp: -columns, Home: -index, End: matches.length - 1 - index }[event.key]; if (delta !== undefined) { event.preventDefault(); focusIcon(index + delta); } }}><CanvasIcon name={name} size={24} /></button>;
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* 选中节点的样式浮层。把颜色、文字排版、背景与旋转/拉伸模式转换成作用域编辑动作,不在此保存样式副本。
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
@@ -11,17 +14,25 @@ import {useScopedNodeEditing} from "./node-editing";
|
||||
import {canvasKeyContext} from "@/lib/canvas-bindings/input-router";
|
||||
import styles from "./canvas.module.css";
|
||||
|
||||
/** NodeToolbar负责定位,AnimatePresence负责选中/取消选中时的进出动画,减少动态效果时关闭位移与时长。
|
||||
* 浮层注册到overlay索引,保证Portal中的输入也能归属于正确视图与元素。 */
|
||||
export function NodeStyleToolbar({ id, data, selected }: NodeProps<CanvasNode>) {
|
||||
const overlay=useCanvasOverlay(id,"node-style");
|
||||
const reduce = useReducedMotion();
|
||||
const editing = useScopedNodeEditing(id,data.__headlessTarget);
|
||||
const style = data.textStyle || {};
|
||||
// 只有text/script/plain-text展示文字排版;图片不展示前景/背景色,图标可调颜色但不展示拉伸开关。
|
||||
const text = data.kind === "text" || data.kind === "script" || data.kind === "plain-text";
|
||||
/** 统一样式动作入口;字号作为独立可选参数,避免把它混进textStyle的字段空间。 */
|
||||
const patch = (value: TextStyle, fontSize?: number) => editing.style(id, value, fontSize);
|
||||
// 颜色输入用现有值或默认色;字号仅提交8—160范围,透明背景在取色器中显示白色但不自动写回模型。
|
||||
// 加粗/斜体/下划线按当前值取反,对齐和行高直接提交所选值;清除背景明确写transparent。
|
||||
// 常规事件停止冒泡;Escape只在非组合输入、非文本编辑且未被处理时关闭当前节点的变换模式。
|
||||
return <AnimatePresence>{selected && <NodeToolbar style={{ zIndex: 100, pointerEvents: "auto" }} isVisible={true} position={Position.Top} offset={34}>
|
||||
<motion.div ref={overlay} initial={{ opacity: 0, y: reduce ? 0 : 5 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: reduce ? 0 : 5 }} transition={{ duration: reduce ? 0 : 0.14 }} className={`${styles.styleToolbar} nodrag nopan nowheel`} data-node-style={id} role="toolbar" aria-label="元素样式"
|
||||
onPointerDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => {
|
||||
// 其它按键留给当前控件并阻断全局快捷键;Escape让输入上下文先决定是否可用于退出模式。
|
||||
if(event.key!=="Escape"){event.stopPropagation();return;}
|
||||
if(event.defaultPrevented||event.nativeEvent.isComposing||canvasKeyContext(event.nativeEvent).textEditing)return;
|
||||
// The registered overlay owns its mode dismissal; global shortcuts stay out.
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 节点共用的几何交互容器:绘制旋转后的内容、选择样式、拉伸手柄及模式提示,协调一次手势的预览/提交/取消。
|
||||
* 拖动节点位置仍由外层ReactFlow处理;此处主要处理旋转与尺寸变化,通过NodeEditing而非直接写文档。
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, type PointerEvent, type ReactNode } from "react";
|
||||
@@ -17,8 +21,12 @@ import {useNodeInternalsUpdate} from "./node-internals";
|
||||
|
||||
export {NodeEditing} from "./node-editing";
|
||||
import {useScopedNodeEditing} from "./node-editing";
|
||||
/** 保存手势起始节点快照与画布坐标,防止每帧以已预览结果为基准累积误差;pointerId/capture限定事件所有权。
|
||||
* 新版lease绑定具体交互,旧版则使用上下文的begin/transform/finish/cancel适配。 */
|
||||
type Gesture = { lease?: EditingLease<NodeTransform>; node: CanvasNode; start: XYPosition; direction: ResizeDirection | "rotate"; capture: HTMLElement; pointerId: number };
|
||||
|
||||
/** children决定内容显示,hideStyleToolbar允许分区使用专属设置栏;className补充类型外观。
|
||||
* 能否缩放/旋转由elementCapabilities和当前选中/模式共同决定,不在每个节点组件重复实现。 */
|
||||
export function TransformableNode(props: NodeProps<CanvasNode> & { children: ReactNode; className?: string; hideStyleToolbar?: boolean }) {
|
||||
const { id, data, selected, width, height, children, className = "" } = props;
|
||||
const editing = useScopedNodeEditing(id,data.__headlessTarget);
|
||||
@@ -27,6 +35,7 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
|
||||
const { zoom } = useViewport();
|
||||
const frame = useRef<HTMLDivElement>(null);
|
||||
const gesture = useRef<Gesture | null>(null);
|
||||
// 用ref让只挂载一次的window blur监听调用最新cancelGesture;失焦和卸载均取消尚未结束的交互。
|
||||
const interrupt=useRef(()=>{});interrupt.current=()=>cancelGesture();
|
||||
useEffect(()=>{const blur=()=>interrupt.current();window.addEventListener('blur',blur);return()=>{window.removeEventListener('blur',blur);interrupt.current();};},[]);
|
||||
const rotation = data.rotation || 0;
|
||||
@@ -34,12 +43,16 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
|
||||
const capabilities = elementCapabilities(data.kind);
|
||||
const resizing = capabilities.resizable && editing.resizing === id && selected;
|
||||
const rotating = capabilities.rotatable && editing.rotating === id && selected;
|
||||
// 几何、旋转或选中变化后请求批量更新ReactFlow连接点内部数据,避免端点仍留在旧位置。
|
||||
useEffect(() => { update(); }, [id, rotation, width, height, selected, update]);
|
||||
// 仅在变换模式启用时监听外部点击与Escape;退出/切换模式拆除监听。
|
||||
useEffect(() => {
|
||||
if (!rotating && !resizing) return;
|
||||
/** 点击容器与对应模式按钮之外退出模式;事件用捕获阶段观察,不要求控件把事件冒泡出来。 */
|
||||
const outside = (event: globalThis.PointerEvent) => {
|
||||
if (!frame.current?.contains(event.target as Node) && !(event.target as Element).closest?.('[data-rotate-toggle="' + id + '"], [data-resize-toggle="' + id + '"]')) { editing.rotate(null); editing.resize(null); }
|
||||
};
|
||||
/** 按统一输入路由判断Escape是否归工具,尊重文本/组合输入与已处理事件;认领后取消手势并退出模式。 */
|
||||
const escape = (event: KeyboardEvent) => {
|
||||
if(event.key!=="Escape")return;
|
||||
const claim=routeCanvasKey({key:event.key,composing:event.isComposing,defaultPrevented:event.defaultPrevented,...canvasKeyContext(event)},{tool:()=>{cancelGesture();editing.rotate(null);editing.resize(null);return true;}});
|
||||
@@ -50,6 +63,8 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
|
||||
return () => { window.removeEventListener("pointerdown", outside, true); window.removeEventListener("keydown", escape); };
|
||||
}, [rotating, resizing, id, editing]);
|
||||
|
||||
/** 主指针左键启动;旋转跳过文本输入目标,重复指针不得接管已有手势,另一指针到来先取消。
|
||||
* 取得起始节点和租约后捕获指针;捕获失败按取消路径恢复,成功才阻止默认行为与冒泡。 */
|
||||
function start(event: PointerEvent<HTMLElement>, direction: Gesture["direction"]) {
|
||||
if (event.button !== 0||event.defaultPrevented) return;
|
||||
if(direction==='rotate'&&event.target instanceof Element&&event.target.closest('input,textarea,[contenteditable]:not([contenteditable="false"]),[role="textbox"]'))return;
|
||||
@@ -57,6 +72,7 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
|
||||
if(event.isPrimary===false)return;
|
||||
const node = flow.getNode(id);
|
||||
if (!node) return;
|
||||
// false表示动作层拒绝启动;缺少专用beginTransform时回退旧版begin,随后按需选中节点。
|
||||
const lease = editing.beginTransform?.(id); if (lease === false) return;
|
||||
if (!editing.beginTransform) editing.begin(id);
|
||||
if (!node.selected) editing.select(id);
|
||||
@@ -64,6 +80,8 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
|
||||
if(!acquirePointerCapture(event.currentTarget,event.pointerId)){cancelGesture();return;}
|
||||
event.preventDefault();event.stopPropagation();
|
||||
}
|
||||
/** 只接受持有捕获的指针,把屏幕坐标变为画布坐标,再基于起始快照算旋转或尺寸变化。
|
||||
* 旋转Shift吸附由geometry函数处理;八向拉伸模式把Shift传入比例约束,普通角落拖动沿用默认行为。 */
|
||||
function move(event: PointerEvent<HTMLElement>) {
|
||||
if(!gesture.current)return;event.stopPropagation();
|
||||
if (!ownsPointerCapture(gesture.current,event)) return;
|
||||
@@ -72,8 +90,11 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
|
||||
const change = direction === "rotate"
|
||||
? { rotation: rotationFromPointer(node, start, current, event.shiftKey) }
|
||||
: resizeFromPointer(node, direction, { x: current.x - start.x, y: current.y - start.y }, resizing ? event.shiftKey : undefined);
|
||||
// 新版只预览当前lease,旧适配发transform;历史单元何时发布由Provider控制。
|
||||
if (gesture.current!.lease) gesture.current!.lease!.preview(change); else editing.transform(id, change);
|
||||
}
|
||||
/** 无事件参数可由失焦/卸载强制取消;有事件时先校验捕获归属,避免其它指针干扰。
|
||||
* 优先lease.cancel,其次cancelTransform;最旧接口缺少取消钩子时把起始几何写回,最后释放捕获。 */
|
||||
function cancelGesture(event?:{pointerId:number;type?:string;target?:unknown;stopPropagation?():void}) {
|
||||
if(event&&gesture.current)event.stopPropagation?.();
|
||||
if(event&&!ownsPointerCapture(gesture.current,event))return;
|
||||
@@ -82,12 +103,16 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
|
||||
if (current.lease) current.lease.cancel(); else if (editing.cancelTransform) editing.cancelTransform(); else editing.transform(current.node.id, current.direction === "rotate" ? { rotation: current.node.data.rotation || 0 } : { position: current.node.position, width: current.node.width, height: current.node.height });
|
||||
releasePointerCapture(current.capture,current.pointerId);
|
||||
}
|
||||
/** 抬起时先采用最后一个指针位置,再清手势引用并提交;随后释放捕获不会误取消已结束交互。 */
|
||||
function finish(event: PointerEvent<HTMLElement>) {
|
||||
if(!gesture.current)return;event.stopPropagation();
|
||||
if (!ownsPointerCapture(gesture.current,event)) return; const current = gesture.current!; move(event); gesture.current = null;
|
||||
if (current.lease) current.lease.commit(); else editing.finishTransform?.();
|
||||
releasePointerCapture(current.capture,current.pointerId);
|
||||
}
|
||||
// 容器的CSS zoom使用viewScale,宽高同时除以viewScale,保持投影尺寸与内部显示坐标对应。
|
||||
// schema=2给纯文本/纯图片补四向连接点,非分区显示成员徽标;普通尺寸调整用右下角,模式内显示八向手柄。
|
||||
// 八向手柄的尺寸与边框反向抵消zoom和viewScale,使屏幕上大小稳定;方向决定位置及cursor。
|
||||
return <>
|
||||
{!props.hideStyleToolbar && <NodeStyleToolbar {...props} />}
|
||||
<div ref={frame} className={`${styles.transformFrame} ${selected ? styles.selectedFrame : ""} ${plain ? styles.plainFrame : ""} ${rotating ? styles.rotatingFrame + " nodrag nopan" : ""} ${className}`}
|
||||
@@ -104,11 +129,13 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
|
||||
<Grip size={14} />
|
||||
</button>}
|
||||
{resizing && (["top-left", "top", "top-right", "right", "bottom-right", "bottom", "bottom-left", "left"] as const).map((direction) => {
|
||||
// 从方向名推导0%/50%/100%锚点:边中点只占一个方向,角点同时包含横纵方向。
|
||||
const left = direction.includes("left") ? "0%" : direction.includes("right") ? "100%" : "50%";
|
||||
const top = direction.includes("top") ? "0%" : direction.includes("bottom") ? "100%" : "50%";
|
||||
return <button type="button" key={direction} className={`${styles.resizeHandle} nodrag nopan`} data-resize-handle={direction} aria-label={`拉伸 ${direction}`} title="拖动拉伸 · Shift 固定比例" style={{ left, top, width: 10 / (zoom * (data.viewScale || 1)), height: 10 / (zoom * (data.viewScale || 1)), borderWidth: 1 / (zoom * (data.viewScale || 1)), cursor: direction === "top" || direction === "bottom" ? "ns-resize" : direction === "left" || direction === "right" ? "ew-resize" : direction === "top-left" || direction === "bottom-right" ? "nwse-resize" : "nesw-resize" }}
|
||||
onPointerDown={(event) => start(event, direction)} onPointerMove={move} onPointerUp={finish} onPointerCancel={cancelGesture}
|
||||
onKeyDown={(event) => {
|
||||
// 键盘每次方向键形成一次完整变换:默认一个viewScale单位,Ctrl/Meta放大十倍,Shift固定比例。
|
||||
const delta = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[event.key]; if (!delta) return;
|
||||
event.preventDefault(); event.stopPropagation(); const node = flow.getNode(id); if (!node) return;
|
||||
const lease = editing.beginTransform?.(id); if (lease === false) return; if (!editing.beginTransform) editing.begin(id); const scale = (data.viewScale || 1) * (event.ctrlKey || event.metaKey ? 10 : 1);
|
||||
@@ -122,6 +149,7 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
|
||||
</>;
|
||||
}
|
||||
|
||||
/** 受控拉伸模式开关;aria-pressed反映当前节点是否处于模式,data标记供外部点击判定排除自身。 */
|
||||
export function ResizeToggle({ id,renderedTarget }: { id: string;renderedTarget?:import("@/lib/canvas-bindings/compat-types").RenderedTarget }) {
|
||||
const editing = useScopedNodeEditing(id,renderedTarget);
|
||||
return <button type="button" data-resize-toggle={id} aria-label="八向拉伸模式" title="八向拉伸 · Shift 固定比例" aria-pressed={editing.resizing === id} onClick={() => editing.resize(editing.resizing === id ? null : id)}><Maximize2 size={16} /></button>;
|
||||
|
||||
Reference in New Issue
Block a user