Skip to content
Edytor
Esc
↑↓navigate↵open⌘Jpreview
On this page

Suggestions (AI)

Propose text, paragraphs, lists, to-dos and images at a place in the document, shown only in your view until you accept them, the way Notion AI drafts.

A suggestion is content a view proposes at a position: an AI draft, a completion, a rewrite. It shows only in the view that made it, rendered with the document’s own kinds inside a tinted, non-editable preview, until it is accepted or discarded. Before accept() nothing reaches the document: no other tab or collaborator sees it, presence does not carry it, onChange does not fire, and it adds no undo step. accept() writes it as one command and one undo step, which then syncs like any edit.

<script lang="ts">
	import { Edytor, type EdytorInstance } from 'edytor';

	let edytor = $state<EdytorInstance>();

	const propose = () => {
		const block = edytor?.selection.caret?.block;
		if (!edytor || !block) return;
		edytor.suggestions.add({ after: block.id }, [
			{ type: 'heading', data: { level: 'h2' }, content: [{ text: 'Next steps' }] },
			{ type: 'todo-item', data: { checked: false }, content: [{ text: 'Book the review' }] }
		]);
	};
</script>

<button onmousedown={(event) => event.preventDefault()} onclick={propose}>Suggest</button>
<Edytor bind:edytor />

<Edytor> binds Mod+Enter and Escape to it, and a bar of yours can show under the preview (Accept, Discard): see The default UI.

Positions

edytor.suggestions.add(at, content?, options?) takes one of these positions:

at Shown Accepting it
{ after: id } As the next siblings of block id (after its children) Places the blocks there
{ before: id } As the previous siblings of block id Places the blocks there
{ inside: id } As the last children of block id Places the blocks there. A block that holds no children (a void, a code line) throws at add
{ end: id } As ghost text after block id’s text: the first block’s content (more blocks show after the block) Inserts that content at the end of the text, as typing would; more blocks follow
{ replace: [ids] } The blocks are marked as removed (data-edytor-suggestion-replaced) and the content shows after the last of them. Blocks that hold nothing (an empty line, as when the user asks AI on one) fold away (data-edytor-suggestion-replaced="empty") and the content shows in their place; typing in the folded line unfolds it Deletes the blocks and places the content in the first one’s slot, in one plan. Their children that are not replaced stay, after the content
{ replace: 'selection' } Resolved when added (below)

replace: 'selection' reads the selection once, at add:

  • a block selection becomes { replace: ids }, its members (a selected list or code block with its items or lines);
  • a text range becomes { replace: { anchor, focus } }, the range’s anchors, marked in the overlay; the content shows after the range’s last block, and accepting deletes the range and pastes the content at its caret (the first block’s text joins the line, as a paste does), as one undo step;
  • a caret becomes { after: block };
  • no selection throws.

suggestion.at is the position as resolved. A position names blocks by id (a range, by anchors), so it follows them through edits elsewhere, a collaborator’s included. add throws when the position names no shown block.

The content goes in as a paste of those blocks would: in a list, a plain paragraph becomes the list’s item; in a code block, lines become code lines; the other kinds keep their kind and data.

Content and streaming

content is JSONBlock[] (the document’s JSON) or a string. A string becomes paragraphs split on blank lines (at an end, one text run, newlines kept). The paragraph kind is the default child of the position’s parent, so a string inside a code block gives code lines. To read markdown, pass textToBlocks(text).

A suggestion added without content starts streaming; with content it starts ready (options.status overrides both). Then:

  • suggestion.update(content) replaces the content. Call it on every chunk with everything received so far;
  • suggestion.append(text) adds text to the last block’s text (a new paragraph when there is none). Away from an end, a blank line in the text starts a new paragraph, even when it arrives split across two chunks;
  • suggestion.done() sets status to ready.

The preview updates as the content does, and following blocks move down. The user can type, select and edit elsewhere meanwhile: the suggestion stays where it is.

Streaming from an AI

Read the response body as text and append each chunk, then mark the suggestion done:

import type { EdytorInstance, Suggestion } from 'edytor';

