feat(canvas): refine connections, text styles and icon editing
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { createPortal } from "react-dom";
|
||||
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";
|
||||
export type BranchRequest = { id: string; direction: NodeDirection; point: XYPosition; at?: XYPosition };
|
||||
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();
|
||||
useEffect(() => {
|
||||
if (!request) return;
|
||||
const focus = document.activeElement as HTMLElement | null;
|
||||
ref.current?.querySelector<HTMLButtonElement>("button[data-choice]")?.focus();
|
||||
const outside = (event: PointerEvent) => { if (!ref.current?.contains(event.target as Node)) close(); };
|
||||
const escape = (event: KeyboardEvent) => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(); focus?.focus(); } };
|
||||
window.addEventListener("pointerdown", outside); window.addEventListener("keydown", escape, true);
|
||||
return () => { window.removeEventListener("pointerdown", outside); window.removeEventListener("keydown", escape, true); };
|
||||
}, [request, close]);
|
||||
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(); } }}>
|
||||
<header><span>添加连接节点</span><button aria-label="关闭节点菜单" onClick={close}><X size={14} /></button></header>
|
||||
{([["text", StickyNote, "文本"], ["image", ImagePlus, "图片"], ["script", FileText, "脚本"]] as const).map(([kind, Icon, label]) => <button data-choice key={kind} onClick={() => choose(kind)}><Icon size={17} /><span>{label}</span></button>)}
|
||||
</motion.div>}</AnimatePresence>, document.body);
|
||||
}
|
||||
@@ -3,19 +3,18 @@
|
||||
.editor :global(.react-flow__attribution) { bottom: 5px; right: 5px; font-size: 9px; background: #fffefbc9; }
|
||||
.handMode :global(.react-flow__pane), .handMode :global(.react-flow__node) { cursor: grab; }
|
||||
.handMode :global(.react-flow__pane.dragging) { cursor: grabbing; }
|
||||
.node { position: relative; width: 260px; padding: 14px; border: 1px solid #ded7ce; border-radius: 10px; background: white; color: #37352f; box-shadow: 0 2px 5px #40302405; }
|
||||
.node { position: relative; padding: 0; 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%; 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; 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; }
|
||||
.nodeHeading { position: absolute; left: 0; top: -26px; display: flex; align-items: center; gap: 6px; height: 20px; font-size: 11px; color: #92877c; white-space: nowrap; cursor: grab; }
|
||||
.cardText, .plainText { box-sizing: border-box; display: block; width: 100%; height: 100%; min-height: 0; min-width: 0; flex: 1; padding: 14px; resize: none; border: 0; border-radius: inherit; background: transparent; outline: none; box-shadow: none; }
|
||||
.cardText:focus, .plainText:focus { outline: none; box-shadow: none; border-color: transparent !important; }
|
||||
.cardText::placeholder, .plainText::placeholder { color: #b2a99e; }
|
||||
.image { display: block; flex: 1; min-height: 0; width: 100%; height: 100%; max-width: 100%; border-radius: inherit; object-fit: contain; }
|
||||
.toolbar button[aria-pressed="true"] { background: #f1e6df; color: #78523f; }
|
||||
.toolbar button:disabled { cursor: not-allowed; }
|
||||
.topRight { position: absolute; top: 12px; right: 12px; z-index: 10; display: flex; flex-direction: column; align-items: flex-end; gap: 3px; }
|
||||
.toolbar .zoomValue { box-sizing: border-box; flex: 0 0 48px; width: 48px; height: var(--toolbar-control-height, 32px); padding: 0; border-radius: 5px; font: inherit; font-variant-numeric: tabular-nums; }
|
||||
.toolbar .zoomValue:hover { background: #e9e7e3; }
|
||||
.saveStatus { padding-right: 5px; font-size: 10px; color: #a59b90; }
|
||||
.bottomToolbar { position: absolute; left: 50%; bottom: 12px; transform: translateX(-50%); z-index: 10; }
|
||||
.helpPopup { position: fixed; inset: auto 12px 62px auto; margin: 0; width: 330px; max-width: calc(100vw - 32px); max-height: calc(100dvh - 110px); overflow-y: auto; padding: 18px; border: 1px solid #e2dcd3; border-radius: 12px; background: white; color: #6d6054; box-shadow: 0 8px 30px #32251a14; font-size: 12px; line-height: 1.7; }
|
||||
.helpPopup strong { color: #49372e; font-size: 13px; }
|
||||
@@ -32,34 +31,53 @@
|
||||
.topRight .toolbar { max-width: calc(100vw - 24px); overflow-x: auto; }
|
||||
|
||||
.node :global(.react-flow__handle).legacyHandle { opacity: 0; pointer-events: none; }
|
||||
.branchButton { position: absolute; display: grid; place-items: center; width: 32px; height: 32px; padding: 0; border: 1px solid #cbb8a8; border-radius: 50%; background: #fffefb; color: #78523f; box-shadow: 0 2px 6px #32251a0a; cursor: pointer; z-index: 5; }
|
||||
.branchButton:hover { background: #f1e6df; border-color: #a47764; }
|
||||
.branch_top { top: -48px; left: 50%; transform: translateX(-50%); }
|
||||
.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; }
|
||||
.transformFrame { position: relative; box-sizing: border-box; display: flex; flex-direction: column; transform-origin: center; overflow: visible; transition: border-color 160ms ease, box-shadow 160ms ease; }
|
||||
.plainFrame { padding: 0; 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; }
|
||||
.plainText { padding: 4px; }
|
||||
.node :global(.react-flow__handle).centerHandle { width: 10px; height: 10px; display: grid; place-items: center; background: #a47764; color: #78523f; border: 2px solid #fff; border-radius: 50%; z-index: 7; cursor: crosshair; touch-action: none; transition: width 160ms ease, height 160ms ease, background 160ms ease, border-color 160ms ease, box-shadow 160ms ease; }
|
||||
.centerHandle svg { opacity: 0; scale: 0.4; pointer-events: none; transition: opacity 140ms ease, scale 160ms ease; }
|
||||
.node :global(.react-flow__handle).plusHandle { width: 28px; height: 28px; background: #fffefb; border: 1px solid #cbb8a8; box-shadow: 0 2px 6px #32251a0a; }
|
||||
.plusHandle svg { opacity: 1; scale: 1; }
|
||||
.node :global(.react-flow__handle).centerHandle:hover { border-color: #a47764; background: #f1e6df; }
|
||||
.cornerResize { position: absolute; right: 2px; bottom: 2px; display: grid; place-items: center; width: 20px; height: 20px; padding: 0; border: 0; border-radius: 3px; color: #92877c; background: transparent; z-index: 8; cursor: nwse-resize; touch-action: none; opacity: 0; scale: 0.8; pointer-events: none; transition: opacity 160ms ease, scale 160ms ease; }
|
||||
.selectedFrame .cornerResize { opacity: 1; scale: 1; pointer-events: auto; }
|
||||
.rotatingFrame .cornerResize { opacity: 0; pointer-events: none; }
|
||||
.rotatingFrame { cursor: grabbing; touch-action: none; }
|
||||
.rotatingFrame textarea, .rotatingFrame .centerHandle { pointer-events: none; }
|
||||
.rotationHint { position: absolute; left: 50%; bottom: -30px; translate: -50% 0; display: flex; align-items: center; gap: 4px; white-space: nowrap; padding: 4px 7px; border-radius: 5px; background: #fffefbe8; color: #78523f; font-size: 10px; pointer-events: none; }
|
||||
.styleToolbar { display: flex; align-items: center; gap: 3px; padding: 4px; border: 1px solid #ded7ce; border-radius: 8px; background: #fffefb; color: #49372e; box-shadow: 0 5px 20px #32251a12; max-width: calc(100vw - 24px); overflow-x: auto; }
|
||||
.styleToolbar button { display: grid; place-items: center; width: 28px; height: 28px; flex: 0 0 28px; padding: 0; border-radius: 4px; }
|
||||
.styleToolbar button:hover, .styleToolbar button[aria-pressed="true"] { background: #f1e6df; color: #78523f; }
|
||||
.styleToolbar select, .styleToolbar input[type="number"] { height: 28px; border: 0; border-radius: 3px; background: transparent; padding: 0 3px; font-size: 12px; }
|
||||
.styleToolbar input[type="number"] { width: 48px; }
|
||||
.colorPicker { position: relative; display: grid; place-items: center; width: 28px; height: 28px; flex: 0 0 28px; cursor: pointer; }
|
||||
.colorPicker span { width: 17px; height: 17px; border: 1px solid #bfb4aa; border-radius: 50%; }
|
||||
.colorPicker input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; }
|
||||
.branchMenu { position: fixed; z-index: 60; width: 210px; padding: 7px; border: 1px solid #ded7ce; border-radius: 10px; background: #fffefb; color: #49372e; box-shadow: 0 8px 30px #32251a1a; }
|
||||
.branchMenu header { display: flex; align-items: center; justify-content: space-between; padding: 3px 6px 6px; color: #92877c; font-size: 11px; }
|
||||
.branchMenu header button { display: grid; place-items: center; width: 24px; height: 24px; }
|
||||
.branchMenu > button { display: flex; align-items: center; gap: 10px; width: 100%; min-height: 36px; padding: 8px; border-radius: 5px; font-size: 13px; text-align: left; }
|
||||
.branchMenu button:hover { background: #f1e6df; }
|
||||
.iconContent { width: 100%; height: 100%; padding: 6px; border-radius: inherit; }
|
||||
.iconContent svg { pointer-events: none; }
|
||||
.iconPicker { position: fixed; top: 70px; left: 12px; bottom: 70px; z-index: 60; display: flex; flex-direction: column; width: 272px; max-width: calc(100vw - 24px); max-height: 550px; padding: 14px; border: 1px solid #ded7ce; border-radius: 12px; background: #fffefb; color: #49372e; box-shadow: 0 8px 30px #32251a14; }
|
||||
.iconPicker header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; font-size: 14px; }
|
||||
.iconPicker header button { width: 28px; height: 28px; display: grid; place-items: center; border-radius: 4px; }
|
||||
.iconPicker input { width: 100%; padding: 7px 9px; border: 1px solid #ded7ce; border-radius: 6px; font-size: 12px; margin-bottom: 12px; }
|
||||
.iconGrid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 6px; overflow-y: auto; }
|
||||
.iconGrid button { display: grid; place-items: center; aspect-ratio: 1; border-radius: 6px; color: #6d6054; }
|
||||
.iconGrid button:hover, .iconPicker header button:hover { background: #f1e6df; }
|
||||
.iconPicker footer { padding-top: 12px; color: #92877c; font-size: 10px; }
|
||||
.handMode .plainText { pointer-events: none; }
|
||||
.handMode .resizeHandle, .handMode .rotationHandle, .handMode .rotationStem, .handMode .rotationValue, .handMode .branchButton { display: none; }
|
||||
.handMode .cornerResize, .handMode .rotationHint, .handMode .centerHandle { visibility: hidden; }
|
||||
.handMode .styleToolbar { display: none; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.transformFrame, .cornerResize, .node :global(.react-flow__handle).centerHandle, .centerHandle svg { transition: none; }
|
||||
}
|
||||
|
||||
.textDragHandle { position: absolute; top: -23px; left: 0; display: grid; place-items: center; width: 20px; height: 20px; color: #92877c; cursor: grab; opacity: 0; pointer-events: none; transition: opacity 160ms ease; }
|
||||
.selectedFrame .textDragHandle { opacity: 1; pointer-events: auto; }
|
||||
.handMode .textDragHandle { visibility: hidden; }
|
||||
@media (prefers-reduced-motion: reduce) { .textDragHandle { transition: none; } }
|
||||
|
||||
@@ -2,21 +2,25 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef, useState, type ChangeEvent } from "react";
|
||||
import {
|
||||
Background, BackgroundVariant, ConnectionMode, ReactFlow, ReactFlowProvider, SelectionMode,
|
||||
addEdge, useReactFlow, useViewport, type Connection,
|
||||
Background, BackgroundVariant, ConnectionLineType, ConnectionMode, ReactFlow, ReactFlowProvider, SelectionMode,
|
||||
addEdge, useReactFlow, useViewport, type Connection, type OnConnectEnd, type XYPosition,
|
||||
} from "@xyflow/react";
|
||||
import { Download, FileText, Hand, HelpCircle, ImagePlus, Maximize, Minus, MousePointer2, Plus, Redo2, Sparkles, StickyNote, Type, Undo2, Upload } from "lucide-react";
|
||||
import { Download, FileText, Hand, HelpCircle, ImagePlus, Maximize, Minus, MousePointer2, Plus, Redo2, Sparkles, StickyNote, Shapes, 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 { BranchMenu, type BranchRequest } from "./branch-menu";
|
||||
import { IconNode } from "./icon-node";
|
||||
import { IconPicker } from "./icon-picker";
|
||||
import type { TextStyle } from "@/lib/canvas-elements";
|
||||
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, plainText: PlainTextNode };
|
||||
const nodeTypes = { material: MaterialNode, plainText: PlainTextNode, icon: IconNode };
|
||||
|
||||
function CanvasEditor({ board }: { board: Board }) {
|
||||
const flow = useReactFlow<CanvasNode>();
|
||||
@@ -32,20 +36,29 @@ function CanvasEditor({ board }: { board: Board }) {
|
||||
const [state, dispatch] = useReducer(canvasReducer, { past: [], present: initial.document, future: [] } satisfies CanvasHistory);
|
||||
const [tool, setTool] = useState<"select" | "hand">("select");
|
||||
const [error, setError] = useState(initial.error);
|
||||
const [saved, setSaved] = useState(true);
|
||||
const [branchRequest, setBranchRequest] = useState<BranchRequest | null>(null);
|
||||
const [iconPicker, setIconPicker] = useState(false);
|
||||
const [rotating, setRotating] = useState<string | null>(null);
|
||||
const connectionStart = useRef<{ id: string; direction: NodeDirection; point: XYPosition } | null>(null);
|
||||
const pendingImage = useRef<BranchRequest | null>(null);
|
||||
const closeBranch = useCallback(() => setBranchRequest(null), []);
|
||||
const imageInput = useRef<HTMLInputElement>(null);
|
||||
const projectInput = useRef<HTMLInputElement>(null);
|
||||
const firstSave = useRef(true);
|
||||
useEffect(() => {
|
||||
const input = imageInput.current;
|
||||
const cancel = () => { pendingImage.current = null; };
|
||||
input?.addEventListener("cancel", cancel);
|
||||
return () => input?.removeEventListener("cancel", cancel);
|
||||
}, []);
|
||||
const { nodes, edges } = state.present;
|
||||
const duration = reduceMotion ? 0 : 180;
|
||||
|
||||
useEffect(() => {
|
||||
if (firstSave.current) { firstSave.current = false; return; }
|
||||
setSaved(false);
|
||||
const timeout = setTimeout(() => {
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify(decodeCanvas(JSON.stringify(state.present))));
|
||||
setSaved(true);
|
||||
} catch { setError("浏览器存储空间不足或暂不可用,请导出图谱备份。当前内容仍保留在画布中。"); }
|
||||
}, 400);
|
||||
return () => clearTimeout(timeout);
|
||||
@@ -65,16 +78,38 @@ function CanvasEditor({ board }: { board: Board }) {
|
||||
|
||||
const beginEdit = useCallback(() => dispatch({ type: "checkpoint" }), []);
|
||||
const edit = useCallback((id: string, text: string) => dispatch({ type: "edit", id, text }), []);
|
||||
const branch = useCallback((id: string, direction: NodeDirection) => {
|
||||
try {
|
||||
const document = branchCanvasNode(state.present, id, direction);
|
||||
dispatch({ type: "replace", document });
|
||||
const child = document.nodes[document.nodes.length - 1];
|
||||
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 branch = useCallback((id: string, direction: NodeDirection, point: XYPosition) => {
|
||||
setBranchRequest({ id, direction, point });
|
||||
setRotating(null);
|
||||
}, []);
|
||||
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 style = useCallback((id: string, style: TextStyle, fontSize?: number) => dispatch({ type: "style", id, style, fontSize }), []);
|
||||
const editing = useMemo(() => ({ begin: beginEdit, edit, branch, transform, style, rotating, rotate: setRotating }), [beginEdit, edit, branch, transform, style, rotating]);
|
||||
function createBranch(request: BranchRequest, kind: "text" | "image" | "script", src?: string) {
|
||||
try {
|
||||
const document = branchCanvasNode(state.present, request.id, request.direction, kind, src, request.at);
|
||||
dispatch({ type: "replace", document });
|
||||
const child = document.nodes.at(-1)!;
|
||||
void flow.setCenter(child.position.x + 130, child.position.y + 100, { zoom: flow.getZoom(), duration });
|
||||
setBranchRequest(null);
|
||||
} catch (error) { setError(error instanceof Error ? error.message : "无法创建节点。"); }
|
||||
}
|
||||
function chooseBranch(kind: "text" | "image" | "script") {
|
||||
if (!branchRequest) return;
|
||||
if (kind === "image") { pendingImage.current = branchRequest; setBranchRequest(null); imageInput.current?.click(); }
|
||||
else createBranch(branchRequest, kind);
|
||||
}
|
||||
const connectionEnd: OnConnectEnd = (event, connection) => {
|
||||
const start = connectionStart.current;
|
||||
connectionStart.current = null;
|
||||
if (!start || connection.isValid) return;
|
||||
const point = "changedTouches" in event ? event.changedTouches[0] : event;
|
||||
if (!point || Math.hypot(point.clientX - start.point.x, point.clientY - start.point.y) < 5) return;
|
||||
if (!document.elementFromPoint(point.clientX, point.clientY)?.closest(".react-flow__pane")) return;
|
||||
if (!flow.getNode(start.id)?.selected) return;
|
||||
const at = flow.screenToFlowPosition({ x: point.clientX, y: point.clientY });
|
||||
setBranchRequest({ id: start.id, direction: start.direction, point: { x: point.clientX, y: point.clientY }, at });
|
||||
};
|
||||
|
||||
const center = () => {
|
||||
const point = flow.screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 });
|
||||
@@ -96,7 +131,7 @@ function CanvasEditor({ board }: { board: Board }) {
|
||||
const script = makeCanvasNode("script", { x: position.x + 340, y: position.y });
|
||||
script.data.text = "从第一个镜头开始,写下你的故事。";
|
||||
dispatch({ type: "replace", document: { ...state.present, nodes: [...nodes, brief, idea, script], edges: [...edges,
|
||||
{ id: crypto.randomUUID(), source: brief.id, target: idea.id },
|
||||
{ id: crypto.randomUUID(), source: brief.id, target: idea.id, type: "default" },
|
||||
{ id: crypto.randomUUID(), source: idea.id, target: script.id },
|
||||
] } });
|
||||
setTool("select");
|
||||
@@ -107,9 +142,9 @@ function CanvasEditor({ board }: { board: Board }) {
|
||||
async function readImage(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
if (!file) { pendingImage.current = null; return; }
|
||||
if (!["image/png", "image/jpeg", "image/webp", "image/gif"].includes(file.type) || file.size > 2_000_000) {
|
||||
setError("请选择小于 2 MB 的 PNG、JPEG、WebP 或 GIF 图片。"); return;
|
||||
pendingImage.current = null; setError("请选择小于 2 MB 的 PNG、JPEG、WebP 或 GIF 图片。"); return;
|
||||
}
|
||||
try {
|
||||
const src = await new Promise<string>((resolve, reject) => {
|
||||
@@ -118,8 +153,9 @@ function CanvasEditor({ board }: { board: Board }) {
|
||||
reader.onerror = () => reject(new Error("无法读取图片。"));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
addNode("image", src);
|
||||
} catch { setError("无法读取图片,请重新选择。"); }
|
||||
const request = pendingImage.current; pendingImage.current = null;
|
||||
if (request) createBranch(request, "image", src); else addNode("image", src);
|
||||
} catch { pendingImage.current = null; setError("无法读取图片,请重新选择。"); }
|
||||
}
|
||||
async function importProject(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
@@ -167,7 +203,13 @@ function CanvasEditor({ board }: { board: Board }) {
|
||||
<ReactFlow<CanvasNode> nodes={nodes} edges={edges} nodeTypes={nodeTypes}
|
||||
onNodesChange={(changes) => dispatch({ type: "nodes", changes })}
|
||||
onEdgesChange={(changes) => dispatch({ type: "edges", changes })}
|
||||
onNodeDragStart={beginEdit} onConnect={connect} connectionMode={ConnectionMode.Loose}
|
||||
connectionLineType={ConnectionLineType.Bezier}
|
||||
onNodeDragStart={() => { beginEdit(); closeBranch(); setRotating(null); }} onConnect={connect} connectionMode={ConnectionMode.Loose}
|
||||
connectOnClick={false} connectionDragThreshold={5}
|
||||
onConnectStart={(event, params) => { closeBranch(); const point = "touches" in event ? event.touches[0] : event; if (params.nodeId && params.handleId && point) connectionStart.current = { id: params.nodeId, direction: params.handleId as NodeDirection, point: { x: point.clientX, y: point.clientY } }; }}
|
||||
onConnectEnd={connectionEnd}
|
||||
onPaneClick={() => { closeBranch(); setRotating(null); }} onNodeClick={(_, node) => { if (node.id !== rotating) setRotating(null); }}
|
||||
onMoveStart={closeBranch}
|
||||
onMoveEnd={(_, viewport) => dispatch({ type: "viewport", viewport })}
|
||||
defaultViewport={initial.document.viewport} minZoom={0.1} maxZoom={4}
|
||||
panOnDrag panOnScroll zoomOnScroll={false} zoomOnPinch zoomActivationKeyCode={["Meta", "Control"]}
|
||||
@@ -191,7 +233,6 @@ function CanvasEditor({ board }: { board: Board }) {
|
||||
<span className={toolbar.divider} aria-hidden="true" />
|
||||
<IconButton icon={Download} label="导出图谱" onClick={exportProject} />
|
||||
</div>
|
||||
<span className={styles.saveStatus} role="status">{saved ? "保存在此浏览器" : "正在保存到此浏览器…"}</span>
|
||||
</div>
|
||||
<div className={`${toolbar.surface} ${toolbar.relaxed} ${styles.toolbar} ${styles.bottomToolbar}`} role="toolbar" aria-label="画布创作工具">
|
||||
<IconButton icon={MousePointer2} label="选择工具(V)" aria-pressed={tool === "select"} onClick={() => setTool("select")} />
|
||||
@@ -199,8 +240,9 @@ function CanvasEditor({ board }: { board: Board }) {
|
||||
<span className={toolbar.divider} aria-hidden="true" />
|
||||
<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={ImagePlus} label="添加图片" onClick={() => { pendingImage.current = null; imageInput.current?.click(); }} />
|
||||
<IconButton icon={FileText} label="添加脚本" onClick={() => addNode("script")} />
|
||||
<IconButton icon={Shapes} label="添加图标" aria-expanded={iconPicker} onClick={() => setIconPicker(!iconPicker)} />
|
||||
<IconButton icon={Sparkles} label="插入创作模板" onClick={addTemplate} />
|
||||
<span className={toolbar.divider} aria-hidden="true" />
|
||||
<IconButton icon={Upload} label="导入图谱" onClick={() => projectInput.current?.click()} />
|
||||
@@ -213,12 +255,20 @@ function CanvasEditor({ board }: { board: Board }) {
|
||||
<p>拖动空白处平移,拖动节点移动位置。</p>
|
||||
<p>触控板滚动平移;按 Ctrl / ⌘ 滚动或双指捏合缩放。</p>
|
||||
<p>V 选择 · H 抓手 · 按住空格临时平移 · Shift 框选</p>
|
||||
<p>选中后拖动边角调整大小,拖动顶部旋转柄旋转;Shift 吸附 15°,双击复位。</p>
|
||||
<p>纯文本缩放会同步改变字号,不显示卡片边框、连接点和四向加号。</p>
|
||||
<p>拖动卡片连接点创建连线;Delete 删除所选内容。</p>
|
||||
<p>拖动右下角调整大小;在样式栏开启旋转,拖动元素旋转,点击外部退出。Shift 吸附 15°。</p>
|
||||
<p>纯文本可独立调整宽高,文字随宽度换行,字号在样式栏设置;不显示卡片连接点或四向加号。</p>
|
||||
<p>拖动四边中心点或选中后的加号创建贝塞尔连线;点击加号选择新节点类型。Delete 删除所选内容。</p>
|
||||
<p>⌘ / Ctrl + Z 撤销,Shift + ⌘ / Ctrl + Z 重做。</p>
|
||||
<p>当前编辑保存在此浏览器,可导出 JSON 备份。</p>
|
||||
<p>可通过右上角导出 JSON 备份。</p>
|
||||
</div>
|
||||
<BranchMenu request={branchRequest} close={closeBranch} choose={chooseBranch} />
|
||||
<IconPicker open={iconPicker} close={() => setIconPicker(false)} choose={(name, label) => {
|
||||
if (nodes.length >= 500) { setError("当前图谱已达到 500 个节点。"); return; }
|
||||
const node = makeCanvasNode("icon", center());
|
||||
node.data = { ...node.data, icon: name, title: label };
|
||||
dispatch({ type: "replace", document: { ...state.present, nodes: [...nodes.map((item) => ({ ...item, selected: false })), { ...node, selected: true }] } });
|
||||
setTool("select");
|
||||
}} />
|
||||
{error && <button className={styles.error} role="alert" onClick={() => setError("")} title="点击关闭提示">{error}</button>}
|
||||
<input ref={imageInput} type="file" accept="image/png,image/jpeg,image/webp,image/gif" onChange={readImage} hidden />
|
||||
<input ref={projectInput} type="file" accept="application/json,.json" onChange={importProject} hidden />
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ArrowRight, BookOpen, Briefcase, Calendar, Camera, Check, Circle, Cloud, Code, Coffee, Compass, Diamond, FileText, Flag, Flame, Folder, Globe, Heart, Image, Layers, Leaf, Lightbulb, Link, MapPin, MessageCircle, Moon, Music, Palette, Play, Rocket, Search, Settings, Shield, Smile, Sparkles, Square, Star, Sun, Target, ThumbsUp, Triangle, Users, Video, WandSparkles, Zap } from "lucide-react";
|
||||
export const iconCatalog = [
|
||||
{ name: "Star", label: "星星", Icon: Star }, { name: "Heart", label: "爱心", Icon: Heart }, { name: "Lightbulb", label: "灵感", Icon: Lightbulb },
|
||||
{ name: "Sparkles", label: "闪光", Icon: Sparkles }, { name: "Zap", label: "闪电", Icon: Zap }, { name: "Flame", label: "火焰", Icon: Flame },
|
||||
{ name: "Rocket", label: "火箭", Icon: Rocket }, { name: "Target", label: "目标", Icon: Target }, { name: "Flag", label: "旗帜", Icon: Flag },
|
||||
{ name: "Check", label: "完成", Icon: Check }, { name: "ThumbsUp", label: "点赞", Icon: ThumbsUp }, { name: "Smile", label: "笑脸", Icon: Smile },
|
||||
{ name: "Coffee", label: "咖啡", Icon: Coffee }, { name: "Leaf", label: "叶子", Icon: Leaf }, { name: "Sun", label: "太阳", Icon: Sun },
|
||||
{ name: "Moon", label: "月亮", Icon: Moon }, { name: "Cloud", label: "云", Icon: Cloud }, { name: "Globe", label: "地球", Icon: Globe },
|
||||
{ name: "MapPin", label: "位置", Icon: MapPin }, { name: "Compass", label: "指南针", Icon: Compass }, { name: "ArrowRight", label: "箭头", Icon: ArrowRight },
|
||||
{ name: "BookOpen", label: "书本", Icon: BookOpen }, { name: "FileText", label: "文件", Icon: FileText }, { name: "Folder", label: "文件夹", Icon: Folder },
|
||||
{ name: "Calendar", label: "日历", Icon: Calendar }, { name: "Briefcase", label: "公文包", Icon: Briefcase }, { name: "Users", label: "用户", Icon: Users },
|
||||
{ name: "MessageCircle", label: "对话", Icon: MessageCircle }, { name: "Link", label: "链接", Icon: Link }, { name: "Code", label: "代码", Icon: Code },
|
||||
{ name: "Settings", label: "设置", Icon: Settings }, { name: "Shield", label: "盾牌", Icon: Shield }, { name: "Search", label: "搜索", Icon: Search },
|
||||
{ name: "Image", label: "图片", Icon: Image }, { name: "Camera", label: "相机", Icon: Camera }, { name: "Video", label: "视频", Icon: Video },
|
||||
{ name: "Music", label: "音乐", Icon: Music }, { name: "Play", label: "播放", Icon: Play }, { name: "Palette", label: "调色板", Icon: Palette },
|
||||
{ name: "WandSparkles", label: "魔法", Icon: WandSparkles }, { name: "Layers", label: "图层", Icon: Layers }, { name: "Circle", label: "圆形", Icon: Circle },
|
||||
{ name: "Square", label: "方形", Icon: Square }, { name: "Triangle", label: "三角形", Icon: Triangle }, { name: "Diamond", label: "菱形", Icon: Diamond },
|
||||
];
|
||||
export function canvasIcon(name?: string) { return iconCatalog.find((icon) => icon.name === name)?.Icon || Star; }
|
||||
@@ -0,0 +1,10 @@
|
||||
"use client";
|
||||
import type { NodeProps } from "@xyflow/react";
|
||||
import type { CanvasNode } from "@/lib/canvas";
|
||||
import { TransformableNode } from "./transformable-node";
|
||||
import { canvasIcon } from "./icon-catalog";
|
||||
import styles from "./canvas.module.css";
|
||||
export function IconNode(props: NodeProps<CanvasNode>) {
|
||||
const Icon = canvasIcon(props.data.icon);
|
||||
return <TransformableNode {...props}><div className={styles.iconContent} style={{ color: props.data.textStyle?.color || "#78523f", background: props.data.textStyle?.background || "transparent" }}><Icon aria-label={props.data.title} width="100%" height="100%" strokeWidth={1.5} /></div></TransformableNode>;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X } from "lucide-react";
|
||||
import { iconCatalog } from "./icon-catalog";
|
||||
import styles from "./canvas.module.css";
|
||||
export function IconPicker({ open, close, choose }: { open: boolean; close: () => void; choose: (name: string, label: string) => void }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const search = useRef<HTMLInputElement>(null);
|
||||
const reduce = useReducedMotion();
|
||||
useEffect(() => { if (open) search.current?.focus(); }, [open]);
|
||||
if (typeof document === "undefined") return null;
|
||||
return createPortal(<AnimatePresence>{open && <motion.aside key="icons" initial={{ opacity: 0, x: reduce ? 0 : -12 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: reduce ? 0 : -12 }} 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>
|
||||
<input ref={search} type="search" aria-label="搜索图标" placeholder="搜索图标…" value={query} onChange={(event) => setQuery(event.target.value)} />
|
||||
<div className={styles.iconGrid}>{iconCatalog.filter((icon) => (icon.name + icon.label).toLowerCase().includes(query.toLowerCase())).map(({ name, label, Icon }) => <button key={name} title={label} aria-label={`添加${label}图标`} onClick={() => choose(name, label)}><Icon size={24} /></button>)}</div>
|
||||
{!iconCatalog.some((icon) => (icon.name + icon.label).toLowerCase().includes(query.toLowerCase())) && <p>未找到图标</p>}
|
||||
<footer>Lucide · {iconCatalog.length} 个图标</footer>
|
||||
</motion.aside>}</AnimatePresence>, document.body);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useContext } from "react";
|
||||
import { useContext, useRef } 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 { nodeDirections, type CanvasNode } from "@/lib/canvas";
|
||||
import { textAppearance } from "./text-appearance";
|
||||
import styles from "./canvas.module.css";
|
||||
import { TransformableNode, NodeEditing } from "./transformable-node";
|
||||
export { NodeEditing } from "./transformable-node";
|
||||
@@ -15,26 +16,29 @@ const labels = { top: "上方", right: "右侧", bottom: "下方", left: "左侧
|
||||
export function MaterialNode(props: NodeProps<CanvasNode>) {
|
||||
const { id, data, selected } = props;
|
||||
const editing = useContext(NodeEditing);
|
||||
const pointerStart = useRef({ x: 0, y: 0 });
|
||||
const Icon = data.kind === "image" ? ImageIcon : data.kind === "script" ? FileText : Type;
|
||||
return (
|
||||
<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" /> : (
|
||||
<textarea className="nodrag nowheel" aria-label={data.title} placeholder={data.kind === "script" ? "写下你的脚本…" : "留住一个想法…"}
|
||||
<textarea style={textAppearance(data)} className={`${styles.cardText} nodrag nowheel`} aria-label={data.title} placeholder={data.kind === "script" ? "写下你的脚本…" : "留住一个想法…"}
|
||||
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]}
|
||||
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()}
|
||||
onClick={(event) => { event.stopPropagation(); editing.branch(id, direction); }}>
|
||||
<Plus size={16} strokeWidth={1.6} aria-hidden="true" />
|
||||
</button>
|
||||
))}
|
||||
className={`${styles.centerHandle} ${selected ? styles.plusHandle : ""}`}
|
||||
aria-label={selected ? `在${labels[direction]}添加或连接节点` : `从${labels[direction]}连接节点`} role="button" tabIndex={selected ? 0 : -1}
|
||||
onPointerDown={(event) => { pointerStart.current = { x: event.clientX, y: event.clientY }; }}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (selected && elementCapabilities(data.kind).branchable && Math.hypot(event.clientX - pointerStart.current.x, event.clientY - pointerStart.current.y) < 5)
|
||||
editing.branch(id, direction, { x: event.clientX, y: event.clientY });
|
||||
}}
|
||||
onKeyDown={(event) => { if (selected && (event.key === "Enter" || event.key === " ")) { event.preventDefault(); event.stopPropagation(); const rect = event.currentTarget.getBoundingClientRect(); editing.branch(id, direction, { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }); } }}>
|
||||
<Plus size={16} strokeWidth={1.6} aria-hidden="true" />
|
||||
</Handle>)}
|
||||
</TransformableNode>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useContext } from "react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { NodeToolbar, Position, type NodeProps } from "@xyflow/react";
|
||||
import { AlignCenter, AlignLeft, AlignRight, Bold, Italic, RotateCw, Underline } from "lucide-react";
|
||||
import type { CanvasNode } from "@/lib/canvas";
|
||||
import type { TextStyle } from "@/lib/canvas-elements";
|
||||
import { NodeEditing } from "./transformable-node";
|
||||
import styles from "./canvas.module.css";
|
||||
|
||||
export function NodeStyleToolbar({ id, data, selected }: NodeProps<CanvasNode>) {
|
||||
const reduce = useReducedMotion();
|
||||
const editing = useContext(NodeEditing);
|
||||
const style = data.textStyle || {};
|
||||
const text = data.kind === "text" || data.kind === "script" || data.kind === "plain-text";
|
||||
const patch = (value: TextStyle, fontSize?: number) => editing.style(id, value, fontSize);
|
||||
return <NodeToolbar isVisible={true} position={Position.Top} offset={34}>
|
||||
<AnimatePresence>{selected && <motion.div 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()} onKeyDown={(event) => { if (event.key !== "Escape") event.stopPropagation(); }}>
|
||||
{data.kind !== "image" && <label title="文字或图标颜色" className={styles.colorPicker}><span style={{ background: style.color || "#37352f" }} /><input aria-label="文字或图标颜色" type="color" value={style.color || "#37352f"} onChange={(event) => patch({ color: event.target.value })} /></label>}
|
||||
{text && <>
|
||||
<select aria-label="字体" value={style.fontFamily || "sans"} onChange={(event) => patch({ fontFamily: event.target.value as TextStyle["fontFamily"] })}><option value="sans">正文</option><option value="serif">衬线</option><option value="mono">等宽</option></select>
|
||||
<input aria-label="字号" title="字号" type="number" min={8} max={160} value={data.fontSize || (data.kind === "plain-text" ? 24 : 13)} onChange={(event) => { const size = Number(event.target.value); if (size >= 8 && size <= 160) patch({}, size); }} />
|
||||
{([["bold", Bold, "加粗"], ["italic", Italic, "斜体"], ["underline", Underline, "下划线"]] as const).map(([key, Icon, label]) => <button key={key} aria-label={label} aria-pressed={!!style[key]} title={label} onClick={() => patch({ [key]: !style[key] })}><Icon size={16} /></button>)}
|
||||
{([["left", AlignLeft, "左对齐"], ["center", AlignCenter, "居中"], ["right", AlignRight, "右对齐"]] as const).map(([align, Icon, label]) => <button key={align} aria-label={label} aria-pressed={(style.align || "left") === align} title={label} onClick={() => patch({ align })}><Icon size={16} /></button>)}
|
||||
<select aria-label="行距" title="行距" value={style.lineHeight || 1.5} onChange={(event) => patch({ lineHeight: Number(event.target.value) })}><option value={1}>1</option><option value={1.25}>1.25</option><option value={1.5}>1.5</option><option value={1.8}>1.8</option><option value={2}>2</option></select>
|
||||
</>}
|
||||
{data.kind !== "image" && <label className={styles.colorPicker} title="背景色"><span style={{ background: style.background && style.background !== "transparent" ? style.background : "#ffffff" }} /><input aria-label="背景色" type="color" value={style.background && style.background !== "transparent" ? style.background : "#ffffff"} onChange={(event) => patch({ background: event.target.value })} /></label>}
|
||||
{data.kind !== "image" && <button title="清除背景" aria-label="清除背景" onClick={() => patch({ background: "transparent" })}>∅</button>}
|
||||
<button data-rotate-toggle={id} title="旋转模式" aria-label="旋转模式" aria-pressed={editing.rotating === id} onClick={() => editing.rotate(editing.rotating === id ? null : id)}><RotateCw size={16} /></button>
|
||||
</motion.div>}</AnimatePresence>
|
||||
</NodeToolbar>;
|
||||
}
|
||||
@@ -1,17 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { GripVertical } from "lucide-react";
|
||||
import { useContext } from "react";
|
||||
import type { NodeProps } from "@xyflow/react";
|
||||
import type { CanvasNode } from "@/lib/canvas";
|
||||
import { NodeEditing, TransformableNode } from "./transformable-node";
|
||||
import { textAppearance } from "./text-appearance";
|
||||
import styles from "./canvas.module.css";
|
||||
|
||||
export function PlainTextNode(props: NodeProps<CanvasNode>) {
|
||||
const editing = useContext(NodeEditing);
|
||||
return (
|
||||
<TransformableNode {...props}>
|
||||
<span className={styles.textDragHandle} title="拖动移动纯文本" aria-label="拖动移动纯文本"><GripVertical size={14} /></span>
|
||||
<textarea className={`${styles.plainText} nodrag nowheel`} aria-label="纯文本" placeholder="写下文字…"
|
||||
style={{ fontSize: props.data.fontSize || 24 }} value={props.data.text} maxLength={100000}
|
||||
style={textAppearance(props.data)} value={props.data.text} maxLength={100000}
|
||||
onFocus={editing.begin} onChange={(event) => editing.edit(props.id, event.target.value)} />
|
||||
</TransformableNode>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import type { CanvasNodeData } from "@/lib/canvas-elements";
|
||||
export function textAppearance(data: CanvasNodeData): CSSProperties {
|
||||
const style = data.textStyle || {};
|
||||
return { fontSize: data.fontSize || (data.kind === "plain-text" ? 24 : 13), color: style.color || "#37352f", fontWeight: style.bold ? 700 : 400, fontStyle: style.italic ? "italic" : "normal", textDecoration: style.underline ? "underline" : "none", textAlign: style.align || "left", lineHeight: style.lineHeight || 1.5,
|
||||
fontFamily: style.fontFamily === "serif" ? 'Georgia, "Songti SC", serif' : style.fontFamily === "mono" ? 'ui-monospace, "SFMono-Regular", monospace' : "inherit", backgroundColor: style.background || "transparent" };
|
||||
}
|
||||
@@ -2,32 +2,46 @@
|
||||
|
||||
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 { Grip, RotateCw } from "lucide-react";
|
||||
import { elementCapabilities, type TextStyle } from "@/lib/canvas-elements";
|
||||
import type { CanvasNode, NodeDirection } from "@/lib/canvas";
|
||||
import { normalizeRotation, resizeFromPointer, rotationFromPointer, type NodeTransform, type ResizeDirection } from "@/lib/canvas-geometry";
|
||||
import { resizeFromPointer, rotationFromPointer, type NodeTransform } from "@/lib/canvas-geometry";
|
||||
import { NodeStyleToolbar } from "./node-style-toolbar";
|
||||
import styles from "./canvas.module.css";
|
||||
|
||||
export const NodeEditing = createContext({
|
||||
begin: () => {}, edit: (_id: string, _text: string) => {},
|
||||
branch: (_id: string, _direction: NodeDirection) => {},
|
||||
branch: (_id: string, _direction: NodeDirection, _point: XYPosition) => {},
|
||||
transform: (_id: string, _transform: NodeTransform) => {},
|
||||
style: (_id: string, _style: TextStyle, _fontSize?: number) => {},
|
||||
rotating: null as string | null, rotate: (_id: string | null) => {},
|
||||
});
|
||||
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" };
|
||||
type Gesture = { node: CanvasNode; start: XYPosition; direction: "bottom-right" | "rotate" };
|
||||
|
||||
export function TransformableNode({ id, data, selected, width, height, children, className = "" }: NodeProps<CanvasNode> & { children: ReactNode; className?: string }) {
|
||||
export function TransformableNode(props: NodeProps<CanvasNode> & { children: ReactNode; className?: string }) {
|
||||
const { id, data, selected, width, height, children, className = "" } = props;
|
||||
const editing = useContext(NodeEditing);
|
||||
const flow = useReactFlow<CanvasNode>();
|
||||
const update = useUpdateNodeInternals();
|
||||
const frame = useRef<HTMLDivElement>(null);
|
||||
const gesture = useRef<Gesture | null>(null);
|
||||
const rotation = data.rotation || 0;
|
||||
const plain = data.kind === "plain-text";
|
||||
const plain = data.kind === "plain-text" || data.kind === "icon";
|
||||
const capabilities = elementCapabilities(data.kind);
|
||||
useEffect(() => { update(id); }, [id, rotation, width, height, update]);
|
||||
const rotating = capabilities.rotatable && editing.rotating === id && selected;
|
||||
useEffect(() => { update(id); }, [id, rotation, width, height, selected, update]);
|
||||
useEffect(() => {
|
||||
if (!rotating) return;
|
||||
const outside = (event: globalThis.PointerEvent) => {
|
||||
if (!frame.current?.contains(event.target as Node) && !(event.target as Element).closest?.('[data-rotate-toggle="' + id + '"]')) editing.rotate(null);
|
||||
};
|
||||
const escape = (event: KeyboardEvent) => { if (event.key === "Escape") editing.rotate(null); };
|
||||
window.addEventListener("pointerdown", outside, true);
|
||||
window.addEventListener("keydown", escape);
|
||||
return () => { window.removeEventListener("pointerdown", outside, true); window.removeEventListener("keydown", escape); };
|
||||
}, [rotating, id, editing]);
|
||||
|
||||
function start(event: PointerEvent<HTMLButtonElement>, direction: Gesture["direction"]) {
|
||||
function start(event: PointerEvent<HTMLElement>, direction: Gesture["direction"]) {
|
||||
if (event.button !== 0) return;
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
const node = flow.getNode(id);
|
||||
@@ -36,7 +50,7 @@ export function TransformableNode({ id, data, selected, width, height, children,
|
||||
gesture.current = { node, direction, start: flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }) };
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
}
|
||||
function move(event: PointerEvent<HTMLButtonElement>) {
|
||||
function move(event: PointerEvent<HTMLElement>) {
|
||||
if (!gesture.current) return;
|
||||
event.stopPropagation();
|
||||
const current = flow.screenToFlowPosition({ x: event.clientX, y: event.clientY });
|
||||
@@ -45,38 +59,24 @@ export function TransformableNode({ id, data, selected, width, height, children,
|
||||
? { 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;
|
||||
function finish(event: PointerEvent<HTMLElement>) {
|
||||
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)` }}>
|
||||
return <>
|
||||
<NodeStyleToolbar {...props} />
|
||||
<div ref={frame} className={`${styles.transformFrame} ${selected ? styles.selectedFrame : ""} ${plain ? styles.plainFrame : ""} ${rotating ? styles.rotatingFrame + " nodrag nopan" : ""} ${className}`}
|
||||
onPointerDownCapture={rotating ? (event) => start(event, "rotate") : undefined}
|
||||
onPointerMove={move} onPointerUp={finish} onPointerCancel={finish}
|
||||
onLostPointerCapture={() => { gesture.current = null; }}
|
||||
style={{ width: width || (data.kind === "icon" ? 80 : plain ? 240 : 260), height: height || (data.kind === "icon" ? 80 : 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>
|
||||
</>}
|
||||
</>}
|
||||
{capabilities.resizable && <button type="button" className={`${styles.cornerResize} nodrag nopan`} tabIndex={selected && !rotating ? 0 : -1}
|
||||
aria-hidden={!selected || rotating} aria-label="拖动右下角调整大小" title="拖动调整大小"
|
||||
onPointerDown={(event) => start(event, "bottom-right")} onPointerMove={move} onPointerUp={finish} onPointerCancel={finish}>
|
||||
<Grip size={14} />
|
||||
</button>}
|
||||
{rotating && <span className={styles.rotationHint}><RotateCw size={12} /> {Math.round(rotation)}° · 拖动旋转,点击外部退出</span>}
|
||||
</div>
|
||||
);
|
||||
</>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user