# Editor

> A rich-text field that saves its content as an EditorValue

Status: alpha. Added in Editor 0.1.0.

A rich-text field that saves its content as an `EditorValue`.

## Import

```tsx
import { Editor } from '@oztix/roadie-editor/editor'
```

Add the editor's CSS after the components' CSS. It places the toolbar and
styles the placeholder and selection:

```css
@import '@oztix/roadie-core/css';
@import '@oztix/roadie-components/css';
@import '@oztix/roadie-editor/css';
```

## Examples

### Default

The toolbar at the top of the editor holds the preset's controls, and each
control's tooltip shows its shortcut.

```tsx
<Editor
  aria-label='Event description'
  placeholder='Tell people about the night'
/>
```

### Toolbar

The toolbar is a bar at the top of the editor, a size smaller than the text.
It scrolls away with a long description, and looks the same whether you're
editing or not, so the text never moves. On a phone or touch screen its
controls scroll sideways on one row when they don't fit.

```tsx
<Editor
  aria-label='Event description'
  preset='rich'
  defaultValue={{
    version: 1,
    preset: 'rich',
    doc: {
      type: 'doc',
      content: [
        { type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Set times' }] },
        ...['Gates open, 11am', 'Ochre Kite, 12pm', 'Feathered Anchor Sessions, 1:15pm', 'The Jumbuck Strings, 2:30pm', 'Kazoo Hollow, 3:45pm', 'Saltbush Choir, 5pm', 'Last entry, 7pm', 'Headliner, 8:30pm', 'Curfew, 11pm'].map((set) => (
          { type: 'paragraph', content: [{ type: 'text', text: set }] }
        )),
        { type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Getting there' }] },
        { type: 'paragraph', content: [{ type: 'text', text: 'Shuttles leave Brisbane city every 20 minutes from 10am, and the last one back leaves at 11:30pm.' }] },
        { type: 'paragraph', content: [{ type: 'text', text: 'Bike racks are by the main gate. There’s no parking on site.' }] },
        { type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'What to bring' }] },
        { type: 'paragraph', content: [{ type: 'text', text: 'A hat, sunscreen, and a refillable bottle. Water stations are by each stage.' }] }
      ]
    }
  }}
/>
```

### With content

`defaultValue` takes an `EditorValue` from `@oztix/roadie-editor/schema`. The
`basic` preset holds paragraphs, bulleted and numbered lists, bold, italic,
underline, and links.

```tsx
<Editor
  aria-label='Event description'
  defaultValue={{
    version: 1,
    preset: 'basic',
    doc: {
      type: 'doc',
      content: [
        {
          type: 'paragraph',
          content: [
            { type: 'text', marks: [{ type: 'bold' }], text: 'Ochre Kite Weekender' },
            { type: 'text', text: ' returns to Jumbuck Orchard Park, Brisbane, for two days in the sun.' }
          ]
        },
        {
          type: 'bulletList',
          content: [
            { type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Gates open 11am' }] }] },
            { type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Last entry 7pm' }] }] }
          ]
        },
        {
          type: 'paragraph',
          content: [
            { type: 'text', marks: [{ type: 'italic' }], text: 'All ages. Under 16s need a guardian.' }
          ]
        }
      ]
    }
  }}
/>
```

### Links

Select text and press `Mod+K`, or use the Link control, to link it. Type a
web address such as `oztix.com.au` and the editor adds `https://`. With
nothing selected, saving inserts the address as linked text. Typing or pasting
a web address links it as you go.

The Link, Embed, Edit link, and Alt text popovers are one row: an input, a
check button that saves, and Close. Enter saves and Escape closes. Editing a
link adds Remove link between the two.

```tsx
<Editor
  aria-label='Event description'
  defaultValue={{
    version: 1,
    preset: 'basic',
    doc: {
      type: 'doc',
      content: [
        {
          type: 'paragraph',
          content: [
            { type: 'text', text: 'Feathered Anchor Sessions plays the Kazoo Hollow Room on Friday. ' },
            {
              type: 'text',
              marks: [{ type: 'link', attrs: { href: 'https://www.oztix.com.au/feathered-anchor-sessions' } }],
              text: 'Get tickets'
            }
          ]
        }
      ]
    }
  }}
/>
```