/** Stream the model's answer into `suggestion`; Try again streams a new one. */
const stream = async (suggestion: Suggestion, prompt: string) => {
	const response = await fetch('/api/draft', { method: 'POST', body: JSON.stringify({ prompt }) });
	if (!response.body) return suggestion.discard();
	const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
	for (;;) {
		const { done, value } = await reader.read();
		// Accepted or discarded meanwhile: stop reading.
		if (done || !suggestion.live) break;
		suggestion.append(value);
	}
	suggestion.done();
};

/** Ask for a draft after the caret's block. */
export const draft = (edytor: EdytorInstance, prompt: string) => {
	const block = edytor.selection.caret?.block;
	if (!block) return;
	const suggestion = edytor.suggestions.add({ after: block.id }, undefined, {
		label: 'AI',
		onRetry: (again) => void stream(again, prompt)
	});
	void stream(suggestion, prompt);
};

/api/draft is your endpoint, which calls the model and streams its text back. For a model that answers in markdown, keep the text received so far and replace the content with its blocks on each chunk, so headings, lists and to-dos take their kinds as they arrive:

let markdown = '';
// in the loop, instead of append:
markdown += value;
suggestion.update(textToBlocks(markdown));

Accept and discard

suggestion.accept() writes the content and answers an OpResult ({ status, ids }):

  • One command. It runs through the dispatcher as acceptSuggestion, with the payload { suggestion: { id, at, content } }. onBeforeOperation sees the command, then each step it plans (insertBlocks, deleteBlock, insertText…; for a text range, the range’s deletion), and can refuse it with prevent(). dispatcher.last records the result.
  • One undo step. The content and anything it replaces go and come back together. Typing right after it is a step of its own.
  • Fresh ids. The blocks and atoms get new ids; the ids in content are ignored.
  • The caret ends the content: the end of the last block’s last line.
  • Refused (status: 'refused'): a readonly view, a read-only document, a veto, or a position the document no longer accepts. The suggestion stays, so the user can discard it.

Once accepted, the suggestion is gone from the list. Accepting while it streams writes what it holds at that moment.

suggestion.discard() drops it and writes nothing. A suggestion is also dropped when its block is deleted, by this view or a collaborator: a block of replace, or a block holding an end of its range. It survives everything else, the selection leaving its block included.

suggestion.retry() empties it, sets it streaming and calls options.onRetry(suggestion), where you stream another answer into it. Without onRetry it does nothing, and suggestion.retryable is false.

API

edytor.suggestions is the view’s list:

PropType
add(at, content?, options?)?Suggestion

Propose content at a position. options: { id?, status?, label?, onRetry? }. An id already listed replaces that suggestion.

TypeSuggestion
list?readonly Suggestion[]

Every suggestion, oldest first. Reactive.

Typereadonly Suggestion[]
latest?Suggestion | undefined

The newest one, which the keys act on.

TypeSuggestion | undefined
get(id)?Suggestion | undefined

One by id.

TypeSuggestion | undefined
clear()?void

Discard every suggestion.

Typevoid
at(blockId)?SuggestionsAt

What shows at a block: { before, after, inside, end, replaced, inPlace }. inPlace: the replace removes only blocks that hold nothing, so its content shows in their place. Reactive.

TypeSuggestionsAt

A Suggestion:

PropType
id?string

Its id (options.id, or minted).

Typestring
at?ResolvedAt

The position, resolved.

TypeResolvedAt
content?readonly JSONBlock[]

The proposed blocks. Reactive.

Typereadonly JSONBlock[]
status?'streaming' | 'ready'

Reactive.

Type'streaming' | 'ready'
label?string | undefined

options.label; the bar shows it.

Typestring | undefined
live?boolean

Still listed: not accepted, discarded or dropped.

Typeboolean
retryable?boolean

An onRetry was given.

Typeboolean
update(content)?void

Replace the content. Does nothing once not live.

Typevoid
append(text)?void

Add streamed text to the last block.

Typevoid
done()?void

status becomes 'ready'.

Typevoid
accept()?OpResult

Write it: one command, one undo step.

TypeOpResult
discard()?void

Drop it; writes nothing.

Typevoid
retry()?void

Empty it and call onRetry.

Typevoid

The default UI

