rojects/source/components/page-editor-panel/page-editor-panel.ts

833 lines
26 KiB
TypeScript

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: '<div class="pbp-full"><div class="pbp-area"></div></div>',
html:
'<page-block class="pep-block-full">\n' +
' <page-area></page-area>\n' +
'</page-block>',
},
{
name: 'Two Columns',
preview: '<div class="pbp-two-col"><div class="pbp-area"></div><div class="pbp-area"></div></div>',
html:
'<page-block class="pep-block-two-col">\n' +
' <page-area></page-area>\n' +
' <page-area></page-area>\n' +
'</page-block>',
},
];
// ── Editor-injected styles ────────────────────────────────────────────────────
// Injected into the iframe <head> after load via <style id="pep-editor-injected">.
// Stripped from captured HTML before saving via document.cloneNode — never on disk.
const PEP_EDITOR_STYLES = `
page-header, page-footer {
display: block;
padding: 0.5rem;
border: 1px dashed #2d3748;
color: #718096;
font-size: 0.8rem;
}
page-root { display: block; }
page-block {
display: flex;
width: 100%;
gap: 0.5rem;
margin: 0.5rem 0;
box-sizing: border-box;
position: relative;
}
page-block.pep-block-full { flex-direction: row; }
page-block.pep-block-two-col { flex-direction: row; }
page-area {
display: block;
flex: 1;
min-height: 3rem;
border: 1px dashed #4a5568;
padding: 0.5rem;
outline: none;
box-sizing: border-box;
}
@media (orientation: portrait) {
page-block.pep-block-two-col { flex-direction: column; }
}
.pep-insert-trigger {
display: block;
height: 10px;
cursor: grab;
}
.pep-insert-trigger:active { cursor: grabbing; }
.pep-insert-trigger::before {
content: '';
display: block;
height: 5px;
margin-top: 2.5px;
border-radius: 2px;
background: transparent;
transition: background 0.1s;
}
.pep-insert-trigger:hover::before {
background: rgba(124, 140, 255, 0.3);
}
.pep-drop-target::before {
background: rgba(124, 140, 255, 0.75) !important;
}
.pep-delete-btn {
position: absolute;
top: 4px;
right: 4px;
background: #1a1d27;
border: 1px solid #3a3d4a;
color: #9ba4c7;
border-radius: 4px;
cursor: pointer;
font-size: 0.7rem;
padding: 2px 6px;
display: none;
z-index: 10;
line-height: 1.4;
}
.pep-delete-btn:hover { background: #2a1d27; border-color: #ff6060; color: #ff8080; }
@media (hover: hover) {
page-block:hover .pep-delete-btn { display: block; }
}
page-block.pep-block-active .pep-delete-btn { display: block; }
marked-text {
font-weight: 700;
color: hsl(190, 80%, 90%);
}
a { color: hsl(200, 80%, 70%); }
`;
// ── Default theme CSS ─────────────────────────────────────────────────────────
const DEFAULT_THEME_CSS = `@import url('https://styles.rokojori.com/get-font?family=Barlow&weights=100,400,700,900');
[data-theme="default-roject"] {
background: #0f1117;
color: #c8cce0;
font-family: 'Barlow', sans-serif;
font-size: 1rem;
line-height: 1.7;
}
[data-theme="default-roject"] h1 {
color: #7c8cff;
font-size: 2.5rem;
font-weight: 900;
font-style: italic;
text-transform: uppercase;
margin: 1.5rem 0 0.75rem;
}
[data-theme="default-roject"] h2 {
color: #7c8cff;
font-size: 1.6rem;
font-weight: 700;
margin: 1.25rem 0 0.5rem;
}
[data-theme="default-roject"] h3 {
color: #9ba4c7;
font-size: 1.2rem;
font-weight: 700;
margin: 1rem 0 0.4rem;
}
[data-theme="default-roject"] p {
margin-bottom: 0.75rem;
}
[data-theme="default-roject"] b,
[data-theme="default-roject"] strong {
color: #e2e4ed;
font-weight: 700;
}
[data-theme="default-roject"] marked-text {
font-weight: 700;
color: hsl(190, 80%, 90%);
}
[data-theme="default-roject"] a {
color: hsl(200, 80%, 70%);
}`;
// ── Standard template for new / empty .page files ────────────────────────────
const PAGE_TEMPLATE = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>New Page</title>
<style>
${ DEFAULT_THEME_CSS }
</style>
</head>
<body data-theme="default-roject">
<page-header></page-header>
<page-root data-theme="default-roject">
<page-block class="pep-block-full">
<page-area></page-area>
</page-block>
</page-root>
<page-footer></page-footer>
</body>
</html>`;
// ── 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<string, string> ): 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 `<button class="pep-fmt-btn ${ extraClass }" ${ dataTag } title="${ title }">
<img class="pep-btn-icon" src="/components/page-editor-panel/icons/${ icon }.svg" width="24" height="24" alt="">
<span class="pep-btn-label">${ label }</span>
</button>`;
}
const TOOLBAR_BUTTONS = `
${ iconBtn( '', 'block', 'BLOCK', 'Insert Block', 'pep-btn-block' ) }
<div class="pep-fmt-sep"></div>
${ iconBtn( 'h1', 'h1', 'H1', 'Heading 1' ) }
${ iconBtn( 'h2', 'h2', 'H2', 'Heading 2' ) }
${ iconBtn( 'h3', 'h3', 'H3', 'Heading 3' ) }
<div class="pep-fmt-sep"></div>
${ iconBtn( 'link', 'link', 'LINK', 'Link' ) }
${ iconBtn( 'marked-text', 'mark', 'MARK', 'Mark' ) }
<div class="pep-fmt-sep"></div>
${ 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 = `
<div class="pep-toolbar">
<button class="pep-pin" title="Pin — keep this file when selecting others">Pin</button>
<button class="pep-undo" title="Undo (Ctrl+Z)" disabled>↩</button>
<button class="pep-redo" title="Redo (Ctrl+Y)" disabled>↪</button>
<button class="pep-save" title="Save (Ctrl+S)" disabled>Save</button>
<div class="pep-toolbar-sep"></div>
${ TOOLBAR_BUTTONS }
</div>
<div class="pep-block-menu" style="display:none">
<button class="pep-block-menu-close" title="Close">✕</button>
<div class="pep-block-list">
${ PAGE_BLOCK_REGISTRY.map( b => `
<div class="pep-block-item" data-block="${ b.name }">
<div class="pep-block-preview">${ b.preview ?? `<span class="pep-block-text-preview">${ b.name }</span>` }</div>
<div class="pep-block-name">${ b.name }</div>
</div>
` ).join( '' ) }
</div>
</div>
<div class="pep-empty">Open a .page file from the file tree</div>
<iframe class="pep-frame" sandbox="allow-same-origin" style="display:none"></iframe>
`;
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 '<!DOCTYPE html>\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<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;
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<void>
{
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 );