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:
parent
0b47e4cb97
commit
87a42fd5a5
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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'; } );
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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' );
|
||||||
|
|
|
||||||
|
|
@ -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, '/' );
|
||||||
|
|
|
||||||
|
|
@ -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()) {
|
||||||
|
|
|
||||||
|
|
@ -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-<deviceId>.json inside each project directory.
|
||||||
re-adds the open class to matching labels after render.
|
Remote projects: storage/<id>/root/.roject/. Local Electron: <localRoot>/.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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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-<deviceId>.json</code> inside
|
||||||
|
each project's root directory. Remote server projects write to
|
||||||
|
<code>storage/<id>/root/.roject/</code>; local Electron projects write to
|
||||||
|
<code><localRoot>/.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>
|
||||||
|
|
|
||||||
|
|
@ -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 >" 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 >" 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.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|
|
||||||
|
|
@ -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-<deviceId>.json</code> inside the project
|
||||||
|
directory. Remote projects write to
|
||||||
|
<code>storage/<id>/root/.roject/</code>; local Electron projects write to
|
||||||
|
<code><localRoot>/.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">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue