polarvo-layout 1.0.88 → 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.88",
3
+ "version": "1.0.90",
4
4
  "type": "module",
5
5
  "author": "unigence <unigencelab@gmail.com>",
6
6
  "repository": {
@@ -11,7 +11,7 @@
11
11
  <PolarLayout
12
12
  :id="preview ? `${key}Preview` : `${key}Layout`"
13
13
  :mode="section.mode"
14
- :style="getSectionStyle(section)"
14
+ :style="getSectionStyle(key, section)"
15
15
  :class="[getSectionClass(key, section), activeSection === key && preview ? 'text-blue-400 bg-blue-50' : 'text-gray-400']"
16
16
  @click.stop="emit('click:section', key)"
17
17
  >
@@ -19,7 +19,14 @@
19
19
  {{ key }}
20
20
  </div>
21
21
  <template v-if="section.mode === 'grid'">
22
- <GridLayout :polarvo="polarvo" :preview="preview" :sectionKey="key" :sectionData="section" :elementIds="section.elementIds" />
22
+ <GridLayout
23
+ :polarvo="polarvo"
24
+ :preview="preview"
25
+ :sectionKey="key"
26
+ :sectionData="section"
27
+ :elementIds="section.elementIds"
28
+ :extraHeight="getSectionExtraHeight(section)"
29
+ />
23
30
  </template>
24
31
 
25
32
  <template v-else>
@@ -37,11 +44,12 @@
37
44
  </template>
38
45
 
39
46
  <script setup>
40
- import { toRefs, computed, onMounted, onUnmounted, ref, inject } from 'vue';
47
+ import { toRefs, computed, onMounted, onUnmounted, ref, inject, unref } from 'vue';
41
48
 
42
49
  import PolarLayout from './PolarLayout.vue';
43
50
  import FreeLayout from './FreeLayout.vue';
44
51
  import GridLayout from './GridLayout.vue';
52
+ import { getGridRowMinPx, getRowExtraPx, toGridTrackSizes, toGridRowSizes, toSectionRowSizes, isInLastLayoutRow } from '../../utils/grid-track.js';
45
53
 
46
54
  // 우클릭 방지
47
55
  // window.oncontextmenu = () => {
@@ -69,29 +77,63 @@ const layoutType = computed(() => {
69
77
  });
70
78
  const { layoutName, layoutData, activeSection, gapSize, gridNumber, gridRatio } = toRefs(props.polarvo.layout.state);
71
79
  const { activeDesign } = toRefs(props.polarvo.display.state);
80
+ const { elements: gridElements } = toRefs(props.polarvo.gridDrop.state);
72
81
  const selectedElement = inject('selectedElement');
73
82
  const activeEditMode = inject('activeEditMode');
74
83
  const activeLeaf = inject('activeLeaf');
84
+ const canvasExtraHeight = inject('canvasExtraHeight', 0); // CanvasContainer 확장 높이 (미리보기는 0)
85
+
86
+ const extraHeight = computed(() => (props.preview ? 0 : (unref(canvasExtraHeight) ?? 0)));
87
+
88
+ // 확장 높이는 마지막 행 섹션만 가져감
89
+ const getSectionExtraHeight = (section) => (isInLastLayoutRow(section, gridRatio.value?.row?.length) ? extraHeight.value : 0);
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
+ };
75
106
 
76
107
  const getBaseStyle = computed(() => {
77
108
  return {
78
109
  gridTemplateColumns: gridRatio.value?.column.map((ratio) => `${ratio}fr`).join(' '),
79
- gridTemplateRows: gridRatio.value?.row.map((ratio) => `${ratio}fr`).join(' '),
110
+ gridTemplateRows: getBaseRows(),
80
111
  marginRight: layoutType.value === 'no-split' ? '0' : layoutType.value === 'three-column-split' ? '14px' : '8px',
81
112
  gap: `${gapSize.value}px`,
82
113
  };
83
114
  });
84
115
 
