Skip to content

Editor

<astl-editor> is a ready exercise editor: the authoring kit (insert menu, inspector, issues list, preview) around an editing surface. The surface is TipTap by default. Any other editor plugs in through an EditorAdapter and a slot="surface" child, and then TipTap is never loaded.

import { defineEditor } from 'astrolabe/editor';
defineEditor(); // every built-in component, editor descriptor, preview element and look
<astl-editor locale="pl" components="astl-gap astl-choice"></astl-editor> <!-- TipTap, restricted -->
<astl-editor><textarea slot="surface"></textarea></astl-editor> <!-- another editor -->
  • astrolabe/editor: defineEditor(options?) with every built-in as default, AstlEditor, createTextareaAdapter, tokensCss, the built-in lists (builtInDefinitions, builtInEditors, builtInElements, builtInStyleTemplates) and @astrolabe/core.
  • astrolabe-editor.js: the script-tag bundle of astrolabe/editor (in dist/cdn/); it calls defineEditor() on load and exports the same.
  • @astrolabe/editor-tiptap: defineEditor(options) without defaults (components, editors and elements are required) and AstlEditor, for lean installs.
  • @astrolabe/editor-tiptap/tiptap: astrolabeExtensions and createTiptapAdapter, for your own TipTap editor; the only entry that imports TipTap.

None of the first three imports TipTap: <astl-editor> imports its surface, with TipTap, as a chunk of its own when it creates it.

defineEditor(options) registers what every <astl-editor> offers and defines <astl-editor> with the kit elements. Nothing is defined on import. A second call adds to the first; a tag, a look or a slot kind already registered by another object throws, as two copies would otherwise mix silently.

Option Type and meaning, and the default in astrolabe/editor
components ComponentDefinition[]: the types documents may contain, as for createEngine. Default: builtInDefinitions
editors EditorDescriptor[]: insert menu and inspector entries, one per type authors can add. Default: the builtInEditors of the components types
elements ElementEntry[]: player elements for the preview, eager or lazy, as for definePlayer. Default: the builtInElements (lazy) of the components types
styleTemplates StyleTemplate[]: looks the inspector offers and the preview renders; []: only the default look. Default: builtInStyleTemplates (playful, notebook)
catalogs MessageCatalog[]: more or overriding messages, whose keys win; English and Polish are always there. Default: none
slotKinds, profile, urlPolicy as for createEngine. Defaults: none, DEFAULT_PROFILE, isSafeUrl
uploadAsset (file, { signal }) => Promise<{ src, width?, height? }>: your upload, for pictures (below). Its address must pass urlPolicy. Default: none, so pictures are added by address only
registry CustomElementRegistry: where the elements are defined. Default: the global customElements

An option replaces its default and is never merged with it. Extend a list with the built-ins spread first, for example a component of your own:

import { builtInDefinitions, builtInEditors, builtInElements, defineEditor } from 'astrolabe/editor';
defineEditor({
components: [...builtInDefinitions, yesNoDefinition],
editors: [...builtInEditors, yesNoEditor],
elements: [...builtInElements, yesNoElement],
});

To let authors use fewer types, set the components attribute: the other types stay registered, so pasted content of those types is kept and reported. To register fewer, pass fewer components: the default editors and elements follow them. A list you pass still replaces its default, and an editor descriptor without its component throws.

const tags = ['astl-gap', 'astl-choice'];
defineEditor({ components: builtInDefinitions.filter((d) => tags.includes(d.manifest.tag)) });

There is no secret: the preview compiles in the browser with a random secret and salt, so preview ids never match published ones.

Name Values Default Meaning
locale (attribute, property) en, pl the editor's lang, else the browser languages, else en UI locale of the kit and the surface; also the lang of a new document
components (attribute, property) space-separated tags, or string[] | null null: every registered type the types of the menu, the inspector, validation and the preview; other types stay in the document and show ELEMENT_UNKNOWN
theme (attribute, property) light, dark, hc, auto auto written to data-theme and passed to the kit elements and the preview
value (property) source HTML (<astl-doc>) an empty <astl-doc> in the UI locale's lang reads adapter.getSource(), writes adapter.setSource(); kept until there is an adapter
adapter (property) EditorAdapter | null TipTap, on a surface the editor creates setting another adapter swaps the editor; null brings the TipTap surface back, with its document, unless a slot="surface" child is there
authoring (read-only property) Authoring the kit's logic for these registrations, locale and components: validate, describe, preview and more
surface (slot) your editor's elements the TipTap surface with an adapter set (also before defineEditor runs) or a slot="surface" child when the editor first renders, TipTap is never loaded
astl-editor-change (event) no detail after every change of the document, the author's or value; bubbles and is composed

The TipTap schema keeps every registered type, whatever components says, so pasted content is never dropped. To check a source as the editor does: editor.authoring.validate(editor.value).

