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; return this._dirty;
} }
getCurrentFile(): string | null
{
return this.currentPath;
}
_updateButtons( dirty: boolean ): void _updateButtons( dirty: boolean ): void
{ {
this._dirty = dirty; this._dirty = dirty;

View File

@ -1,7 +1,43 @@
import { Editor } from '../../editor/Editor.js'; import { Editor } from '../../editor/Editor.js';
import { EditorConsole } from '../../editor/EditorConsole.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 function removeAdjacentHandle( el: HTMLElement, handleClass: string ): void
{ {
@ -21,10 +57,12 @@ function removeAdjacentHandle( el: HTMLElement, handleClass: string ): void
let tcCounter = 0; let tcCounter = 0;
function nextTcId(): string { return `tc-${ ++tcCounter }`; } function nextTcId(): string { return `tc-${ ++tcCounter }`; }
function makeResizeHandle(direction: 'v' | 'h'): HTMLElement { function makeResizeHandle( direction: 'v' | 'h', onResize?: () => void ): HTMLElement
{
const h = document.createElement( 'div' ); const h = document.createElement( 'div' );
h.className = direction === 'v' ? 'es-v-handle' : 'es-h-handle'; h.className = direction === 'v' ? 'es-v-handle' : 'es-h-handle';
h.addEventListener('pointerdown', (e: PointerEvent) => { h.addEventListener( 'pointerdown', ( e: PointerEvent ) =>
{
e.preventDefault(); e.preventDefault();
h.setPointerCapture( e.pointerId ); h.setPointerCapture( e.pointerId );
const prev = h.previousElementSibling as HTMLElement | null; const prev = h.previousElementSibling as HTMLElement | null;
@ -35,7 +73,8 @@ function makeResizeHandle(direction: 'v' | 'h'): HTMLElement {
const startNext = direction === 'v' ? next.offsetWidth : next.offsetHeight; const startNext = direction === 'v' ? next.offsetWidth : next.offsetHeight;
const total = startPrev + startNext; const total = startPrev + startNext;
const onMove = (ev: PointerEvent) => { const onMove = ( ev: PointerEvent ) =>
{
const delta = ( direction === 'v' ? ev.clientX : ev.clientY ) - startPos; const delta = ( direction === 'v' ? ev.clientX : ev.clientY ) - startPos;
const newPrev = Math.max( 60, Math.min( total - 60, startPrev + delta ) ); const newPrev = Math.max( 60, Math.min( total - 60, startPrev + delta ) );
prev.style.flexBasis = `${ newPrev }px`; prev.style.flexBasis = `${ newPrev }px`;
@ -44,12 +83,17 @@ function makeResizeHandle(direction: 'v' | 'h'): HTMLElement {
next.style.flex = `0 0 ${ total - newPrev }px`; next.style.flex = `0 0 ${ total - newPrev }px`;
}; };
h.addEventListener( 'pointermove', onMove ); h.addEventListener( 'pointermove', onMove );
h.addEventListener('pointerup', () => h.removeEventListener('pointermove', onMove), { once: true }); h.addEventListener( 'pointerup', () =>
{
h.removeEventListener( 'pointermove', onMove );
onResize?.();
}, { once: true } );
} ); } );
return h; return h;
} }
function makeSection(): HTMLElement { function makeSection(): HTMLElement
{
const sec = document.createElement( 'div' ); const sec = document.createElement( 'div' );
sec.className = 'es-section'; sec.className = 'es-section';
const tc = document.createElement( 'tab-container' ) as HTMLElement; const tc = document.createElement( 'tab-container' ) as HTMLElement;
@ -58,22 +102,24 @@ function makeSection(): HTMLElement {
return sec; return sec;
} }
function makePanelInner(): HTMLElement { function makePanelInner(): HTMLElement
{
const inner = document.createElement( 'div' ); const inner = document.createElement( 'div' );
inner.className = 'es-sections'; inner.className = 'es-sections';
const sec = makeSection(); inner.appendChild( makeSection() );
inner.appendChild(sec);
return inner; return inner;
} }
// ── EditorShell ─────────────────────────────────────────────────────────────── // ── EditorShell ───────────────────────────────────────────────────────────────
class EditorShell extends HTMLElement { class EditorShell extends HTMLElement
{
private activePortraitPanel: string = 'center'; private activePortraitPanel: string = 'center';
private _deviceId: string = ''; private _deviceId: string = '';
private _saveTimer: ReturnType<typeof setTimeout> | null = null; private _saveTimer: ReturnType<typeof setTimeout> | null = null;
async connectedCallback(): Promise<void> { async connectedCallback(): Promise<void>
{
const authRes = await fetch( '/api/auth/me' ); const authRes = await fetch( '/api/auth/me' );
if ( !authRes.ok ) { location.href = '/'; return; } if ( !authRes.ok ) { location.href = '/'; return; }
@ -82,6 +128,7 @@ class EditorShell extends HTMLElement {
const localRoot = params.get( 'localRoot' ) ?? ''; const localRoot = params.get( 'localRoot' ) ?? '';
const remoteProject = params.get( 'remoteProject' ) ?? ''; const remoteProject = params.get( 'remoteProject' ) ?? '';
const projectName = params.get( 'name' ) ?? 'Project'; const projectName = params.get( 'name' ) ?? 'Project';
Editor.get().projectId = projectId; Editor.get().projectId = projectId;
Editor.get().localRoot = localRoot; Editor.get().localRoot = localRoot;
Editor.get().remoteProject = remoteProject; Editor.get().remoteProject = remoteProject;
@ -113,36 +160,242 @@ class EditorShell extends HTMLElement {
this.setupResizeHandler(); this.setupResizeHandler();
this._setupInfo(); this._setupInfo();
let loadedLayout: SerializedLayout | null = null;
await Promise.all( [ await Promise.all( [
customElements.whenDefined( 'tab-container' ), customElements.whenDefined( 'tab-container' ),
customElements.whenDefined( 'file-tree-panel' ), customElements.whenDefined( 'file-tree-panel' ),
customElements.whenDefined( 'page-editor-panel' ), customElements.whenDefined( 'page-editor-panel' ),
customElements.whenDefined( 'code-panel' ), customElements.whenDefined( 'code-panel' ),
customElements.whenDefined( 'console-panel' ), customElements.whenDefined( 'console-panel' ),
this._loadLayout(), this._loadLayout().then( l => { loadedLayout = l; } ),
] ); ] );
if ( loadedLayout?.panels )
{
await this._restoreLayout( loadedLayout );
}
else
{
this.initDefaultLayout(); this.initDefaultLayout();
} }
private initDefaultLayout(): void { // 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 leftTc = this.querySelector( '[data-panel="left"] tab-container' ) as any;
const centerTc = this.querySelector( '[data-panel="center"] 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' }, () => { leftTc?.addTab( { id: 'file-tree', label: 'Files', panelType: 'file-tree' }, () =>
return document.createElement('file-tree-panel'); document.createElement( 'file-tree-panel' )
);
centerTc?.addTab( { id: 'page-editor', label: 'Page', panelType: 'page-editor' }, () =>
document.createElement( 'page-editor-panel' )
);
}
// ── Layout serialization ────────────────────────────────────────────────────
private _layoutUrl(): string
{
const qp = new URLSearchParams( location.search );
const p = new URLSearchParams( { deviceId: this._getDeviceId() } );
const projectId = qp.get( 'project' );
const localRoot = qp.get( 'localRoot' );
const remoteProject = qp.get( 'remoteProject' );
if ( projectId ) p.set( 'projectId', projectId );
else if ( localRoot ) p.set( 'localRoot', localRoot );
else if ( remoteProject ) p.set( 'remoteProject', remoteProject );
return `/api/layout?${ p }`;
}
private _serializeLayout(): SerializedLayout
{
const serializePanel = ( panel: HTMLElement ): SerializedPanel =>
{
const sections: SerializedSection[] = [];
const sectionsEl = panel.querySelector( '.es-sections' )!;
for ( const child of Array.from( sectionsEl.children ) )
{
if ( !child.classList.contains( 'es-section' ) ) continue;
const sec = child as HTMLElement;
const tabContainers: SerializedTabContainer[] = [];
for ( const secChild of Array.from( sec.children ) )
{
if ( secChild.tagName.toLowerCase() !== 'tab-container' ) continue;
const tc = secChild as any;
const tabs: SerializedTab[] = ( tc.tabs as any[] ).map( ( tab: any ) =>
{
const openFile: string | null =
typeof tab.element.getCurrentFile === 'function'
? tab.element.getCurrentFile()
: null;
return {
id: tab.id,
label: tab.label,
panelType: tab.panelType,
tag: tab.element.tagName.toLowerCase(),
openFile,
};
} ); } );
centerTc?.addTab({ id: 'page-editor', label: 'Page', panelType: 'page-editor' }, () => { tabContainers.push( {
return document.createElement('page-editor-panel'); flex: ( secChild as HTMLElement ).style.flex || null,
activeTabId: tc.activeId,
tabs,
} ); } );
} }
private setupMainHandles(): void { 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' )!; const workspace = this.querySelector( '.es-workspace' )!;
workspace.querySelectorAll(':scope > .es-v-handle').forEach(h => { workspace.querySelectorAll( ':scope > .es-v-handle' ).forEach( h =>
{
const handle = h as HTMLElement; const handle = h as HTMLElement;
handle.addEventListener('pointerdown', (e: PointerEvent) => { handle.addEventListener( 'pointerdown', ( e: PointerEvent ) =>
{
e.preventDefault(); e.preventDefault();
handle.setPointerCapture( e.pointerId ); handle.setPointerCapture( e.pointerId );
const prev = handle.previousElementSibling as HTMLElement; const prev = handle.previousElementSibling as HTMLElement;
@ -152,28 +405,34 @@ class EditorShell extends HTMLElement {
const startNext = next.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 delta = ev.clientX - start;
const np = Math.max( 80, Math.min( total - 80, startPrev + delta ) ); const np = Math.max( 80, Math.min( total - 80, startPrev + delta ) );
prev.style.flex = `0 0 ${ np }px`; prev.style.flex = `0 0 ${ np }px`;
next.style.flex = `0 0 ${ total - np }px`; next.style.flex = `0 0 ${ total - np }px`;
}; };
handle.addEventListener( 'pointermove', onMove ); handle.addEventListener( 'pointermove', onMove );
handle.addEventListener('pointerup', () => { handle.addEventListener( 'pointerup', () =>
{
handle.removeEventListener( 'pointermove', onMove ); handle.removeEventListener( 'pointermove', onMove );
this._scheduleLayoutSave(); this._scheduleLayoutSave();
}, { once: true } ); }, { once: true } );
} ); } );
} ); } );
this.querySelectorAll('.es-sections').forEach(sections => { this.querySelectorAll( '.es-sections' ).forEach( sections =>
{
this.observeNewHandles( sections as HTMLElement ); this.observeNewHandles( sections as HTMLElement );
} ); } );
} }
private setupSplitListener(): void { private setupSplitListener(): void
this.addEventListener('tab-container:split', (e: Event) => { {
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 { containerId, direction } = ( e as CustomEvent ).detail as { containerId: string; direction: 'horizontal' | 'vertical' };
const tc = document.getElementById( containerId ); const tc = document.getElementById( containerId );
if ( !tc ) return; if ( !tc ) return;
@ -184,7 +443,7 @@ class EditorShell extends HTMLElement {
{ {
const newTc = document.createElement( 'tab-container' ) as HTMLElement; const newTc = document.createElement( 'tab-container' ) as HTMLElement;
newTc.id = nextTcId(); newTc.id = nextTcId();
const handle = makeResizeHandle('h'); const handle = makeResizeHandle( 'h', save );
section.appendChild( handle ); section.appendChild( handle );
section.appendChild( newTc ); section.appendChild( newTc );
} }
@ -193,13 +452,16 @@ class EditorShell extends HTMLElement {
const sections = section.closest( '.es-sections' ) as HTMLElement | null; const sections = section.closest( '.es-sections' ) as HTMLElement | null;
if ( !sections ) return; if ( !sections ) return;
const newSec = makeSection(); const newSec = makeSection();
const handle = makeResizeHandle('v'); const handle = makeResizeHandle( 'v', save );
sections.insertBefore( handle, section.nextSibling ); sections.insertBefore( handle, section.nextSibling );
sections.insertBefore( newSec, handle.nextSibling ); sections.insertBefore( newSec, handle.nextSibling );
} }
save();
} ); } );
this.addEventListener('tab-container:close-container', (e: Event) => { this.addEventListener( 'tab-container:close-container', ( e: Event ) =>
{
const { containerId } = ( e as CustomEvent ).detail as { containerId: string }; const { containerId } = ( e as CustomEvent ).detail as { containerId: string };
const tc = document.getElementById( containerId ) as HTMLElement | null; const tc = document.getElementById( containerId ) as HTMLElement | null;
if ( !tc ) return; if ( !tc ) return;
@ -219,17 +481,24 @@ class EditorShell extends HTMLElement {
removeAdjacentHandle( section, 'es-v-handle' ); removeAdjacentHandle( section, 'es-v-handle' );
section.remove(); section.remove();
} }
save();
} ); } );
this.addEventListener('tab-container:add-panel', (e: Event) => { 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 { containerId, panelType, tag, label } = ( e as CustomEvent ).detail as { containerId: string; panelType: string; tag: string; label: string };
const tc = document.getElementById( containerId ) as any; const tc = document.getElementById( containerId ) as any;
if ( !tc ) return; if ( !tc ) return;
const id = panelType + '-' + Math.random().toString( 36 ).slice( 2 ); const id = panelType + '-' + Math.random().toString( 36 ).slice( 2 );
tc.addTab( { id, label: label ?? panelType, panelType }, () => document.createElement( tag ) ); tc.addTab( { id, label: label ?? panelType, panelType }, () => document.createElement( tag ) );
save();
} ); } );
} }
// ── Device ID ───────────────────────────────────────────────────────────────
private _getDeviceId(): string private _getDeviceId(): string
{ {
if ( this._deviceId ) return this._deviceId; if ( this._deviceId ) return this._deviceId;
@ -243,62 +512,7 @@ class EditorShell extends HTMLElement {
return id; return id;
} }
private async _loadLayout(): Promise<void> // ── Resize redistribution ───────────────────────────────────────────────────
{
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 private setupResizeHandler(): void
{ {
@ -332,7 +546,7 @@ class EditorShell extends HTMLElement {
const handles = Array.from( container.children ).filter( const handles = Array.from( container.children ).filter(
c => !( c as HTMLElement ).matches( childSelector ) c => !( c as HTMLElement ).matches( childSelector )
) as HTMLElement[]; ) as HTMLElement[];
const handleTotal = handles.reduce( ( sum, h ) => sum + ( h as HTMLElement ).offsetWidth, 0 ); const handleTotal = handles.reduce( ( sum, h ) => sum + h.offsetWidth, 0 );
const available = container.clientWidth - handleTotal; const available = container.clientWidth - handleTotal;
children.forEach( c => children.forEach( c =>
@ -342,15 +556,20 @@ class EditorShell extends HTMLElement {
} ); } );
} }
private observeNewHandles(sections: HTMLElement): void { private observeNewHandles( sections: HTMLElement ): void
const observer = new MutationObserver(() => { {
sections.querySelectorAll('.es-h-handle:not([data-bound])').forEach(h => { const observer = new MutationObserver( () =>
{
sections.querySelectorAll( '.es-h-handle:not([data-bound])' ).forEach( h =>
{
( h as HTMLElement ).dataset.bound = '1'; ( h as HTMLElement ).dataset.bound = '1';
} ); } );
} ); } );
observer.observe( sections, { childList: true } ); observer.observe( sections, { childList: true } );
} }
// ── Info bar ────────────────────────────────────────────────────────────────
private _setupInfo(): void private _setupInfo(): void
{ {
const infoEl = this.querySelector( '.es-info' ) as HTMLElement; const infoEl = this.querySelector( '.es-info' ) as HTMLElement;
@ -372,11 +591,15 @@ class EditorShell extends HTMLElement {
} ); } );
} }
private setupPortrait(): void { // ── Portrait mode ───────────────────────────────────────────────────────────
private setupPortrait(): void
{
const btns = this.querySelector( '.es-portrait-btns' ) as HTMLElement; const btns = this.querySelector( '.es-portrait-btns' ) as HTMLElement;
const mq = window.matchMedia( '(orientation: portrait)' ); const mq = window.matchMedia( '(orientation: portrait)' );
const apply = ( portrait: boolean ) => { const apply = ( portrait: boolean ) =>
{
this.classList.toggle( 'portrait', portrait ); this.classList.toggle( 'portrait', portrait );
if ( portrait ) if ( portrait )
{ {
@ -389,8 +612,10 @@ class EditorShell extends HTMLElement {
} }
}; };
btns.querySelectorAll( '.es-pb-btn' ).forEach( btn => { btns.querySelectorAll( '.es-pb-btn' ).forEach( btn =>
btn.addEventListener( 'click', () => { {
btn.addEventListener( 'click', () =>
{
const panel = ( btn as HTMLElement ).dataset.panel!; const panel = ( btn as HTMLElement ).dataset.panel!;
this.activePortraitPanel = panel; this.activePortraitPanel = panel;
btns.querySelectorAll( '.es-pb-btn' ).forEach( b => b.classList.remove( 'active' ) ); btns.querySelectorAll( '.es-pb-btn' ).forEach( b => b.classList.remove( 'active' ) );
@ -404,8 +629,10 @@ class EditorShell extends HTMLElement {
apply( mq.matches ); apply( mq.matches );
} }
private showPortraitPanel(panelId: string): void { private showPortraitPanel( panelId: string ): void
this.querySelectorAll('.es-panel').forEach(p => { {
this.querySelectorAll( '.es-panel' ).forEach( p =>
{
( p as HTMLElement ).style.display = ( p as HTMLElement ).dataset.panel === panelId ? '' : 'none'; ( 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'; } ); this.querySelectorAll( '.es-v-handle' ).forEach( h => { ( h as HTMLElement ).style.display = 'none'; } );

View File

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

View File

@ -19,6 +19,7 @@ export interface EditorPanel extends HTMLElement
export interface FileEditorPanel extends EditorPanel export interface FileEditorPanel extends EditorPanel
{ {
hasUnsavedChanges(): boolean; hasUnsavedChanges(): boolean;
getCurrentFile(): string | null;
} }
export function implementsInterface( el: any, def: { type: string } ): boolean export function implementsInterface( el: any, def: { type: string } ): boolean

View File

@ -2,7 +2,7 @@ import { Router } from 'express';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { requireAuth } from '../../auth-connector/source/server/auth'; import { requireAuth } from '../../auth-connector/source/server/auth';
import { RJLog } from '../../library-ts/node/log/RJLog'; import { checkAccess } from '../projectAccess';
import { ROOT } from '../rootDir'; import { ROOT } from '../rootDir';
const router = Router(); const router = Router();
@ -10,20 +10,58 @@ router.use( requireAuth );
const LAYOUTS_DIR = path.join( ROOT, 'build', 'data', 'storage', 'layouts' ); 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 } ); return s.replace( /[^a-zA-Z0-9_-]/g, '_' );
const safe = ( s: string ) => s.replace( /[^a-zA-Z0-9_-]/g, '_' ); }
const dir = path.join( LAYOUTS_DIR, safe( userId ) );
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 } ); if ( !fs.existsSync( dir ) ) fs.mkdirSync( dir, { recursive: true } );
return path.join( dir, safe( deviceId ) + '.json' ); 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 ) => router.get( '/', ( req, res ) =>
{ {
const deviceId = req.query.deviceId as string; const projectId = req.query.projectId as string | undefined;
if ( !deviceId ) { res.json( null ); return; } if ( projectId )
const fp = layoutFilePath( req.auth!.userId, deviceId ); {
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; } if ( !fs.existsSync( fp ) ) { res.json( null ); return; }
try { res.json( JSON.parse( fs.readFileSync( fp, 'utf8' ) ) ); } try { res.json( JSON.parse( fs.readFileSync( fp, 'utf8' ) ) ); }
catch { res.json( null ); } catch { res.json( null ); }
@ -31,9 +69,15 @@ router.get( '/', ( req, res ) =>
router.put( '/', ( req, res ) => router.put( '/', ( req, res ) =>
{ {
const deviceId = req.query.deviceId as string; const projectId = req.query.projectId as string | undefined;
if ( !deviceId ) { res.status( 400 ).json( { error: 'Missing deviceId' } ); return; } if ( projectId )
const fp = layoutFilePath( req.auth!.userId, deviceId ); {
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 try
{ {
fs.writeFileSync( fp, JSON.stringify( req.body ), 'utf8' ); 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[] 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 abs = path.join( absDir, name );
const rel = path.relative( rootDir, abs ).replace( /\\/g, '/' ); 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[] { 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 abs = path.join(absDir, name);
const rel = path.relative(rootDir, abs).replace(/\\/g, '/'); const rel = path.relative(rootDir, abs).replace(/\\/g, '/');
if (fs.statSync(abs).isDirectory()) { if (fs.statSync(abs).isDirectory()) {

View File

@ -223,56 +223,24 @@
<div class="lane-header">Done</div> <div class="lane-header">Done</div>
<task-item class="green hide-content"> <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> <task-content>
— Open-state preservation: refresh() now records which ftp-dir elements are open Full tab tree (panels → sections → tab-containers → tabs including open files)
(via data-path on their ftp-dir-label) before rebuilding the HTML, then saved to .roject/layout-&lt;deviceId&gt;.json inside each project directory.
re-adds the open class to matching labels after render. Remote projects: storage/&lt;id&gt;/root/.roject/. Local Electron: &lt;localRoot&gt;/.roject/.
— "Mark As Root Directory" moved from dblclick to context menu (isDir detection Remote proxy (Electron opening a roject.rokojori.com project): centralized
via targetPath.endsWith('/'), shown inside showItemMenu). layouts dir keyed by device + remoteProjectId.
— "Open >" submenu for files: context menu lists the default editor plus all deviceId in localStorage already differentiates Firefox, Chrome, and Electron.
registered alternatives (_panelTypeMap / _editorAlternatives static maps). Serialized format: { version, activePortraitPanel, panels: { left, center, right } }
Selecting an entry calls _openFileIn(path, editorTag). where each panel has sections[], each section has tabContainers[], each
— Context menu label: shows filename only, truncated to menuLabelMaxChars (20) tab-container has tabs[] with { id, label, panelType, tag, openFile }.
with a leading "..." prefix when over the limit. FileEditorPanel interface extended with getCurrentFile(): string | null,
</task-content> implemented in code-panel and page-editor-panel.
</task-item> Restored on editor load; falls back to default layout if none saved.
makeResizeHandle() gained an optional onResize callback so inner handles trigger saves.
<task-item class="green hide-content"> .roject/ filtered from both remote (storage.ts) and local (localFiles.ts) file trees.
<task-title>Page editor: mode buttons moved into toolbar</task-title> Save triggers: panel resize, inner handle resize, tab click, file open,
<task-content> split, close-container, add-panel, portrait panel switch.
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.
</task-content> </task-content>
</task-item> </task-item>

View File

@ -221,6 +221,60 @@
</p> </p>
</div> </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>
<section> <section>

View File

@ -21,7 +21,7 @@
<div class="card"> <div class="card">
<h3><a href="2026/07-July/31-Friday/index.html">Friday, 31 July 2026</a></h3> <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>
<div class="card"> <div class="card">

View File

@ -231,6 +231,27 @@
dispatch must honour that choice so alternative editors (e.g. code-panel opening dispatch must honour that choice so alternative editors (e.g. code-panel opening
a .page file) receive and display the document correctly. a .page file) receive and display the document correctly.
</p> </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>
<div class="card"> <div class="card">