history: tab container updates, EditorPanel interfaces, editor-shell bug fixes, workspace docs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3e25a419ca
commit
dba0fdca1e
|
|
@ -1,3 +1,3 @@
|
||||||
# Roject
|
# Roject
|
||||||
|
|
||||||
See **[workspace/outline/index.html](workspace/outline/index.html)** for the full developer documentation — project outline, tech stack, coding conventions, and repeatable actions.
|
See **[workspace/index.html](workspace/index.html)** for the full developer documentation — project outline, tech stack, coding conventions, and repeatable actions.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { Editor } from '../../editor/Editor.js';
|
import { Editor } from '../../editor/Editor.js';
|
||||||
|
import { EditorPanelDefinition, FileEditorPanelDefinition } from '../../editor/editor-panel.js';
|
||||||
import { ContextMenuDirectory, ContextMenuReadOnlyEntry } from '../context-menu/context-menu.js';
|
import { ContextMenuDirectory, ContextMenuReadOnlyEntry } from '../context-menu/context-menu.js';
|
||||||
import { csharpMode } from './CSharpMode.js';
|
import { csharpMode } from './CSharpMode.js';
|
||||||
|
|
||||||
|
|
@ -9,7 +10,9 @@ CodeMirror.defineMode( 'rokojori-cs', () => csharpMode.cmMode );
|
||||||
|
|
||||||
class CodePanel extends HTMLElement
|
class CodePanel extends HTMLElement
|
||||||
{
|
{
|
||||||
|
__interfaces__ = [ EditorPanelDefinition.type, FileEditorPanelDefinition.type ];
|
||||||
currentPath: string | null = null;
|
currentPath: string | null = null;
|
||||||
|
_dirty = false;
|
||||||
_pinned: boolean = false;
|
_pinned: boolean = false;
|
||||||
_cm: any = null;
|
_cm: any = null;
|
||||||
_initialized = false;
|
_initialized = false;
|
||||||
|
|
@ -132,8 +135,14 @@ class CodePanel extends HTMLElement
|
||||||
this.dispatchEvent( new CustomEvent( 'panel:label-change', { bubbles: true, detail: { label: '📝 ' + name } } ) );
|
this.dispatchEvent( new CustomEvent( 'panel:label-change', { bubbles: true, detail: { label: '📝 ' + name } } ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasUnsavedChanges(): boolean
|
||||||
|
{
|
||||||
|
return this._dirty;
|
||||||
|
}
|
||||||
|
|
||||||
_updateButtons( dirty: boolean ): void
|
_updateButtons( dirty: boolean ): void
|
||||||
{
|
{
|
||||||
|
this._dirty = dirty;
|
||||||
( this.querySelector( '.cp-save' ) as HTMLButtonElement ).disabled = ! dirty;
|
( this.querySelector( '.cp-save' ) as HTMLButtonElement ).disabled = ! dirty;
|
||||||
( this.querySelector( '.cp-undo' ) as HTMLButtonElement ).disabled = this._cm.historySize().undo < 1;
|
( this.querySelector( '.cp-undo' ) as HTMLButtonElement ).disabled = this._cm.historySize().undo < 1;
|
||||||
( this.querySelector( '.cp-redo' ) as HTMLButtonElement ).disabled = this._cm.historySize().redo < 1;
|
( this.querySelector( '.cp-redo' ) as HTMLButtonElement ).disabled = this._cm.historySize().redo < 1;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,21 @@ import { Editor } from '../../editor/Editor.js';
|
||||||
|
|
||||||
// ── Layout helpers ────────────────────────────────────────────────────────────
|
// ── Layout helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function removeAdjacentHandle( el: HTMLElement, handleClass: string ): void
|
||||||
|
{
|
||||||
|
const next = el.nextElementSibling as HTMLElement | null;
|
||||||
|
if ( next && next.classList.contains( handleClass ) )
|
||||||
|
{
|
||||||
|
next.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const prev = el.previousElementSibling as HTMLElement | null;
|
||||||
|
if ( prev && prev.classList.contains( handleClass ) )
|
||||||
|
{
|
||||||
|
prev.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let tcCounter = 0;
|
let tcCounter = 0;
|
||||||
function nextTcId(): string { return `tc-${++tcCounter}`; }
|
function nextTcId(): string { return `tc-${++tcCounter}`; }
|
||||||
|
|
||||||
|
|
@ -151,16 +166,51 @@ class EditorShell extends HTMLElement {
|
||||||
|
|
||||||
private setupSplitListener(): void {
|
private setupSplitListener(): void {
|
||||||
this.addEventListener('tab-container:split', (e: Event) => {
|
this.addEventListener('tab-container:split', (e: Event) => {
|
||||||
const { containerId } = (e as CustomEvent).detail as { containerId: string };
|
const { containerId, direction } = (e as CustomEvent).detail as { containerId: string; direction: 'horizontal' | 'vertical' };
|
||||||
const tc = document.getElementById(containerId);
|
const tc = document.getElementById(containerId);
|
||||||
if (!tc) return;
|
if (!tc) return;
|
||||||
const section = tc.closest('.es-section') as HTMLElement | null;
|
const section = tc.closest('.es-section') as HTMLElement | null;
|
||||||
const sections = tc.closest('.es-sections') as HTMLElement | null;
|
if (!section) return;
|
||||||
if (!section || !sections) return;
|
|
||||||
const newSec = makeSection();
|
if ( 'vertical' === direction )
|
||||||
const handle = makeResizeHandle('v');
|
{
|
||||||
sections.insertBefore(handle, section.nextSibling);
|
const newTc = document.createElement('tab-container') as HTMLElement;
|
||||||
sections.insertBefore(newSec, handle.nextSibling);
|
newTc.id = nextTcId();
|
||||||
|
const handle = makeResizeHandle('h');
|
||||||
|
section.appendChild(handle);
|
||||||
|
section.appendChild(newTc);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
const sections = section.closest('.es-sections') as HTMLElement | null;
|
||||||
|
if (!sections) return;
|
||||||
|
const newSec = makeSection();
|
||||||
|
const handle = makeResizeHandle('v');
|
||||||
|
sections.insertBefore(handle, section.nextSibling);
|
||||||
|
sections.insertBefore(newSec, handle.nextSibling);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.addEventListener('tab-container:close-container', (e: Event) => {
|
||||||
|
const { containerId } = (e as CustomEvent).detail as { containerId: string };
|
||||||
|
const tc = document.getElementById(containerId) as HTMLElement | null;
|
||||||
|
if (!tc) return;
|
||||||
|
const section = tc.closest('.es-section') as HTMLElement | null;
|
||||||
|
if (!section) return;
|
||||||
|
|
||||||
|
const tcsInSection = section.querySelectorAll(':scope > tab-container');
|
||||||
|
if ( tcsInSection.length > 1 )
|
||||||
|
{
|
||||||
|
removeAdjacentHandle(tc, 'es-h-handle');
|
||||||
|
tc.remove();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
const sections = section.closest('.es-sections') as HTMLElement | null;
|
||||||
|
if (!sections) return;
|
||||||
|
removeAdjacentHandle(section, 'es-v-handle');
|
||||||
|
section.remove();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.addEventListener('tab-container:add-panel', (e: Event) => {
|
this.addEventListener('tab-container:add-panel', (e: Event) => {
|
||||||
|
|
@ -245,15 +295,21 @@ class EditorShell extends HTMLElement {
|
||||||
private setupResizeHandler(): void
|
private setupResizeHandler(): void
|
||||||
{
|
{
|
||||||
const workspace = this.querySelector( '.es-workspace' ) as HTMLElement;
|
const workspace = this.querySelector( '.es-workspace' ) as HTMLElement;
|
||||||
const obs = new ResizeObserver( () =>
|
|
||||||
|
const workspaceObs = new ResizeObserver( () =>
|
||||||
{
|
{
|
||||||
this._redistributeFlex( workspace, '.es-panel' );
|
this._redistributeFlex( workspace, '.es-panel' );
|
||||||
workspace.querySelectorAll( '.es-sections' ).forEach( container =>
|
|
||||||
{
|
|
||||||
this._redistributeFlex( container as HTMLElement, '.es-section' );
|
|
||||||
} );
|
|
||||||
} );
|
} );
|
||||||
obs.observe( workspace );
|
workspaceObs.observe( workspace );
|
||||||
|
|
||||||
|
const sectionsObs = new ResizeObserver( ( entries ) =>
|
||||||
|
{
|
||||||
|
for ( const entry of entries )
|
||||||
|
{
|
||||||
|
this._redistributeFlex( entry.target as HTMLElement, '.es-section' );
|
||||||
|
}
|
||||||
|
} );
|
||||||
|
workspace.querySelectorAll( '.es-sections' ).forEach( s => sectionsObs.observe( s ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
private _redistributeFlex( container: HTMLElement, childSelector: string ): void
|
private _redistributeFlex( container: HTMLElement, childSelector: string ): void
|
||||||
|
|
@ -293,7 +349,15 @@ class EditorShell extends HTMLElement {
|
||||||
|
|
||||||
const apply = ( portrait: boolean ) => {
|
const apply = ( portrait: boolean ) => {
|
||||||
this.classList.toggle( 'portrait', portrait );
|
this.classList.toggle( 'portrait', portrait );
|
||||||
if ( portrait ) this.showPortraitPanel( this.activePortraitPanel );
|
if ( portrait )
|
||||||
|
{
|
||||||
|
this.showPortraitPanel( this.activePortraitPanel );
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
this.querySelectorAll( '.es-panel' ).forEach( p => { ( p as HTMLElement ).style.display = ''; } );
|
||||||
|
this.querySelectorAll( '.es-v-handle' ).forEach( h => { ( h as HTMLElement ).style.display = ''; } );
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
btns.querySelectorAll( '.es-pb-btn' ).forEach( btn => {
|
btns.querySelectorAll( '.es-pb-btn' ).forEach( btn => {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { Editor } from '../../editor/Editor.js';
|
import { Editor } from '../../editor/Editor.js';
|
||||||
|
import { EditorPanelDefinition } from '../../editor/editor-panel.js';
|
||||||
import { ContextMenuDirectory, ContextMenuEntry, ContextMenuReadOnlyEntry, ContextMenuSeparator } from '../context-menu/context-menu.js';
|
import { ContextMenuDirectory, ContextMenuEntry, ContextMenuReadOnlyEntry, ContextMenuSeparator } from '../context-menu/context-menu.js';
|
||||||
import { showConfirmDialog } from '../confirm-dialog/confirm-dialog.js';
|
import { showConfirmDialog } from '../confirm-dialog/confirm-dialog.js';
|
||||||
|
|
||||||
|
|
@ -10,6 +11,7 @@ interface FileNode {
|
||||||
}
|
}
|
||||||
|
|
||||||
class FileTreePanel extends HTMLElement {
|
class FileTreePanel extends HTMLElement {
|
||||||
|
__interfaces__ = [ EditorPanelDefinition.type ];
|
||||||
selectedPath: string | null = null;
|
selectedPath: string | null = null;
|
||||||
_rootPath: string = '';
|
_rootPath: string = '';
|
||||||
_initialized = false;
|
_initialized = false;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { Editor } from '../../editor/Editor.js';
|
import { Editor } from '../../editor/Editor.js';
|
||||||
|
import { EditorPanelDefinition, FileEditorPanelDefinition } from '../../editor/editor-panel.js';
|
||||||
import { ContextMenuDirectory, ContextMenuReadOnlyEntry } from '../context-menu/context-menu.js';
|
import { ContextMenuDirectory, ContextMenuReadOnlyEntry } from '../context-menu/context-menu.js';
|
||||||
|
|
||||||
// ── Block registry ────────────────────────────────────────────────────────────
|
// ── Block registry ────────────────────────────────────────────────────────────
|
||||||
|
|
@ -191,7 +192,9 @@ function wrapSelection( doc: Document, range: Range, tagName: string, attributes
|
||||||
|
|
||||||
class PageEditorPanel extends HTMLElement
|
class PageEditorPanel extends HTMLElement
|
||||||
{
|
{
|
||||||
|
__interfaces__ = [ EditorPanelDefinition.type, FileEditorPanelDefinition.type ];
|
||||||
currentPath: string | null = null;
|
currentPath: string | null = null;
|
||||||
|
_dirty = false;
|
||||||
_pinned: boolean = false;
|
_pinned: boolean = false;
|
||||||
_iframe: HTMLIFrameElement | null = null;
|
_iframe: HTMLIFrameElement | null = null;
|
||||||
_undoStack: string[] = [];
|
_undoStack: string[] = [];
|
||||||
|
|
@ -477,8 +480,14 @@ class PageEditorPanel extends HTMLElement
|
||||||
dir.add( new ContextMenuReadOnlyEntry( dir, this.currentPath ? `Editing: ${ this.currentPath }` : 'No document open' ) );
|
dir.add( new ContextMenuReadOnlyEntry( dir, this.currentPath ? `Editing: ${ this.currentPath }` : 'No document open' ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasUnsavedChanges(): boolean
|
||||||
|
{
|
||||||
|
return this._dirty;
|
||||||
|
}
|
||||||
|
|
||||||
_updateButtons( dirty: boolean ): void
|
_updateButtons( dirty: boolean ): void
|
||||||
{
|
{
|
||||||
|
this._dirty = dirty;
|
||||||
( this.querySelector( '.pep-save' ) as HTMLButtonElement ).disabled = ! dirty;
|
( this.querySelector( '.pep-save' ) as HTMLButtonElement ).disabled = ! dirty;
|
||||||
( this.querySelector( '.pep-undo' ) as HTMLButtonElement ).disabled = this._undoStack.length < 2;
|
( this.querySelector( '.pep-undo' ) as HTMLButtonElement ).disabled = this._undoStack.length < 2;
|
||||||
( this.querySelector( '.pep-redo' ) as HTMLButtonElement ).disabled = this._redoStack.length === 0;
|
( this.querySelector( '.pep-redo' ) as HTMLButtonElement ).disabled = this._redoStack.length === 0;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { Editor } from '../../editor/Editor.js';
|
import { Editor } from '../../editor/Editor.js';
|
||||||
|
import { EditorPanelDefinition } from '../../editor/editor-panel.js';
|
||||||
import { ContextMenuDirectory, ContextMenuReadOnlyEntry } from '../context-menu/context-menu.js';
|
import { ContextMenuDirectory, ContextMenuReadOnlyEntry } from '../context-menu/context-menu.js';
|
||||||
|
|
||||||
declare const markdownit: ( options?: Record<string, unknown> ) => { render: ( md: string ) => string };
|
declare const markdownit: ( options?: Record<string, unknown> ) => { render: ( md: string ) => string };
|
||||||
|
|
@ -39,6 +40,7 @@ function parentDir( filePath: string ): string
|
||||||
|
|
||||||
class RojoChatPanel extends HTMLElement
|
class RojoChatPanel extends HTMLElement
|
||||||
{
|
{
|
||||||
|
__interfaces__ = [ EditorPanelDefinition.type ];
|
||||||
_initialized = false;
|
_initialized = false;
|
||||||
_conversationId = '';
|
_conversationId = '';
|
||||||
_sending = false;
|
_sending = false;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { Editor } from '../../editor/Editor.js';
|
import { Editor } from '../../editor/Editor.js';
|
||||||
|
import { EditorPanelDefinition } from '../../editor/editor-panel.js';
|
||||||
import { ContextMenuDirectory, ContextMenuReadOnlyEntry } from '../context-menu/context-menu.js';
|
import { ContextMenuDirectory, ContextMenuReadOnlyEntry } from '../context-menu/context-menu.js';
|
||||||
|
|
||||||
const NS_INKSCAPE = 'http://www.inkscape.org/namespaces/inkscape';
|
const NS_INKSCAPE = 'http://www.inkscape.org/namespaces/inkscape';
|
||||||
|
|
@ -111,6 +112,7 @@ async function loadPortraitSvg(): Promise<string>
|
||||||
|
|
||||||
class RojoSettingsPanel extends HTMLElement
|
class RojoSettingsPanel extends HTMLElement
|
||||||
{
|
{
|
||||||
|
__interfaces__ = [ EditorPanelDefinition.type ];
|
||||||
currentPath: string | null = null;
|
currentPath: string | null = null;
|
||||||
_initialized = false;
|
_initialized = false;
|
||||||
_ignoreChange = false;
|
_ignoreChange = false;
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,17 @@
|
||||||
import { Editor } from '../../editor/Editor.js';
|
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 { ContextMenuDirectory, ContextMenuEntry, ContextMenuSeparator } from '../context-menu/context-menu.js';
|
||||||
|
import { showConfirmDialog } from '../confirm-dialog/confirm-dialog.js';
|
||||||
|
|
||||||
interface TabEntry {
|
interface TabEntry {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
panelType: string;
|
panelType: string;
|
||||||
element: HTMLElement;
|
element: HTMLElement;
|
||||||
dirty: boolean;
|
|
||||||
factory: () => HTMLElement;
|
factory: () => HTMLElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EditorPanel extends HTMLElement {
|
export { EditorPanel };
|
||||||
addContextMenuEntries(dir: ContextMenuDirectory): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
class TabContainer extends HTMLElement {
|
class TabContainer extends HTMLElement {
|
||||||
tabs: TabEntry[] = [];
|
tabs: TabEntry[] = [];
|
||||||
|
|
@ -54,17 +53,8 @@ class TabContainer extends HTMLElement {
|
||||||
if ( tab ) { tab.label = ( e as CustomEvent ).detail.label; this.renderBar(); }
|
if ( tab ) { tab.label = ( e as CustomEvent ).detail.label; this.renderBar(); }
|
||||||
} );
|
} );
|
||||||
|
|
||||||
Editor.get().onDocumentDirty.addListener( ( e ) =>
|
Editor.get().onDocumentDirty.addListener( () => this.renderBar() );
|
||||||
{
|
Editor.get().onDocumentSaved.addListener( () => this.renderBar() );
|
||||||
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 {
|
openMenu(e: MouseEvent): void {
|
||||||
|
|
@ -91,9 +81,26 @@ class TabContainer extends HTMLElement {
|
||||||
root.add(addDir);
|
root.add(addDir);
|
||||||
|
|
||||||
root.add(new ContextMenuEntry(root, 'Duplicate', () => this.duplicateActive()));
|
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 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);
|
const activeTab = this.tabs.find(t => t.id === this.activeId);
|
||||||
if (activeTab) {
|
if (activeTab) {
|
||||||
|
|
@ -110,6 +117,32 @@ class TabContainer extends HTMLElement {
|
||||||
root.show(rect.left, rect.bottom);
|
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
|
duplicateActive(): void
|
||||||
{
|
{
|
||||||
if ( ! this.activeId )
|
if ( ! this.activeId )
|
||||||
|
|
@ -133,7 +166,6 @@ class TabContainer extends HTMLElement {
|
||||||
if ( ! this.activeId )
|
if ( ! this.activeId )
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.extractTab( this.activeId );
|
this.extractTab( this.activeId );
|
||||||
|
|
@ -144,7 +176,7 @@ class TabContainer extends HTMLElement {
|
||||||
const element = factory();
|
const element = factory();
|
||||||
element.style.display = 'none';
|
element.style.display = 'none';
|
||||||
this.querySelector('.tc-content')!.appendChild(element);
|
this.querySelector('.tc-content')!.appendChild(element);
|
||||||
this.tabs.push({ ...config, element, dirty: false, factory });
|
this.tabs.push({ ...config, element, factory });
|
||||||
this.activateTab(config.id);
|
this.activateTab(config.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -176,15 +208,25 @@ class TabContainer extends HTMLElement {
|
||||||
|
|
||||||
renderBar(): void {
|
renderBar(): void {
|
||||||
const bar = this.querySelector('.tc-tabs')!;
|
const bar = this.querySelector('.tc-tabs')!;
|
||||||
bar.innerHTML = this.tabs.map(t => `
|
bar.innerHTML = this.tabs.map(t => {
|
||||||
<div class="tc-tab${t.id === this.activeId ? ' active' : ''}" draggable="true"
|
const unsaved = implementsInterface( t.element, FileEditorPanelDefinition ) &&
|
||||||
data-tab-id="${t.id}" data-source="${this.id}">
|
( t.element as FileEditorPanel ).hasUnsavedChanges();
|
||||||
${t.dirty ? '<span class="dirty-dot">●</span>' : ''}${t.label}
|
return `
|
||||||
</div>
|
<div class="tc-tab${t.id === this.activeId ? ' active' : ''}" draggable="true"
|
||||||
`).join('');
|
data-tab-id="${t.id}" data-source="${this.id}">
|
||||||
|
${unsaved ? '<span class="dirty-dot">●</span>' : ''}${t.label}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
bar.querySelectorAll('.tc-tab').forEach(el => {
|
bar.querySelectorAll('.tc-tab').forEach(el => {
|
||||||
el.addEventListener('click', () => this.activateTab((el as HTMLElement).dataset.tabId!));
|
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) => {
|
el.addEventListener('dragstart', (e: Event) => {
|
||||||
const de = e as DragEvent;
|
const de = e as DragEvent;
|
||||||
const tabId = (el as HTMLElement).dataset.tabId!;
|
const tabId = (el as HTMLElement).dataset.tabId!;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { ContextMenuDirectory } from '../components/context-menu/context-menu.js';
|
||||||
|
|
||||||
|
export class EditorPanelDefinition
|
||||||
|
{
|
||||||
|
static readonly type = 'EditorPanel';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FileEditorPanelDefinition
|
||||||
|
{
|
||||||
|
static readonly type = 'FileEditorPanel';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditorPanel extends HTMLElement
|
||||||
|
{
|
||||||
|
__interfaces__: string[];
|
||||||
|
addContextMenuEntries( dir: ContextMenuDirectory ): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileEditorPanel extends EditorPanel
|
||||||
|
{
|
||||||
|
hasUnsavedChanges(): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function implementsInterface( el: any, def: { type: string } ): boolean
|
||||||
|
{
|
||||||
|
return Array.isArray( el.__interfaces__ ) && el.__interfaces__.includes( def.type );
|
||||||
|
}
|
||||||
|
|
@ -80,8 +80,8 @@
|
||||||
<h3>Step 2 — Check CLAUDE.md</h3>
|
<h3>Step 2 — Check CLAUDE.md</h3>
|
||||||
<p>
|
<p>
|
||||||
Open <code>CLAUDE.md</code> at the project root and verify it still just points
|
Open <code>CLAUDE.md</code> at the project root and verify it still just points
|
||||||
to the outline. If it has drifted and contains duplicated or stale content,
|
to the workspace index. If it has drifted and contains duplicated or stale content,
|
||||||
trim it back so it only references <code>workspace/outline/index.html</code>.
|
trim it back so it only references <code>workspace/index.html</code>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,57 @@
|
||||||
from the shared library — not a plain function property or a custom event.
|
from the shared library — not a plain function property or a custom event.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="decision">
|
||||||
|
<strong>Structural markup — custom element names, not div.class-name</strong>
|
||||||
|
<p>
|
||||||
|
Never write <code><div class="es-header"></code> or similar. Use a
|
||||||
|
hyphenated custom element name instead: <code><editor-shell-header></code>.
|
||||||
|
The element does not need to be registered with <code>customElements.define</code>
|
||||||
|
— it can be an anonymous container used purely as a CSS selector. This makes
|
||||||
|
the DOM tree readable in DevTools without decoding class names, and keeps
|
||||||
|
component markup self-documenting.
|
||||||
|
</p>
|
||||||
|
<pre><code>/* Wrong */
|
||||||
|
<div class="es-header">...</div>
|
||||||
|
|
||||||
|
/* Right */
|
||||||
|
<editor-shell-header>...</editor-shell-header></code></pre>
|
||||||
|
<p>CSS uses the element name as the root selector:</p>
|
||||||
|
<pre><code>editor-shell-header {
|
||||||
|
display: flex;
|
||||||
|
height: 40px;
|
||||||
|
}</code></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="decision">
|
||||||
|
<strong>Web Component interfaces — Definition classes</strong>
|
||||||
|
<p>
|
||||||
|
Web Components must extend <code>HTMLElement</code> and cannot use class
|
||||||
|
inheritance for shared contracts — interfaces are the justified exception to
|
||||||
|
the "classes not interfaces" rule. Each interface has a companion Definition
|
||||||
|
class with a <code>static readonly type</code> string, and every implementing
|
||||||
|
class declares an <code>__interfaces__</code> member listing its Definition types.
|
||||||
|
Use <code>implementsInterface(el, SomeDefinition)</code> for runtime checks —
|
||||||
|
never spread raw string literals.
|
||||||
|
</p>
|
||||||
|
<pre><code>export class EditorPanelDefinition
|
||||||
|
{
|
||||||
|
static readonly type = 'EditorPanel';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditorPanel extends HTMLElement
|
||||||
|
{
|
||||||
|
__interfaces__: string[];
|
||||||
|
addContextMenuEntries( dir: ContextMenuDirectory ): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// implementing class:
|
||||||
|
__interfaces__ = [ EditorPanelDefinition.type ];
|
||||||
|
|
||||||
|
// runtime check:
|
||||||
|
if ( implementsInterface( panel, EditorPanelDefinition ) ) { ... }</code></pre>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,82 @@
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Tab container updates</h2>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Split submenu + vertical split</h3>
|
||||||
|
<p>
|
||||||
|
The single <em>Split</em> menu entry is replaced by a <em>Split ></em> submenu
|
||||||
|
with <em>↔ Horizontally</em> (adds a new section side by side in <code>.es-sections</code>)
|
||||||
|
and <em>↕ Vertically</em> (adds a second <code>tab-container</code> + horizontal
|
||||||
|
resize handle inside the same <code>.es-section</code>). The
|
||||||
|
<code>tab-container:split</code> event now carries a <code>direction</code> field.
|
||||||
|
<code>.es-section</code> was already <code>flex-direction: column</code>, so no CSS
|
||||||
|
changes were needed for vertical split.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Close Container</h3>
|
||||||
|
<p>
|
||||||
|
A <em>Close Container</em> context menu entry fires <code>tab-container:close-container</code>,
|
||||||
|
handled by <code>editor-shell</code>: if the container is in a vertical split (multiple
|
||||||
|
<code>tab-container</code> elements in the section), only that container and its adjacent
|
||||||
|
handle are removed; otherwise the whole section and its adjacent <code>es-v-handle</code>
|
||||||
|
are removed. The entry is hidden when the container is the last one in its
|
||||||
|
<code>.es-panel</code> slot. If any tab has unsaved changes,
|
||||||
|
<code>showConfirmDialog</code> prompts with <em>Don't Close</em> /
|
||||||
|
<em>Close Without Saving</em> before dispatching the event.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Middle-mouse tab close</h3>
|
||||||
|
<p>
|
||||||
|
Tabs now close on middle-mouse click: a <code>mousedown</code> listener with
|
||||||
|
<code>e.button === 1</code> calls <code>e.preventDefault()</code> (suppresses the
|
||||||
|
browser scroll cursor) and extracts the tab immediately.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>EditorPanel / FileEditorPanel interface system</h3>
|
||||||
|
<p>
|
||||||
|
New file <code>source/editor/editor-panel.ts</code> defines the panel contract.
|
||||||
|
<code>EditorPanel</code> requires <code>__interfaces__: string[]</code> and
|
||||||
|
<code>addContextMenuEntries()</code>. <code>FileEditorPanel extends EditorPanel</code>
|
||||||
|
adds <code>hasUnsavedChanges(): boolean</code>, replacing the old
|
||||||
|
<code>TabEntry.dirty</code> flag — <code>renderBar()</code> now queries the panel
|
||||||
|
live instead of caching a boolean.
|
||||||
|
Each interface has a companion Definition class with <code>static readonly type</code>;
|
||||||
|
<code>implementsInterface(el, Def)</code> is the single runtime check function.
|
||||||
|
All five panels (<code>page-editor-panel</code>, <code>code-panel</code>,
|
||||||
|
<code>file-tree-panel</code>, <code>rojo-settings-panel</code>,
|
||||||
|
<code>rojo-chat-panel</code>) updated with <code>__interfaces__</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Bug fixes</h3>
|
||||||
|
<p>
|
||||||
|
<strong>Section resize:</strong> <code>setupResizeHandler</code> previously observed
|
||||||
|
only the workspace element, so section redistribution never fired when a panel was
|
||||||
|
dragged (workspace size doesn't change on panel drag). Fixed by observing each
|
||||||
|
<code>.es-sections</code> element directly — when the panel narrows, its sections
|
||||||
|
container narrows too and the observer redistributes sections correctly.
|
||||||
|
</p>
|
||||||
|
<p style="margin-top:0.75rem">
|
||||||
|
<strong>Portrait → landscape:</strong> <code>showPortraitPanel</code> set inline
|
||||||
|
<code>display: none</code> on panels and handles, but returning to landscape only
|
||||||
|
removed the <code>portrait</code> class — the inline styles persisted and kept panels
|
||||||
|
hidden. Fixed by clearing <code>style.display</code> on all panels and
|
||||||
|
<code>.es-v-handle</code> elements when <code>apply(false)</code> runs.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2>Key decisions</h2>
|
<h2>Key decisions</h2>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,11 @@
|
||||||
<section>
|
<section>
|
||||||
<h2>2026 — July</h2>
|
<h2>2026 — July</h2>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3><a href="2026/07-July/25-Friday/index.html">Friday, 25 July 2026</a></h3>
|
||||||
|
<p>Tab container: split submenu (horizontal/vertical), close container with unsaved-changes dialog, middle-mouse tab close. EditorPanel/FileEditorPanel interface system replacing TabEntry.dirty. Section resize and portrait→landscape bug fixes. TypeScript guide: custom element names + web component interface pattern. CLAUDE.md and workspace index corrected.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3><a href="2026/07-July/18-Friday/index.html">Friday, 18 July 2026</a></h3>
|
<h3><a href="2026/07-July/18-Friday/index.html">Friday, 18 July 2026</a></h3>
|
||||||
<p>CodeMirror syntax highlighting: vendor modes (clike, python, shell, yaml) + custom C# mode built on BrowserLexer + CodeMirrorLexerMode with dynamic keyword sets. File tree single-click smart open (focus existing, skip pinned). rokojori-auth login fix: removed broken refresh-session redirect. Electron local dev fixes: Bearer-before-cookie token extraction, JWT_CLOCK_TOLERANCE clock skew escape hatch, startup token refresh, quit-on-login race fix, credential persistence with remember-me. Mobile nav z-index fix.</p>
|
<p>CodeMirror syntax highlighting: vendor modes (clike, python, shell, yaml) + custom C# mode built on BrowserLexer + CodeMirrorLexerMode with dynamic keyword sets. File tree single-click smart open (focus existing, skip pinned). rokojori-auth login fix: removed broken refresh-session redirect. Electron local dev fixes: Bearer-before-cookie token extraction, JWT_CLOCK_TOLERANCE clock skew escape hatch, startup token refresh, quit-on-login race fix, credential persistence with remember-me. Mobile nav z-index fix.</p>
|
||||||
|
|
|
||||||
|
|
@ -85,9 +85,10 @@
|
||||||
Check Boards for what is active right now.
|
Check Boards for what is active right now.
|
||||||
</p>
|
</p>
|
||||||
<p style="margin-top: 0.75rem">
|
<p style="margin-top: 0.75rem">
|
||||||
<strong>Agents:</strong> read Outline → Guides → Reference before making changes.
|
<strong>Agents:</strong> read Outline → Guides → Actions → Reference before making changes.
|
||||||
Check Boards and the most recent History entry to understand the current context.
|
Check Boards and the most recent History entry to understand the current context.
|
||||||
Follow all coding conventions in the Guides — do not infer style from existing code alone.
|
Follow all coding conventions in the Guides — do not infer style from existing code alone.
|
||||||
|
After completing work, run the Update Outline and Update History actions.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -185,6 +185,27 @@
|
||||||
available unpinned non-dirty editor of the correct type is used; a new panel is
|
available unpinned non-dirty editor of the correct type is used; a new panel is
|
||||||
created in the active section if none qualifies. Pinned panels are never overwritten.
|
created in the active section if none qualifies. Pinned panels are never overwritten.
|
||||||
</p>
|
</p>
|
||||||
|
<p style="margin-top:0.75rem">
|
||||||
|
<strong>Tab container context menu:</strong>
|
||||||
|
<em>Add ></em> opens a panel-type submenu. <em>Duplicate</em> clones the active
|
||||||
|
tab. <em>Split ></em> offers <em>↔ Horizontally</em> (new section side by side)
|
||||||
|
and <em>↕ Vertically</em> (new tab-container stacked inside the same section).
|
||||||
|
<em>Close Container</em> removes the container and its adjacent resize handle; it is
|
||||||
|
hidden when the container is the last one in its slot. If any tab has unsaved changes,
|
||||||
|
a confirm dialog (<em>Don't Close</em> / <em>Close Without Saving</em>) appears first.
|
||||||
|
Tabs can also be closed by middle-mouse click.
|
||||||
|
</p>
|
||||||
|
<p style="margin-top:0.75rem">
|
||||||
|
<strong>Panel interfaces</strong> — <code>source/editor/editor-panel.ts</code> defines
|
||||||
|
two interfaces for Web Component panels. <code>EditorPanel</code> (all panels) requires
|
||||||
|
<code>__interfaces__: string[]</code> and <code>addContextMenuEntries()</code>.
|
||||||
|
<code>FileEditorPanel extends EditorPanel</code> (file-editing panels only) adds
|
||||||
|
<code>hasUnsavedChanges(): boolean</code>, replacing the old <code>TabEntry.dirty</code>
|
||||||
|
flag. Each interface has a companion Definition class
|
||||||
|
(<code>EditorPanelDefinition</code>, <code>FileEditorPanelDefinition</code>) with a
|
||||||
|
<code>static readonly type</code> string; use <code>implementsInterface(el, Def)</code>
|
||||||
|
for runtime checks.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue