polarvo-layout 1.0.89 → 1.0.90

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.89",
3
+ "version": "1.0.90",
4
4
  "type": "module",
5
5
  "author": "unigence <unigencelab@gmail.com>",
6
6
  "repository": {
@@ -49,7 +49,7 @@ import { toRefs, computed, onMounted, onUnmounted, ref, inject, unref } from 'vu
49
49
  import PolarLayout from './PolarLayout.vue';
50
50
  import FreeLayout from './FreeLayout.vue';
51
51
  import GridLayout from './GridLayout.vue';
52
- import { getGridRowMinPx, toGridTrackSizes, toGridRowSizes, isInLastLayoutRow } from '../../utils/grid-track.js';
52
+ import { getGridRowMinPx, getRowExtraPx, toGridTrackSizes, toGridRowSizes, toSectionRowSizes, isInLastLayoutRow } from '../../utils/grid-track.js';
53
53
 
54
54
  // 우클릭 방지
55
55
  // window.oncontextmenu = () => {
@@ -88,18 +88,26 @@ const extraHeight = computed(() => (props.preview ? 0 : (unref(canvasExtraHeight
88
88
  // 확장 높이는 마지막 행 섹션만 가져감
89
89
  const getSectionExtraHeight = (section) => (isInLastLayoutRow(section, gridRatio.value?.row?.length) ? extraHeight.value : 0);
90
90
 
91
+ // 레이아웃 행 크기
92
+ // - 미리보기: 부모 높이를 벗어나지 않도록 min 0 + 비율(fr)만 사용
93
+ // - 편집 화면: 확장 높이가 있으면 마지막 행만 가져감 (min은 기존 fr과 동일하게 auto - 섹션 콘텐츠 최소 높이 유지), 없으면 기존 fr
94
+ const getBaseRows = () => {
95
+ const rowRatio = gridRatio.value?.row;
96
+ if (!rowRatio) return undefined;
97
+ if (props.preview) return rowRatio.map((ratio) => `minmax(0, ${ratio}fr)`).join(' ');
98
+ if (!(extraHeight.value > 0)) return rowRatio.map((ratio) => `${ratio}fr`).join(' ');
99
+ return toGridRowSizes(
100
+ rowRatio,
101
+ rowRatio.map(() => 'auto'),
102
+ gapSize.value ?? 0,
103
+ extraHeight.value,
104
+ );
105
+ };
106
+
91
107
  const getBaseStyle = computed(() => {
92
108
  return {
93
109
  gridTemplateColumns: gridRatio.value?.column.map((ratio) => `${ratio}fr`).join(' '),
94
- // 확장 높이는 마지막 행만 가져감 (min은 기존 fr과 동일하게 auto - 섹션 콘텐츠 최소 높이 유지)
95
- gridTemplateRows: gridRatio.value?.row
96
- ? toGridRowSizes(
97
- gridRatio.value.row,
98
- gridRatio.value.row.map(() => 'auto'),
99
- gapSize.value ?? 0,
100
- extraHeight.value,
101
- )
102
- : undefined,
110
+ gridTemplateRows: getBaseRows(),
103
111
  marginRight: layoutType.value === 'no-split' ? '0' : layoutType.value === 'three-column-split' ? '14px' : '8px',
104
112
  gap: `${gapSize.value}px`,
105
113
  };
@@ -109,17 +117,19 @@ const getSectionStyle = (key, section) => {
109
117
  const gridRows = section.config?.gridRows ?? 3;
110
118
  const rowRatio = section.config?.gridRowRatio?.length ? section.config.gridRowRatio : Array(gridRows).fill(1);
111
119
  const sectionElements = gridElements.value.filter((el) => el.section === key && section.elementIds?.includes(el.id));
120
+ const gap = section.config?.gridGap ?? 5;
121
+ const extraHeight = getSectionExtraHeight(section);
122
+ // 미리보기는 요소 최소 높이(편집 화면 px) 미적용 - 부모 높이 초과 방지
123
+ const minPx = props.preview
124
+ ? []
125
+ : getGridRowMinPx(sectionElements, gridRows, rowRatio, gap, getRowExtraPx(section.config?.gridRowExtra, gridRows, extraHeight));
112
126
 
113
127
  return {
114
128
  '--grid-columns': section.config?.gridColumns ?? 3,
115
129
  '--grid-rows': gridRows,
116
130
  '--grid-gap': `${section.config?.gridGap ?? 5}px`,
117
- '--grid-row-sizes': toGridRowSizes(
118
- rowRatio,
119
- getGridRowMinPx(sectionElements, gridRows),
120
- section.config?.gridGap ?? 5,
121
- getSectionExtraHeight(section),
122
- ),
131
+ // 비율(gridRowRatio)은 확장 높이 제외 기준 - 확장 높이는 마지막 행 + 위로 넘어간 몫(gridRowExtra)으로 편집 화면에만 붙음
132
+ '--grid-row-sizes': toSectionRowSizes(rowRatio, minPx, gap, extraHeight, section.config?.gridRowExtra),
123
133
  '--grid-column-sizes': section.config?.gridColumnRatio?.length ? toGridTrackSizes(section.config.gridColumnRatio) : undefined, // 비어있으면 아예 안 넣어서 CSS 기본값으로 폴백
124
134
  position: 'relative',
125
135
  };
@@ -26,7 +26,7 @@
26
26
  <script setup>
27
27
  import { toRefs, ref, computed, watch, provide, onMounted } from 'vue';
28
28
  import BaseLayout from './BaseLayout.vue';
29
- import { isInLastLayoutRow, getGridLastRowRequiredPx } from '../../utils/grid-track.js';
29
+ import { GRID_MIN_ROW_PX, isInLastLayoutRow, getRowExtraPx, satisfiesGridRowMin } from '../../utils/grid-track.js';
30
30
 
31
31
  const props = defineProps({
32
32
  polarvo: {
@@ -44,10 +44,14 @@ const DEFAULT_DISPLAY_SIZE = {
44
44
 
45
45
  // height 관련 여유 공간 추가하여 계산함 (50, 200)
46
46
  const { displaySize, canvasSize } = toRefs(props.polarvo.display.state);
47
- const { elements, activeMode, layoutData, gridRatio } = toRefs(props.polarvo.layout.state);
47
+ const { elements, layoutData, gridRatio, canvasExtraHeight } = toRefs(props.polarvo.layout.state);
48
+ const { setCanvasExtraHeight } = props.polarvo.layout;
48
49
  const { elements: gridElements } = toRefs(props.polarvo.gridDrop.state);
49
50
  const { elements: freeElements } = toRefs(props.polarvo.freeDrop.state);
50
51
 
52
+ // 화면에 그리드 섹션이 하나라도 있는지 - 높이 계산 기준 (선택된 섹션 모드와 무관하게 화면 단위로 고정)
53
+ const hasGridSection = computed(() => Object.values(layoutData.value ?? {}).some((section) => section?.mode === 'grid'));
54
+
51
55
  const defaultHeight = computed(() => {
52
56
  const freeElementsBottom =
53
57
  elements.value?.reduce((max, el) => {
@@ -62,8 +66,8 @@ const defaultHeight = computed(() => {
62
66
  // 저장된 canvasSize가 없으면 free 요소 기준 계산값만 사용 (grid 섹션은 기본값 750으로 취급)
63
67
  if (!canvasSize.value) return freeElementsBottom;
64
68
 
65
- // grid: 라이브로 정확히 계산할 방법이 없어 저장된 값을 그대로 신뢰
66
- if (activeMode.value === 'grid') {
69
+ // 그리드 섹션이 있는 화면: 라이브로 정확히 계산할 방법이 없어 저장된 값을 그대로 신뢰
70
+ if (hasGridSection.value) {
67
71
  return canvasSize.value.defaultHeight ?? freeElementsBottom;
68
72
  }
69
73
 
@@ -76,7 +80,7 @@ const defaultHeight = computed(() => {
76
80
  function restoreAdditionalHeight() {
77
81
  if (!canvasSize.value) return 0;
78
82
 
79
- if (activeMode.value === 'grid') {
83
+ if (hasGridSection.value) {
80
84
  return canvasSize.value.additionalHeight ?? 0;
81
85
  }
82
86
 
@@ -84,7 +88,13 @@ function restoreAdditionalHeight() {
84
88
  return Math.max(0, savedTotal - defaultHeight.value);
85
89
  }
86
90
 
87
- const additionalHeight = ref(restoreAdditionalHeight());
91
+ // 확장 높이는 LayoutEngine 상태(canvasExtraHeight)로 관리 - 섹션 행 비율과 함께 히스토리(undo/redo)에 기록됨
92
+ // 직접 대입은 히스토리 없이 동기화만 함 (화면 전환 시 복원 등)
93
+ const additionalHeight = computed({
94
+ get: () => canvasExtraHeight.value ?? 0,
95
+ set: (value) => setCanvasExtraHeight(value),
96
+ });
97
+ additionalHeight.value = restoreAdditionalHeight();
88
98
 
89
99
  // canvasSize(저장된 캔버스 크기)가 바뀌면(=화면 전환) 그 값으로 복원, 없으면 0
90
100
  watch(
@@ -141,55 +151,109 @@ defineExpose({ defaultHeight, additionalHeight, displayHeight });
141
151
  provide('canvasExtraHeight', additionalHeight);
142
152
 
143
153
  const RESIZE_STEP = 200;
144
- const MIN_ROW_PX = 20; // GridLayout.vue의 MIN_ROW_PX와 동일한 값
145
154
 
146
- function expandContainer() {
147
- additionalHeight.value += RESIZE_STEP;
155
+ // 확장 높이가 delta만큼 줄었을 때 행별 확장 높이 몫 - 마지막 행 몫부터 줄이고, 모자라면 아래쪽 행부터 줄임
156
+ function reduceExtraShares(shares, delta) {
157
+ const reduced = [...shares];
158
+ for (let i = reduced.length - 1; i >= 0 && delta > 0; i--) {
159
+ const take = Math.min(reduced[i], delta);
160
+ reduced[i] -= take;
161
+ delta -= take;
162
+ }
163
+ return reduced;
148
164
  }
149
165
 
150
- // 확장 높이를 받는 마지막 행 섹션들이 요소 최소 크기를 유지하면서 줄어들 수 있는 높이(px)
151
- // 확장 높이는 마지막 행만 가져가므로 마지막 행 섹션의 여유분이 곧 줄일 수 있는 최대치
152
- function getShrinkableHeight() {
153
- let slack = additionalHeight.value;
166
+ // 마지막 행 그리드 섹션의 현재 행 px / 행별 확장 높이 몫 / 요소 (DOM 기준)
167
+ function getGridSectionInfo(key, section, sectionEl) {
168
+ const gridRows = section.config?.gridRows || 1;
169
+ const trackPx = getComputedStyle(sectionEl)
170
+ .gridTemplateRows.split(' ')
171
+ .slice(0, gridRows)
172
+ .map((v) => parseFloat(v) || 0);
154
173
 
155
- Object.entries(layoutData.value ?? {}).forEach(([key, section]) => {
156
- if (!isInLastLayoutRow(section, gridRatio.value?.row?.length)) return;
157
- const sectionEl = document.getElementById(`${key}Layout`);
158
- if (!sectionEl) return;
174
+ // GridLayout.filteredElements와 동일 (범위 밖 요소 제외)
175
+ const elements = (gridElements.value ?? []).filter(
176
+ (el) => el.section === key && section.elementIds?.includes(el.id) && el.position.row >= 1 && el.position.row + el.size.rowSpan - 1 <= gridRows,
177
+ );
159
178
 
160
- const modeElements = (section.mode === 'grid' ? gridElements.value : freeElements.value) ?? [];
161
- const sectionElements = modeElements.filter((el) => el.section === key && section.elementIds?.includes(el.id));
179
+ return {
180
+ trackPx,
181
+ shares: getRowExtraPx(section.config?.gridRowExtra, gridRows, additionalHeight.value),
182
+ elements,
183
+ gap: section.config?.gridGap ?? 5,
184
+ };
185
+ }
186
+
187
+ // 그리드 섹션이 행/요소 최소 크기를 유지하면서 줄어들 수 있는 최대 px (limit 이하)
188
+ function getGridShrinkable({ trackPx, shares, elements, gap }, limit) {
189
+ const canShrinkBy = (delta) => {
190
+ const next = reduceExtraShares(shares, delta);
191
+ const nextPx = trackPx.map((v, i) => v - (shares[i] - next[i]));
192
+ // 줄어드는 행만 검사 (기존에 이미 최소 크기를 못 맞춘 다른 행 때문에 막히지 않도록)
193
+ const shrunkRows = next.map((v, i) => (v < shares[i] ? i : -1)).filter((i) => i >= 0);
194
+ return satisfiesGridRowMin(elements, nextPx, gap, GRID_MIN_ROW_PX, shrunkRows);
195
+ };
162
196
 
197
+ for (let delta = limit; delta > 0; delta--) {
198
+ if (canShrinkBy(delta)) return delta;
199
+ }
200
+ return 0;
201
+ }
202
+
203
+ // 확장 높이를 받는 마지막 행 섹션 목록 (DOM이 있는 것만)
204
+ function getExtraSections() {
205
+ return Object.entries(layoutData.value ?? {})
206
+ .filter(([, section]) => isInLastLayoutRow(section, gridRatio.value?.row?.length))
207
+ .map(([key, section]) => ({ key, section, sectionEl: document.getElementById(`${key}Layout`) }))
208
+ .filter(({ sectionEl }) => sectionEl);
209
+ }
210
+
211
+ // 확장 높이를 받는 마지막 행 섹션들이 요소 최소 크기를 유지하면서 줄어들 수 있는 높이(px, limit 이하)
212
+ function getShrinkableHeight(limit = additionalHeight.value) {
213
+ let slack = Math.max(0, Math.floor(limit));
214
+
215
+ getExtraSections().forEach(({ key, section, sectionEl }) => {
163
216
  if (section.mode === 'grid') {
164
- const gridRows = section.config?.gridRows ?? 3;
165
- const trackPx = getComputedStyle(sectionEl)
166
- .gridTemplateRows.split(' ')
167
- .map((v) => parseFloat(v));
168
- const lastTrackPx = trackPx[gridRows - 1] ?? 0;
169
- const required = getGridLastRowRequiredPx(sectionElements, gridRows, trackPx, section.config?.gridGap ?? 5, MIN_ROW_PX);
170
- slack = Math.min(slack, lastTrackPx - required);
217
+ slack = Math.min(slack, getGridShrinkable(getGridSectionInfo(key, section, sectionEl), slack));
171
218
  } else {
219
+ const sectionElements = (freeElements.value ?? []).filter((el) => el.section === key && section.elementIds?.includes(el.id));
172
220
  const bottom = sectionElements.reduce((max, el) => Math.max(max, (el.position?.y || 0) + (el.size?.h || 0)), 0);
173
- slack = Math.min(slack, sectionEl.clientHeight - bottom);
221
+ slack = Math.min(slack, Math.floor(sectionEl.clientHeight - bottom));
174
222
  }
175
223
  });
176
224
 
177
- return Math.max(0, Math.floor(slack));
225
+ return Math.max(0, slack);
226
+ }
227
+
228
+ // 확장: 늘어난 높이는 마지막 행이 전부 가져감 (비율/위로 넘어간 확장분은 그대로)
229
+ function expandContainer() {
230
+ setCanvasExtraHeight(additionalHeight.value + RESIZE_STEP, {}, true);
178
231
  }
179
232
 
180
233
  // 요소 크기/배치·레이아웃·확장 높이가 바뀔 때마다 DOM 반영 후 다시 계산
181
234
  const shrinkableHeight = ref(0);
182
235
  const updateShrinkableHeight = () => {
183
- shrinkableHeight.value = getShrinkableHeight();
236
+ shrinkableHeight.value = getShrinkableHeight(Math.min(RESIZE_STEP, additionalHeight.value));
184
237
  };
185
238
  watch([displayHeight, gridElements, freeElements, layoutData, gridRatio], updateShrinkableHeight, { deep: true, flush: 'post' });
186
239
  onMounted(updateShrinkableHeight);
187
240
 
188
- // displayHeight(=defaultHeight + additionalHeight)는 항상 defaultHeight 이상, 마지막 행 요소 최소 크기 이상으로 유지
241
+ // 축소: 마지막 행 몫부터 줄이고, 모자라면 위로 넘어간 확장분(gridRowExtra)도 줄임 - 확장 높이와 함께 히스토리에 한 번 기록
242
+ // displayHeight(=defaultHeight + additionalHeight)는 항상 defaultHeight 이상, 요소 최소 크기 이상으로 유지
189
243
  const canShrink = computed(() => shrinkableHeight.value > 0);
190
244
  function shrinkContainer() {
191
- const shrinkable = getShrinkableHeight();
192
- additionalHeight.value -= Math.min(RESIZE_STEP, shrinkable);
245
+ const delta = getShrinkableHeight(Math.min(RESIZE_STEP, additionalHeight.value));
246
+ if (!delta) return;
247
+
248
+ // 위로 넘어간 확장분(gridRowExtra)이 줄어드는 섹션만 함께 저장
249
+ const rowExtras = {};
250
+ getExtraSections().forEach(({ key, section, sectionEl }) => {
251
+ if (section.mode !== 'grid' || !section.config?.gridRowExtra?.length) return;
252
+ const moved = reduceExtraShares(getGridSectionInfo(key, section, sectionEl).shares, delta).slice(0, -1);
253
+ if (moved.some((v, i) => v !== section.config.gridRowExtra[i])) rowExtras[key] = moved;
254
+ });
255
+
256
+ setCanvasExtraHeight(additionalHeight.value - delta, rowExtras, true);
193
257
  }
194
258
  </script>
195
259
 
@@ -1,8 +1,8 @@
1
1
  <template>
2
2
  <template v-if="!preview">
3
3
  <GridItem
4
- v-for="(item, index) in mergedData"
5
- :key="item.id || `empty-${index}`"
4
+ v-for="item in mergedData"
5
+ :key="item.id || `empty-${item.row}-${item.col}`"
6
6
  :item="item"
7
7
  :polarvo="props.polarvo"
8
8
  :sectionKey="props.sectionKey"
@@ -74,7 +74,7 @@
74
74
 
75
75
  <script setup>
76
76
  import GridItem from './GridItem.vue';
77
- import { getGridRowMinPx, toGridTrackSizes, toGridRowSizes } from '../../utils/grid-track.js';
77
+ import { GRID_MIN_ROW_PX, getGridRowMinPx, toGridTrackSizes, toSectionRowSizes, getRowExtraPx } from '../../utils/grid-track.js';
78
78
 
79
79
  import { ref, toRefs, getCurrentInstance, onMounted, onUnmounted, inject, watch, computed } from 'vue';
80
80
 
@@ -99,7 +99,7 @@ const props = defineProps({
99
99
  type: Array,
100
100
  required: true,
101
101
  },
102
- // 캔버스 확장 높이 - 마지막 행이 추가로 가져가는 px (BaseLayout에서 마지막 행 섹션에만 전달)
102
+ // 캔버스 확장 높이 - 이 섹션이 추가로 가져가는 px (BaseLayout에서 마지막 행 섹션에만 전달, 미리보기는 0)
103
103
  extraHeight: {
104
104
  type: Number,
105
105
  default: 0,
@@ -127,7 +127,7 @@ const gridRows = ref(1); // 동적으로 계산
127
127
  const gridGap = computed(() => props.sectionData?.config?.gridGap ?? 5); // BaseLayout.vue --grid-gap과 동일한 소스/기본값
128
128
 
129
129
  // row, col Resize
130
- const MIN_ROW_PX = 20; // 행 최소 높이(px)
130
+ const MIN_ROW_PX = GRID_MIN_ROW_PX; // 행 최소 높이(px)
131
131
  const MIN_COL_PX = 20; // 열 최소 너비(px)
132
132
 
133
133
  const DRAG_CONFIG = {
@@ -142,8 +142,19 @@ const DRAG_CONFIG = {
142
142
  posKey: 'row',
143
143
  spanKey: 'rowSpan',
144
144
  getMinPx: (el) => el.size?.h ?? MIN_ROW_PX, // GridDropEngine.getElementStyle의 minHeight와 동일
145
- extraPx: () => props.extraHeight, // 마지막 행에 붙은 확장 높이 (비율 계산에서 제외)
146
- toSizes: (ratio) => toGridRowSizes(ratio, getGridRowMinPx(filteredElements.value, ratio.length), gridGap.value, props.extraHeight), // BaseLayout과 동일한 형식
145
+ // 행별 확장 높이 몫 - 비율에는 섞지 않고 편집 화면에만 붙임
146
+ getExtraPx: () => getRowExtraPx(props.sectionData?.config?.gridRowExtra, gridRows.value, props.extraHeight),
147
+ // BaseLayout과 동일한 형식
148
+ toSizes: (ratio, extras) =>
149
+ toSectionRowSizes(
150
+ ratio,
151
+ getGridRowMinPx(filteredElements.value, ratio.length, ratio, gridGap.value, extras),
152
+ gridGap.value,
153
+ props.extraHeight,
154
+ extras.slice(0, -1),
155
+ ),
156
+ // 확장 높이를 받는 섹션만 gridRowExtra를 함께 저장 (비율 + 확장분 한 번에 히스토리 기록)
157
+ toConfig: (ratio, extras) => (props.extraHeight > 0 ? { ratio, extra: extras.slice(0, -1).map((v) => Math.max(0, v)) } : ratio),
147
158
  },
148
159
  col: {
149
160
  ref: gridColumns,
@@ -156,8 +167,9 @@ const DRAG_CONFIG = {
156
167
  posKey: 'col',
157
168
  spanKey: 'colSpan',
158
169
  getMinPx: () => MIN_COL_PX,
159
- extraPx: () => 0,
170
+ getExtraPx: () => Array(gridColumns.value).fill(0),
160
171
  toSizes: (ratio) => toGridTrackSizes(ratio), // BaseLayout과 동일한 형식
172
+ toConfig: (ratio) => ratio,
161
173
  },
162
174
  };
163
175
 
@@ -173,7 +185,7 @@ function handleMouseDown(event, axis, index) {
173
185
  event.preventDefault();
174
186
  event.stopPropagation();
175
187
 
176
- const { ref, minPx, ratioKey, gridTemplateKey, clientKey, positionKey, cssVar, posKey, spanKey, getMinPx, extraPx, toSizes } =
188
+ const { ref, minPx, ratioKey, gridTemplateKey, clientKey, positionKey, cssVar, posKey, spanKey, getMinPx, getExtraPx, toSizes, toConfig } =
177
189
  DRAG_CONFIG[axis];
178
190
 
179
191
  dragState.value.axis = axis;
@@ -185,6 +197,7 @@ function handleMouseDown(event, axis, index) {
185
197
 
186
198
  let dragSnapShot = null; // 드래그 시작 시 스냅샷
187
199
  let liveRatio = null; // 미리보기 중 계산된 최종 fr 배열 (stopDrag에서 커밋)
200
+ let liveExtras = null; // 미리보기 중 계산된 트랙별 확장 높이 px (stopDrag에서 커밋)
188
201
  let pendingClient = null;
189
202
  let rafId = null;
190
203
 
@@ -230,27 +243,33 @@ function handleMouseDown(event, axis, index) {
230
243
 
231
244
  const trackPx = getComputedStyle(container)
232
245
  [gridTemplateKey].split(' ')
246
+ .slice(0, ref.value)
233
247
  .map((v) => parseFloat(v));
234
248
 
235
249
  const prevIdx = index - 1; // 드래그 행 위쪽 행 인덱스
236
250
  const nextIdx = index; // 드래그 행 인덱스
237
251
 
238
- // 1px당 Fr 계산 - 드래그로 바뀌는 두 트랙 기준
239
- // 마지막 트랙의 확장 높이는 비율과 무관하게 붙는 값이라 제외
240
- const ratioPx = [...trackPx];
241
- ratioPx[ratioPx.length - 1] -= extraPx();
242
- const frPerPx = (baselineRatio[prevIdx] + baselineRatio[nextIdx]) / (ratioPx[prevIdx] + ratioPx[nextIdx]);
252
+ // 트랙 px = 비율 몫(basePx) + 확장 높이 몫(extraPx)
253
+ const baselineExtras = getExtraPx();
254
+ const basePx = trackPx.map((v, i) => v - baselineExtras[i]);
243
255
 
244
- // 요소 최소 크기 때문에 두 트랙이 가져야 하는 최소 px
256
+ // 1px당 Fr 계산 - 드래그로 바뀌는 두 트랙의 비율 몫 기준 (확장 높이는 비율과 무관)
257
+ const frPerPx = (baselineRatio[prevIdx] + baselineRatio[nextIdx]) / (basePx[prevIdx] + basePx[nextIdx]);
258
+
259
+ // 요소 최소 크기 때문에 두 트랙이 가져야 하는 최소 px (확장 높이 포함한 트랙 전체 기준)
245
260
  const { prevMinPx, nextMinPx } = _getElementMinPx(trackPx, prevIdx, nextIdx);
246
261
 
247
262
  dragSnapShot = {
248
263
  container,
249
264
  baselineRatio,
265
+ baselineExtras,
250
266
  frPerPx,
251
- prevMinFr: Math.max(minPx, prevMinPx) * frPerPx,
252
- // 마지막 트랙은 확장 높이가 이미 확보돼 있으므로 그만큼 비율 최소값에서 뺌
253
- nextMinFr: Math.max(minPx, nextMinPx - (trackPx[nextIdx] - ratioPx[nextIdx])) * frPerPx,
267
+ // 두 트랙이 각각 줄어들 수 있는 최대 px
268
+ prevShrinkPx: Math.max(0, trackPx[prevIdx] - Math.max(minPx, prevMinPx)),
269
+ nextShrinkPx: Math.max(0, trackPx[nextIdx] - Math.max(minPx, nextMinPx)),
270
+ // 비율 몫이 줄어들 수 있는 최대 px (미리보기에서도 행 최소 크기 유지)
271
+ prevBaseShrinkPx: Math.max(0, basePx[prevIdx] - minPx),
272
+ nextBaseShrinkPx: Math.max(0, basePx[nextIdx] - minPx),
254
273
  };
255
274
 
256
275
  document.addEventListener('mousemove', _startDragMove);
@@ -294,26 +313,42 @@ function handleMouseDown(event, axis, index) {
294
313
  rafId = requestAnimationFrame(() => {
295
314
  rafId = null;
296
315
 
297
- const { baselineRatio, frPerPx, prevMinFr, nextMinFr, container } = dragSnapShot;
316
+ const { baselineRatio, baselineExtras, frPerPx, prevShrinkPx, nextShrinkPx, prevBaseShrinkPx, nextBaseShrinkPx, container } =
317
+ dragSnapShot;
298
318
  const prevIdx = index - 1; // 드래그 행 위쪽 행 인덱스
299
319
  const nextIdx = index; // 드래그 행 인덱스
300
320
 
301
- // 순수 이동량
302
- let delta = (pendingClient - startPos.value[positionKey]) * frPerPx;
303
- // 위쪽 행 작아지는 것 방지: 가장 커져도 prevMinFr - baselineRatio[prevIdx] (음수값 = 위로 이동)
304
- delta = Math.max(prevMinFr - baselineRatio[prevIdx], delta);
305
- // 아래쪽 행 작아지는 것 방지: 가장 작아져도 nextMinFr (양수값 = 아래로 이동)
306
- delta = Math.min(baselineRatio[nextIdx] - nextMinFr, delta);
321
+ // 순수 이동량(px) - 양수 = 아래로 이동(위쪽 행 커짐), 음수 = 위로 이동(아래쪽 행 커짐)
322
+ // 두 트랙 모두 요소/행 최소 크기 이하로 작아지지 않도록 제한
323
+ const movePx = Math.min(Math.max(pendingClient - startPos.value[positionKey], -prevShrinkPx), nextShrinkPx);
324
+
325
+ // 줄어드는 트랙에서 옮길 px를 비율 몫 / 확장 높이 몫으로 나눔 - 확장 높이는 아래 행에 우선 배정
326
+ // 아래쪽 행이 줄어들 때: 비율 몫 먼저 → 최소치에 닿으면 확장 높이 몫을 위 행으로 넘김
327
+ // 위쪽 행이 줄어들 때: 넘겨받은 확장 높이 몫 먼저 아래 행으로 돌려줌 → 그다음 비율 몫
328
+ let ratioPx = 0; // 위쪽 행으로 옮겨지는 비율 몫 px (음수 = 아래쪽 행으로)
329
+ let extraPx = 0; // 위쪽 행으로 옮겨지는 확장 높이 몫 px (음수 = 아래쪽 행으로)
330
+ if (movePx >= 0) {
331
+ ratioPx = Math.min(movePx, nextBaseShrinkPx);
332
+ extraPx = Math.min(movePx - ratioPx, baselineExtras[nextIdx]);
333
+ } else {
334
+ extraPx = -Math.min(-movePx, baselineExtras[prevIdx]);
335
+ ratioPx = -Math.min(-movePx + extraPx, prevBaseShrinkPx);
336
+ }
307
337
 
308
338
  // 미리보기용
309
339
  const preview = [...baselineRatio];
310
- preview[prevIdx] += delta;
311
- preview[nextIdx] -= delta;
340
+ preview[prevIdx] += ratioPx * frPerPx;
341
+ preview[nextIdx] -= ratioPx * frPerPx;
342
+
343
+ const extras = [...baselineExtras];
344
+ extras[prevIdx] += extraPx;
345
+ extras[nextIdx] -= extraPx;
312
346
 
313
347
  liveRatio = preview;
348
+ liveExtras = extras;
314
349
 
315
350
  // 화면에만 즉시 반영
316
- container.style.setProperty(cssVar, toSizes(preview));
351
+ container.style.setProperty(cssVar, toSizes(preview, extras));
317
352
  });
318
353
  }
319
354
  function _stopDrag() {
@@ -322,12 +357,13 @@ function handleMouseDown(event, axis, index) {
322
357
  dragState.value.activeIdx = null;
323
358
 
324
359
  if (liveRatio) {
325
- setSectionConfig(ratioKey, liveRatio);
360
+ setSectionConfig(ratioKey, toConfig(liveRatio, liveExtras));
326
361
  }
327
362
 
328
363
  startPos.value = { x: 0, y: 0 };
329
364
  dragSnapShot = null;
330
365
  liveRatio = null;
366
+ liveExtras = null;
331
367
  pendingClient = null;
332
368
  rafId = null;
333
369
 
@@ -1,4 +1,5 @@
1
1
  import { cloneDeep } from 'lodash-es';
2
+ import { GRID_MIN_ROW_PX } from '../../utils/grid-track.js';
2
3
 
3
4
  // event 발행 시 'gridDrop'으로 시작하는 이름 사용
4
5
  class GridDropEngine {
@@ -245,12 +246,10 @@ class GridDropEngine {
245
246
  * @returns {object} - 스타일 객체
246
247
  */
247
248
  getElementStyle(type, cell) {
248
- const MIN_CELL_PX = 20; // GridLayout.vue의 MIN_ROW_PX와 동일한 값
249
-
250
249
  if (type == 'empty') {
251
250
  return {
252
251
  gridArea: `${cell.row} / ${cell.col} / span 1 / span 1`,
253
- minHeight: `${MIN_CELL_PX}px`,
252
+ minHeight: `${GRID_MIN_ROW_PX}px`,
254
253
  };
255
254
  } else if (type == 'element') {
256
255
  const { row, col } = cell.position;
@@ -258,7 +257,7 @@ class GridDropEngine {
258
257
 
259
258
  return {
260
259
  gridArea: `${row} / ${col} / span ${rowSpan} / span ${colSpan}`,
261
- minHeight: `${cell.size?.h ?? MIN_CELL_PX}px`,
260
+ minHeight: `${cell.size?.h ?? GRID_MIN_ROW_PX}px`,
262
261
  };
263
262
  }
264
263
  }
@@ -477,10 +476,21 @@ class GridDropEngine {
477
476
  console.error('[GridDropEngine] 활성화된 배치요소를 찾을 수 없습니다.');
478
477
  return false;
479
478
  }
480
- if (col === this._activeElement.position.col && row === this._activeElement.position.row) return;
481
479
 
482
- this._activeElement.position.col = col;
483
- this._activeElement.position.row = row;
480
+ // 요소 크기만큼 그리드 범위 안으로 제한 (범위를 벗어나면 GridLayout.filteredElements에서 제외되어 사라짐)
481
+ const { colSpan, rowSpan } = this._activeElement.size;
482
+ const { gridColumns, gridRows } = this._activeConfig ?? {};
483
+ const nextCol = Math.max(1, Math.min(col, (gridColumns ?? col) - colSpan + 1));
484
+ const nextRow = Math.max(1, Math.min(row, (gridRows ?? row) - rowSpan + 1));
485
+
486
+ if (nextCol === this._activeElement.position.col && nextRow === this._activeElement.position.row) return;
487
+
488
+ // 다른 요소와 겹치면 이동하지 않음
489
+ const nextPosition = { ...this._activeElement.position, col: nextCol, row: nextRow };
490
+ if (this._detectElementCollision(nextPosition, this._activeElement.size)) return;
491
+
492
+ this._activeElement.position.col = nextCol;
493
+ this._activeElement.position.row = nextRow;
484
494
 
485
495
  this.eventBus.emit('gridDrop:requestUpdateLocalBounds', {
486
496
  elementId: this._activeElement.id,
@@ -12,6 +12,7 @@ class LayoutEngine {
12
12
  this.gridNumber = { column: 1, row: 1 }; // 레이아웃 그리드 수
13
13
  this.gridRatio = { column: [100], row: [100] }; // 레이아웃 그리드 비율
14
14
  this.gapSize = 8; // 레이아웃 갭 간격
15
+ this.canvasExtraHeight = 0; // 캔버스 확장 높이(px) - 마지막 행에 배정, 히스토리에 기록
15
16
 
16
17
  this._subscriptions = []; // 구독 해제 함수 저장용
17
18
  this._setupSubscriptions();
@@ -139,6 +140,7 @@ class LayoutEngine {
139
140
  gridNumber: this.gridNumber,
140
141
  gridRatio: this.gridRatio,
141
142
  gapSize: this.gapSize,
143
+ canvasExtraHeight: this.canvasExtraHeight,
142
144
  });
143
145
  }
144
146
 
@@ -154,6 +156,10 @@ class LayoutEngine {
154
156
  row: [...state.gridRatio?.row],
155
157
  };
156
158
  this.gapSize = state.gapSize;
159
+ // 이전 버전 스냅샷에는 값이 없으므로 있을 때만 복원
160
+ if (typeof state.canvasExtraHeight === 'number') {
161
+ this.canvasExtraHeight = state.canvasExtraHeight;
162
+ }
157
163
  }
158
164
 
159
165
  /** ---------------------------------- 공통 메소드 ---------------------------------- **/
@@ -278,6 +284,49 @@ class LayoutEngine {
278
284
  return { result: true, message: '갭 사이즈가 성공적으로 변경되었습니다.' };
279
285
  }
280
286
 
287
+ /** 캔버스 확장 높이 변경 (+ 확장분을 받는 섹션들의 gridRowExtra 일괄 변경)
288
+ * gridRowExtra: 편집 화면 전용 - 마지막 행에서 위 행으로 넘어간 확장 높이(px), 길이 = gridRows - 1
289
+ * (미리보기용 비율 gridRowRatio에는 확장 높이가 섞이지 않음)
290
+ * @param {number} height - 새로운 확장 높이(px)
291
+ * @param {object} rowExtras - { [sectionKey]: gridRowExtra[] } - 함께 바꿀 섹션 값 (축소로 넘어간 확장분이 줄어드는 경우)
292
+ * @param {boolean} record - 히스토리 기록 여부 (확장/축소 버튼 = true, 화면 전환 시 복원 = false)
293
+ */
294
+ setCanvasExtraHeight(height, rowExtras = {}, record = false) {
295
+ const value = Number(height);
296
+ if (!Number.isFinite(value) || value < 0) {
297
+ return { result: false, message: '잘못된 캔버스 확장 높이입니다.' };
298
+ }
299
+
300
+ const changedSections = {};
301
+ Object.entries(rowExtras ?? {}).forEach(([key, extra]) => {
302
+ const section = this.layoutData?.[key];
303
+ const gridRows = section?.config?.gridRows || 1;
304
+ if (!section || !Array.isArray(extra) || extra.length !== gridRows - 1 || extra.some((v) => typeof v !== 'number' || !(v >= 0))) return;
305
+
306
+ changedSections[key] = { ...section, config: { ...section.config, gridRowExtra: [...extra] } };
307
+ });
308
+
309
+ const sectionKeys = Object.keys(changedSections);
310
+ if (value === this.canvasExtraHeight && !sectionKeys.length) {
311
+ return { result: true, message: '변경 사항이 없습니다.' };
312
+ }
313
+
314
+ if (sectionKeys.length) {
315
+ this.layoutData = { ...this.layoutData, ...changedSections };
316
+ }
317
+ this.canvasExtraHeight = value;
318
+
319
+ this.eventBus.emit('layout:updateCanvasExtraHeight', {
320
+ $canvasExtraHeight: this.canvasExtraHeight,
321
+ $layoutData: this.layoutData,
322
+ $sections: sectionKeys,
323
+ $record: record,
324
+ timestamp: Date.now(),
325
+ });
326
+
327
+ return { result: true, message: '캔버스 확장 높이가 성공적으로 변경되었습니다.' };
328
+ }
329
+
281
330
  /** 섹션 비율 변경
282
331
  * @param {string} type - 'column' | 'row'
283
332
  * @param {number} index - 변경할 비율의 인덱스
@@ -374,6 +374,17 @@ class EngineManager {
374
374
  });
375
375
  });
376
376
 
377
+ // 캔버스 확장/축소 - 확장 높이와 섹션 gridRowExtra를 한 번에 기록
378
+ this._subscribe('layout:updateCanvasExtraHeight', ({ $layoutData, $record }) => {
379
+ this.state.layoutData = $layoutData;
380
+
381
+ if (!$record) return;
382
+ this.eventBus.emit('system:historyCheckpoint', {
383
+ domain: 'layout',
384
+ state: this.getEngine('layout')?.getState() || null,
385
+ });
386
+ });
387
+
377
388
  this._subscribe('layout:updateGapSize', () => {
378
389
  this.eventBus.emit('system:historyCheckpoint', {
379
390
  domain: 'layout',
@@ -481,6 +492,7 @@ class EngineManager {
481
492
  gridRows: this.activeData.config?.gridRows || 2,
482
493
  gridGap: this.activeData.config?.gridGap || 4,
483
494
  gridRowRatio: this.activeData.config?.gridRowRatio || [],
495
+ gridRowExtra: this.activeData.config?.gridRowExtra || [],
484
496
  gridColumnRatio: this.activeData.config?.gridColumnRatio || [],
485
497
  showGuideLine: this.activeData.config?.showGuideLine || false,
486
498
  };
@@ -579,13 +591,21 @@ class EngineManager {
579
591
  if (type === 'guideLine') {
580
592
  newConfig = { showGuideLine: value };
581
593
  } else if (type === 'gridRowRatio') {
594
+ // 배열(비율만) 또는 { ratio, extra } (비율 + 위 행으로 넘어간 캔버스 확장분 px - 편집 화면 전용) - 한 번에 히스토리 기록
595
+ const ratio = Array.isArray(value) ? value : value?.ratio;
596
+ const extra = Array.isArray(value) ? undefined : value?.extra;
597
+
582
598
  // gridRowRatio 배열은 gridRows 길이와 일치해야 함
583
599
  const gridRows = this.activeData.config?.gridRows || 1;
584
- if (!Array.isArray(value) || value.length !== gridRows || value.some((v) => typeof v !== 'number' || !(v > 0))) {
600
+ if (!Array.isArray(ratio) || ratio.length !== gridRows || ratio.some((v) => typeof v !== 'number' || !(v > 0))) {
585
601
  return { result: false, message: '잘못된 행 비율 값입니다.' };
586
602
  }
603
+ // gridRowExtra 배열은 gridRows - 1 길이와 일치해야 함
604
+ if (extra !== undefined && (!Array.isArray(extra) || extra.length !== gridRows - 1 || extra.some((v) => typeof v !== 'number' || !(v >= 0)))) {
605
+ return { result: false, message: '잘못된 행 확장 높이 값입니다.' };
606
+ }
587
607
 
588
- newConfig = { gridRowRatio: value };
608
+ newConfig = extra === undefined ? { gridRowRatio: ratio } : { gridRowRatio: ratio, gridRowExtra: extra };
589
609
  } else if (type === 'gridColumnRatio') {
590
610
  // gridColumnRatio 배열은 gridColumns 길이와 일치해야 함
591
611
  const gridColumns = this.activeData.config?.gridColumns || 1;
@@ -614,7 +634,8 @@ class EngineManager {
614
634
  } else if (type === 'row') {
615
635
  // gridRows 변경되면 gridRowRatio 초기화
616
636
  // newConfig = { gridRows: value, gridRowRatio: [] };
617
- newConfig = { gridRows: value, gridRowRatio: this._calculateGridRatio(type, value) };
637
+ // 행 개수가 바뀌면 위 행으로 넘어간 확장분도 초기화 (확장분 전체가 다시 마지막 행으로)
638
+ newConfig = { gridRows: value, gridRowRatio: this._calculateGridRatio(type, value), gridRowExtra: [] };
618
639
  } else if (type === 'gap') {
619
640
  newConfig = { gridGap: value };
620
641
  }
@@ -1073,6 +1094,7 @@ class EngineManager {
1073
1094
  gapSize: layoutEngine.gapSize,
1074
1095
  gridNumber: cloneDeep(layoutEngine.gridNumber),
1075
1096
  gridRatio: cloneDeep(layoutEngine.gridRatio),
1097
+ canvasExtraHeight: layoutEngine.canvasExtraHeight,
1076
1098
  }),
1077
1099
 
1078
1100
  getFreeDropState: () => ({
@@ -1110,6 +1132,7 @@ class EngineManager {
1110
1132
  setLayoutName: (name, userData = null, setting = false) => layoutEngine.setLayoutName(name, userData, setting),
1111
1133
  setGapSize: (size) => layoutEngine.setGapSize(size),
1112
1134
  setRatio: (type, index, ratio) => layoutEngine.setRatio(type, index, ratio),
1135
+ setCanvasExtraHeight: (height, rowExtras, record) => layoutEngine.setCanvasExtraHeight(height, rowExtras, record),
1113
1136
  // setUserLayoutData: (userData) => layoutEngine.setUserLayoutData(userData),
1114
1137
  setActiveSection: (name) => this.setActiveSection(name),
1115
1138
  updateActiveElement: (id, changes, emitEvent) => this.updateActiveElement(id, changes, emitEvent),
@@ -17,6 +17,7 @@ function useLayout(api) {
17
17
  gapSize: null,
18
18
  gridNumber: {},
19
19
  gridRatio: {},
20
+ canvasExtraHeight: 0,
20
21
  });
21
22
 
22
23
  const _subscriptions = [];
@@ -82,6 +83,15 @@ function useLayout(api) {
82
83
  state.gridRatio[$type] = [...$gridRatio[$type]];
83
84
  });
84
85
 
86
+ // 캔버스 확장 높이 변경 감지 (축소 시 섹션 gridRowExtra가 함께 바뀜)
87
+ _subscribe('layout:updateCanvasExtraHeight', ({ $canvasExtraHeight, $layoutData, $sections }) => {
88
+ state.canvasExtraHeight = $canvasExtraHeight;
89
+ $sections.forEach((key) => {
90
+ state.layoutData[key] = { ...$layoutData[key] };
91
+ if (state.activeSection === key) state.activeConfig = $layoutData[key]?.config || null;
92
+ });
93
+ });
94
+
85
95
  // activeSection 변경 감지 (activeData가 함께 초기화됨)
86
96
  _subscribe('system:updateActiveSection', ({ $section, $mode, $config }) => {
87
97
  state.activeSection = $section;
@@ -132,6 +142,7 @@ function useLayout(api) {
132
142
  row: [...snapShot.layout.gridRatio.row],
133
143
  };
134
144
  state.gapSize = snapShot.layout.gapSize;
145
+ if (typeof snapShot.layout.canvasExtraHeight === 'number') state.canvasExtraHeight = snapShot.layout.canvasExtraHeight;
135
146
  }
136
147
 
137
148
  if (domain === 'manager') {
@@ -148,6 +159,7 @@ function useLayout(api) {
148
159
  row: [...snapShot.gridRatio.row],
149
160
  };
150
161
  state.gapSize = snapShot.gapSize;
162
+ if (typeof snapShot.canvasExtraHeight === 'number') state.canvasExtraHeight = snapShot.canvasExtraHeight;
151
163
  }
152
164
  });
153
165
  };
@@ -213,6 +225,14 @@ function useLayout(api) {
213
225
  }
214
226
  };
215
227
 
228
+ const setCanvasExtraHeight = (height, rowExtras = {}, record = false) => {
229
+ try {
230
+ return api.layout.setCanvasExtraHeight(height, rowExtras, record);
231
+ } catch (error) {
232
+ console.error('[useLayout] setCanvasExtraHeight 실행 중 오류 발생:', error);
233
+ }
234
+ };
235
+
216
236
  const setActiveSection = (name) => {
217
237
  try {
218
238
  api.layout.setActiveSection(name);
@@ -261,6 +281,7 @@ function useLayout(api) {
261
281
  setLayoutName,
262
282
  setGapSize,
263
283
  setRatio,
284
+ setCanvasExtraHeight,
264
285
  setActiveSection,
265
286
  setElements,
266
287
  updateActiveElement,
@@ -1,11 +1,28 @@
1
- // 그리드 행 트랙별 최소 높이(px) - 해당 행 하나만 차지하는 요소의 size.h 중 최대값
2
- // (여러 행에 걸친 요소는 GridLayout 행 크기 조절 시 별도로 제한)
3
- export function getGridRowMinPx(elements, gridRows) {
4
- const mins = Array(gridRows).fill(0);
1
+ // 그리드 행/셀 최소 높이(px) - 빈 셀 minHeight(GridDropEngine.getElementStyle), 행 크기 조절, 캔버스 축소 제한 공통
2
+ export const GRID_MIN_ROW_PX = 20;
3
+
4
+ // 그리드 행 트랙별 최소 높이(px) - 기본 GRID_MIN_ROW_PX, 요소가 걸친 행들은 합계가 요소 높이(size.h) 이상이 되도록
5
+ // 여러 행에 걸친 요소: 행별 확장 높이 몫(extraPx)을 먼저 채우고, 모자란 높이는 행 비율(ratio)대로 나눔
6
+ // → 행 크기가 요소를 만족하는 동안에는 각 행 실제 크기보다 작아서 레이아웃에 영향이 없고, 섹션이 작아질 때만 요소 높이를 지킴
7
+ // @param {Array} ratio - 행 비율 (생략 시 균등), extraPx - 행별 확장 높이 몫 (getRowExtraPx 결과, 생략 시 0)
8
+ export function getGridRowMinPx(elements, gridRows, ratio = [], gap = 0, extraPx = []) {
9
+ const mins = Array(gridRows).fill(GRID_MIN_ROW_PX);
10
+ const weight = (i) => (ratio[i] > 0 ? ratio[i] : 1);
11
+ const extra = (i) => extraPx[i] ?? 0;
12
+
5
13
  elements.forEach((el) => {
6
- const { row } = el.position;
7
- if (el.size.rowSpan !== 1 || row < 1 || row > gridRows) return;
8
- mins[row - 1] = Math.max(mins[row - 1], el.size.h ?? 0);
14
+ const start = el.position.row - 1;
15
+ const end = start + el.size.rowSpan - 1;
16
+ if (start < 0 || end >= gridRows) return;
17
+
18
+ const rows = Array.from({ length: end - start + 1 }, (_, k) => start + k);
19
+ const totalWeight = rows.reduce((sum, i) => sum + weight(i), 0);
20
+ const totalExtra = rows.reduce((sum, i) => sum + extra(i), 0);
21
+ const lackPx = Math.max(0, (el.size.h ?? 0) - gap * (rows.length - 1) - totalExtra);
22
+
23
+ rows.forEach((i) => {
24
+ mins[i] = Math.max(mins[i], extra(i) + (lackPx * weight(i)) / totalWeight);
25
+ });
9
26
  });
10
27
  return mins;
11
28
  }
@@ -17,36 +34,63 @@ export function toGridTrackSizes(ratio, minPx = []) {
17
34
  }
18
35
 
19
36
  // fr 비율 → grid-template-rows 값 (마지막 행만 추가 높이를 가져가도록)
20
- // 마지막 행을 제외한 행은 (컨테이너 높이 - 추가 높이 - gap 합) 기준 비율로 고정하고, 남는 높이는 마지막 행(1fr)이 전부 가져감
21
- // → 캔버스 확장(CanvasContainer.additionalHeight) 시 전체 행에 나눠지지 않고 마지막 행만 늘어남
22
- export function toGridRowSizes(ratio, minPx = [], gap = 0, extraPx = 0) {
37
+ // 마지막 행을 제외한 행은 (컨테이너 높이 - 추가 높이 - gap 합) 기준 비율 + 위 행으로 넘어간 추가 높이(rowExtraPx)로 고정하고,
38
+ // 남는 높이는 마지막 행(1fr)이 전부 가져감
39
+ // → 캔버스 확장(CanvasContainer.additionalHeight) 시 전체 행에 나눠지지 않고 마지막 행만 늘어나며, 비율(ratio)에는 추가 높이가 섞이지 않음
40
+ export function toGridRowSizes(ratio, minPx = [], gap = 0, extraPx = 0, rowExtraPx = []) {
23
41
  const sum = ratio.reduce((acc, r) => acc + r, 0) || 1;
24
42
  const fixedPx = extraPx + gap * (ratio.length - 1);
25
43
  const toMin = (i) => (typeof minPx[i] === 'string' ? minPx[i] : `${minPx[i] ?? 0}px`);
44
+ const toSize = (r, i) => {
45
+ const base = `(100% - ${fixedPx}px) * ${r / sum}`;
46
+ return rowExtraPx[i] > 0 ? `calc(${base} + ${rowExtraPx[i]}px)` : `calc(${base})`;
47
+ };
26
48
 
27
- return ratio
28
- .map((r, i) => (i === ratio.length - 1 ? `minmax(${toMin(i)}, 1fr)` : `minmax(${toMin(i)}, calc((100% - ${fixedPx}px) * ${r / sum}))`))
29
- .join(' ');
49
+ return ratio.map((r, i) => (i === ratio.length - 1 ? `minmax(${toMin(i)}, 1fr)` : `minmax(${toMin(i)}, ${toSize(r, i)})`)).join(' ');
30
50
  }
31
51
 
32
- // 섹션이 BaseLayout의 마지막 행에 걸쳐 있는지 (layoutPosition.row는 0-based) - 캔버스 추가 높이를 받는 섹션
33
- export function isInLastLayoutRow(section, layoutRowCount) {
34
- const { row = 0, rowSpan = 1 } = section?.layoutPosition ?? {};
35
- return row + rowSpan >= (layoutRowCount || 1);
52
+ // 편집 화면 전용 - 행별 확장 높이 몫(px), 길이 = 행 개수
53
+ // 위 행들은 마지막 행에서 넘어간 몫(gridRowExtra), 마지막 행은 나머지 전부
54
+ // 확장 높이가 없으면(미리보기/마지막 행이 아닌 섹션) 전부 0, gridRowExtra 합계가 확장 높이를 넘으면 비례 축소
55
+ export function getRowExtraPx(rowExtra, gridRows, extraPx) {
56
+ const shares = Array(gridRows).fill(0);
57
+ if (!(extraPx > 0)) return shares;
58
+
59
+ const moved = Array.isArray(rowExtra) && rowExtra.length === gridRows - 1 ? rowExtra.map((v) => (v > 0 ? v : 0)) : [];
60
+ const sum = moved.reduce((acc, v) => acc + v, 0);
61
+ const scale = sum > extraPx ? extraPx / sum : 1;
62
+ moved.forEach((v, i) => (shares[i] = v * scale));
63
+ shares[gridRows - 1] = extraPx - sum * scale;
64
+ return shares;
36
65
  }
37
66
 
38
- // 그리드 섹션 마지막 행이 가져야 하는 최소 높이(px) - 빈 셀/요소 minHeight(GridDropEngine.getElementStyle) 기준
39
- // 여러 행에 걸친 요소는 나머지 행(trackPx) + gap 만큼은 이미 확보된 것으로 봄
40
- export function getGridLastRowRequiredPx(elements, gridRows, trackPx, gap, minRowPx) {
41
- let required = minRowPx;
42
- elements.forEach((el) => {
43
- const { row } = el.position;
44
- const { rowSpan } = el.size;
45
- if (row + rowSpan - 1 !== gridRows) return;
67
+ // 그리드 섹션 grid-template-rows 값 - BaseLayout/GridLayout 공용
68
+ // 확장 높이를 받지 않는 섹션은 일반 fr 비율, 받는 섹션은 비율 + 확장 높이 몫
69
+ export function toSectionRowSizes(ratio, minPx = [], gap = 0, extraPx = 0, rowExtra = []) {
70
+ if (!(extraPx > 0)) return toGridTrackSizes(ratio, minPx);
71
+ return toGridRowSizes(ratio, minPx, gap, extraPx, getRowExtraPx(rowExtra, ratio.length, extraPx));
72
+ }
73
+
74
+ // 행 px(trackPx)가 행 최소 높이와 요소 최소 높이(GridDropEngine.getElementStyle의 minHeight)를 모두 만족하는지
75
+ // rows(0-based 행 인덱스 배열)를 넘기면 해당 행과 그 행에 걸친 요소만 검사 (바뀌지 않은 행의 기존 상태는 무시)
76
+ export function satisfiesGridRowMin(elements, trackPx, gap, minRowPx, rows = null) {
77
+ const EPS = 0.5;
78
+ const checkRows = rows ?? trackPx.map((_, i) => i);
79
+ if (checkRows.some((i) => trackPx[i] < minRowPx - EPS)) return false;
80
+
81
+ return elements.every((el) => {
82
+ const start = el.position.row - 1;
83
+ const end = start + el.size.rowSpan - 1;
84
+ if (!checkRows.some((i) => start <= i && i <= end)) return true;
46
85
 
47
- let otherPx = gap * (rowSpan - 1);
48
- for (let t = row - 1; t < gridRows - 1; t++) otherPx += trackPx[t] ?? 0;
49
- required = Math.max(required, (el.size.h ?? minRowPx) - otherPx);
86
+ let px = gap * (end - start);
87
+ for (let t = start; t <= end; t++) px += trackPx[t] ?? 0;
88
+ return px >= (el.size.h ?? minRowPx) - EPS;
50
89
  });
51
- return required;
90
+ }
91
+
92
+ // 섹션이 BaseLayout의 마지막 행에 걸쳐 있는지 (layoutPosition.row는 0-based) - 캔버스 추가 높이를 받는 섹션
93
+ export function isInLastLayoutRow(section, layoutRowCount) {
94
+ const { row = 0, rowSpan = 1 } = section?.layoutPosition ?? {};
95
+ return row + rowSpan >= (layoutRowCount || 1);
52
96
  }