fix(engine): 좁은 창에서 레이아웃 편집기가 찌그러지는 문제

레이아웃 편집기는 라우트 트리(280px 고정) + 디바이스 미리보기 캔버스 +
라벨 있는 툴바 버튼 10여 개가 나란히 놓이는 대화면 전용 도구다. 그런데
셸에 너비 하한이 없어 창을 좁히면 이들이 창 너비에 맞춰 압착됐고, 툴바가
flex row 라 각 항목 폭이 깎이면서 라벨이 글자 단위로 줄바꿈됐다("요 소 추 가").

반응형으로 재배치하는 대신 셸이 최소 너비(EDITOR_MIN_WIDTH=1280)를 유지하고
모자란 폭은 브라우저 가로 스크롤이 흡수하도록 했다. 편집기 UI 는 축약 대상이
아니며, 압착을 허용하면 캔버스 실측 폭이 뷰포트에 종속돼 디바이스 미리보기의
의미 자체가 사라진다.

툴바 압착 차단은 직접 자식 전체를 덮는 CSS 규칙으로 뒀다. 버튼마다 인라인
style 로 붙이면 자체 style 을 가진 하위 컴포넌트(템플릿 전환기·언어 전환기)와
이후 추가될 항목이 빠져 같은 결함이 재발한다.

