diff --git a/.gitignore b/.gitignore index 2e1d691..99243e8 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,6 @@ public/manifest.json CLAUDE.md -/DOCS \ No newline at end of file +/DOCS + +.claude/ \ No newline at end of file diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..259a959 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "playwright": { + "command": "npx", + "args": ["@playwright/mcp@latest"] + } + } +} diff --git a/manifest.js b/manifest.js index 570f479..aa062c3 100755 --- a/manifest.js +++ b/manifest.js @@ -34,15 +34,11 @@ const manifest = { content_scripts: [ { matches: ['https://*.tistory.com/manage/newpost/*', 'https://*.tistory.com/manage/page?returnURL=/manage/pages'], - js: ['src/pages/contentInjected/index.js'], + js: ['src/pages/contentInjected/index.js', 'src/pages/contentUI/index.js'], // KEY for cache invalidation css: ['assets/css/contentStyle.chunk.css'], all_frames: true, }, - // { - // matches: ['http://*/*', 'https://*/*', ''], - // js: ['src/pages/contentUI/index.js'], - // }, ], web_accessible_resources: [ { diff --git a/package.json b/package.json index 4820e51..ceef7c9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "chrome-extension-boilerplate-react-vite", - "version": "1.6.3", + "version": "1.7.0", "description": "티스토리 확장프로그램 StoryHelper", "license": "MIT", "repository": { diff --git a/public/_locales/en/messages.json b/public/_locales/en/messages.json index 60898de..548a83c 100644 --- a/public/_locales/en/messages.json +++ b/public/_locales/en/messages.json @@ -103,6 +103,10 @@ "description": "SEO optimization checker feature", "message": "SEO Optimization Checker (v1.5)" }, + "feature_preview_side_view": { + "description": "Preview side view feature", + "message": "Preview Side View (v1.7)" + }, "shortcut_publish": { "description": "Publish post shortcut", "message": "Publish Post" @@ -223,6 +227,14 @@ "description": "Image size bulk edit prompt", "message": "Resize all images in the content:" }, + "menu_side_view_on": { + "description": "Side view activate menu text", + "message": "Preview Side View" + }, + "menu_side_view_off": { + "description": "Side view deactivate menu text", + "message": "Close Side View" + }, "tooltip_alt_tag_editor": { "description": "Alt tag editor tooltip", "message": "Edit Alt Tag" diff --git a/public/_locales/ko/messages.json b/public/_locales/ko/messages.json index 72413f2..c696976 100644 --- a/public/_locales/ko/messages.json +++ b/public/_locales/ko/messages.json @@ -103,6 +103,10 @@ "description": "SEO 최적화 검증 기능", "message": "SEO 최적화 검증기능 (v1.5)" }, + "feature_preview_side_view": { + "description": "미리보기 사이드뷰 기능", + "message": "미리보기 사이드뷰 (v1.7)" + }, "shortcut_publish": { "description": "글 발행 단축키", "message": "글 발행" @@ -223,6 +227,14 @@ "description": "이미지 크기 일괄 수정 프롬프트", "message": "본문의 이미지의 크기를 모두 수정합니다:" }, + "menu_side_view_on": { + "description": "사이드뷰 활성화 메뉴 텍스트", + "message": "미리보기 사이드뷰" + }, + "menu_side_view_off": { + "description": "사이드뷰 비활성화 메뉴 텍스트", + "message": "사이드뷰 닫기" + }, "tooltip_alt_tag_editor": { "description": "Alt 태그 수정 툴팁", "message": "Alt 태그 수정" diff --git a/src/pages/background/index.ts b/src/pages/background/index.ts index 7c8a462..cbea2f1 100644 --- a/src/pages/background/index.ts +++ b/src/pages/background/index.ts @@ -3,6 +3,12 @@ import 'webextension-polyfill'; chrome.runtime.setUninstallURL('https://storyhelper.shipfriend.dev/feedback'); +chrome.runtime.onInstalled.addListener(details => { + if (details.reason === 'install') { + chrome.tabs.create({ url: 'https://storyhelper.shipfriend.dev/introduce' }); + } +}); + reloadOnUpdate('pages/background'); /** diff --git a/src/pages/content/injected/altTager.ts b/src/pages/content/injected/altTager.ts index d413f97..515c9fd 100644 --- a/src/pages/content/injected/altTager.ts +++ b/src/pages/content/injected/altTager.ts @@ -3,11 +3,7 @@ import { createTooltip, showTooltip, hideTooltip } from '@pages/content/util/too async function altTager() { const result = await chrome.storage.local.get('func_1'); - if (typeof result.func_1 === 'boolean') { - if (!result.func_1) { - return; - } - } + if (result.func_1 === false) return; let altTag = ''; const menu = $('#mceu_18', document.body); diff --git a/src/pages/content/injected/checkSEO.ts b/src/pages/content/injected/checkSEO.ts index 62f0681..8edc2e0 100644 --- a/src/pages/content/injected/checkSEO.ts +++ b/src/pages/content/injected/checkSEO.ts @@ -7,11 +7,7 @@ const checkSEO = async () => { let hasShownReviewPromptThisSession = false; const result = await chrome.storage.local.get('func_4'); - if (typeof result.func_4 === 'boolean') { - if (!result.func_4) { - return; - } - } + if (result.func_4 === false) return; const post: Document = getEditorDocument(); const OPTIMIZED_SVG = diff --git a/src/pages/content/injected/components/SVG.ts b/src/pages/content/injected/components/SVG.ts index d71e637..7a37be8 100644 --- a/src/pages/content/injected/components/SVG.ts +++ b/src/pages/content/injected/components/SVG.ts @@ -19,3 +19,5 @@ export const ImageScale = ``; export const SEO = ``; + +export const SidebarView = ``; diff --git a/src/pages/content/injected/imageSize.ts b/src/pages/content/injected/imageSize.ts index 7d3eed8..426a22e 100644 --- a/src/pages/content/injected/imageSize.ts +++ b/src/pages/content/injected/imageSize.ts @@ -5,13 +5,10 @@ import { getEditorDocument } from '@root/utils/dom/utilDOM'; async function imageSize() { const result = await chrome.storage.local.get('func_2'); - if (typeof result.func_2 === 'boolean') { - if (!result.func_2) { - return; - } - } - const menu = document.body.querySelector('#mceu_18'); + if (result.func_2 === false) return; + const anchor = document.body.querySelector('#altTager') ?? document.body.querySelector('#mceu_18'); const imageSizer = document.createElement('div'); + imageSizer.id = 'sh-image-sizer-btn'; imageSizer.classList.add('mce-widget', 'mce-btn', 'mce-menubtn', 'mce-fixed-width'); const button = document.createElement('button'); @@ -19,7 +16,7 @@ async function imageSize() { button.innerHTML = ''; - menu.insertAdjacentElement('afterend', imageSizer); + anchor.insertAdjacentElement('afterend', imageSizer); // Tooltip 생성 const tooltip = createTooltip(chrome.i18n.getMessage('tooltip_image_resizer')); diff --git a/src/pages/content/injected/index.ts b/src/pages/content/injected/index.ts index 49b4904..497efc0 100644 --- a/src/pages/content/injected/index.ts +++ b/src/pages/content/injected/index.ts @@ -3,15 +3,15 @@ import textCounter from '@pages/content/injected/textCounter'; import altTager from '@pages/content/injected/altTager'; import imageSize from '@pages/content/injected/imageSize'; import checkSEO from '@pages/content/injected/checkSEO'; -import statusIndicator from '@pages/content/injected/statusIndicator'; +import previewSideView from '@pages/content/injected/previewSideView'; (async () => { await keyMapping(); - await imageSize(); - await altTager(); + await altTager(); // 1. #mceu_18 뒤에 삽입 + await imageSize(); // 2. #altTager 뒤에 삽입 await checkSEO(); await textCounter(); - await statusIndicator(); + await previewSideView(); // 3. #sh-image-sizer-btn 뒤에 삽입 })(); console.log('StoryHelper Load Complete'); diff --git a/src/pages/content/injected/keymap.ts b/src/pages/content/injected/keymap.ts index 9074aeb..28dc91b 100644 --- a/src/pages/content/injected/keymap.ts +++ b/src/pages/content/injected/keymap.ts @@ -10,11 +10,7 @@ interface Shortcut { async function keyMapping() { const result = await chrome.storage.local.get('func_0'); - if (typeof result.func_0 === 'boolean') { - if (!result.func_0) { - return; - } - } + if (result.func_0 === false) return; const editor = $('#tinymce', getEditorDocument()); diff --git a/src/pages/content/injected/previewSideView.ts b/src/pages/content/injected/previewSideView.ts new file mode 100644 index 0000000..2f6d91a --- /dev/null +++ b/src/pages/content/injected/previewSideView.ts @@ -0,0 +1,352 @@ +import { $, create$ } from '@root/utils/dom/utilDOM'; +import { createTooltip, showTooltip, hideTooltip } from '@pages/content/util/tooltip'; + +const TOOLBAR_BTN_ID = 'sh-side-view-toolbar-btn'; +const INTERCEPT_STYLE_ID = 'sh-preview-intercept-style'; +const PANEL_IFRAME_ID = 'sh-preview-iframe'; + +let sideViewActive = false; +let debounceTimer: ReturnType | null = null; +let editorInputHandler: (() => void) | null = null; +let srcdocTemplate: string | null = null; + +// ── React 컴포넌트와의 통신 ─────────────────────────────────────── + +const dispatchSideViewOpen = () => window.dispatchEvent(new CustomEvent('sh:sideview-open')); + +const dispatchSideViewClose = () => window.dispatchEvent(new CustomEvent('sh:sideview-close')); + +const dispatchSrcdoc = (srcdoc: string) => { + window.dispatchEvent(new CustomEvent('sh:sideview-srcdoc', { detail: { srcdoc } })); + dispatchLoading(false); +}; + +const dispatchLoading = (loading: boolean) => + window.dispatchEvent(new CustomEvent('sh:sideview-loading', { detail: { loading } })); + +// ── CSS 차단 (미리보기 모달 숨김) ──────────────────────────────── + +const injectInterceptStyle = () => { + if (document.getElementById(INTERCEPT_STYLE_ID)) return; + const style = document.createElement('style'); + style.id = INTERCEPT_STYLE_ID; + style.textContent = `.ReactModal__Overlay { visibility: hidden !important; pointer-events: none !important; }`; + document.head.appendChild(style); +}; + +const removeInterceptStyle = () => { + document.getElementById(INTERCEPT_STYLE_ID)?.remove(); +}; + +// ── 미리보기 로드 ──────────────────────────────────────────────── + +const loadPreview = () => { + if (srcdocTemplate) { + updatePreviewFromTemplate(); + return; + } + loadInitialPreview(); +}; + +const loadInitialPreview = (attempt = 0) => { + if (!sideViewActive) return; + if (attempt > 30) return; + + const previewBtn = document.getElementById('preview-btn'); + if (!previewBtn) { + setTimeout(() => loadInitialPreview(attempt + 1), 100); + return; + } + + if (previewBtn.getAttribute('aria-expanded') === 'true') { + previewBtn.click(); + setTimeout(() => loadInitialPreview(attempt + 1), 200); + return; + } + + if (!sideViewActive) return; + previewBtn.click(); + pollForTemplate(previewBtn); +}; + +const pollForTemplate = (previewBtn: HTMLElement, attempt = 0) => { + if (!sideViewActive) { + if (previewBtn.getAttribute('aria-expanded') === 'true') previewBtn.click(); + return; + } + if (attempt > 30) return; + + const overlay = document.querySelector('.ReactModal__Overlay'); + const sourceIframe = overlay?.querySelector('iframe[name="previewIframe"]'); + + if (sourceIframe?.srcdoc) { + srcdocTemplate = sourceIframe.srcdoc; + dispatchSrcdoc(srcdocTemplate); + if (previewBtn.getAttribute('aria-expanded') === 'true') previewBtn.click(); + return; + } + + setTimeout(() => pollForTemplate(previewBtn, attempt + 1), 100); +}; + +const updatePreviewFromTemplate = () => { + if (!srcdocTemplate) return; + + const editorIframe = document.getElementById('editor-tistory_ifr') as HTMLIFrameElement | null; + const editorContent = editorIframe?.contentDocument?.body?.innerHTML ?? ''; + const postTitle = (document.getElementById('post-title-inp') as HTMLTextAreaElement | null)?.value ?? ''; + + const parser = new DOMParser(); + const doc = parser.parseFromString(srcdocTemplate, 'text/html'); + + const contentArea = doc.querySelector('.tt_article_useless_p_margin'); + if (contentArea) contentArea.innerHTML = editorContent; + + const titleEl = doc.querySelector('.article-info .title'); + if (titleEl) titleEl.textContent = postTitle; + + dispatchSrcdoc(doc.documentElement.outerHTML); +}; + +// ── 에디터 입력 감지 ────────────────────────────────────────────── + +const attachEditorListener = () => { + const tryAttach = (attempt = 0) => { + if (!sideViewActive) return; + if (attempt > 25) return; + + const iframe = document.getElementById('editor-tistory_ifr') as HTMLIFrameElement | null; + const iframeBody = iframe?.contentDocument?.body ?? null; + + if (!iframeBody) { + setTimeout(() => tryAttach(attempt + 1), 200); + return; + } + + const handler = () => { + if (debounceTimer) clearTimeout(debounceTimer); + dispatchLoading(true); + debounceTimer = setTimeout(loadPreview, 1500); + }; + + iframeBody.addEventListener('input', handler); + editorInputHandler = () => iframeBody.removeEventListener('input', handler); + }; + + tryAttach(); +}; + +const detachEditorListener = () => { + if (debounceTimer) { + clearTimeout(debounceTimer); + debounceTimer = null; + } + editorInputHandler?.(); + editorInputHandler = null; +}; + +// ── 모달 닫기 ──────────────────────────────────────────────────── + +const closePreviewModal = () => { + const overlay = document.querySelector('.ReactModal__Overlay'); + if (!overlay) return; + overlay.click(); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', keyCode: 27, bubbles: true, cancelable: true })); +}; + +const waitForOverlayRemoved = (attempt = 0) => { + const overlay = document.querySelector('.ReactModal__Overlay'); + if (!overlay) { + removeInterceptStyle(); + return; + } + if (attempt > 20) { + (overlay as HTMLElement).remove(); + removeInterceptStyle(); + return; + } + setTimeout(() => waitForOverlayRemoved(attempt + 1), 100); +}; + +// ── 사이드뷰 토글 ──────────────────────────────────────────────── + +const activateSideView = () => { + sideViewActive = true; + injectInterceptStyle(); + dispatchSideViewOpen(); + loadPreview(); + attachEditorListener(); +}; + +const deactivateSideView = () => { + sideViewActive = false; + detachEditorListener(); + srcdocTemplate = null; + closePreviewModal(); + dispatchSideViewClose(); + waitForOverlayRemoved(); +}; + +const toggleSideView = () => { + if (sideViewActive) { + deactivateSideView(); + } else { + activateSideView(); + } + updateToolbarButtonIcon(); +}; + +// ── 툴바 버튼 ──────────────────────────────────────────────────── + +// altTager와 동일한 fill 아웃라인 방식 — 외곽 CW + 내부 CCW = 테두리만 채움 +const SVG_OPEN = + '' + + '' + + ''; + +const SVG_CLOSE = + '' + + '' + + ''; + +const updateToolbarButtonIcon = () => { + const btn = document.getElementById(TOOLBAR_BTN_ID); + if (!btn) return; + const inner = btn.querySelector('button'); + if (inner) inner.innerHTML = sideViewActive ? SVG_CLOSE : SVG_OPEN; +}; + +const injectToolbarButton = (anchorEl: Element) => { + if (document.getElementById(TOOLBAR_BTN_ID)) return; + + const btn = create$('div', { + id: TOOLBAR_BTN_ID, + class: 'mce-widget mce-btn mce-menubtn mce-fixed-width', + innerHTML: ``, + }); + + const tooltip = createTooltip(chrome.i18n.getMessage('menu_side_view_on')); + document.body.appendChild(tooltip); + + btn.addEventListener('mouseover', () => showTooltip(tooltip, btn)); + btn.addEventListener('mouseout', () => hideTooltip(tooltip)); + btn.addEventListener('click', toggleSideView); + anchorEl.insertAdjacentElement('afterend', btn); +}; + +// ── 진입점 ─────────────────────────────────────────────────────── + +const previewSideView = async () => { + const result = await chrome.storage.local.get('func_5'); + if (result.func_5 === false) return; + + // React 컴포넌트에서 새로고침 요청 수신 + window.addEventListener('sh:sideview-refresh', () => { + srcdocTemplate = null; + loadPreview(); + }); + + // React 컴포넌트 닫기 버튼에서 요청 수신 + window.addEventListener('sh:sideview-close-request', toggleSideView); + + // React 컴포넌트에서 panel iframe ID 요청 시 응답 (필요 시 확장) + window.dispatchEvent(new CustomEvent('sh:sideview-ready', { detail: { iframeId: PANEL_IFRAME_ID } })); + + const anchor = await waitForFirstElement(['#sh-image-sizer-btn', '#altTager', '#mceu_18']); + if (!anchor) return; + injectToolbarButton(anchor); + + // 첫 방문 온보딩 강조 효과 + const { sh_onboarded } = await chrome.storage.local.get('sh_onboarded'); + if (!sh_onboarded) { + await showOnboardingHighlight(); + await chrome.storage.local.set({ sh_onboarded: true }); + } +}; + +const ONBOARDING_STYLE_ID = 'sh-onboarding-style'; +const ONBOARDING_OVERLAY_ID = 'sh-onboarding-overlay'; + +const showOnboardingHighlight = (): Promise => + new Promise(resolve => { + const selectors = ['#altTager', '#sh-image-sizer-btn', `#${TOOLBAR_BTN_ID}`]; + const els = selectors + .map(s => document.querySelector(s)) + .filter((el): el is HTMLElement => el !== null); + + if (els.length === 0) { + resolve(); + return; + } + + // 세 버튼을 감싸는 단일 바운딩 박스 계산 + const rects = els.map(el => el.getBoundingClientRect()); + const top = Math.min(...rects.map(r => r.top)); + const left = Math.min(...rects.map(r => r.left)); + const right = Math.max(...rects.map(r => r.right)); + const bottom = Math.max(...rects.map(r => r.bottom)); + const pad = 4; + + // keyframe 주입 + const style = document.createElement('style'); + style.id = ONBOARDING_STYLE_ID; + style.textContent = ` + @keyframes sh-onboarding-pulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(50,94,75,0.8); } + 50% { box-shadow: 0 0 0 6px rgba(50,94,75,0); } + } + `; + document.head.appendChild(style); + + // 단일 오버레이 생성 + const overlay = document.createElement('div'); + overlay.id = ONBOARDING_OVERLAY_ID; + Object.assign(overlay.style, { + position: 'fixed', + top: `${top - pad}px`, + left: `${left - pad}px`, + width: `${right - left + pad * 2}px`, + height: `${bottom - top + pad * 2}px`, + border: '2px solid #325e4b', + borderRadius: '4px', + pointerEvents: 'none', + zIndex: '9999', + animation: 'sh-onboarding-pulse 1.2s ease-in-out 3', + }); + document.body.appendChild(overlay); + + setTimeout(() => { + overlay.remove(); + document.getElementById(ONBOARDING_STYLE_ID)?.remove(); + resolve(); + }, 3800); + }); + +// 여러 셀렉터 중 먼저 발견되는 요소 반환 (우선순위 폴백, 최대 10초) +const waitForFirstElement = (selectors: string[], timeoutMs = 10000): Promise => + new Promise(resolve => { + const found = selectors.map(s => $(s, document.body)).find(Boolean); + if (found) return resolve(found); + + let timer: ReturnType | null = null; + + const observer = new MutationObserver(() => { + const el = selectors.map(s => $(s, document.body)).find(Boolean); + if (el) { + if (timer) clearTimeout(timer); + observer.disconnect(); + resolve(el); + } + }); + + observer.observe(document.body, { childList: true, subtree: true }); + + timer = setTimeout(() => { + observer.disconnect(); + resolve(null); + }, timeoutMs); + }); + +export default previewSideView; diff --git a/src/pages/content/injected/statusIndicator.ts b/src/pages/content/injected/statusIndicator.ts index 273595c..bf38a3f 100644 --- a/src/pages/content/injected/statusIndicator.ts +++ b/src/pages/content/injected/statusIndicator.ts @@ -1,5 +1,6 @@ import { $, create$ } from '@root/utils/dom/utilDOM'; import { AltTag, Command, ImageScale, SEO, TextCounter } from '@pages/content/injected/components/SVG'; +import { FEATURES } from '@src/shared/config/features'; interface FunctionStatus { id: string; @@ -8,15 +9,21 @@ interface FunctionStatus { enabled: boolean; } +const FEATURE_ICONS: Record = { + func_0: Command, + func_1: AltTag, + func_2: ImageScale, + func_3: TextCounter, + func_4: SEO, +}; + const statusIndicator = async () => { // 기능 정의 - const functions: Omit[] = [ - { id: 'func_0', name: chrome.i18n.getMessage('feature_extra_shortcuts'), icon: Command }, - { id: 'func_1', name: chrome.i18n.getMessage('feature_alt_tagger'), icon: AltTag }, - { id: 'func_2', name: chrome.i18n.getMessage('feature_image_resizer'), icon: ImageScale }, - { id: 'func_3', name: chrome.i18n.getMessage('feature_text_counter'), icon: TextCounter }, - { id: 'func_4', name: chrome.i18n.getMessage('feature_seo_checker'), icon: SEO }, - ]; + const functions: Omit[] = FEATURES.map(f => ({ + id: f.key, + name: chrome.i18n.getMessage(f.messageKey), + icon: FEATURE_ICONS[f.key], + })); // 컨테이너 스타일 const containerStyle = { diff --git a/src/pages/content/injected/textCounter.ts b/src/pages/content/injected/textCounter.ts index ec91e3b..ad6a592 100644 --- a/src/pages/content/injected/textCounter.ts +++ b/src/pages/content/injected/textCounter.ts @@ -3,11 +3,7 @@ import { getEditorElement } from '@root/utils/dom/utilDOM'; async function textCounter() { const result = await chrome.storage.local.get('func_3'); - if (typeof result.func_3 === 'boolean') { - if (!result.func_3) { - return; - } - } + if (result.func_3 === false) return; const post = getEditorElement(); diff --git a/src/pages/content/ui/SideViewPanel.tsx b/src/pages/content/ui/SideViewPanel.tsx new file mode 100644 index 0000000..6b160be --- /dev/null +++ b/src/pages/content/ui/SideViewPanel.tsx @@ -0,0 +1,153 @@ +import { useState, useEffect } from 'react'; +import { createPortal } from 'react-dom'; + +const PANEL_IFRAME_ID = 'sh-preview-iframe'; +const PULSE_STYLE_ID = 'sh-pulse-style'; + +const containerStyle = (top: number, height: string): React.CSSProperties => ({ + position: 'fixed', + top, + right: 0, + width: '50vw', + height, + zIndex: 1000, + display: 'flex', + flexDirection: 'column', + backgroundColor: '#fff', + borderLeft: '1px solid #e0e0e0', + boxShadow: '-2px 0 8px rgba(0,0,0,0.06)', +}); + +const headerStyle: React.CSSProperties = { + padding: '10px 16px', + borderBottom: '1px solid #e0e0e0', + fontSize: '13px', + color: '#666', + flexShrink: 0, + display: 'flex', + alignItems: 'center', + gap: '8px', +}; + +const iframeStyle: React.CSSProperties = { + flex: 1, + border: 'none', + width: '100%', +}; + +const dotStyle: React.CSSProperties = { + width: '8px', + height: '8px', + borderRadius: '50%', + backgroundColor: '#4a90e2', + animation: 'sh-pulse 1.2s ease-in-out infinite', + flexShrink: 0, +}; + +const injectPulseStyle = () => { + if (document.getElementById(PULSE_STYLE_ID)) return; + const style = document.createElement('style'); + style.id = PULSE_STYLE_ID; + style.textContent = ` + @keyframes sh-pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.3; transform: scale(0.6); } + } + `; + document.head.appendChild(style); +}; + +const removePulseStyle = () => { + document.getElementById(PULSE_STYLE_ID)?.remove(); +}; + +const SideViewPanel = () => { + const [active, setActive] = useState(false); + const [srcdoc, setSrcdoc] = useState(''); + const [headerHeight, setHeaderHeight] = useState(58); + const [loading, setLoading] = useState(false); + + useEffect(() => { + injectPulseStyle(); + return () => removePulseStyle(); + }, []); + + useEffect(() => { + const onOpen = () => { + const h = document.getElementById('kakaoHead')?.getBoundingClientRect().height ?? 58; + setHeaderHeight(h); + setActive(true); + }; + + const onClose = () => { + setActive(false); + setLoading(false); + }; + + const onSrcdoc = (e: Event) => { + setSrcdoc((e as CustomEvent<{ srcdoc: string }>).detail.srcdoc); + }; + + const onLoading = (e: Event) => { + setLoading((e as CustomEvent<{ loading: boolean }>).detail.loading); + }; + + window.addEventListener('sh:sideview-open', onOpen); + window.addEventListener('sh:sideview-close', onClose); + window.addEventListener('sh:sideview-srcdoc', onSrcdoc); + window.addEventListener('sh:sideview-loading', onLoading); + + return () => { + window.removeEventListener('sh:sideview-open', onOpen); + window.removeEventListener('sh:sideview-close', onClose); + window.removeEventListener('sh:sideview-srcdoc', onSrcdoc); + window.removeEventListener('sh:sideview-loading', onLoading); + }; + }, []); + + useEffect(() => { + if (active) { + document.body.style.setProperty('margin-right', '50vw', 'important'); + } else { + document.body.style.removeProperty('margin-right'); + } + }, [active]); + + if (!active) return null; + + return createPortal( +
+
+ {loading ? '리렌더링 중...' : '미리보기'} + {loading && } + +
+