polarvo-layout 1.0.57 → 1.0.59

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.57",
3
+ "version": "1.0.59",
4
4
  "type": "module",
5
5
  "author": "unigence <unigencelab@gmail.com>",
6
6
  "repository": {
@@ -90,6 +90,10 @@ 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 기본값으로 폴백
94
+ '--grid-column-sizes': section.config?.gridColumnRatio?.length
95
+ ? section.config.gridColumnRatio.map((r) => `${r}fr`).join(' ')
96
+ : undefined,
93
97
  position: 'relative',
94
98
  });
95
99
 
@@ -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,64 @@
8
8
  :sectionKey="props.sectionKey"
9
9
  ></GridItem>
10
10
 
11
+ <!-- 행 크기 조절 -->
12
+ <div
13
+ v-for="r in gridRows - 1"
14
+ :key="`row-handle-${r}`"
15
+ :id="`row-handle-${r}`"
16
+ class="z-[60] relative self-end justify-self-stretch cursor-s-resize"
17
+ :style="{
18
+ gridRow: r,
19
+ gridColumn: '1 / -1',
20
+ height: `${gridGap}px`,
21
+ transform: 'translateY(100%)',
22
+ }"
23
+ @mousedown="handleMouseDown($event, 'row', r)"
24
+ >
25
+ <!-- 점선 -->
26
+ <div
27
+ class="pointer-events-none absolute left-0 right-0 top-1/2 -translate-y-1/2 border-t border-dashed border-blue-700"
28
+ :class="{
29
+ 'opacity-100': dragState.axis === 'row' && dragState.activeIdx === r,
30
+ 'opacity-0': !(dragState.axis === 'row' && dragState.activeIdx === r),
31
+ }"
32
+ ></div>
33
+
34
+ <!-- 핸들 -->
35
+ <!-- <div
36
+ 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"
37
+ ></div> -->
38
+ </div>
39
+
40
+ <!-- 열 크기 조절 -->
41
+ <div
42
+ v-for="c in gridColumns - 1"
43
+ :key="`col-handle-${c}`"
44
+ :id="`col-handle-${c}`"
45
+ class="z-[60] relative self-stretch justify-self-end cursor-e-resize"
46
+ :style="{
47
+ gridRow: '1 / -1',
48
+ gridColumn: c,
49
+ width: `${gridGap}px`,
50
+ transform: 'translateX(100%)',
51
+ }"
52
+ @mousedown="handleMouseDown($event, 'col', c)"
53
+ >
54
+ <!-- 점선 -->
55
+ <div
56
+ class="pointer-events-none absolute top-0 bottom-0 left-1/2 -translate-x-1/2 border-l border-dashed border-blue-700"
57
+ :class="{
58
+ 'opacity-100': dragState.axis === 'col' && dragState.activeIdx === c,
59
+ 'opacity-0': !(dragState.axis === 'col' && dragState.activeIdx === c),
60
+ }"
61
+ ></div>
62
+
63
+ <!-- 핸들 -->
64
+ <!-- <div
65
+ 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"
66
+ ></div> -->
67
+ </div>
68
+
11
69
  <!-- 오버레이 -->
12
70
  <div v-if="activeEditMode" class="absolute inset-0 bg-black opacity-50 z-30"></div>
13
71
  </template>
@@ -41,9 +99,10 @@ const props = defineProps({
41
99
  },
42
100
  });
43
101
 
44
- const { setActiveDesign } = props.polarvo.display;
45
- const { updateActiveElement } = props.polarvo.layout;
102
+ const { setActiveDesign, setActiveMenu } = props.polarvo.display;
103
+ const { updateActiveElement, setSectionConfig } = props.polarvo.layout;
46
104
  const { elements, activeElement } = toRefs(props.polarvo.gridDrop.state);
105
+ const { activeDesign } = toRefs(props.polarvo.display.state);
47
106
 
48
107
  const selectedElement = inject('selectedElement');
49
108
  const activeEditMode = inject('activeEditMode');
@@ -52,6 +111,172 @@ import { omit, cloneDeep, debounce } from 'lodash-es';
52
111
 
53
112
  const gridColumns = ref(3); // 동적으로 계산
54
113
  const gridRows = ref(1); // 동적으로 계산
