import { Editor } from '../../editor/Editor.js'; // ── Layout helpers ──────────────────────────────────────────────────────────── let tcCounter = 0; function nextTcId(): string { return `tc-${++tcCounter}`; } function makeResizeHandle(direction: 'v' | 'h'): HTMLElement { const h = document.createElement('div'); h.className = direction === 'v' ? 'es-v-handle' : 'es-h-handle'; h.addEventListener('pointerdown', (e: PointerEvent) => { e.preventDefault(); h.setPointerCapture(e.pointerId); const prev = h.previousElementSibling as HTMLElement | null; const next = h.nextElementSibling as HTMLElement | null; if (!prev || !next) return; const startPos = direction === 'v' ? e.clientX : e.clientY; const startPrev = direction === 'v' ? prev.offsetWidth : prev.offsetHeight; const startNext = direction === 'v' ? next.offsetWidth : next.offsetHeight; const total = startPrev + startNext; const onMove = (ev: PointerEvent) => { const delta = (direction === 'v' ? ev.clientX : ev.clientY) - startPos; const newPrev = Math.max(60, Math.min(total - 60, startPrev + delta)); prev.style.flexBasis = `${newPrev}px`; next.style.flexBasis = `${total - newPrev}px`; prev.style.flex = `0 0 ${newPrev}px`; next.style.flex = `0 0 ${total - newPrev}px`; }; h.addEventListener('pointermove', onMove); h.addEventListener('pointerup', () => h.removeEventListener('pointermove', onMove), { once: true }); }); return h; } function makeSection(): HTMLElement { const sec = document.createElement('div'); sec.className = 'es-section'; const tc = document.createElement('tab-container') as HTMLElement; tc.id = nextTcId(); sec.appendChild(tc); return sec; } function makePanelInner(): HTMLElement { const inner = document.createElement('div'); inner.className = 'es-sections'; const sec = makeSection(); inner.appendChild(sec); return inner; } // ── EditorShell ─────────────────────────────────────────────────────────────── class EditorShell extends HTMLElement { private activePortraitPanel: string = 'center'; private _deviceId: string = ''; private _saveTimer: ReturnType | null = null; async connectedCallback(): Promise { const authRes = await fetch( '/api/auth/me' ); if ( !authRes.ok ) { location.href = '/'; return; } const params = new URLSearchParams(location.search); const projectId = params.get('project') ?? ''; const projectName = params.get('name') ?? 'Project'; Editor.get().projectId = projectId; Editor.get().projectName = projectName; this.innerHTML = `
${projectName}
${makePanelInner().outerHTML}
${makePanelInner().outerHTML}
${makePanelInner().outerHTML}
`; this.setupMainHandles(); this.setupPortrait(); this.setupSplitListener(); this.setupResizeHandler(); await Promise.all( [ customElements.whenDefined( 'tab-container' ), customElements.whenDefined( 'file-tree-panel' ), customElements.whenDefined( 'page-editor-panel' ), customElements.whenDefined( 'code-panel' ), this._loadLayout(), ] ); this.initDefaultLayout(); } private initDefaultLayout(): void { const leftTc = this.querySelector('[data-panel="left"] tab-container') as any; const centerTc = this.querySelector('[data-panel="center"] tab-container') as any; leftTc?.addTab({ id: 'file-tree', label: 'Files', panelType: 'file-tree' }, () => { return document.createElement('file-tree-panel'); }); centerTc?.addTab({ id: 'page-editor', label: 'Page', panelType: 'page-editor' }, () => { return document.createElement('page-editor-panel'); }); } private setupMainHandles(): void { const workspace = this.querySelector('.es-workspace')!; workspace.querySelectorAll(':scope > .es-v-handle').forEach(h => { const handle = h as HTMLElement; handle.addEventListener('pointerdown', (e: PointerEvent) => { e.preventDefault(); handle.setPointerCapture(e.pointerId); const prev = handle.previousElementSibling as HTMLElement; const next = handle.nextElementSibling as HTMLElement; const start = e.clientX; const startPrev = prev.offsetWidth; const startNext = next.offsetWidth; const total = startPrev + startNext; const onMove = (ev: PointerEvent) => { const delta = ev.clientX - start; const np = Math.max(80, Math.min(total - 80, startPrev + delta)); prev.style.flex = `0 0 ${np}px`; next.style.flex = `0 0 ${total - np}px`; }; handle.addEventListener('pointermove', onMove); handle.addEventListener('pointerup', () => { handle.removeEventListener('pointermove', onMove); this._scheduleLayoutSave(); }, { once: true }); }); }); this.querySelectorAll('.es-sections').forEach(sections => { this.observeNewHandles(sections as HTMLElement); }); } private setupSplitListener(): void { this.addEventListener('tab-container:split', (e: Event) => { const { containerId } = (e as CustomEvent).detail as { containerId: string }; const tc = document.getElementById(containerId); if (!tc) return; const section = tc.closest('.es-section') as HTMLElement | null; const sections = tc.closest('.es-sections') as HTMLElement | null; if (!section || !sections) return; const newSec = makeSection(); const handle = makeResizeHandle('v'); sections.insertBefore(handle, section.nextSibling); sections.insertBefore(newSec, handle.nextSibling); }); this.addEventListener('tab-container:add-panel', (e: Event) => { const { containerId, panelType, tag, label } = (e as CustomEvent).detail as { containerId: string; panelType: string; tag: string; label: string }; const tc = document.getElementById(containerId) as any; if (!tc) return; const id = panelType + '-' + Math.random().toString(36).slice(2); tc.addTab({ id, label: label ?? panelType, panelType }, () => document.createElement(tag)); }); } private _getDeviceId(): string { if ( this._deviceId ) return this._deviceId; let id = localStorage.getItem( 'roject:deviceId' ); if ( !id ) { id = Math.random().toString( 36 ).slice( 2 ) + Math.random().toString( 36 ).slice( 2 ); localStorage.setItem( 'roject:deviceId', id ); } this._deviceId = id; return id; } private async _loadLayout(): Promise { try { const res = await fetch( `/api/layout?deviceId=${this._getDeviceId()}` ); if ( !res.ok ) return; const layout = await res.json(); if ( !layout ) return; if ( layout.panels ) { const workspace = this.querySelector( '.es-workspace' ) as HTMLElement; for ( const [ panel, flex ] of Object.entries( layout.panels ) ) { if ( flex ) { const el = workspace.querySelector( `[data-panel="${panel}"]` ) as HTMLElement; if ( el ) el.style.flex = flex as string; } } } if ( layout.activePortraitPanel ) { this.activePortraitPanel = layout.activePortraitPanel; } } catch {} } private _scheduleLayoutSave(): void { if ( this._saveTimer ) clearTimeout( this._saveTimer ); this._saveTimer = setTimeout( () => this._saveLayout(), 800 ); } private async _saveLayout(): Promise { const workspace = this.querySelector( '.es-workspace' ) as HTMLElement; const panels: Record = {}; for ( const p of [ 'left', 'center', 'right' ] ) { const el = workspace.querySelector( `[data-panel="${p}"]` ) as HTMLElement; panels[ p ] = el?.style.flex || null; } const layout = { panels, activePortraitPanel: this.activePortraitPanel }; try { await fetch( `/api/layout?deviceId=${this._getDeviceId()}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify( layout ), } ); } catch {} } private setupResizeHandler(): void { const workspace = this.querySelector( '.es-workspace' ) as HTMLElement; const obs = new ResizeObserver( () => { this._redistributeFlex( workspace, '.es-panel' ); workspace.querySelectorAll( '.es-sections' ).forEach( container => { this._redistributeFlex( container as HTMLElement, '.es-section' ); } ); } ); obs.observe( workspace ); } private _redistributeFlex( container: HTMLElement, childSelector: string ): void { const children = Array.from( container.querySelectorAll( `:scope > ${childSelector}` ) ) as HTMLElement[]; if ( children.length < 2 ) return; if ( ! children.some( c => c.style.flex ) ) return; const totalPanel = children.reduce( ( sum, c ) => sum + c.offsetWidth, 0 ); if ( totalPanel === 0 ) return; const handles = Array.from( container.children ).filter( c => ! ( c as HTMLElement ).matches( childSelector ) ) as HTMLElement[]; const handleTotal = handles.reduce( ( sum, h ) => sum + ( h as HTMLElement ).offsetWidth, 0 ); const available = container.clientWidth - handleTotal; children.forEach( c => { const ratio = c.offsetWidth / totalPanel; c.style.flex = `0 0 ${Math.round( ratio * available )}px`; } ); } private observeNewHandles(sections: HTMLElement): void { const observer = new MutationObserver(() => { sections.querySelectorAll('.es-h-handle:not([data-bound])').forEach(h => { (h as HTMLElement).dataset.bound = '1'; }); }); observer.observe(sections, { childList: true }); } private setupPortrait(): void { const btns = this.querySelector( '.es-portrait-btns' ) as HTMLElement; const mq = window.matchMedia( '(orientation: portrait)' ); const apply = ( portrait: boolean ) => { this.classList.toggle( 'portrait', portrait ); if ( portrait ) this.showPortraitPanel( this.activePortraitPanel ); }; btns.querySelectorAll( '.es-pb-btn' ).forEach( btn => { btn.addEventListener( 'click', () => { const panel = ( btn as HTMLElement ).dataset.panel!; this.activePortraitPanel = panel; btns.querySelectorAll( '.es-pb-btn' ).forEach( b => b.classList.remove( 'active' ) ); btn.classList.add( 'active' ); this.showPortraitPanel( panel ); this._scheduleLayoutSave(); } ); } ); mq.addEventListener( 'change', e => apply( e.matches ) ); apply( mq.matches ); } private showPortraitPanel(panelId: string): void { this.querySelectorAll('.es-panel').forEach(p => { (p as HTMLElement).style.display = (p as HTMLElement).dataset.panel === panelId ? '' : 'none'; }); this.querySelectorAll('.es-v-handle').forEach(h => { (h as HTMLElement).style.display = 'none'; }); } } customElements.define('editor-shell', EditorShell);