jsdom 은 레이아웃 엔진이 없어 단위 테스트로는 "style 선언 존재" 까지만 잠긴다.
실제로 압착이 멈췄는지는 렌더 박스를 재는 브라우저에서만 증명되므로 Playwright
spec 을 함께 추가했다(수정 되돌림 시 red 확인). 아울러 이 파일의 e2e:allow 면제
주석은 사유("브라우저 가시 UI 무변경")가 더 이상 참이 아니라 제거했다.
This commit is contained in:
HeuJung
2026-07-12 16:13:51 +09:00
parent bfaf551d41
commit 68cbee0e83
8 changed files with 182 additions and 10 deletions
+1
View File
@@ -8,6 +8,7 @@
### Fixed
- 화면 편집기를 작은 화면에서 열면 상단 도구 모음의 버튼 글자가 세로로 쪼개지는 등 화면이 찌그러지던 문제를 수정했습니다. 화면 편집기는 왼쪽 화면 목록과 미리보기 화면을 나란히 놓고 쓰는 도구라 넓은 화면이 필요합니다. 이제 창이 좁아도 편집기를 억지로 우겨넣지 않고 원래 크기를 유지하며, 모자란 만큼은 가로 스크롤로 넘겨 보면 됩니다.
- 모바일에서 새로고침을 연속으로 눌렀을 때 간헐적으로 "초기화 실패" 또는 "페이지 로딩 실패" 화면이 뜨던 문제를 수정했습니다. 화면을 그리는 데 필요한 요청 하나가 회선 사정으로 잠깐 끊기면 그것만으로 페이지 전체가 뜨지 않았습니다. 이제 이런 일시적인 끊김은 자동으로 다시 시도해 복구하므로, 대부분의 경우 사용자는 아무것도 느끼지 못한 채 화면이 정상적으로 열립니다.
- 화면 구성에 필요한 스크립트를 끝내 불러오지 못한 경우, 아무 안내 없이 빈 화면만 남거나 영문 오류 문구가 그대로 노출되던 문제를 수정했습니다. 이제 무슨 일이 있었는지 알려주는 안내와 새로고침 버튼이 표시됩니다. 관리자 화면에도 동일하게 적용됩니다.
- 쇼핑몰 등 확장 기능을 불러오지 못한 상태에서 화면이 5초가량 빈 채로 멈춰 있다가 뜨던 문제를 수정했습니다. 이제 지체 없이 화면이 열리고, 불러오지 못한 확장 기능만 조용히 빠집니다.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,6 +5,16 @@
>
> 형식: [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)
## [engine-v1.53.1] - 2026-07-12
### Fixed
#### 좁은 창에서 레이아웃 편집기가 압착되던 문제
- `LayoutEditorChrome.tsx` — 편집기 셸에 최소 너비(`EDITOR_MIN_WIDTH` = 1280px)를 부여했다. 레이아웃 편집기는 라우트 트리(280px 고정) + 디바이스 미리보기 캔버스 + 라벨 있는 툴바 버튼 10여 개가 나란히 놓이는 대화면 전용 도구다. 종전에는 셸에 하한이 없어 창을 좁히면 이들이 창 너비에 맞춰 압착됐다. 이제 최소 너비 아래로는 압착하지 않고 부족한 폭을 브라우저 가로 스크롤로 흡수한다(반응형 재배치 아님 — 편집기 UI 는 축약 대상이 아니다).
- `EditorToolbar.tsx` — 툴바 직접 자식 전체에 `flex-shrink: 0` + `white-space: nowrap` 규칙을 적용했다. 툴바는 flex row 라 기본값(`flex-shrink: 1`)에서는 폭이 모자랄 때 각 항목이 깎이고 라벨이 글자 단위로 줄바꿈됐다("요 소 추 가"). 버튼마다 인라인 style 로 붙이는 대신 직접 자식 전체를 덮는 단일 규칙으로 뒀다 — 자체 style 을 가진 하위 컴포넌트(템플릿 전환기·언어 전환기)와 이후 추가될 항목까지 자동으로 포함된다.
- 디바이스 미리보기 프레임이 남는 캔버스 폭보다 넓을 때는 종전대로 줌 슬라이더로 축소해 본다(프레임 폭 SSoT 인 `deviceList.ts` 는 변경 없음).
## [engine-v1.53.0] - 2026-07-11
### Fixed
@@ -1,6 +1,3 @@
// e2e:allow(기반 인프라) — spec/엔진/훅만 변경, 브라우저 가시 UI 무변경.
// 페이지 설정 탭·셸·진입점 + Playwright E2E 는 세션 D 에서 추가(계획서).
// (세션 D 가 page-settings E2E spec 을 추가하면 본 면제 주석은 제거 대상.)
/**
* LayoutEditorChrome.tsx
*
@@ -79,6 +76,20 @@ import { registerCoreWidgets } from './spec/registerCoreWidgets';
import { registerCoreEditors } from './spec/registerCoreEditors';
import { exposeLayoutEditorGlobals } from './spec/exposeLayoutEditorGlobals';
/**
* 편집기 셸 최소 너비(px) — 이 아래로는 셸을 압착하지 않고 브라우저 가로 스크롤로 흡수한다.
*
* 레이아웃 편집기는 대화면 전용 도구다. 셸 안에는 축약 불가능한 요소가 나란히 놓인다:
* 라우트 트리(280px 고정) + 캔버스(디바이스 미리보기 프레임) + 툴바(라벨 있는 버튼 10여 개).
* 창 너비에 맞춰 이들을 압착하면 툴바 라벨이 글자 단위로 줄바꿈되고("요 소 추 가") 캔버스
* 실측 폭이 뷰포트에 종속돼 디바이스 미리보기의 의미 자체가 사라진다. 편집기 UI 를 반응형으로
* 재배치하지 않고 최소 너비를 유지하는 이유다.
*
* 값 1280 = 툴바 한 줄이 줄바꿈 없이 들어가는 폭. 디바이스 미리보기 프레임이 남는 캔버스 폭보다
* 넓을 때는 종전대로 줌 슬라이더로 축소해 본다(프레임 폭은 별도 SSoT — deviceList.ts).
*/
export const EDITOR_MIN_WIDTH = 1280;
// 코어 위젯 등록 — 셸 모듈 로드 시 1회(멱등). 속성 편집 모달의 widgetRegistry
// 디스패치가 동작하려면 segmented/slider/select/toggle/color/image/tag-input 위젯이
// 등록돼 있어야 한다.
@@ -822,6 +833,12 @@ function LayoutEditorChromeBody({
// 편집기 라우트에서는 `#app` 의 유일한 자식이라 호스트 콘텐츠와 겹치지 않는다.
position: 'static',
minHeight: '100vh',
// 레이아웃 편집기는 대화면 전용 도구다(좌: 라우트 트리 280px, 우: 실측 캔버스,
// 상단: 축약 불가 툴바 10여 개). 창이 좁아도 이들을 압착해 반응형으로 재배치하지
// 않고 최소 너비를 유지하며, 모자란 만큼은 브라우저 가로 스크롤로 흡수한다.
// 압착을 허용하면 툴바 라벨이 글자 단위로 줄바꿈되고 캔버스 실측 폭이 뷰포트에
// 종속돼 디바이스 미리보기(데스크톱 1280 등)의 의미가 사라진다.
minWidth: EDITOR_MIN_WIDTH,
background: '#f8fafc',
color: '#0f172a',
fontFamily:
@@ -5,12 +5,13 @@
* - 셸이 같은 React tree 안에 마운트됨 (Provider 트리 안에서 동작)
* - g7le- CSS 프리픽스 강제 (19.3 격리)
* - 툴바 + 디바이스 미리보기 툴바 + 라우트 트리 패널 + 프리뷰 캔버스 통합
* - 대화면 전용 최소 너비(좁은 창에서 압착 대신 가로 스크롤)
*/
import React from 'react';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { LayoutEditorChrome } from '../../LayoutEditorChrome';
import { LayoutEditorChrome, EDITOR_MIN_WIDTH } from '../../LayoutEditorChrome';
import { TranslationProvider } from '../../../TranslationContext';
import { TranslationEngine } from '../../../TranslationEngine';
@@ -64,6 +65,31 @@ describe('LayoutEditorChrome', () => {
});
});
describe('LayoutEditorChrome — 대화면 전용 최소 너비 (좁은 창 압착 차단)', () => {
// 회귀 배경: 셸에 최소 너비가 없어 좁은 창에서 툴바 버튼이 flex-shrink 로 깎이고
// 라벨이 글자 단위로 줄바꿈됐다("요 소 추 가"). 편집기는 대화면 전용 도구이므로
// 반응형 재배치 대신 최소 너비를 유지하고 모자란 폭은 브라우저 가로 스크롤로 흡수한다.
it('셸이 EDITOR_MIN_WIDTH 최소 너비를 갖는다 (압착 대신 가로 스크롤)', () => {
renderShell();
const chrome = screen.getByTestId('g7le-chrome') as HTMLElement;
expect(chrome.style.minWidth).toBe(`${EDITOR_MIN_WIDTH}px`);
});
it('툴바 직접 자식 전체가 축소·줄바꿈되지 않는다 (flex-shrink:0 + nowrap)', () => {
// 버튼마다 인라인 style 로 붙이면 자체 style 을 가진 하위 컴포넌트
// (TemplateSwitcher/LocaleSwitcher)와 이후 추가 항목이 빠져 결함이 재발한다.
// 직접 자식 전체를 덮는 CSS 규칙이어야 한다 — 그 규칙의 존재를 잠근다.
renderShell();
const toolbar = screen.getByTestId('g7le-toolbar');
expect(toolbar.children.length).toBeGreaterThan(1);
const styleEl = toolbar.querySelector('style');
expect(styleEl?.textContent).toContain('.g7le-toolbar > *');
expect(styleEl?.textContent).toContain('flex-shrink: 0');
expect(styleEl?.textContent).toContain('white-space: nowrap');
});
});
describe('LayoutEditorChrome — 템플릿 메타 fetch 인증 헤더', () => {
// 회귀 배경: admin 템플릿 API 는 Sanctum 토큰 인증이라, 메타/목록 fetch 가
// Authorization Bearer 토큰을 누락하면 401 → name/version 미수신 → 식별자만 노출
@@ -506,8 +506,17 @@ export function EditorToolbar(props: EditorToolbarProps = {}): React.ReactElemen
boxShadow: '0 1px 2px rgba(15, 23, 42, 0.04)',
}}
>
{/* 저장 스피너 회전 keyframe — 인라인 style 로는 @keyframes 표현 불가. */}
<style>{'@keyframes g7le-spin { to { transform: rotate(360deg); } }'}</style>
{/* 저장 스피너 회전 keyframe — 인라인 style 로는 @keyframes 표현 불가.
+ 툴바 압착 차단: 툴바는 flex row 라 기본값(flex-shrink:1)이면 창이 좁을 때
모든 항목의 폭이 깎이고 라벨이 글자 단위로 줄바꿈된다("요 소 추 가").
직접 자식 **전체**에 적용해야 한다 — 버튼마다 인라인 style 로 붙이면
자체 style 을 가진 하위 컴포넌트(TemplateSwitcher/LocaleSwitcher)와 이후 추가될
항목이 빠져 같은 결함이 재발한다. 셸 최소 너비(EDITOR_MIN_WIDTH) 안에서 각 항목은
자연 폭을 유지하고, 넘치는 만큼은 셸 가로 스크롤이 흡수한다. */}
<style>
{'@keyframes g7le-spin { to { transform: rotate(360deg); } }' +
'.g7le-toolbar > * { flex-shrink: 0; white-space: nowrap; }'}
</style>
<button
type="button"
className="g7le-toolbar__route-tree-toggle"
@@ -0,0 +1,109 @@
/**
* Layout Editor — 대화면 전용 최소 너비 (좁은 창에서 압착 대신 가로 스크롤).
*
* 레이아웃 편집기는 라우트 트리(280px 고정) + 디바이스 미리보기 캔버스 + 라벨 있는 툴바
* 버튼 10여 개가 나란히 놓이는 대화면 전용 도구다. 종전에는 셸에 너비 하한이 없어 창을
* 좁히면 이들이 창 너비에 맞춰 압착됐다 — 툴바가 flex row 라 각 항목 폭이 깎이고 라벨이
* 글자 단위로 줄바꿈됐다("요 소 추 가"). 이제 셸이 최소 너비(EDITOR_MIN_WIDTH=1280)를
* 유지하고, 모자란 폭은 브라우저 가로 스크롤이 흡수한다(반응형 재배치 아님).
*
* 본 E2E 가 필요한 이유: 단위 테스트(LayoutEditorChrome.test.tsx)는 jsdom 이라 레이아웃
* 엔진이 없어 "style 선언이 존재한다" 까지만 잠근다. **실제로 압착이 멈췄는지**(셸이 뷰포트에
* 눌리지 않는지, 툴바 항목이 자연 폭을 유지하고 줄바꿈되지 않는지)는 실제 렌더 박스를 측정하는
* 브라우저에서만 증명된다 — 앞의 두 케이스가 그 회귀 가드다(수정 되돌림 시 red 확인).
*
* 세 번째 케이스(클리핑 차단)는 이 수정의 회귀 가드가 아니라 그 **전제**를 잠근다 — 상세는
* 해당 테스트 주석 참조.
*
* @scenario editor_min_width + narrow_viewport_no_squish + horizontal_scroll
* @effects shell_keeps_min_width_below_viewport, toolbar_items_keep_natural_width_no_wrap, narrow_window_produces_horizontal_scroll_not_compression
*/
import { test, expect, issueToken, authenticatePage } from '../../fixtures/auth';
import type { Page } from '@playwright/test';
/** 셸 최소 너비 — LayoutEditorChrome.tsx 의 EDITOR_MIN_WIDTH 와 동일 SSoT. */
const EDITOR_MIN_WIDTH = 1280;
/** 압착을 유발하는 좁은 창 — 최소 너비보다 확실히 좁게 잡는다. */
const NARROW_WIDTH = 900;
async function gotoEditor(page: Page, route = '%2F'): Promise<void> {
const token = issueToken('core.templates.layouts.edit');
await authenticatePage(page, token);
await page.goto(`/admin/layout-editor/sirsoft-basic?route=${route}`);
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
await page.waitForSelector('[data-testid="g7le-toolbar"]', { timeout: 30_000 });
}
test.describe('레이아웃 편집기 — 좁은 창 압착 차단', () => {
test('좁은 창에서도 셸이 최소 너비를 유지한다 (압착 없음)', async ({ page }) => {
await page.setViewportSize({ width: NARROW_WIDTH, height: 900 });
await gotoEditor(page);
// 셸의 실제 렌더 폭이 뷰포트로 눌리지 않고 최소 너비 이상을 유지한다.
const shellWidth = await page
.getByTestId('g7le-chrome')
.evaluate((el) => el.getBoundingClientRect().width);
expect(shellWidth).toBeGreaterThanOrEqual(EDITOR_MIN_WIDTH);
});
test('좁은 창에서 툴바 항목이 깎이거나 줄바꿈되지 않는다', async ({ page }) => {
// 넓은 창에서 각 툴바 항목의 자연 폭·높이를 먼저 측정한다.
await page.setViewportSize({ width: 1600, height: 900 });
await gotoEditor(page);
const wide = await measureToolbarItems(page);
expect(wide.length).toBeGreaterThan(1);
// 창을 최소 너비 아래로 좁혀도 각 항목의 폭·높이가 그대로여야 한다.
// 압착되면 폭이 줄고, 라벨이 글자 단위로 줄바꿈되며 높이가 늘어난다("요 소 추 가").
await page.setViewportSize({ width: NARROW_WIDTH, height: 900 });
await page.waitForTimeout(300); // 리사이즈 후 레이아웃 확정
const narrow = await measureToolbarItems(page);
// 절대 임계값(예: width > 40)은 거짓 통과를 만든다 — 같은 항목의 두 상태를 직접 비교한다.
expect(narrow).toEqual(wide);
});
// 주의: 본 케이스는 최소 너비 수정의 **회귀 가드가 아니다** — 수정 전에도 캔버스 프레임
// (데스크톱 1280px)이 좁은 창을 넘쳐 가로 스크롤 자체는 존재했다(수정 되돌림 시 green 유지 확인).
// 이 케이스가 잠그는 것은 별개의 전제다: 넘친 폭이 `overflow-x: hidden` 으로 **잘리지 않고**
// 스크롤로 접근 가능해야 최소 너비 설계가 성립한다. 호스트/템플릿 CSS 가 나중에 body 나
// #app 에 overflow-x:hidden 을 걸면 편집기 우측이 영영 닿을 수 없게 되는데, 그것을 여기서 잡는다.
test('넘친 폭이 잘리지 않고 가로 스크롤로 접근 가능하다 (클리핑 차단)', async ({ page }) => {
await page.setViewportSize({ width: NARROW_WIDTH, height: 900 });
await gotoEditor(page);
// 문서가 뷰포트보다 넓어야(= 가로 스크롤 가능) 편집기 전체를 스크롤로 볼 수 있다.
// overflow-x:hidden 으로 잘리면 scrollWidth == clientWidth 라 우측이 영영 접근 불가.
const { scrollWidth, clientWidth } = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
}));
expect(scrollWidth).toBeGreaterThan(clientWidth);
// 실제로 우측 끝까지 스크롤되는지 확인 — 스크롤이 가능하다는 선언만으로는 부족하다.
await page.evaluate(() => window.scrollTo(document.documentElement.scrollWidth, 0));
const scrolledX = await page.evaluate(() => window.scrollX);
expect(scrolledX).toBeGreaterThan(0);
});
});
/**
* 툴바 직접 자식 중 **실제 렌더 박스를 가진 항목**의 폭·높이(정수 반올림).
*
* 압착되면 폭이 줄고(flex-shrink), 라벨이 글자 단위로 줄바꿈되면 높이가 는다 — 두 값이
* 창 크기와 무관하게 동일해야 압착이 없는 것이다.
*
* 박스 없는 노드(`<style>` 태그, 조건부 미표시 요소 등)는 제외한다. 이들은 창 크기와
* 무관하게 0×0 이라 비교에 의미가 없고, 포함하면 측정 노이즈만 만든다.
*/
async function measureToolbarItems(page: Page): Promise<Array<{ w: number; h: number }>> {
return page.getByTestId('g7le-toolbar').evaluate((toolbar) =>
Array.from(toolbar.children)
.map((el) => {
const r = el.getBoundingClientRect();
return { w: Math.round(r.width), h: Math.round(r.height) };
})
.filter((box) => box.w > 0 && box.h > 0),
);
}