650 lines
23 KiB
TypeScript
650 lines
23 KiB
TypeScript
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 );
|