import { Editor } from '../../editor/Editor.js'; import { EditorConsole } from '../../editor/EditorConsole.js'; import { TokenUpdater } from '../../auth/TokenUpdater.js'; import { GuardedCall } from '../../auth/GuardedCall.js'; // ── Serialized layout types ─────────────────────────────────────────────────── interface SerializedTab { id: string; label: string; panelType: string; tag: string; openFile: string | null; } interface SerializedTabContainer { flex: string | null; activeTabId: string | null; tabs: SerializedTab[]; } interface SerializedSection { flex: string | null; tabContainers: SerializedTabContainer[]; } interface SerializedPanel { flex: string | null; sections: SerializedSection[]; } interface SerializedLayout { version: number; activePortraitPanel: string; panels: { left: SerializedPanel; center: SerializedPanel; right: SerializedPanel; }; } // ── DOM helpers ─────────────────────────────────────────────────────────────── function removeAdjacentHandle( el: HTMLElement, handleClass: string ): void { const next = el.nextElementSibling as HTMLElement | null; if ( next && next.classList.contains( handleClass ) ) { next.remove(); return; } const prev = el.previousElementSibling as HTMLElement | null; if ( prev && prev.classList.contains( handleClass ) ) { prev.remove(); } } let tcCounter = 0; function nextTcId(): string { return `tc-${ ++tcCounter }`; } function makeResizeHandle( direction: 'v' | 'h', onResize?: () => void ): 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 ); onResize?.(); }, { 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'; inner.appendChild( makeSection() ); return inner; } // ── EditorShell ─────────────────────────────────────────────────────────────── class EditorShell extends HTMLElement { private activePortraitPanel: string = 'center'; private _deviceId: string = ''; private _saveTimer: ReturnType | null = null; private readonly _tokenUpdater = new TokenUpdater(); async connectedCallback(): Promise { const authRes = await fetch( '/api/auth/me' ); if ( !authRes.ok ) { location.href = '/'; return; } this._tokenUpdater.onStateChanged.addListener( state => { if ( state === 'expired' ) { location.href = '/'; return; } if ( state === 'network-error' ) console.warn( '[auth] session check failed — network error' ); } ); this._tokenUpdater.start(); GuardedCall.init( this._tokenUpdater ); const params = new URLSearchParams( location.search ); const projectId = params.get( 'project' ) ?? ''; const localRoot = params.get( 'localRoot' ) ?? ''; const remoteProject = params.get( 'remoteProject' ) ?? ''; const projectName = params.get( 'name' ) ?? 'Project'; Editor.get().projectId = projectId; Editor.get().localRoot = localRoot; Editor.get().remoteProject = remoteProject; Editor.get().projectName = projectName; this.innerHTML = `
${ projectName }
${ makePanelInner().outerHTML }
${ makePanelInner().outerHTML }
${ makePanelInner().outerHTML }
`; this.setupMainHandles(); this.setupPortrait(); this.setupSplitListener(); this.setupResizeHandler(); this._setupInfo(); let loadedLayout: SerializedLayout | null = null; await Promise.all( [ customElements.whenDefined( 'tab-container' ), customElements.whenDefined( 'file-tree-panel' ), customElements.whenDefined( 'page-editor-panel' ), customElements.whenDefined( 'code-panel' ), customElements.whenDefined( 'console-panel' ), this._loadLayout().then( l => { loadedLayout = l; } ), ] ); if ( loadedLayout?.panels ) { await this._restoreLayout( loadedLayout ); } else { this.initDefaultLayout(); } // Save on active tab switch const workspace = this.querySelector( '.es-workspace' ) as HTMLElement; workspace.addEventListener( 'click', ( e ) => { if ( ( e.target as HTMLElement ).closest( '.tc-tab' ) ) this._scheduleLayoutSave(); } ); // Save when a file is opened in any panel Editor.get().onDocumentOpened.addListener( () => this._scheduleLayoutSave() ); } 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' }, () => document.createElement( 'file-tree-panel' ) ); centerTc?.addTab( { id: 'page-editor', label: 'Page', panelType: 'page-editor' }, () => document.createElement( 'page-editor-panel' ) ); } // ── Layout serialization ──────────────────────────────────────────────────── private _layoutUrl(): string { const qp = new URLSearchParams( location.search ); const p = new URLSearchParams( { deviceId: this._getDeviceId() } ); const projectId = qp.get( 'project' ); const localRoot = qp.get( 'localRoot' ); const remoteProject = qp.get( 'remoteProject' ); if ( projectId ) p.set( 'projectId', projectId ); else if ( localRoot ) p.set( 'localRoot', localRoot ); else if ( remoteProject ) p.set( 'remoteProject', remoteProject ); return `/api/layout?${ p }`; } private _serializeLayout(): SerializedLayout { const serializePanel = ( panel: HTMLElement ): SerializedPanel => { const sections: SerializedSection[] = []; const sectionsEl = panel.querySelector( '.es-sections' )!; for ( const child of Array.from( sectionsEl.children ) ) { if ( !child.classList.contains( 'es-section' ) ) continue; const sec = child as HTMLElement; const tabContainers: SerializedTabContainer[] = []; for ( const secChild of Array.from( sec.children ) ) { if ( secChild.tagName.toLowerCase() !== 'tab-container' ) continue; const tc = secChild as any; const tabs: SerializedTab[] = ( tc.tabs as any[] ).map( ( tab: any ) => { const openFile: string | null = typeof tab.element.getCurrentFile === 'function' ? tab.element.getCurrentFile() : null; return { id: tab.id, label: tab.label, panelType: tab.panelType, tag: tab.element.tagName.toLowerCase(), openFile, }; } ); tabContainers.push( { flex: ( secChild as HTMLElement ).style.flex || null, activeTabId: tc.activeId, tabs, } ); } sections.push( { flex: sec.style.flex || null, tabContainers } ); } return { flex: panel.style.flex || null, sections }; }; const workspace = this.querySelector( '.es-workspace' ) as HTMLElement; return { version: 1, activePortraitPanel: this.activePortraitPanel, panels: { left: serializePanel( workspace.querySelector( '[data-panel="left"]' ) as HTMLElement ), center: serializePanel( workspace.querySelector( '[data-panel="center"]' ) as HTMLElement ), right: serializePanel( workspace.querySelector( '[data-panel="right"]' ) as HTMLElement ), }, }; } private async _restoreLayout( layout: SerializedLayout ): Promise { this.activePortraitPanel = layout.activePortraitPanel ?? 'center'; const workspace = this.querySelector( '.es-workspace' ) as HTMLElement; const openTasks: Array<{ file: string; panel: HTMLElement }> = []; const save = () => this._scheduleLayoutSave(); const restorePanel = ( panel: HTMLElement, data: SerializedPanel ) => { if ( data.flex ) panel.style.flex = data.flex; const sectionsEl = panel.querySelector( '.es-sections' ) as HTMLElement; sectionsEl.innerHTML = ''; let firstSection = true; for ( const secData of data.sections ) { if ( !firstSection ) sectionsEl.appendChild( makeResizeHandle( 'v', save ) ); firstSection = false; const sec = document.createElement( 'div' ); sec.className = 'es-section'; if ( secData.flex ) sec.style.flex = secData.flex; const entries: Array<{ tc: HTMLElement; tcData: SerializedTabContainer }> = []; let firstTc = true; for ( const tcData of secData.tabContainers ) { if ( !firstTc ) sec.appendChild( makeResizeHandle( 'h', save ) ); firstTc = false; const tc = document.createElement( 'tab-container' ) as HTMLElement; tc.id = nextTcId(); if ( tcData.flex ) tc.style.flex = tcData.flex; sec.appendChild( tc ); entries.push( { tc, tcData } ); } // Connect to DOM so each tab-container's connectedCallback fires sectionsEl.appendChild( sec ); // Safe to call addTab now for ( const { tc, tcData } of entries ) { for ( const tabData of tcData.tabs ) { const tag = tabData.tag; ( tc as any ).addTab( { id: tabData.id, label: tabData.label, panelType: tabData.panelType }, () => document.createElement( tag ), ); if ( tabData.openFile ) { const entry = ( tc as any ).tabs.find( ( t: any ) => t.id === tabData.id ); if ( entry ) openTasks.push( { file: tabData.openFile, panel: entry.element } ); } } if ( tcData.activeTabId ) ( tc as any ).activateTab( tcData.activeTabId ); } } }; restorePanel( workspace.querySelector( '[data-panel="left"]' ) as HTMLElement, layout.panels.left ); restorePanel( workspace.querySelector( '[data-panel="center"]' ) as HTMLElement, layout.panels.center ); restorePanel( workspace.querySelector( '[data-panel="right"]' ) as HTMLElement, layout.panels.right ); workspace.querySelectorAll( '.es-sections' ).forEach( s => this.observeNewHandles( s as HTMLElement ) ); for ( const { file, panel } of openTasks ) { await Editor.get().openDocumentIn( file, panel ); } } private async _loadLayout(): Promise { try { const res = await GuardedCall.get().call( 'silent', () => fetch( this._layoutUrl() ) ); if ( !res.ok ) return null; const data = await res.json(); return data ?? null; } catch { return null; } } private _scheduleLayoutSave(): void { if ( this._saveTimer ) clearTimeout( this._saveTimer ); this._saveTimer = setTimeout( () => this._saveLayout(), 800 ); } private async _saveLayout(): Promise { const layout = this._serializeLayout(); await GuardedCall.get().call( 'silent', () => fetch( this._layoutUrl(), { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify( layout ), } ) ); } // ── Resize handles ────────────────────────────────────────────────────────── 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 { const save = () => this._scheduleLayoutSave(); this.addEventListener( 'tab-container:split', ( e: Event ) => { const { containerId, direction } = ( e as CustomEvent ).detail as { containerId: string; direction: 'horizontal' | 'vertical' }; const tc = document.getElementById( containerId ); if ( !tc ) return; const section = tc.closest( '.es-section' ) as HTMLElement | null; if ( !section ) return; if ( 'vertical' === direction ) { const newTc = document.createElement( 'tab-container' ) as HTMLElement; newTc.id = nextTcId(); const handle = makeResizeHandle( 'h', save ); section.appendChild( handle ); section.appendChild( newTc ); } else { const sections = section.closest( '.es-sections' ) as HTMLElement | null; if ( !sections ) return; const newSec = makeSection(); const handle = makeResizeHandle( 'v', save ); sections.insertBefore( handle, section.nextSibling ); sections.insertBefore( newSec, handle.nextSibling ); } save(); } ); this.addEventListener( 'tab-container:close-container', ( e: Event ) => { const { containerId } = ( e as CustomEvent ).detail as { containerId: string }; const tc = document.getElementById( containerId ) as HTMLElement | null; if ( !tc ) return; const section = tc.closest( '.es-section' ) as HTMLElement | null; if ( !section ) return; const tcsInSection = section.querySelectorAll( ':scope > tab-container' ); if ( tcsInSection.length > 1 ) { removeAdjacentHandle( tc, 'es-h-handle' ); tc.remove(); } else { const sections = section.closest( '.es-sections' ) as HTMLElement | null; if ( !sections ) return; removeAdjacentHandle( section, 'es-v-handle' ); section.remove(); } save(); } ); 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 ) ); save(); } ); } // ── Device ID ─────────────────────────────────────────────────────────────── 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; } // ── Resize redistribution ─────────────────────────────────────────────────── private setupResizeHandler(): void { const workspace = this.querySelector( '.es-workspace' ) as HTMLElement; const workspaceObs = new ResizeObserver( () => { this._redistributeFlex( workspace, '.es-panel' ); } ); workspaceObs.observe( workspace ); const sectionsObs = new ResizeObserver( ( entries ) => { for ( const entry of entries ) { this._redistributeFlex( entry.target as HTMLElement, '.es-section' ); } } ); workspace.querySelectorAll( '.es-sections' ).forEach( s => sectionsObs.observe( s ) ); } 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.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 } ); } // ── Info bar ──────────────────────────────────────────────────────────────── private _setupInfo(): void { const infoEl = this.querySelector( '.es-info' ) as HTMLElement; let hideTimer: ReturnType | null = null; Editor.get().onFileTypeUnknown.addListener( e => { const lastDot = e.path.lastIndexOf( '.' ); const ext = lastDot === -1 ? e.path.slice( e.path.lastIndexOf( '/' ) + 1 ) : e.path.slice( lastDot ); EditorConsole.get().log( `Cannot open "${ ext }" files`, 'error' ); } ); EditorConsole.get().onMessage.addListener( msg => { if ( hideTimer ) clearTimeout( hideTimer ); infoEl.textContent = msg.text; infoEl.className = `es-info es-info-visible es-info-${ msg.type }`; hideTimer = setTimeout( () => infoEl.classList.remove( 'es-info-visible' ), 5000 ); } ); } // ── Portrait mode ─────────────────────────────────────────────────────────── 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 ); } else { this.querySelectorAll( '.es-panel' ).forEach( p => { ( p as HTMLElement ).style.display = ''; } ); this.querySelectorAll( '.es-v-handle' ).forEach( h => { ( h as HTMLElement ).style.display = ''; } ); } }; 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 );