@zoneflow/react 0.0.19 → 0.0.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import type { UniverseLayoutModel, UniverseModel } from "@zoneflow/core";
1
+ import type { UniverseLayoutModel, UniverseModel, ZoneId } from "@zoneflow/core";
2
2
  import { type BackgroundRenderer, type CameraState, type ComponentLayoutEngine, type DensityEngine, type DrawEngine, type GraphLayoutEngine, type GridOptions, type RendererFrame, type PathComponentRendererMap, type RendererDebugOptions, type RendererInteractionHandlers, type ResolvePathColor, type ResolveZoneColor, type ResolveZoneShape, type TextScaleLevel, type ViewportConfig, type VisibilityEngine, type ZoneComponentRendererMap, type ZoneflowTheme } from "@zoneflow/renderer-dom";
3
3
  import { type ZoneMoveEditorConfig } from "../editor/ZoneMoveEditorOverlay";
4
4
  import { type BackgroundComponent, type PathSlotComponentMap, type ZoneSlotComponentMap } from "../slots/slotComponents";
@@ -30,4 +30,32 @@ export type UniverseCanvasProps = {
30
30
  onFrameChange?: (frame: RendererFrame | null) => void;
31
31
  debug?: RendererDebugOptions;
32
32
  };
33
- export declare function UniverseCanvas({ model, layoutModel, theme, textScale, viewport, grid, graphLayoutEngine, densityEngine, visibilityEngine, componentLayoutEngine, drawEngine, zoneComponentRenderers, pathComponentRenderers, resolveZoneShape, resolveZoneColor, resolvePathColor, zoneComponents, pathComponents, backgroundRenderer, background, interactionHandlers, zoneMoveEditor, cameraState, onCameraChange, onFrameChange, debug, }: UniverseCanvasProps): import("react/jsx-runtime").JSX.Element;
33
+ export type UniverseCanvasFocusZoneOptions = {
34
+ /**
35
+ * 이동 후 zoom. 미지정 시 현재 zoom 을 유지합니다.
36
+ * 카메라 컨트롤과 동일하게 0.25 ~ 3 범위로 clamp 됩니다.
37
+ */
38
+ zoom?: number;
39
+ };
40
+ export type UniverseCanvasHandle = {
41
+ /**
42
+ * 특정 zone 이 화면 중앙에 오도록 카메라를 이동합니다.
43
+ *
44
+ * - 아직 첫 프레임이 그려지지 않았거나 zone 을 찾지 못하면 `false` 를 반환합니다.
45
+ * - controlled camera (`cameraState`/`onCameraChange`) 모드에서도 동작합니다 —
46
+ * 계산된 카메라가 `onCameraChange` 로 전달됩니다.
47
+ */
48
+ focusZone: (zoneId: ZoneId, options?: UniverseCanvasFocusZoneOptions) => boolean;
49
+ };
50
+ /**
51
+ * zone 이 뷰포트 중앙에 오는 CameraState 를 계산합니다.
52
+ * ref 핸들 없이 controlled camera 를 직접 관리하는 쪽에서 사용할 수 있습니다.
53
+ */
54
+ export declare function computeZoneFocusCamera(params: {
55
+ frame: RendererFrame | null;
56
+ zoneId: ZoneId;
57
+ viewportWidth: number;
58
+ viewportHeight: number;
59
+ zoom: number;
60
+ }): CameraState | null;
61
+ export declare const UniverseCanvas: import("react").ForwardRefExoticComponent<UniverseCanvasProps & import("react").RefAttributes<UniverseCanvasHandle>>;
@@ -1,23 +1,42 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, } from "react";
3
3
  import { screenPointToWorldPoint } from "@zoneflow/editor-dom";
4
4
  import { createRenderer, } from "@zoneflow/renderer-dom";
5
- import { useCameraControls } from "../controls/useCameraControls";
5
+ import { CAMERA_MAX_ZOOM, CAMERA_MIN_ZOOM, useCameraControls, } from "../controls/useCameraControls";
6
6
  import { ZoneMoveEditorOverlay, } from "../editor/ZoneMoveEditorOverlay";