Parts: surface, insert-menu, inspector, issues and preview. Tokens set on <astl-editor> reach the kit elements, the preview player and the surface; see Looks.

The kit never publishes. When the author saves, take editor.value, validate it (editor.authoring.validate) and store it; a source with zero errors compiles, warnings are advice.

Put the textarea (and its label) in slot="surface" and set adapter to createTextareaAdapter, which edits the source as text and keeps your formatting:

<astl-editor>
<label slot="surface" for="source">Source (HTML)</label>
<textarea slot="surface" id="source"></textarea>
</astl-editor>
import { createTextareaAdapter, defineEditor } from 'astrolabe/editor';
defineEditor();
const editor = document.querySelector('astl-editor');
editor.adapter = createTextareaAdapter(editor.querySelector('textarea'));

Until the adapter is set, the kit waits: its buttons are disabled and the issues list is empty. It runs live on the authoring kit example page (examples/editor-textarea).

Any editor works the same way: its element in slot="surface", and an EditorAdapter over it in adapter (source in and out, one element by id, the selection, change events). How to write one for CKEditor, Lexical or ProseMirror: Any WYSIWYG editor.

The surface is a TipTap editor (StarterKit restricted to the content format) in the editor's light DOM, slotted into surface: ProseMirror selection is unreliable inside shadow roots. It is imported on demand, as its own chunk; while it loads, the surface says "Loading the editor…" in a status message, and if the chunk cannot be loaded, "The editor could not be loaded. Reload the page to try again." while the kit stays disabled. Its nodes come from the manifests: inline types are atoms shown as chips ([grey]), blocks with rich children are framed with their children editable, other blocks are atoms. TipTap adds no <style> element: the surface stylesheet is adopted, so the editor works under the strict policy of the security checklist.

  • Enter in a child adds a sibling: another option after an option, the first option after the prompt. Nothing of the answer is copied (an option's correct), and each child keeps a local id of its own: a new or pasted copy gets <tag>-<n>, for example option-1.
  • A pasted or dropped copy of a component or a page whose id another one has gets a fresh id, as the insert menu makes them (gap-2), and keeps everything else, its answer too. The ids inside it (a choice's option ids) are local to it and stay. Cut and paste is a move: the id stays.
  • <astl-doc> keeps its attributes as written; rich text keeps lang and dir, never other attributes.

"Picture" in the insert menu's "Content" group asks for a picture in a dialog: a file, which uploadAsset stores, or its address (the only way without uploadAsset, as the dialog says); then the alt text, required unless the picture is decorative (alt=""), and an optional caption, which makes it a <figure>. The address must pass urlPolicy, an uploaded one too, and the surface shows only pictures whose address passes.

defineEditor({
// `signal` aborts when the author cancels or picks another file. Check type and size on your server.
uploadAsset: async (file, { signal }) => {
const response = await fetch('/assets', { method: 'POST', body: file, signal });
const { url, width, height } = await response.json();
return { src: url, width, height };
},
});
  • A picture file pasted or dropped into the surface goes through the same upload and dialog, where it lands. Without uploadAsset nothing is inserted, and a status message says why until the next edit.
  • To change a picture's address or alt text later, select it and press Enter (in a figure: Enter in its caption, which stays one line), or press its "Edit" button. The caption is edited in place.
  • Store the file where students can load it: the player shows only pictures its own URL policy allows, and your Content Security Policy needs the asset origin in img-src (URLs).

Install @astrolabe/editor-tiptap: its /tiptap entry has the same building blocks for a TipTap editor you create yourself (astrolabe/editor does not export them, so that importing it never loads TipTap):

  • astrolabeExtensions(authoring, manifests?, { pictures? }): the extensions of the content format: StarterKit restricted to it, sub, sup, mark, span, lang and dir, pictures and figures, the document, pages and a node per type (default: the authoring's), labelled in the authoring locale. pictures (PictureHooks) is how the surface asks about pictures: ask(request) opens a picture dialog (<astl-picture-dialog>'s ask does) and refuse() says that a pasted or dropped file was not inserted (no upload). Without it, picture files are left to the browser and Enter on a picture does what it does on any selection.
  • createTiptapAdapter(editor, manifests): the EditorAdapter over that editor, for the kit elements or for <astl-editor>'s adapter.
import { Editor } from '@tiptap/core';
import { astrolabeExtensions, createTiptapAdapter } from '@astrolabe/editor-tiptap/tiptap';
const editor = new Editor({ element, injectCSS: false, extensions: astrolabeExtensions(authoring) });
const adapter = createTiptapAdapter(editor, authoring.engine.manifests);

The surface strings (surface.label, surface.pageTitle, surface.loading, surface.failed, surface.picture.*) and the picture dialog's (picture.*) are in the kit's namespace astl.editor, in English and Polish. catalogs rewords any of them (Internationalisation).