114
+ const gridGap = computed(() => props.sectionData?.config?.gridGap ?? 5); // BaseLayout.vue --grid-gap과 동일한 소스/기본값
115
+
116
+ // row, col Resize
117
+ const MIN_ROW_PX = 20; // 행 최소 높이(px)
118
+ const MIN_COL_PX = 20; // 열 최소 너비(px)
119
+
120
+ const DRAG_CONFIG = {
121
+ row: {
122
+ ref: gridRows,
123
+ minPx: MIN_ROW_PX,
124
+ ratioKey: 'gridRowRatio',
125
+ gridTemplateKey: 'gridTemplateRows',
126
+ clientKey: 'clientY',
127
+ positionKey: 'y',
128
+ cssVar: '--grid-row-sizes',
129
+ },
130
+ col: {
131
+ ref: gridColumns,
132
+ minPx: MIN_COL_PX,
133
+ ratioKey: 'gridColumnRatio',
134
+ gridTemplateKey: 'gridTemplateColumns',
135
+ clientKey: 'clientX',
136
+ positionKey: 'x',
137
+ cssVar: '--grid-column-sizes',
138
+ },
139
+ };
140
+
141
+ const dragState = ref({
142
+ axis: null, // 'row' | 'col'
143
+ activeIdx: null,
144
+ isDragging: false,
145
+ });
146
+ const startPos = ref({ x: 0, y: 0 });
147
+
148
+ function handleMouseDown(event, axis, index) {
149
+ if (!index) return;
150
+ event.preventDefault();
151
+ event.stopPropagation();
152
+
153
+ const { ref, minPx, ratioKey, gridTemplateKey, clientKey, positionKey, cssVar } = DRAG_CONFIG[axis];
154
+
155
+ dragState.value.axis = axis;
156
+ dragState.value.activeIdx = index;
157
+ startPos.value = { x: event.clientX, y: event.clientY };
158
+
159
+ setActiveMenu(null); // 메뉴 닫기
160
+ setActiveDesign(null); // 디자인 닫기
161
+
162
+ let dragSnapShot = null; // 드래그 시작 시 스냅샷
163
+ let liveRatio = null; // 미리보기 중 계산된 최종 fr 배열 (stopDrag에서 커밋)
164
+ let pendingClient = null;
165
+ let rafId = null;
166
+
167
+ document.addEventListener('mousemove', _detectDragIntent);
168
+ document.addEventListener('mouseup', _cancelDragIntent);
169
+
170
+ function _detectDragIntent(e) {
171
+ if (dragState.value.axis !== axis || dragState.value.activeIdx !== index) return;
172
+ const deltaX = Math.abs(e.clientX - startPos.value.x);
173
+ const deltaY = Math.abs(e.clientY - startPos.value.y);
174
+
175
+ const isMoved = deltaX > 5 || deltaY > 5;
176
+
177
+ if (isMoved) {
178
+ // 의도 감지 리스너 제거
179
+ document.removeEventListener('mousemove', _detectDragIntent);
180
+ document.removeEventListener('mouseup', _cancelDragIntent);
181
+
182
+ // 드래그 시작
183
+ _startDrag();
184
+ }
185
+ }
186
+
187
+ function _cancelDragIntent() {
188
+ dragState.value.axis = null;
189
+ dragState.value.activeIdx = null;
190
+
191
+ document.removeEventListener('mousemove', _detectDragIntent);
192
+ document.removeEventListener('mouseup', _cancelDragIntent);
193
+ }
194
+
195
+ function _startDrag() {
196
+ dragState.value.isDragging = true;
197
+
198
+ const container = document.getElementById(`${props.sectionKey}Layout`);
199
+ if (!container) return _stopDrag();
200
+
201
+ const configured = props.sectionData?.config?.[ratioKey];
202
+ const baselineRatio =
203
+ (Array.isArray(configured) && configured.length === ref.value) || configured.some((v) => typeof v !== 'number' || !(v > 0))
204
+ ? [...configured]
205
+ : Array(ref.value).fill(1);
206
+
207
+ const trackPx = getComputedStyle(container)
208
+ [gridTemplateKey].split(' ')
209
+ .map((v) => parseFloat(v));
210
+
211
+ // 총 Fr 계산
212
+ const totalFr = baselineRatio.reduce((sum, v) => sum + v, 0);
213
+ // 총 px 계산
214
+ const totalPx = trackPx.reduce((sum, v) => sum + v, 0);
215
+ // 1px당 Fr 계산
216
+ const frPerPx = totalFr / totalPx;
217
+
218
+ dragSnapShot = {
219
+ container,
220
+ baselineRatio,
221
+ frPerPx,
222
+ minFr: minPx * frPerPx, // 최소 Fr 계산
223
+ };
224
+
225
+ document.addEventListener('mousemove', _startDragMove);
226
+ document.addEventListener('mouseup', _stopDrag);
227
+ }
228
+
229
+ function _startDragMove(e) {
230
+ if (!dragSnapShot) return;
231
+ pendingClient = e[clientKey];
232
+
233
+ // rafId 중복 예약 방지
234
+ if (rafId) return;
235
+
236
+ rafId = requestAnimationFrame(() => {
237
+ rafId = null;
238
+
239
+ const { baselineRatio, frPerPx, minFr, container } = dragSnapShot;
240
+ const prevIdx = index - 1; // 드래그 행 위쪽 행 인덱스
241
+ const nextIdx = index; // 드래그 행 인덱스
242
+
243
+ // 순수 이동량
244
+ let delta = (pendingClient - startPos.value[positionKey]) * frPerPx;
245
+ // 위쪽 행 작아지는 것 방지: 가장 커져도 minFr - baselineRatio[prevIdx] (음수값 = 위로 이동)
246
+ delta = Math.max(minFr - baselineRatio[prevIdx], delta);
247
+ // 아래쪽 행 작아지는 것 방지: 가장 작아져도 minFr (양수값 = 아래로 이동)
248
+ delta = Math.min(baselineRatio[nextIdx] - minFr, delta);
249
+
250
+ // 미리보기용
251
+ const preview = [...baselineRatio];
252
+ preview[prevIdx] += delta;
253
+ preview[nextIdx] -= delta;
254
+
255
+ liveRatio = preview;
256
+
257
+ // 화면에만 즉시 반영
258
+ container.style.setProperty(cssVar, preview.map((v) => `${v}fr`).join(' '));
259
+ });
260
+ }
261
+ function _stopDrag() {
262
+ dragState.value.isDragging = false;
263
+ dragState.value.axis = null;
264
+ dragState.value.activeIdx = null;
265
+
266
+ if (liveRatio) {
267
+ setSectionConfig(ratioKey, liveRatio);
268
+ }
269
+
270
+ startPos.value = { x: 0, y: 0 };
271
+ dragSnapShot = null;
272
+ liveRatio = null;
273
+ pendingClient = null;
274
+ rafId = null;
275
+
276
+ document.removeEventListener('mousemove', _startDragMove);
277
+ document.removeEventListener('mouseup', _stopDrag);
278
+ }
279
+ }
55
280
 