85
- const getSectionStyle = (section) => ({
86
- '--grid-columns': section.config?.gridColumns ?? 3,
87
- '--grid-rows': section.config?.gridRows ?? 3,
88
- '--grid-gap': `${section.config?.gridGap ?? 5}px`,
89
- '--grid-row-sizes': section.config?.gridRowRatio?.length ? section.config.gridRowRatio.map((r) => `${r}fr`).join(' ') : undefined, // 비어있으면 아예 안 넣어서 CSS 기본값으로 폴백
90
- '--grid-column-sizes': section.config?.gridColumnRatio?.length
91
- ? section.config.gridColumnRatio.map((r) => `${r}fr`).join(' ')
92
- : undefined,
93
- position: 'relative',
94
- });
116
+ const getSectionStyle = (key, section) => {
117
+ const gridRows = section.config?.gridRows ?? 3;
118
+ const rowRatio = section.config?.gridRowRatio?.length ? section.config.gridRowRatio : Array(gridRows).fill(1);
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));
126
+
127
+ return {
128
+ '--grid-columns': section.config?.gridColumns ?? 3,
129
+ '--grid-rows': gridRows,
130
+ '--grid-gap': `${section.config?.gridGap ?? 5}px`,
131
+ // 비율(gridRowRatio)은 확장 높이 제외 기준 - 확장 높이는 마지막 행 + 위로 넘어간 몫(gridRowExtra)으로 편집 화면에만 붙음
132
+ '--grid-row-sizes': toSectionRowSizes(rowRatio, minPx, gap, extraHeight, section.config?.gridRowExtra),
133
+ '--grid-column-sizes': section.config?.gridColumnRatio?.length ? toGridTrackSizes(section.config.gridColumnRatio) : undefined, // 비어있으면 아예 안 넣어서 CSS 기본값으로 폴백
134
+ position: 'relative',
135
+ };
136
+ };
95
137
 
