import { Editor } from '../../editor/Editor.js'; import { EditorPanel, EditorPanelDefinition, FileEditorPanel, FileEditorPanelDefinition, implementsInterface } from '../../editor/editor-panel.js'; import { ContextMenuDirectory, ContextMenuEntry, ContextMenuSeparator } from '../context-menu/context-menu.js'; import { showConfirmDialog } from '../confirm-dialog/confirm-dialog.js'; interface TabEntry { id: string; label: string; panelType: string; element: HTMLElement; factory: () => HTMLElement; } export { EditorPanel }; class TabContainer extends HTMLElement { tabs: TabEntry[] = []; activeId: string | null = null; uid = Math.random().toString(36).slice(2); connectedCallback(): void { if (!this.id) this.id = 'tc-' + this.uid; this.classList.add('tab-container'); this.innerHTML = `
`; this.querySelector('.tc-menu')!.addEventListener('click', (e: Event) => { this.openMenu(e as MouseEvent); }); this.addEventListener('dragover', (e: DragEvent) => { if (e.dataTransfer?.types.includes('application/editor-tab')) { e.preventDefault(); this.classList.add('tc-drop-target'); } }); this.addEventListener('dragleave', () => this.classList.remove('tc-drop-target')); this.addEventListener('drop', (e: DragEvent) => { e.preventDefault(); this.classList.remove('tc-drop-target'); const raw = e.dataTransfer?.getData('application/editor-tab'); if (!raw) return; const { tabId, sourceContainerId } = JSON.parse(raw) as { tabId: string; sourceContainerId: string }; if (sourceContainerId === this.id) return; const source = document.getElementById(sourceContainerId) as TabContainer | null; if (!source) return; const tab = source.extractTab(tabId); if (tab) this.receiveTab(tab); }); this.addEventListener( 'panel:label-change', ( e: Event ) => { const panel = e.target as HTMLElement; const tab = this.tabs.find( t => t.element === panel ); if ( tab ) { tab.label = ( e as CustomEvent ).detail.label; this.renderBar(); } } ); Editor.get().onDocumentDirty.addListener( () => this.renderBar() ); Editor.get().onDocumentSaved.addListener( () => this.renderBar() ); } openMenu(e: MouseEvent): void { const btn = e.currentTarget as HTMLElement; const rect = btn.getBoundingClientRect(); const root = new ContextMenuDirectory(null); const addDir = new ContextMenuDirectory(root, 'Add'); const panelTypes = [ { 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' }, { label: 'Rojo Settings', panelType: 'rojo-settings', tag: 'rojo-settings-panel' }, ]; for (const pt of panelTypes) { addDir.add(new ContextMenuEntry(addDir, pt.label, () => { this.dispatchEvent(new CustomEvent('tab-container:add-panel', { bubbles: true, detail: { containerId: this.id, panelType: pt.panelType, tag: pt.tag, label: pt.label }, })); })); } root.add(addDir); root.add(new ContextMenuEntry(root, 'Duplicate', () => this.duplicateActive())); const splitDir = new ContextMenuDirectory(root, 'Split'); splitDir.add(new ContextMenuEntry(splitDir, '↔ Horizontally', () => { this.dispatchEvent(new CustomEvent('tab-container:split', { bubbles: true, detail: { containerId: this.id, direction: 'horizontal' }, })); })); splitDir.add(new ContextMenuEntry(splitDir, '↕ Vertically', () => { this.dispatchEvent(new CustomEvent('tab-container:split', { bubbles: true, detail: { containerId: this.id, direction: 'vertical' }, })); })); root.add(splitDir); const panelCount = this.closest('.es-panel')?.querySelectorAll('tab-container').length ?? 1; if (panelCount > 1) { root.add(new ContextMenuEntry(root, 'Close Container', () => this.closeContainer())); } const activeTab = this.tabs.find(t => t.id === this.activeId); if (activeTab) { const panel = activeTab.element as EditorPanel; if (typeof panel.addContextMenuEntries === 'function') { root.add(new ContextMenuSeparator(root)); panel.addContextMenuEntries(root); } } root.add(new ContextMenuSeparator(root)); root.add(new ContextMenuEntry(root, 'Close', () => this.closeActive())); root.show(rect.left, rect.bottom); } async closeContainer(): Promise { const hasUnsaved = this.tabs.some( t => implementsInterface( t.element, FileEditorPanelDefinition ) && ( t.element as FileEditorPanel ).hasUnsavedChanges() ); if ( hasUnsaved ) { const confirmed = await showConfirmDialog( { title: 'Close Container', message: 'Some tabs have unsaved changes.', confirmLabel: 'Close Without Saving', cancelLabel: "Don't Close", danger: true, } ); if ( !confirmed ) return; } this.dispatchEvent( new CustomEvent( 'tab-container:close-container', { bubbles: true, detail: { containerId: this.id }, } ) ); } duplicateActive(): void { if ( ! this.activeId ) { return; } const tab = this.tabs.find( t => t.id === this.activeId ); if ( ! tab ) { return; } const newId = tab.id + '-' + Math.random().toString( 36 ).slice( 2 ); this.addTab( { id: newId, label: tab.label, panelType: tab.panelType }, tab.factory ); } closeActive(): void { if ( ! this.activeId ) { return; } this.extractTab( this.activeId ); } addTab(config: { id: string; label: string; panelType: string }, factory: () => HTMLElement): void { const element = factory(); element.style.display = 'none'; this.querySelector('.tc-content')!.appendChild(element); this.tabs.push({ ...config, element, factory }); this.activateTab(config.id); } extractTab(tabId: string): TabEntry | null { const idx = this.tabs.findIndex(t => t.id === tabId); if (idx === -1) return null; const [tab] = this.tabs.splice(idx, 1); tab.element.remove(); if (this.activeId === tabId) { this.activeId = this.tabs[0]?.id ?? null; } this.renderBar(); this.showActive(); return tab; } receiveTab(tab: TabEntry): void { tab.element.style.display = 'none'; this.querySelector('.tc-content')!.appendChild(tab.element); this.tabs.push(tab); this.activateTab(tab.id); } activateTab(tabId: string): void { this.activeId = tabId; this.renderBar(); this.showActive(); } renderBar(): void { const iconMap: Record = { 'file-tree': '/icons/directory.svg', 'code-panel': '/icons/file.svg', 'page-editor': '/icons/file.svg', }; const bar = this.querySelector('.tc-tabs')!; bar.innerHTML = this.tabs.map(t => { const unsaved = implementsInterface( t.element, FileEditorPanelDefinition ) && ( t.element as FileEditorPanel ).hasUnsavedChanges(); const iconSrc = iconMap[ t.panelType ]; const icon = iconSrc ? `` : ''; return `
${ icon }${unsaved ? '' : ''}${t.label}
`; }).join(''); bar.querySelectorAll('.tc-tab').forEach(el => { el.addEventListener('click', () => this.activateTab((el as HTMLElement).dataset.tabId!)); el.addEventListener('mousedown', (e: Event) => { const me = e as MouseEvent; if ( me.button !== 1 ) return; me.preventDefault(); this.extractTab( (el as HTMLElement).dataset.tabId! ); }); el.addEventListener('dragstart', (e: Event) => { const de = e as DragEvent; const tabId = (el as HTMLElement).dataset.tabId!; de.dataTransfer?.setData('application/editor-tab', JSON.stringify({ tabId, sourceContainerId: this.id })); de.dataTransfer!.effectAllowed = 'move'; }); }); } showActive(): void { this.tabs.forEach(t => { t.element.style.display = t.id === this.activeId ? '' : 'none'; }); } } customElements.define('tab-container', TabContainer);