/** * 미리보기 — 발행본을 **iframe 으로** 띄운다. * * ★ 왜 iframe 인가 (2026-09-09) * 처음엔 발행본 컴포넌트를 빌더 안에 직접 그렸다. 렌더러도 payload 도 토큰도 같게 맞췄고 * 섹션 15개의 계산 스타일(색·서체·여백)까지 동일해졌는데, **레이아웃 폭이 어긋났다.** * 미디어 쿼리는 창 폭을 보는데 미리보기의 실제 사이트 폭은 그 안의 프레임이기 때문이다. * 실측(1400px 창 · 1024px 프레임, Playwright): * festival 2560px → 6027px · guide 1168 → 2168 · location 586 → 1135 * 내용(글자 수)은 완전히 같은데 그리드 컬럼 수만 달라 두 배씩 길어졌다. 전체 픽셀 차이 89%. * "같은 렌더러를 쓴다" 만으로는 안 되고 **뷰포트가 같아야** 한다. * * iframe 은 자체 뷰포트를 가진다 — 폭을 390/768/1024 로 주면 발행본이 그 폭에서 보는 것과 * 같은 미디어 쿼리가 걸린다. 해상도 전환도 그제서야 진짜가 된다. * * ★ iframe 이 여는 것은 `/preview?placeId=…` — 프리렌더가 굽는 CSR 셸이다 * (`site/scripts/prerender.writePreviewShell`). 그 안에서 발행본 앱이 그대로 돈다. * ★ 같은 오리진이라 토큰(localStorage)을 iframe 이 그대로 읽는다. 따로 넘기지 않는다. */ import {useCallback, useEffect, useRef, useState} from 'react'; import type {ViewportMode} from '@o2o/shared'; /** * 각 해상도에서 iframe 에 줄 **크기**. 발행본이 그 기기에서 보는 것과 같아야 한다. * * ★ 높이도 준다. 폭만 맞추면 `vh` 를 쓰는 자리가 어긋난다 — 히어로가 * `clamp(24rem, 62vh, 36rem)` 이라(`HeroSection`), iframe 이 낮으면 하한 384px 에 걸린다. * 실측: 발행본 576px ↔ 미리보기 384px. 지연 로딩 이미지도 화면에 덜 들어와 적게 뜬다. */ const FRAME_SIZE: Record = { pc: {w: 1024, h: 800}, tablet: {w: 768, h: 1024}, mobile: {w: 390, h: 844}, }; export function SitePreview({ placeId, viewport, interactive = false, selectedId = null, onSelect, }: { placeId: string | null; viewport: ViewportMode; /** 편집 모드 — 섹션을 눌러 고를 수 있게 한다. 미리보기에서는 끈다. */ interactive?: boolean; selectedId?: string | null; onSelect?: (sectionId: string) => void; }) { const ref = useRef(null); const boxRef = useRef(null); const [scale, setScale] = useState(1); /* * ★ iframe 은 **진짜 폭(1024·768·390)** 을 유지하고, 자리에 안 들어가면 축소해서 넣는다. * 폭을 줄여 맞추면 미디어 쿼리가 그 좁은 폭을 보고 발행본과 다른 그리드가 된다 — * 그걸 피하려고 iframe 을 쓴 것이라 여기서 무너뜨리면 안 된다. * 기기 미리보기 도구가 쓰는 방식과 같다: 크기는 그대로, 그림만 줄인다. */ useEffect(() => { const box = boxRef.current; if (!box) return; const fit = () => { const avail = box.clientWidth; setScale(avail > 0 ? Math.min(1, avail / FRAME_SIZE[viewport].w) : 1); }; fit(); const ro = new ResizeObserver(fit); ro.observe(box); return () => ro.disconnect(); }, [viewport]); /** * 편집 모드에서 iframe 안 섹션을 고를 수 있게 한다. * * ★ 같은 오리진이라 안쪽 문서를 그대로 만질 수 있다. 굳이 postMessage 를 쓰지 않는다. * ★ 어느 섹션인지는 `data-editor-id` 로 안다 — 화면 id(`gallery`)와 설정 id(`photos`)가 * 달라서, 그 다리를 발행본 렌더러가 놓아 준다(`site/pages/HomePage`). */ const wire = useCallback(() => { const doc = ref.current?.contentDocument; if (!doc || !interactive) return; let style = doc.getElementById('editor-outline') as HTMLStyleElement | null; if (!style) { style = doc.createElement('style'); style.id = 'editor-outline'; doc.head.appendChild(style); } // ★ outline 을 쓴다(border 아님). 상자 크기를 바꾸지 않아 발행본과 레이아웃이 그대로다. style.textContent = ` [data-editor-id] > * { cursor: pointer; } [data-editor-id]:hover > * { outline: 2px dashed rgb(59 130 246 / .5); outline-offset: -2px; } [data-editor-id][data-selected='true'] > * { outline: 2px solid rgb(59 130 246); outline-offset: -2px; } `; doc.querySelectorAll('[data-editor-id]').forEach((el) => { el.dataset.selected = String(el.dataset.editorId === selectedId); }); const onClick = (event: Event) => { const host = (event.target as HTMLElement | null)?.closest('[data-editor-id]'); const id = host?.dataset.editorId; if (id) onSelect?.(id); }; doc.addEventListener('click', onClick); return () => doc.removeEventListener('click', onClick); }, [interactive, selectedId, onSelect]); useEffect(() => wire(), [wire]); if (!placeId) { return (
사업장을 먼저 만들어 주세요
); } const {w, h} = FRAME_SIZE[viewport]; return (
{/* 축소한 만큼 실제 차지하는 크기도 줄여 준다 — 안 그러면 아래쪽에 빈 공간이 남는다. */}