Writing Editor Panels

How to create a new panel that lives inside a tab container in the editor.

Summary

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.

Steps

1 — Create the component files

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).

2 — Write the custom element

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.

3 — Include a toolbar

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;
}

4 — Implement 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' ) );
}

5 — Register in TabContainer.openMenu

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.

6 — Wire into editor.html

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:

  1. Download the minified build and place it in public/vendor/.
  2. Add a plain <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>
  3. In the TypeScript source file, declare the global at the top so the compiler accepts it without a type package:
    declare const SomeLib: any;
  4. Use the global directly in your code. TypeScript will not complain, and the browser will find it at runtime because the plain script ran first.

Existing examples: markdown-it (used in rojo-chat-panel), CodeMirror 5 and its language mode files (used in code-panel).

7 — Update the tab label with 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.

Responsive Layout

Panels must work on desktop, tablet, and mobile in both orientations

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.

Input areas at the bottom should not overlap the content

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.