From 87a42fd5a509d2f4f38064de832a14b9fc139c28 Mon Sep 17 00:00:00 2001 From: Rokojori Date: Fri, 31 Jul 2026 20:43:08 +0200 Subject: [PATCH] feat: per-project per-device layout persistence (.roject/layout-.json) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- source/components/code-panel/code-panel.ts | 5 + .../components/editor-shell/editor-shell.ts | 617 ++++++++++++------ .../page-editor-panel/page-editor-panel.ts | 5 + source/editor/editor-panel.ts | 1 + source/server/routes/layout.ts | 70 +- source/server/routes/localFiles.ts | 2 +- source/server/storage.ts | 2 +- workspace/boards/tasks.html | 66 +- .../history/2026/07-July/31-Friday/index.html | 54 ++ workspace/history/index.html | 2 +- workspace/outline/index.html | 21 + 11 files changed, 585 insertions(+), 260 deletions(-) diff --git a/source/components/code-panel/code-panel.ts b/source/components/code-panel/code-panel.ts index 3228d94..b55faea 100644 --- a/source/components/code-panel/code-panel.ts +++ b/source/components/code-panel/code-panel.ts @@ -140,6 +140,11 @@ class CodePanel extends HTMLElement return this._dirty; } + getCurrentFile(): string | null + { + return this.currentPath; + } + _updateButtons( dirty: boolean ): void { this._dirty = dirty; diff --git a/source/components/editor-shell/editor-shell.ts b/source/components/editor-shell/editor-shell.ts index c105b7c..7b91544 100644 --- a/source/components/editor-shell/editor-shell.ts +++ b/source/components/editor-shell/editor-shell.ts @@ -1,7 +1,43 @@ import { Editor } from '../../editor/Editor.js'; import { EditorConsole } from '../../editor/EditorConsole.js'; -// ── Layout helpers ──────────────────────────────────────────────────────────── +// ── Serialized layout types ─────────────────────────────────────────────────── + +interface SerializedTab { + id: string; + label: string; + panelType: string; + tag: string; + openFile: string | null; +} + +interface SerializedTabContainer { + flex: string | null; + activeTabId: string | null; + tabs: SerializedTab[]; +} + +interface SerializedSection { + flex: string | null; + tabContainers: SerializedTabContainer[]; +} + +interface SerializedPanel { + flex: string | null; + sections: SerializedSection[]; +} + +interface SerializedLayout { + version: number; + activePortraitPanel: string; + panels: { + left: SerializedPanel; + center: SerializedPanel; + right: SerializedPanel; + }; +} + +// ── DOM helpers ─────────────────────────────────────────────────────────────── function removeAdjacentHandle( el: HTMLElement, handleClass: string ): void { @@ -19,78 +55,89 @@ function removeAdjacentHandle( el: HTMLElement, handleClass: string ): void } let tcCounter = 0; -function nextTcId(): string { return `tc-${++tcCounter}`; } +function nextTcId(): string { return `tc-${ ++tcCounter }`; } -function makeResizeHandle(direction: 'v' | 'h'): HTMLElement { - const h = document.createElement('div'); +function makeResizeHandle( direction: 'v' | 'h', onResize?: () => void ): HTMLElement +{ + const h = document.createElement( 'div' ); h.className = direction === 'v' ? 'es-v-handle' : 'es-h-handle'; - h.addEventListener('pointerdown', (e: PointerEvent) => { + h.addEventListener( 'pointerdown', ( e: PointerEvent ) => + { e.preventDefault(); - h.setPointerCapture(e.pointerId); + h.setPointerCapture( e.pointerId ); const prev = h.previousElementSibling as HTMLElement | null; const next = h.nextElementSibling as HTMLElement | null; - if (!prev || !next) return; - const startPos = direction === 'v' ? e.clientX : e.clientY; + if ( !prev || !next ) return; + const startPos = direction === 'v' ? e.clientX : e.clientY; const startPrev = direction === 'v' ? prev.offsetWidth : prev.offsetHeight; const startNext = direction === 'v' ? next.offsetWidth : next.offsetHeight; - const total = startPrev + startNext; + const total = startPrev + startNext; - const onMove = (ev: PointerEvent) => { - const delta = (direction === 'v' ? ev.clientX : ev.clientY) - startPos; - const newPrev = Math.max(60, Math.min(total - 60, startPrev + delta)); - prev.style.flexBasis = `${newPrev}px`; - next.style.flexBasis = `${total - newPrev}px`; - prev.style.flex = `0 0 ${newPrev}px`; - next.style.flex = `0 0 ${total - newPrev}px`; + const onMove = ( ev: PointerEvent ) => + { + const delta = ( direction === 'v' ? ev.clientX : ev.clientY ) - startPos; + const newPrev = Math.max( 60, Math.min( total - 60, startPrev + delta ) ); + prev.style.flexBasis = `${ newPrev }px`; + next.style.flexBasis = `${ total - newPrev }px`; + prev.style.flex = `0 0 ${ newPrev }px`; + next.style.flex = `0 0 ${ total - newPrev }px`; }; - h.addEventListener('pointermove', onMove); - h.addEventListener('pointerup', () => h.removeEventListener('pointermove', onMove), { once: true }); - }); + h.addEventListener( 'pointermove', onMove ); + h.addEventListener( 'pointerup', () => + { + h.removeEventListener( 'pointermove', onMove ); + onResize?.(); + }, { once: true } ); + } ); return h; } -function makeSection(): HTMLElement { - const sec = document.createElement('div'); +function makeSection(): HTMLElement +{ + const sec = document.createElement( 'div' ); sec.className = 'es-section'; - const tc = document.createElement('tab-container') as HTMLElement; + const tc = document.createElement( 'tab-container' ) as HTMLElement; tc.id = nextTcId(); - sec.appendChild(tc); + sec.appendChild( tc ); return sec; } -function makePanelInner(): HTMLElement { - const inner = document.createElement('div'); +function makePanelInner(): HTMLElement +{ + const inner = document.createElement( 'div' ); inner.className = 'es-sections'; - const sec = makeSection(); - inner.appendChild(sec); + inner.appendChild( makeSection() ); return inner; } // ── EditorShell ─────────────────────────────────────────────────────────────── -class EditorShell extends HTMLElement { +class EditorShell extends HTMLElement +{ private activePortraitPanel: string = 'center'; private _deviceId: string = ''; private _saveTimer: ReturnType | null = null; - async connectedCallback(): Promise { + async connectedCallback(): Promise + { const authRes = await fetch( '/api/auth/me' ); if ( !authRes.ok ) { location.href = '/'; return; } - const params = new URLSearchParams(location.search); - const projectId = params.get('project') ?? ''; - const localRoot = params.get('localRoot') ?? ''; - const remoteProject = params.get('remoteProject') ?? ''; - const projectName = params.get('name') ?? 'Project'; - Editor.get().projectId = projectId; - Editor.get().localRoot = localRoot; + const params = new URLSearchParams( location.search ); + const projectId = params.get( 'project' ) ?? ''; + const localRoot = params.get( 'localRoot' ) ?? ''; + const remoteProject = params.get( 'remoteProject' ) ?? ''; + const projectName = params.get( 'name' ) ?? 'Project'; + + Editor.get().projectId = projectId; + Editor.get().localRoot = localRoot; Editor.get().remoteProject = remoteProject; - Editor.get().projectName = projectName; + Editor.get().projectName = projectName; this.innerHTML = `
- ${projectName} + ${ projectName }
@@ -99,11 +146,11 @@ class EditorShell extends HTMLElement {
-
${makePanelInner().outerHTML}
+
${ makePanelInner().outerHTML }
-
${makePanelInner().outerHTML}
+
${ makePanelInner().outerHTML }
-
${makePanelInner().outerHTML}
+
${ makePanelInner().outerHTML }
`; @@ -113,123 +160,345 @@ class EditorShell extends HTMLElement { this.setupResizeHandler(); this._setupInfo(); + let loadedLayout: SerializedLayout | null = null; await Promise.all( [ customElements.whenDefined( 'tab-container' ), customElements.whenDefined( 'file-tree-panel' ), customElements.whenDefined( 'page-editor-panel' ), customElements.whenDefined( 'code-panel' ), customElements.whenDefined( 'console-panel' ), - this._loadLayout(), + this._loadLayout().then( l => { loadedLayout = l; } ), ] ); - this.initDefaultLayout(); + if ( loadedLayout?.panels ) + { + await this._restoreLayout( loadedLayout ); + } + else + { + this.initDefaultLayout(); + } + + // Save on active tab switch + const workspace = this.querySelector( '.es-workspace' ) as HTMLElement; + workspace.addEventListener( 'click', ( e ) => + { + if ( ( e.target as HTMLElement ).closest( '.tc-tab' ) ) this._scheduleLayoutSave(); + } ); + + // Save when a file is opened in any panel + Editor.get().onDocumentOpened.addListener( () => this._scheduleLayoutSave() ); } - private initDefaultLayout(): void { - const leftTc = this.querySelector('[data-panel="left"] tab-container') as any; - const centerTc = this.querySelector('[data-panel="center"] tab-container') as any; + private initDefaultLayout(): void + { + const leftTc = this.querySelector( '[data-panel="left"] tab-container' ) as any; + const centerTc = this.querySelector( '[data-panel="center"] tab-container' ) as any; - leftTc?.addTab({ id: 'file-tree', label: 'Files', panelType: 'file-tree' }, () => { - return document.createElement('file-tree-panel'); - }); + leftTc?.addTab( { id: 'file-tree', label: 'Files', panelType: 'file-tree' }, () => + document.createElement( 'file-tree-panel' ) + ); - centerTc?.addTab({ id: 'page-editor', label: 'Page', panelType: 'page-editor' }, () => { - return document.createElement('page-editor-panel'); - }); + centerTc?.addTab( { id: 'page-editor', label: 'Page', panelType: 'page-editor' }, () => + document.createElement( 'page-editor-panel' ) + ); } - private setupMainHandles(): void { - const workspace = this.querySelector('.es-workspace')!; - workspace.querySelectorAll(':scope > .es-v-handle').forEach(h => { + // ── Layout serialization ──────────────────────────────────────────────────── + + private _layoutUrl(): string + { + const qp = new URLSearchParams( location.search ); + const p = new URLSearchParams( { deviceId: this._getDeviceId() } ); + + const projectId = qp.get( 'project' ); + const localRoot = qp.get( 'localRoot' ); + const remoteProject = qp.get( 'remoteProject' ); + + if ( projectId ) p.set( 'projectId', projectId ); + else if ( localRoot ) p.set( 'localRoot', localRoot ); + else if ( remoteProject ) p.set( 'remoteProject', remoteProject ); + + return `/api/layout?${ p }`; + } + + private _serializeLayout(): SerializedLayout + { + const serializePanel = ( panel: HTMLElement ): SerializedPanel => + { + const sections: SerializedSection[] = []; + const sectionsEl = panel.querySelector( '.es-sections' )!; + + for ( const child of Array.from( sectionsEl.children ) ) + { + if ( !child.classList.contains( 'es-section' ) ) continue; + const sec = child as HTMLElement; + const tabContainers: SerializedTabContainer[] = []; + + for ( const secChild of Array.from( sec.children ) ) + { + if ( secChild.tagName.toLowerCase() !== 'tab-container' ) continue; + const tc = secChild as any; + + const tabs: SerializedTab[] = ( tc.tabs as any[] ).map( ( tab: any ) => + { + const openFile: string | null = + typeof tab.element.getCurrentFile === 'function' + ? tab.element.getCurrentFile() + : null; + return { + id: tab.id, + label: tab.label, + panelType: tab.panelType, + tag: tab.element.tagName.toLowerCase(), + openFile, + }; + } ); + + tabContainers.push( { + flex: ( secChild as HTMLElement ).style.flex || null, + activeTabId: tc.activeId, + tabs, + } ); + } + + sections.push( { flex: sec.style.flex || null, tabContainers } ); + } + + return { flex: panel.style.flex || null, sections }; + }; + + const workspace = this.querySelector( '.es-workspace' ) as HTMLElement; + return { + version: 1, + activePortraitPanel: this.activePortraitPanel, + panels: { + left: serializePanel( workspace.querySelector( '[data-panel="left"]' ) as HTMLElement ), + center: serializePanel( workspace.querySelector( '[data-panel="center"]' ) as HTMLElement ), + right: serializePanel( workspace.querySelector( '[data-panel="right"]' ) as HTMLElement ), + }, + }; + } + + private async _restoreLayout( layout: SerializedLayout ): Promise + { + this.activePortraitPanel = layout.activePortraitPanel ?? 'center'; + const workspace = this.querySelector( '.es-workspace' ) as HTMLElement; + const openTasks: Array<{ file: string; panel: HTMLElement }> = []; + const save = () => this._scheduleLayoutSave(); + + const restorePanel = ( panel: HTMLElement, data: SerializedPanel ) => + { + if ( data.flex ) panel.style.flex = data.flex; + const sectionsEl = panel.querySelector( '.es-sections' ) as HTMLElement; + sectionsEl.innerHTML = ''; + + let firstSection = true; + for ( const secData of data.sections ) + { + if ( !firstSection ) sectionsEl.appendChild( makeResizeHandle( 'v', save ) ); + firstSection = false; + + const sec = document.createElement( 'div' ); + sec.className = 'es-section'; + if ( secData.flex ) sec.style.flex = secData.flex; + + const entries: Array<{ tc: HTMLElement; tcData: SerializedTabContainer }> = []; + + let firstTc = true; + for ( const tcData of secData.tabContainers ) + { + if ( !firstTc ) sec.appendChild( makeResizeHandle( 'h', save ) ); + firstTc = false; + + const tc = document.createElement( 'tab-container' ) as HTMLElement; + tc.id = nextTcId(); + if ( tcData.flex ) tc.style.flex = tcData.flex; + sec.appendChild( tc ); + entries.push( { tc, tcData } ); + } + + // Connect to DOM so each tab-container's connectedCallback fires + sectionsEl.appendChild( sec ); + + // Safe to call addTab now + for ( const { tc, tcData } of entries ) + { + for ( const tabData of tcData.tabs ) + { + const tag = tabData.tag; + ( tc as any ).addTab( + { id: tabData.id, label: tabData.label, panelType: tabData.panelType }, + () => document.createElement( tag ), + ); + if ( tabData.openFile ) + { + const entry = ( tc as any ).tabs.find( ( t: any ) => t.id === tabData.id ); + if ( entry ) openTasks.push( { file: tabData.openFile, panel: entry.element } ); + } + } + if ( tcData.activeTabId ) ( tc as any ).activateTab( tcData.activeTabId ); + } + } + }; + + restorePanel( workspace.querySelector( '[data-panel="left"]' ) as HTMLElement, layout.panels.left ); + restorePanel( workspace.querySelector( '[data-panel="center"]' ) as HTMLElement, layout.panels.center ); + restorePanel( workspace.querySelector( '[data-panel="right"]' ) as HTMLElement, layout.panels.right ); + + workspace.querySelectorAll( '.es-sections' ).forEach( s => this.observeNewHandles( s as HTMLElement ) ); + + for ( const { file, panel } of openTasks ) + { + await Editor.get().openDocumentIn( file, panel ); + } + } + + private async _loadLayout(): Promise + { + try + { + const res = await fetch( this._layoutUrl() ); + if ( !res.ok ) return null; + const data = await res.json(); + return data ?? null; + } + catch { return null; } + } + + private _scheduleLayoutSave(): void + { + if ( this._saveTimer ) clearTimeout( this._saveTimer ); + this._saveTimer = setTimeout( () => this._saveLayout(), 800 ); + } + + private async _saveLayout(): Promise + { + const layout = this._serializeLayout(); + try + { + await fetch( this._layoutUrl(), { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify( layout ), + } ); + } + catch {} + } + + // ── Resize handles ────────────────────────────────────────────────────────── + + private setupMainHandles(): void + { + const workspace = this.querySelector( '.es-workspace' )!; + workspace.querySelectorAll( ':scope > .es-v-handle' ).forEach( h => + { const handle = h as HTMLElement; - handle.addEventListener('pointerdown', (e: PointerEvent) => { + handle.addEventListener( 'pointerdown', ( e: PointerEvent ) => + { e.preventDefault(); - handle.setPointerCapture(e.pointerId); - const prev = handle.previousElementSibling as HTMLElement; - const next = handle.nextElementSibling as HTMLElement; - const start = e.clientX; + handle.setPointerCapture( e.pointerId ); + const prev = handle.previousElementSibling as HTMLElement; + const next = handle.nextElementSibling as HTMLElement; + const start = e.clientX; const startPrev = prev.offsetWidth; const startNext = next.offsetWidth; - const total = startPrev + startNext; + const total = startPrev + startNext; - const onMove = (ev: PointerEvent) => { + const onMove = ( ev: PointerEvent ) => + { const delta = ev.clientX - start; - const np = Math.max(80, Math.min(total - 80, startPrev + delta)); - prev.style.flex = `0 0 ${np}px`; - next.style.flex = `0 0 ${total - np}px`; + const np = Math.max( 80, Math.min( total - 80, startPrev + delta ) ); + prev.style.flex = `0 0 ${ np }px`; + next.style.flex = `0 0 ${ total - np }px`; }; - handle.addEventListener('pointermove', onMove); - handle.addEventListener('pointerup', () => { - handle.removeEventListener('pointermove', onMove); + handle.addEventListener( 'pointermove', onMove ); + handle.addEventListener( 'pointerup', () => + { + handle.removeEventListener( 'pointermove', onMove ); this._scheduleLayoutSave(); - }, { once: true }); - }); - }); - - this.querySelectorAll('.es-sections').forEach(sections => { - this.observeNewHandles(sections as HTMLElement); - }); + }, { once: true } ); + } ); + } ); + this.querySelectorAll( '.es-sections' ).forEach( sections => + { + this.observeNewHandles( sections as HTMLElement ); + } ); } - private setupSplitListener(): void { - this.addEventListener('tab-container:split', (e: Event) => { - const { containerId, direction } = (e as CustomEvent).detail as { containerId: string; direction: 'horizontal' | 'vertical' }; - const tc = document.getElementById(containerId); - if (!tc) return; - const section = tc.closest('.es-section') as HTMLElement | null; - if (!section) return; + private setupSplitListener(): void + { + const save = () => this._scheduleLayoutSave(); + + this.addEventListener( 'tab-container:split', ( e: Event ) => + { + const { containerId, direction } = ( e as CustomEvent ).detail as { containerId: string; direction: 'horizontal' | 'vertical' }; + const tc = document.getElementById( containerId ); + if ( !tc ) return; + const section = tc.closest( '.es-section' ) as HTMLElement | null; + if ( !section ) return; if ( 'vertical' === direction ) { - const newTc = document.createElement('tab-container') as HTMLElement; + const newTc = document.createElement( 'tab-container' ) as HTMLElement; newTc.id = nextTcId(); - const handle = makeResizeHandle('h'); - section.appendChild(handle); - section.appendChild(newTc); + const handle = makeResizeHandle( 'h', save ); + section.appendChild( handle ); + section.appendChild( newTc ); } else { - const sections = section.closest('.es-sections') as HTMLElement | null; - if (!sections) return; + const sections = section.closest( '.es-sections' ) as HTMLElement | null; + if ( !sections ) return; const newSec = makeSection(); - const handle = makeResizeHandle('v'); - sections.insertBefore(handle, section.nextSibling); - sections.insertBefore(newSec, handle.nextSibling); + const handle = makeResizeHandle( 'v', save ); + sections.insertBefore( handle, section.nextSibling ); + sections.insertBefore( newSec, handle.nextSibling ); } - }); - this.addEventListener('tab-container:close-container', (e: Event) => { - const { containerId } = (e as CustomEvent).detail as { containerId: string }; - const tc = document.getElementById(containerId) as HTMLElement | null; - if (!tc) return; - const section = tc.closest('.es-section') as HTMLElement | null; - if (!section) return; + save(); + } ); - const tcsInSection = section.querySelectorAll(':scope > tab-container'); + this.addEventListener( 'tab-container:close-container', ( e: Event ) => + { + const { containerId } = ( e as CustomEvent ).detail as { containerId: string }; + const tc = document.getElementById( containerId ) as HTMLElement | null; + if ( !tc ) return; + const section = tc.closest( '.es-section' ) as HTMLElement | null; + if ( !section ) return; + + const tcsInSection = section.querySelectorAll( ':scope > tab-container' ); if ( tcsInSection.length > 1 ) { - removeAdjacentHandle(tc, 'es-h-handle'); + removeAdjacentHandle( tc, 'es-h-handle' ); tc.remove(); } else { - const sections = section.closest('.es-sections') as HTMLElement | null; - if (!sections) return; - removeAdjacentHandle(section, 'es-v-handle'); + const sections = section.closest( '.es-sections' ) as HTMLElement | null; + if ( !sections ) return; + removeAdjacentHandle( section, 'es-v-handle' ); section.remove(); } - }); - this.addEventListener('tab-container:add-panel', (e: Event) => { - const { containerId, panelType, tag, label } = (e as CustomEvent).detail as { containerId: string; panelType: string; tag: string; label: string }; - const tc = document.getElementById(containerId) as any; - if (!tc) return; - const id = panelType + '-' + Math.random().toString(36).slice(2); - tc.addTab({ id, label: label ?? panelType, panelType }, () => document.createElement(tag)); - }); + save(); + } ); + + this.addEventListener( 'tab-container:add-panel', ( e: Event ) => + { + const { containerId, panelType, tag, label } = ( e as CustomEvent ).detail as { containerId: string; panelType: string; tag: string; label: string }; + const tc = document.getElementById( containerId ) as any; + if ( !tc ) return; + const id = panelType + '-' + Math.random().toString( 36 ).slice( 2 ); + tc.addTab( { id, label: label ?? panelType, panelType }, () => document.createElement( tag ) ); + + save(); + } ); } + // ── Device ID ─────────────────────────────────────────────────────────────── + private _getDeviceId(): string { if ( this._deviceId ) return this._deviceId; @@ -243,62 +512,7 @@ class EditorShell extends HTMLElement { return id; } - private async _loadLayout(): Promise - { - try - { - const res = await fetch( `/api/layout?deviceId=${this._getDeviceId()}` ); - if ( !res.ok ) return; - const layout = await res.json(); - if ( !layout ) return; - - if ( layout.panels ) - { - const workspace = this.querySelector( '.es-workspace' ) as HTMLElement; - for ( const [ panel, flex ] of Object.entries( layout.panels ) ) - { - if ( flex ) - { - const el = workspace.querySelector( `[data-panel="${panel}"]` ) as HTMLElement; - if ( el ) el.style.flex = flex as string; - } - } - } - - if ( layout.activePortraitPanel ) - { - this.activePortraitPanel = layout.activePortraitPanel; - } - } - catch {} - } - - private _scheduleLayoutSave(): void - { - if ( this._saveTimer ) clearTimeout( this._saveTimer ); - this._saveTimer = setTimeout( () => this._saveLayout(), 800 ); - } - - private async _saveLayout(): Promise - { - const workspace = this.querySelector( '.es-workspace' ) as HTMLElement; - const panels: Record = {}; - for ( const p of [ 'left', 'center', 'right' ] ) - { - const el = workspace.querySelector( `[data-panel="${p}"]` ) as HTMLElement; - panels[ p ] = el?.style.flex || null; - } - const layout = { panels, activePortraitPanel: this.activePortraitPanel }; - try - { - await fetch( `/api/layout?deviceId=${this._getDeviceId()}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify( layout ), - } ); - } - catch {} - } + // ── Resize redistribution ─────────────────────────────────────────────────── private setupResizeHandler(): void { @@ -322,35 +536,40 @@ class EditorShell extends HTMLElement { private _redistributeFlex( container: HTMLElement, childSelector: string ): void { - const children = Array.from( container.querySelectorAll( `:scope > ${childSelector}` ) ) as HTMLElement[]; + const children = Array.from( container.querySelectorAll( `:scope > ${ childSelector }` ) ) as HTMLElement[]; if ( children.length < 2 ) return; - if ( ! children.some( c => c.style.flex ) ) return; + if ( !children.some( c => c.style.flex ) ) return; - const totalPanel = children.reduce( ( sum, c ) => sum + c.offsetWidth, 0 ); + const totalPanel = children.reduce( ( sum, c ) => sum + c.offsetWidth, 0 ); if ( totalPanel === 0 ) return; - const handles = Array.from( container.children ).filter( - c => ! ( c as HTMLElement ).matches( childSelector ) + const handles = Array.from( container.children ).filter( + c => !( c as HTMLElement ).matches( childSelector ) ) as HTMLElement[]; - const handleTotal = handles.reduce( ( sum, h ) => sum + ( h as HTMLElement ).offsetWidth, 0 ); - const available = container.clientWidth - handleTotal; + const handleTotal = handles.reduce( ( sum, h ) => sum + h.offsetWidth, 0 ); + const available = container.clientWidth - handleTotal; children.forEach( c => { const ratio = c.offsetWidth / totalPanel; - c.style.flex = `0 0 ${Math.round( ratio * available )}px`; + c.style.flex = `0 0 ${ Math.round( ratio * available ) }px`; } ); } - private observeNewHandles(sections: HTMLElement): void { - const observer = new MutationObserver(() => { - sections.querySelectorAll('.es-h-handle:not([data-bound])').forEach(h => { - (h as HTMLElement).dataset.bound = '1'; - }); - }); - observer.observe(sections, { childList: true }); + private observeNewHandles( sections: HTMLElement ): void + { + const observer = new MutationObserver( () => + { + sections.querySelectorAll( '.es-h-handle:not([data-bound])' ).forEach( h => + { + ( h as HTMLElement ).dataset.bound = '1'; + } ); + } ); + observer.observe( sections, { childList: true } ); } + // ── Info bar ──────────────────────────────────────────────────────────────── + private _setupInfo(): void { const infoEl = this.querySelector( '.es-info' ) as HTMLElement; @@ -367,16 +586,20 @@ class EditorShell extends HTMLElement { { if ( hideTimer ) clearTimeout( hideTimer ); infoEl.textContent = msg.text; - infoEl.className = `es-info es-info-visible es-info-${ msg.type }`; + infoEl.className = `es-info es-info-visible es-info-${ msg.type }`; hideTimer = setTimeout( () => infoEl.classList.remove( 'es-info-visible' ), 5000 ); } ); } - private setupPortrait(): void { - const btns = this.querySelector( '.es-portrait-btns' ) as HTMLElement; - const mq = window.matchMedia( '(orientation: portrait)' ); + // ── Portrait mode ─────────────────────────────────────────────────────────── - const apply = ( portrait: boolean ) => { + private setupPortrait(): void + { + const btns = this.querySelector( '.es-portrait-btns' ) as HTMLElement; + const mq = window.matchMedia( '(orientation: portrait)' ); + + const apply = ( portrait: boolean ) => + { this.classList.toggle( 'portrait', portrait ); if ( portrait ) { @@ -389,8 +612,10 @@ class EditorShell extends HTMLElement { } }; - btns.querySelectorAll( '.es-pb-btn' ).forEach( btn => { - btn.addEventListener( 'click', () => { + btns.querySelectorAll( '.es-pb-btn' ).forEach( btn => + { + btn.addEventListener( 'click', () => + { const panel = ( btn as HTMLElement ).dataset.panel!; this.activePortraitPanel = panel; btns.querySelectorAll( '.es-pb-btn' ).forEach( b => b.classList.remove( 'active' ) ); @@ -404,12 +629,14 @@ class EditorShell extends HTMLElement { apply( mq.matches ); } - private showPortraitPanel(panelId: string): void { - this.querySelectorAll('.es-panel').forEach(p => { - (p as HTMLElement).style.display = (p as HTMLElement).dataset.panel === panelId ? '' : 'none'; - }); - this.querySelectorAll('.es-v-handle').forEach(h => { (h as HTMLElement).style.display = 'none'; }); + private showPortraitPanel( panelId: string ): void + { + this.querySelectorAll( '.es-panel' ).forEach( p => + { + ( p as HTMLElement ).style.display = ( p as HTMLElement ).dataset.panel === panelId ? '' : 'none'; + } ); + this.querySelectorAll( '.es-v-handle' ).forEach( h => { ( h as HTMLElement ).style.display = 'none'; } ); } } -customElements.define('editor-shell', EditorShell); +customElements.define( 'editor-shell', EditorShell ); diff --git a/source/components/page-editor-panel/page-editor-panel.ts b/source/components/page-editor-panel/page-editor-panel.ts index 0d8b9b3..a26b3a7 100644 --- a/source/components/page-editor-panel/page-editor-panel.ts +++ b/source/components/page-editor-panel/page-editor-panel.ts @@ -820,6 +820,11 @@ class PageEditorPanel extends HTMLElement return this._dirty; } + getCurrentFile(): string | null + { + return this.currentPath; + } + _updateButtons( dirty: boolean ): void { this._dirty = dirty; diff --git a/source/editor/editor-panel.ts b/source/editor/editor-panel.ts index 22693d8..ff1f5b9 100644 --- a/source/editor/editor-panel.ts +++ b/source/editor/editor-panel.ts @@ -19,6 +19,7 @@ export interface EditorPanel extends HTMLElement export interface FileEditorPanel extends EditorPanel { hasUnsavedChanges(): boolean; + getCurrentFile(): string | null; } export function implementsInterface( el: any, def: { type: string } ): boolean diff --git a/source/server/routes/layout.ts b/source/server/routes/layout.ts index e2b0961..6d52925 100644 --- a/source/server/routes/layout.ts +++ b/source/server/routes/layout.ts @@ -2,7 +2,7 @@ import { Router } from 'express'; import fs from 'fs'; import path from 'path'; import { requireAuth } from '../../auth-connector/source/server/auth'; -import { RJLog } from '../../library-ts/node/log/RJLog'; +import { checkAccess } from '../projectAccess'; import { ROOT } from '../rootDir'; const router = Router(); @@ -10,20 +10,58 @@ router.use( requireAuth ); const LAYOUTS_DIR = path.join( ROOT, 'build', 'data', 'storage', 'layouts' ); -function layoutFilePath( userId: string, deviceId: string ): string +function safeId( s: string ): string { - RJLog.log( { userId, deviceId } ); - const safe = ( s: string ) => s.replace( /[^a-zA-Z0-9_-]/g, '_' ); - const dir = path.join( LAYOUTS_DIR, safe( userId ) ); - if ( !fs.existsSync( dir ) ) fs.mkdirSync( dir, { recursive: true } ); - return path.join( dir, safe( deviceId ) + '.json' ); + return s.replace( /[^a-zA-Z0-9_-]/g, '_' ); +} + +function resolveLayoutPath( req: any ): string | null +{ + const deviceId = req.query.deviceId as string; + if ( !deviceId ) return null; + + const projectId = req.query.projectId as string | undefined; + const localRoot = req.query.localRoot as string | undefined; + const remoteProject = req.query.remoteProject as string | undefined; + + if ( projectId ) + { + const dir = path.join( ROOT, 'build', 'data', 'storage', projectId, 'root', '.roject' ); + if ( !fs.existsSync( dir ) ) fs.mkdirSync( dir, { recursive: true } ); + return path.join( dir, `layout-${ safeId( deviceId ) }.json` ); + } + + if ( localRoot ) + { + const resolved = path.resolve( localRoot ); + const dir = path.join( resolved, '.roject' ); + if ( !fs.existsSync( dir ) ) fs.mkdirSync( dir, { recursive: true } ); + return path.join( dir, `layout-${ safeId( deviceId ) }.json` ); + } + + if ( remoteProject ) + { + // Remote project opened in Electron: store locally, keyed by device + remote project id + const userId = req.auth!.userId; + const dir = path.join( LAYOUTS_DIR, safeId( userId ) ); + if ( !fs.existsSync( dir ) ) fs.mkdirSync( dir, { recursive: true } ); + return path.join( dir, `${ safeId( deviceId ) }-${ safeId( remoteProject ) }.json` ); + } + + return null; } router.get( '/', ( req, res ) => { - const deviceId = req.query.deviceId as string; - if ( !deviceId ) { res.json( null ); return; } - const fp = layoutFilePath( req.auth!.userId, deviceId ); + const projectId = req.query.projectId as string | undefined; + if ( projectId ) + { + const denied = checkAccess( projectId, req.auth!, 'view' ); + if ( denied ) { res.status( denied.status ).json( { error: denied.error } ); return; } + } + + const fp = resolveLayoutPath( req ); + if ( !fp ) { res.json( null ); return; } if ( !fs.existsSync( fp ) ) { res.json( null ); return; } try { res.json( JSON.parse( fs.readFileSync( fp, 'utf8' ) ) ); } catch { res.json( null ); } @@ -31,9 +69,15 @@ router.get( '/', ( req, res ) => router.put( '/', ( req, res ) => { - const deviceId = req.query.deviceId as string; - if ( !deviceId ) { res.status( 400 ).json( { error: 'Missing deviceId' } ); return; } - const fp = layoutFilePath( req.auth!.userId, deviceId ); + const projectId = req.query.projectId as string | undefined; + if ( projectId ) + { + const denied = checkAccess( projectId, req.auth!, 'edit' ); + if ( denied ) { res.status( denied.status ).json( { error: denied.error } ); return; } + } + + const fp = resolveLayoutPath( req ); + if ( !fp ) { res.status( 400 ).json( { error: 'Missing project identifier or deviceId' } ); return; } try { fs.writeFileSync( fp, JSON.stringify( req.body ), 'utf8' ); diff --git a/source/server/routes/localFiles.ts b/source/server/routes/localFiles.ts index 6c63b13..4f004f1 100644 --- a/source/server/routes/localFiles.ts +++ b/source/server/routes/localFiles.ts @@ -24,7 +24,7 @@ function safeResolve( root: string, filePath: string ): string | null function buildTree( absDir: string, rootDir: string ): FileNode[] { - return fs.readdirSync( absDir ).map( name => + return fs.readdirSync( absDir ).filter( name => name !== '.roject' ).map( name => { const abs = path.join( absDir, name ); const rel = path.relative( rootDir, abs ).replace( /\\/g, '/' ); diff --git a/source/server/storage.ts b/source/server/storage.ts index 3b027ff..0d349b2 100644 --- a/source/server/storage.ts +++ b/source/server/storage.ts @@ -25,7 +25,7 @@ export function createProjectStorage(projectId: string): void { } function buildTree(absDir: string, rootDir: string): FileNode[] { - return fs.readdirSync(absDir).map(name => { + return fs.readdirSync(absDir).filter(name => name !== '.roject').map(name => { const abs = path.join(absDir, name); const rel = path.relative(rootDir, abs).replace(/\\/g, '/'); if (fs.statSync(abs).isDirectory()) { diff --git a/workspace/boards/tasks.html b/workspace/boards/tasks.html index 01c6e30..034c934 100644 --- a/workspace/boards/tasks.html +++ b/workspace/boards/tasks.html @@ -223,56 +223,24 @@
Done
- File tree: UX improvements + Per-project per-device layout persistence - — Open-state preservation: refresh() now records which ftp-dir elements are open - (via data-path on their ftp-dir-label) before rebuilding the HTML, then - re-adds the open class to matching labels after render. - — "Mark As Root Directory" moved from dblclick to context menu (isDir detection - via targetPath.endsWith('/'), shown inside showItemMenu). - — "Open >" submenu for files: context menu lists the default editor plus all - registered alternatives (_panelTypeMap / _editorAlternatives static maps). - Selecting an entry calls _openFileIn(path, editorTag). - — Context menu label: shows filename only, truncated to menuLabelMaxChars (20) - with a leading "..." prefix when over the limit. - - - - - Page editor: mode buttons moved into toolbar - - The left sidebar (.pep-sidebar) and its .pep-main wrapper were removed. - The Blocks (⊞) and Areas (T) mode buttons now live directly in .pep-toolbar, - pushed right by a .pep-toolbar-sep spacer (flex: 1). - page-editor-panel now uses flex-direction: column with three direct children: - .pep-toolbar, .pep-mode-panel, iframe. - - - - - EditorConsole + console-panel - - EditorConsole is a new standalone singleton (source/editor/EditorConsole.ts) - that holds a capped ring of 500 ConsoleMessage objects and dispatches them via - onMessage: EventSlot. editor-shell subscribes and shows messages in a new - .es-info element in the header (5 s fade; portrait: fixed bottom bar). - Editor.onFileTypeUnknown is bridged to EditorConsole here, removing the - inline ftp-type-error element from file-tree-panel. - console-panel is a new tab that renders all messages from EditorConsole using - custom elements (conp-header, conp-list, conp-entry, conp-time, conp-text). - Added to tab-container panel-type list and loaded in editor.html. - - - - - Bug fix: openDocumentIn ignored the target panel type - - openDocumentIn was re-resolving editorTag from FileEditorRegistry, which always - returned the default editor for the file type (e.g. page-editor-panel for .page - files). The code-panel listener checks editorTag and bailed, so files opened - via "Open in Code Editor" showed a blank panel. - Fix: derive editorTag from panelElement.tagName.toLowerCase() directly, - removing the registry lookup from openDocumentIn entirely. + Full tab tree (panels → sections → tab-containers → tabs including open files) + saved to .roject/layout-<deviceId>.json inside each project directory. + Remote projects: storage/<id>/root/.roject/. Local Electron: <localRoot>/.roject/. + Remote proxy (Electron opening a roject.rokojori.com project): centralized + layouts dir keyed by device + remoteProjectId. + deviceId in localStorage already differentiates Firefox, Chrome, and Electron. + Serialized format: { version, activePortraitPanel, panels: { left, center, right } } + where each panel has sections[], each section has tabContainers[], each + tab-container has tabs[] with { id, label, panelType, tag, openFile }. + FileEditorPanel interface extended with getCurrentFile(): string | null, + implemented in code-panel and page-editor-panel. + Restored on editor load; falls back to default layout if none saved. + makeResizeHandle() gained an optional onResize callback so inner handles trigger saves. + .roject/ filtered from both remote (storage.ts) and local (localFiles.ts) file trees. + Save triggers: panel resize, inner handle resize, tab click, file open, + split, close-container, add-panel, portrait panel switch. diff --git a/workspace/history/2026/07-July/31-Friday/index.html b/workspace/history/2026/07-July/31-Friday/index.html index 6931c1c..c352c3b 100644 --- a/workspace/history/2026/07-July/31-Friday/index.html +++ b/workspace/history/2026/07-July/31-Friday/index.html @@ -221,6 +221,60 @@

