feat: per-project per-device layout persistence (.roject/layout-<deviceId>.json)

Full tab tree (panels → sections → tab-containers → tabs + open files) saved and
restored per project per device. FileEditorPanel extended with getCurrentFile().
.roject/ hidden from file tree listings. Boards, outline, and history updated.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Rokojori 2026-07-31 20:43:08 +02:00
parent 0b47e4cb97
commit 87a42fd5a5
11 changed files with 585 additions and 260 deletions

View File

@ -140,6 +140,11 @@ class CodePanel extends HTMLElement
return this._dirty;
}
getCurrentFile(): string | null
{
return this.currentPath;
}
_updateButtons( dirty: boolean ): void
{
this._dirty = dirty;

View File

@ -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<typeof setTimeout> | null = null;
async connectedCallback(): Promise<void> {
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;
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 = `
<div class="es-header">
<a class="es-back" href="/"></a>
<span class="es-title">${projectName}</span>
<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>
@ -99,11 +146,11 @@ class EditorShell extends HTMLElement {
</div>
</div>
<div class="es-workspace">
<div class="es-panel" data-panel="left">${makePanelInner().outerHTML}</div>
<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-panel" data-panel="center">${ makePanelInner().outerHTML }</div>
<div class="es-v-handle"></div>
<div class="es-panel" data-panel="right">${makePanelInner().outerHTML}</div>
<div class="es-panel" data-panel="right">${ makePanelInner().outerHTML }</div>
</div>
`;
@ -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<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 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();
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<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 {}
}
// ── 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 );

View File

@ -820,6 +820,11 @@ class PageEditorPanel extends HTMLElement
return this._dirty;
}
getCurrentFile(): string | null
{
return this.currentPath;
}
_updateButtons( dirty: boolean ): void
{
this._dirty = dirty;

View File

@ -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

View File

@ -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' );

View File

@ -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, '/' );

View File

@ -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()) {

View File

@ -223,56 +223,24 @@
<div class="lane-header">Done</div>
<task-item class="green hide-content">
<task-title>File tree: UX improvements</task-title>
<task-title>Per-project per-device layout persistence</task-title>
<task-content>
— 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.
</task-content>
</task-item>
<task-item class="green hide-content">
<task-title>Page editor: mode buttons moved into toolbar</task-title>
<task-content>
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.
</task-content>
</task-item>
<task-item class="green hide-content">
<task-title>EditorConsole + console-panel</task-title>
<task-content>
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.
</task-content>
</task-item>
<task-item class="green hide-content">
<task-title>Bug fix: openDocumentIn ignored the target panel type</task-title>
<task-content>
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-&lt;deviceId&gt;.json inside each project directory.
Remote projects: storage/&lt;id&gt;/root/.roject/. Local Electron: &lt;localRoot&gt;/.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.
</task-content>
</task-item>

View File

@ -221,6 +221,60 @@
</p>
</div>
</section>
<div class="card">
<h3>Per-project per-device layout persistence</h3>
<p>
The editor now fully saves and restores its window configuration — tab structure,
open files, panel widths — per project per device.
</p>
<p style="margin-top:0.75rem">
<strong>Storage:</strong> <code>.roject/layout-&lt;deviceId&gt;.json</code> inside
each project's root directory. Remote server projects write to
<code>storage/&lt;id&gt;/root/.roject/</code>; local Electron projects write to
<code>&lt;localRoot&gt;/.roject/</code>; remote projects opened via the Electron
proxy use the centralized <code>build/data/storage/layouts/</code> keyed by
device + remote project ID. <code>deviceId</code> is already a per-browser UUID
in <code>localStorage</code> — Firefox, Chrome, and Electron automatically get
distinct IDs.
</p>
<p style="margin-top:0.75rem">
<strong>Serialized format</strong> (<code>SerializedLayout</code>):
<code>{ version, activePortraitPanel, panels: { left, center, right } }</code>
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
<code>activeTabId</code> and an array of tabs with
<code>{ id, label, panelType, tag, openFile }</code>.
</p>
<p style="margin-top:0.75rem">
<strong><code>FileEditorPanel</code> interface</strong> extended with
<code>getCurrentFile(): string | null</code>, implemented by
<code>code-panel</code> and <code>page-editor-panel</code> (both already tracked
<code>currentPath</code>).
</p>
<p style="margin-top:0.75rem">
<strong>Restore flow:</strong> <code>editor-shell</code> awaits the layout fetch
alongside <code>customElements.whenDefined</code> promises; if a layout with
<code>panels</code> is returned, <code>_restoreLayout()</code> rebuilds the DOM
(sections, tab-containers with resize handles), calls <code>addTab()</code> once
the containers are connected, activates the correct tab, then opens each saved
file via <code>Editor.openDocumentIn()</code>. Falls back to
<code>initDefaultLayout()</code> if no layout exists.
</p>
<p style="margin-top:0.75rem">
<strong>Save triggers:</strong> panel resize, inner section/tab-container resize
(via <code>makeResizeHandle</code>'s new optional <code>onResize</code> callback),
tab click, file opened (<code>Editor.onDocumentOpened</code>), split,
close-container, add-panel, portrait panel switch. All debounced at 800 ms.
</p>
<p style="margin-top:0.75rem">
<strong><code>.roject/</code> hidden</strong> from both the remote
(<code>storage.ts buildTree</code>) and local (<code>localFiles.ts buildTree</code>)
file tree listings via a server-side <code>.filter(name !== '.roject')</code>.
</p>
</div>
</section>
<section>

View File

@ -21,7 +21,7 @@
<div class="card">
<h3><a href="2026/07-July/31-Friday/index.html">Friday, 31 July 2026</a></h3>
<p>File tree: context menu open-state preservation, "Open &gt;" 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.</p>
<p>File tree: context menu open-state preservation, "Open &gt;" 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-&lt;deviceId&gt;.json, FileEditorPanel.getCurrentFile(), restore on load.</p>
</div>
<div class="card">

View File

@ -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.
</p>
<p style="margin-top:0.75rem">
<strong>Layout persistence</strong> — the full tab tree is saved per project per
device to <code>.roject/layout-&lt;deviceId&gt;.json</code> inside the project
directory. Remote projects write to
<code>storage/&lt;id&gt;/root/.roject/</code>; local Electron projects write to
<code>&lt;localRoot&gt;/.roject/</code>; remote projects opened via the Electron
proxy use the centralized <code>build/data/storage/layouts/</code> keyed by
device + remote project ID. <code>deviceId</code> is a UUID in
<code>localStorage</code> — Firefox, Chrome, and Electron each get a distinct ID
automatically. The serialized format records
<code>panels → sections → tabContainers → tabs</code>, with each tab carrying
<code>{ id, label, panelType, tag, openFile }</code>.
<code>FileEditorPanel</code> was extended with
<code>getCurrentFile(): string | null</code> (implemented by <code>code-panel</code>
and <code>page-editor-panel</code>) so the serializer can read the open file from
each panel element. On editor load, <code>editor-shell</code> restores the saved
structure; if no layout is found it falls back to the hard-coded default
(file tree left, page editor centre). <code>.roject/</code> is filtered out of
both the remote and local file tree listings server-side.
<code>GET / PUT /api/layout</code> in <code>source/server/routes/layout.ts</code>.
</p>
</div>
<div class="card">