createSuggestionsPlugin() is on by default in <Edytor>, with arrow moves, images and rich text (Default plugins): its keys. Its bar under each block suggestion, in the overlay, is your bar snippet (below; the demo’s shows the label, AI is writing… while it streams, Accept, Discard, and Try again when the suggestion has an onRetry, Discard only in a readonly view). An end suggestion has no bar: the keys accept it.

Keys Action Where
Mod + Enter Accept the latest suggestion Focus in the editor
Escape Discard the latest suggestion Focus in the editor
Tab Accept the end suggestion of the caret’s block A caret in that block, except a list-like block (bulleted, numbered, to-do, toggle, a list’s item), where Tab keeps indenting: use Mod + Enter there

The keys claim nothing while there is no suggestion, so Mod + Enter still checks to-dos and opens toggles. Plugins listed before it in plugins get the keys first (Plugin order); in a code line, the code plugin’s Tab accepts an end suggestion too.

Render the bar with createSuggestionBarPlugin({ bar }) (without it, no bar shows). Its placement is the plugin’s, the keys the default suggestions plugin’s. The bar receives a SuggestionBarPayload: the suggestion, accept, discard, retry (with an onRetry), readonly and mac (Mod is ⌘); its words are your markup’s:

<script lang="ts">
	import { Edytor, createSuggestionBarPlugin, type SuggestionBarPayload } from 'edytor';
</script>

