rojects/workspace/guides/writing-editor-panels/index.html

259 lines
10 KiB
HTML
Raw Permalink Normal View History

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Writing Editor Panels — Roject</title>
<link rel="stylesheet" href="../../_assets_/styles.css">
<link rel="stylesheet" href="../../_assets_/nav.css">
</head>
<body>
<div class="page">
<header>
<h1>Writing Editor Panels</h1>
<p class="subtitle">How to create a new panel that lives inside a tab container in the editor.</p>
</header>
<section>
<h2>Summary</h2>
<div class="card">
<p>
Every editor panel is a custom element registered with
<code>customElements.define</code>. It lives inside a
<code>&lt;tab-container&gt;</code>, 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 <code>connectedCallback</code>, and
must be registered in <code>TabContainer.openMenu</code> so users can add it.
See <code>html-editor-panel</code> and <code>rojo-chat-panel</code> as
reference implementations.
</p>
</div>
</section>
<section>
<h2>Steps</h2>
<div class="card">
<h3>1 — Create the component files</h3>
<p>
Create two files following the naming convention:
</p>
<pre><code>src/components/&lt;name&gt;/&lt;name&gt;.ts ← TypeScript source
public/components/&lt;name&gt;/&lt;name&gt;.css ← CSS (maintained directly here, not compiled)</code></pre>
<p style="margin-top:0.75rem">
The TypeScript compiles to <code>public/components/&lt;name&gt;/&lt;name&gt;.js</code>
via <code>tsconfig.client.json</code> (<code>npm run build</code>).
</p>
</div>
<div class="card">
<h3>2 — Write the custom element</h3>
<p>
Extend <code>HTMLElement</code> and guard <code>connectedCallback</code>
with an <code>_initialized</code> flag so it only runs once. Set up the
panel's full HTML structure inside <code>connectedCallback</code>:
</p>
<pre><code>class MyPanel extends HTMLElement
{
_initialized = false;
connectedCallback(): void
{
if ( this._initialized ) return;
this._initialized = true;
this.className = 'my-panel';
this.innerHTML = `
&lt;div class="mp-toolbar"&gt;&lt;/div&gt;
&lt;div class="mp-content"&gt;&lt;/div&gt;
`;
}
}
customElements.define( 'my-panel', MyPanel );</code></pre>
<p style="margin-top:0.75rem">
All state that should be independent per instance (IDs, history, etc.)
must be initialised inside <code>connectedCallback</code>, not at class
level — because <strong>Duplicate</strong> creates a fresh element via
<code>document.createElement</code>, which triggers
<code>connectedCallback</code> again on the new instance.
</p>
</div>
<div class="card">
<h3>3 — Include a toolbar</h3>
<p>
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 (<code>flex: 1</code>), action buttons on the right.
Button style should match the other panels (see
<code>html-editor-panel.css</code> for the canonical colours and sizing).
</p>
<pre><code>/* 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;
}</code></pre>
</div>
<div class="card">
<h3>4 — Implement <code>addContextMenuEntries</code></h3>
<p>
Import and implement the <code>EditorPanel</code> interface from
<code>tab-container.ts</code>. 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.
</p>
<pre><code>import { ContextMenuDirectory, ContextMenuReadOnlyEntry } from '../context-menu/context-menu.js';
// inside the class:
addContextMenuEntries( dir: ContextMenuDirectory ): void
{
dir.add( new ContextMenuReadOnlyEntry( dir, 'My Panel' ) );
}</code></pre>
</div>
<div class="card">
<h3>5 — Register in TabContainer.openMenu</h3>
<p>
Open <code>src/components/tab-container/tab-container.ts</code> and add
an entry to the <code>panelTypes</code> array inside <code>openMenu</code>:
</p>
<pre><code>{ label: 'My Panel', panelType: 'my-panel', tag: 'my-panel' },</code></pre>
<p style="margin-top:0.75rem">
<code>label</code> — the text shown in the Add submenu.<br>
<code>panelType</code> — internal identifier (used for dirty-state tracking and queries).<br>
<code>tag</code> — the custom element tag passed to <code>document.createElement</code>.
</p>
<p style="margin-top:0.75rem">
<strong>Duplicate</strong> works automatically once the panel is registered —
it calls the same factory, creating a new independent instance.
</p>
</div>
<div class="card">
<h3>6 — Wire into editor.html</h3>
<p>
Add the CSS link and module script to <code>public/editor.html</code>:
</p>
<pre><code>&lt;link rel="stylesheet" href="/components/my-panel/my-panel.css"&gt;
&lt;script type="module" src="/components/my-panel/my-panel.js"&gt;&lt;/script&gt;</code></pre>
<p style="margin-top:1rem"><strong>Using a UMD vendor library (e.g. CodeMirror, markdown-it)</strong></p>
<p>
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:
</p>
<ol style="margin-top:0.5rem;line-height:1.9">
<li>Download the minified build and place it in <code>public/vendor/</code>.</li>
<li>Add a plain <code>&lt;script&gt;</code> tag in <code>editor.html</code>
<strong>before</strong> the module script. Order matters — the global must
exist before the module runs:
<pre style="margin-top:0.5rem"><code>&lt;script src="/vendor/some-lib.min.js"&gt;&lt;/script&gt;
&lt;script type="module" src="/components/my-panel/my-panel.js"&gt;&lt;/script&gt;</code></pre>
</li>
<li>In the TypeScript source file, declare the global at the top so the
compiler accepts it without a type package:
<pre style="margin-top:0.5rem"><code>declare const SomeLib: any;</code></pre>
</li>
<li>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.
</li>
</ol>
<p style="margin-top:0.75rem">
Existing examples: <code>markdown-it</code> (used in <code>rojo-chat-panel</code>),
CodeMirror 5 and its language mode files (used in <code>code-panel</code>).
</p>
</div>
<div class="card">
<h3>7 — Update the tab label with <code>panel:label-change</code></h3>
<p>
When a panel loads a file it should update its own tab title to reflect the
open filename. Dispatch a bubbling <code>CustomEvent</code> named
<code>panel:label-change</code> with a <code>detail.label</code> string —
the <code>TabContainer</code> listens for it and updates the tab automatically:
</p>
<pre><code>_updateTabLabel( path: string ): void
&#123;
const name = path ? path.slice( path.lastIndexOf( '/' ) + 1 ) : '';
this.dispatchEvent( new CustomEvent( 'panel:label-change',
&#123;
bubbles: true,
detail: &#123; label: '📄 ' + name &#125;,
&#125; ) );
&#125;</code></pre>
<p style="margin-top:0.75rem">
Call this from your <code>_loadDocument</code> (or equivalent) method, after
setting <code>this.currentPath</code>. The event must bubble so it reaches
the ancestor <code>&lt;tab-container&gt;</code>.
</p>
<p style="margin-top:0.75rem">
The initial tab label (shown before any file is opened) is set in
<code>TabContainer.openMenu</code> via the <code>label</code> field of the
<code>panelTypes</code> entry — that is the only place the label is set
without this event.
</p>
</div>
</section>
<section>
<h2>Responsive Layout</h2>
<div class="decision">
<strong>Panels must work on desktop, tablet, and mobile in both orientations</strong>
<p>
The editor shell handles the outer panel arrangement and switching between
landscape and portrait modes. Inside the panel, use <code>height: 100%</code>
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
<code>flex: 1</code> for the scrollable area and <code>flex-shrink: 0</code>
for the toolbar and any fixed-height input areas.
</p>
</div>
<div class="decision">
<strong>Input areas at the bottom should not overlap the content</strong>
<p>
If the panel has an input area (like a chat box), place it as the last
child and give it <code>flex-shrink: 0</code>. The scrollable content
area above it takes <code>flex: 1</code>. 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.
</p>
</div>
</section>
<footer>
Roject &mdash; writing editor panels
</footer>
</div>
<script>var NAV_ROOT = '../../';</script>
<script src="../../_assets_/nav-data.js"></script>
<script src="../../_assets_/nav.js"></script>
</body>
</html>