import { Editor } from '../../editor/Editor.js'; import { EditorPanelDefinition, FileEditorPanelDefinition } from '../../editor/editor-panel.js'; import { ContextMenuDirectory, ContextMenuReadOnlyEntry } from '../context-menu/context-menu.js'; import { showInputDialog } from '../confirm-dialog/confirm-dialog.js'; // ── Block registry ──────────────────────────────────────────────────────────── 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 when format is stable. function validatePageFormat( _doc: Document ): boolean { return true; } // ── Rich-text helper ────────────────────────────────────────────────────────── // 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; } // ── Toolbar HTML ────────────────────────────────────────────────────────────── function iconBtn( tag: string, icon: string, label: string, title: string, extraClass = '' ): string { const dataTag = tag ? `data-tag="${ tag }"` : ''; return ``; } const TOOLBAR_BUTTONS = ` ${ iconBtn( '', 'block', 'BLOCK', 'Insert Block', 'pep-btn-block' ) }
${ iconBtn( 'h1', 'h1', 'H1', 'Heading 1' ) } ${ iconBtn( 'h2', 'h2', 'H2', 'Heading 2' ) } ${ iconBtn( 'h3', 'h3', 'H3', 'Heading 3' ) }
${ iconBtn( 'link', 'link', 'LINK', 'Link' ) } ${ iconBtn( 'marked-text', 'mark', 'MARK', 'Mark' ) }
${ iconBtn( 'b', 'bold', 'BOLD', 'Bold' ) } ${ iconBtn( 'i', 'italic', 'ITALIC', 'Italic' ) } ${ iconBtn( 'u', 'under', 'UNDER', 'Underline' ) } `; // ── 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; _blockInsertTarget: Element | null = null; _pageRootClickHandler: (( e: Event ) => void) | null = null; _bodyClickHandler: (() => void) | null = null; _draggedBlock: Element | null = null; _dropTrigger: Element | null = null; _dragBar: HTMLElement | null = null; _iframeTriggers: Element[] = []; connectedCallback(): void { if ( this._initialized ) return; this._initialized = true; this.className = 'page-editor-panel'; this.innerHTML = `
${ TOOLBAR_BUTTONS }
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-btn-block' )!.addEventListener( 'click', ( e ) => { e.stopPropagation(); this._blockInsertTarget = null; const rect = ( e.currentTarget as HTMLElement ).getBoundingClientRect(); this._openBlockMenu( rect ); } ); this.querySelector( '.pep-block-menu-close' )!.addEventListener( 'click', () => this._closeBlockMenu() ); this.querySelector( '.pep-block-menu' )!.addEventListener( 'click', ( e ) => e.stopPropagation() ); 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._closeBlockMenu(); } ); } ); this.querySelectorAll( '.pep-fmt-btn' ).forEach( btn => { // Prevent focus leaving the iframe (which would clear the selection) on click btn.addEventListener( 'mousedown', ( e ) => e.preventDefault() ); } ); this.querySelectorAll( '.pep-fmt-btn[data-tag]' ).forEach( btn => { btn.addEventListener( 'click', () => this._applyFormat( ( btn as HTMLElement ).dataset.tag! ) ); } ); document.addEventListener( 'click', () => this._closeBlockMenu() ); 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; } _openBlockMenu( anchorRect: DOMRect ): void { const menu = this.querySelector( '.pep-block-menu' ) as HTMLElement; const componentRect = this.getBoundingClientRect(); // Show offscreen first to measure menu.style.top = '-9999px'; menu.style.left = '-9999px'; menu.style.display = ''; const menuHeight = menu.offsetHeight; const menuWidth = menu.offsetWidth; let top = anchorRect.bottom - componentRect.top + 4; let left = anchorRect.left - componentRect.left; if ( top + menuHeight > componentRect.height - 8 ) { top = anchorRect.top - componentRect.top - menuHeight - 4; } left = Math.max( 8, Math.min( left, componentRect.width - menuWidth - 8 ) ); top = Math.max( 8, top ); menu.style.top = top + 'px'; menu.style.left = left + 'px'; } _openBlockMenuNearIframeElement( el: Element ): void { const iframeRect = this._iframe!.getBoundingClientRect(); const elRect = el.getBoundingClientRect(); const anchorRect = new DOMRect( iframeRect.left + elRect.left, iframeRect.top + elRect.top, elRect.width, elRect.height ); this._openBlockMenu( anchorRect ); } _closeBlockMenu(): void { ( this.querySelector( '.pep-block-menu' ) as HTMLElement ).style.display = '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' ) as HTMLElement ).style.display = 'none'; this._iframe!.style.display = ''; 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._refreshIframeOverlays( doc, pageRoot ); this._mutationObserver.observe( pageRoot, { subtree: true, childList: true, characterData: true, attributes: true } ); } }; } _refreshIframeOverlays( doc: Document, pageRoot: Element ): void { this._mutationObserver?.disconnect(); doc.querySelectorAll( '.pep-insert-trigger, .pep-delete-btn' ).forEach( el => el.remove() ); const blocks = Array.from( pageRoot.querySelectorAll( ':scope > page-block' ) ); for ( const block of blocks ) { const trigger = doc.createElement( 'div' ); trigger.className = 'pep-insert-trigger'; pageRoot.insertBefore( trigger, block ); trigger.addEventListener( 'mousedown', ( e ) => { e.preventDefault(); this._startDragTracking( e as MouseEvent, block, trigger, doc, pageRoot ); } ); const delBtn = doc.createElement( 'button' ); delBtn.className = 'pep-delete-btn'; delBtn.textContent = '✕'; delBtn.title = 'Delete block'; block.appendChild( delBtn ); delBtn.addEventListener( 'click', ( e ) => { e.stopPropagation(); this._deleteBlock( block as HTMLElement, doc, pageRoot ); } ); } const endTrigger = doc.createElement( 'div' ); endTrigger.className = 'pep-insert-trigger'; pageRoot.appendChild( endTrigger ); endTrigger.addEventListener( 'mousedown', ( e ) => { e.preventDefault(); this._startDragTracking( e as MouseEvent, null, endTrigger, doc, pageRoot ); } ); this._iframeTriggers = Array.from( pageRoot.querySelectorAll( '.pep-insert-trigger' ) ); // Portrait block-tap: event delegation on pageRoot if ( this._pageRootClickHandler ) { pageRoot.removeEventListener( 'click', this._pageRootClickHandler ); } this._pageRootClickHandler = ( e: Event ) => { const block = ( e.target as Element ).closest( 'page-block' ); if ( ! block ) return; e.stopPropagation(); doc.querySelectorAll( 'page-block.pep-block-active' ).forEach( b => b.classList.remove( 'pep-block-active' ) ); block.classList.add( 'pep-block-active' ); }; pageRoot.addEventListener( 'click', this._pageRootClickHandler ); // Tap outside all blocks dismisses active selection if ( this._bodyClickHandler ) { doc.body.removeEventListener( 'click', this._bodyClickHandler ); } this._bodyClickHandler = () => { doc.querySelectorAll( 'page-block.pep-block-active' ).forEach( b => b.classList.remove( 'pep-block-active' ) ); }; doc.body.addEventListener( 'click', this._bodyClickHandler ); if ( this._mutationObserver ) { 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 clone = doc.cloneNode( true ) as Document; clone.getElementById( 'pep-editor-injected' )?.remove(); clone.querySelectorAll( '.pep-insert-trigger, .pep-delete-btn' ).forEach( el => el.remove() ); clone.querySelectorAll( '.pep-block-active' ).forEach( el => el.classList.remove( 'pep-block-active' ) ); return '\n' + clone.documentElement.outerHTML; } _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; if ( this._blockInsertTarget ) { pageRoot.insertBefore( child, this._blockInsertTarget ); } else { 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'; } ); } } this._onContentChanged(); this._refreshIframeOverlays( doc, pageRoot ); } _deleteBlock( block: HTMLElement, doc: Document, pageRoot: Element ): void { block.remove(); this._onContentChanged(); this._refreshIframeOverlays( doc, pageRoot ); } _startDragTracking( e: MouseEvent, draggedBlock: Element | null, trigger: Element, doc: Document, pageRoot: Element ): void { const startX = e.clientX; const startY = e.clientY; let dragging = false; const onMove = ( me: MouseEvent ) => { if ( ! dragging ) { if ( Math.hypot( me.clientX - startX, me.clientY - startY ) <= 4 ) return; if ( draggedBlock === null ) return; // end trigger — click only dragging = true; this._draggedBlock = draggedBlock; this._createDragBar(); } this._updateDragVisual( me ); }; const onUp = () => { doc.removeEventListener( 'mousemove', onMove ); doc.removeEventListener( 'mouseup', onUp ); document.removeEventListener( 'mouseup', onUp ); if ( dragging ) { this._endDrag( doc, pageRoot ); } else { this._blockInsertTarget = draggedBlock; this._openBlockMenuNearIframeElement( trigger ); } dragging = false; }; doc.addEventListener( 'mousemove', onMove ); doc.addEventListener( 'mouseup', onUp ); document.addEventListener( 'mouseup', onUp ); } _createDragBar(): void { if ( this._dragBar ) this._dragBar.remove(); const bar = document.createElement( 'div' ); bar.className = 'pep-drag-bar'; this.appendChild( bar ); this._dragBar = bar; } _updateDragVisual( me: MouseEvent ): void { let nearest: Element | null = null; let minDist = Infinity; for ( const t of this._iframeTriggers ) { const rect = t.getBoundingClientRect(); const dist = Math.abs( me.clientY - ( rect.top + rect.height / 2 ) ); if ( dist < minDist ) { minDist = dist; nearest = t; } } if ( this._dropTrigger !== nearest ) { this._dropTrigger?.classList.remove( 'pep-drop-target' ); this._dropTrigger = nearest; nearest?.classList.add( 'pep-drop-target' ); } if ( this._dragBar && nearest ) { const iframeRect = this._iframe!.getBoundingClientRect(); const componentRect = this.getBoundingClientRect(); const triggerRect = nearest.getBoundingClientRect(); const barTop = ( iframeRect.top - componentRect.top ) + triggerRect.top + triggerRect.height / 2 - 1.5; this._dragBar.style.top = barTop + 'px'; } } _endDrag( doc: Document, pageRoot: Element ): void { const draggedBlock = this._draggedBlock; const dropTrigger = this._dropTrigger; this._clearDragVisual(); if ( draggedBlock && dropTrigger ) { this._reorderBlock( draggedBlock, dropTrigger, pageRoot, doc ); } } _clearDragVisual(): void { this._dragBar?.remove(); this._dragBar = null; this._draggedBlock = null; this._dropTrigger?.classList.remove( 'pep-drop-target' ); this._dropTrigger = null; } _reorderBlock( draggedBlock: Element, dropTrigger: Element, pageRoot: Element, doc: Document ): void { const insertBefore = dropTrigger.nextElementSibling; if ( insertBefore === draggedBlock ) return; // dropping immediately before itself if ( draggedBlock.nextElementSibling === dropTrigger ) return; // dropping immediately after itself if ( insertBefore ) { pageRoot.insertBefore( draggedBlock, insertBefore ); } else { pageRoot.appendChild( draggedBlock ); } this._onContentChanged(); this._refreshIframeOverlays( doc, pageRoot ); } async _applyFormat( tagName: string ): Promise { 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; const doc = this._iframe!.contentDocument!; if ( tagName === 'link' ) { const selectedText = sel.toString().trim(); const defaultUrl = /^https?:\/\//.test( selectedText ) ? selectedText : ''; const url = await showInputDialog( { icon: '🔗', title: 'Create Link', label: 'URL', defaultValue: defaultUrl } ); if ( ! url ) return; wrapSelection( doc, range, 'a', { href: url } ); return; } wrapSelection( doc, range, tagName ); } _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; } getCurrentFile(): string | null { return this.currentPath; } _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 );