From 420c682408e5fb8942a217c28f68123fb5836a43 Mon Sep 17 00:00:00 2001 From: Rokojori Date: Fri, 24 Jul 2026 21:07:32 +0200 Subject: [PATCH] page-editor-panel: replace html-editor-panel with structured .page editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 + + + + + + + + + + + + +`; + +// ── Validation ──────────────────────────────────────────────────────────────── +// Placeholder — always returns true. +// TODO: implement real validation: +// - Exactly one , one , one as direct +// children of ; no other elements at that level. +// - may be empty or contain any number of 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 ): 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 = ` +
+ + +
+
+
+ + + + +
+
+
+ ${ PAGE_BLOCK_REGISTRY.map( b => ` +
+
${ b.preview ?? `${ b.name }` }
+
${ b.name }
+
+ ` ).join( '' ) } +
+
+ +
Open a .page file from the file tree
+ +
+ `; + + 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 = '\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 ): 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 + { + 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 ); diff --git a/source/components/tab-container/tab-container.ts b/source/components/tab-container/tab-container.ts index 592d9d2..bdfc689 100644 --- a/source/components/tab-container/tab-container.ts +++ b/source/components/tab-container/tab-container.ts @@ -74,7 +74,7 @@ class TabContainer extends HTMLElement { const addDir = new ContextMenuDirectory(root, 'Add'); 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: 'File Tree', panelType: 'file-tree', tag: 'file-tree-panel' }, { label: 'Rojo Chat', panelType: 'rojo-chat', tag: 'rojo-chat-panel' }, diff --git a/source/editor/FileEditorRegistry.ts b/source/editor/FileEditorRegistry.ts index 0ef1667..d14edd3 100644 --- a/source/editor/FileEditorRegistry.ts +++ b/source/editor/FileEditorRegistry.ts @@ -9,8 +9,7 @@ export class FileEditorRegistry static readonly DefaultEntries: RegistryEntry[] = [ { suffix: 'rojo', editor: 'RojoSettingsPanel' }, - { suffix: 'html', editor: 'HTMLEditorPanel' }, - { suffix: 'htm', editor: 'HTMLEditorPanel' }, + { suffix: 'page', editor: 'PageEditorPanel' }, { suffix: 'js', editor: 'CodePanel' }, { suffix: 'ts', editor: 'CodePanel' }, { suffix: 'css', editor: 'CodePanel' }, @@ -37,7 +36,7 @@ export class FileEditorRegistry static readonly EditorTagNames: Record = { 'RojoSettingsPanel': 'rojo-settings-panel', - 'HTMLEditorPanel': 'html-editor-panel', + 'PageEditorPanel': 'page-editor-panel', 'CodePanel': 'code-panel', }; diff --git a/source/pages/editor.html b/source/pages/editor.html index c6170e1..1461e2d 100644 --- a/source/pages/editor.html +++ b/source/pages/editor.html @@ -8,7 +8,7 @@ - + @@ -25,7 +25,7 @@ - + diff --git a/workspace/outline/index.html b/workspace/outline/index.html index 3752836..3a43860 100644 --- a/workspace/outline/index.html +++ b/workspace/outline/index.html @@ -171,8 +171,8 @@ each section holds a <tab-container> with drag-and-drop tabs. The Left panel shows the file tree (create, rename, delete). A FileEditorRegistry routes files to the correct panel by extension: - HTML → html-editor-panel (iframe, contenteditable, MutationObserver, - undo/redo, Ctrl+S save); all other text formats → code-panel + .pagepage-editor-panel (structured page editor, + see card below); all other text formats → code-panel (CodeMirror 5, syntax highlighting, dark theme). Godot file types (.gd, .gdshader, .gdshaderinc, .tscn, .tres, .res) are pre-registered. @@ -187,6 +187,125 @@

+
+

Page Editor Panel (page-editor-panel)

+

+ A structured authoring editor for .page files — Roject's custom + documentation format. A .page file is a full HTML document whose + <body> must follow a fixed structure: +

+
<page-header></page-header>
+
+<page-root>
+  <page-block>
+    <page-area></page-area>
+  </page-block>
+</page-root>
+
+<page-footer></page-footer>
+

+ The <head> 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-panel for plain-text editing. +

+ +

Validation

+

+ Format validation is handled by a single replaceable function + (validatePageFormat(doc): boolean) in + source/components/page-editor-panel/page-editor-panel.ts. + Currently a placeholder that always returns true + — swap for real DOM inspection when the format is stable. + Required structure when implemented: exactly one <page-header>, + one <page-root>, and 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. +

+ +

Auto-template for new/empty files

+

+ When a .page 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 .page + files — no "Init" button exists. +

+ +

JS safety — iframe sandbox

+

+ The editor iframe uses sandbox="allow-same-origin", which blocks + script execution inside the rendered page. This is intentional: user-authored + <script> tags must not run in the editor context. + To change sandboxing behaviour, adjust the sandbox attribute on + .pep-frame in page-editor-panel.ts. +

+ +

Editor CSS injection

+

+ Block and area layout styles (page-block, page-area, + etc.) are injected into the live iframe <head> after load via + a <style id="pep-editor-injected"> element. This element is + never part of srcdoc and is stripped from the captured HTML before + saving, keeping the saved file clean. To update the editor-side layout styles, + edit the PEP_EDITOR_STYLES constant in + page-editor-panel.ts. +

+ +

Block registry

+

+ Available block templates are defined in a static table + (PAGE_BLOCK_REGISTRY) in + source/components/page-editor-panel/page-editor-panel.ts. + Each entry has a name, optional CSS-based preview + markup (a small layout sketch), and an html snippet inserted into + <page-root> when the block is added. Blocks without a preview + show their name as a text label. +

+

Current standard blocks:

+
    +
  • Full Width — one <page-area> spanning + the full container width.
  • +
  • Two Columns — two equal <page-area> + elements side by side on landscape; stacked (left above right) on portrait + via a CSS media query.
  • +
+ +

Sidebar modes

+

+ Two icon buttons on the left edge of the panel switch between modes: +

+
    +
  • Blocks mode — 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 <page-root>.
  • +
  • Areas mode — a formatting toolbar that acts on the current + selection inside a <page-area>. See rich text below.
  • +
+ +

Rich text editing in areas

+

+ Each <page-area> inside the iframe is + contenteditable. Formatting is applied via the Selection / Range + API — no execCommand. The shared helper + wrapSelection(range, tagName, attributes?) in + page-editor-panel.ts uses Range.extractContents() + to pull out the selected fragment, wraps it in the target element, and + re-inserts via Range.insertNode(). 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: <b>, <i>, + <u>; <span style="..."> for colour / + font-family. +

+

+ Known limitation (future work): after wrapping, adjacent + identical elements (e.g. two consecutive <b> tags) are not + merged. A cleanup pass is not implemented yet. +

+
+

CodeMirror syntax highlighting