Bind rendered editing callbacks to captured element lifetimes
This commit is contained in:
@@ -453,3 +453,13 @@ A real three-node fixture verifies that editing one node through ACK leaves an
|
||||
unrelated node and nonincident edge's render function counts unchanged. Connected
|
||||
edges may legitimately remeasure when a custom renderer's data changes; this does
|
||||
not assume arbitrary custom Handle layouts stay fixed.
|
||||
|
||||
Node projections now carry readonly `__headlessTarget` display metadata with
|
||||
id/incarnation/ownerCanvasId/display canvasId, written from model identity after
|
||||
user data is projected. It is not a persisted element field. The application binds
|
||||
legacy editing actions to that captured target through `useScopedNodeEditing`;
|
||||
old incarnation/owner/placement and detached/closed-view callbacks do not write to
|
||||
current records. Frame titles, node titles, text/style/branch/transform controls
|
||||
use the scoped bridge. Legacy non-headless contexts retain their old interface.
|
||||
Text grouping now records incarnation and drops obsolete groups. This is separate
|
||||
from the public explicit-Target action API and gesture lease tokens.
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useContext, useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { CanvasNodeData } from "@/lib/canvas-elements";
|
||||
import { NodeEditing } from "./transformable-node";
|
||||
import {useScopedNodeEditing} from "./node-editing";
|
||||
import { textAppearance } from "./text-appearance";
|
||||
import styles from "./canvas.module.css";
|
||||
|
||||
/** Single clicks select; native double-click timing controls entry into editing. */
|
||||
export function CanvasTextEditor({ id, data, selected, plain = false }: { id: string; data: CanvasNodeData; selected: boolean; plain?: boolean }) {
|
||||
const actions = useContext(NodeEditing);
|
||||
const actions = useScopedNodeEditing(id,data.__headlessTarget);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const textarea = useRef<HTMLTextAreaElement>(null);
|
||||
const press = useRef({ x: 0, y: 0, moved: false });
|
||||
|
||||
@@ -5,18 +5,19 @@ import { FoldVertical, UnfoldVertical, ExternalLink } from "lucide-react";
|
||||
import { Handle, NodeToolbar, Position, useViewport, type NodeProps } from "@xyflow/react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import type { CanvasNode } from "@/lib/canvas";
|
||||
import { NodeEditing, TransformableNode, ResizeToggle } from "./transformable-node";
|
||||
import { TransformableNode, ResizeToggle } from "./transformable-node";
|
||||
import { FrameFeedback } from "./frame-feedback";
|
||||
import { FrameLabelMenu } from "./frame-label-menu";
|
||||
import { ContentHandles } from "./content-handles";
|
||||
import {useCanvasOverlay} from "./canvas-overlays";
|
||||
import {useScopedNodeEditing} from "./node-editing";
|
||||
import styles from "./canvas.module.css";
|
||||
|
||||
export function FrameNode(props: NodeProps<CanvasNode> & { directAnchors?: boolean }) {
|
||||
const { directAnchors = false, ...nodeProps } = props;
|
||||
const { id, data, selected } = nodeProps;
|
||||
const overlay=useCanvasOverlay(id,"frame-settings");
|
||||
const editing = useContext(NodeEditing);
|
||||
const editing = useScopedNodeEditing(id,data.__headlessTarget);
|
||||
const feedback = useContext(FrameFeedback);
|
||||
const incoming = feedback.drops.filter((drop) => drop.to === id && drop.from !== id).length;
|
||||
const outgoing = feedback.drops.filter((drop) => drop.from === id && drop.to !== id).length;
|
||||
@@ -39,7 +40,7 @@ export function FrameNode(props: NodeProps<CanvasNode> & { directAnchors?: boole
|
||||
<input className={styles.frameNameInput} aria-label="分区名称" value={title} maxLength={300} onChange={(event) => setTitle(event.target.value)} onBlur={() => { if (cancelBlur.current) cancelBlur.current = false; else saveTitle(); }}
|
||||
onKeyDown={(event) => { if (event.key === "Enter" && !event.nativeEvent.isComposing) { event.currentTarget.blur(); } if (event.key === "Escape") { cancelBlur.current = true; setTitle(data.title); event.currentTarget.blur(); } }} />
|
||||
<FrameLabelMenu value={mode} change={(labelMode) => editing.frame(id, { labelMode })} />
|
||||
<ResizeToggle id={id} />
|
||||
<ResizeToggle id={id} renderedTarget={data.__headlessTarget} />
|
||||
{data.subgraph && <><button type="button" aria-label={data.presentation?.collapsed ? "展开子画布" : "收起子画布"} title={data.presentation?.collapsed ? "展开子画布" : "收起子画布"} onClick={() => {
|
||||
const collapsed = !data.presentation?.collapsed;
|
||||
const currentSize = { width: (props.width || 640) / (data.viewScale || 1), height: (props.height || 400) / (data.viewScale || 1) };
|
||||
|
||||
@@ -67,7 +67,8 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
||||
projectionCache.current ??= createProjectionCache();
|
||||
const geometryCache=useRef<ReturnType<typeof createGeometryProjectionCache>|null>(null);geometryCache.current??=createGeometryProjectionCache();
|
||||
const measurements = useRef(new Map<Id, { width: number; height: number; measured: { width: number; height: number } }>());
|
||||
const imageInput = useRef<HTMLInputElement>(null), groups = useRef(new Map<Id, Id>());
|
||||
const imageInput = useRef<HTMLInputElement>(null), groups = useRef(new Map<Id,{incarnation:number;groupId:Id}>());
|
||||
useEffect(()=>{for(const [id,group]of groups.current)if(root.document.elements.get(id)?.incarnation!==group.incarnation)groups.current.delete(id);},[root.document]);
|
||||
const catalog=useMemo(()=>loading?createLoadingRendererCatalog(engine.editor.registry,renderers.catalog):renderers.catalog,[engine,renderers,!!loading]);
|
||||
const projection = useMemo(() => {
|
||||
const result = projectCanvas(root.document, view.canvasId, engine.editor.registry, view.selection, engine.sessions.effectiveGeometry, sessions.connectionGesture,catalog,{queries:engine.editor.getQueries(root.document),geometry:geometryCache.current!,owner:engine});
|
||||
@@ -190,9 +191,9 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
||||
const resizing = [...modes].find(([, mode]) => mode === 'resize')?.[0] ?? null;
|
||||
const imagePreviews = useMemo<ContextType<typeof ImagePreviews>>(() => ({sources,loaded:()=>{}}),[sources]);
|
||||
const nodeEditing = useMemo<ContextType<typeof NodeEditing>>(() => ({ workspace: board.workspace_id, schema: 2,
|
||||
begin: id => { if (id) { groups.current.set(id, crypto.randomUUID()); engine.sessions.mode(viewId, id, 'editing'); } },
|
||||
begin: id => { if (id) { groups.current.set(id,{incarnation:target(id).incarnation,groupId:crypto.randomUUID()}); engine.sessions.mode(viewId, id, 'editing'); } },
|
||||
endEditing: id => { groups.current.delete(id); engine.sessions.mode(viewId, id, null); },
|
||||
select: id => engine.sessions.select(viewId, [id]), edit: (id, text) => data(id, { text }, groups.current.get(id)), rename: (id, title) => data(id, { title }),
|
||||
select: id => engine.sessions.select(viewId, [id]), edit: (id, text) => data(id,{text},groups.current.get(id)?.incarnation===engine.getSnapshot().document.elements.get(id)?.incarnation?groups.current.get(id)?.groupId:undefined), rename: (id, title) => data(id, { title }),
|
||||
beginTransform, transform, finishTransform: () => { if (engine.sessions.getSnapshot().gesture?.viewId === viewId) report(engine.sessions.commit(viewId)); }, cancelTransform: () => { if (engine.sessions.getSnapshot().gesture?.viewId === viewId) engine.sessions.cancel('pointer_cancel'); },
|
||||
style: (id, textStyle, fontSize) => data(id, json({ textStyle, ...(fontSize !== undefined ? { fontSize } : {}) })), frame: (id, patch) => data(id, json(patch)),
|
||||
rotating, rotate: id => { for (const [key, mode] of engine.sessions.getSnapshot().views.get(viewId)?.modes ?? []) if (mode === 'rotate') engine.sessions.mode(viewId, key, null); if (id) engine.sessions.mode(viewId, id, 'rotate'); },
|
||||
|
||||
@@ -2,15 +2,16 @@
|
||||
import { CanvasImage } from "./canvas-image";
|
||||
import { managedImageSource } from "@/lib/canvas-v2";
|
||||
|
||||
import { useContext, useRef } from "react";
|
||||
import { useRef } from "react";
|
||||
import { Handle, Position, useViewport, type NodeProps } from "@xyflow/react";
|
||||
import { FileText, Image as ImageIcon, Type } from "lucide-react";
|
||||
import { elementCapabilities } from "@/lib/canvas-elements";
|
||||
import { nodeDirections, type CanvasNode } from "@/lib/canvas";
|
||||
import { NodeTitle } from "./node-title";
|
||||
import { CanvasTextEditor } from "./canvas-text-editor";
|
||||
import {useScopedNodeEditing} from "./node-editing";
|
||||
import styles from "./canvas.module.css";
|
||||
import { TransformableNode, NodeEditing } from "./transformable-node";
|
||||
import { TransformableNode } from "./transformable-node";
|
||||
export { NodeEditing } from "./transformable-node";
|
||||
|
||||
const positions = { top: Position.Top, right: Position.Right, bottom: Position.Bottom, left: Position.Left };
|
||||
@@ -18,14 +19,14 @@ const labels = { top: "上方", right: "右侧", bottom: "下方", left: "左侧
|
||||
|
||||
export function MaterialNode(props: NodeProps<CanvasNode>) {
|
||||
const { id, data, selected } = props;
|
||||
const editing = useContext(NodeEditing);
|
||||
const editing = useScopedNodeEditing(id,data.__headlessTarget);
|
||||
const { zoom } = useViewport();
|
||||
const preview = (props.width || 260) * zoom * (typeof window === "undefined" ? 1 : window.devicePixelRatio || 1) <= 1024;
|
||||
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} /><NodeTitle id={id} title={data.title} selected={selected} /></header>
|
||||
<header className={styles.nodeHeading}><Icon size={13} /><NodeTitle id={id} renderedTarget={data.__headlessTarget} title={data.title} selected={selected} /></header>
|
||||
<Handle type="target" position={Position.Left} className={styles.legacyHandle} isConnectable={false} />
|
||||
{data.kind === "image" ? <CanvasImage id={id} className={styles.image} src={managedImageSource(data.src, preview)} title={data.title} /> : (
|
||||
<CanvasTextEditor id={id} data={data} selected={selected} />
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
import {createContext,useContext,useMemo} from 'react';
|
||||
import type {XYPosition} from '@xyflow/react';
|
||||
import type {NodeDirection} from '@/lib/canvas';
|
||||
import type {TextStyle} from '@/lib/canvas-elements';
|
||||
import type {NodeTransform} from '@/lib/canvas-geometry';
|
||||
import type {EditingLease} from './editing-lease';
|
||||
import type {RenderedTarget} from '@/lib/canvas-bindings/compat-types';
|
||||
import {CanvasOverlays} from './canvas-overlays';
|
||||
const defaultEditing = {
|
||||
beginTransform: undefined as undefined | ((id: string) => boolean | void | EditingLease<NodeTransform>), finishTransform: undefined as undefined | (() => void), cancelTransform: undefined as undefined | (() => void),
|
||||
endEditing: undefined as undefined | ((id: string) => void),
|
||||
workspace: "", schema: 1 as 1 | 2,
|
||||
begin: (_id?: string) => {}, select: (_id: string) => {}, edit: (_id: string, _text: string) => {},
|
||||
branch: (_id: string, _direction: NodeDirection, _point: XYPosition) => {},
|
||||
transform: (_id: string, _transform: NodeTransform) => {},
|
||||
style: (_id: string, _style: TextStyle, _fontSize?: number) => {},
|
||||
rename: (_id: string, _title: string) => {},
|
||||
frame: (_id: string, _patch: { title?: string; labelMode?: import("@/lib/canvas-elements").FrameLabelMode; presentation?: import("@/lib/canvas-v2").FramePresentation }) => {},
|
||||
rotating: null as string | null, rotate: (_id: string | null) => {},
|
||||
resizing: null as string | null, resize: (_id: string | null) => {},
|
||||
};
|
||||
type GestureHooks = 'beginTransform' | 'finishTransform' | 'cancelTransform' | 'endEditing';
|
||||
export const NodeEditing = createContext<Omit<typeof defaultEditing, GestureHooks> & Partial<Pick<typeof defaultEditing, GestureHooks>>>(defaultEditing);
|
||||
|
||||
/** Bind legacy renderer actions to the lifetime and placement that rendered them. */
|
||||
export function useScopedNodeEditing(id:string,rendered?:RenderedTarget){
|
||||
const editing=useContext(NodeEditing),scope=useContext(CanvasOverlays),incarnation=rendered?.id===id?rendered.incarnation:undefined,owner=rendered?.ownerCanvasId,canvas=rendered?.canvasId;
|
||||
return useMemo(()=>{if(!scope)return editing;
|
||||
const current=(candidate:string|null|undefined=id)=>{const durability=scope.engine.getDurability(),view=scope.engine.sessions.getSnapshot().views.get(scope.index.viewId),record=scope.engine.getSnapshot().document.elements.get(id);return !durability.closing&&!durability.closed&&!!view&&view.canvasId===canvas&&incarnation!==undefined&&record?.incarnation===incarnation&&record.ownerCanvasId===owner&&(candidate===undefined||candidate===null||candidate===id);};
|
||||
const ownGesture=()=>scope.engine.sessions.getSnapshot().gesture?.viewId===scope.index.viewId&&scope.engine.sessions.getSnapshot().gesture?.targets.some(target=>target.id===id&&target.incarnation===incarnation);
|
||||
const scoped:typeof editing={...editing,
|
||||
begin:candidate=>{if(current(candidate))editing.begin(candidate??id);},select:candidate=>{if(current(candidate))editing.select(candidate);},
|
||||
edit:(candidate,text)=>{if(current(candidate))editing.edit(candidate,text);},rename:(candidate,title)=>{if(current(candidate))editing.rename(candidate,title);},
|
||||
style:(candidate,style,size)=>{if(current(candidate))editing.style(candidate,style,size);},frame:(candidate,patch)=>{if(current(candidate))editing.frame(candidate,patch);},
|
||||
transform:(candidate,change)=>{if(current(candidate))editing.transform(candidate,change);},branch:(candidate,direction,point)=>{if(current(candidate))editing.branch(candidate,direction,point);},
|
||||
rotate:candidate=>{if(current(candidate)&&(candidate!==null||scope.engine.sessions.getSnapshot().views.get(scope.index.viewId)?.modes.get(id)==='rotate'))editing.rotate(candidate);},resize:candidate=>{if(current(candidate)&&(candidate!==null||scope.engine.sessions.getSnapshot().views.get(scope.index.viewId)?.modes.get(id)==='resize'))editing.resize(candidate);},
|
||||
};
|
||||
if(editing.beginTransform)scoped.beginTransform=candidate=>current(candidate)?editing.beginTransform!(candidate):false;
|
||||
if(editing.endEditing)scoped.endEditing=candidate=>{if(current(candidate))editing.endEditing!(candidate);};
|
||||
if(editing.finishTransform)scoped.finishTransform=()=>{if(current()&&ownGesture())editing.finishTransform!();};
|
||||
if(editing.cancelTransform)scoped.cancelTransform=()=>{if(current()&&ownGesture())editing.cancelTransform!();};
|
||||
return scoped;
|
||||
},[editing,scope,id,incarnation,owner,canvas]);
|
||||
}
|
||||
@@ -1,19 +1,19 @@
|
||||
"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, ResizeToggle } from "./transformable-node";
|
||||
import { ResizeToggle } from "./transformable-node";
|
||||
import {useCanvasOverlay} from "./canvas-overlays";
|
||||
import {useScopedNodeEditing} from "./node-editing";
|
||||
import styles from "./canvas.module.css";
|
||||
|
||||
export function NodeStyleToolbar({ id, data, selected }: NodeProps<CanvasNode>) {
|
||||
const overlay=useCanvasOverlay(id,"node-style");
|
||||
const reduce = useReducedMotion();
|
||||
const editing = useContext(NodeEditing);
|
||||
const editing = useScopedNodeEditing(id,data.__headlessTarget);
|
||||
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);
|
||||
@@ -31,7 +31,7 @@ export function NodeStyleToolbar({ id, data, selected }: NodeProps<CanvasNode>)
|
||||
</>}
|
||||
{!(["image", "plain-image"].includes(data.kind)) && <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>}
|
||||
{!(["image", "plain-image"].includes(data.kind)) && <button title="清除背景" aria-label="清除背景" onClick={() => patch({ background: "transparent" })}>∅</button>}
|
||||
{data.kind !== "icon" && <ResizeToggle id={id} />}
|
||||
{data.kind !== "icon" && <ResizeToggle id={id} renderedTarget={data.__headlessTarget} />}
|
||||
<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,9 +1,9 @@
|
||||
"use client";
|
||||
import { useContext, useEffect, useRef, useState } from "react";
|
||||
import { NodeEditing } from "./transformable-node";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {useScopedNodeEditing} from "./node-editing";
|
||||
import styles from "./canvas.module.css";
|
||||
export function NodeTitle({ id, title, selected }: { id: string; title: string; selected: boolean }) {
|
||||
const actions = useContext(NodeEditing);
|
||||
export function NodeTitle({ id, title, selected,renderedTarget }: { id: string; title: string; selected: boolean;renderedTarget?:import("@/lib/canvas-bindings/compat-types").RenderedTarget }) {
|
||||
const actions = useScopedNodeEditing(id,renderedTarget);
|
||||
const [editing, setEditing] = useState(false), [value, setValue] = useState(title);
|
||||
const input = useRef<HTMLInputElement>(null), cancelled = useRef(false);
|
||||
useEffect(() => { if (!editing) setValue(title); }, [title, editing]);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useEffect, useRef, type PointerEvent, type ReactNode } from "react";
|
||||
import { useEffect, useRef, type PointerEvent, type ReactNode } from "react";
|
||||
import { useReactFlow, useUpdateNodeInternals, useViewport, type NodeProps, type XYPosition } from "@xyflow/react";
|
||||
import { Grip, Maximize2, RotateCw } from "lucide-react";
|
||||
import { elementCapabilities, type TextStyle } from "@/lib/canvas-elements";
|
||||
import type { CanvasNode, NodeDirection } from "@/lib/canvas";
|
||||
import { elementCapabilities } from "@/lib/canvas-elements";
|
||||
import type { CanvasNode } from "@/lib/canvas";
|
||||
import { resizeFromPointer, rotationFromPointer, type NodeTransform, type ResizeDirection } from "@/lib/canvas-geometry";
|
||||
import type { EditingLease } from "./editing-lease";
|
||||
import { NodeStyleToolbar } from "./node-style-toolbar";
|
||||
@@ -13,26 +13,13 @@ import { ContentHandles } from "./content-handles";
|
||||
import {ownsPointerCapture,acquirePointerCapture,releasePointerCapture} from "@/lib/canvas-bindings/pointer-capture";
|
||||
import styles from "./canvas.module.css";
|
||||
|
||||
const defaultEditing = {
|
||||
beginTransform: undefined as undefined | ((id: string) => boolean | void | EditingLease<NodeTransform>), finishTransform: undefined as undefined | (() => void), cancelTransform: undefined as undefined | (() => void),
|
||||
endEditing: undefined as undefined | ((id: string) => void),
|
||||
workspace: "", schema: 1 as 1 | 2,
|
||||
begin: (_id?: string) => {}, select: (_id: string) => {}, edit: (_id: string, _text: string) => {},
|
||||
branch: (_id: string, _direction: NodeDirection, _point: XYPosition) => {},
|
||||
transform: (_id: string, _transform: NodeTransform) => {},
|
||||
style: (_id: string, _style: TextStyle, _fontSize?: number) => {},
|
||||
rename: (_id: string, _title: string) => {},
|
||||
frame: (_id: string, _patch: { title?: string; labelMode?: import("@/lib/canvas-elements").FrameLabelMode; presentation?: import("@/lib/canvas-v2").FramePresentation }) => {},
|
||||
rotating: null as string | null, rotate: (_id: string | null) => {},
|
||||
resizing: null as string | null, resize: (_id: string | null) => {},
|
||||
};
|
||||
type GestureHooks = 'beginTransform' | 'finishTransform' | 'cancelTransform' | 'endEditing';
|
||||
export const NodeEditing = createContext<Omit<typeof defaultEditing, GestureHooks> & Partial<Pick<typeof defaultEditing, GestureHooks>>>(defaultEditing);
|
||||
export {NodeEditing} from "./node-editing";
|
||||
import {useScopedNodeEditing} from "./node-editing";
|
||||
type Gesture = { lease?: EditingLease<NodeTransform>; node: CanvasNode; start: XYPosition; direction: ResizeDirection | "rotate"; capture: HTMLElement; pointerId: number };
|
||||
|
||||
export function TransformableNode(props: NodeProps<CanvasNode> & { children: ReactNode; className?: string; hideStyleToolbar?: boolean }) {
|
||||
const { id, data, selected, width, height, children, className = "" } = props;
|
||||
const editing = useContext(NodeEditing);
|
||||
const editing = useScopedNodeEditing(id,data.__headlessTarget);
|
||||
const flow = useReactFlow<CanvasNode>();
|
||||
const update = useUpdateNodeInternals();
|
||||
const { zoom } = useViewport();
|
||||
@@ -129,7 +116,7 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
|
||||
</>;
|
||||
}
|
||||
|
||||
export function ResizeToggle({ id }: { id: string }) {
|
||||
const editing = useContext(NodeEditing);
|
||||
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>;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ export type FramePresentation = { collapsed?: boolean; contentScale?: number; ex
|
||||
export type ElementKind = "text" | "image" | "script" | "plain-text" | "icon" | "frame" | "plain-image";
|
||||
export type FrameLabelMode = "hidden" | "fixed" | "scaled";
|
||||
export type TextStyle = { color?: string; background?: string; bold?: boolean; italic?: boolean; underline?: boolean; align?: "left" | "center" | "right"; fontFamily?: "sans" | "serif" | "mono"; lineHeight?: number };
|
||||
export type RenderedTarget={readonly id:string;readonly incarnation:number;readonly ownerCanvasId:string;readonly canvasId:string};
|
||||
export type CanvasNodeData = {
|
||||
__headlessTarget?:RenderedTarget;
|
||||
kind: ElementKind;
|
||||
title: string;
|
||||
text: string;
|
||||
|
||||
@@ -37,7 +37,7 @@ export function projectCanvas(document: Document, canvasId: Id, registry: Regist
|
||||
const child = queries?queries.relations.childCanvasByEntry.get(element.id):childByEntry.get(element.id), data = element.data;
|
||||
const projected: ProjectionNode = { id: element.id, type, position: projectedGeometry?{...projectedGeometry.position}:{ x: center!.x - width / 2, y: center!.y - height / 2 }, width, height, selected: selected.has(element.id), draggable: known && registry.supports(element, 'geometry.move'), zIndex: element.componentId === 'frame' ? depth - 100 : depth * 2,
|
||||
style: { width, height, ...(element.componentId === 'frame'&&!fallback&&renderer?.projection==='compat-v1' ? { pointerEvents: 'none' as const } : {}) },
|
||||
data: { ...(fallback?{}:data), kind: element.componentId as CanvasNodeData['kind'], title: typeof data.title === 'string' ? data.title : element.componentId, text: !fallback&&typeof data.text === 'string' ? data.text : '', __headlessFallback:fallback, __headlessRecord:!fallback&&renderer?.projection==='record-v1'?element:undefined,__headlessTransform:!fallback&&renderer?.projection==='record-v1'?transform:undefined, rotation: projectedGeometry?.rotation??(g.rotation + transform.rotation) % 360, viewScale: transform.scale*(!fallback?unitScale(data.presentation):1), ...(child ? { subgraph: { boardId: child } } : {}) } as CanvasNodeData,
|
||||
data: { ...(fallback?{}:data), __headlessTarget:Object.freeze({id:element.id,incarnation:element.incarnation,ownerCanvasId:element.ownerCanvasId,canvasId:canvasId}), kind: element.componentId as CanvasNodeData['kind'], title: typeof data.title === 'string' ? data.title : element.componentId, text: !fallback&&typeof data.text === 'string' ? data.text : '', __headlessFallback:fallback, __headlessRecord:!fallback&&renderer?.projection==='record-v1'?element:undefined,__headlessTransform:!fallback&&renderer?.projection==='record-v1'?transform:undefined, rotation: projectedGeometry?.rotation??(g.rotation + transform.rotation) % 360, viewScale: transform.scale*(!fallback?unitScale(data.presentation):1), ...(child ? { subgraph: { boardId: child } } : {}) } as CanvasNodeData,
|
||||
};
|
||||
nodes.push(projected); visible.add(element.id);
|
||||
if (child) {
|
||||
|
||||
@@ -121,3 +121,4 @@ test('known components without a matching renderer retain registered geometry ca
|
||||
const {defineComponent,movable}=await import('../../src/lib/canvas-core/index.ts'),custom=defineComponent({id:'plugin:known',dataVersion:1,structure:'node',validate:()=>true,features:[movable()]}),r=createRegistry([...builtinComponents(),custom]),source=createDocument('project',[{id:'root',parent:null}],[node('custom','root','plugin:known')]);
|
||||
const projected=projectCanvas(source,'root',r,new Set(),()=>undefined).nodes[0];assert.equal(projected.type,'unsupported');assert.equal(projected.draggable,true);assert.equal(projected.data.__headlessFallback.reason,'renderer-unavailable');const editor=createEditor({document:source,registry:r});assert.equal(editor.dispatch({type:'element.geometry',target:{id:'custom',incarnation:1},capability:'geometry.move',patch:{x:20}}).status,'applied');
|
||||
});
|
||||
test('rendered targets carry captured incarnation/owner/placement and overwrite spoofed data without mutating the model',()=>{const projection=projectCanvas(document,'root',registry,new Set(),()=>undefined),inside=projection.nodes.find(node=>node.id==='inside');assert.deepEqual(inside.data.__headlessTarget,{id:'inside',incarnation:1,ownerCanvasId:'child',canvasId:'root'});assert.ok(Object.isFrozen(inside.data.__headlessTarget));const raw={...node('safe','root'),incarnation:2,data:{title:'safe',text:'',__headlessTarget:{id:'spoof',incarnation:99,ownerCanvasId:'elsewhere',canvasId:'elsewhere'}}},source=createDocument('p',[{id:'root',parent:null}],[raw]),before=JSON.stringify([...source.elements]),drawn=projectCanvas(source,'root',registry,new Set(),()=>undefined).nodes[0];assert.deepEqual(drawn.data.__headlessTarget,{id:'safe',incarnation:2,ownerCanvasId:'root',canvasId:'root'});assert.equal(JSON.stringify([...source.elements]),before);});
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
import {useScopedNodeEditing} from '@/components/canvas/node-editing';
|
||||
import { Handle, Position, BaseEdge, getBezierPath, type NodeProps, type EdgeProps } from '@xyflow/react';
|
||||
import { defineComponent, editable, movable } from '@/lib/canvas-core/index';
|
||||
import type {ReactFlowProjectionNode as ProjectionNode,ReactFlowProjectionEdge as ProjectionEdge} from '@/lib/canvas-bindings/react-flow';
|
||||
import type { CanvasRuntimeExtension } from '@/lib/canvas-bindings/react-flow';
|
||||
import { useElement, useCanvasRendererCommands } from '@/lib/canvas-bindings/react-flow';
|
||||
import { useCanvasBinding,useElement, useCanvasRendererCommands } from '@/lib/canvas-bindings/react-flow';
|
||||
function rendered(id:string){const host=globalThis as typeof globalThis&{__fixtureRenders?:Record<string,number>};const counts=host.__fixtureRenders??={};counts[id]=(counts[id]??0)+1;}
|
||||
function Meter({data}:NodeProps<ProjectionNode>){
|
||||
const commands=useCanvasRendererCommands(),lookup=useElement(data.__headlessRecord!),record=lookup.status==='present'?lookup.element:data.__headlessRecord!;rendered(record.id);
|
||||
const commands=useCanvasRendererCommands(),lookup=useElement(data.__headlessRecord!),record=lookup.status==='present'?lookup.element:data.__headlessRecord!;rendered(record.id);const legacy=useScopedNodeEditing(record.id,data.__headlessTarget),unbound=useScopedNodeEditing(record.id),binding=useCanvasBinding();const host=globalThis as typeof globalThis&{__fixtureEngine?:typeof binding.engine;__fixtureLegacy?:Record<string,typeof legacy>;__fixtureUnbound?:Record<string,typeof legacy>};host.__fixtureEngine=binding.engine;(host.__fixtureLegacy??={})[record.id]=legacy;(host.__fixtureUnbound??={})[record.id]=unbound;
|
||||
return <div data-renderer-fixture="meter" style={{width:'100%',height:'100%',background:'white',border:'1px solid #ded7ce',padding:16}}>
|
||||
<span>Metric {String(record.data.metric)}</span><button className="nodrag" aria-label={`Increase ${record.id}`} onClick={event=>{event.stopPropagation();commands.transaction(tx=>{tx.dispatch({type:'element.data',target:{id:record.id,incarnation:record.incarnation},patch:{metric:Number(record.data.metric)+1}});tx.dispatch({type:'element.data',target:{id:record.id,incarnation:record.incarnation},patch:{raw:{retained:true,edited:true}}});},{label:'Increase metric'});}}>Increase</button>
|
||||
<Handle type="source" id="custom-out" position={Position.Right}/><Handle type="source" id="custom-in" position={Position.Left}/>
|
||||
|
||||
@@ -374,6 +374,15 @@ try {
|
||||
documents.set(extensionRoot,createDocument(extensionRoot,[{id:extensionRoot,parent:null}],[meter,meterTwo,meterThree,meterEdge,{...meterEdge,id:'unrelated-meter-edge',source:{elementId:'meter-two',portId:'custom-out'},target:{elementId:'meter-three',portId:'custom-in'}},{...meterEdge,id:'meter-builtin-edge',componentId:'connection',data:{},path:null}]));versions.set(extensionRoot,1);boards.push({id:extensionRoot,workspace_id:workspace,kind:'board',version:1,title:'Extension board'});await call('Page.navigate',{url:`http://127.0.0.1:${port}/dashboard/${workspace}/${extensionRoot}`});await until(`document.querySelectorAll('[data-renderer-fixture="meter"]').length===3&&document.querySelectorAll('[data-renderer-fixture="metric-edge"]').length===2`,'host-installed custom node/edge renderers did not mount');await until(`document.querySelectorAll('.react-flow__edge').length===3`,'built-in edge did not connect custom renderer ports');assert.equal(await evaluate(`document.querySelectorAll('[aria-label="暂不支持编辑的元素"]').length`),0);await until(`[...document.querySelectorAll('[role="status"]')].some(item=>item.textContent.trim()==='已同步')`,'extension baseline did not settle');await pause(200);await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');const rendersBefore=await evaluate('({...window.__fixtureRenders})');await click('Increase meter-one');for(let i=0;i<100&&documents.get(extensionRoot).elements.get('meter-one').data.metric!==4;i++)await pause(20);assert.deepEqual(documents.get(extensionRoot).elements.get('meter-one').data,{metric:4,raw:{retained:true,edited:true}});assert.deepEqual(documents.get(extensionRoot).elements.get('meter-edge'),meterEdge);await until(`[...document.querySelectorAll('[role="status"]')].some(item=>item.textContent.trim()==='已同步')`,'extension edit/ACK did not settle');await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');const rendersAfter=await evaluate('({...window.__fixtureRenders})');assert.ok(rendersAfter['meter-one']>rendersBefore['meter-one'],'edited custom node did not render');assert.equal(rendersAfter['meter-two'],rendersBefore['meter-two'],'unrelated custom node rendered during edit/ACK');assert.equal(rendersAfter['unrelated-meter-edge'],rendersBefore['unrelated-meter-edge'],'unrelated custom edge rendered during edit/ACK');console.log('PASS actual registered renderer updates the edited node while unrelated node/edge render counts remain unchanged through ACK');
|
||||
await click('撤销');for(let i=0;i<100&&documents.get(extensionRoot).elements.get('meter-one').data.metric!==3;i++)await pause(20);assert.equal(documents.get(extensionRoot).elements.get('meter-one').data.metric,3);assert.deepEqual(documents.get(extensionRoot).elements.get('meter-one').data.raw,{retained:true});await click('重做');for(let i=0;i<100&&documents.get(extensionRoot).elements.get('meter-one').data.metric!==4;i++)await pause(20);assert.equal(documents.get(extensionRoot).elements.get('meter-one').data.metric,4);await call('Page.reload');await until(`document.querySelector('[data-id="meter-one"] [data-renderer-fixture="meter"]')?.textContent.includes('Metric 4')&&!!document.querySelector('[data-renderer-fixture="metric-edge"]')`,'registered renderer edits did not recover');
|
||||
console.log('PASS Host-installed custom model/node/edge definitions render without core type switches; command-backed edits preserve raw fields and recover through undo/redo/reload');
|
||||
assert.equal(await evaluate(`(()=>{const engine=window.__fixtureEngine,before=engine.getSnapshot();window.__fixtureUnbound['meter-one'].rename('meter-one','unbound callback');return engine.getSnapshot()===before;})()`),true,'headless legacy editing without a rendered target was allowed');
|
||||
const staleEdit=await evaluate(`(()=>{const engine=window.__fixtureEngine,old=window.__fixtureLegacy['meter-one'],target=engine.getSnapshot().document.elements.get('meter-one');window.__fixtureStaleLegacy=old;engine.editor.dispatch({type:'element.remove',target:{id:target.id,incarnation:target.incarnation}});engine.editor.history.undo();const restored=engine.getSnapshot();old.rename('meter-one','stale callback');return{unchanged:engine.getSnapshot()===restored,incarnation:restored.document.elements.get('meter-one').incarnation};})()`);assert.equal(staleEdit.incarnation,2);assert.equal(staleEdit.unchanged,true,'old rendered edit callback modified a restored incarnation');
|
||||
console.log('PASS old rendered edit callbacks cannot modify a restored incarnation');
|
||||
await until(`window.__fixtureLegacy['meter-one']!==window.__fixtureStaleLegacy&&window.__fixtureEngine.getSnapshot().document.elements.get('meter-one').incarnation===2`,'restored renderer did not bind a fresh edit target');await evaluate(`window.__fixtureLegacy['meter-one'].rename('meter-one','fresh callback')`);for(let i=0;i<200&&documents.get(extensionRoot).elements.get('meter-one')?.data.title!=='fresh callback';i++)await pause(20);assert.equal(documents.get(extensionRoot).elements.get('meter-one').data.title,'fresh callback');
|
||||
assert.equal(await evaluate(`(()=>{const engine=window.__fixtureEngine,old=window.__fixtureLegacy['meter-one'],other=window.__fixtureLegacy['meter-two'];old.resize('meter-one');other.resize('meter-two');old.resize(null);const view=[...engine.sessions.getSnapshot().views.values()][0],retained=view.modes.get('meter-two')==='resize';other.resize(null);return retained;})()`),true,'old target mode cleanup canceled another target');
|
||||
await evaluate(`(()=>{window.__detachedEngine=window.__fixtureEngine;window.__detachedEditing=window.__fixtureLegacy['meter-one'];[...document.querySelectorAll('a')].find(link=>link.pathname.endsWith('/${other}')).click();})()`);await until(`location.pathname.endsWith('${other}')&&!!document.querySelector('[data-headless-canvas]')`,'renderer view did not detach on navigation');assert.equal(await evaluate(`(()=>{const before=window.__detachedEngine.getSnapshot();window.__detachedEditing.rename('meter-one','detached callback');return window.__detachedEngine.getSnapshot()===before;})()`),true,'detached renderer wrote into its retained project');
|
||||
console.log('PASS restored renderers can edit with fresh bindings and detached views cannot write through old callbacks');
|
||||
|
||||
|
||||
|
||||
brokenStreamRoot=id(830);const brokenRoot=brokenStreamRoot;
|
||||
documents.set(brokenRoot,createDocument(brokenRoot,[{id:brokenRoot,parent:null}],[{...node('prefix-visible',brokenRoot,80),data:{title:'Preserved prefix',text:'prefix-kept'}},node('tail-node',brokenRoot,450)]));versions.set(brokenRoot,1);boards.push({id:brokenRoot,workspace_id:workspace,kind:'board',version:1,title:'Interrupted board'});
|
||||
|
||||
Reference in New Issue
Block a user