diff --git a/editor-packages/editor-canvas/canvas/canvas.tsx b/editor-packages/editor-canvas/canvas/canvas.tsx index 70afbb02..54be5372 100644 --- a/editor-packages/editor-canvas/canvas/canvas.tsx +++ b/editor-packages/editor-canvas/canvas/canvas.tsx @@ -20,7 +20,7 @@ import { import q from "@design-sdk/query"; import { LazyFrame } from "@code-editor/canvas/lazy-frame"; import { HudCustomRenderers, HudSurface } from "../hud"; -import type { Box, XY, CanvasTransform, XYWH } from "../types"; +import type { Box, XY, CanvasTransform, XYWH, XYWHR } from "../types"; import type { FrameOptimizationFactors } from "../frame"; // import { TransformDraftingStore } from "../drafting"; import { @@ -98,23 +98,44 @@ const default_canvas_preferences: CanvsPreferences = { }, }; -type CanvasProps = CanvasCursorOptions & { - viewbound: Box; - onSelectNode?: (...node: ReflectSceneNode[]) => void; - onMoveNodeStart?: (...node: string[]) => void; - onMoveNode?: (delta: XY, ...node: string[]) => void; - onMoveNodeEnd?: (delta: XY, ...node: string[]) => void; - onClearSelection?: () => void; -} & CanvasCustomRenderers & +type CanvasProps = CanvasFocusProps & + CanvasCursorOptions & { + viewbound: Box; + onSelectNode?: (...node: ReflectSceneNode[]) => void; + onMoveNodeStart?: (...node: string[]) => void; + onMoveNode?: (delta: XY, ...node: string[]) => void; + onMoveNodeEnd?: (delta: XY, ...node: string[]) => void; + onClearSelection?: () => void; + } & CanvasCustomRenderers & CanvasState & { config?: CanvsPreferences; }; +type CanvasFocusProps = { + /** + * IDs of focus nodes. + * + * @default [] + */ + focus?: string[]; + focusRefreshkey?: string; +}; + interface HovringNode { node: ReflectSceneNode; reason: "frame-title" | "raycast" | "external"; } +function xywhr_of(node: ReflectSceneNode): XYWHR { + return [ + node.absoluteX, + node.absoluteY, + node.width, + node.height, + node.rotation, + ] as XYWHR; +} + export function Canvas({ viewbound, renderItem, @@ -126,6 +147,8 @@ export function Canvas({ filekey, pageid, nodes, + focus = [], + focusRefreshkey: focusRefreshKey, initialTransform, highlightedLayer, selectedNodes, @@ -135,6 +158,11 @@ export function Canvas({ cursor, ...props }: CanvasProps) { + const viewboundmeasured = useMemo( + () => !viewbound_not_measured(viewbound), + viewbound + ); + useEffect(() => { if (transformIntitialized) { return; @@ -148,7 +176,7 @@ export function Canvas({ return; } - if (viewbound_not_measured(viewbound)) { + if (!viewboundmeasured) { return; } @@ -158,6 +186,39 @@ export function Canvas({ setTransformInitialized(true); }, [viewbound]); + useEffect(() => { + // change the canvas transform to visually fit the focus nodes. + + if (!viewboundmeasured) { + return; + } + + if (focus.length == 0) { + return; + } + + // TODO: currently only the root nodes are supported to be focused. + const _focus_nodes = nodes.filter((n) => focus.includes(n.id)); + if (_focus_nodes.length == 0) { + return; + } + + const _focus_center = centerOf( + viewbound, + 200, + ..._focus_nodes.map((n) => ({ + x: n.absoluteX, + y: n.absoluteY, + width: n.width, + height: n.height, + rotation: n.rotation, + })) + ); + + setOffset(_focus_center.translate); + setZoom(_focus_center.scale); + }, [...focus, focusRefreshKey, viewboundmeasured]); + const [transformIntitialized, setTransformInitialized] = useState(false); const [zoom, setZoom] = useState(initialTransform?.scale || 1); const [isZooming, setIsZooming] = useState(false); @@ -339,9 +400,7 @@ export function Canvas({ const [x, y] = [cx / zoom, cy / zoom]; const box = boundingbox( - selected_nodes.map((d) => { - return [d.absoluteX, d.absoluteY, d.width, d.height, d.rotation]; - }), + selected_nodes.map((d) => xywhr_of(d)), 2 ); @@ -543,29 +602,12 @@ function position_guide({ const guides = []; const a = boundingbox( - selections.map((s) => [ - s.absoluteX, - s.absoluteY, - s.width, - s.height, - s.rotation, - ]), + selections.map((s) => xywhr_of(s)), 2 ); if (hover) { - const hover_box = boundingbox( - [ - [ - hover.absoluteX, - hover.absoluteY, - hover.width, - hover.height, - hover.rotation, - ], - ], - 2 - ); + const hover_box = boundingbox([xywhr_of(hover)], 2); const guide_relative_to_hover = { a: a, @@ -580,18 +622,7 @@ function position_guide({ if (selections.length === 1) { const parent = selections[0].parent; if (parent) { - const parent_box = boundingbox( - [ - [ - parent.absoluteX, - parent.absoluteY, - parent.width, - parent.height, - parent.rotation, - ], - ], - 2 - ); + const parent_box = boundingbox([xywhr_of(parent)], 2); const guide_relative_to_parent = { a: a, b: parent_box, @@ -702,7 +733,7 @@ function auto_initial_transform( } const fit_single_node = (n: ReflectSceneNode) => { - return centerOf(viewbound, n); + return centerOf(viewbound, 0, n); }; if (nodes.length === 0) { @@ -716,7 +747,7 @@ function auto_initial_transform( }; } else if (nodes.length < 20) { // fit bounds - const c = centerOf(viewbound, ...nodes); + const c = centerOf(viewbound, 0, ...nodes); return { xy: c.translate, scale: c.scale, diff --git a/editor-packages/editor-canvas/math/center-of.test.ts b/editor-packages/editor-canvas/math/center-of.test.ts new file mode 100644 index 00000000..904971de --- /dev/null +++ b/editor-packages/editor-canvas/math/center-of.test.ts @@ -0,0 +1,60 @@ +import type { X1Y1X2Y2 } from "types"; +import { centerOf, scaleToFit, scaleToFit1D } from "./center-of"; + +test("centerof", () => { + const box = [0, 0, 100, 100] as X1Y1X2Y2; + const fit = { + x: 10, + y: 10, + width: 50, + height: 50, + rotation: 0, + }; + + const r = centerOf(box, 0, fit); + // to make "fit" fit into "box", we need to translate it by 10, 10 and scale it by 0.5 + + expect(r.scale).toBe(2); + expect(r.center).toStrictEqual([35, 35]); + // todo + expect(r.translate).toStrictEqual([-20, -20]); +}); + +test("scale to fit (smaller)", () => { + const a = [0, 0, 100, 100] as X1Y1X2Y2; + const b = [0, 0, 50, 50] as X1Y1X2Y2; + expect(scaleToFit1D(100, 50)).toBe(2); + expect(scaleToFit(a, b)).toBe(2); +}); + +test("scale to fit (bigger)", () => { + const a = [0, 0, 100, 100] as X1Y1X2Y2; + const b = [0, 0, 200, 200] as X1Y1X2Y2; + expect(scaleToFit(a, b)).toBe(0.5); +}); + +test("scale to fit (bigger) #1", () => { + const a = [0, 0, 100, 100] as X1Y1X2Y2; + const b = [0, 0, 10, 200] as X1Y1X2Y2; + expect(scaleToFit(a, b)).toBe(0.5); +}); + +test("scale to fit (bigger) #2", () => { + const a = [0, 0, 100, 100] as X1Y1X2Y2; + const b = [0, 0, 200, 10] as X1Y1X2Y2; + expect(scaleToFit(a, b)).toBe(0.5); +}); + +test("scale to fit with margin", () => { + const a = [0, 0, 100, 100] as X1Y1X2Y2; + const b = [0, 0, 100, 100] as X1Y1X2Y2; + expect(scaleToFit(a, b, 50)).toBe(0.5); +}); + +test("scale to fit 1D", () => { + expect(scaleToFit1D(100, 200)).toBe(0.5); +}); + +test("scale to fit 1D", () => { + expect(scaleToFit1D(100, 50, 25)).toBe(1); +}); diff --git a/editor-packages/editor-canvas/math/center-of.ts b/editor-packages/editor-canvas/math/center-of.ts index 9c9e46e3..0d8e0015 100644 --- a/editor-packages/editor-canvas/math/center-of.ts +++ b/editor-packages/editor-canvas/math/center-of.ts @@ -18,9 +18,13 @@ type Rect = { */ export function centerOf( viewbound: Box, + m: number = 0, ...rects: Rect[] ): { box: Box; + /** + * center of the givven rects + */ center: XY; translate: XY; scale: number; @@ -46,7 +50,7 @@ export function centerOf( // center of the box, viewbound not considered. const boxcenter: XY = [(x1 + x2) / 2, (y1 + y2) / 2]; // scale factor to fix the box to the viewbound. - const scale = Math.min(scaleToFit(box, viewbound), 1); // no need to zoom-in + const scale = scaleToFit(viewbound, box, m); // center of the viewbound. const vbcenter: XY = [ viewbound[0] + (viewbound[0] + viewbound[2]) / 2, @@ -73,16 +77,55 @@ function rotate(x: number, y: number, r: number): [number, number] { return [x * cos - y * sin, x * sin + y * cos]; } -function scaleToFit(a: Box, b: Box): number { +/** + * scale to fit a box into b box. with optional margin. + * @param a box a container + * @param b box b contained + * @param m optional margin @default 0 (does not get affected by the scale) + * @returns how much to scale should be applied to b to fit a + * + * @example + * const a = [0, 0, 100, 100]; + * const b = [0, 0, 200, 200]; + * const m = 50; + * => scaleToFit(a, b, m) === 0.4 + * + * const a = [0, 0, 100, 100]; + * const b = [0, 0, 50, 50]; + * const m = 50; + * => scaleToFit(a, b, m) === 1 + * + */ +export function scaleToFit(a: Box, b: Box, m: number = 0): number { if (!a || !b) { return 1; } + const [ax1, ay1, ax2, ay2] = a; const [bx1, by1, bx2, by2] = b; + const aw = ax2 - ax1; const ah = ay2 - ay1; const bw = bx2 - bx1; const bh = by2 - by1; - const scale = Math.min(bw / aw, bh / ah); - return scale; + + const sw = scaleToFit1D(aw, bw, m); + const sh = scaleToFit1D(ah, bh, m); + + return Math.min(sw, sh); +} + +/** + * + * @param a line a + * @param b line b + * @param m margin + * + * @returns the scale factor to be applied to b to fit a with margin + */ +export function scaleToFit1D(a: number, b: number, m: number = 0): number { + const aw = a; + const bw = b + m * 2; + + return aw / bw; } diff --git a/editor/core/actions/index.ts b/editor/core/actions/index.ts index 5bcdefd9..c8468c57 100644 --- a/editor/core/actions/index.ts +++ b/editor/core/actions/index.ts @@ -31,7 +31,7 @@ export type Action = | EditorModeAction | DesignerModeSwitchActon | SelectNodeAction - | LocateNodeAction + | CanvasFocusNodeAction | HighlightNodeAction | CanvasEditAction | CanvasModeAction @@ -78,8 +78,8 @@ export interface SelectNodeAction { /** * Select and move to the node. */ -export interface LocateNodeAction { - type: "locate-node"; +export interface CanvasFocusNodeAction { + type: "canvas/focus"; node: string; } diff --git a/editor/core/reducers/editor-reducer.ts b/editor/core/reducers/editor-reducer.ts index 5ad2904b..57cdd985 100644 --- a/editor/core/reducers/editor-reducer.ts +++ b/editor/core/reducers/editor-reducer.ts @@ -14,7 +14,7 @@ import type { BackgroundTaskPopAction, BackgroundTaskUpdateProgressAction, EditorModeSwitchAction, - LocateNodeAction, + CanvasFocusNodeAction, DesignerModeSwitchActon, CodingInitialFilesSeedAction, CodingNewTemplateSessionAction, @@ -25,9 +25,19 @@ import { CanvasStateStore } from "@code-editor/canvas/stores"; import q from "@design-sdk/query"; import assert from "assert"; import { getPageNode } from "utils/get-target-node"; +import { nanoid } from "nanoid"; const _editor_path_name = "/files/[key]/"; +const _DEV_CLEAR_LOG = false; + +const clearlog = (by: string) => { + if (_DEV_CLEAR_LOG) { + console.clear(); + console.log(`cleard console by ${by}`); + } +}; + export function editorReducer(state: EditorState, action: Action): EditorState { const router = useRouter(); const filekey = state.design.key; @@ -94,8 +104,7 @@ export function editorReducer(state: EditorState, action: Action): EditorState { case "select-node": { const { node } = action; - console.clear(); - console.info("cleard console by editorReducer#select-node"); + clearlog("editorReducer#select-node"); // update router update_route(router, { @@ -105,8 +114,8 @@ export function editorReducer(state: EditorState, action: Action): EditorState { return reducers["select-node"](state, action); } - case "locate-node": { - const { node } = action; + case "canvas/focus": { + const { node } = action; update_route(router, { node }); @@ -123,17 +132,24 @@ export function editorReducer(state: EditorState, action: Action): EditorState { page: page.id, }); - // TODO: move canvas to the node + const final = _2_select_page; + + // refresh canvas focus to the target. + final.focus = { + refreshkey: nanoid(4), + nodes: [node], + }; + + final.selectedNodes = [node]; - return { ..._1_select_node, ..._2_select_page }; + return final; }); } case "select-page": { const { page } = action; - console.clear(); - console.info("cleard console by editorReducer#select-page"); + clearlog("editorReducer#select-page"); switch (page) { case "home": { diff --git a/editor/core/states/editor-initial-state.ts b/editor/core/states/editor-initial-state.ts index 34d255fa..30d63642 100644 --- a/editor/core/states/editor-initial-state.ts +++ b/editor/core/states/editor-initial-state.ts @@ -5,6 +5,10 @@ export function createInitialEditorState(editor: EditorSnapshot): EditorState { pages: editor.pages, selectedPage: editor.selectedPage, selectedNodes: editor.selectedNodes, + focus: { + refreshkey: "initial", + nodes: editor.selectedNodes, + }, // auto focus to selection selectedNodesInitial: editor.selectedNodes, selectedLayersOnPreview: editor.selectedLayersOnPreview, design: editor.design, @@ -21,6 +25,10 @@ export function createPendingEditorState(): EditorState { pages: [], selectedPage: null, selectedNodes: [], + focus: { + refreshkey: "initial", + nodes: [], + }, selectedNodesInitial: null, selectedLayersOnPreview: [], design: null, diff --git a/editor/core/states/editor-state.ts b/editor/core/states/editor-state.ts index f91dcd6a..7a7770bb 100644 --- a/editor/core/states/editor-state.ts +++ b/editor/core/states/editor-state.ts @@ -39,6 +39,7 @@ export interface EditorState { pages: EditorPage[]; selectedPage: string; selectedNodes: string[]; + focus: CanvasFocusData; selectedLayersOnPreview: string[]; /** * this is the initial node selection triggered by the url param, not caused by the user interaction. @@ -96,6 +97,15 @@ export interface FigmaReflectRepository { input: DesignInput; } +export type CanvasFocusData = { + /** + * refresh key is passed to the canvas to force the focus update, event the last focus is same as the current focus. + * this is required because the canvas has indipendent transform state, and it can loose focus to the focus node. + */ + refreshkey: string; + nodes: string[]; +}; + export type ScenePreviewData = | IScenePreviewDataVanillaPreview | IScenePreviewDataFlutterPreview diff --git a/editor/scaffolds/canvas/canvas.tsx b/editor/scaffolds/canvas/canvas.tsx index b860e199..cd8326ec 100644 --- a/editor/scaffolds/canvas/canvas.tsx +++ b/editor/scaffolds/canvas/canvas.tsx @@ -24,7 +24,7 @@ export function VisualContentArea() { const { highlightedLayer, highlightLayer } = useWorkspace(); const dispatch = useDispatch(); - const { selectedPage, design, selectedNodes, canvasMode } = state; + const { selectedPage, design, selectedNodes, focus, canvasMode } = state; const thisPage = design?.pages?.find((p) => p.id == selectedPage); const thisPageNodes = selectedPage ? thisPage?.children?.filter(Boolean) : []; @@ -95,6 +95,8 @@ export function VisualContentArea() { pageid={selectedPage} backgroundColor={_bg} selectedNodes={selectedNodes} + focusRefreshkey={focus.refreshkey} + focus={focus.nodes} highlightedLayer={highlightedLayer} onSelectNode={(...nodes) => { dispatch({ type: "select-node", node: nodes.map((n) => n.id) }); diff --git a/editor/scaffolds/editor-home/editor-home.tsx b/editor/scaffolds/editor-home/editor-home.tsx index f943baa2..1c04d908 100644 --- a/editor/scaffolds/editor-home/editor-home.tsx +++ b/editor/scaffolds/editor-home/editor-home.tsx @@ -71,7 +71,7 @@ export function EditorHomePageView() { }} onDoubleClick={() => { dispatch({ - type: "locate-node", + type: "canvas/focus", node: s.id, }); dispatch({ @@ -101,7 +101,7 @@ export function EditorHomePageView() { }} onDoubleClick={() => { dispatch({ - type: "locate-node", + type: "canvas/focus", node: cmp.id, }); dispatch({ diff --git a/editor/scaffolds/editor-home/scene-card.tsx b/editor/scaffolds/editor-home/scene-card.tsx index 0e4ff0f6..e91804e3 100644 --- a/editor/scaffolds/editor-home/scene-card.tsx +++ b/editor/scaffolds/editor-home/scene-card.tsx @@ -76,6 +76,7 @@ export function SceneCard({ highlightClassName="name" searchWords={q ? [q] : []} textToHighlight={scene.name} + autoEscape // required to escape regex special characters, like, `+`, `(`, `)`, etc. />