Skip to content

Authoring with any WYSIWYG editor

The Astrolabe-specific parts of an editor are an authoring kit: an insert menu, an inspector for answers and settings, a live issues list and a preview that grades. It works with the editor you already use (TipTap, ProseMirror, CKEditor, Lexical, Quill, a plain textarea, your own) through one small interface, EditorAdapter.

  • <astl-editor> is the kit around a surface, ready to use: TipTap by default, or your editor in its slot="surface" with your adapter in adapter, and then TipTap is never loaded.
  • @astrolabe/editor has the kit's pieces, for a layout of your own: createAuthoring and the four elements. It contains no WYSIWYG engine; only @astrolabe/editor-tiptap imports TipTap.
your editor (TipTap, CKEditor, textarea, ...)
| implements EditorAdapter: getSource / setSource / insert / getElement / replaceElement / select / events
v
<astl-editor adapter>, or the kit elements: <astl-insert-menu> <astl-inspector> <astl-issues> <astl-preview>
| createAuthoring({ engine, descriptors, ... })
v
the engine (validate, compile) the player (preview)

Everything crosses the boundary as source HTML in the content format, so your editor may keep any internal model.

interface EditorAdapter {
getSource(): string; // the whole <astl-doc>
setSource(source: string): void;
insert(html: string): void; // an instantiated template, at the cursor
getElement(id: string): string | null; // one component element as source HTML
replaceElement(id: string, html: string): void; // inspector edits, in place
getSelection(): { id: string; tag: string } | null; // the component at the cursor
select(id: string): void; // jump to a component or page (from the issues list)
onChange(listener: () => void): () => void;
onSelectionChange(listener: (s: { id: string; tag: string } | null) => void): () => void;
}

Quick start with the built-in textarea adapter

Section titled “Quick start with the built-in textarea adapter”

<astl-editor> with a textarea in its surface slot, as in examples/editor-textarea:

<astl-editor>
<label slot="surface" for="source">Source (HTML)</label>
<textarea slot="surface" id="source" rows="20"></textarea>
</astl-editor>
import { createTextareaAdapter, defineEditor } from 'astrolabe/editor';
defineEditor(); // every built-in; nothing is defined on import
const editor = document.querySelector('astl-editor')!;
editor.adapter = createTextareaAdapter(editor.querySelector('textarea')!);

Your own editor plugs in the same way: its element in slot="surface", your adapter in adapter.

