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 itsslot="surface"with your adapter inadapter, and then TipTap is never loaded.@astrolabe/editorhas the kit's pieces, for a layout of your own:createAuthoringand the four elements. It contains no WYSIWYG engine; only@astrolabe/editor-tiptapimports 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)The adapter contract
Section titled “The adapter contract”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 importconst 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.
The pieces on their own
Section titled “The pieces on their own”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 importconst 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 });}The pieces
Section titled “The pieces”| 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).
Inspector models and changes
Section titled “Inspector models and changes”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-textThe textarea adapter
Section titled “The textarea adapter”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.
Writing an adapter for your editor
Section titled “Writing an adapter for your editor”- 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 whosecontent: 'inline'children (prompt, options) are editable rich text; privatecontent: 'text'children (accepted alternatives) and private attributes are data the inspector edits. - Serialise your document to the content format in
getSource()and parse it back insetSource(). - Implement
getElement/replaceElementfor one component by id (the inspector uses them). - Fire
onChangeafter every document change andonSelectionChangewhen 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.
Secrets
Section titled “Secrets”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.
Publishing
Section titled “Publishing”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.