489 lines
17 KiB
TypeScript
489 lines
17 KiB
TypeScript
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 );
|