A link that could run script, such as a `javascript:` address, never reaches
the saved value. The link editor refuses it, pasted HTML keeps only its text,
and `migrate` drops it from a stored value.

### Rich preset

`preset='rich'` adds headings 2 to 4, quotes, and dividers to the basic
preset, for an event page. The Block type menu leads the toolbar and names
the block at the caret, then come the basic controls, from Bold, Italic, and
Underline to the lists. Markdown works as you type: `## `, `### `, and
`#### ` start a heading, `> ` a quote, and `---` a divider.

```tsx
<Editor
  aria-label='Event description'
  preset='rich'
  defaultValue={{
    version: 1,
    preset: 'rich',
    doc: {
      type: 'doc',
      content: [
        {
          type: 'paragraph',
          content: [
            { type: 'text', marks: [{ type: 'bold' }], text: 'Ochre Kite Weekender' },
            { type: 'text', text: ' returns to Jumbuck Orchard Park, Brisbane, for two days in the sun.' }
          ]
        },
        { type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Lineup' }] },
        {
          type: 'bulletList',
          content: [
            { type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Velvet Kookaburra' }] }] },
            { type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'The Saltbush Echoes' }] }] }
          ]
        },
        { type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: 'Getting there' }] },
        { type: 'paragraph', content: [{ type: 'text', text: 'Shuttles leave Brisbane city every 20 minutes from 10am.' }] },
        { type: 'horizontalRule' },
        {
          type: 'blockquote',
          content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Bring a hat, sunscreen, and a refillable bottle.' }] }]
        }
      ]
    }
  }}
/>
```

The page owns its `h1`, so pasted `h1` becomes a heading 2, and `h5` and
`h6` become a heading 4.

### Images

The rich preset holds images. Pass `onUploadImage` to add the Image control:
it uploads a file the person picks, pastes, or drops, and resolves to where
the image is served. A placeholder shows while it uploads, and a rejection's
message shows in `Field.ErrorText`. Without `onUploadImage` there's no Image
control, and image files are ignored.

```tsx
function uploadImage(file) {
  // Send the file to your storage and resolve to where it's served.
  return new Promise((resolve) =>
    setTimeout(() => resolve({ src: '/brand/photography/04.webp' }), 1000)
  )
}

render(
  <Field>
    <Field.Label>Event description</Field.Label>
    <Editor
      preset='rich'
      onUploadImage={uploadImage}
      defaultValue={{
        version: 1,
        preset: 'rich',
        doc: {
          type: 'doc',
          content: [
            {
              type: 'paragraph',
              content: [{ type: 'text', text: 'Show your ticket on your phone at the Kazoo Hollow Room door.' }]
            },
            {
              type: 'image',
              attrs: { src: '/brand/photography/04.webp', alt: 'A staff member scans a ticket on a phone at the door' }
            }
          ]
        }
      }}
    />
    <Field.ErrorText />
  </Field>
)
```

An image saves only its `src` and `alt`. The `src` must be `https` or a path
on the same site, such as `/images/poster.webp`; any other address is refused
when it's inserted, pasted, or loaded.

Click an image, or move onto it with the arrow keys, to edit it in place. A
toolbar by the image has Alt text, Replace image (with `onUploadImage`, which
uploads the new file the same way and keeps the alt text), and Remove image.
An image with no alt text shows a No alt text badge until it has some; Enter
on a selected image opens Alt text too.

### Embeds

The rich preset embeds videos and tracks from YouTube, Vimeo, Spotify, and
TikTok. Paste a share link alone on an empty line, or use the Embed control.
The editor shows each player at its own shape, under a cover, so a click
selects it rather than playing it, and Backspace or Delete removes it. A
player with a ratio is no taller than 36rem, so a portrait one, such as
TikTok's, stays narrow at the start of the line, in the editor and in
`/render`.

