feat(canvas): add inherited elements, resizing and rotation
This commit is contained in:
@@ -6,9 +6,9 @@
|
||||
.node { position: relative; width: 260px; padding: 14px; border: 1px solid #ded7ce; border-radius: 10px; background: white; color: #37352f; box-shadow: 0 2px 5px #40302405; }
|
||||
.selectedNode { border-color: #a47764; box-shadow: 0 0 0 2px #a4776425; }
|
||||
.nodeHeading { display: flex; align-items: center; gap: 6px; margin-bottom: 12px; font-size: 11px; color: #92877c; }
|
||||
.node textarea { display: block; width: 100%; min-height: 140px; padding: 0; resize: vertical; border: 0; background: none; color: #37352f; font-family: inherit; font-size: 13px; line-height: 1.8; outline: none; }
|
||||
.node textarea { display: block; width: 100%; flex: 1; min-height: 0; height: 100%; padding: 0; resize: none; border: 0; background: none; color: #37352f; font-family: inherit; font-size: 13px; line-height: 1.8; outline: none; }
|
||||
.node textarea::placeholder { color: #b2a99e; }
|
||||
.image { display: block; max-width: 100%; max-height: 280px; margin: auto; border-radius: 5px; object-fit: contain; }
|
||||
.image { display: block; flex: 1; min-height: 0; width: 100%; height: 100%; max-width: 100%; max-height: none; margin: auto; border-radius: 5px; object-fit: contain; }
|
||||
.node :global(.react-flow__handle) { width: 8px; height: 8px; background: #a47764; border: 2px solid #fff; }
|
||||
.toolbar button[aria-pressed="true"] { background: #f1e6df; color: #78523f; }
|
||||
.toolbar button:disabled { cursor: not-allowed; }
|
||||
@@ -38,3 +38,28 @@
|
||||
.branch_right { right: -48px; top: 50%; transform: translateY(-50%); }
|
||||
.branch_bottom { bottom: -48px; left: 50%; transform: translateX(-50%); }
|
||||
.branch_left { left: -48px; top: 50%; transform: translateY(-50%); }
|
||||
|
||||
.transformFrame { position: relative; box-sizing: border-box; display: flex; flex-direction: column; transform-origin: center; overflow: visible; }
|
||||
.nodeHeading { flex: 0 0 auto; }
|
||||
.plainFrame { padding: 4px; border: 1px solid transparent; border-radius: 2px; background: transparent; box-shadow: none; }
|
||||
.plainFrame.selectedFrame { border-color: #a47764; }
|
||||
.plainText { display: block; flex: 1; min-width: 0; min-height: 0; width: 100%; height: 100%; padding: 0; border: 0; outline: none; background: transparent; color: #37352f; font-family: inherit; line-height: 1.25; resize: none; }
|
||||
.plainText::placeholder { color: #b3a79c; }
|
||||
.resizeHandle { position: absolute; box-sizing: border-box; width: 8px; height: 8px; padding: 0; border: 1px solid #a47764; border-radius: 2px; background: white; z-index: 8; touch-action: none; }
|
||||
.resize_top { top: -4px; left: calc(50% - 4px); cursor: ns-resize; }
|
||||
.resize_bottom { bottom: -4px; left: calc(50% - 4px); cursor: ns-resize; }
|
||||
.resize_left { left: -4px; top: calc(50% - 4px); cursor: ew-resize; }
|
||||
.resize_right { right: -4px; top: calc(50% - 4px); cursor: ew-resize; }
|
||||
.resize_top-left { left: -4px; top: -4px; cursor: nwse-resize; }
|
||||
.resize_bottom-right { right: -4px; bottom: -4px; cursor: nwse-resize; }
|
||||
.resize_top-right { right: -4px; top: -4px; cursor: nesw-resize; }
|
||||
.resize_bottom-left { left: -4px; bottom: -4px; cursor: nesw-resize; }
|
||||
.rotationStem { position: absolute; left: 50%; top: -62px; width: 1px; height: 62px; background: #d5c7bb; pointer-events: none; }
|
||||
.rotationHandle { position: absolute; top: -88px; left: calc(50% - 12px); display: grid; place-items: center; width: 24px; height: 24px; padding: 0; border: 1px solid #cbb8a8; border-radius: 50%; background: white; color: #78523f; z-index: 8; cursor: grab; touch-action: none; }
|
||||
.rotationHandle:active { cursor: grabbing; }
|
||||
.rotationValue { position: absolute; top: -85px; left: calc(50% + 18px); color: #92877c; background: #fffefbe6; padding: 0 4px; border-radius: 3px; font-size: 10px; pointer-events: none; }
|
||||
.plainFrame .rotationHandle { top: -38px; }
|
||||
.plainFrame .rotationStem { top: -14px; height: 14px; }
|
||||
.plainFrame .rotationValue { top: -35px; }
|
||||
.handMode .plainText { pointer-events: none; }
|
||||
.handMode .resizeHandle, .handMode .rotationHandle, .handMode .rotationStem, .handMode .rotationValue, .handMode .branchButton { display: none; }
|
||||
|
||||
@@ -5,16 +5,18 @@ import {
|
||||
Background, BackgroundVariant, ConnectionMode, ReactFlow, ReactFlowProvider, SelectionMode,
|
||||
addEdge, useReactFlow, useViewport, type Connection,
|
||||
} from "@xyflow/react";
|
||||
import { Download, FileText, Hand, HelpCircle, ImagePlus, Maximize, Minus, MousePointer2, Plus, Redo2, Sparkles, Type, Undo2, Upload } from "lucide-react";
|
||||
import { Download, FileText, Hand, HelpCircle, ImagePlus, Maximize, Minus, MousePointer2, Plus, Redo2, Sparkles, StickyNote, Type, Undo2, Upload } from "lucide-react";
|
||||
import { useReducedMotion } from "motion/react";
|
||||
import toolbar from "@/components/ui/toolbar.module.css";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import { branchCanvasNode, canvasReducer, canvasStorageKey, decodeCanvas, emptyCanvas, makeCanvasNode, type CanvasHistory, type CanvasNode, type NodeDirection } from "@/lib/canvas";
|
||||
import type { Board } from "@/lib/workspace";
|
||||
import { PlainTextNode } from "./plain-text-node";
|
||||
import type { NodeTransform } from "@/lib/canvas-geometry";
|
||||
import { MaterialNode, NodeEditing } from "./material-node";
|
||||
import styles from "./canvas.module.css";
|
||||
|
||||
const nodeTypes = { material: MaterialNode };
|
||||
const nodeTypes = { material: MaterialNode, plainText: PlainTextNode };
|
||||
|
||||
function CanvasEditor({ board }: { board: Board }) {
|
||||
const flow = useReactFlow<CanvasNode>();
|
||||
@@ -71,7 +73,8 @@ function CanvasEditor({ board }: { board: Board }) {
|
||||
void flow.setCenter(child.position.x + 130, child.position.y + 100, { zoom: flow.getZoom(), duration });
|
||||
} catch (error) { setError(error instanceof Error ? error.message : "无法创建节点。"); }
|
||||
}, [state.present, flow, duration]);
|
||||
const editing = useMemo(() => ({ begin: beginEdit, edit, branch }), [beginEdit, edit, branch]);
|
||||
const transform = useCallback((id: string, transform: NodeTransform) => dispatch({ type: "transform", id, transform }), []);
|
||||
const editing = useMemo(() => ({ begin: beginEdit, edit, branch, transform }), [beginEdit, edit, branch, transform]);
|
||||
|
||||
const center = () => {
|
||||
const point = flow.screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 });
|
||||
@@ -194,7 +197,8 @@ function CanvasEditor({ board }: { board: Board }) {
|
||||
<IconButton icon={MousePointer2} label="选择工具(V)" aria-pressed={tool === "select"} onClick={() => setTool("select")} />
|
||||
<IconButton icon={Hand} label="抓手工具(H)" aria-pressed={tool === "hand"} onClick={() => setTool("hand")} />
|
||||
<span className={toolbar.divider} aria-hidden="true" />
|
||||
<IconButton icon={Type} label="添加文字" onClick={() => addNode("text")} />
|
||||
<IconButton icon={Type} label="添加纯文本" onClick={() => addNode("plain-text")} />
|
||||
<IconButton icon={StickyNote} label="添加文本卡片" onClick={() => addNode("text")} />
|
||||
<IconButton icon={ImagePlus} label="添加图片" onClick={() => imageInput.current?.click()} />
|
||||
<IconButton icon={FileText} label="添加脚本" onClick={() => addNode("script")} />
|
||||
<IconButton icon={Sparkles} label="插入创作模板" onClick={addTemplate} />
|
||||
@@ -209,7 +213,9 @@ function CanvasEditor({ board }: { board: Board }) {
|
||||
<p>拖动空白处平移,拖动节点移动位置。</p>
|
||||
<p>触控板滚动平移;按 Ctrl / ⌘ 滚动或双指捏合缩放。</p>
|
||||
<p>V 选择 · H 抓手 · 按住空格临时平移 · Shift 框选</p>
|
||||
<p>拖动节点两侧圆点创建连线;Delete 删除所选内容。</p>
|
||||
<p>选中后拖动边角调整大小,拖动顶部旋转柄旋转;Shift 吸附 15°,双击复位。</p>
|
||||
<p>纯文本缩放会同步改变字号,不显示卡片边框、连接点和四向加号。</p>
|
||||
<p>拖动卡片连接点创建连线;Delete 删除所选内容。</p>
|
||||
<p>⌘ / Ctrl + Z 撤销,Shift + ⌘ / Ctrl + Z 重做。</p>
|
||||
<p>当前编辑保存在此浏览器,可导出 JSON 备份。</p>
|
||||
</div>
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
import { useContext } from "react";
|
||||
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
||||
import { FileText, Image as ImageIcon, Plus, Type } from "lucide-react";
|
||||
import { elementCapabilities } from "@/lib/canvas-elements";
|
||||
import { nodeDirections, type CanvasNode, type NodeDirection } from "@/lib/canvas";
|
||||
import styles from "./canvas.module.css";
|
||||
|
||||
export const NodeEditing = createContext({ begin: () => {}, edit: (_id: string, _text: string) => {}, branch: (_id: string, _direction: NodeDirection) => {} });
|
||||
import { TransformableNode, NodeEditing } from "./transformable-node";
|
||||
export { NodeEditing } from "./transformable-node";
|
||||
|
||||
const positions = { top: Position.Top, right: Position.Right, bottom: Position.Bottom, left: Position.Left };
|
||||
const labels = { top: "上方", right: "右侧", bottom: "下方", left: "左侧" };
|
||||
|
||||
export function MaterialNode({ id, data, selected }: NodeProps<CanvasNode>) {
|
||||
export function MaterialNode(props: NodeProps<CanvasNode>) {
|
||||
const { id, data, selected } = props;
|
||||
const editing = useContext(NodeEditing);
|
||||
const Icon = data.kind === "image" ? ImageIcon : data.kind === "script" ? FileText : Type;
|
||||
return (
|
||||
<article className={`${styles.node} ${selected ? styles.selectedNode : ""}`}>
|
||||
<TransformableNode {...props} className={`${styles.node} ${selected ? styles.selectedNode : ""}`}>
|
||||
<header className={styles.nodeHeading}><Icon size={13} /><span>{data.title}</span></header>
|
||||
<Handle type="target" position={Position.Left} className={styles.legacyHandle} isConnectable={false} />
|
||||
{data.kind === "image" ? <img className={styles.image} src={data.src} alt={data.title} draggable={false} referrerPolicy="no-referrer" /> : (
|
||||
@@ -23,8 +25,9 @@ export function MaterialNode({ id, data, selected }: NodeProps<CanvasNode>) {
|
||||
value={data.text} maxLength={100000} onFocus={editing.begin} onChange={(event) => editing.edit(id, event.target.value)} />
|
||||
)}
|
||||
<Handle type="source" position={Position.Right} className={styles.legacyHandle} isConnectable={false} />
|
||||
{nodeDirections.map((direction) => <Handle key={direction} id={direction} type="source" position={positions[direction]} />)}
|
||||
{selected && nodeDirections.map((direction) => (
|
||||
{nodeDirections.map((direction) => <Handle key={direction} id={direction} type="source" position={positions[direction]}
|
||||
style={direction === "left" || direction === "right" ? { top: "calc(50% + 20px)" } : { left: "calc(50% + 20px)" }} />)}
|
||||
{selected && elementCapabilities(data.kind).branchable && nodeDirections.map((direction) => (
|
||||
<button key={direction} type="button" className={`${styles.branchButton} ${styles["branch_" + direction]} nodrag nopan`}
|
||||
aria-label={`在${labels[direction]}添加节点`} title={`在${labels[direction]}添加节点并连线`}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
@@ -32,6 +35,6 @@ export function MaterialNode({ id, data, selected }: NodeProps<CanvasNode>) {
|
||||
<Plus size={16} strokeWidth={1.6} aria-hidden="true" />
|
||||
</button>
|
||||
))}
|
||||
</article>
|
||||
</TransformableNode>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useContext } from "react";
|
||||
import type { NodeProps } from "@xyflow/react";
|
||||
import type { CanvasNode } from "@/lib/canvas";
|
||||
import { NodeEditing, TransformableNode } from "./transformable-node";
|
||||
import styles from "./canvas.module.css";
|
||||
|
||||
export function PlainTextNode(props: NodeProps<CanvasNode>) {
|
||||
const editing = useContext(NodeEditing);
|
||||
return (
|
||||
<TransformableNode {...props}>
|
||||
<textarea className={`${styles.plainText} nodrag nowheel`} aria-label="纯文本" placeholder="写下文字…"
|
||||
style={{ fontSize: props.data.fontSize || 24 }} value={props.data.text} maxLength={100000}
|
||||
onFocus={editing.begin} onChange={(event) => editing.edit(props.id, event.target.value)} />
|
||||
</TransformableNode>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useEffect, useRef, type PointerEvent, type ReactNode } from "react";
|
||||
import { useReactFlow, useUpdateNodeInternals, type NodeProps, type XYPosition } from "@xyflow/react";
|
||||
import { RotateCw } from "lucide-react";
|
||||
import { elementCapabilities } from "@/lib/canvas-elements";
|
||||
import type { CanvasNode, NodeDirection } from "@/lib/canvas";
|
||||
import { normalizeRotation, resizeFromPointer, rotationFromPointer, type NodeTransform, type ResizeDirection } from "@/lib/canvas-geometry";
|
||||
import styles from "./canvas.module.css";
|
||||
|
||||
export const NodeEditing = createContext({
|
||||
begin: () => {}, edit: (_id: string, _text: string) => {},
|
||||
branch: (_id: string, _direction: NodeDirection) => {},
|
||||
transform: (_id: string, _transform: NodeTransform) => {},
|
||||
});
|
||||
const resizeDirections: ResizeDirection[] = ["top", "top-right", "right", "bottom-right", "bottom", "bottom-left", "left", "top-left"];
|
||||
const resizeLabels = { top: "上边", right: "右边", bottom: "下边", left: "左边", "top-right": "右上角", "bottom-right": "右下角", "bottom-left": "左下角", "top-left": "左上角" };
|
||||
type Gesture = { node: CanvasNode; start: XYPosition; direction: ResizeDirection | "rotate" };
|
||||
|
||||
export function TransformableNode({ id, data, selected, width, height, children, className = "" }: NodeProps<CanvasNode> & { children: ReactNode; className?: string }) {
|
||||
const editing = useContext(NodeEditing);
|
||||
const flow = useReactFlow<CanvasNode>();
|
||||
const update = useUpdateNodeInternals();
|
||||
const gesture = useRef<Gesture | null>(null);
|
||||
const rotation = data.rotation || 0;
|
||||
const plain = data.kind === "plain-text";
|
||||
const capabilities = elementCapabilities(data.kind);
|
||||
useEffect(() => { update(id); }, [id, rotation, width, height, update]);
|
||||
|
||||
function start(event: PointerEvent<HTMLButtonElement>, direction: Gesture["direction"]) {
|
||||
if (event.button !== 0) return;
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
const node = flow.getNode(id);
|
||||
if (!node) return;
|
||||
editing.begin();
|
||||
gesture.current = { node, direction, start: flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }) };
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
}
|
||||
function move(event: PointerEvent<HTMLButtonElement>) {
|
||||
if (!gesture.current) return;
|
||||
event.stopPropagation();
|
||||
const current = flow.screenToFlowPosition({ x: event.clientX, y: event.clientY });
|
||||
const { node, start, direction } = gesture.current;
|
||||
editing.transform(id, direction === "rotate"
|
||||
? { rotation: rotationFromPointer(node, start, current, event.shiftKey) }
|
||||
: resizeFromPointer(node, direction, { x: current.x - start.x, y: current.y - start.y }));
|
||||
}
|
||||
function finish(event: PointerEvent<HTMLButtonElement>) {
|
||||
event.stopPropagation();
|
||||
gesture.current = null;
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
function rotateBy(step: number) {
|
||||
editing.begin();
|
||||
editing.transform(id, { rotation: normalizeRotation(rotation + step) });
|
||||
}
|
||||
return (
|
||||
<div className={`${styles.transformFrame} ${selected ? styles.selectedFrame : ""} ${plain ? styles.plainFrame : ""} ${className}`}
|
||||
style={{ width: width || (plain ? 240 : 260), height: height || (plain ? 72 : 200), transform: `rotate(${rotation}deg)` }}>
|
||||
{children}
|
||||
{selected && <>
|
||||
{capabilities.resizable && resizeDirections.map((direction) => <button key={direction} type="button"
|
||||
className={`${styles.resizeHandle} ${styles["resize_" + direction]} nodrag nopan`}
|
||||
aria-label={`缩放${resizeLabels[direction]}`} title={`拖动${resizeLabels[direction]}调整大小`}
|
||||
onPointerDown={(event) => start(event, direction)} onPointerMove={move} onPointerUp={finish} onPointerCancel={finish} onLostPointerCapture={() => { gesture.current = null; }} />)}
|
||||
{capabilities.rotatable && <>
|
||||
<span className={styles.rotationStem} />
|
||||
<button type="button" className={`${styles.rotationHandle} nodrag nopan`} aria-label="旋转节点" title="拖动旋转;Shift 吸附 15°;双击复位"
|
||||
onPointerDown={(event) => start(event, "rotate")} onPointerMove={move} onPointerUp={finish} onPointerCancel={finish} onLostPointerCapture={() => { gesture.current = null; }}
|
||||
onDoubleClick={(event) => { event.stopPropagation(); editing.begin(); editing.transform(id, { rotation: 0 }); }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "ArrowLeft" || event.key === "ArrowRight") { event.preventDefault(); event.stopPropagation(); rotateBy((event.key === "ArrowLeft" ? -1 : 1) * (event.shiftKey ? 15 : 1)); }
|
||||
if (event.key === "Home") { event.preventDefault(); event.stopPropagation(); editing.begin(); editing.transform(id, { rotation: 0 }); }
|
||||
}}>
|
||||
<RotateCw size={14} strokeWidth={1.6} />
|
||||
</button>
|
||||
<span className={styles.rotationValue}>{Math.round(rotation)}°</span>
|
||||
</>}
|
||||
</>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user