rojects/source/components/editor-shell/editor-shell.ts

650 lines
23 KiB
TypeScript
Raw Permalink Normal View History

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<typeof setTimeout> | null = null;
private readonly _tokenUpdater = new TokenUpdater();
async connectedCallback(): Promise<void>
{
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 = `
<div class="es-header">
<a class="es-back" href="/"></a>
<span class="es-title">${ projectName }</span>
<div class="es-info"></div>
<div class="es-portrait-btns">
<button class="es-pb-btn" data-panel="left"></button>
<button class="es-pb-btn active" data-panel="center"></button>
<button class="es-pb-btn" data-panel="right"></button>
</div>
</div>
<div class="es-workspace">
<div class="es-panel" data-panel="left">${ makePanelInner().outerHTML }</div>
<div class="es-v-handle"></div>
<div class="es-panel" data-panel="center">${ makePanelInner().outerHTML }</div>
<div class="es-v-handle"></div>
<div class="es-panel" data-panel="right">${ makePanelInner().outerHTML }</div>
</div>
`;
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<void>
{
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<SerializedLayout | null>
{
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<void>
{
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<typeof setTimeout> | 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 );