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

203 lines
6.8 KiB
TypeScript
Raw Normal View History

import { Editor } from '../../editor/Editor.js';
import { ContextMenuDirectory, ContextMenuEntry, ContextMenuSeparator } from '../context-menu/context-menu.js';
interface TabEntry {
id: string;
label: string;
panelType: string;
element: HTMLElement;
dirty: boolean;
factory: () => HTMLElement;
}
export interface EditorPanel extends HTMLElement {
addContextMenuEntries(dir: ContextMenuDirectory): void;
}
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( ( e ) =>
{
const tab = this.tabs.find( t => ( t.element as any ).currentPath === e.path );
if ( tab ) { tab.dirty = true; this.renderBar(); }
} );
Editor.get().onDocumentSaved.addListener( ( e ) =>
{
const tab = this.tabs.find( t => ( t.element as any ).currentPath === e.path );
if ( tab ) { tab.dirty = false; 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()));
root.add(new ContextMenuEntry(root, 'Split', () => {
this.dispatchEvent(new CustomEvent('tab-container:split', { bubbles: true, detail: { containerId: this.id } }));
}));
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);
}
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, dirty: false, 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 bar = this.querySelector('.tc-tabs')!;
bar.innerHTML = this.tabs.map(t => `
<div class="tc-tab${t.id === this.activeId ? ' active' : ''}" draggable="true"
data-tab-id="${t.id}" data-source="${this.id}">
${t.dirty ? '<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('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);