diff --git a/source/components/code-panel/code-panel.ts b/source/components/code-panel/code-panel.ts
index 3228d94..b55faea 100644
--- a/source/components/code-panel/code-panel.ts
+++ b/source/components/code-panel/code-panel.ts
@@ -140,6 +140,11 @@ class CodePanel extends HTMLElement
return this._dirty;
}
+ getCurrentFile(): string | null
+ {
+ return this.currentPath;
+ }
+
_updateButtons( dirty: boolean ): void
{
this._dirty = dirty;
diff --git a/source/components/editor-shell/editor-shell.ts b/source/components/editor-shell/editor-shell.ts
index c105b7c..7b91544 100644
--- a/source/components/editor-shell/editor-shell.ts
+++ b/source/components/editor-shell/editor-shell.ts
@@ -1,7 +1,43 @@
import { Editor } from '../../editor/Editor.js';
import { EditorConsole } from '../../editor/EditorConsole.js';
-// ── Layout helpers ────────────────────────────────────────────────────────────
+// ── 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
{
@@ -19,78 +55,89 @@ function removeAdjacentHandle( el: HTMLElement, handleClass: string ): void
}
let tcCounter = 0;
-function nextTcId(): string { return `tc-${++tcCounter}`; }
+function nextTcId(): string { return `tc-${ ++tcCounter }`; }
-function makeResizeHandle(direction: 'v' | 'h'): HTMLElement {
- const h = document.createElement('div');
+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) => {
+ h.addEventListener( 'pointerdown', ( e: PointerEvent ) =>
+ {
e.preventDefault();
- h.setPointerCapture(e.pointerId);
+ 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;
+ 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 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`;
+ 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 });
- });
+ h.addEventListener( 'pointermove', onMove );
+ h.addEventListener( 'pointerup', () =>
+ {
+ h.removeEventListener( 'pointermove', onMove );
+ onResize?.();
+ }, { once: true } );
+ } );
return h;
}
-function makeSection(): HTMLElement {
- const sec = document.createElement('div');
+function makeSection(): HTMLElement
+{
+ const sec = document.createElement( 'div' );
sec.className = 'es-section';
- const tc = document.createElement('tab-container') as HTMLElement;
+ const tc = document.createElement( 'tab-container' ) as HTMLElement;
tc.id = nextTcId();
- sec.appendChild(tc);
+ sec.appendChild( tc );
return sec;
}
-function makePanelInner(): HTMLElement {
- const inner = document.createElement('div');
+function makePanelInner(): HTMLElement
+{
+ const inner = document.createElement( 'div' );
inner.className = 'es-sections';
- const sec = makeSection();
- inner.appendChild(sec);
+ inner.appendChild( makeSection() );
return inner;
}
// ── EditorShell ───────────────────────────────────────────────────────────────
-class EditorShell extends HTMLElement {
+class EditorShell extends HTMLElement
+{
private activePortraitPanel: string = 'center';
private _deviceId: string = '';
private _saveTimer: ReturnType | null = null;
- async connectedCallback(): Promise {
+ 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 localRoot = params.get('localRoot') ?? '';
- const remoteProject = params.get('remoteProject') ?? '';
- const projectName = params.get('name') ?? 'Project';
- Editor.get().projectId = projectId;
- Editor.get().localRoot = localRoot;
+ 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;
+ Editor.get().projectName = projectName;
this.innerHTML = `
-
${makePanelInner().outerHTML}
+
${ makePanelInner().outerHTML }
-
${makePanelInner().outerHTML}
+
${ makePanelInner().outerHTML }
-
${makePanelInner().outerHTML}
+
${ makePanelInner().outerHTML }
`;
@@ -113,123 +160,345 @@ class EditorShell extends HTMLElement {
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(),
+ this._loadLayout().then( l => { loadedLayout = l; } ),
] );
- this.initDefaultLayout();
+ 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;
+ 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');
- });
+ 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' }, () => {
- return document.createElement('page-editor-panel');
- });
+ centerTc?.addTab( { id: 'page-editor', label: 'Page', panelType: 'page-editor' }, () =>
+ document.createElement( 'page-editor-panel' )
+ );
}
- private setupMainHandles(): void {
- const workspace = this.querySelector('.es-workspace')!;
- workspace.querySelectorAll(':scope > .es-v-handle').forEach(h => {
+ // ── 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 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();
+ try
+ {
+ await fetch( this._layoutUrl(), {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify( layout ),
+ } );
+ }
+ catch {}
+ }
+
+ // ── 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) => {
+ 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;
+ 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 total = startPrev + startNext;
- const onMove = (ev: PointerEvent) => {
+ 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`;
+ 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);
+ 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);
- });
+ }, { 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;
+ 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;
+ const newTc = document.createElement( 'tab-container' ) as HTMLElement;
newTc.id = nextTcId();
- const handle = makeResizeHandle('h');
- section.appendChild(handle);
- section.appendChild(newTc);
+ 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 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);
+ const handle = makeResizeHandle( 'v', save );
+ 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;
+ save();
+ } );
- const tcsInSection = section.querySelectorAll(':scope > tab-container');
+ 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');
+ 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');
+ 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));
- });
+ 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;
@@ -243,62 +512,7 @@ class EditorShell extends HTMLElement {
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 {}
- }
+ // ── Resize redistribution ───────────────────────────────────────────────────
private setupResizeHandler(): void
{
@@ -322,35 +536,40 @@ class EditorShell extends HTMLElement {
private _redistributeFlex( container: HTMLElement, childSelector: string ): void
{
- const children = Array.from( container.querySelectorAll( `:scope > ${childSelector}` ) ) as HTMLElement[];
+ const children = Array.from( container.querySelectorAll( `:scope > ${ childSelector }` ) ) as HTMLElement[];
if ( children.length < 2 ) return;
- if ( ! children.some( c => c.style.flex ) ) return;
+ if ( !children.some( c => c.style.flex ) ) return;
- const totalPanel = children.reduce( ( sum, c ) => sum + c.offsetWidth, 0 );
+ 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 )
+ 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;
+ 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`;
+ 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 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;
@@ -367,16 +586,20 @@ class EditorShell extends HTMLElement {
{
if ( hideTimer ) clearTimeout( hideTimer );
infoEl.textContent = msg.text;
- infoEl.className = `es-info es-info-visible es-info-${ msg.type }`;
+ infoEl.className = `es-info es-info-visible es-info-${ msg.type }`;
hideTimer = setTimeout( () => infoEl.classList.remove( 'es-info-visible' ), 5000 );
} );
}
- private setupPortrait(): void {
- const btns = this.querySelector( '.es-portrait-btns' ) as HTMLElement;
- const mq = window.matchMedia( '(orientation: portrait)' );
+ // ── Portrait mode ───────────────────────────────────────────────────────────
- const apply = ( portrait: boolean ) => {
+ 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 )
{
@@ -389,8 +612,10 @@ class EditorShell extends HTMLElement {
}
};
- btns.querySelectorAll( '.es-pb-btn' ).forEach( btn => {
- btn.addEventListener( 'click', () => {
+ 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' ) );
@@ -404,12 +629,14 @@ class EditorShell extends HTMLElement {
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'; });
+ 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);
+customElements.define( 'editor-shell', EditorShell );
diff --git a/source/components/page-editor-panel/page-editor-panel.ts b/source/components/page-editor-panel/page-editor-panel.ts
index 0d8b9b3..a26b3a7 100644
--- a/source/components/page-editor-panel/page-editor-panel.ts
+++ b/source/components/page-editor-panel/page-editor-panel.ts
@@ -820,6 +820,11 @@ class PageEditorPanel extends HTMLElement
return this._dirty;
}
+ getCurrentFile(): string | null
+ {
+ return this.currentPath;
+ }
+
_updateButtons( dirty: boolean ): void
{
this._dirty = dirty;
diff --git a/source/editor/editor-panel.ts b/source/editor/editor-panel.ts
index 22693d8..ff1f5b9 100644
--- a/source/editor/editor-panel.ts
+++ b/source/editor/editor-panel.ts
@@ -19,6 +19,7 @@ export interface EditorPanel extends HTMLElement
export interface FileEditorPanel extends EditorPanel
{
hasUnsavedChanges(): boolean;
+ getCurrentFile(): string | null;
}
export function implementsInterface( el: any, def: { type: string } ): boolean
diff --git a/source/server/routes/layout.ts b/source/server/routes/layout.ts
index e2b0961..6d52925 100644
--- a/source/server/routes/layout.ts
+++ b/source/server/routes/layout.ts
@@ -2,7 +2,7 @@ import { Router } from 'express';
import fs from 'fs';
import path from 'path';
import { requireAuth } from '../../auth-connector/source/server/auth';
-import { RJLog } from '../../library-ts/node/log/RJLog';
+import { checkAccess } from '../projectAccess';
import { ROOT } from '../rootDir';
const router = Router();
@@ -10,20 +10,58 @@ router.use( requireAuth );
const LAYOUTS_DIR = path.join( ROOT, 'build', 'data', 'storage', 'layouts' );
-function layoutFilePath( userId: string, deviceId: string ): string
+function safeId( s: string ): string
{
- RJLog.log( { userId, deviceId } );
- const safe = ( s: string ) => s.replace( /[^a-zA-Z0-9_-]/g, '_' );
- const dir = path.join( LAYOUTS_DIR, safe( userId ) );
- if ( !fs.existsSync( dir ) ) fs.mkdirSync( dir, { recursive: true } );
- return path.join( dir, safe( deviceId ) + '.json' );
+ return s.replace( /[^a-zA-Z0-9_-]/g, '_' );
+}
+
+function resolveLayoutPath( req: any ): string | null
+{
+ const deviceId = req.query.deviceId as string;
+ if ( !deviceId ) return null;
+
+ const projectId = req.query.projectId as string | undefined;
+ const localRoot = req.query.localRoot as string | undefined;
+ const remoteProject = req.query.remoteProject as string | undefined;
+
+ if ( projectId )
+ {
+ const dir = path.join( ROOT, 'build', 'data', 'storage', projectId, 'root', '.roject' );
+ if ( !fs.existsSync( dir ) ) fs.mkdirSync( dir, { recursive: true } );
+ return path.join( dir, `layout-${ safeId( deviceId ) }.json` );
+ }
+
+ if ( localRoot )
+ {
+ const resolved = path.resolve( localRoot );
+ const dir = path.join( resolved, '.roject' );
+ if ( !fs.existsSync( dir ) ) fs.mkdirSync( dir, { recursive: true } );
+ return path.join( dir, `layout-${ safeId( deviceId ) }.json` );
+ }
+
+ if ( remoteProject )
+ {
+ // Remote project opened in Electron: store locally, keyed by device + remote project id
+ const userId = req.auth!.userId;
+ const dir = path.join( LAYOUTS_DIR, safeId( userId ) );
+ if ( !fs.existsSync( dir ) ) fs.mkdirSync( dir, { recursive: true } );
+ return path.join( dir, `${ safeId( deviceId ) }-${ safeId( remoteProject ) }.json` );
+ }
+
+ return null;
}
router.get( '/', ( req, res ) =>
{
- const deviceId = req.query.deviceId as string;
- if ( !deviceId ) { res.json( null ); return; }
- const fp = layoutFilePath( req.auth!.userId, deviceId );
+ const projectId = req.query.projectId as string | undefined;
+ if ( projectId )
+ {
+ const denied = checkAccess( projectId, req.auth!, 'view' );
+ if ( denied ) { res.status( denied.status ).json( { error: denied.error } ); return; }
+ }
+
+ const fp = resolveLayoutPath( req );
+ if ( !fp ) { res.json( null ); return; }
if ( !fs.existsSync( fp ) ) { res.json( null ); return; }
try { res.json( JSON.parse( fs.readFileSync( fp, 'utf8' ) ) ); }
catch { res.json( null ); }
@@ -31,9 +69,15 @@ router.get( '/', ( req, res ) =>
router.put( '/', ( req, res ) =>
{
- const deviceId = req.query.deviceId as string;
- if ( !deviceId ) { res.status( 400 ).json( { error: 'Missing deviceId' } ); return; }
- const fp = layoutFilePath( req.auth!.userId, deviceId );
+ const projectId = req.query.projectId as string | undefined;
+ if ( projectId )
+ {
+ const denied = checkAccess( projectId, req.auth!, 'edit' );
+ if ( denied ) { res.status( denied.status ).json( { error: denied.error } ); return; }
+ }
+
+ const fp = resolveLayoutPath( req );
+ if ( !fp ) { res.status( 400 ).json( { error: 'Missing project identifier or deviceId' } ); return; }
try
{
fs.writeFileSync( fp, JSON.stringify( req.body ), 'utf8' );
diff --git a/source/server/routes/localFiles.ts b/source/server/routes/localFiles.ts
index 6c63b13..4f004f1 100644
--- a/source/server/routes/localFiles.ts
+++ b/source/server/routes/localFiles.ts
@@ -24,7 +24,7 @@ function safeResolve( root: string, filePath: string ): string | null
function buildTree( absDir: string, rootDir: string ): FileNode[]
{
- return fs.readdirSync( absDir ).map( name =>
+ return fs.readdirSync( absDir ).filter( name => name !== '.roject' ).map( name =>
{
const abs = path.join( absDir, name );
const rel = path.relative( rootDir, abs ).replace( /\\/g, '/' );
diff --git a/source/server/storage.ts b/source/server/storage.ts
index 3b027ff..0d349b2 100644
--- a/source/server/storage.ts
+++ b/source/server/storage.ts
@@ -25,7 +25,7 @@ export function createProjectStorage(projectId: string): void {
}
function buildTree(absDir: string, rootDir: string): FileNode[] {
- return fs.readdirSync(absDir).map(name => {
+ return fs.readdirSync(absDir).filter(name => name !== '.roject').map(name => {
const abs = path.join(absDir, name);
const rel = path.relative(rootDir, abs).replace(/\\/g, '/');
if (fs.statSync(abs).isDirectory()) {
diff --git a/workspace/boards/tasks.html b/workspace/boards/tasks.html
index 01c6e30..034c934 100644
--- a/workspace/boards/tasks.html
+++ b/workspace/boards/tasks.html
@@ -223,56 +223,24 @@
- File tree: UX improvements
+ Per-project per-device layout persistence
- — Open-state preservation: refresh() now records which ftp-dir elements are open
- (via data-path on their ftp-dir-label) before rebuilding the HTML, then
- re-adds the open class to matching labels after render.
- — "Mark As Root Directory" moved from dblclick to context menu (isDir detection
- via targetPath.endsWith('/'), shown inside showItemMenu).
- — "Open >" submenu for files: context menu lists the default editor plus all
- registered alternatives (_panelTypeMap / _editorAlternatives static maps).
- Selecting an entry calls _openFileIn(path, editorTag).
- — Context menu label: shows filename only, truncated to menuLabelMaxChars (20)
- with a leading "..." prefix when over the limit.
-
-
-
-
- Page editor: mode buttons moved into toolbar
-
- The left sidebar (.pep-sidebar) and its .pep-main wrapper were removed.
- The Blocks (⊞) and Areas (T) mode buttons now live directly in .pep-toolbar,
- pushed right by a .pep-toolbar-sep spacer (flex: 1).
- page-editor-panel now uses flex-direction: column with three direct children:
- .pep-toolbar, .pep-mode-panel, iframe.
-
-
-
-
- EditorConsole + console-panel
-
- EditorConsole is a new standalone singleton (source/editor/EditorConsole.ts)
- that holds a capped ring of 500 ConsoleMessage objects and dispatches them via
- onMessage: EventSlot. editor-shell subscribes and shows messages in a new
- .es-info element in the header (5 s fade; portrait: fixed bottom bar).
- Editor.onFileTypeUnknown is bridged to EditorConsole here, removing the
- inline ftp-type-error element from file-tree-panel.
- console-panel is a new tab that renders all messages from EditorConsole using
- custom elements (conp-header, conp-list, conp-entry, conp-time, conp-text).
- Added to tab-container panel-type list and loaded in editor.html.
-
-
-
-
- Bug fix: openDocumentIn ignored the target panel type
-
- openDocumentIn was re-resolving editorTag from FileEditorRegistry, which always
- returned the default editor for the file type (e.g. page-editor-panel for .page
- files). The code-panel listener checks editorTag and bailed, so files opened
- via "Open in Code Editor" showed a blank panel.
- Fix: derive editorTag from panelElement.tagName.toLowerCase() directly,
- removing the registry lookup from openDocumentIn entirely.
+ Full tab tree (panels → sections → tab-containers → tabs including open files)
+ saved to .roject/layout-<deviceId>.json inside each project directory.
+ Remote projects: storage/<id>/root/.roject/. Local Electron: <localRoot>/.roject/.
+ Remote proxy (Electron opening a roject.rokojori.com project): centralized
+ layouts dir keyed by device + remoteProjectId.
+ deviceId in localStorage already differentiates Firefox, Chrome, and Electron.
+ Serialized format: { version, activePortraitPanel, panels: { left, center, right } }
+ where each panel has sections[], each section has tabContainers[], each
+ tab-container has tabs[] with { id, label, panelType, tag, openFile }.
+ FileEditorPanel interface extended with getCurrentFile(): string | null,
+ implemented in code-panel and page-editor-panel.
+ Restored on editor load; falls back to default layout if none saved.
+ makeResizeHandle() gained an optional onResize callback so inner handles trigger saves.
+ .roject/ filtered from both remote (storage.ts) and local (localFiles.ts) file trees.
+ Save triggers: panel resize, inner handle resize, tab click, file open,
+ split, close-container, add-panel, portrait panel switch.
diff --git a/workspace/history/2026/07-July/31-Friday/index.html b/workspace/history/2026/07-July/31-Friday/index.html
index 6931c1c..c352c3b 100644
--- a/workspace/history/2026/07-July/31-Friday/index.html
+++ b/workspace/history/2026/07-July/31-Friday/index.html
@@ -221,6 +221,60 @@
+
+
+
+
Per-project per-device layout persistence
+
+ The editor now fully saves and restores its window configuration — tab structure,
+ open files, panel widths — per project per device.
+
+
+ Storage: .roject/layout-<deviceId>.json inside
+ each project's root directory. Remote server projects write to
+ storage/<id>/root/.roject/; local Electron projects write to
+ <localRoot>/.roject/; remote projects opened via the Electron
+ proxy use the centralized build/data/storage/layouts/ keyed by
+ device + remote project ID. deviceId is already a per-browser UUID
+ in localStorage — Firefox, Chrome, and Electron automatically get
+ distinct IDs.
+
+
+ Serialized format (SerializedLayout):
+ { version, activePortraitPanel, panels: { left, center, right } }
+ where each panel carries its flex value and an array of sections; each section
+ carries its flex and an array of tab-containers; each tab-container carries
+ activeTabId and an array of tabs with
+ { id, label, panelType, tag, openFile }.
+
+
+ FileEditorPanel interface extended with
+ getCurrentFile(): string | null, implemented by
+ code-panel and page-editor-panel (both already tracked
+ currentPath).
+
+
+ Restore flow: editor-shell awaits the layout fetch
+ alongside customElements.whenDefined promises; if a layout with
+ panels is returned, _restoreLayout() rebuilds the DOM
+ (sections, tab-containers with resize handles), calls addTab() once
+ the containers are connected, activates the correct tab, then opens each saved
+ file via Editor.openDocumentIn(). Falls back to
+ initDefaultLayout() if no layout exists.
+
+
+ Save triggers: panel resize, inner section/tab-container resize
+ (via makeResizeHandle's new optional onResize callback),
+ tab click, file opened (Editor.onDocumentOpened), split,
+ close-container, add-panel, portrait panel switch. All debounced at 800 ms.
+
+
+ .roject/ hidden from both the remote
+ (storage.ts buildTree) and local (localFiles.ts buildTree)
+ file tree listings via a server-side .filter(name !== '.roject').
+
+
+
diff --git a/workspace/history/index.html b/workspace/history/index.html
index 76d8538..2796cda 100644
--- a/workspace/history/index.html
+++ b/workspace/history/index.html
@@ -21,7 +21,7 @@
-
File tree: context menu open-state preservation, "Open >" submenu, label truncation. Page editor full redesign: unified icon+label toolbar, floating block menu, insertion trigger overlays, block deletion, drag-to-reorder blocks. InputDialog component. LINK/MARK formats with styled custom elements. EditorConsole + console-panel. openDocumentIn fix.
+
File tree: context menu open-state preservation, "Open >" submenu, label truncation. Page editor full redesign: unified icon+label toolbar, floating block menu, insertion trigger overlays, block deletion, drag-to-reorder blocks. InputDialog component. LINK/MARK formats with styled custom elements. EditorConsole + console-panel. openDocumentIn fix. Per-project per-device layout persistence: full tab tree saved to .roject/layout-<deviceId>.json, FileEditorPanel.getCurrentFile(), restore on load.
diff --git a/workspace/outline/index.html b/workspace/outline/index.html
index 7bc3c17..fa233b2 100644
--- a/workspace/outline/index.html
+++ b/workspace/outline/index.html
@@ -231,6 +231,27 @@
dispatch must honour that choice so alternative editors (e.g. code-panel opening
a .page file) receive and display the document correctly.
+
+ Layout persistence — the full tab tree is saved per project per
+ device to .roject/layout-<deviceId>.json inside the project
+ directory. Remote projects write to
+ storage/<id>/root/.roject/; local Electron projects write to
+ <localRoot>/.roject/; remote projects opened via the Electron
+ proxy use the centralized build/data/storage/layouts/ keyed by
+ device + remote project ID. deviceId is a UUID in
+ localStorage — Firefox, Chrome, and Electron each get a distinct ID
+ automatically. The serialized format records
+ panels → sections → tabContainers → tabs, with each tab carrying
+ { id, label, panelType, tag, openFile }.
+ FileEditorPanel was extended with
+ getCurrentFile(): string | null (implemented by code-panel
+ and page-editor-panel) so the serializer can read the open file from
+ each panel element. On editor load, editor-shell restores the saved
+ structure; if no layout is found it falls back to the hard-coded default
+ (file tree left, page editor centre). .roject/ is filtered out of
+ both the remote and local file tree listings server-side.
+ GET / PUT /api/layout in source/server/routes/layout.ts.
+