838 lines
26 KiB
TypeScript
838 lines
26 KiB
TypeScript
import { Editor } from '../../editor/Editor.js';
|
|
import { EditorPanelDefinition, FileEditorPanelDefinition } from '../../editor/editor-panel.js';
|
|
import { ContextMenuDirectory, ContextMenuReadOnlyEntry } from '../context-menu/context-menu.js';
|
|
import { showInputDialog } from '../confirm-dialog/confirm-dialog.js';
|
|
|
|
// ── Block registry ────────────────────────────────────────────────────────────
|
|
|
|
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">.
|
|
// Stripped from captured HTML before saving via document.cloneNode — never on disk.
|
|
|
|
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;
|
|
position: relative;
|
|
}
|
|
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; }
|
|
}
|
|
.pep-insert-trigger {
|
|
display: block;
|
|
height: 10px;
|
|
cursor: grab;
|
|
}
|
|
.pep-insert-trigger:active { cursor: grabbing; }
|
|
.pep-insert-trigger::before {
|
|
content: '';
|
|
display: block;
|
|
height: 5px;
|
|
margin-top: 2.5px;
|
|
border-radius: 2px;
|
|
background: transparent;
|
|
transition: background 0.1s;
|
|
}
|
|
.pep-insert-trigger:hover::before {
|
|
background: rgba(124, 140, 255, 0.3);
|
|
}
|
|
.pep-drop-target::before {
|
|
background: rgba(124, 140, 255, 0.75) !important;
|
|
}
|
|
.pep-delete-btn {
|
|
position: absolute;
|
|
top: 4px;
|
|
right: 4px;
|
|
background: #1a1d27;
|
|
border: 1px solid #3a3d4a;
|
|
color: #9ba4c7;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 0.7rem;
|
|
padding: 2px 6px;
|
|
display: none;
|
|
z-index: 10;
|
|
line-height: 1.4;
|
|
}
|
|
.pep-delete-btn:hover { background: #2a1d27; border-color: #ff6060; color: #ff8080; }
|
|
@media (hover: hover) {
|
|
page-block:hover .pep-delete-btn { display: block; }
|
|
}
|
|
page-block.pep-block-active .pep-delete-btn { display: block; }
|
|
marked-text {
|
|
font-weight: 700;
|
|
color: hsl(190, 80%, 90%);
|
|
}
|
|
a { color: hsl(200, 80%, 70%); }
|
|
`;
|
|
|
|
// ── Default theme CSS ─────────────────────────────────────────────────────────
|
|
|
|
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;
|
|
}
|
|
|
|
[data-theme="default-roject"] marked-text {
|
|
font-weight: 700;
|
|
color: hsl(190, 80%, 90%);
|
|
}
|
|
|
|
[data-theme="default-roject"] a {
|
|
color: hsl(200, 80%, 70%);
|
|
}`;
|
|
|
|
// ── Standard template for new / empty .page files ────────────────────────────
|
|
|
|
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 when format is stable.
|
|
|
|
function validatePageFormat( _doc: Document ): boolean
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// ── Rich-text helper ──────────────────────────────────────────────────────────
|
|
// 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;
|
|
}
|
|
|
|
// ── Toolbar HTML ──────────────────────────────────────────────────────────────
|
|
|
|
function iconBtn( tag: string, icon: string, label: string, title: string, extraClass = '' ): string
|
|
{
|
|
const dataTag = tag ? `data-tag="${ tag }"` : '';
|
|
return `<button class="pep-fmt-btn ${ extraClass }" ${ dataTag } title="${ title }">
|
|
<img class="pep-btn-icon" src="/components/page-editor-panel/icons/${ icon }.svg" width="24" height="24" alt="">
|
|
<span class="pep-btn-label">${ label }</span>
|
|
</button>`;
|
|
}
|
|
|
|
const TOOLBAR_BUTTONS = `
|
|
${ iconBtn( '', 'block', 'BLOCK', 'Insert Block', 'pep-btn-block' ) }
|
|
<div class="pep-fmt-sep"></div>
|
|
${ iconBtn( 'h1', 'h1', 'H1', 'Heading 1' ) }
|
|
${ iconBtn( 'h2', 'h2', 'H2', 'Heading 2' ) }
|
|
${ iconBtn( 'h3', 'h3', 'H3', 'Heading 3' ) }
|
|
<div class="pep-fmt-sep"></div>
|
|
${ iconBtn( 'link', 'link', 'LINK', 'Link' ) }
|
|
${ iconBtn( 'marked-text', 'mark', 'MARK', 'Mark' ) }
|
|
<div class="pep-fmt-sep"></div>
|
|
${ iconBtn( 'b', 'bold', 'BOLD', 'Bold' ) }
|
|
${ iconBtn( 'i', 'italic', 'ITALIC', 'Italic' ) }
|
|
${ iconBtn( 'u', 'under', 'UNDER', 'Underline' ) }
|
|
`;
|
|
|
|
// ── Component ─────────────────────────────────────────────────────────────────
|
|
|
|
class PageEditorPanel extends HTMLElement
|
|
{
|
|
__interfaces__ = [ EditorPanelDefinition.type, FileEditorPanelDefinition.type ];
|
|
currentPath: string | null = null;
|
|
_dirty = false;
|
|
_pinned: boolean = false;
|
|
_iframe: HTMLIFrameElement | null = null;
|
|
_undoStack: string[] = [];
|
|
_redoStack: string[] = [];
|
|
_mutationObserver: MutationObserver | null = null;
|
|
_initialized = false;
|
|
_needsRestore = false;
|
|
_blockInsertTarget: Element | null = null;
|
|
_pageRootClickHandler: (( e: Event ) => void) | null = null;
|
|
_bodyClickHandler: (() => void) | null = null;
|
|
_draggedBlock: Element | null = null;
|
|
_dropTrigger: Element | null = null;
|
|
_dragBar: HTMLElement | null = null;
|
|
_iframeTriggers: Element[] = [];
|
|
|
|
connectedCallback(): void
|
|
{
|
|
if ( this._initialized ) return;
|
|
this._initialized = true;
|
|
|
|
this.className = 'page-editor-panel';
|
|
this.innerHTML = `
|
|
<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 class="pep-toolbar-sep"></div>
|
|
${ TOOLBAR_BUTTONS }
|
|
</div>
|
|
<div class="pep-block-menu" style="display:none">
|
|
<button class="pep-block-menu-close" title="Close">✕</button>
|
|
<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-empty">Open a .page file from the file tree</div>
|
|
<iframe class="pep-frame" sandbox="allow-same-origin" style="display:none"></iframe>
|
|
`;
|
|
|
|
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-btn-block' )!.addEventListener( 'click', ( e ) =>
|
|
{
|
|
e.stopPropagation();
|
|
this._blockInsertTarget = null;
|
|
const rect = ( e.currentTarget as HTMLElement ).getBoundingClientRect();
|
|
this._openBlockMenu( rect );
|
|
} );
|
|
|
|
this.querySelector( '.pep-block-menu-close' )!.addEventListener( 'click', () => this._closeBlockMenu() );
|
|
this.querySelector( '.pep-block-menu' )!.addEventListener( 'click', ( e ) => e.stopPropagation() );
|
|
|
|
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._closeBlockMenu();
|
|
} );
|
|
} );
|
|
|
|
this.querySelectorAll( '.pep-fmt-btn' ).forEach( btn =>
|
|
{
|
|
// Prevent focus leaving the iframe (which would clear the selection) on click
|
|
btn.addEventListener( 'mousedown', ( e ) => e.preventDefault() );
|
|
} );
|
|
|
|
this.querySelectorAll( '.pep-fmt-btn[data-tag]' ).forEach( btn =>
|
|
{
|
|
btn.addEventListener( 'click', () => this._applyFormat( ( btn as HTMLElement ).dataset.tag! ) );
|
|
} );
|
|
|
|
document.addEventListener( 'click', () => this._closeBlockMenu() );
|
|
|
|
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;
|
|
}
|
|
|
|
_openBlockMenu( anchorRect: DOMRect ): void
|
|
{
|
|
const menu = this.querySelector( '.pep-block-menu' ) as HTMLElement;
|
|
const componentRect = this.getBoundingClientRect();
|
|
|
|
// Show offscreen first to measure
|
|
menu.style.top = '-9999px';
|
|
menu.style.left = '-9999px';
|
|
menu.style.display = '';
|
|
|
|
const menuHeight = menu.offsetHeight;
|
|
const menuWidth = menu.offsetWidth;
|
|
|
|
let top = anchorRect.bottom - componentRect.top + 4;
|
|
let left = anchorRect.left - componentRect.left;
|
|
|
|
if ( top + menuHeight > componentRect.height - 8 )
|
|
{
|
|
top = anchorRect.top - componentRect.top - menuHeight - 4;
|
|
}
|
|
|
|
left = Math.max( 8, Math.min( left, componentRect.width - menuWidth - 8 ) );
|
|
top = Math.max( 8, top );
|
|
|
|
menu.style.top = top + 'px';
|
|
menu.style.left = left + 'px';
|
|
}
|
|
|
|
_openBlockMenuNearIframeElement( el: Element ): void
|
|
{
|
|
const iframeRect = this._iframe!.getBoundingClientRect();
|
|
const elRect = el.getBoundingClientRect();
|
|
const anchorRect = new DOMRect(
|
|
iframeRect.left + elRect.left,
|
|
iframeRect.top + elRect.top,
|
|
elRect.width,
|
|
elRect.height
|
|
);
|
|
this._openBlockMenu( anchorRect );
|
|
}
|
|
|
|
_closeBlockMenu(): void
|
|
{
|
|
( this.querySelector( '.pep-block-menu' ) as HTMLElement ).style.display = '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' ) as HTMLElement ).style.display = 'none';
|
|
this._iframe!.style.display = '';
|
|
|
|
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._refreshIframeOverlays( doc, pageRoot );
|
|
this._mutationObserver.observe( pageRoot, { subtree: true, childList: true, characterData: true, attributes: true } );
|
|
}
|
|
};
|
|
}
|
|
|
|
_refreshIframeOverlays( doc: Document, pageRoot: Element ): void
|
|
{
|
|
this._mutationObserver?.disconnect();
|
|
|
|
doc.querySelectorAll( '.pep-insert-trigger, .pep-delete-btn' ).forEach( el => el.remove() );
|
|
|
|
const blocks = Array.from( pageRoot.querySelectorAll( ':scope > page-block' ) );
|
|
|
|
for ( const block of blocks )
|
|
{
|
|
const trigger = doc.createElement( 'div' );
|
|
trigger.className = 'pep-insert-trigger';
|
|
pageRoot.insertBefore( trigger, block );
|
|
trigger.addEventListener( 'mousedown', ( e ) =>
|
|
{
|
|
e.preventDefault();
|
|
this._startDragTracking( e as MouseEvent, block, trigger, doc, pageRoot );
|
|
} );
|
|
|
|
const delBtn = doc.createElement( 'button' );
|
|
delBtn.className = 'pep-delete-btn';
|
|
delBtn.textContent = '✕';
|
|
delBtn.title = 'Delete block';
|
|
block.appendChild( delBtn );
|
|
delBtn.addEventListener( 'click', ( e ) =>
|
|
{
|
|
e.stopPropagation();
|
|
this._deleteBlock( block as HTMLElement, doc, pageRoot );
|
|
} );
|
|
}
|
|
|
|
const endTrigger = doc.createElement( 'div' );
|
|
endTrigger.className = 'pep-insert-trigger';
|
|
pageRoot.appendChild( endTrigger );
|
|
endTrigger.addEventListener( 'mousedown', ( e ) =>
|
|
{
|
|
e.preventDefault();
|
|
this._startDragTracking( e as MouseEvent, null, endTrigger, doc, pageRoot );
|
|
} );
|
|
|
|
this._iframeTriggers = Array.from( pageRoot.querySelectorAll( '.pep-insert-trigger' ) );
|
|
|
|
// Portrait block-tap: event delegation on pageRoot
|
|
if ( this._pageRootClickHandler )
|
|
{
|
|
pageRoot.removeEventListener( 'click', this._pageRootClickHandler );
|
|
}
|
|
this._pageRootClickHandler = ( e: Event ) =>
|
|
{
|
|
const block = ( e.target as Element ).closest( 'page-block' );
|
|
if ( ! block ) return;
|
|
e.stopPropagation();
|
|
doc.querySelectorAll( 'page-block.pep-block-active' ).forEach( b => b.classList.remove( 'pep-block-active' ) );
|
|
block.classList.add( 'pep-block-active' );
|
|
};
|
|
pageRoot.addEventListener( 'click', this._pageRootClickHandler );
|
|
|
|
// Tap outside all blocks dismisses active selection
|
|
if ( this._bodyClickHandler )
|
|
{
|
|
doc.body.removeEventListener( 'click', this._bodyClickHandler );
|
|
}
|
|
this._bodyClickHandler = () =>
|
|
{
|
|
doc.querySelectorAll( 'page-block.pep-block-active' ).forEach( b => b.classList.remove( 'pep-block-active' ) );
|
|
};
|
|
doc.body.addEventListener( 'click', this._bodyClickHandler );
|
|
|
|
if ( this._mutationObserver )
|
|
{
|
|
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 clone = doc.cloneNode( true ) as Document;
|
|
clone.getElementById( 'pep-editor-injected' )?.remove();
|
|
clone.querySelectorAll( '.pep-insert-trigger, .pep-delete-btn' ).forEach( el => el.remove() );
|
|
clone.querySelectorAll( '.pep-block-active' ).forEach( el => el.classList.remove( 'pep-block-active' ) );
|
|
return '<!DOCTYPE html>\n' + clone.documentElement.outerHTML;
|
|
}
|
|
|
|
_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;
|
|
|
|
if ( this._blockInsertTarget )
|
|
{
|
|
pageRoot.insertBefore( child, this._blockInsertTarget );
|
|
}
|
|
else
|
|
{
|
|
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';
|
|
} );
|
|
}
|
|
}
|
|
|
|
this._onContentChanged();
|
|
this._refreshIframeOverlays( doc, pageRoot );
|
|
}
|
|
|
|
_deleteBlock( block: HTMLElement, doc: Document, pageRoot: Element ): void
|
|
{
|
|
block.remove();
|
|
this._onContentChanged();
|
|
this._refreshIframeOverlays( doc, pageRoot );
|
|
}
|
|
|
|
_startDragTracking( e: MouseEvent, draggedBlock: Element | null, trigger: Element, doc: Document, pageRoot: Element ): void
|
|
{
|
|
const startX = e.clientX;
|
|
const startY = e.clientY;
|
|
let dragging = false;
|
|
|
|
const onMove = ( me: MouseEvent ) =>
|
|
{
|
|
if ( ! dragging )
|
|
{
|
|
if ( Math.hypot( me.clientX - startX, me.clientY - startY ) <= 4 ) return;
|
|
if ( draggedBlock === null ) return; // end trigger — click only
|
|
dragging = true;
|
|
this._draggedBlock = draggedBlock;
|
|
this._createDragBar();
|
|
}
|
|
this._updateDragVisual( me );
|
|
};
|
|
|
|
const onUp = () =>
|
|
{
|
|
doc.removeEventListener( 'mousemove', onMove );
|
|
doc.removeEventListener( 'mouseup', onUp );
|
|
document.removeEventListener( 'mouseup', onUp );
|
|
|
|
if ( dragging )
|
|
{
|
|
this._endDrag( doc, pageRoot );
|
|
}
|
|
else
|
|
{
|
|
this._blockInsertTarget = draggedBlock;
|
|
this._openBlockMenuNearIframeElement( trigger );
|
|
}
|
|
dragging = false;
|
|
};
|
|
|
|
doc.addEventListener( 'mousemove', onMove );
|
|
doc.addEventListener( 'mouseup', onUp );
|
|
document.addEventListener( 'mouseup', onUp );
|
|
}
|
|
|
|
_createDragBar(): void
|
|
{
|
|
if ( this._dragBar ) this._dragBar.remove();
|
|
const bar = document.createElement( 'div' );
|
|
bar.className = 'pep-drag-bar';
|
|
this.appendChild( bar );
|
|
this._dragBar = bar;
|
|
}
|
|
|
|
_updateDragVisual( me: MouseEvent ): void
|
|
{
|
|
let nearest: Element | null = null;
|
|
let minDist = Infinity;
|
|
|
|
for ( const t of this._iframeTriggers )
|
|
{
|
|
const rect = t.getBoundingClientRect();
|
|
const dist = Math.abs( me.clientY - ( rect.top + rect.height / 2 ) );
|
|
if ( dist < minDist ) { minDist = dist; nearest = t; }
|
|
}
|
|
|
|
if ( this._dropTrigger !== nearest )
|
|
{
|
|
this._dropTrigger?.classList.remove( 'pep-drop-target' );
|
|
this._dropTrigger = nearest;
|
|
nearest?.classList.add( 'pep-drop-target' );
|
|
}
|
|
|
|
if ( this._dragBar && nearest )
|
|
{
|
|
const iframeRect = this._iframe!.getBoundingClientRect();
|
|
const componentRect = this.getBoundingClientRect();
|
|
const triggerRect = nearest.getBoundingClientRect();
|
|
const barTop = ( iframeRect.top - componentRect.top ) + triggerRect.top + triggerRect.height / 2 - 1.5;
|
|
this._dragBar.style.top = barTop + 'px';
|
|
}
|
|
}
|
|
|
|
_endDrag( doc: Document, pageRoot: Element ): void
|
|
{
|
|
const draggedBlock = this._draggedBlock;
|
|
const dropTrigger = this._dropTrigger;
|
|
this._clearDragVisual();
|
|
|
|
if ( draggedBlock && dropTrigger )
|
|
{
|
|
this._reorderBlock( draggedBlock, dropTrigger, pageRoot, doc );
|
|
}
|
|
}
|
|
|
|
_clearDragVisual(): void
|
|
{
|
|
this._dragBar?.remove();
|
|
this._dragBar = null;
|
|
this._draggedBlock = null;
|
|
this._dropTrigger?.classList.remove( 'pep-drop-target' );
|
|
this._dropTrigger = null;
|
|
}
|
|
|
|
_reorderBlock( draggedBlock: Element, dropTrigger: Element, pageRoot: Element, doc: Document ): void
|
|
{
|
|
const insertBefore = dropTrigger.nextElementSibling;
|
|
|
|
if ( insertBefore === draggedBlock ) return; // dropping immediately before itself
|
|
if ( draggedBlock.nextElementSibling === dropTrigger ) return; // dropping immediately after itself
|
|
|
|
if ( insertBefore )
|
|
{
|
|
pageRoot.insertBefore( draggedBlock, insertBefore );
|
|
}
|
|
else
|
|
{
|
|
pageRoot.appendChild( draggedBlock );
|
|
}
|
|
|
|
this._onContentChanged();
|
|
this._refreshIframeOverlays( doc, pageRoot );
|
|
}
|
|
|
|
async _applyFormat( tagName: string ): Promise<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;
|
|
const doc = this._iframe!.contentDocument!;
|
|
|
|
if ( tagName === 'link' )
|
|
{
|
|
const selectedText = sel.toString().trim();
|
|
const defaultUrl = /^https?:\/\//.test( selectedText ) ? selectedText : '';
|
|
const url = await showInputDialog( { icon: '🔗', title: 'Create Link', label: 'URL', defaultValue: defaultUrl } );
|
|
if ( ! url ) return;
|
|
wrapSelection( doc, range, 'a', { href: url } );
|
|
return;
|
|
}
|
|
|
|
wrapSelection( doc, range, tagName );
|
|
}
|
|
|
|
_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' ) );
|
|
}
|
|
|
|
hasUnsavedChanges(): boolean
|
|
{
|
|
return this._dirty;
|
|
}
|
|
|
|
getCurrentFile(): string | null
|
|
{
|
|
return this.currentPath;
|
|
}
|
|
|
|
_updateButtons( dirty: boolean ): void
|
|
{
|
|
this._dirty = dirty;
|
|
( 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 );
|