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

391 lines
14 KiB
TypeScript
Raw Normal View History

import { Editor } from '../../editor/Editor.js';
// ── Layout 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'): 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<typeof setTimeout> | null = null;
async connectedCallback(): Promise<void> {
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 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-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();
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, 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');
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');
sections.insertBefore(handle, section.nextSibling);
sections.insertBefore(newSec, handle.nextSibling);
}
});
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();
}
});
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<void>
{
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<void>
{
const workspace = this.querySelector( '.es-workspace' ) as HTMLElement;
const panels: Record<string, string | null> = {};
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 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 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 );
}
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);