```tsx
<Editor
  aria-label='Event description'
  preset='rich'
  defaultValue={{
    version: 1,
    preset: 'rich',
    doc: {
      type: 'doc',
      content: [
        { type: 'paragraph', content: [{ type: 'text', text: 'Warm up for the Ochre Kite Weekender.' }] },
        { type: 'embed', attrs: { provider: 'youtube', id: 'sBzrzS1Ag_g', params: null } },
        { type: 'embed', attrs: { provider: 'spotify', id: '5INjqkS1o8h1imAzPqGZBb', params: { type: 'artist' } } },
        { type: 'embed', attrs: { provider: 'vimeo', id: '1084537', params: null } },
        { type: 'embed', attrs: { provider: 'tiktok', id: '6718335390845095173', params: null } }
      ]
    }
  }}
/>
```

An embed saves only its provider, its id, and the params its provider reads,
never a URL, so `/render` builds the player's address from
`defaultEmbedProviders` and a provider can move its player without a
migration. Players run in a sandboxed `iframe`, and a look-alike host such as
`youtube.com.evil.test` is never embedded.

- `embedProviders` sets the providers the editor reads links from, starting
  from `defaultEmbedProviders`. Pass the same list to `/render`, or it drops
  the embeds it doesn't know.
- `resolveUrl` turns a link no provider reads, such as a TikTok short link,
  into one it can, when it's entered in the Embed control. Resolve it on
  your server.

Click an embed, or move onto it with the arrow keys, to edit it in place. A
toolbar by the embed has Edit link and Remove embed. Edit link starts from the
embed's share link, from its provider's optional `shareUrl`, and replaces the
embed with the new link's player. It refuses a link no provider reads, as the
Embed control does.

### Tables

The rich preset holds tables. The Table control inserts one of three rows
and three columns, with a header row. Tab moves to the next cell, adding a
row after the last, and Shift+Tab moves back, leaving the editor from the
first cell. While the caret is in a table, a toolbar at its start edge, above
it, or below it when a table comes first, has its actions: a Row menu with
Insert above, Insert below, and Delete row, a Column menu with Insert before,
Insert after, and Delete column, the Header row toggle, and Delete table.

```tsx
<Editor
  aria-label='Event description'
  preset='rich'
  defaultValue={{
    version: 1,
    preset: 'rich',
    doc: {
      type: 'doc',
      content: [
        {
          type: 'table',
          content: [
            {
              type: 'tableRow',
              content: [
                { type: 'tableHeader', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Time' }] }] },
                { type: 'tableHeader', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Act' }] }] }
              ]
            },
            {
              type: 'tableRow',
              content: [
                { type: 'tableCell', content: [{ type: 'paragraph', content: [{ type: 'text', text: '7pm' }] }] },
                { type: 'tableCell', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'The Saltbush Echoes' }] }] }
              ]
            },
            {
              type: 'tableRow',
              content: [
                { type: 'tableCell', content: [{ type: 'paragraph', content: [{ type: 'text', text: '9pm' }] }] },
                { type: 'tableCell', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Velvet Kookaburra' }] }] }
              ]
            }
          ]
        }
      ]
    }
  }}
/>
```

A cell holds paragraphs, with bold, italic, underline, and links, but no
headings, lists, images, embeds, or other tables, so a list pasted into a
cell, as from Word, keeps each item as a paragraph. A table saves no widths or
styles, only each cell's `colspan` and `rowspan`, and a wide one scrolls
sideways in the editor and in `/render`, so it never widens the page on a
phone.

### Emphasis

