259 lines
10 KiB
HTML
259 lines
10 KiB
HTML
|
|
<!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><tab-container></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/<name>/<name>.ts ← TypeScript source
|
||
|
|
public/components/<name>/<name>.css ← CSS (maintained directly here, not compiled)</code></pre>
|
||
|
|
<p style="margin-top:0.75rem">
|
||
|
|
The TypeScript compiles to <code>public/components/<name>/<name>.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 = `
|
||
|
|
<div class="mp-toolbar">…</div>
|
||
|
|
<div class="mp-content">…</div>
|
||
|
|
`;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
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><link rel="stylesheet" href="/components/my-panel/my-panel.css">
|
||
|
|
<script type="module" src="/components/my-panel/my-panel.js"></script></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><script></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><script src="/vendor/some-lib.min.js"></script>
|
||
|
|
<script type="module" src="/components/my-panel/my-panel.js"></script></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
|
||
|
|
{
|
||
|
|
const name = path ? path.slice( path.lastIndexOf( '/' ) + 1 ) : '';
|
||
|
|
this.dispatchEvent( new CustomEvent( 'panel:label-change',
|
||
|
|
{
|
||
|
|
bubbles: true,
|
||
|
|
detail: { label: '📄 ' + name },
|
||
|
|
} ) );
|
||
|
|
}</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><tab-container></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 — 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>
|