diff --git a/source/components/editor-shell/editor-shell.ts b/source/components/editor-shell/editor-shell.ts index 1acb0fc..7c80097 100644 --- a/source/components/editor-shell/editor-shell.ts +++ b/source/components/editor-shell/editor-shell.ts @@ -94,7 +94,7 @@ class EditorShell extends HTMLElement { await Promise.all( [ customElements.whenDefined( 'tab-container' ), customElements.whenDefined( 'file-tree-panel' ), - customElements.whenDefined( 'html-editor-panel' ), + customElements.whenDefined( 'page-editor-panel' ), customElements.whenDefined( 'code-panel' ), this._loadLayout(), ] ); @@ -110,11 +110,9 @@ class EditorShell extends HTMLElement { return document.createElement('file-tree-panel'); }); - centerTc?.addTab({ id: 'html-editor', label: 'Editor', panelType: 'html-editor' }, () => { - return document.createElement('html-editor-panel'); + centerTc?.addTab({ id: 'page-editor', label: 'Page', panelType: 'page-editor' }, () => { + return document.createElement('page-editor-panel'); }); - - Editor.get().openDocument('index.html'); } private setupMainHandles(): void { diff --git a/source/components/file-tree-panel/file-tree-panel.ts b/source/components/file-tree-panel/file-tree-panel.ts index 870c114..a19f57b 100644 --- a/source/components/file-tree-panel/file-tree-panel.ts +++ b/source/components/file-tree-panel/file-tree-panel.ts @@ -157,7 +157,7 @@ class FileTreePanel extends HTMLElement { const panelTypeMap: Record = { - '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' }, 'rojo-settings-panel': { panelType: 'rojo-settings', label: 'Rojo' }, }; diff --git a/source/components/html-editor-panel/html-editor-panel.css b/source/components/html-editor-panel/html-editor-panel.css deleted file mode 100644 index 132545d..0000000 --- a/source/components/html-editor-panel/html-editor-panel.css +++ /dev/null @@ -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%; -} diff --git a/source/components/html-editor-panel/html-editor-panel.ts b/source/components/html-editor-panel/html-editor-panel.ts deleted file mode 100644 index d1d4d35..0000000 --- a/source/components/html-editor-panel/html-editor-panel.ts +++ /dev/null @@ -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 = ` -
- - - - - -
-
Open an HTML file from the file tree
- - `; - - 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 = '\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 = [ - '', - '', - '', - ' ', - ' Hello World', - '', - '', - ' ', - '

Hello World

', - '

Welcome.

', - '
', - '', - '', - ].join( '\n' ); - - this._undoStack.push( template ); - this._redoStack = []; - Editor.get().markDirty( this.currentPath, template ); - this._renderContent( template ); - this._updateButtons( true ); - } - - async _save(): Promise - { - 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 ); diff --git a/source/components/page-editor-panel/page-editor-panel.css b/source/components/page-editor-panel/page-editor-panel.css new file mode 100644 index 0000000..bfe05d8 --- /dev/null +++ b/source/components/page-editor-panel/page-editor-panel.css @@ -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%; +} diff --git a/source/components/page-editor-panel/page-editor-panel.ts b/source/components/page-editor-panel/page-editor-panel.ts new file mode 100644 index 0000000..5e68806 --- /dev/null +++ b/source/components/page-editor-panel/page-editor-panel.ts @@ -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 when the block is added + +interface PageBlockEntry +{ + name: string; + preview?: string; + html: string; +} + +const PAGE_BLOCK_REGISTRY: PageBlockEntry[] = +[ + { + name: 'Full Width', + preview: '
', + html: + '\n' + + ' \n' + + '', + }, + { + name: 'Two Columns', + preview: '
', + html: + '\n' + + ' \n' + + ' \n' + + '', + }, +]; + +// ── Editor-injected styles ──────────────────────────────────────────────────── +// Injected into the iframe after load via + + + + + + + + + + + + +`; + +// ── 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