rojects/source/components/tab-container/tab-container.ts

253 lines
8.7 KiB
TypeScript
Raw Permalink Normal View History

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 = `<div class="tc-bar"><div class="tc-tabs"></div><button class="tc-menu" title="Tab options">⋮</button></div><div class="tc-content"></div>`;
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: 'Console', panelType: 'console-panel', tag: 'console-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<void>
{
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<string, string> = {
'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 ? `<img class="tc-icon" src="${ iconSrc }" alt="">` : '';
return `
<div class="tc-tab${t.id === this.activeId ? ' active' : ''}" draggable="true"
data-tab-id="${t.id}" data-source="${this.id}">
${ icon }${unsaved ? '<span class="dirty-dot">●</span>' : ''}${t.label}
</div>
`;
}).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);