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:
parent
4a72363aaf
commit
420c682408
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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' },
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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%;
|
|
||||||
}
|
|
||||||
|
|
@ -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 );
|
|
||||||
|
|
@ -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%;
|
||||||
|
}
|
||||||
|
|
@ -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 );
|
||||||
|
|
@ -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' },
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -171,8 +171,8 @@
|
||||||
each section holds a <code><tab-container></code> with drag-and-drop tabs.
|
each section holds a <code><tab-container></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><body></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"><page-header></page-header>
|
||||||
|
|
||||||
|
<page-root>
|
||||||
|
<page-block>
|
||||||
|
<page-area></page-area>
|
||||||
|
</page-block>
|
||||||
|
</page-root>
|
||||||
|
|
||||||
|
<page-footer></page-footer></pre>
|
||||||
|
<p>
|
||||||
|
The <code><head></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><page-header></code>,
|
||||||
|
one <code><page-root></code>, and one <code><page-footer></code>
|
||||||
|
as direct children of <code><body></code>; no other elements at that level.
|
||||||
|
<code><page-root></code> may be empty or contain any number of
|
||||||
|
<code><page-block></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><script></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><head></code> after load via
|
||||||
|
a <code><style id="pep-editor-injected"></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><page-root></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><page-area></code> spanning
|
||||||
|
the full container width.</li>
|
||||||
|
<li><strong>Two Columns</strong> — two equal <code><page-area></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><page-root></code>.</li>
|
||||||
|
<li><strong>Areas mode</strong> — a formatting toolbar that acts on the current
|
||||||
|
selection inside a <code><page-area></code>. See rich text below.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4 style="margin-top:1rem">Rich text editing in areas</h4>
|
||||||
|
<p>
|
||||||
|
Each <code><page-area></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><b></code>, <code><i></code>,
|
||||||
|
<code><u></code>; <code><span style="..."></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><b></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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue