2026-07-06 17:28:59 +00:00
|
|
|
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 ) =>
|
|
|
|
|
{
|
2026-07-12 11:05:53 +00:00
|
|
|
const tab = this.tabs.find( t => ( t.element as any ).currentPath === e.path );
|
2026-07-06 17:28:59 +00:00
|
|
|
if ( tab ) { tab.dirty = true; this.renderBar(); }
|
|
|
|
|
} );
|
|
|
|
|
|
|
|
|
|
Editor.get().onDocumentSaved.addListener( ( e ) =>
|
|
|
|
|
{
|
2026-07-12 11:05:53 +00:00
|
|
|
const tab = this.tabs.find( t => ( t.element as any ).currentPath === e.path );
|
2026-07-06 17:28:59 +00:00
|
|
|
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 = [
|
2026-07-16 13:06:33 +00:00
|
|
|
{ label: 'HTML Editor', panelType: 'html-editor', tag: 'html-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' },
|
2026-07-06 17:28:59 +00:00
|
|
|
];
|
|
|
|
|
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);
|