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

489 lines
17 KiB
TypeScript
Raw Normal View History

import { Editor } from '../../editor/Editor.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 <page-root> when the block is added
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">.
// Never written to disk — stripped from captured HTML before saving by temporarily
// removing the element, capturing outerHTML, then re-appending.
// To update editor-side layout styles, edit PEP_EDITOR_STYLES.
//
// Sandbox: the iframe uses sandbox="allow-same-origin" to block script execution.
// To adjust sandboxing behaviour, change the sandbox attribute on .pep-frame.
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;
}
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; }
}
`;
// ── Default theme CSS ─────────────────────────────────────────────────────────
// Scoped under [data-theme="default-roject"] so rules never leak outside page-root.
// Embedded as a <style> tag in the page <head> for new files.
// Future: replace with a <link> to styles.rokojori.com when themes are hosted there.
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;
}`;
// ── Standard template for new / empty .page files ────────────────────────────
// Injected automatically when a .page file is opened with empty (or whitespace-only)
// content. The document is marked dirty immediately — user must save to persist.
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:
// - Exactly one <page-header>, one <page-root>, one <page-footer> as direct
// children of <body>; no other elements at that level.
// - <page-root> may be empty or contain any number of <page-block> 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<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;
}
// ── Component ─────────────────────────────────────────────────────────────────
class PageEditorPanel extends HTMLElement
{
currentPath: string | null = null;
_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 = `
<div class="pep-sidebar">
<button class="pep-mode-btn pep-mode-blocks active" title="Blocks"></button>
<button class="pep-mode-btn pep-mode-areas" title="Areas">T</button>
</div>
<div class="pep-main">
<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>
<div class="pep-mode-panel pep-blocks-panel">
<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-mode-panel pep-areas-panel" style="display:none">
<button class="pep-fmt-btn" data-tag="b" title="Bold"><b>B</b></button>
<button class="pep-fmt-btn" data-tag="i" title="Italic"><i>I</i></button>
<button class="pep-fmt-btn" data-tag="u" title="Underline"><u>U</u></button>
<div class="pep-fmt-sep"></div>
<button class="pep-fmt-btn" data-tag="h1" title="Heading 1">H1</button>
<button class="pep-fmt-btn" data-tag="h2" title="Heading 2">H2</button>
<button class="pep-fmt-btn" data-tag="h3" title="Heading 3">H3</button>
</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>
</div>
`;
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 = '<!DOCTYPE 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<string, string> ): 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<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' ) );
}
_updateButtons( dirty: boolean ): void
{
( 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 );