polarvo-layout 1.0.56 → 1.0.58

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polarvo-layout",
3
- "version": "1.0.56",
3
+ "version": "1.0.58",
4
4
  "type": "module",
5
5
  "author": "unigence <unigencelab@gmail.com>",
6
6
  "repository": {
@@ -90,6 +90,7 @@ const getSectionStyle = (section) => ({
90
90
  '--grid-columns': section.config?.gridColumns ?? 3,
91
91
  '--grid-rows': section.config?.gridRows ?? 3,
92
92
  '--grid-gap': `${section.config?.gridGap ?? 5}px`,
93
+ '--grid-row-sizes': section.config?.gridRowRatio?.length ? section.config.gridRowRatio.map((r) => `${r}fr`).join(' ') : undefined, // 비어있으면 아예 안 넣어서 CSS 기본값으로 폴백
93
94
  position: 'relative',
94
95
  });
95
96
 
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <div class="canvas-wrapper w-full h-full max-h-[765px] p-4 overflow-y-auto overflow-x-hidden">
2
+ <div class="canvas-wrapper w-full h-full max-h-[765px] overflow-y-auto overflow-x-hidden">
3
3
  <div id="canvas-container" class="border rounded-md w-full h-full bg-white" :style="containerStyle">
4
4
  <BaseLayout :polarvo="polarvo" class="relative" />
5
5
  <div
@@ -67,13 +67,14 @@ const containerStyle = computed(() => {
67
67
  width: `${DEFAULT_DISPLAY_SIZE.px}px`,
68
68
  height: `${displayHeight.value}px`,
69
69
  margin: '0 auto',
70
- transform: `scale(${DEFAULT_DISPLAY_SIZE.percent / 100})`,
71
70
  transformOrigin: 'top center',
71
+ transform: `scale(${DEFAULT_DISPLAY_SIZE.percent / 100})`,
72
72
  backgroundImage: `
73
73
  linear-gradient(rgba(0,0,0,0.05) 1px, transparent 1px),
74
74
  linear-gradient(90deg, rgba(0,0,0,0.05) 1px, transparent 1px)
75
75
  `,
76
76
  backgroundSize: `${DEFAULT_DISPLAY_SIZE.gridSize}px ${DEFAULT_DISPLAY_SIZE.gridSize}px`,
77
+ backgroundPosition: '8px 8px', // BaseLayout의 p-2(8px)와 맞춰 좌상단 기준으로 정렬
77
78
  };
78
79
  }
79
80
 
@@ -89,6 +90,7 @@ const containerStyle = computed(() => {
89
90
  linear-gradient(90deg, rgba(0,0,0,0.05) 1px, transparent 1px)
90
91
  `,
91
92
  backgroundSize: `${displaySize.value.gridSize}px ${displaySize.value.gridSize}px`,
93
+ backgroundPosition: '8px 8px', // BaseLayout의 p-2(8px)와 맞춰 좌상단 기준으로 정렬
92
94
  };
93
95
  });
94
96
  </script>
@@ -60,6 +60,7 @@ const props = defineProps({
60
60
  const { setActiveDesign, setActiveMenu } = props.polarvo.display;
61
61
  const { updateActiveElement } = props.polarvo.layout;
62
62
  const { elements, guides, activeElement } = toRefs(props.polarvo.freeDrop.state);
63
+ const { activeDesign } = toRefs(props.polarvo.display.state);
63
64
  const filteredElements = computed(() => {
64
65
  return elements.value.filter((el) => props.elementIds?.includes(el.id) && el.section === props.sectionKey) ?? [];
65
66
  });
@@ -74,6 +75,7 @@ watch(
74
75
  () => activeElement.value?.id,
75
76
  (newId, oldId) => {
76
77
  if (newId === oldId) return;
78
+
77
79
  if (!newId) {
78
80
  setActiveDesign(null);
79
81
  setActiveMenu(null);
@@ -102,6 +104,17 @@ watch(
102
104
  { deep: true },
103
105
  );
104
106
 
107
+ watch(
108
+ () => activeDesign.value,
109
+ (newDesign, oldDesign) => {
110
+ if (newDesign != null || oldDesign == null) return; // null → null이 아닌 값으로 닫히는(값이 있다가 null 되는) 경우만
111
+
112
+ if (activeElement.value?.id && !activeElement.value.id.includes('inputForm')) {
113
+ updateActiveElement(activeElement.value.id, selectedElementForWatch.value, true);
114
+ }
115
+ },
116
+ );
117
+
105
118
  onMounted(() => {
106
119
  props.polarvo.components.register('sections', props.sectionKey, getCurrentInstance());
107
120
  activeEditMode.value = false;
@@ -4,7 +4,7 @@
4
4
  :class="
5
5
  item.id
6
6
  ? ['relative bg-gray-200 user-select-none', { 'z-50 bg-white border-2 border-blue-400': item.id === activeId }]
7
- : 'flex items-center justify-center bg-gray-100 border border-gray-300'
7
+ : 'flex items-center justify-center bg-gray-100 border border-gray-300 '
8
8
  "
9
9
  :style="itemStyle"
10
10
  @mousedown.stop="activeEditMode ? null : handleMouseDown($event, item)"
@@ -14,7 +14,7 @@
14
14
  <!-- 리사이즈 핸들 -->
15
15
  <div v-if="item.id === activeId" class="absolute inset-0 pointer-events-none">
16
16
  <div
17
- class="absolute w-4 h-4 bottom-0 right-0 bg-blue-400 z-10 pointer-events-auto cursor-se-resize"
17
+ class="absolute w-2 h-2 bottom-0 right-0 bg-blue-400 z-10 pointer-events-auto cursor-se-resize"
18
18
  @mousedown="startResize($event, item.id)"
19
19
  ></div>
20
20
  <div class="flex gap-1 m-1">
@@ -8,6 +8,32 @@
8
8
  :sectionKey="props.sectionKey"
9
9
  ></GridItem>
10
10
 
11
+ <div
12
+ v-for="r in gridRows - 1"
13
+ :key="`row-handle-${r}`"
14
+ :id="`row-handle-${r}`"
15
+ class="z-[60] relative self-end justify-self-stretch cursor-s-resize "
16
+ :style="{
17
+ gridRow: r,
18
+ gridColumn: '1 / -1',
19
+ height: `${gridGap}px`,
20
+ transform: 'translateY(100%)',
21
+ }"
22
+ @mousedown="handleMouseDown($event, r)"
23
+ >
24
+ <!-- 점선 -->
25
+ <div
26
+ class="pointer-events-none absolute left-0 right-0 top-1/2 -translate-y-1/2 border-t border-dashed border-blue-700 "
27
+ :class="{ 'opacity-100': rowState.activeRow === r, 'opacity-0': rowState.activeRow !== r }"
28
+ ></div>
29
+
30
+ <!-- 핸들 -->
31
+ <!-- <div
32
+ class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-3 h-3 cursor-s-resize rounded-full bg-violet-400 opacity-30 hover:opacity-100"
33
+ @mousedown="handleMouseDown($event, r)"
34
+ ></div> -->
35
+ </div>
36
+
11
37
  <!-- 오버레이 -->
12
38
  <div v-if="activeEditMode" class="absolute inset-0 bg-black opacity-50 z-30"></div>
13
39
  </template>
@@ -41,9 +67,10 @@ const props = defineProps({
41
67
  },
42
68
  });
43
69
 
44
- const { setActiveDesign } = props.polarvo.display;
45
- const { updateActiveElement } = props.polarvo.layout;
70
+ const { setActiveDesign, setActiveMenu } = props.polarvo.display;
71
+ const { updateActiveElement, setSectionConfig } = props.polarvo.layout;
46
72
  const { elements, activeElement } = toRefs(props.polarvo.gridDrop.state);
73
+ const { activeDesign } = toRefs(props.polarvo.display.state);
47
74
 
48
75
  const selectedElement = inject('selectedElement');
49
76
  const activeEditMode = inject('activeEditMode');
@@ -52,6 +79,144 @@ import { omit, cloneDeep, debounce } from 'lodash-es';
52
79
 
53
80
  const gridColumns = ref(3); // 동적으로 계산
54
81
  const gridRows = ref(1); // 동적으로 계산
82
+ const gridGap = computed(() => props.sectionData?.config?.gridGap ?? 5); // BaseLayout.vue --grid-gap과 동일한 소스/기본값
83
+
84
+ // row Resize
85
+ const MIN_ROW_PX = 20; // 행 최소 높이(px)
86
+
87
+ const rowState = ref({
88
+ activeRow: null,
89
+ isDragging: false,
90
+ });
91
+ const startPos = ref({ x: 0, y: 0 });
92
+
93
+ function handleMouseDown(event, row) {
94
+ if (!row) return;
95
+ event.preventDefault();
96
+ event.stopPropagation();
97
+
98
+ rowState.value.activeRow = row;
99
+ startPos.value = { x: event.clientX, y: event.clientY };
100
+
101
+ setActiveMenu(null); // 메뉴 닫기
102
+ setActiveDesign(null); // 디자인 닫기
103
+
104
+ let dragSnapShot = null; // 드래그 시작 시 스냅샷
105
+ let liveRowRatio = null; // 미리보기 중 계산된 최종 fr 배열 (stopDrag에서 커밋)
106
+ let pendingClientY = null;
107
+ let rafId = null;
108
+
109
+ document.addEventListener('mousemove', _detectDragIntent);
110
+ document.addEventListener('mouseup', _cancelDragIntent);
111
+
112
+ function _detectDragIntent(e) {
113
+ if (rowState.value.activeRow !== row) return;
114
+ const deltaX = Math.abs(e.clientX - startPos.value.x);
115
+ const deltaY = Math.abs(e.clientY - startPos.value.y);
116
+
117
+ const isMoved = deltaX > 5 || deltaY > 5;
118
+
119
+ if (isMoved) {
120
+ // 의도 감지 리스너 제거
121
+ document.removeEventListener('mousemove', _detectDragIntent);
122
+ document.removeEventListener('mouseup', _cancelDragIntent);
123
+
124
+ // 드래그 시작
125
+ _startDrag();
126
+ }
127
+ }
128
+
129
+ function _cancelDragIntent() {
130
+ rowState.value.activeRow = null;
131
+
132
+ document.removeEventListener('mousemove', _detectDragIntent);
133
+ document.removeEventListener('mouseup', _cancelDragIntent);
134
+ }
135
+
136
+ function _startDrag() {
137
+ rowState.value.isDragging = true;
138
+
139
+ const container = document.getElementById(`${props.sectionKey}Layout`);
140
+ if (!container) return _stopDrag();
141
+
142
+ const configured = props.sectionData?.config?.gridRowRatio;
143
+ const baselineRatio =
144
+ (Array.isArray(configured) && configured.length === gridRows.value) || configured.some((v) => typeof v !== 'number' || !(v > 0))
145
+ ? [...configured]
146
+ : Array(gridRows.value).fill(1);
147
+
148
+ const trackPx = getComputedStyle(container)
149
+ .gridTemplateRows.split(' ')
150
+ .map((v) => parseFloat(v));
151
+
152
+ // 총 Fr 계산
153
+ const totalFr = baselineRatio.reduce((sum, v) => sum + v, 0);
154
+ // 총 px 계산
155
+ const totalPx = trackPx.reduce((sum, v) => sum + v, 0);
156
+ // 1px당 Fr 계산
157
+ const frPerPx = totalFr / totalPx;
158
+
159
+ dragSnapShot = {
160
+ container,
161
+ baselineRatio,
162
+ frPerPx,
163
+ minFr: MIN_ROW_PX * frPerPx, // 최소 Fr 계산
164
+ };
165
+
166
+ document.addEventListener('mousemove', _startDragMove);
167
+ document.addEventListener('mouseup', _stopDrag);
168
+ }
169
+
170
+ function _startDragMove(e) {
171
+ if (!dragSnapShot) return;
172
+ pendingClientY = e.clientY;
173
+
174
+ // rafId 중복 예약 방지
175
+ if (rafId) return;
176
+
177
+ rafId = requestAnimationFrame(() => {
178
+ rafId = null;
179
+
180
+ const { baselineRatio, frPerPx, minFr, container } = dragSnapShot;
181
+ const topIdx = row - 1; // 드래그 행 위쪽 행 인덱스
182
+ const bottomIdx = row; // 드래그 행 인덱스
183
+
184
+ // 순수 이동량
185
+ let delta = (pendingClientY - startPos.value.y) * frPerPx;
186
+ // 위쪽 행 작아지는 것 방지: 가장 커져도 minFr - baselineRatio[topIdx] (음수값 = 위로 이동)
187
+ delta = Math.max(minFr - baselineRatio[topIdx], delta);
188
+ // 아래쪽 행 작아지는 것 방지: 가장 작아져도 minFr (양수값 = 아래로 이동)
189
+ delta = Math.min(baselineRatio[bottomIdx] - minFr, delta);
190
+
191
+ // 미리보기용
192
+ const preview = [...baselineRatio];
193
+ preview[topIdx] += delta;
194
+ preview[bottomIdx] -= delta;
195
+
196
+ liveRowRatio = preview;
197
+
198
+ // 화면에만 즉시 반영
199
+ container.style.setProperty('--grid-row-sizes', preview.map((v) => `${v}fr`).join(' '));
200
+ });
201
+ }
202
+ function _stopDrag() {
203
+ rowState.value.isDragging = false;
204
+ rowState.value.activeRow = null;
205
+
206
+ if (liveRowRatio) {
207
+ setSectionConfig('gridRowRatio', liveRowRatio);
208
+ }
209
+
210
+ startPos.value = { x: 0, y: 0 };
211
+ dragSnapShot = null;
212
+ liveRowRatio = null;
213
+ pendingClientY = null;
214
+ rafId = null;
215
+
216
+ document.removeEventListener('mousemove', _startDragMove);
217
+ document.removeEventListener('mouseup', _stopDrag);
218
+ }
219
+ }
55
220
 
56
221
  function initData() {
57
222
  gridColumns.value = props.sectionData?.config?.gridColumns || 3;
@@ -131,11 +296,22 @@ watch(
131
296
  _isElementSwitching = false;
132
297
  return;
133
298
  }
134
- updateActiveElement(selectedElement.value?.id, newElement);
299
+ updateActiveElement(selectedElement.value?.id, newElement);
135
300
  },
136
301
  { deep: true },
137
302
  );
138
303
 
304
+ watch(
305
+ () => activeDesign.value,
306
+ (newDesign, oldDesign) => {
307
+ if (newDesign != null || oldDesign == null) return; // null → null이 아닌 값으로 닫히는(값이 있다가 null 되는) 경우만
308
+
309
+ if (activeElement.value?.id && !activeElement.value.id.includes('inputForm')) {
310
+ updateActiveElement(activeElement.value.id, selectedElementForWatch.value, true);
311
+ }
312
+ },
313
+ );
314
+
139
315
  onMounted(() => {
140
316
  props.polarvo.components.register('sections', props.sectionKey, getCurrentInstance());
141
317
  activeEditMode.value = false;
@@ -146,4 +322,18 @@ onMounted(() => {
146
322
  onUnmounted(() => {
147
323
  props.polarvo.components.unregister('sections', props.sectionKey);
148
324
  });
325
+
326
+ //
149
327
  </script>
328
+
329
+ <style lang="scss" scoped>
330
+ // .re-handle-line {
331
+ // position: absolute;
332
+ // z-index: 0;
333
+ // left: 0;
334
+ // right: 0;
335
+ // top: 50%;
336
+ // height: 1px;
337
+ // transform: translateY(-50%);
338
+ // }
339
+ </style>
@@ -18,7 +18,8 @@ const props = defineProps({
18
18
  .polar-grid {
19
19
  display: grid;
20
20
  grid-template-columns: repeat(var(--grid-columns, 3), 1fr);
21
- grid-template-rows: repeat(var(--grid-rows, 3), 1fr);
21
+ /* grid-template-rows: repeat(var(--grid-rows, 3), 1fr); */
22
+ grid-template-rows: var(--grid-row-sizes, repeat(var(--grid-rows, 3), 1fr));
22
23
  gap: var(--grid-gap, 5px);
23
24
  width: 100%;
24
25
  height: 100%;
@@ -64,7 +64,7 @@ class DisplayEngine {
64
64
  this._subscribe('freeDrop:setActiveElement', () => {
65
65
  this.setActiveMenu(null);
66
66
  this.setActiveDesign(null);
67
- })
67
+ });
68
68
 
69
69
  // freeDrop:setActiveElement가 항상 먼저 발행되어 아래 처리가 중복되므로 주석 처리
70
70
  // this._subscribe('freeDrop:startDrag', () => {
@@ -151,10 +151,12 @@ class DisplayEngine {
151
151
  */
152
152
  setActiveDesign(name) {
153
153
  if (this.activeDesign === name) return;
154
+ const prevDesign = this.activeDesign;
154
155
  this.activeDesign = name;
155
156
 
156
157
  this.eventBus.emit('display:updateActiveDesign', {
157
- $design: name,
158
+ $activeDesign: name,
159
+ prev: { activeDesign: prevDesign },
158
160
  timestamp: Date.now(),
159
161
  });
160
162
  }
@@ -147,7 +147,7 @@ class FreeDropEngine {
147
147
 
148
148
  this._subscribe('system:restoredState', ({ domain, state: snapShot }) => {
149
149
  if (domain === 'all' || domain === 'draft') {
150
- this._elements = snapShot.manager.elements || [];
150
+ this._elements = cloneDeep(snapShot.manager.elements) || [];
151
151
  this._elementsUpdate = true;
152
152
  this.setActiveElement(null);
153
153
 
@@ -161,7 +161,7 @@ class FreeDropEngine {
161
161
  }
162
162
 
163
163
  if (domain === 'manager') {
164
- this._elements = snapShot.elements || [];
164
+ this._elements = cloneDeep(snapShot.elements) || [];
165
165
  this._elementsUpdate = true;
166
166
  this.setActiveElement(null);
167
167
 
@@ -666,7 +666,8 @@ class FreeDropEngine {
666
666
  const layoutRect = this._getLayoutRect();
667
667
  if (!layoutRect) return;
668
668
 
669
- const step = this._gridSize || 1;
669
+ // const step = this._gridSize || 1;
670
+ const step = 1;
670
671
  const rawX = this._activeElement.position.x + delta.x * step;
671
672
  const rawY = this._activeElement.position.y + delta.y * step;
672
673
 
@@ -834,7 +835,7 @@ class FreeDropEngine {
834
835
  guides: this.guides,
835
836
  timestamp: Date.now(),
836
837
  });
837
- this._resetDrag(true, true);
838
+ this._resetDrag(true);
838
839
  // }
839
840
 
840
841
  return;
@@ -861,7 +862,7 @@ class FreeDropEngine {
861
862
  * @param {boolean} emitEvent - 업데이트 이벤트 발송 여부
862
863
  * @param {boolean} historyEvent - 히스토리 이벤트 기록 여부
863
864
  */
864
- _resetDrag(emitEvent = false, historyEvent = false) {
865
+ _resetDrag(emitEvent = false) {
865
866
  this._dragState.isDragging = false;
866
867
  this.guides = { x: null, y: null, w: null, h: null };
867
868
 
@@ -870,17 +871,15 @@ class FreeDropEngine {
870
871
  this._offsetPos = { x: 0, y: 0 };
871
872
  this._dragOriginPos = { x: 0, y: 0 };
872
873
 
873
- if (emitEvent) {
874
- if (this._activeElement) {
875
- this.eventBus.emit('freeDrop:requestUpdateElement', {
876
- historyEvent: historyEvent,
877
- elementId: this._activeElement.id,
878
- position: this._activeElement.position,
879
- size: this._activeElement.size,
880
- guides: this.guides,
881
- timestamp: Date.now(),
882
- });
883
- }
874
+ if (emitEvent && this._activeElement) {
875
+ this.eventBus.emit('freeDrop:requestUpdateElement', {
876
+ historyEvent: true,
877
+ elementId: this._activeElement.id,
878
+ position: this._activeElement.position,
879
+ size: this._activeElement.size,
880
+ guides: this.guides,
881
+ timestamp: Date.now(),
882
+ });
884
883
  }
885
884
 
886
885
  document.removeEventListener('mousemove', this._startDragMove);
@@ -112,7 +112,7 @@ class GridDropEngine {
112
112
 
113
113
  this._subscribe('system:restoredState', ({ domain, state: snapShot }) => {
114
114
  if (domain === 'all' || domain === 'draft') {
115
- this._elements = snapShot.manager.elements || [];
115
+ this._elements = cloneDeep(snapShot.manager.elements) || [];
116
116
  this._elementsUpdate = true;
117
117
 
118
118
  this.setActiveElement(null);
@@ -124,7 +124,7 @@ class GridDropEngine {
124
124
  }
125
125
 
126
126
  if (domain === 'manager') {
127
- this._elements = snapShot.elements || [];
127
+ this._elements = cloneDeep(snapShot.elements) || [];
128
128
  this._elementsUpdate = true;
129
129
 
130
130
  this.setActiveElement(null);
@@ -69,7 +69,7 @@ class EngineManager {
69
69
  * @param {object} state - 복원할 상태 객체
70
70
  */
71
71
  setState(state) {
72
- this.state.elements = state.elements;
72
+ this.state.elements = cloneDeep(state.elements);
73
73
  this.setActiveSection(state.activeSection ?? null, true);
74
74
  }
75
75
 
@@ -178,10 +178,14 @@ class EngineManager {
178
178
  return false;
179
179
  }
180
180
 
181
- const { width, height } = containerEl.getBoundingClientRect();
182
- const newHeight = width / calculateAspectRatio(this._displaySize.aspectRatio);
181
+ const { width } = containerEl.getBoundingClientRect();
182
+ const style = getComputedStyle(containerEl);
183
+ const paddingX = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
184
+ const contentWidth = width - paddingX;
183
185
 
184
- this._containerWidth = width ?? this._containerWidth;
186
+ const newHeight = contentWidth / calculateAspectRatio(this._displaySize.aspectRatio);
187
+
188
+ this._containerWidth = contentWidth ?? this._containerWidth;
185
189
  this._containerHeight = newHeight ?? this._containerHeight;
186
190
  } catch (error) {
187
191
  console.error('[EngineManager] 컨테이너 크기 설정 중 오류 발생: ', error);
@@ -228,8 +232,9 @@ class EngineManager {
228
232
  /** activeElement 업데이트
229
233
  * @param {string} id - 업데이트할 element의 ID
230
234
  * @param {object} changes - 변경할 속성 객체
235
+ * @param {boolean} emitEvent - 이벤트 발생 여부
231
236
  */
232
- updateActiveElement(id, changes) {
237
+ updateActiveElement(id, changes, emitEvent = false) {
233
238
  if (!id || !changes) return false;
234
239
 
235
240
  const index = this.state.elements.findIndex((x) => x.id === id);
@@ -253,6 +258,13 @@ class EngineManager {
253
258
  element: updatedElement,
254
259
  timestamp: Date.now(),
255
260
  });
261
+
262
+ if (emitEvent) {
263
+ this.eventBus.emit('system:historyCheckpoint', {
264
+ domain: 'manager',
265
+ state: this.getState() || null,
266
+ });
267
+ }
256
268
  }
257
269
 
258
270
  /** ---------------------------------- display Engine ---------------------------------- **/
@@ -448,13 +460,12 @@ class EngineManager {
448
460
  * @param {number|boolean} config - 새로운 설정 값
449
461
  */
450
462
  setSectionConfig(type, config) {
451
- if (!['column', 'row', 'gap', 'guideLine'].includes(type)) {
463
+ if (!['column', 'row', 'gap', 'guideLine', 'gridRowRatio'].includes(type)) {
452
464
  return { result: false, message: '입력할 수 없는 config 타입입니다.' };
453
465
  }
454
466
 
455
467
  if (!this.activeData) return;
456
468
 
457
- const limit = CONFIG_LIMITS[type];
458
469
  const result = { result: true, message: 'config가 성공적으로 변경되었습니다.' };
459
470
  // const limits = {
460
471
  // column: { min: 1, max: 12, name: '열 개수' },
@@ -467,7 +478,17 @@ class EngineManager {
467
478
 
468
479
  if (type === 'guideLine') {
469
480
  newConfig = { showGuideLine: value };
481
+ } else if (type === 'gridRowRatio') {
482
+ // gridRowRatio 배열은 gridRows 길이와 일치해야 함
483
+ const gridRows = this.activeData.config?.gridRows || 1;
484
+ if (!Array.isArray(value) || value.length !== gridRows || value.some((v) => typeof v !== 'number' || !(v > 0))) {
485
+ return { result: false, message: '잘못된 행 비율 값입니다.' };
486
+ }
487
+
488
+ newConfig = { gridRowRatio: value };
470
489
  } else {
490
+ const limit = CONFIG_LIMITS[type];
491
+
471
492
  if (value < limit.min) {
472
493
  result.result = false;
473
494
  result.message = `설정 가능한 최소 ${limit.name}는 ${limit.min}입니다.`;
@@ -481,7 +502,8 @@ class EngineManager {
481
502
  if (type === 'column') {
482
503
  newConfig = { gridColumns: value };
483
504
  } else if (type === 'row') {
484
- newConfig = { gridRows: value };
505
+ // gridRows 변경되면 gridRowRatio 초기화
506
+ newConfig = { gridRows: value, gridRowRatio: [] };
485
507
  } else if (type === 'gap') {
486
508
  newConfig = { gridGap: value };
487
509
  }
@@ -535,7 +557,11 @@ class EngineManager {
535
557
 
536
558
  this.eventBus.emit('system:historyCheckpoint', {
537
559
  domain: 'manager',
538
- state: this.getState(),
560
+ state: {
561
+ manager: this.getState() || null,
562
+ layout: this.getEngine('layout')?.getState() || null,
563
+ display: this.getEngine('display')?.getState() || null,
564
+ },
539
565
  });
540
566
  });
541
567
 
@@ -957,7 +983,7 @@ class EngineManager {
957
983
  setRatio: (type, index, ratio) => layoutEngine.setRatio(type, index, ratio),
958
984
  // setUserLayoutData: (userData) => layoutEngine.setUserLayoutData(userData),
959
985
  setActiveSection: (name) => this.setActiveSection(name),
960
- updateActiveElement: (id, changes) => this.updateActiveElement(id, changes),
986
+ updateActiveElement: (id, changes, emitEvent) => this.updateActiveElement(id, changes, emitEvent),
961
987
 
962
988
  setElements: (elements) => this.setElements(elements),
963
989
  setSectionMode: (mode) => this.setSectionMode(mode),
@@ -48,8 +48,8 @@ function useDisplay(api) {
48
48
  });
49
49
 
50
50
  // activeDesign 변경 감지
51
- _subscribe('display:updateActiveDesign', ({ $design }) => {
52
- state.activeDesign = $design;
51
+ _subscribe('display:updateActiveDesign', ({ $activeDesign }) => {
52
+ state.activeDesign = $activeDesign;
53
53
  });
54
54
 
55
55
  // displayMode 변경 감지 (displaySize가 함께 초기화됨)
@@ -228,9 +228,9 @@ function useLayout(api) {
228
228
  }
229
229
  };
230
230
 
231
- const updateActiveElement = (id, changes) => {
231
+ const updateActiveElement = (id, changes, emitEvent) => {
232
232
  try {
233
- api.layout.updateActiveElement(id, changes);
233
+ api.layout.updateActiveElement(id, changes, emitEvent);
234
234
  } catch (error) {
235
235
  console.error('[useLayout] updateActiveElement 실행 중 오류 발생:', error);
236
236
  }
@@ -4,6 +4,8 @@ const dataConverter = {
4
4
  // 저장: 자유 배치인 경우, position, size 등을 뷰포트 대비 비율로 변환하여 저장
5
5
  normalize(elements, containerWidth, containerHeight) {
6
6
  const normalize = cloneDeep(elements);
7
+ if (!containerWidth || !containerHeight) return normalize;
8
+
7
9
  normalize.map((el) => {
8
10
  if (el.mode !== 'free') return;
9
11
  el.position = {
@@ -25,12 +27,12 @@ const dataConverter = {
25
27
  denormalize.map((el) => {
26
28
  if (el.mode !== 'free') return;
27
29
  el.position = {
28
- x: el.position.x * containerWidth,
29
- y: el.position.y * containerHeight,
30
+ x: Math.round(el.position.x * containerWidth),
31
+ y: Math.round(el.position.y * containerHeight),
30
32
  };
31
33
  el.size = {
32
- w: el.size.w * containerWidth,
33
- h: el.size.h * containerHeight,
34
+ w: Math.round(el.size.w * containerWidth),
35
+ h: Math.round(el.size.h * containerHeight),
34
36
  };
35
37
  });
36
38