7
7
  import { resolvePermissions } from "../editor/editorPermissions";
8
8
  import { SlotPortals, } from "../slots/slotComponents";
9
+ /**
10
+ * zone 이 뷰포트 중앙에 오는 CameraState 를 계산합니다.
11
+ * ref 핸들 없이 controlled camera 를 직접 관리하는 쪽에서 사용할 수 있습니다.
12
+ */
13
+ export function computeZoneFocusCamera(params) {
14
+ const { frame, zoneId, viewportWidth, viewportHeight, zoom } = params;
15
+ const rect = frame?.pipeline.graphLayout.zonesById[zoneId]?.rect;
16
+ if (!rect)
17
+ return null;
18
+ if (viewportWidth <= 0 || viewportHeight <= 0)
19
+ return null;
20
+ return {
21
+ x: viewportWidth / 2 - (rect.x + rect.width / 2) * zoom,
22
+ y: viewportHeight / 2 - (rect.y + rect.height / 2) * zoom,
23
+ zoom,
24
+ };
25
+ }
9
26
  const DEFAULT_CAMERA = {
10
27
  x: 0,
11
28
  y: 0,
12
29
  zoom: 1,
13
30
  };
14
31
  const noopRenderer = () => { };
15
- export function UniverseCanvas({ model, layoutModel, theme, textScale = "md", viewport, grid, graphLayoutEngine, densityEngine, visibilityEngine, componentLayoutEngine, drawEngine, zoneComponentRenderers, pathComponentRenderers, resolveZoneShape, resolveZoneColor, resolvePathColor, zoneComponents, pathComponents, backgroundRenderer, background, interactionHandlers, zoneMoveEditor, cameraState, onCameraChange, onFrameChange, debug, }) {
32
+ const HOST_RESIZE_DEBOUNCE_MS = 150;
33
+ export const UniverseCanvas = forwardRef(function UniverseCanvas({ model, layoutModel, theme, textScale = "md", viewport, grid, graphLayoutEngine, densityEngine, visibilityEngine, componentLayoutEngine, drawEngine, zoneComponentRenderers, pathComponentRenderers, resolveZoneShape, resolveZoneColor, resolvePathColor, zoneComponents, pathComponents, backgroundRenderer, background, interactionHandlers, zoneMoveEditor, cameraState, onCameraChange, onFrameChange, debug, }, handleRef) {
16
34
  const viewportRef = useRef(null);
17
35
  const ref = useRef(null);
18
36
  const rendererRef = useRef(createRenderer());
19
37
  const [internalCamera, setInternalCamera] = useState(DEFAULT_CAMERA);
20
38
  const [frame, setFrame] = useState(null);
39
+ const frameRef = useRef(null);
21
40
  const [exclusionState, setExclusionState] = useState(undefined);
22
41
  const [mounts, setMounts] = useState({
23
42
  zones: [],
@@ -83,6 +102,24 @@ export function UniverseCanvas({ model, layoutModel, theme, textScale = "md", vi
83
102
  camera,
84
103
  setCamera,
85
104
  });
105
+ const focusZone = useCallback((zoneId, options) => {
106
+ const viewportEl = viewportRef.current;
107
+ if (!viewportEl)
108
+ return false;
109
+ const zoom = Math.min(Math.max(options?.zoom ?? cameraRef.current.zoom, CAMERA_MIN_ZOOM), CAMERA_MAX_ZOOM);
110
+ const nextCamera = computeZoneFocusCamera({
111
+ frame: frameRef.current,
112
+ zoneId,
113
+ viewportWidth: viewportEl.clientWidth,
114
+ viewportHeight: viewportEl.clientHeight,
115
+ zoom,
116
+ });
117
+ if (!nextCamera)
118
+ return false;
119
+ setCamera(nextCamera);
120
+ return true;
121
+ }, [setCamera]);
122
+ useImperativeHandle(handleRef, () => ({ focusZone }), [focusZone]);
86
123
  useEffect(() => {
87
124
  if (!ref.current)
88
125
  return;
@@ -91,6 +128,53 @@ export function UniverseCanvas({ model, layoutModel, theme, textScale = "md", vi
91
128
  rendererRef.current.destroy();
92
129
  };
93
130
  }, []);
131
+ // 렌더러는 update 시점에 host.clientWidth/Height 로 world viewport 를 계산해
132
+ // 화면 밖 zone/path 노드를 컬링한다. props 가 전혀 안 바뀌는 정적 viewer 는
133
+ // update 가 다시 돌지 않아 mount 시점 측정값이 stale 해질 수 있으므로
134
+ // (초기 0×0 측정, 윈도우/컨테이너 리사이즈 등) host 크기 변화를 감지해 재렌더한다.
135
+ // update 한 번이 캔버스 전체 redraw 라 리사이즈 중 매 프레임 그리지 않고,
136
+ // 크기 변화가 멈춘 시점에 한 번만 반영한다 (debounce).
137
+ const [hostSizeVersion, setHostSizeVersion] = useState(0);
138
+ useEffect(() => {
139
+ const el = ref.current;
140
+ if (!el)
141
+ return;
142
+ if (typeof ResizeObserver === "undefined")
143
+ return;
144
+ let lastWidth = el.clientWidth;
145
+ let lastHeight = el.clientHeight;
146
+ let debounceTimer = null;
147
+ const observer = new ResizeObserver(() => {
148
+ const nextWidth = el.clientWidth;
149
+ const nextHeight = el.clientHeight;
150
+ if (nextWidth === lastWidth && nextHeight === lastHeight)
151
+ return;
152
+ // 0×0 측정 상태에서는 아무것도 안 그려져 있으므로, 처음 실제 크기를
153
+ // 얻는 순간만큼은 디바운스 없이 즉시 그려 mount 직후 공백을 줄인다.
154
+ const wasEmpty = lastWidth === 0 || lastHeight === 0;
155
+ lastWidth = nextWidth;
156
+ lastHeight = nextHeight;
157
+ if (debounceTimer !== null) {
158
+ window.clearTimeout(debounceTimer);
159
+ debounceTimer = null;
160
+ }
161
+ if (wasEmpty) {
162
+ setHostSizeVersion((version) => version + 1);
163
+ return;
164
+ }
165
+ debounceTimer = window.setTimeout(() => {
166
+ debounceTimer = null;
167
+ setHostSizeVersion((version) => version + 1);
168
+ }, HOST_RESIZE_DEBOUNCE_MS);
169
+ });
170
+ observer.observe(el);
171
+ return () => {
172
+ observer.disconnect();
173
+ if (debounceTimer !== null) {
174
+ window.clearTimeout(debounceTimer);
175
+ }
176
+ };
177
+ }, []);
94
178
  useEffect(() => {
95
179
  if (zoneMoveEditor?.enabled)
96
180
  return;
@@ -120,6 +204,7 @@ export function UniverseCanvas({ model, layoutModel, theme, textScale = "md", vi
120
204
  exclusionState,
121
205
  debug,
122
206
  });
207
+ frameRef.current = frame ?? null;
123
208
  setFrame(frame ?? null);
124
209
  setMounts(frame?.mounts ?? {
125
210
  zones: [],
@@ -155,6 +240,7 @@ export function UniverseCanvas({ model, layoutModel, theme, textScale = "md", vi
155
240
  cameraState,
156
241
  onCameraChange,
157
242
  onFrameChange,
243
+ hostSizeVersion,
158
244
  ]);
159
245
  return (_jsxs("div", { ref: viewportRef, onDragOver: (event) => {
160
246
  if (!externalDropEnabled)
@@ -198,4 +284,4 @@ export function UniverseCanvas({ model, layoutModel, theme, textScale = "md", vi
198
284
  inset: 0,
199
285
  zIndex: 1,
200
286
  } }), _jsx(SlotPortals, { mounts: mounts, zoneComponents: zoneComponents, pathComponents: pathComponents, background: background }), _jsx(ZoneMoveEditorOverlay, { model: model, layoutModel: layoutModel, camera: camera, frame: frame, zoneComponents: zoneComponents, pathComponents: pathComponents, editor: zoneMoveEditor, resolveZoneShape: resolveZoneShape, onExclusionStateChange: setExclusionState })] }));
201
- }
287
+ });
@@ -5,5 +5,7 @@ type UseCameraControlsParams = {
5
5
  camera: CameraState;
6
6
  setCamera: React.Dispatch<React.SetStateAction<CameraState>>;
7
7
  };
8
+ export declare const CAMERA_MIN_ZOOM = 0.25;
9
+ export declare const CAMERA_MAX_ZOOM = 3;
8
10
  export declare function useCameraControls({ hostRef, camera, setCamera, }: UseCameraControlsParams): void;
9
11
  export {};
@@ -1,6 +1,8 @@
1
1
  import { useEffect, useRef } from "react";
2
- const MIN_ZOOM = 0.25;
3
- const MAX_ZOOM = 3;
2
+ export const CAMERA_MIN_ZOOM = 0.25;
3
+ export const CAMERA_MAX_ZOOM = 3;
4
+ const MIN_ZOOM = CAMERA_MIN_ZOOM;
5
+ const MAX_ZOOM = CAMERA_MAX_ZOOM;
4
6
  const ZOOM_STEP = 1.1;
5
7
  const TOUCH_PAN_MIN_POINTERS = 2;
6
8
  function clamp(value, min, max) {
@@ -1,4 +1,5 @@
1
- import { type UniverseCanvasProps } from "../canvas/UniverseCanvas";
1
+ import type { ZoneId } from "@zoneflow/core";
2
+ import { type UniverseCanvasFocusZoneOptions, type UniverseCanvasProps } from "../canvas/UniverseCanvas";
2
3
  import type { ZoneMoveEditorConfig } from "./ZoneMoveEditorOverlay";
3
4
  import type { UniverseEditorController } from "./useUniverseEditor";
4
5
  type ControlledZoneMoveEditorConfig = Omit<ZoneMoveEditorConfig, "enabled" | "gridSnap" | "onModelChange" | "onLayoutModelChange" | "onTransactionStart" | "onTransactionCommit" | "onTransactionCancel" | "history">;
@@ -6,5 +7,14 @@ export type UniverseEditorCanvasProps = Omit<UniverseCanvasProps, "model" | "lay
6
7
  editor: UniverseEditorController;
7
8
  editorConfig?: ControlledZoneMoveEditorConfig;
8
9
  };
9
- export declare function UniverseEditorCanvas(props: UniverseEditorCanvasProps): import("react/jsx-runtime").JSX.Element;
10
+ export type UniverseEditorCanvasHandle = {
11
+ /** 특정 zone 이 화면 중앙에 오도록 카메라를 이동. zone 을 못 찾으면 false. */
12
+ focusZone: (zoneId: ZoneId, options?: UniverseCanvasFocusZoneOptions) => boolean;
13
+ /** 모든 zone/path 가 화면에 들어오도록 카메라를 맞춤. */
14
+ fitToView: () => void;
15
+ };
16
+ export declare const UniverseEditorCanvas: import("react").ForwardRefExoticComponent<Omit<UniverseCanvasProps, "model" | "layoutModel" | "zoneMoveEditor" | "cameraState" | "onCameraChange" | "onFrameChange"> & {
17
+ editor: UniverseEditorController;
18
+ editorConfig?: ControlledZoneMoveEditorConfig;
19
+ } & import("react").RefAttributes<UniverseEditorCanvasHandle>>;
10
20
  export {};
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useCallback, useMemo, useRef, useState } from "react";
3
- import { UniverseCanvas } from "../canvas/UniverseCanvas";
2
+ import { forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState, } from "react";
3
+ import { computeZoneFocusCamera, UniverseCanvas, } from "../canvas/UniverseCanvas";
4
4
  import { getGridToggleLabel, getGridSnapToggleLabel, getObjectSnapToggleLabel, getZoneflowEditorStrings, resolveEditorLocale, } from "./strings";
5
5
  import { resolveEditorTheme } from "./theme";
6
6
  const DEFAULT_CAMERA = {
@@ -69,7 +69,7 @@ function fitCameraToWorldRect(params) {
69
69
  zoom,
70
70
  };
71
71
  }
72
- export function UniverseEditorCanvas(props) {
72
+ export const UniverseEditorCanvas = forwardRef(function UniverseEditorCanvas(props, handleRef) {
73
73
  const { editor, editorConfig, grid, ...canvasProps } = props;
74
74
  const hostRef = useRef(null);
75
75
  const frameRef = useRef(null);
@@ -129,6 +129,26 @@ export function UniverseEditorCanvas(props) {
129
129
  viewportHeight: rect.height,
130
130
  }));
131
131
  }, []);
132
+ const focusZone = useCallback((zoneId, options) => {
133
+ const rect = hostRef.current?.getBoundingClientRect();
134
+ const frame = frameRef.current;
135
+ const zoneRect = frame?.pipeline.graphLayout.zonesById[zoneId]?.rect;
136
+ if (!rect || rect.width <= 0 || rect.height <= 0 || !zoneRect) {
137
+ return false;
138
+ }
139
+ setCamera((prev) => {
140
+ const zoom = clamp(options?.zoom ?? prev.zoom, MIN_ZOOM, MAX_ZOOM);
141
+ return (computeZoneFocusCamera({
142
+ frame,
143
+ zoneId,
144
+ viewportWidth: rect.width,
145
+ viewportHeight: rect.height,
146
+ zoom,
147
+ }) ?? prev);
148
+ });
149
+ return true;
150
+ }, []);
151
+ useImperativeHandle(handleRef, () => ({ focusZone, fitToView }), [focusZone, fitToView]);
132
152
  const zoneMoveEditor = editor.isEditMode
133
153
  ? {
134
154
  ...editorConfig,
@@ -241,4 +261,4 @@ export function UniverseEditorCanvas(props) {
241
261
  ...viewerHudButtonStyle,
242
262
  fontVariantNumeric: "tabular-nums",
243
263
  }, children: [Math.round(camera.zoom * 100), "%"] })) : (_jsx("div", {})), editorConfig?.overlayControls?.showZoomControls !== false ? (_jsx("button", { type: "button", onClick: () => zoomAtCenter(ZOOM_STEP), style: viewerHudButtonStyle, children: "+" })) : (_jsx("div", {}))] })) : null] })) : null] }));
