How to create a new panel that lives inside a tab container in the editor.
Every editor panel is a custom element registered with
customElements.define. It lives inside a
<tab-container>, gets its own tab, and can be opened,
closed, dragged to another container, and duplicated. A panel must have a
toolbar, must be self-initialising on connectedCallback, and
must be registered in TabContainer.openMenu so users can add it.
See html-editor-panel and rojo-chat-panel as
reference implementations.
Create two files following the naming convention:
src/components/<name>/<name>.ts ← TypeScript source
public/components/<name>/<name>.css ← CSS (maintained directly here, not compiled)
The TypeScript compiles to public/components/<name>/<name>.js
via tsconfig.client.json (npm run build).
Extend HTMLElement and guard connectedCallback
with an _initialized flag so it only runs once. Set up the
panel's full HTML structure inside connectedCallback:
class MyPanel extends HTMLElement
{
_initialized = false;
connectedCallback(): void
{
if ( this._initialized ) return;
this._initialized = true;
this.className = 'my-panel';
this.innerHTML = `
<div class="mp-toolbar">…</div>
<div class="mp-content">…</div>
`;
}
}
customElements.define( 'my-panel', MyPanel );
All state that should be independent per instance (IDs, history, etc.)
must be initialised inside connectedCallback, not at class
level — because Duplicate creates a fresh element via
document.createElement, which triggers
connectedCallback again on the new instance.
Every panel must have a toolbar as its first child. The toolbar is a flex
row, fixed height, that does not shrink. Typical layout: icon or label on
the left, spacer (flex: 1), action buttons on the right.
Button style should match the other panels (see
html-editor-panel.css for the canonical colours and sizing).
/* in public/components/my-panel/my-panel.css */
my-panel {
display: flex;
flex-direction: column;
height: 100%;
background: #0f1117;
}
.mp-toolbar {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 8px;
background: #13151f;
border-bottom: 1px solid #2a2d3a;
flex-shrink: 0;
}
.mp-content {
flex: 1;
overflow: auto;
}
addContextMenuEntries
Import and implement the EditorPanel interface from
tab-container.ts. This allows the panel to append its own
entries to the tab container's context menu when it is the active tab.
At minimum, add a read-only label identifying the panel type or its
current state.
import { ContextMenuDirectory, ContextMenuReadOnlyEntry } from '../context-menu/context-menu.js';
// inside the class:
addContextMenuEntries( dir: ContextMenuDirectory ): void
{
dir.add( new ContextMenuReadOnlyEntry( dir, 'My Panel' ) );
}
Open src/components/tab-container/tab-container.ts and add
an entry to the panelTypes array inside openMenu:
{ label: 'My Panel', panelType: 'my-panel', tag: 'my-panel' },
label — the text shown in the Add submenu.
panelType — internal identifier (used for dirty-state tracking and queries).
tag — the custom element tag passed to document.createElement.
Duplicate works automatically once the panel is registered — it calls the same factory, creating a new independent instance.
Add the CSS link and module script to public/editor.html:
<link rel="stylesheet" href="/components/my-panel/my-panel.css">
<script type="module" src="/components/my-panel/my-panel.js"></script>
Using a UMD vendor library (e.g. CodeMirror, markdown-it)
When a panel depends on a third-party library that ships as a UMD bundle (a single JS file that sets a global variable), follow this pattern:
public/vendor/.<script> tag in editor.html
before the module script. Order matters — the global must
exist before the module runs:
<script src="/vendor/some-lib.min.js"></script>
<script type="module" src="/components/my-panel/my-panel.js"></script>
declare const SomeLib: any;
Existing examples: markdown-it (used in rojo-chat-panel),
CodeMirror 5 and its language mode files (used in code-panel).
panel:label-change
When a panel loads a file it should update its own tab title to reflect the
open filename. Dispatch a bubbling CustomEvent named
panel:label-change with a detail.label string —
the TabContainer listens for it and updates the tab automatically:
_updateTabLabel( path: string ): void
{
const name = path ? path.slice( path.lastIndexOf( '/' ) + 1 ) : '';
this.dispatchEvent( new CustomEvent( 'panel:label-change',
{
bubbles: true,
detail: { label: '📄 ' + name },
} ) );
}
Call this from your _loadDocument (or equivalent) method, after
setting this.currentPath. The event must bubble so it reaches
the ancestor <tab-container>.
The initial tab label (shown before any file is opened) is set in
TabContainer.openMenu via the label field of the
panelTypes entry — that is the only place the label is set
without this event.
The editor shell handles the outer panel arrangement and switching between
landscape and portrait modes. Inside the panel, use height: 100%
on the root element and a column flex layout so the panel always fills the
available space. Avoid fixed pixel heights for content zones — use
flex: 1 for the scrollable area and flex-shrink: 0
for the toolbar and any fixed-height input areas.
If the panel has an input area (like a chat box), place it as the last
child and give it flex-shrink: 0. The scrollable content
area above it takes flex: 1. On narrow screens the input
area will naturally occupy more vertical proportion — keep it compact
(avoid large padding or decorative margins) so content remains visible.