EXTENSIONS / Custom Editors

FAQs

FAQs

Custom Editors

Build a custom editor extension for Nimbalyst: the useEditorLifecycle hook, the EditorHost interface, undo and redo, and large file handling.

This guide covers building custom editor extensions: the useEditorLifecycle hook, the EditorHost interface, styling, and patterns for larger editors. It is for extension developers; read Getting Started first if you have not built an extension yet.

Custom editors are the most powerful extension type. They let you create entirely new ways to view and edit file types, from spreadsheets to diagrams to 3D models.

How Custom Editors Work

When a user opens a file, Nimbalyst checks if any extension has registered a custom editor for that file type. If found, your React component is rendered instead of the default editor.

Your component receives a single host prop from Nimbalyst. The host handles loading, saving, dirty tracking, file change notifications, and optional features like diff mode.

The useEditorLifecycle Hook

The recommended way to build custom editors is with the useEditorLifecycle hook. It replaces all manual EditorHost subscription boilerplate (loading, saving, echo detection, file watching, diff mode, theme) with a single hook call.

import { useEditorLifecycle } from '@nimbalyst/extension-sdk';
import type { EditorHostProps } from '@nimbalyst/extension-sdk';

export function MyEditor({ host }: EditorHostProps) {
  const editorRef = useRef<MyEditorAPI>(null);

  const { isLoading, error, theme, markDirty, diffState } = useEditorLifecycle(host, {
    applyContent: (data) => editorRef.current?.load(data),
    getCurrentContent: () => editorRef.current?.getData() ?? defaultValue,
    parse: (raw) => JSON.parse(raw),
    serialize: (data) => JSON.stringify(data),
  });

  if (error) return <div>Failed to load: {error.message}</div>;
  if (isLoading) return <div>Loading...</div>;

  return <MyEditorComponent ref={editorRef} onChange={markDirty} theme={theme} />;
}

How It Works

The hook interacts with your editor through pull/push callbacks; content never lives in React state:

  • applyContent(parsed): Called to push content INTO the editor (on initial load, on external file change). Update your editor's internal state here.
  • getCurrentContent(): Called to pull content FROM the editor (on save). Return the current state. Omit for read-only editors.
  • parse(raw): Convert raw file string into your editor's format. Omit if your editor works with raw strings.
  • serialize(data): Convert your editor's format back to a string for saving. Omit if already a string.

What It Returns

FieldTypeDescription
isLoadingbooleantrue until initial content is loaded
errorError | nullError from initial load
themestringCurrent theme name (reactive)
markDirty() => voidCall when the user makes an edit
isDirtybooleanWhether unsaved changes exist
diffStateDiffState<T> | nullAI edit diff with accept/reject callbacks
toggleSourceMode(() => void) | undefinedToggle to Monaco source view
isSourceModebooleanWhether source mode is active

Editor Architecture Patterns

The hook supports three common architectures:

Library-managed (Excalidraw, Three.js): callbacks talk to the library's imperative API via refs:

const apiRef = useRef<ExcalidrawImperativeAPI>(null);
useEditorLifecycle(host, {
  applyContent: (elements) => apiRef.current?.updateScene({ elements }),
  getCurrentContent: () => apiRef.current?.getSceneElements() ?? [],
  parse: (raw) => JSON.parse(raw).elements,
  serialize: (elements) => JSON.stringify({ elements }),
});

Store-managed (Zustand, custom stores): callbacks talk to a store:

const storeRef = useRef(createMyStore());
useEditorLifecycle(host, {
  applyContent: (doc) => storeRef.current.getState().loadDocument(doc),
  getCurrentContent: () => storeRef.current.getState().document,
  parse: parseDocument,
  serialize: serializeDocument,
});

Read-only (PDF viewer, SQLite browser): only applyContent, no save:

const dataRef = useRef<ArrayBuffer | null>(null);
const [, forceRender] = useReducer((x) => x + 1, 0);
useEditorLifecycle(host, {
  applyContent: (data) => { dataRef.current = data; forceRender(); },
  binary: true,
});

