import { Editor } from '../../editor/Editor.js'; import { EditorPanelDefinition, FileEditorPanelDefinition } from '../../editor/editor-panel.js'; import { ContextMenuDirectory, ContextMenuReadOnlyEntry } from '../context-menu/context-menu.js'; // ── Block registry ──────────────────────────────────────────────────────────── // Add new block templates here. Each entry needs: // name — display label shown below the preview // preview — HTML markup for the CSS layout sketch (use .pbp-* classes); omit for text-only label // html — snippet inserted into when the block is added interface PageBlockEntry { name: string; preview?: string; html: string; } const PAGE_BLOCK_REGISTRY: PageBlockEntry[] = [ { name: 'Full Width', preview: '
', html: '\n' + ' \n' + '', }, { name: 'Two Columns', preview: '
', html: '\n' + ' \n' + ' \n' + '', }, ]; // ── Editor-injected styles ──────────────────────────────────────────────────── // Injected into the iframe after load via `; // ── Validation ──────────────────────────────────────────────────────────────── // Placeholder — always returns true. // TODO: implement real validation: // - Exactly one , one , one as direct // children of ; no other elements at that level. // - may be empty or contain any number of children. // When validation fails, the file should fall back to code-panel. function validatePageFormat( _doc: Document ): boolean { return true; } // ── Rich-text helper ────────────────────────────────────────────────────────── // Wraps the given Range in a new element created in `doc`. // Uses Range.extractContents() which handles both fully-contained nodes // (wrapped outside) and boundary intersections (text nodes split automatically // by the Range API, wrapped inside the outer element). // Note: adjacent identical elements are not merged after wrapping (future work). function wrapSelection( doc: Document, range: Range, tagName: string, attributes?: Record ): HTMLElement { const wrapper = doc.createElement( tagName ); if ( attributes ) { for ( const [ key, value ] of Object.entries( attributes ) ) { wrapper.setAttribute( key, value ); } } wrapper.appendChild( range.extractContents() ); range.insertNode( wrapper ); return wrapper; } // ── Component ───────────────────────────────────────────────────────────────── class PageEditorPanel extends HTMLElement { __interfaces__ = [ EditorPanelDefinition.type, FileEditorPanelDefinition.type ]; currentPath: string | null = null; _dirty = false; _pinned: boolean = false; _iframe: HTMLIFrameElement | null = null; _undoStack: string[] = []; _redoStack: string[] = []; _mutationObserver: MutationObserver | null = null; _initialized = false; _needsRestore = false; _mode: 'blocks' | 'areas' = 'blocks'; connectedCallback(): void { if ( this._initialized ) return; this._initialized = true; this.className = 'page-editor-panel'; this.innerHTML = `
${ PAGE_BLOCK_REGISTRY.map( b => `
${ b.preview ?? `${ b.name }` }
${ b.name }
` ).join( '' ) }
Open a .page file from the file tree
`; this._iframe = this.querySelector( 'iframe' ); this.querySelector( '.pep-pin' )!.addEventListener( 'click', () => this._togglePin() ); this.querySelector( '.pep-save' )!.addEventListener( 'click', () => this._save() ); this.querySelector( '.pep-undo' )!.addEventListener( 'click', () => this._undo() ); this.querySelector( '.pep-redo' )!.addEventListener( 'click', () => this._redo() ); this.querySelector( '.pep-mode-blocks' )!.addEventListener( 'click', () => this._setMode( 'blocks' ) ); this.querySelector( '.pep-mode-areas' )!.addEventListener( 'click', () => this._setMode( 'areas' ) ); this.querySelectorAll( '.pep-block-item' ).forEach( item => { item.addEventListener( 'click', () => { const blockName = ( item as HTMLElement ).dataset.block!; const entry = PAGE_BLOCK_REGISTRY.find( b => b.name === blockName ); if ( entry ) this._insertBlock( entry.html ); } ); } ); this.querySelectorAll( '.pep-fmt-btn' ).forEach( btn => { btn.addEventListener( 'click', () => this._applyFormat( ( btn as HTMLElement ).dataset.tag! ) ); } ); Editor.get().onDocumentOpened.addListener( ( e ) => { if ( 'page-editor-panel' !== e.editorTag ) return; if ( e.targetElement && e.targetElement !== this ) return; if ( ! this._pinned ) this._loadDocument( e.path, e.content ); } ); document.addEventListener( 'keydown', ( e: KeyboardEvent ) => { if ( ! this.currentPath ) return; if ( e.ctrlKey && e.key === 's' ) { e.preventDefault(); this._save(); } if ( e.ctrlKey && ! e.shiftKey && e.key === 'z' ) { e.preventDefault(); this._undo(); } if ( e.ctrlKey && ( e.key === 'y' || ( e.shiftKey && e.key === 'z' ) ) ) { e.preventDefault(); this._redo(); } } ); } disconnectedCallback(): void { if ( this._undoStack.length > 0 ) this._needsRestore = true; } _setMode( mode: 'blocks' | 'areas' ): void { this._mode = mode; this.querySelector( '.pep-mode-blocks' )!.classList.toggle( 'active', mode === 'blocks' ); this.querySelector( '.pep-mode-areas' )!.classList.toggle( 'active', mode === 'areas' ); ( this.querySelector( '.pep-blocks-panel' ) as HTMLElement ).style.display = mode === 'blocks' ? '' : 'none'; ( this.querySelector( '.pep-areas-panel' ) as HTMLElement ).style.display = mode === 'areas' ? '' : 'none'; } _updateTabLabel( path: string ): void { const name = path ? path.slice( path.lastIndexOf( '/' ) + 1 ) : ''; this.dispatchEvent( new CustomEvent( 'panel:label-change', { bubbles: true, detail: { label: '📄 ' + name } } ) ); } _loadDocument( path: string, content: string ): void { this.currentPath = path; this._updateTabLabel( path ); this.querySelector( '.pep-empty' )!.setAttribute( 'style', 'display:none' ); this._iframe!.style.display = ''; // Auto-template: empty files get the standard structure injected and marked dirty if ( ! content.trim() ) { this._undoStack = [ PAGE_TEMPLATE ]; this._redoStack = []; Editor.get().markDirty( path, PAGE_TEMPLATE ); this._renderContent( PAGE_TEMPLATE ); this._updateButtons( true ); return; } this._undoStack = [ content ]; this._redoStack = []; this._renderContent( content ); this._updateButtons( false ); } _renderContent( html: string ): void { const iframe = this._iframe!; if ( this._mutationObserver ) { this._mutationObserver.disconnect(); this._mutationObserver = null; } iframe.srcdoc = html; iframe.onload = () => { if ( this._needsRestore ) { this._needsRestore = false; this._renderContent( this._undoStack[ this._undoStack.length - 1 ] ); return; } const doc = iframe.contentDocument!; this._injectEditorStyles( doc ); doc.querySelectorAll( 'page-area' ).forEach( el => { ( el as HTMLElement ).contentEditable = 'true'; ( el as HTMLElement ).style.outline = 'none'; } ); const pageRoot = doc.querySelector( 'page-root' ); if ( pageRoot ) { this._mutationObserver = new MutationObserver( () => this._onContentChanged() ); this._mutationObserver.observe( pageRoot, { subtree: true, childList: true, characterData: true, attributes: true } ); } }; } _injectEditorStyles( doc: Document ): void { const existing = doc.getElementById( 'pep-editor-injected' ); if ( existing ) existing.remove(); const style = doc.createElement( 'style' ); style.id = 'pep-editor-injected'; style.textContent = PEP_EDITOR_STYLES; doc.head.appendChild( style ); } _captureHtml(): string { const doc = this._iframe!.contentDocument!; const injected = doc.getElementById( 'pep-editor-injected' ); if ( injected ) injected.remove(); const html = '\n' + doc.documentElement.outerHTML; if ( injected ) doc.head.appendChild( injected ); return html; } _onContentChanged(): void { if ( ! this.currentPath || ! this._iframe?.contentDocument ) return; const html = this._captureHtml(); const last = this._undoStack[ this._undoStack.length - 1 ]; if ( html === last ) return; this._undoStack.push( html ); if ( this._undoStack.length > 200 ) this._undoStack.shift(); this._redoStack = []; Editor.get().markDirty( this.currentPath, html ); this._updateButtons( true ); } _insertBlock( blockHtml: string ): void { if ( ! this.currentPath || ! this._iframe?.contentDocument ) return; const doc = this._iframe.contentDocument; const pageRoot = doc.querySelector( 'page-root' ); if ( ! pageRoot ) return; const temp = doc.createElement( 'div' ); temp.innerHTML = blockHtml; while ( temp.firstChild ) { const child = temp.firstChild; pageRoot.appendChild( child ); if ( child.nodeType === Node.ELEMENT_NODE ) { ( child as HTMLElement ).querySelectorAll( 'page-area' ).forEach( area => { ( area as HTMLElement ).contentEditable = 'true'; ( area as HTMLElement ).style.outline = 'none'; } ); } } } _applyFormat( tagName: string, attributes?: Record ): void { const iframeWin = this._iframe?.contentWindow; if ( ! iframeWin ) return; const sel = iframeWin.getSelection(); if ( ! sel || sel.rangeCount === 0 ) return; const range = sel.getRangeAt( 0 ); if ( range.collapsed ) return; wrapSelection( this._iframe!.contentDocument!, range, tagName, attributes ); } _undo(): void { if ( this._undoStack.length < 2 ) return; const current = this._undoStack.pop()!; this._redoStack.push( current ); const prev = this._undoStack[ this._undoStack.length - 1 ]; Editor.get().markDirty( this.currentPath!, prev ); this._renderContent( prev ); this._updateButtons( true ); } _redo(): void { if ( ! this._redoStack.length ) return; const next = this._redoStack.pop()!; this._undoStack.push( next ); Editor.get().markDirty( this.currentPath!, next ); this._renderContent( next ); this._updateButtons( true ); } _togglePin(): void { this._pinned = ! this._pinned; this.querySelector( '.pep-pin' )!.classList.toggle( 'pinned', this._pinned ); } async _save(): Promise { if ( ! this.currentPath ) return; await Editor.get().save( this.currentPath ); this._updateButtons( false ); } addContextMenuEntries( dir: ContextMenuDirectory ): void { dir.add( new ContextMenuReadOnlyEntry( dir, this.currentPath ? `Editing: ${ this.currentPath }` : 'No document open' ) ); } hasUnsavedChanges(): boolean { return this._dirty; } _updateButtons( dirty: boolean ): void { this._dirty = dirty; ( this.querySelector( '.pep-save' ) as HTMLButtonElement ).disabled = ! dirty; ( this.querySelector( '.pep-undo' ) as HTMLButtonElement ).disabled = this._undoStack.length < 2; ( this.querySelector( '.pep-redo' ) as HTMLButtonElement ).disabled = this._redoStack.length === 0; } } customElements.define( 'page-editor-panel', PageEditorPanel );