+ + +
+

Per-project per-device layout persistence

+

+ The editor now fully saves and restores its window configuration — tab structure, + open files, panel widths — per project per device. +

+

+ Storage: .roject/layout-<deviceId>.json inside + each project's root directory. Remote server projects write to + storage/<id>/root/.roject/; local Electron projects write to + <localRoot>/.roject/; remote projects opened via the Electron + proxy use the centralized build/data/storage/layouts/ keyed by + device + remote project ID. deviceId is already a per-browser UUID + in localStorage — Firefox, Chrome, and Electron automatically get + distinct IDs. +

+

+ Serialized format (SerializedLayout): + { version, activePortraitPanel, panels: { left, center, right } } + where each panel carries its flex value and an array of sections; each section + carries its flex and an array of tab-containers; each tab-container carries + activeTabId and an array of tabs with + { id, label, panelType, tag, openFile }. +

+

+ FileEditorPanel interface extended with + getCurrentFile(): string | null, implemented by + code-panel and page-editor-panel (both already tracked + currentPath). +

+

+ Restore flow: editor-shell awaits the layout fetch + alongside customElements.whenDefined promises; if a layout with + panels is returned, _restoreLayout() rebuilds the DOM + (sections, tab-containers with resize handles), calls addTab() once + the containers are connected, activates the correct tab, then opens each saved + file via Editor.openDocumentIn(). Falls back to + initDefaultLayout() if no layout exists. +