`emphasis` sets the frame. The toolbar takes its own
[emphasis](#toolbar-emphasis), so it's the same in each.

| Emphasis           | Frame                                   | Use it for                                                |
| ------------------ | --------------------------------------- | --------------------------------------------------------- |
| `normal` (default) | A field's border, fill, and focus ring  | A form, beside other fields                               |
| `subtle`           | A tint with no border, and a focus ring | A form on a busy surface, where a border adds noise       |
| `subtler`          | None: the content sits on the page      | Content edited in place, such as an event page's sections |

```tsx
<Editor aria-label='Normal emphasis' emphasis='normal' placeholder='Normal emphasis' />
<Editor aria-label='Subtle emphasis' emphasis='subtle' placeholder='Subtle emphasis' />
```

#### Subtler

`emphasis='subtler'` drops the border, fill, and padding, so the text lines up
with the page around it and editing looks like reading. The caret shows
where focus is.
Its skeleton is lines of text, not a box.

```tsx
<h2 id='about-the-event' className='text-display-ui-4 text-strong'>
  About the event
</h2>
<Editor
  aria-labelledby='about-the-event'
  emphasis='subtler'
  preset='rich'
  defaultValue={{
    version: 1,
    preset: 'rich',
    doc: {
      type: 'doc',
      content: [
        {
          type: 'paragraph',
          content: [
            { type: 'text', marks: [{ type: 'bold' }], text: 'Ochre Kite Weekender' },
            { type: 'text', text: ' returns to Jumbuck Orchard Park, Brisbane, for two days in the sun.' }
          ]
        },
        {
          type: 'bulletList',
          content: [
            { type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Gates open 11am' }] }] },
            { type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Shuttles from Brisbane city every 20 minutes' }] }] }
          ]
        }
      ]
    }
  }}
/>
```

An invalid `subtler` editor has no border to turn red, so it draws a danger
edge on its start side, just outside the text, and `Field.ErrorText` says
what's wrong. Give it room at the start: a container that clips its overflow
hides the edge. A disabled one dims as a disabled field does.

#### Toolbar emphasis

`Editor.Toolbar` takes [Toolbar's `emphasis`](https://ticketsolutionsptyltd.github.io/roadie/components/toolbar.md#emphasis) for its bar, the same on every screen and
whether you're editing or not. It's independent of the editor's own `emphasis`. To
change it, pass the parts yourself:

| Emphasis           | Bar                                      | Use it for                                   |
| ------------------ | ---------------------------------------- | -------------------------------------------- |
| `normal` (default) | A visible border on the normal surface   | Most editors, in a form or on the page       |
| `subtle`           | A tint with no border                    | A busy surface, where a border adds noise    |
| `subtler`          | No surface, just the controls            | A quiet bar, such as over a `subtler` editor |
| `raised`           | A translucent raised surface             | An editor on a tinted surface                |
| `floating`         | A translucent surface with a deep shadow | A bar that should read as above the content  |

```tsx
<Editor aria-label='Subtler toolbar' placeholder='Subtler toolbar'>
  <Editor.Toolbar emphasis='subtler' />
  <Editor.Content />
</Editor>
<Editor aria-label='Raised toolbar' placeholder='Raised toolbar'>
  <Editor.Toolbar emphasis='raised' />
  <Editor.Content />
</Editor>
```

### Sizes

`size` sets the body and heading sizes to match `Prose`, so editing looks
like the published page.

```tsx
<Editor aria-label='Small' size='sm' placeholder='Small' />
<Editor aria-label='Medium' size='md' placeholder='Medium (default)' />
<Editor aria-label='Large' size='lg' placeholder='Large' />
```

### States

```tsx
{/* Default */}
<Editor aria-label='Event description' placeholder='Click to focus' />
{/* Read-only */}
<Editor
  aria-label='Event description'
  readOnly
  defaultValue={{
    version: 1,
    preset: 'basic',
    doc: {
      type: 'doc',
      content: [
        { type: 'paragraph', content: [{ type: 'text', text: 'Doors open 7pm at the Kazoo Hollow Room.' }] }
      ]
    }
  }}
/>
{/* Disabled */}
<Editor aria-label='Event description' placeholder='Tell people about the night' disabled />
```

### Controlled

`onValueChange` gets the whole `EditorValue` on each change. Save it as JSON,
never as HTML, and pass a stored value through `migrate` before it goes back in.
The editor takes its preset from the value. A `preset` prop that differs reads
the value into that preset, keeping the text of what it can't hold.

```tsx
function DescriptionEditor() {
  const [value, setValue] = useState()
  return (
    <Editor
      aria-label='Event description'
      placeholder='Tell people about the night'
      value={value}
      onValueChange={setValue}
    />
  )
}

render(<DescriptionEditor />)
```

### Composition

#### Field

In a `Field`, the editor is named by `Field.Label` and described by
`Field.HelperText`, or by `Field.ErrorText` while the field is invalid. It
takes `invalid`, `required`, and `disabled` from the field.

```tsx
{/* Default */}
<Field>
  <Field.Label>Event description</Field.Label>
  <Editor placeholder='Tell people about the night' />
  <Field.HelperText>Shown on the event page, under the lineup.</Field.HelperText>
</Field>
{/* Invalid */}
<Field invalid required>
  <Field.Label showIndicator>Event description</Field.Label>
  <Editor placeholder='Tell people about the night' />
  <Field.ErrorText>Add a description so people know what's on.</Field.ErrorText>
</Field>
```

#### Forms

`name` adds a hidden input that holds the `EditorValue` as JSON, so a native
form submits it. A disabled editor leaves it out, as a disabled input does.

```tsx
<form action='/events/ochre-kite-weekender' method='post'>
  <Field>
    <Field.Label>Event description</Field.Label>
    <Editor name='description' defaultValue={savedDescription} />
  </Field>
  <Button type='submit'>Save</Button>
</form>
```

#### Custom controls

With no children, `Editor` renders `Editor.Toolbar` then `Editor.Content`.
Pass them yourself to add controls: `Editor.Toolbar` renders the preset's
controls, then its children. A `Toolbar.Button` child joins the toolbar's
arrow order, and `useEditorContext()` gives it the editor to run commands on.

```tsx
function DoorsTimeControl() {
  const { editor } = useEditorContext()
  return (
    <Toolbar.Button
      onClick={() =>
        editor?.chain().focus().insertContent('Doors open 7pm. ').run()
      }
    >
      Add doors time
    </Toolbar.Button>
  )
}

render(
  <Editor aria-label='Event description' placeholder='Tell people about the night'>
    <Editor.Toolbar>
      <Toolbar.Separator />
      <DoorsTimeControl />
    </Editor.Toolbar>
    <Editor.Content />
  </Editor>
)
```

### Rendering saved content

`@oztix/roadie-editor/render` turns a saved `EditorValue` into the page's
content, with none of the editor's code. It works in a server component and
on the client.

```tsx
import { renderEditorHtml, renderEditorValue } from '@oztix/roadie-editor/render'
```

- `renderEditorValue(value, { size, className })` returns React elements in
  `Prose` at `size`, so pass the editor's size. Links render through `Link`,
  so internal hrefs route through the Link given to `RoadieProvider`.
- `renderEditorHtml(value, { size, className })` returns the same content as
  an HTML string in a `.prose` element, for a consumer that isn't React.
  External links get `target="_blank"` and `rel="noopener noreferrer"`, as
  `Link` gives them.
- Both render images with `loading="lazy"`, and embeds as a lazy, sandboxed
  `iframe` whose address they rebuild from the provider. Pass
  `embedProviders` if the editor has its own, or embeds from any other
  provider drop.
- Both render tables in a `.prose-scroll`, so a wide table scrolls on a
  phone rather than widening the page, with a first row of header cells in a
  `thead`, unless one spans down, as a `thead` would clip it.

Both run `migrate` first, so a value from a newer package, or with a preset
this one doesn't know, throws. They render only the nodes, marks, and
attributes the value's preset defines, and keep only the text of the rest. A link to an unsafe
href, such as `javascript:`, keeps its text and loses its link. Neither
renders raw HTML, so the saved JSON stays the source of truth.

The same value in the editor, then rendered:

```tsx
const description = {
  version: 1,
  preset: 'basic',
  doc: {
    type: 'doc',
    content: [
      {
        type: 'paragraph',
        content: [
          { type: 'text', marks: [{ type: 'bold' }], text: 'Ochre Kite Weekender' },
          { type: 'text', text: ' returns to Jumbuck Orchard Park, Brisbane.' }
        ]
      },
      {
        type: 'bulletList',
        content: [
          { type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Gates open 11am' }] }] },
          { type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Last entry 7pm' }] }] }
        ]
      }
    ]
  }
}

render(
  <>
    <Editor aria-label='Event description' defaultValue={description} />
    {renderEditorValue(description)}
  </>
)
```

## Guidelines

- The editor's code loads after the page, so `Editor` shows a skeleton the
  height of the empty editor until then, and always on the server. Call `preloadEditor()`
  on the hover or focus that leads to an editor to start loading it sooner.
- Put the editor in a `Field` with a `Field.Label`. Outside one, label it
  with `aria-label` or `aria-labelledby`; a `<label for>` can't name editable
  content.
- Add a custom control only for something the preset can't do. A preset's
  controls come with its schema, so each one works on what the editor can
  save.

## Accessibility

- The editable area is a `textbox` with `aria-multiline`. In a `Field` it's
  named by `Field.Label` through `aria-labelledby`, unless `Editor` has its
  own `aria-label` or `aria-labelledby`, and described by the helper or error
  text through `aria-describedby`.
- An invalid or required field sets `aria-invalid` or `aria-required`. A
  read-only editor sets `aria-readonly`, and a disabled one `aria-disabled`.
- A `subtler` editor has no focus ring: the caret shows
  focus. While invalid it shows a danger edge at its start, beside
  `aria-invalid` and the error text.
- Tab moves into and out of the editor. In a list, Tab nests an item under
  the one before it and Shift+Tab lifts it back out.
- The toolbar is a `toolbar` named Formatting, with `aria-controls` set to
  the editable area. It takes one Tab stop, and the arrow keys move between its
  controls, scrolling the one with focus into view on a phone. Bold, italic, underline,
  link, and the lists are toggles that report
  `aria-pressed` for the text at the caret. Undo and Redo are disabled when
  there's nothing to undo or redo, and stay focusable.
- A selected image or embed has its own `toolbar`, named Image actions or
  Embed actions, with the same keys as Formatting. It shows only while the
  node is selected and the editor has focus, and never while read-only or
  disabled. Each action is one step to undo.
- An image's alt text is edited in a popover with a named input, opened by
  Enter on a selected image or its Alt text action. Escape closes it and
  returns focus to the image. An upload in progress is a `status`, and an upload error
  shows in `Field.ErrorText`, or under the editor outside a `Field`.
- An embed's player is titled with its provider and id, and takes no Tab
  stop in the editor. Arrow keys select an embed or an image, and Backspace
  or Delete removes it. The Embed control's popover has a named input,
  and a link it can't embed shows its error under the row.
- While the caret is in a table, a `toolbar` named Table actions shows above
  it, or below it when above would cover Formatting, with the same keys as
  Formatting, and never while read-only or disabled. Row and Column open
  menus whose items are `menuitem`s, and a menu returns focus to the cell
  when it closes. Header row is a toggle that reports `aria-pressed`, and
  Delete row and Delete column are disabled when they'd delete the last one.
  Alt+F10 in a table moves focus to its first action, and Escape back to the
  cell. In a table, Tab and Shift+Tab move between cells instead of nesting.
  Tab in the last cell adds a row, and Shift+Tab in the first leaves the
  editor, as it does from any text.
- In the rich preset, the Block type menu's trigger is named for the block at
  the caret, such as "Block type: Heading 2". Its items are radio items, with
  the current type checked, and choosing one puts focus back in the text.
- Each control carries its shortcut in `aria-keyshortcuts`.
- The link editor is a popover by the selection, with an input named Link. It
  takes focus when it opens, Enter or the Save link button saves, and Escape
  or the Close button closes it and puts the caret back where it was. A refused
  address shows its error under the row, and focus stays in the input.
- Each popover's icon buttons are named for what they do: Save link, Embed
  link, Save alt text, Remove link, and Close. A check with nothing to save is
  disabled but keeps its Tab stop.

| Keys                       | Action                                                                                                                              |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `Mod+B`                    | Bold                                                                                                                                |
| `Mod+I`                    | Italic                                                                                                                              |
| `Mod+U`                    | Underline                                                                                                                           |
| `Mod+K`                    | Add, change, or remove a link                                                                                                       |
| `Mod+Shift+8`              | Bulleted list                                                                                                                       |
| `Mod+Shift+7`              | Numbered list                                                                                                                       |
| `Mod+Alt+0`                | Paragraph, in the rich preset                                                                                                       |
| `Mod+Alt+2` to `Mod+Alt+4` | Heading 2 to 4, in the rich preset                                                                                                  |
| `Mod+Shift+B`              | Quote, in the rich preset                                                                                                           |
| `Enter`                    | On a selected image, edit its alt text                                                                                              |
| `Tab`                      | In a table, move to the next cell, adding a row after the last                                                                      |
| `Shift+Tab`                | In a table, move to the cell before, or leave the editor from the first                                                             |
| `Mod+Z`                    | Undo                                                                                                                                |
| `Mod+Shift+Z`              | Redo                                                                                                                                |
| `Shift+Enter`              | Line break                                                                                                                          |
| `Alt+F10`                  | Move focus to the toolbar's first enabled control, a selected image's or embed's first action, or in a table its first table action |
| `Escape`                   | In a toolbar, move focus back to the text, where the caret was, or to the selected image or embed                                   |

`Mod` is `⌘` on Apple devices and `Ctrl` elsewhere, and `Alt` is `⌥` on
Apple devices.

## Hooks

### useEditorContext

```tsx
function useEditorContext(): { editor: Editor | null }
```

Returns the Tiptap `Editor` of the nearest `Editor`, for a custom control in
`Editor.Toolbar` or elsewhere among its children. `editor` is null for a moment after the editor's code
loads, until it mounts. It throws outside an `Editor`.

## API reference

### Editor

```tsx
import { Editor } from '@oztix/roadie-editor/editor'
```

- `preset`: `"basic" | "rich"`. Defaults to `the value's preset, or 'basic'`. The nodes and marks the document may hold. A value in another preset is read into this one, keeping the text of what it can't hold.
- `value`: `EditorValue`. The document, for a controlled editor.
- `defaultValue`: `EditorValue`. The starting document, for an uncontrolled editor.
- `onValueChange`: `((value: EditorValue) => void)`. Called with the whole document on each change.
- `placeholder`: `string`. Shown while the document is empty.
- `readOnly`: `boolean`.
- `disabled`: `boolean`. Takes no input and dims, as does a disabled `Field` around it.
- `name`: `string`. Submits the value as JSON with a native form, under this name.
- `size`: `"sm" | "md" | "lg"`. Defaults to `'md'`. Body size, matching `Prose`'s sizes.
- `emphasis`: `"normal" | "subtle" | "subtler"`. Defaults to `'normal'`. The frame. `normal` and `subtle` match `Textarea`'s; `subtler` has none, for content edited in place on the page.
- `className`: `string`.
- `onUploadImage`: `((file: File) => Promise<{ src: string; alt?: string; }>)`. Uploads an image the person picks, pastes, or drops, and resolves to where it's served: https, or a path on this site. Without it the editor has no image control and ignores image files. A rejection's message shows in `Field.ErrorText`.
- `embedProviders`: `readonly EmbedProvider[]`. Defaults to `defaultEmbedProviders`. The providers the editor embeds from, matching what renders the value.
- `resolveUrl`: `((url: string) => Promise<string>)`. Resolves a link no provider reads, such as a short link, to one it can. Called when one is entered in the Embed control; run it on your server.
- `aria-label`: `string`.
- `aria-labelledby`: `string`.
- `children`: `ReactNode`. Replaces the default parts, `<Editor.Toolbar />` then `<Editor.Content />`.

### Editor.Content

The editable area: a multiline textbox styled as `.prose` at the editor's size.

No props beyond the standard HTML attributes.

### Editor.Toolbar

The preset's formatting controls, then `children`, at the editor's size. Alt+F10 in the content moves focus here, and Escape moves it back.

- `className`: `string`. Classes for the toolbar's surface, its outermost element.
- `disabled`: `boolean`.
- `orientation`: `"horizontal" | "vertical"`. Defaults to `'horizontal'`. The orientation of the toolbar.
- `loopFocus`: `boolean`. Defaults to `true`. If `true`, using keyboard navigation will wrap focus to the other end of the toolbar once the end is reached.
- `emphasis`: `"normal" | "subtle" | "subtler" | "raised" | "floating"`. Defaults to `'normal'`. The surface behind the toolbar. `subtler` has none, for a toolbar inside another surface. The buttons stay at `subtler` on every one.

Docs updated 20 Oct 2026.