56
281
  function initData() {
57
282
  gridColumns.value = props.sectionData?.config?.gridColumns || 3;
@@ -131,11 +356,22 @@ watch(
131
356
  _isElementSwitching = false;
132
357
  return;
133
358
  }
134
- updateActiveElement(selectedElement.value?.id, newElement);
359
+ updateActiveElement(selectedElement.value?.id, newElement);
135
360
  },
136
361
  { deep: true },
137
362
  );
138
363
 
364
+ watch(
365
+ () => activeDesign.value,
366
+ (newDesign, oldDesign) => {
367
+ if (newDesign != null || oldDesign == null) return; // null → null이 아닌 값으로 닫히는(값이 있다가 null 되는) 경우만
368
+
369
+ if (activeElement.value?.id && !activeElement.value.id.includes('inputForm')) {
370
+ updateActiveElement(activeElement.value.id, selectedElementForWatch.value, true);
371
+ }
372
+ },
373
+ );
374
+
139
375
  onMounted(() => {
140
376
  props.polarvo.components.register('sections', props.sectionKey, getCurrentInstance());
141
377
  activeEditMode.value = false;
@@ -146,4 +382,18 @@ onMounted(() => {
146
382
  onUnmounted(() => {
147
383
  props.polarvo.components.unregister('sections', props.sectionKey);
148
384
  });
385
+
386
+ //
149
387
  </script>
388
+
389
+ <style lang="scss" scoped>
390
+ // .re-handle-line {
391
+ // position: absolute;
392
+ // z-index: 0;
393
+ // left: 0;
394
+ // right: 0;
395
+ // top: 50%;
396
+ // height: 1px;
397
+ // transform: translateY(-50%);
398
+ // }
399
+ </style>
@@ -17,8 +17,9 @@ const props = defineProps({
17
17
  <style scoped>
18
18
  .polar-grid {
19
19
  display: grid;
20
- grid-template-columns: repeat(var(--grid-columns, 3), 1fr);
21
- grid-template-rows: repeat(var(--grid-rows, 3), 1fr);
20
+ grid-template-columns: var(--grid-column-sizes, repeat(var(--grid-columns, 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
 
@@ -835,7 +835,7 @@ class FreeDropEngine {
835
835
  guides: this.guides,
836
836
  timestamp: Date.now(),
837
837
  });
838
- this._resetDrag(true, true);
838
+ this._resetDrag(true);
839
839
  // }
840
840
 
841
841
  return;
@@ -862,7 +862,7 @@ class FreeDropEngine {
862
862
  * @param {boolean} emitEvent - 업데이트 이벤트 발송 여부
863
863
  * @param {boolean} historyEvent - 히스토리 이벤트 기록 여부
864
864
  */
865
- _resetDrag(emitEvent = false, historyEvent = false) {
865
+ _resetDrag(emitEvent = false) {
866
866
  this._dragState.isDragging = false;
867
867
  this.guides = { x: null, y: null, w: null, h: null };
868
868
 
@@ -871,17 +871,15 @@ class FreeDropEngine {
871
871
  this._offsetPos = { x: 0, y: 0 };
872
872
  this._dragOriginPos = { x: 0, y: 0 };
873
873
 
874
- if (emitEvent) {
875
- if (this._activeElement) {
876
- this.eventBus.emit('freeDrop:requestUpdateElement', {
877
- historyEvent: historyEvent,
878
- elementId: this._activeElement.id,
879
- position: this._activeElement.position,
880
- size: this._activeElement.size,
881
- guides: this.guides,
882
- timestamp: Date.now(),
883
- });
884
- }
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
+ });
885
883
  }
886
884
 
887
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', 'gridColumnRatio'].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,25 @@ 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 };
489
+ } else if (type === 'gridColumnRatio') {
490
+ // gridColumnRatio 배열은 gridColumns 길이와 일치해야 함
491
+ const gridColumns = this.activeData.config?.gridColumns || 1;
492
+ if (!Array.isArray(value) || value.length !== gridColumns || value.some((v) => typeof v !== 'number' || !(v > 0))) {
493
+ return { result: false, message: '잘못된 열 비율 값입니다.' };
494
+ }
495
+
496
+ newConfig = { gridColumnRatio: value };
470
497
  } else {
498
+ const limit = CONFIG_LIMITS[type];
499
+
471
500
  if (value < limit.min) {
472
501
  result.result = false;
473
502
  result.message = `설정 가능한 최소 ${limit.name}는 ${limit.min}입니다.`;
@@ -479,9 +508,11 @@ class EngineManager {
479
508
  value = Math.min(Math.max(limit.min, value), limit.max);
480
509
 
481
510
  if (type === 'column') {
482
- newConfig = { gridColumns: value };
511
+ // gridColumns 변경되면 gridColumnRatio 초기화
512
+ newConfig = { gridColumns: value, gridColumnRatio: [] };
483
513
  } else if (type === 'row') {
484
- newConfig = { gridRows: value };
514
+ // gridRows 변경되면 gridRowRatio 초기화
515
+ newConfig = { gridRows: value, gridRowRatio: [] };
485
516
  } else if (type === 'gap') {
486
517
  newConfig = { gridGap: value };
487
518
  }
@@ -535,7 +566,11 @@ class EngineManager {
535
566
 
536
567
  this.eventBus.emit('system:historyCheckpoint', {
537
568
  domain: 'manager',
538
- state: this.getState(),
569
+ state: {
570
+ manager: this.getState() || null,
571
+ layout: this.getEngine('layout')?.getState() || null,
572
+ display: this.getEngine('display')?.getState() || null,
573
+ },
539
574
  });
540
575
  });
541
576
 
@@ -957,7 +992,7 @@ class EngineManager {
957
992
  setRatio: (type, index, ratio) => layoutEngine.setRatio(type, index, ratio),
958
993
  // setUserLayoutData: (userData) => layoutEngine.setUserLayoutData(userData),
959
994
  setActiveSection: (name) => this.setActiveSection(name),
960
- updateActiveElement: (id, changes) => this.updateActiveElement(id, changes),
995
+ updateActiveElement: (id, changes, emitEvent) => this.updateActiveElement(id, changes, emitEvent),
961
996
 
962
997
  setElements: (elements) => this.setElements(elements),
963
998
  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