244
- }
264
+ });
@@ -103,6 +103,20 @@ export type ZoneMoveEditorConfig = {
103
103
  onPathLabelClick?: (event: PathLabelEventPayload) => void;
104
104
  onPathLabelDoubleClick?: (event: PathLabelEventPayload) => void;
105
105
  onPathLabelContextMenu?: (event: PathLabelEventPayload) => void;
106
+ /**
107
+ * zone 선택이 바뀔 때마다 호출됩니다.
108
+ *
109
+ * - 단일 클릭, shift/ctrl/cmd 토글, 마퀴(드래그 박스) 선택 모두 같은 콜백으로 옵니다.
110
+ * - 선택 해제(빈 캔버스 클릭, 편집 모드 종료, 선택된 zone 삭제 등) 시 빈 배열로 호출됩니다.
111
+ * - 선택 내용이 실제로 바뀔 때만 호출됩니다 (같은 zone 재클릭 시 재호출되지 않음).
112
+ * - zone 과 path 선택은 상호 배타라서, zone 선택 시 path 선택이 비워지며
113
+ * `onPathSelectionChange` 도 빈 배열로 함께 호출될 수 있습니다.
114
+ */
115
+ onZoneSelectionChange?: (zoneIds: ZoneId[]) => void;
116
+ /**
117
+ * path 선택이 바뀔 때마다 호출됩니다. 규칙은 `onZoneSelectionChange` 와 동일합니다.
118
+ */
119
+ onPathSelectionChange?: (pathIds: PathId[]) => void;
106
120
  /**
107
121
  * 외부에서 zone 간 path 연결 가능 여부를 검증하는 콜백.
108
122
  *
@@ -176,6 +176,10 @@ function togglePathSelection(pathIds, pathId) {
176
176
  ? pathIds.filter((current) => current !== pathId)
177
177
  : [...pathIds, pathId];
178
178
  }
179
+ function areIdListsEqual(left, right) {
180
+ return (left.length === right.length &&
181
+ left.every((id, index) => id === right[index]));
182
+ }
179
183
  function clamp(value, min, max) {
180
184
  return Math.min(Math.max(value, min), max);
181
185
  }
@@ -576,6 +580,8 @@ export function ZoneMoveEditorOverlay(props) {
576
580
  const marqueeSelectionRef = useRef(null);
577
581
  const selectedZoneIdsRef = useRef([]);
578
582
  const selectedPathIdsRef = useRef([]);
583
+ const notifiedZoneSelectionRef = useRef([]);
584
+ const notifiedPathSelectionRef = useRef([]);
579
585
  const longPressRef = useRef(null);
580
586
  const longPressTimerRef = useRef(null);
581
587
  const deleteUndoTimerRef = useRef(null);
@@ -601,6 +607,8 @@ export function ZoneMoveEditorOverlay(props) {
601
607
  canConnectPath: editor?.canConnectPath,
602
608
  onPathCreated: editor?.onPathCreated,
603
609
  onPathDropOnEmptySpace: editor?.onPathDropOnEmptySpace,
610
+ onZoneSelectionChange: editor?.onZoneSelectionChange,
611
+ onPathSelectionChange: editor?.onPathSelectionChange,
604
612
  resolveZoneShape,
605
613
  onExclusionStateChange,
606
614
  });
@@ -622,6 +630,8 @@ export function ZoneMoveEditorOverlay(props) {
622
630
  canConnectPath: editor?.canConnectPath,
623
631
  onPathCreated: editor?.onPathCreated,
624
632
  onPathDropOnEmptySpace: editor?.onPathDropOnEmptySpace,
633
+ onZoneSelectionChange: editor?.onZoneSelectionChange,
634
+ onPathSelectionChange: editor?.onPathSelectionChange,
625
635
  resolveZoneShape,
626
636
  onExclusionStateChange,
627
637
  };
@@ -640,6 +650,22 @@ export function ZoneMoveEditorOverlay(props) {
640
650
  useEffect(() => {
641
651
  selectedPathIdsRef.current = selectedPathIds;
642
652
  }, [selectedPathIds]);
653
+ // state 배열의 identity 가 아니라 내용이 바뀔 때만 외부에 알린다 —
654
+ // 같은 선택을 유지한 채 새 배열로 set 되는 경로(모델 prune, 재클릭 등)가 많다.
655
+ useEffect(() => {
656
+ if (areIdListsEqual(notifiedZoneSelectionRef.current, selectedZoneIds)) {
657
+ return;
658
+ }
659
+ notifiedZoneSelectionRef.current = selectedZoneIds;
660
+ latestRef.current.onZoneSelectionChange?.([...selectedZoneIds]);
661
+ }, [selectedZoneIds]);
662
+ useEffect(() => {
663
+ if (areIdListsEqual(notifiedPathSelectionRef.current, selectedPathIds)) {
664
+ return;
665
+ }
666
+ notifiedPathSelectionRef.current = selectedPathIds;
667
+ latestRef.current.onPathSelectionChange?.([...selectedPathIds]);
668
+ }, [selectedPathIds]);
643
669
  useEffect(() => {
644
670
  if (typeof window === "undefined")
645
671
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zoneflow/react",
3
- "version": "0.0.19",
3
+ "version": "0.0.21",
4
4
  "license": "MIT",
5
5
  "description": "React renderer and editor components for Zoneflow.",
6
6
  "type": "module",
@@ -23,9 +23,9 @@
23
23
  "react-dom": "^18 || ^19"
24
24
  },
25
25
  "dependencies": {
26
- "@zoneflow/core": "0.0.19",
27
- "@zoneflow/renderer-dom": "0.0.19",
28
- "@zoneflow/editor-dom": "0.0.19"
26
+ "@zoneflow/core": "0.0.21",
27
+ "@zoneflow/editor-dom": "0.0.21",
28
+ "@zoneflow/renderer-dom": "0.0.21"
29
29
  },
30
30
  "devDependencies": {
31
31
  "@types/react": "^19.2.14",