{#snippet bar({ suggestion, accept, discard, retry, readonly }: SuggestionBarPayload)}
	<div class="ai-bar">
		<span>{suggestion.status === 'streaming' ? 'Writing…' : 'Done'}</span>
		{#if !readonly}<button onmousedown={(e) => e.preventDefault()} onclick={accept}>Keep</button>{/if}
		<button onmousedown={(e) => e.preventDefault()} onclick={discard}>Remove</button>
		{#if retry}<button onmousedown={(e) => e.preventDefault()} onclick={retry}>Again</button>{/if}
	</div>
{/snippet}

<Edytor plugins={[createSuggestionBarPlugin({ bar })]} />

Prevent the buttons’ mousedown so the editor keeps its focus and selection. <Edytor labels> (its editor.suggestion) translates a preview’s accessible name when the suggestion has no label (see Localization). A plugin instance in plugins replaces the default one; defaultPlugins={false} removes it, and suggestions still show, without a bar or keys.

Rendering and styling

A block suggestion renders inside the editor, in the document’s flow, as one group:

<div data-edytor-suggestion="s_…" data-status="streaming" contenteditable="false" role="group">
	<!-- each block, rendered by its kind: the same element, snippet and attributes -->
</div>

The kinds render their blocks as in the document, images included, with data-edytor-block and data-edytor-type (no data-edytor-id, no text elements). A snippet receives a BlockView whose handle is undefined, selected and focused false: guard commands on block.handle in a kind’s own controls. Nothing inside answers the pointer, and a press on the group is cancelled, so it never takes the caret. The group gives no caret stop, no selection, no copied content and no drag image; the editor’s DOM check ignores it.

Selector What
[data-edytor-suggestion], [data-status] A block suggestion’s group, by id, and its status
[data-edytor-suggestion-replaced] A block a replace removes (="empty": one that holds nothing, folded by the core so the content takes its place)
[data-edytor-suggestion-range] A rectangle over the text a range replace removes (overlay)
[data-edytor-text-suggestion] An end suggestion’s ghost text
[data-edytor-suggestion-bar] Where your bar renders, under its preview (overlay)

The Notion theme gives the group a tinted background, pulses it while it streams (not with reduced motion), strikes through replaced blocks and greys the ghost text. Its custom properties: --edytor-suggestion-background, --edytor-suggestion-removed (replaced blocks), --edytor-suggestion-removed-line (a range’s underline), --edytor-suggestion-bar-space (the room under a group for the bar, 40px).

Live example

The editor on the home page has an Ask AI button and two slash commands. They need no API key: the answers are canned and streamed word by word. Until you accept one, the shared room never sees it; once accepted, everyone does. Its plugin, trimmed:

import type { EdytorInstance, JSONBlock, Plugin, Suggestion } from 'edytor';

const ANSWERS: JSONBlock[][] = [
	[
		{ type: 'heading', data: { level: 'h2' }, content: [{ text: 'Launch plan' }] },
		{ type: 'bulleted-list-item', content: [{ text: 'Write the announcement post' }] },
		{ type: 'todo-item', data: { checked: false }, content: [{ text: 'Book the launch review' }] }
	],
	[{ type: 'paragraph', content: [{ text: 'Another answer, for Try again.' }] }]
];
let next = 0;

/** Show `blocks` one block more every 300 ms, then mark the suggestion ready. */
const stream = (suggestion: Suggestion, blocks: JSONBlock[]) => {
	let shown = 0;
	const timer = setInterval(() => {
		if (!suggestion.live) return clearInterval(timer);
		suggestion.update(blocks.slice(0, ++shown));
		if (shown === blocks.length) {
			clearInterval(timer);
			suggestion.done();
		}
	}, 300);
};

export const askAI = (edytor: EdytorInstance) => {
	const block = edytor.selection.caret?.block;
	if (!block) return;
	// An empty line is replaced, as in Notion; else the answer goes after the block.
	const at = block.isEmpty && block.convertible ? { replace: [block.id] } : { after: block.id };
	const suggestion = edytor.suggestions.add(at, undefined, {
		label: 'AI',
		onRetry: (again) => stream(again, ANSWERS[next++ % ANSWERS.length]!)
	});
	stream(suggestion, ANSWERS[next++ % ANSWERS.length]!);
};

/** `/ai` in the slash menu: it runs once the `/ai` text is removed. */
export const aiPlugin: Plugin = () => ({
	commands: [
		{
			id: 'ai.ask',
			label: 'Ask AI',
			icon: '✦',
			group: 'AI',
			keywords: ['ai', 'write', 'generate'],
			run: async (edytor) => {
				await Promise.resolve();
				askAI(edytor);
			}
		}
	]
});

The full demo (site/islands/ai-demo.ts in the repository) streams word by word, adds an image to the answer and a Continue writing command (/continue), an end suggestion that Tab accepts.

Text to blocks

textToBlocks(text, options?) turns a string into JSONBlock[]. It is pure: use it in a server or a Worker to shape a model’s answer before sending it.

  • markdown: false keeps every character as text: blank lines split paragraphs; a single newline stays in the text.
  • Markdown (the default) reads one line at a time: # to ### headings (deeper ones read as ###), -, * or + bullets, 1. numbers, [ ] and [x] to-dos (also after a bullet), > quotes, --- dividers, fenced code blocks (an unclosed fence keeps its lines, so a stream cut mid-block reads well), and list items nested by indentation (two spaces a level). Consecutive plain lines make one paragraph, joined by a space; a blank line ends it.
  • Inline, **bold**, *italic* or _italic_, `code` and [text](url) links become marks. Marks do not nest.
  • type sets the paragraph kind (default paragraph). The other kinds and marks are the rich text and code plugins’.
import { textToBlocks } from 'edytor';

textToBlocks('## Plan\n- [ ] Ship it\nSee **notes**');
// [{ type: 'heading', data: { level: 'h2' }, content: [{ text: 'Plan' }] },
//  { type: 'todo-item', data: { checked: false }, content: [{ text: 'Ship it' }] },
//  { type: 'paragraph', content: [{ text: 'See ' }, { text: 'notes', marks: { bold: true } }] }]

The block API, before edytor.suggestions

block.suggestText({ value }), block.suggestions and block.acceptSuggestedText() are gone: add an { end: block.id } suggestion with edytor.suggestions.add, read the block’s with edytor.suggestions.at(block.id).end, and accept one with suggestion.accept() (migration).

Limitations

  • One view only. A suggestion lives in the view that made it: another tab or a collaborator cannot review it, and a reload loses it. Keep your own copy if it must survive.
  • A text-range replace is two writes in one command and one undo step: the range’s deletion, then the paste at its caret. Hooks see the deletion’s steps only.
  • The preview applies no transformText (a code block’s preview is not highlighted) and renders the kinds this view registers; an unknown kind shows as a plain block.
  • Marks in textToBlocks do not nest, and the rest of markdown (tables, images, HTML) stays text.

Was this page helpful?