96
138
  const getSectionClass = (key, section) => ({
97
139
  [key]: true,
@@ -24,8 +24,9 @@
24
24
  </template>
25
25
 
26
26
  <script setup>
27
- import { toRefs, ref, computed, watch } from 'vue';
27
+ import { toRefs, ref, computed, watch, provide, onMounted } from 'vue';
28
28
  import BaseLayout from './BaseLayout.vue';
29
+ import { GRID_MIN_ROW_PX, isInLastLayoutRow, getRowExtraPx, satisfiesGridRowMin } from '../../utils/grid-track.js';
29
30
 
30
31
  const props = defineProps({
31
32
  polarvo: {
@@ -43,7 +44,13 @@ const DEFAULT_DISPLAY_SIZE = {
43
44
 
44
45
  // height 관련 여유 공간 추가하여 계산함 (50, 200)
45
46
  const { displaySize, canvasSize } = toRefs(props.polarvo.display.state);
46
- const { elements, activeMode } = toRefs(props.polarvo.layout.state);
47
+ const { elements, layoutData, gridRatio, canvasExtraHeight } = toRefs(props.polarvo.layout.state);
48
+ const { setCanvasExtraHeight } = props.polarvo.layout;
49
+ const { elements: gridElements } = toRefs(props.polarvo.gridDrop.state);
50
+ const { elements: freeElements } = toRefs(props.polarvo.freeDrop.state);
51
+
52
+ // 화면에 그리드 섹션이 하나라도 있는지 - 높이 계산 기준 (선택된 섹션 모드와 무관하게 화면 단위로 고정)
53
+ const hasGridSection = computed(() => Object.values(layoutData.value ?? {}).some((section) => section?.mode === 'grid'));
47
54
 
48
55
  const defaultHeight = computed(() => {
49
56
  const freeElementsBottom =
@@ -59,8 +66,8 @@ const defaultHeight = computed(() => {
59
66
  // 저장된 canvasSize가 없으면 free 요소 기준 계산값만 사용 (grid 섹션은 기본값 750으로 취급)
60
67
  if (!canvasSize.value) return freeElementsBottom;
61
68
 
62
- // grid: 라이브로 정확히 계산할 방법이 없어 저장된 값을 그대로 신뢰
63
- if (activeMode.value === 'grid') {
69
+ // 그리드 섹션이 있는 화면: 라이브로 정확히 계산할 방법이 없어 저장된 값을 그대로 신뢰
70
+ if (hasGridSection.value) {
64
71
  return canvasSize.value.defaultHeight ?? freeElementsBottom;
65
72
  }
66
73
 
@@ -73,7 +80,7 @@ const defaultHeight = computed(() => {
73
80
  function restoreAdditionalHeight() {
74
81
  if (!canvasSize.value) return 0;
75
82
 
76
- if (activeMode.value === 'grid') {
83
+ if (hasGridSection.value) {
77
84
  return canvasSize.value.additionalHeight ?? 0;
78
85
  }
79
86
 
@@ -81,7 +88,13 @@ function restoreAdditionalHeight() {
81
88
  return Math.max(0, savedTotal - defaultHeight.value);
82
89
  }
83
90
 
84
- 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();
85
98
 
86
99
  // canvasSize(저장된 캔버스 크기)가 바뀌면(=화면 전환) 그 값으로 복원, 없으면 0
87
100
  watch(
@@ -134,14 +147,113 @@ const containerStyle = computed(() => {
134
147
 
135
148
  defineExpose({ defaultHeight, additionalHeight, displayHeight });
136
149
 
150
+ // 확장 높이는 BaseLayout/GridLayout에서 마지막 행에만 붙임
151
+ provide('canvasExtraHeight', additionalHeight);
152
+
153
+ const RESIZE_STEP = 200;
154
+
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;
164
+ }
165
+
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);
173
+
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
+ );
178
+
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
+ };
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 }) => {
216
+ if (section.mode === 'grid') {
217
+ slack = Math.min(slack, getGridShrinkable(getGridSectionInfo(key, section, sectionEl), slack));
218
+ } else {
219
+ const sectionElements = (freeElements.value ?? []).filter((el) => el.section === key && section.elementIds?.includes(el.id));
220
+ const bottom = sectionElements.reduce((max, el) => Math.max(max, (el.position?.y || 0) + (el.size?.h || 0)), 0);
221
+ slack = Math.min(slack, Math.floor(sectionEl.clientHeight - bottom));
222
+ }
223
+ });
224
+
225
+ return Math.max(0, slack);
226
+ }
227
+
228
+ // 확장: 늘어난 높이는 마지막 행이 전부 가져감 (비율/위로 넘어간 확장분은 그대로)
137
229
  function expandContainer() {
138
- additionalHeight.value += 200;
230
+ setCanvasExtraHeight(additionalHeight.value + RESIZE_STEP, {}, true);
139
231
  }
140
232
 
141
- // displayHeight(=defaultHeight + additionalHeight)는 항상 defaultHeight 이상으로 유지
142
- const canShrink = computed(() => additionalHeight.value > 0);
233
+ // 요소 크기/배치·레이아웃·확장 높이가 바뀔 때마다 DOM 반영 후 다시 계산
234
+ const shrinkableHeight = ref(0);
235
+ const updateShrinkableHeight = () => {
236
+ shrinkableHeight.value = getShrinkableHeight(Math.min(RESIZE_STEP, additionalHeight.value));
237
+ };
238
+ watch([displayHeight, gridElements, freeElements, layoutData, gridRatio], updateShrinkableHeight, { deep: true, flush: 'post' });
239
+ onMounted(updateShrinkableHeight);
240
+
241
+ // 축소: 마지막 행 몫부터 줄이고, 모자라면 위로 넘어간 확장분(gridRowExtra)도 줄임 - 확장 높이와 함께 히스토리에 한 번 기록
242
+ // displayHeight(=defaultHeight + additionalHeight)는 항상 defaultHeight 이상, 요소 최소 크기 이상으로 유지
243
+ const canShrink = computed(() => shrinkableHeight.value > 0);
143
244
  function shrinkContainer() {
144
- additionalHeight.value = Math.max(0, additionalHeight.value - 200);
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);
145
257
  }
146
258
  </script>
147
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,6 +74,7 @@
74
74
 
75
75
  <script setup>
76
76
  import GridItem from './GridItem.vue';
77
+ import { GRID_MIN_ROW_PX, getGridRowMinPx, toGridTrackSizes, toSectionRowSizes, getRowExtraPx } from '../../utils/grid-track.js';
77
78
 
78
79
  import { ref, toRefs, getCurrentInstance, onMounted, onUnmounted, inject, watch, computed } from 'vue';
79
80
 
@@ -98,6 +99,11 @@ const props = defineProps({
98
99
  type: Array,
99
100
  required: true,
100
101
  },
102
+ // 캔버스 확장 높이 - 이 섹션이 추가로 가져가는 px (BaseLayout에서 마지막 행 섹션에만 전달, 미리보기는 0)
103
+ extraHeight: {
104
+ type: Number,
105
+ default: 0,
106
+ },
101
107
  });
102
108
 
103
109
  const { setActiveDesign, setActiveMenu } = props.polarvo.display;
@@ -121,7 +127,7 @@ const gridRows = ref(1); // 동적으로 계산
121
127
  const gridGap = computed(() => props.sectionData?.config?.gridGap ?? 5); // BaseLayout.vue --grid-gap과 동일한 소스/기본값
122
128
 
123
129
  // row, col Resize
124
- const MIN_ROW_PX = 20; // 행 최소 높이(px)
130
+ const MIN_ROW_PX = GRID_MIN_ROW_PX; // 행 최소 높이(px)
125
131
  const MIN_COL_PX = 20; // 열 최소 너비(px)
126
132
 
127
133
  const DRAG_CONFIG = {
@@ -133,6 +139,22 @@ const DRAG_CONFIG = {
133
139
  clientKey: 'clientY',
134
140
  positionKey: 'y',
135
141
  cssVar: '--grid-row-sizes',
142
+ posKey: 'row',
143
+ spanKey: 'rowSpan',
144
+ getMinPx: (el) => el.size?.h ?? MIN_ROW_PX, // GridDropEngine.getElementStyle의 minHeight와 동일
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),
136
158
  },
137
159
  col: {
138
160
  ref: gridColumns,
@@ -142,6 +164,12 @@ const DRAG_CONFIG = {
142
164
  clientKey: 'clientX',
143
165
  positionKey: 'x',
144
166
  cssVar: '--grid-column-sizes',
167
+ posKey: 'col',
168
+ spanKey: 'colSpan',
169
+ getMinPx: () => MIN_COL_PX,
170
+ getExtraPx: () => Array(gridColumns.value).fill(0),
171
+ toSizes: (ratio) => toGridTrackSizes(ratio), // BaseLayout과 동일한 형식
172
+ toConfig: (ratio) => ratio,
145
173
  },
146
174
  };
147
175
 
@@ -157,7 +185,8 @@ function handleMouseDown(event, axis, index) {
157
185
  event.preventDefault();
158
186
  event.stopPropagation();
159
187
 
160
- const { ref, minPx, ratioKey, gridTemplateKey, clientKey, positionKey, cssVar } = DRAG_CONFIG[axis];
188
+ const { ref, minPx, ratioKey, gridTemplateKey, clientKey, positionKey, cssVar, posKey, spanKey, getMinPx, getExtraPx, toSizes, toConfig } =
189
+ DRAG_CONFIG[axis];
161
190
 
162
191
  dragState.value.axis = axis;
163
192
  dragState.value.activeIdx = index;
@@ -168,6 +197,7 @@ function handleMouseDown(event, axis, index) {
168
197
 
169
198
  let dragSnapShot = null; // 드래그 시작 시 스냅샷
170
199
  let liveRatio = null; // 미리보기 중 계산된 최종 fr 배열 (stopDrag에서 커밋)
200
+ let liveExtras = null; // 미리보기 중 계산된 트랙별 확장 높이 px (stopDrag에서 커밋)
171
201
  let pendingClient = null;
172
202
  let rafId = null;
173
203
 
@@ -207,32 +237,72 @@ function handleMouseDown(event, axis, index) {
207
237
 
208
238
  const configured = props.sectionData?.config?.[ratioKey];
209
239
  const baselineRatio =
210
- (Array.isArray(configured) && configured.length === ref.value) || configured.some((v) => typeof v !== 'number' || !(v > 0))
240
+ Array.isArray(configured) && configured.length === ref.value && configured.every((v) => typeof v === 'number' && v > 0)
211
241
  ? [...configured]
212
242
  : Array(ref.value).fill(1);
213
243
 
214
244
  const trackPx = getComputedStyle(container)
215
245
  [gridTemplateKey].split(' ')
246
+ .slice(0, ref.value)
216
247
  .map((v) => parseFloat(v));
217
248
 
218
- // 총 Fr 계산
219
- const totalFr = baselineRatio.reduce((sum, v) => sum + v, 0);
220
- // 총 px 계산
221
- const totalPx = trackPx.reduce((sum, v) => sum + v, 0);
222
- // 1px당 Fr 계산
223
- const frPerPx = totalFr / totalPx;
249
+ const prevIdx = index - 1; // 드래그 행 위쪽 행 인덱스
250
+ const nextIdx = index; // 드래그 행 인덱스
251
+
252
+ // 트랙 px = 비율 몫(basePx) + 확장 높이 몫(extraPx)
253
+ const baselineExtras = getExtraPx();
254
+ const basePx = trackPx.map((v, i) => v - baselineExtras[i]);
255
+
256
+ // 1px당 Fr 계산 - 드래그로 바뀌는 두 트랙의 비율 몫 기준 (확장 높이는 비율과 무관)
257
+ const frPerPx = (baselineRatio[prevIdx] + baselineRatio[nextIdx]) / (basePx[prevIdx] + basePx[nextIdx]);
258
+
259
+ // 요소 최소 크기 때문에 두 트랙이 가져야 하는 최소 px (확장 높이 포함한 트랙 전체 기준)
260
+ const { prevMinPx, nextMinPx } = _getElementMinPx(trackPx, prevIdx, nextIdx);
224
261
 
225
262
  dragSnapShot = {
226
263
  container,
227
264
  baselineRatio,
265
+ baselineExtras,
228
266
  frPerPx,
229
- minFr: minPx * frPerPx, // 최소 Fr 계산
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),
230
273
  };
231
274
 
232
275
  document.addEventListener('mousemove', _startDragMove);
233
276
  document.addEventListener('mouseup', _stopDrag);
234
277
  }
235
278
 
279
+ // 드래그로 바뀌는 트랙(prev/next) 중 하나에만 걸친 요소 기준으로, 각 트랙이 가져야 하는 최소 px 계산
280
+ // (두 트랙 모두에 걸친 요소는 합이 그대로라 제약 없음)
281
+ function _getElementMinPx(trackPx, prevIdx, nextIdx) {
282
+ const gap = gridGap.value;
283
+ let prevMinPx = 0;
284
+ let nextMinPx = 0;
285
+
286
+ filteredElements.value.forEach((el) => {
287
+ const start = el.position[posKey] - 1; // 0-based 트랙 인덱스
288
+ const end = start + el.size[spanKey] - 1;
289
+ const coversPrev = start <= prevIdx && prevIdx <= end;
290
+ const coversNext = start <= nextIdx && nextIdx <= end;
291
+ if (coversPrev === coversNext) return;
292
+
293
+ const targetIdx = coversPrev ? prevIdx : nextIdx;
294
+ // 요소가 걸친 나머지 트랙 + gap 만큼은 이미 확보되어 있음
295
+ let otherPx = gap * (end - start);
296
+ for (let t = start; t <= end; t++) if (t !== targetIdx) otherPx += trackPx[t];
297
+
298
+ const required = getMinPx(el) - otherPx;
299
+ if (coversPrev) prevMinPx = Math.max(prevMinPx, required);
300
+ else nextMinPx = Math.max(nextMinPx, required);
301
+ });
302
+
303
+ return { prevMinPx, nextMinPx };
304
+ }
305
+
236
306
  function _startDragMove(e) {
237
307
  if (!dragSnapShot) return;
238
308
  pendingClient = e[clientKey];
@@ -243,26 +313,42 @@ function handleMouseDown(event, axis, index) {
243
313
  rafId = requestAnimationFrame(() => {
244
314
  rafId = null;
245
315
 
246
- const { baselineRatio, frPerPx, minFr, container } = dragSnapShot;
316
+ const { baselineRatio, baselineExtras, frPerPx, prevShrinkPx, nextShrinkPx, prevBaseShrinkPx, nextBaseShrinkPx, container } =
317
+ dragSnapShot;
247
318
  const prevIdx = index - 1; // 드래그 행 위쪽 행 인덱스
248
319
  const nextIdx = index; // 드래그 행 인덱스
249
320
 
250
- // 순수 이동량
251
- let delta = (pendingClient - startPos.value[positionKey]) * frPerPx;
252
- // 위쪽 행 작아지는 것 방지: 가장 커져도 minFr - baselineRatio[prevIdx] (음수값 = 위로 이동)
253
- delta = Math.max(minFr - baselineRatio[prevIdx], delta);
254
- // 아래쪽 행 작아지는 것 방지: 가장 작아져도 minFr (양수값 = 아래로 이동)
255
- delta = Math.min(baselineRatio[nextIdx] - minFr, 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
+ }
256
337
 
257
338
  // 미리보기용
258
339
  const preview = [...baselineRatio];
259
- preview[prevIdx] += delta;
260
- 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;
261
346
 
262
347
  liveRatio = preview;
348
+ liveExtras = extras;
263
349
 
264
350
  // 화면에만 즉시 반영
265
- container.style.setProperty(cssVar, preview.map((v) => `${v}fr`).join(' '));
351
+ container.style.setProperty(cssVar, toSizes(preview, extras));
266
352
  });
267
353
  }
268
354
  function _stopDrag() {
@@ -271,12 +357,13 @@ function handleMouseDown(event, axis, index) {
271
357
  dragState.value.activeIdx = null;
272
358
 
273
359
  if (liveRatio) {
274
- setSectionConfig(ratioKey, liveRatio);
360
+ setSectionConfig(ratioKey, toConfig(liveRatio, liveExtras));
275
361
  }
276
362
 
277
363
  startPos.value = { x: 0, y: 0 };
278
364
  dragSnapShot = null;
279
365
  liveRatio = null;
366
+ liveExtras = null;
280
367
  pendingClient = null;
281
368
  rafId = null;
282
369
 
@@ -17,9 +17,9 @@ const props = defineProps({
17
17
  <style scoped>
18
18
  .polar-grid {
19
19
  display: grid;
20
- grid-template-columns: var(--grid-column-sizes, repeat(var(--grid-columns, 3), 1fr));
20
+ grid-template-columns: var(--grid-column-sizes, repeat(var(--grid-columns, 3), minmax(0, 1fr)));
21
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
+ grid-template-rows: var(--grid-row-sizes, repeat(var(--grid-rows, 3), minmax(0, 1fr)));
23
23
  gap: var(--grid-gap, 5px);
24
24
  width: 100%;
25
25
  height: 100%;
@@ -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,
@@ -0,0 +1,96 @@
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
+
13
+ elements.forEach((el) => {
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
+ });
26
+ });
27
+ return mins;
28
+ }
29
+
30
+ // fr 비율 → grid-template 값
31
+ // min을 auto(콘텐츠 크기) 대신 명시값으로 고정 - 콘텐츠 크기가 트랙을 고정하면 남은 공간이 다른 트랙으로 재분배됨
32
+ export function toGridTrackSizes(ratio, minPx = []) {
33
+ return ratio.map((r, i) => `minmax(${minPx[i] ?? 0}px, ${r}fr)`).join(' ');
34
+ }
35
+
36
+ // fr 비율 → grid-template-rows 값 (마지막 행만 추가 높이를 가져가도록)
37
+ // 마지막 행을 제외한 행은 (컨테이너 높이 - 추가 높이 - gap 합) 기준 비율 + 위 행으로 넘어간 추가 높이(rowExtraPx)로 고정하고,
38
+ // 남는 높이는 마지막 행(1fr)이 전부 가져감
39
+ // → 캔버스 확장(CanvasContainer.additionalHeight) 시 전체 행에 나눠지지 않고 마지막 행만 늘어나며, 비율(ratio)에는 추가 높이가 섞이지 않음
40
+ export function toGridRowSizes(ratio, minPx = [], gap = 0, extraPx = 0, rowExtraPx = []) {
41
+ const sum = ratio.reduce((acc, r) => acc + r, 0) || 1;
42
+ const fixedPx = extraPx + gap * (ratio.length - 1);
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
+ };
48
+
49
+ return ratio.map((r, i) => (i === ratio.length - 1 ? `minmax(${toMin(i)}, 1fr)` : `minmax(${toMin(i)}, ${toSize(r, i)})`)).join(' ');
50
+ }
51
+
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;
65
+ }
66
+
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;
85
+
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;
89
+ });
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);
96
+ }