Additional Options

OptionTypeDescription
binarybooleanUse loadBinaryContent() instead of loadContent(). For PDFs, images, SQLite, etc.
onLoaded() => voidCalled after initial content is loaded and applied
onExternalChange(content: T) => voidCalled when an external file change is detected (not from our own save)
onSave() => Promise<void>Replace the default save flow. Use for async content extraction (e.g., RevoGrid)
onDiffRequested(config: DiffConfig) => voidReplace default diff handling. Use for specialized diff rendering (e.g., cell-level CSV diff)
onDiffCleared() => Promise<void>Replace default diff cleanup. Paired with onDiffRequested

Echo Detection

The hook automatically ignores file change notifications caused by our own saves. This prevents the editor from reloading content immediately after saving, a common source of bugs in manual implementations.

EditorHost Interface

The useEditorLifecycle hook wraps this interface. You rarely need to use it directly, but it's useful to understand what's available. This is an abbreviated view of the core members; see the API Reference for the full interface, including storage, chat context, and editor API registration.

interface EditorHostProps {
  host: EditorHost;
}

interface EditorHost {
  readonly filePath: string;
  readonly fileName: string;
  readonly theme: string;
  readonly isActive: boolean;
  readonly workspaceId?: string;
  readonly fs?: EditorHostFileSystem;

  loadContent(): Promise<string>;
  loadBinaryContent(): Promise<ArrayBuffer>;
  onFileChanged(callback: (newContent: string) => void): () => void;
  setDirty(isDirty: boolean): void;
  saveContent(content: string | ArrayBuffer): Promise<void>;
  onSaveRequested(callback: () => void | Promise<void>): () => void;
  onThemeChanged(callback: (theme: string) => void): () => void;
  openHistory(): void;

  // Diff mode (AI edits)
  onDiffRequested?(callback: (config: DiffConfig) => void): () => void;
  reportDiffResult?(result: DiffResult): void;
  onDiffCleared?(callback: () => void): () => void;

  // Source mode (toggle to Monaco)
  toggleSourceMode?(): void;
  onSourceModeChanged?(callback: (isActive: boolean) => void): () => void;
  isSourceModeActive?(): boolean;
}

Key Concepts

Content Ownership

Nimbalyst editors use a host-driven save model where the editor owns its content state:

  1. Initial load: The hook calls host.loadContent() and passes the result to your applyContent callback
  2. Dirty tracking: Call markDirty() when the user makes changes
  3. Saving: The hook subscribes to save events and calls your getCurrentContent to get the data
  4. External changes: The hook detects external file changes, filters echoes, and calls applyContent

Why Not Pass Content as a Prop?

The EditorHost model is more efficient for complex editors:

  • Spreadsheets with thousands of cells do not need to serialize on every keystroke
  • Diagram editors can maintain rich object graphs internally
  • Binary editors can load and save ArrayBuffer data without pretending everything is text
  • Imperative editor libraries (Excalidraw, RevoGrid, Three.js) cannot be re-rendered anyway

Accessing Other Project Files

A file-backed custom editor can use the optional host.fs service when it needs to read or update project files beyond the document currently open in the editor. This is useful for CAD projects, generated assets, compilers, analyzers, and editors backed by several related files.

const snapshots = await host.fs?.read([
  'models/assembly.json',
  'models/materials.json',
]);

const assembly = snapshots?.[0];
if (assembly?.exists && assembly.content && assembly.sha256) {
  await host.fs?.write({
    label: 'Update assembly',
    actor: 'user',
    changes: [
      {
        path: assembly.path,
        expectedSha256: assembly.sha256,
        content: updateAssembly(assembly.content),
      },
    ],
  });
}

The service:

  • Restricts paths to the active workspace, including protection against symlink escapes.
  • Returns a SHA-256 version token with every read.
  • Uses the version token for compare-and-swap writes so an editor cannot silently overwrite a newer file.
  • Refuses to write a file that has unsaved changes in another open editor.
  • Records successful writes in Nimbalyst document history.
  • Can observe external project-file changes through host.fs.onChanged().

host.fs is optional. It is not present for virtual, embedded, offscreen, or collaborative hosts that cannot provide raw local-disk semantics. Always guard it before use and provide a clear read-only or unavailable state.

Use loadContent() and saveContent() for the editor's primary file. Use host.fs only for additional project files.

Registering the Editor

In your manifest.json:

{
  "contributions": {
    "customEditors": [
      {
        "filePatterns": ["*.mytype", "*.myt"],
        "displayName": "My Type Editor",
        "component": "MyEditor",
        "supportsDiffMode": false,
        "showDocumentHeader": false
      }
    ]
  }
}

And export it from your entry point:

// src/index.ts
import { MyEditor } from './MyEditor';

export const components = {
  MyEditor,
};

Styling Your Editor

Using CSS Variables

Nimbalyst provides CSS variables for theming. Always use these instead of hardcoded colors:

.my-editor {
  background: var(--nim-bg);
  color: var(--nim-text);
  border: 1px solid var(--nim-border);
}

.my-editor-toolbar {
  background: var(--nim-bg-secondary);
  border-bottom: 1px solid var(--nim-border);
}

.my-editor-button:hover {
  background: var(--nim-bg-hover);
}

Available CSS Variables

VariablePurpose
--nim-bgMain background
--nim-bg-secondaryToolbar/panel background
--nim-bg-tertiaryNested element background
--nim-bg-hoverHover state background
--nim-textMain text color
--nim-text-mutedMuted text
--nim-text-faintVery muted text
--nim-borderMain borders
--nim-primaryAccent/brand color

Including Styles

Create a styles.css file and reference it in your manifest:

{
  "styles": "dist/index.css"
}

Import it in your entry point:

// src/index.ts
import './styles.css';

Handling Large Files

For large files, consider:

Virtualization

Only render visible rows/items:

import { useVirtualizer } from '@tanstack/react-virtual';

function LargeListEditor({ items }) {
  const parentRef = useRef<HTMLDivElement>(null);

  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 35,
  });

  return (
    <div ref={parentRef} style={{ height: '100%', overflow: 'auto' }}>
      <div style={{ height: virtualizer.getTotalSize() }}>
        {virtualizer.getVirtualItems().map(virtualRow => (
          <div
            key={virtualRow.key}
            style={{
              position: 'absolute',
              top: virtualRow.start,
              height: virtualRow.size,
            }}
          >
            {items[virtualRow.index]}
          </div>
        ))}
      </div>
    </div>
  );
}

Lazy Parsing

Parse content incrementally:

function parseContentLazy(content: string) {
  // Return a lightweight wrapper that parses on demand
  return {
    getRow(index: number) {
      // Parse just this row when needed
    },
    get length() {
      // Count rows without full parse
    }
  };
}

Undo/Redo Support

Nimbalyst doesn't provide built-in undo for custom editors. Implement your own:

import { useState, useCallback } from 'react';

function useUndoRedo<T>(initialState: T) {
  const [history, setHistory] = useState<T[]>([initialState]);
  const [index, setIndex] = useState(0);

  const state = history[index];

  const setState = useCallback((newState: T) => {
    const newHistory = history.slice(0, index + 1);
    newHistory.push(newState);
    setHistory(newHistory);
    setIndex(newHistory.length - 1);
  }, [history, index]);

  const undo = useCallback(() => {
    if (index > 0) setIndex(index - 1);
  }, [index]);

  const redo = useCallback(() => {
    if (index < history.length - 1) setIndex(index + 1);
  }, [index, history.length]);

  const canUndo = index > 0;
  const canRedo = index < history.length - 1;

  return { state, setState, undo, redo, canUndo, canRedo };
}

Keyboard Shortcuts

Handle keyboard shortcuts in your editor:

function MyEditor({ content, onChange }) {
  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      // Cmd/Ctrl + Z for undo
      if ((e.metaKey || e.ctrlKey) && e.key === 'z' && !e.shiftKey) {
        e.preventDefault();
        undo();
      }
      // Cmd/Ctrl + Shift + Z for redo
      if ((e.metaKey || e.ctrlKey) && e.key === 'z' && e.shiftKey) {
        e.preventDefault();
        redo();
      }
    };

    window.addEventListener('keydown', handleKeyDown);
    return () => window.removeEventListener('keydown', handleKeyDown);
  }, [undo, redo]);

  // ...
}

Example: Simple Table Editor

A complete example using useEditorLifecycle:

import React, { useRef } from 'react';
import { useEditorLifecycle } from '@nimbalyst/extension-sdk';
import type { EditorHostProps } from '@nimbalyst/extension-sdk';

export function TableEditor({ host }: EditorHostProps) {
  const dataRef = useRef<string[][]>([]);
  const tableRef = useRef<HTMLTableElement>(null);

  const parseCSV = (text: string): string[][] =>
    text.split('\n').map(row => row.split(',').map(cell => cell.trim()));

  const serializeCSV = (data: string[][]): string =>
    data.map(row => row.join(',')).join('\n');

  const { isLoading, error, theme, markDirty } = useEditorLifecycle(host, {
    applyContent: (data: string[][]) => {
      dataRef.current = data;
      renderTable();
    },
    getCurrentContent: () => dataRef.current,
    parse: parseCSV,
    serialize: serializeCSV,
  });

  function renderTable() {
    // Re-render table imperatively or use forceUpdate
  }

  if (error) return <div>Error: {error.message}</div>;
  if (isLoading) return <div>Loading...</div>;

  return (
    <div style={{ padding: '10px', overflow: 'auto', height: '100%' }}>
      <table ref={tableRef} style={{ borderCollapse: 'collapse', width: '100%' }}>
        <tbody>
          {dataRef.current.map((row, rowIndex) => (
            <tr key={rowIndex}>
              {row.map((cell, colIndex) => (
                <td key={colIndex} style={{ border: '1px solid var(--nim-border)' }}>
                  <input
                    defaultValue={cell}
                    onChange={e => {
                      dataRef.current[rowIndex][colIndex] = e.target.value;
                      markDirty();
                    }}
                    style={{
                      width: '100%',
                      padding: '4px',
                      border: 'none',
                      background: 'transparent',
                      color: 'var(--nim-text)'
                    }}
                  />
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

Best Practices

Shared documents

useEditorLifecycle handles file-backed editing. A custom editor that opts into shared documents also needs two collaboration pieces:

  • useCollaborativeEditor binds one mounted editor to the shared Y.Doc for live edits and presence.
  • A CollabContentAdapter lets the host seed a shared document from a file, export it, build revision snapshots and search text, and apply structured AI edits even when no tab is open.

Register one adapter per documentType during activate() with context.services.collab.registerContentAdapter(adapter), and declare matching collaboration metadata on the custom editor contribution. Implement toPlainText for search and AI read access; AI write access also requires toStructured and applyStructuredPatch so the host has a typed patch contract.

If the Y.Doc layout changes, bump the adapter's layoutVersion and provide migrations. Adapters run in the client against an already decrypted Y.Doc; the collaboration server continues to treat document state as opaque encrypted data.

  1. Use useEditorLifecycle - Handles loading, saving, echo detection, file watching, diff mode, and theme
  2. Keep content out of React state - Use refs or external stores for editor data
  3. Use CSS variables - Your editor should respect the user's theme
  4. Handle empty content - The file might be new or empty
  5. Call markDirty() - Not host.setDirty() directly -- the hook tracks dirty state for you
  6. Test with large files - Ensure your editor performs well
  7. Register a collaboration adapter if the file type can be shared, so history, export, search, re-upload, and AI features can understand it

Next Steps

  • Add ai-tools.md so Claude can interact with your editor
  • See manifest-reference.md for all configuration options
  • Check the built-in extensions in packages/extensions/ in the Nimbalyst repository for production examples