page-editor-panel: replace html-editor-panel with structured .page editor

Renames html-editor-panel → page-editor-panel and changes the handled
extension from .html/.htm to .page. The new editor introduces a structured
format (page-header / page-root / page-block / page-area / page-footer),
a block registry with Full Width and Two Columns templates, a two-mode
sidebar (Blocks / Areas), rich-text wrapSelection helper, auto-template
injection for empty files, sandbox="allow-same-origin" on the iframe,
and editor-style injection that is stripped before saving.

Adds a default-roject theme (dark BG, Barlow font, blue headings) embedded
as a <style> block in the page <head>, scoped to [data-theme="default-roject"]
on both <body> and <page-root>. Areas toolbar gains H1, H2, H3 buttons.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Rokojori 2026-07-24 21:07:32 +02:00
parent 4a72363aaf
commit 420c682408
10 changed files with 812 additions and 307 deletions

View File

@ -94,7 +94,7 @@ class EditorShell extends HTMLElement {
await Promise.all( [ await Promise.all( [
customElements.whenDefined( 'tab-container' ), customElements.whenDefined( 'tab-container' ),
customElements.whenDefined( 'file-tree-panel' ), customElements.whenDefined( 'file-tree-panel' ),
customElements.whenDefined( 'html-editor-panel' ), customElements.whenDefined( 'page-editor-panel' ),
customElements.whenDefined( 'code-panel' ), customElements.whenDefined( 'code-panel' ),
this._loadLayout(), this._loadLayout(),
] ); ] );
@ -110,11 +110,9 @@ class EditorShell extends HTMLElement {
return document.createElement('file-tree-panel'); return document.createElement('file-tree-panel');
}); });
centerTc?.addTab({ id: 'html-editor', label: 'Editor', panelType: 'html-editor' }, () => { centerTc?.addTab({ id: 'page-editor', label: 'Page', panelType: 'page-editor' }, () => {
return document.createElement('html-editor-panel'); return document.createElement('page-editor-panel');
}); });
Editor.get().openDocument('index.html');
} }
private setupMainHandles(): void { private setupMainHandles(): void {

View File

@ -157,7 +157,7 @@ class FileTreePanel extends HTMLElement {
const panelTypeMap: Record<string, { panelType: string; label: string }> = const panelTypeMap: Record<string, { panelType: string; label: string }> =
{ {
'html-editor-panel': { panelType: 'html-editor', label: 'HTML Editor' }, 'page-editor-panel': { panelType: 'page-editor', label: 'Page Editor' },
'code-panel': { panelType: 'code-panel', label: 'Code' }, 'code-panel': { panelType: 'code-panel', label: 'Code' },
'rojo-settings-panel': { panelType: 'rojo-settings', label: 'Rojo' }, 'rojo-settings-panel': { panelType: 'rojo-settings', label: 'Rojo' },
}; };

View File

@ -1,52 +0,0 @@
html-editor-panel {
display: flex;
flex-direction: column;
height: 100%;
background: #0f1117;
}
.hep-toolbar {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: #13151f;
border-bottom: 1px solid #2a2d3a;
flex-shrink: 0;
}
.hep-toolbar button {
padding: 3px 10px;
background: transparent;
border: 1px solid #2a2d3a;
border-radius: 4px;
color: #9ba4c7;
cursor: pointer;
font-size: 0.8rem;
font-family: inherit;
}
.hep-toolbar button:hover:not(:disabled) { background: #1a1d27; color: #e2e4ed; }
.hep-toolbar button:disabled { opacity: 0.3; cursor: default; }
.hep-save:not(:disabled) { border-color: #7c8cff; color: #7c8cff; }
.hep-save:not(:disabled):hover { background: #1e2235; }
.hep-pin.pinned { border-color: #f0a050; color: #f0a050; }
.hep-pin.pinned:hover { background: #1e1a10; }
.hep-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: #555;
font-size: 0.9rem;
}
.hep-frame {
flex: 1;
border: none;
background: #fff;
width: 100%;
}

View File

@ -1,241 +0,0 @@
import { Editor } from '../../editor/Editor.js';
import { ContextMenuDirectory, ContextMenuReadOnlyEntry } from '../context-menu/context-menu.js';
class HtmlEditorPanel 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;
connectedCallback(): void
{
if ( this._initialized ) return;
this._initialized = true;
this.className = 'html-editor-panel';
this.innerHTML = `
<div class="hep-toolbar">
<button class="hep-pin" title="Pin — keep this file when selecting others">Pin</button>
<button class="hep-undo" title="Undo (Ctrl+Z)" disabled></button>
<button class="hep-redo" title="Redo (Ctrl+Y)" disabled></button>
<button class="hep-save" title="Save (Ctrl+S)" disabled>Save</button>
<button class="hep-init" title="Insert Hello World template" disabled>Init</button>
</div>
<div class="hep-empty">Open an HTML file from the file tree</div>
<iframe class="hep-frame" style="display:none"></iframe>
`;
this._iframe = this.querySelector( 'iframe' );
this.querySelector( '.hep-pin' )!.addEventListener( 'click', () => this._togglePin() );
this.querySelector( '.hep-save' )!.addEventListener( 'click', () => this._save() );
this.querySelector( '.hep-undo' )!.addEventListener( 'click', () => this._undo() );
this.querySelector( '.hep-redo' )!.addEventListener( 'click', () => this._redo() );
this.querySelector( '.hep-init' )!.addEventListener( 'click', () => this._initTemplate() );
Editor.get().onDocumentOpened.addListener( ( e ) =>
{
if ( 'html-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;
}
}
_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._undoStack = [ content ];
this._redoStack = [];
this._updateTabLabel( path );
this.querySelector( '.hep-empty' )!.setAttribute( 'style', 'display:none' );
this._iframe!.style.display = '';
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!;
const pc = doc.querySelector( 'page-content' );
if ( pc )
{
( pc as HTMLElement ).contentEditable = 'true';
( pc as HTMLElement ).style.outline = 'none';
this._mutationObserver = new MutationObserver( () => this._onContentChanged() );
this._mutationObserver.observe( pc, { subtree: true, childList: true, characterData: true, attributes: true } );
}
};
}
_onContentChanged(): void
{
if ( ! this.currentPath || ! this._iframe?.contentDocument )
{
return;
}
const html = '<!DOCTYPE html>\n' + this._iframe.contentDocument.documentElement.outerHTML;
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 );
}
_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( '.hep-pin' )!.classList.toggle( 'pinned', this._pinned );
}
_initTemplate(): void
{
if ( ! this.currentPath ) return;
const template = [
'<!DOCTYPE html>',
'<html lang="en">',
'<head>',
' <meta charset="UTF-8">',
' <title>Hello World</title>',
'</head>',
'<body>',
' <page-content>',
' <h1>Hello World</h1>',
' <p>Welcome.</p>',
' </page-content>',
'</body>',
'</html>',
].join( '\n' );
this._undoStack.push( template );
this._redoStack = [];
Editor.get().markDirty( this.currentPath, template );
this._renderContent( template );
this._updateButtons( true );
}
async _save(): Promise<void>
{
if ( ! this.currentPath )
{
return;
}
await Editor.get().save( this.currentPath );
this._updateButtons( false );
}
addContextMenuEntries( dir: ContextMenuDirectory ): void
{
if ( this.currentPath )
{
dir.add( new ContextMenuReadOnlyEntry( dir, `Editing: ${this.currentPath}` ) );
}
else
{
dir.add( new ContextMenuReadOnlyEntry( dir, 'No document open' ) );
}
}
_updateButtons( dirty: boolean ): void
{
( this.querySelector( '.hep-save' ) as HTMLButtonElement ).disabled = ! dirty;
( this.querySelector( '.hep-undo' ) as HTMLButtonElement ).disabled = this._undoStack.length < 2;
( this.querySelector( '.hep-redo' ) as HTMLButtonElement ).disabled = this._redoStack.length === 0;
( this.querySelector( '.hep-init' ) as HTMLButtonElement ).disabled = ! this.currentPath;
}
}
customElements.define( 'html-editor-panel', HtmlEditorPanel );

View File

@ -0,0 +1,194 @@
page-editor-panel {
display: flex;
flex-direction: row;
height: 100%;
background: #0f1117;
}
/* ── Sidebar (mode switcher) ─────────────────────────────────────────────── */
.pep-sidebar {
display: flex;
flex-direction: column;
width: 36px;
background: #13151f;
border-right: 1px solid #2a2d3a;
flex-shrink: 0;
padding: 6px 0;
gap: 4px;
align-items: center;
}
.pep-mode-btn {
width: 28px;
height: 28px;
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
color: #555;
cursor: pointer;
font-size: 0.9rem;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.pep-mode-btn:hover { color: #9ba4c7; background: #1a1d27; }
.pep-mode-btn.active { color: #7c8cff; border-color: #7c8cff; }
/* ── Main column ─────────────────────────────────────────────────────────── */
.pep-main {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
/* ── Toolbar ─────────────────────────────────────────────────────────────── */
.pep-toolbar {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: #13151f;
border-bottom: 1px solid #2a2d3a;
flex-shrink: 0;
}
.pep-toolbar button {
padding: 3px 10px;
background: transparent;
border: 1px solid #2a2d3a;
border-radius: 4px;
color: #9ba4c7;
cursor: pointer;
font-size: 0.8rem;
font-family: inherit;
}
.pep-toolbar button:hover:not(:disabled) { background: #1a1d27; color: #e2e4ed; }
.pep-toolbar button:disabled { opacity: 0.3; cursor: default; }
.pep-save:not(:disabled) { border-color: #7c8cff; color: #7c8cff; }
.pep-save:not(:disabled):hover { background: #1e2235; }
.pep-pin.pinned { border-color: #f0a050; color: #f0a050; }
.pep-pin.pinned:hover { background: #1e1a10; }
/* ── Mode panels ─────────────────────────────────────────────────────────── */
.pep-mode-panel {
background: #13151f;
border-bottom: 1px solid #2a2d3a;
flex-shrink: 0;
padding: 6px 8px;
}
/* Blocks panel — horizontal scrollable block list */
.pep-block-list {
display: flex;
flex-direction: row;
gap: 8px;
overflow-x: auto;
padding-bottom: 2px;
}
.pep-block-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
cursor: pointer;
padding: 4px;
border: 1px solid #2a2d3a;
border-radius: 4px;
flex-shrink: 0;
}
.pep-block-item:hover { border-color: #7c8cff; background: #1a1d27; }
.pep-block-preview {
width: 88px;
height: 44px;
background: #1a1d27;
border-radius: 2px;
display: flex;
align-items: stretch;
padding: 5px;
box-sizing: border-box;
gap: 4px;
}
/* CSS layout sketch helpers used inside .pep-block-preview */
.pbp-full { display: flex; flex: 1; }
.pbp-two-col { display: flex; flex: 1; gap: 4px; }
.pbp-area { flex: 1; background: #3a3d4a; border-radius: 2px; }
.pep-block-text-preview {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.65rem;
color: #9ba4c7;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.pep-block-name {
font-size: 0.7rem;
color: #9ba4c7;
white-space: nowrap;
}
/* Areas panel — rich text formatting toolbar */
.pep-areas-panel {
display: flex;
flex-direction: row;
gap: 4px;
}
.pep-fmt-btn {
padding: 2px 8px;
background: transparent;
border: 1px solid #2a2d3a;
border-radius: 4px;
color: #9ba4c7;
cursor: pointer;
font-size: 0.8rem;
font-family: inherit;
min-width: 28px;
}
.pep-fmt-btn:hover { background: #1a1d27; color: #e2e4ed; }
.pep-fmt-sep {
width: 1px;
height: 18px;
background: #2a2d3a;
align-self: center;
margin: 0 2px;
}
/* ── Content area ────────────────────────────────────────────────────────── */
.pep-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: #555;
font-size: 0.9rem;
}
.pep-frame {
flex: 1;
border: none;
background: #fff;
width: 100%;
}

View File

@ -0,0 +1,488 @@
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 );

View File

@ -74,7 +74,7 @@ class TabContainer extends HTMLElement {
const addDir = new ContextMenuDirectory(root, 'Add'); const addDir = new ContextMenuDirectory(root, 'Add');
const panelTypes = [ const panelTypes = [
{ label: 'HTML Editor', panelType: 'html-editor', tag: 'html-editor-panel' }, { label: 'Page Editor', panelType: 'page-editor', tag: 'page-editor-panel' },
{ label: 'Code Editor', panelType: 'code-panel', tag: 'code-panel' }, { label: 'Code Editor', panelType: 'code-panel', tag: 'code-panel' },
{ label: 'File Tree', panelType: 'file-tree', tag: 'file-tree-panel' }, { label: 'File Tree', panelType: 'file-tree', tag: 'file-tree-panel' },
{ label: 'Rojo Chat', panelType: 'rojo-chat', tag: 'rojo-chat-panel' }, { label: 'Rojo Chat', panelType: 'rojo-chat', tag: 'rojo-chat-panel' },

View File

@ -9,8 +9,7 @@ export class FileEditorRegistry
static readonly DefaultEntries: RegistryEntry[] = static readonly DefaultEntries: RegistryEntry[] =
[ [
{ suffix: 'rojo', editor: 'RojoSettingsPanel' }, { suffix: 'rojo', editor: 'RojoSettingsPanel' },
{ suffix: 'html', editor: 'HTMLEditorPanel' }, { suffix: 'page', editor: 'PageEditorPanel' },
{ suffix: 'htm', editor: 'HTMLEditorPanel' },
{ suffix: 'js', editor: 'CodePanel' }, { suffix: 'js', editor: 'CodePanel' },
{ suffix: 'ts', editor: 'CodePanel' }, { suffix: 'ts', editor: 'CodePanel' },
{ suffix: 'css', editor: 'CodePanel' }, { suffix: 'css', editor: 'CodePanel' },
@ -37,7 +36,7 @@ export class FileEditorRegistry
static readonly EditorTagNames: Record<string, string> = static readonly EditorTagNames: Record<string, string> =
{ {
'RojoSettingsPanel': 'rojo-settings-panel', 'RojoSettingsPanel': 'rojo-settings-panel',
'HTMLEditorPanel': 'html-editor-panel', 'PageEditorPanel': 'page-editor-panel',
'CodePanel': 'code-panel', 'CodePanel': 'code-panel',
}; };

View File

@ -8,7 +8,7 @@
<link rel="stylesheet" href="/components/editor-shell/editor-shell.css"> <link rel="stylesheet" href="/components/editor-shell/editor-shell.css">
<link rel="stylesheet" href="/components/tab-container/tab-container.css"> <link rel="stylesheet" href="/components/tab-container/tab-container.css">
<link rel="stylesheet" href="/components/file-tree-panel/file-tree-panel.css"> <link rel="stylesheet" href="/components/file-tree-panel/file-tree-panel.css">
<link rel="stylesheet" href="/components/html-editor-panel/html-editor-panel.css"> <link rel="stylesheet" href="/components/page-editor-panel/page-editor-panel.css">
<link rel="stylesheet" href="/components/confirm-dialog/confirm-dialog.css"> <link rel="stylesheet" href="/components/confirm-dialog/confirm-dialog.css">
<link rel="stylesheet" href="/components/rojo-chat-panel/rojo-chat-panel.css"> <link rel="stylesheet" href="/components/rojo-chat-panel/rojo-chat-panel.css">
<link rel="stylesheet" href="/components/rojo-settings-panel/rojo-settings-panel.css"> <link rel="stylesheet" href="/components/rojo-settings-panel/rojo-settings-panel.css">
@ -25,7 +25,7 @@
<script type="module" src="/components/editor-shell/editor-shell.js"></script> <script type="module" src="/components/editor-shell/editor-shell.js"></script>
<script type="module" src="/components/tab-container/tab-container.js"></script> <script type="module" src="/components/tab-container/tab-container.js"></script>
<script type="module" src="/components/file-tree-panel/file-tree-panel.js"></script> <script type="module" src="/components/file-tree-panel/file-tree-panel.js"></script>
<script type="module" src="/components/html-editor-panel/html-editor-panel.js"></script> <script type="module" src="/components/page-editor-panel/page-editor-panel.js"></script>
<script type="module" src="/components/confirm-dialog/confirm-dialog.js"></script> <script type="module" src="/components/confirm-dialog/confirm-dialog.js"></script>
<script src="/vendor/markdown-it.min.js"></script> <script src="/vendor/markdown-it.min.js"></script>
<script type="module" src="/components/rojo-chat-panel/rojo-chat-panel.js"></script> <script type="module" src="/components/rojo-chat-panel/rojo-chat-panel.js"></script>

View File

@ -171,8 +171,8 @@
each section holds a <code>&lt;tab-container&gt;</code> with drag-and-drop tabs. each section holds a <code>&lt;tab-container&gt;</code> with drag-and-drop tabs.
The Left panel shows the file tree (create, rename, delete). The Left panel shows the file tree (create, rename, delete).
A <code>FileEditorRegistry</code> routes files to the correct panel by extension: A <code>FileEditorRegistry</code> routes files to the correct panel by extension:
HTML → <code>html-editor-panel</code> (iframe, contenteditable, MutationObserver, <code>.page</code><code>page-editor-panel</code> (structured page editor,
undo/redo, Ctrl+S save); all other text formats → <code>code-panel</code> see card below); all other text formats → <code>code-panel</code>
(CodeMirror 5, syntax highlighting, dark theme). Godot file types (CodeMirror 5, syntax highlighting, dark theme). Godot file types
(<code>.gd</code>, <code>.gdshader</code>, <code>.gdshaderinc</code>, (<code>.gd</code>, <code>.gdshader</code>, <code>.gdshaderinc</code>,
<code>.tscn</code>, <code>.tres</code>, <code>.res</code>) are pre-registered. <code>.tscn</code>, <code>.tres</code>, <code>.res</code>) are pre-registered.
@ -187,6 +187,125 @@
</p> </p>
</div> </div>
<div class="card">
<h3>Page Editor Panel (<code>page-editor-panel</code>)</h3>
<p>
A structured authoring editor for <code>.page</code> files — Roject's custom
documentation format. A <code>.page</code> file is a full HTML document whose
<code>&lt;body&gt;</code> must follow a fixed structure:
</p>
<pre style="margin:0.75rem 0;padding:0.75rem;background:#0a0c13;border-radius:6px;font-size:0.8rem;line-height:1.7;overflow-x:auto">&lt;page-header&gt;&lt;/page-header&gt;
&lt;page-root&gt;
&lt;page-block&gt;
&lt;page-area&gt;&lt;/page-area&gt;
&lt;/page-block&gt;
&lt;/page-root&gt;
&lt;page-footer&gt;&lt;/page-footer&gt;</pre>
<p>
The <code>&lt;head&gt;</code> may contain links to CSS/JS asset bundles;
these will load inside the editor iframe.
Pages that do not follow the required body structure fall back to
<code>code-panel</code> for plain-text editing.
</p>
<h4 style="margin-top:1rem">Validation</h4>
<p>
Format validation is handled by a single replaceable function
(<code>validatePageFormat(doc): boolean</code>) in
<code>source/components/page-editor-panel/page-editor-panel.ts</code>.
<strong>Currently a placeholder that always returns <code>true</code></strong>
— swap for real DOM inspection when the format is stable.
Required structure when implemented: exactly one <code>&lt;page-header&gt;</code>,
one <code>&lt;page-root&gt;</code>, and one <code>&lt;page-footer&gt;</code>
as direct children of <code>&lt;body&gt;</code>; no other elements at that level.
<code>&lt;page-root&gt;</code> may be empty or contain any number of
<code>&lt;page-block&gt;</code> children.
</p>
<h4 style="margin-top:1rem">Auto-template for new/empty files</h4>
<p>
When a <code>.page</code> file is opened and its content is empty (or all
whitespace), format validation is bypassed and the standard template is injected
automatically. The document is marked dirty so the user must save to persist the
initial structure. This is the intended flow for newly created <code>.page</code>
files — no "Init" button exists.
</p>
<h4 style="margin-top:1rem">JS safety — iframe sandbox</h4>
<p>
The editor iframe uses <code>sandbox="allow-same-origin"</code>, which blocks
script execution inside the rendered page. This is intentional: user-authored
<code>&lt;script&gt;</code> tags must not run in the editor context.
To change sandboxing behaviour, adjust the <code>sandbox</code> attribute on
<code>.pep-frame</code> in <code>page-editor-panel.ts</code>.
</p>
<h4 style="margin-top:1rem">Editor CSS injection</h4>
<p>
Block and area layout styles (<code>page-block</code>, <code>page-area</code>,
etc.) are injected into the live iframe <code>&lt;head&gt;</code> after load via
a <code>&lt;style id="pep-editor-injected"&gt;</code> element. This element is
never part of <code>srcdoc</code> and is stripped from the captured HTML before
saving, keeping the saved file clean. To update the editor-side layout styles,
edit the <code>PEP_EDITOR_STYLES</code> constant in
<code>page-editor-panel.ts</code>.
</p>
<h4 style="margin-top:1rem">Block registry</h4>
<p>
Available block templates are defined in a static table
(<code>PAGE_BLOCK_REGISTRY</code>) in
<code>source/components/page-editor-panel/page-editor-panel.ts</code>.
Each entry has a <code>name</code>, optional CSS-based <code>preview</code>
markup (a small layout sketch), and an <code>html</code> snippet inserted into
<code>&lt;page-root&gt;</code> when the block is added. Blocks without a preview
show their name as a text label.
</p>
<p style="margin-top:0.5rem">Current standard blocks:</p>
<ul style="line-height:1.9;margin-top:0.5rem">
<li><strong>Full Width</strong> — one <code>&lt;page-area&gt;</code> spanning
the full container width.</li>
<li><strong>Two Columns</strong> — two equal <code>&lt;page-area&gt;</code>
elements side by side on landscape; stacked (left above right) on portrait
via a CSS media query.</li>
</ul>
<h4 style="margin-top:1rem">Sidebar modes</h4>
<p>
Two icon buttons on the left edge of the panel switch between modes:
</p>
<ul style="line-height:1.9;margin-top:0.5rem">
<li><strong>Blocks mode</strong> — a horizontal scrollable list of block
templates. Each entry shows a small CSS layout preview (or text name) above
the block name. Clicking a block appends it to <code>&lt;page-root&gt;</code>.</li>
<li><strong>Areas mode</strong> — a formatting toolbar that acts on the current
selection inside a <code>&lt;page-area&gt;</code>. See rich text below.</li>
</ul>
<h4 style="margin-top:1rem">Rich text editing in areas</h4>
<p>
Each <code>&lt;page-area&gt;</code> inside the iframe is
<code>contenteditable</code>. Formatting is applied via the Selection / Range
API — <strong>no <code>execCommand</code></strong>. The shared helper
<code>wrapSelection(range, tagName, attributes?)</code> in
<code>page-editor-panel.ts</code> uses <code>Range.extractContents()</code>
to pull out the selected fragment, wraps it in the target element, and
re-inserts via <code>Range.insertNode()</code>. This handles both fully-contained
elements (wrapped outside) and boundary intersections (text nodes split
automatically by the Range API, wrapped inside the outer element).
Semantic tags are preferred: <code>&lt;b&gt;</code>, <code>&lt;i&gt;</code>,
<code>&lt;u&gt;</code>; <code>&lt;span style="..."&gt;</code> for colour /
font-family.
</p>
<p style="margin-top:0.5rem">
<strong>Known limitation (future work):</strong> after wrapping, adjacent
identical elements (e.g. two consecutive <code>&lt;b&gt;</code> tags) are not
merged. A cleanup pass is not implemented yet.
</p>
</div>
<div class="card"> <div class="card">
<h3>CodeMirror syntax highlighting</h3> <h3>CodeMirror syntax highlighting</h3>
<p> <p>