For a layout of your own, place the four kit elements yourself and give each the same authoring and adapter. With the @astrolabe/* packages, every list is explicit:

<textarea id="source" rows="20" cols="80"></textarea>
<astl-insert-menu></astl-insert-menu>
<astl-inspector></astl-inspector>
<astl-issues></astl-issues>
<astl-preview></astl-preview>
import { createEngine } from '@astrolabe/engine';
import { createAuthoring, createTextareaAdapter, defineAuthoringElements } from '@astrolabe/editor';
import { builtInDefinitions } from '@astrolabe/components/definitions';
import { builtInEditors } from '@astrolabe/components/editors';
import { builtInElements } from '@astrolabe/components/elements';
const engine = createEngine({ components: builtInDefinitions, secret: 'editor-preview-only-key' });
const authoring = createAuthoring({
engine,
descriptors: builtInEditors,
elements: builtInElements, // for the preview
locale: 'en',
// uploadAsset: (file) => upload(file), // optional: { src, width?, height? } of a stored picture
});
defineAuthoringElements(); // nothing is defined on import
const adapter = createTextareaAdapter(document.querySelector<HTMLTextAreaElement>('#source')!);
for (const el of document.querySelectorAll('astl-insert-menu, astl-inspector, astl-issues, astl-preview')) {
Object.assign(el, { authoring, adapter });
}
Piece What it does
createAuthoring(options) Pure helpers (no DOM): instantiate(tag, source, templateId?) (the template, or a named starter, with a fresh id: <idPrefix>-<n>, the smallest n the source does not use), templates(tag) (named starters), inspect(html, source?) (inspector model; with the document, a drop lists the words of its bank), update(html, change), validate(source), describe(issue), assignIds(source), outline(source), preview(source), t(namespace, key)
<astl-insert-menu> Named groups: "Components" (one button per type: label, icon name, description from its descriptor), "Exercises" (named starters) and "Content" ("Picture", through <astl-picture-dialog>: upload with uploadAsset or an address, alt text, caption); a press inserts at the cursor
<astl-inspector> Form for the component at the cursor: answers, alternatives, which options are correct, scoring, points, zones and pins on a picture. Fields come from the EditorDescriptor (or the manifest), controls from the attribute types
<astl-issues> Live validation in the UI language (300 ms after the last change), errors first; selecting an issue calls adapter.select(id)
<astl-preview> Compiles with the engine you gave createAuthoring plus a random per-session salt (preview ids never match published views) and plays the result in <astl-player> with local grading; an invalid source shows its errors instead
createTextareaAdapter(textarea) Reference adapter: edits the source as text, keeps your formatting, finds elements by id
scanElements(source) Pages and components with their offsets in the text, for text-based adapters; tolerant of comments, quoting, case and unclosed tags

Use as many pieces as you like; each element only needs its authoring and adapter properties. Pictures pasted or dropped into your editor are your editor's to handle: hand the file to <astl-picture-dialog>'s ask({ file }), which uploads it with uploadAsset and asks for the alt text, then insert pictureHtml(fields) with adapter.insert. The elements use native controls, the --astl-* tokens (override them on the elements) and ::part(root), ::part(title).

inspect(html, source?) returns { tag, id, label, icon, fields }. Each field is plain data: an attribute control (text, number with min, max and step, checkbox, select with translated options from editor.value.<attribute>.<value>, or with the document a select of what another element holds, e.g. the words of a drop's bank), a text-list (accepted alternatives), child-flags (which options are correct, each labelled by its text), child-texts (a free-text attribute per child, e.g. the word each clue hides), child-choices (one value of an enum attribute per child, e.g. whether each statement is true or false; with options, the id of another child, e.g. the group of each card) or a child-table (a row of attribute controls per child, with add and remove, e.g. a crossword's words). update(html, change) applies one change and leaves everything else byte-identical:

type InspectorChange =
| { kind: 'attribute'; name: string; value: string | boolean | null } // true = bare attribute, false/null = remove
| { kind: 'text-list'; tag: string; values: readonly string[] }
| { kind: 'child-flag'; tag: string; attribute: string; index: number; value: boolean }
| { kind: 'child-choice'; tag: string; attribute: string; index: number; value: string | null } // null = remove
| { kind: 'child-attributes'; tag: string; index: number; attributes: Record<string, string | null> }; // child-text

insert(html) replaces the selection, except where that would break the source: inside a tag it inserts after the tag, inside or on a component after that component (components do not nest), outside <astl-doc> at the end of the last page. The inserted element is selected, so the inspector shows it. replaceElement keeps the caret inside the element it replaces. Change events fire on input and after the adapter's own edits; selection events on select, keyup, click and focus, only when the component at the caret changes.

  1. Map component elements to your editor's model. Each manifest tells you how: display: 'inline' (a gap) is an inline atom; display: 'block' (a closed question) is a block whose content: 'inline' children (prompt, options) are editable rich text; private content: 'text' children (accepted alternatives) and private attributes are data the inspector edits.
  2. Serialise your document to the content format in getSource() and parse it back in setSource().
  3. Implement getElement / replaceElement for one component by id (the inspector uses them).
  4. Fire onChange after every document change and onSelectionChange when the cursor enters or leaves an component.

The TipTap integration is the package @astrolabe/editor-tiptap: <astl-editor> uses it, and its /tiptap entry has the extensions and the adapter for a TipTap editor of your own (how it maps the manifests). The textarea version is examples/editor-textarea, live on the authoring kit example page. Every option, method and element: Editor and the authoring kit reference.

The authoring kit runs the engine in the author's browser (live validation, preview). <astl-editor> gives its engine a random secret per page. With the pieces on their own, give that engine a throw-away secret such as 'editor-preview-only-key', never your production secret. Publishing compiles on your server with the real secret; preview views and keys never need to match published ones.

The kit never publishes. When the author saves, take the source (editor.value, or adapter.getSource()), validate it (editor.authoring.validate, engine.validate, or compile on your server) and store it. A source with zero errors compiles; warnings are advice.