Rich text / foldkit-richtext-dom

Chrome around the editor

The editor renders its host and nothing else. A toolbar, a menu, a handle: each is the application's chrome, drawn beside the host from the Model, sending the editor's Messages. This package ships the plain versions below; foldkit-mixins-richtext draws the same chrome as slot views an application restyles.

The marks toolbar

marksToolbar({ state, toMessage, marks? }) returns a view, (h) => Html, of one button per mark, dispatching the Message a chord would. state is what the editor projects (document, selection, storedMarks), and marks defaults to the three the package ships. A mark is active when the caret carries it, or, with no stored format, when every run the selection covers does. markActive(state, mark) is that rule alone, for a renderer drawing its own buttons.

The floating toolbar

The same buttons, in an element selectionAnchor places over the selection:

ts
import * as RichText from 'foldkit-richtext'
import { marksToolbar, selectionAnchor } from 'foldkit-richtext-dom/toolbar'

RichText.coversText(model.editor.selection)
  ? h.div(
      [h.OnMount(selectionAnchor({ hostId: 'body', gap: 8 }))],
      [
        marksToolbar({
          state: { ...model.editor, document: model.document },
          toMessage: mark => edited(Message.ToggledMark({ mark })),
        })(h),
      ],
    )
  : h.empty

Whether it is drawn is the view's, from the Model: RichText.coversText is a range that is not a caret. The Mount only moves it. It places the element gap pixels above the page's selection, centred and kept inside the window's width, or below when there is no room above, and again on selectionchange, any scroll, and resize; a selection outside the host is ignored. It writes position: fixed, top, left, and data-placement (top or bottom) on the element, so a style there must leave those alone. With caret: true it follows a caret too, opening under it (above when there is no room below), which is where a slash menu goes; a caret in an empty run is read from the element it is in.

The block handle: anchor and drag

blockAnchor({ hostId, node, gap }) places an element gap pixels left of block node, level with its top edge and never past the window's left edge; with side: 'above' it goes gap pixels above the block, at its left edge, or just inside its top when there is no room above, which is where a block's toolbar (a table's) goes; with side: 'inside-end' it goes inside the block's top right corner, for a toolbar that should cover nothing around the block (a code block's, over its own header). It places it again when the editor's subtree changes (a patch can move a block without the page scrolling), on scroll, on resize, and when the element's own size changes, and it finds a host drawn after it, or drawn again in the same parent.

blockDrag({ hostId, node }) drags block node by the element it is mounted on, to any block RichText.moveTargets allows under the placed vocabulary, so an item can go into another list but not to the top level:

  • The pointer picks the nearest edge of those blocks: before one at its top, after one at its bottom. Where a container's edge meets its first or last block's, the block's is picked while the pointer is inside it, and the container's once the pointer is past it.
  • A line, [data-richtext-drop], is fixed at that edge, outside the editable subtree, for a stylesheet to draw: it has no size or color of its own, so a page that styles nothing shows no feedback at all. Once the pointer moves, the dragged block carries data-richtext-dragging until the drag ends, and the page's root element data-richtext-drag, for a grabbing cursor wherever the pointer goes. The place is read again on a scroll and on release, where the blocks are then, and release sends MovedBlock there.
  • Held within 48 pixels of the window's top or bottom, the pointer scrolls the page a frame at a time, faster the nearer the edge (edgeScroll), so a block can be dragged past what the window shows.
  • Escape, a cancelled pointer, a move with the button up (a release the page never heard, over a frame say), or a place where the block already is ends it with nothing sent.
  • Only the pointer that pressed steers or ends it. The page is listened to only during a drag, in the capture phase, so a handler that stops a release does not hide it, and the element gets touch-action: none while mounted, or a touch would pan the page instead.

Both are Mounts, and a Mount reads its args once, when its element is inserted. Key the element by the block (h.Key(node)) when the block it stands for can change.

The slash menu

The editor owns the slash menu's vocabulary, because an entry is one of its own Messages and the Bundle resolves Enter by handling the chosen entry as that Message:

ts
import { slashEntries, slashMenu, slashQuery } from 'foldkit-richtext-dom/editor'

slashQuery('see /head') // 'head': opens at a block's start or after whitespace
slashMenu(slashEntries, 'see /head', 0)?.highlighted?.label // 'Heading 1'
  • slashEntries leads with the text blocks a caret can become (Paragraph, Heading 1–3), then the standard containers (Quote, Bulleted list, Numbered list, Code block, whose id is code-block because code is the Code mark's), then the marks. Each has a stable id, a label, the words a query may also match, and its Message, and for a menu to draw, a description, the group it is listed under (Text, Lists, Blocks, Formatting), and an icon by name.
  • slashMenu(entries, textBefore, index) is the one value a view and update share: whether a query is open, what matches, and what Enter would send. A stale index falls back to the first match. textBefore is a read (RichText.textBefore(document, position)), not stored state, so there is no switch that opens or closes the menu.
  • EditorState.menuIndex is the one thing a menu owns. The application moves it (with slashMove from foldkit-mixins-richtext), and on Entered the Bundle treats the highlighted entry as the Message a click would send. Choosing an entry also removes the query typed for it, as one action: one transition and one undo step.

matchingEntries decides the order. With no query it lists every entry with each group's entries together, in the order the group first appears; with one, the best matches first (a label the query starts, then a word of a label, then a keyword, then any containing it). The keys move through that order and a menu draws it, so they cannot disagree. It, slashQuery, and slashMenu work over any entries carrying a message, so an application's own catalogue reuses them.