+

+ Save triggers: panel resize, inner section/tab-container resize + (via makeResizeHandle's new optional onResize callback), + tab click, file opened (Editor.onDocumentOpened), split, + close-container, add-panel, portrait panel switch. All debounced at 800 ms. +

+

+ .roject/ hidden from both the remote + (storage.ts buildTree) and local (localFiles.ts buildTree) + file tree listings via a server-side .filter(name !== '.roject'). +

+
+
diff --git a/workspace/history/index.html b/workspace/history/index.html index 76d8538..2796cda 100644 --- a/workspace/history/index.html +++ b/workspace/history/index.html @@ -21,7 +21,7 @@

Friday, 31 July 2026

-

File tree: context menu open-state preservation, "Open >" submenu, label truncation. Page editor full redesign: unified icon+label toolbar, floating block menu, insertion trigger overlays, block deletion, drag-to-reorder blocks. InputDialog component. LINK/MARK formats with styled custom elements. EditorConsole + console-panel. openDocumentIn fix.

+

File tree: context menu open-state preservation, "Open >" submenu, label truncation. Page editor full redesign: unified icon+label toolbar, floating block menu, insertion trigger overlays, block deletion, drag-to-reorder blocks. InputDialog component. LINK/MARK formats with styled custom elements. EditorConsole + console-panel. openDocumentIn fix. Per-project per-device layout persistence: full tab tree saved to .roject/layout-<deviceId>.json, FileEditorPanel.getCurrentFile(), restore on load.

diff --git a/workspace/outline/index.html b/workspace/outline/index.html index 7bc3c17..fa233b2 100644 --- a/workspace/outline/index.html +++ b/workspace/outline/index.html @@ -231,6 +231,27 @@ dispatch must honour that choice so alternative editors (e.g. code-panel opening a .page file) receive and display the document correctly.

+

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