Revise.js

Rich text editing foundations for the web

Simple Editable

Plain text with undo/redo. The minimal setup.

Hello World!
Try typing here.
Undo with Ctrl/Cmd+Z.
import type {Context} from "@b9g/crank";
import {renderer} from "@b9g/crank/dom";
import {Editable, EditableState} from "@b9g/crankeditable";

function* SimpleEditable(this: Context) {
const state = new EditableState({
value: `Hello World!
Try typing here.
Undo with Ctrl/Cmd+Z.
`,
});
for (const {} of this) {
const lines = state.value.split("\n");
if (lines[lines.length - 1] === "") lines.pop();
let cursor = 0;
yield (
<Editable state={state} onstatechange={() => this.refresh()}>
<pre class="editable" contenteditable="true" spellcheck="false">
{lines.map((line) => {
const key = state.keyer.keyAt(cursor);
cursor += line.length + 1;
return <div key={key}>{line || <br />}</div>;
})}
</pre>
</Editable>
);
}
}

renderer.render(<SimpleEditable />, document.body);

Rainbow

Per-character coloring with keyed lines.

Hello
World
Rainbow
Text
import type {Context} from "@b9g/crank";
import {renderer} from "@b9g/crank/dom";
import {Editable, EditableState} from "@b9g/crankeditable";

const COLORS = [
"#ef4444", "#f97316", "#eab308",
"#22c55e", "#3b82f6", "#8b5cf6", "#ec4899",
];

function* RainbowEditable(this: Context) {
const state = new EditableState({
value: `Hello
World
Rainbow
Text
`,
});
for (const {} of this) {
const lines = state.value.split("\n");
if (lines[lines.length - 1] === "") lines.pop();
let cursor = 0;
yield (
<Editable state={state} onstatechange={() => this.refresh()}>
<div class="editable" contenteditable="true" spellcheck="false">
{lines.map((line) => {
const key = state.keyer.keyAt(cursor);
cursor += line.length + 1;
const chars = line
? [...line].map((char, i) => (
<span style={"color: " + COLORS[i % COLORS.length]}>{char}</span>
))
: <br />;
return <div key={key}>{chars}</div>;
})}
</div>
</Editable>
);
}
}

renderer.render(<RainbowEditable />, document.body);

Social Highlighting

Clickable #hashtags, @mentions, and links.

Check out #revise by @bikeshaving
Visit https://revise.js.org
#javascript #editing @everyone
import type {Context, Element} from "@b9g/crank";
import {renderer} from "@b9g/crank/dom";
import {Editable, EditableState} from "@b9g/crankeditable";

const PATTERN = /(#\w+)|(@\w+)|(https?:\/\/[^\s]+)/g;

function highlightSocial(text: string): (Element | string)[] {
const result: (Element | string)[] = [];
let lastIndex = 0;
for (const match of text.matchAll(PATTERN)) {
const index = match.index!;
if (index > lastIndex) result.push(text.slice(lastIndex, index));
const value = match[0];
let color: string, href: string;
if (match[1]) {
color = "#c084fc";
href = "https://example.com/tags/" + value.slice(1);
} else if (match[2]) {
color = "#60a5fa";
href = "https://example.com/" + value.slice(1);
} else {
color = "#34d399";
href = value;
}
result.push(
<a href={href} target="_blank" rel="noopener"
style={"color: " + color + "; text-decoration: underline"}>{value}</a>
);
lastIndex = index + value.length;
}
if (lastIndex < text.length) result.push(text.slice(lastIndex));
return result;
}

function* SocialEditable(this: Context) {
const state = new EditableState({
value: `Check out #revise by @bikeshaving
Visit https://revise.js.org
#javascript #editing @everyone
`,
});
for (const {} of this) {
const lines = state.value.split("\n");
if (lines[lines.length - 1] === "") lines.pop();
let cursor = 0;
yield (
<Editable state={state} onstatechange={() => this.refresh()}>
<div class="editable" contenteditable="true" spellcheck="false">
{lines.map((line) => {
const key = state.keyer.keyAt(cursor);
cursor += line.length + 1;
return (
<div key={key}>
{line ? highlightSocial(line) : <br />}
</div>
);
})}
</div>
</Editable>
);
}
}

renderer.render(<SocialEditable />, document.body);

Twemoji

Emoji replaced with SVG using data-content.

Hello World! πŸ‘‹
Revise.js is πŸ”₯πŸ”₯πŸ”₯
Type some emoji: πŸ˜Žβ€οΈπŸš€
import type {Context, Element} from "@b9g/crank";
import {renderer} from "@b9g/crank/dom";
import {Editable, EditableState, ContentAreaElement} from "@b9g/crankeditable";
import {parse as parseEmoji} from "@twemoji/parser";

if (!customElements.get("content-area")) {
customElements.define("content-area", ContentAreaElement);
}

function renderTwemoji(text: string): (Element | string)[] {
const entities = parseEmoji(text);
if (!entities.length) return [text];
const result: (Element | string)[] = [];
let lastIndex = 0;
for (const entity of entities) {
const [start, end] = entity.indices;
if (start > lastIndex) result.push(text.slice(lastIndex, start));
result.push(
<img
data-content={entity.text}
src={entity.url}
alt={entity.text}
draggable={false}
style="height:1.2em;width:1.2em;vertical-align:middle;display:inline-block"
/>
);
lastIndex = end;
}
if (lastIndex < text.length) result.push(text.slice(lastIndex));
return result;
}

function* TwemojiEditable(this: Context) {
const state = new EditableState({
value: `Hello World! πŸ‘‹
Revise.js is πŸ”₯πŸ”₯πŸ”₯
Type some emoji: πŸ˜Žβ€οΈπŸš€
`,
});
for (const {} of this) {
const lines = state.value.split("\n");
if (lines[lines.length - 1] === "") lines.pop();
let cursor = 0;
yield (
<Editable state={state} onstatechange={() => this.refresh()}>
<div class="editable" contenteditable="true" spellcheck="false">
{lines.map((line) => {
const key = state.keyer.keyAt(cursor);
cursor += line.length + 1;
return (
<div key={key}>
{line ? renderTwemoji(line) : <br />}
</div>
);
})}
</div>
</Editable>
);
}
}

renderer.render(<TwemojiEditable />, document.body);

Code Editor

Keyword highlighting with a simple regex tokenizer.

function greet(name) {
return 'Hello, ' + name;
}

const message = greet('World');
console.log(message);
import type {Context, Element} from "@b9g/crank";
import {renderer} from "@b9g/crank/dom";
import {Editable, EditableState} from "@b9g/crankeditable";

const KW = /\b(function|const|let|var|return|if|else|for|while|class|import|export|from|new|typeof)\b/g;

function highlight(line: string): (Element | string)[] {
const result: (Element | string)[] = [];
let lastIndex = 0;
for (const match of line.matchAll(KW)) {
const index = match.index!;
if (index > lastIndex) result.push(line.slice(lastIndex, index));
result.push(<span style="color: #c084fc">{match[0]}</span>);
lastIndex = index + match[0].length;
}
if (lastIndex < line.length) result.push(line.slice(lastIndex));
return result;
}

function* CodeEditable(this: Context) {
const state = new EditableState({
value: `function greet(name) {
return 'Hello, ' + name;
}

const message = greet('World');
console.log(message);
`,
});
for (const {} of this) {
const lines = state.value.split("\n");
if (lines[lines.length - 1] === "") lines.pop();
let cursor = 0;
yield (
<Editable state={state} onstatechange={() => this.refresh()}>
<pre class="editable" contenteditable="true" spellcheck="false">
{lines.map((line) => {
const key = state.keyer.keyAt(cursor);
cursor += line.length + 1;
return (
<div key={key}>
<code>{line ? highlight(line) : null}</code>
<br />
</div>
);
})}
</pre>
</Editable>
);
}
}

renderer.render(<CodeEditable />, document.body);

Blockquote

Styled prefixes with data-contentbefore. Lines starting with > become quotes.

To be, or not to be, that is the questionβ€”
Whether 'tis nobler in the mind to suffer
Hamlet, Act 3, Scene 1
import type {Context} from "@b9g/crank";
import {renderer} from "@b9g/crank/dom";
import {Editable, EditableState} from "@b9g/crankeditable";

function getLineAt(val: string, pos: number) {
const start = val.lastIndexOf("\n", pos - 1) + 1;
const end = val.indexOf("\n", pos);
return {start, end: end === -1 ? val.length : end};
}

function* BlockquoteEditable(this: Context) {
const state = new EditableState({
value: `> To be, or not to be, that is the questionβ€”
> Whether 'tis nobler in the mind to suffer
Hamlet, Act 3, Scene 1
`,
});
for (const {} of this) {
const lines = state.value.split("\n");
if (lines[lines.length - 1] === "") lines.pop();
let cursor = 0;
yield (
<Editable state={state} onstatechange={() => this.refresh()}>
<div class="editable" contenteditable="true" spellcheck="false"
onkeydown={(ev: KeyboardEvent) => {
if (ev.shiftKey || ev.ctrlKey || ev.metaKey) return;
const area = (ev.currentTarget as HTMLElement)
.closest("content-area") as any;
const pos = area.selectionStart;
if (pos !== area.selectionEnd) return;
const val = state.value;
const {start, end} = getLineAt(val, pos);
const line = val.slice(start, end);
if (!/^> /.test(line)) return;
if (ev.key === "Enter") {
ev.preventDefault();
if (line === "> ") {
// Empty quote: remove prefix
state.setValue(
val.slice(0, start) + val.slice(start + 2),
"user",
);
} else {
// Continue blockquote
state.setValue(
val.slice(0, pos) + "\n> " + val.slice(pos),
"user",
);
this.refresh();
area.setSelectionRange(pos + 3, pos + 3);
return;
}
} else if (ev.key === "Backspace") {
if (line === "> ") {
// Empty quote: remove prefix
ev.preventDefault();
state.setValue(
val.slice(0, start) + val.slice(start + 2),
"user",
);
} else if (pos === start + 2) {
ev.preventDefault();
// Remove prefix and merge with previous line
state.setValue(
val.slice(0, Math.max(0, start - 1))
+ val.slice(start + 2),
"user",
);
} else {
return;
}
} else {
return;
}
this.refresh();
}}
>
{lines.map((line) => {
const key = state.keyer.keyAt(cursor);
cursor += line.length + 1;
const match = line.match(/^(> )([\s\S]*)$/);
if (match) {
return (
<div key={key} data-contentbefore="> " style={{
borderLeft: "3px solid currentColor",
paddingLeft: "0.5em",
opacity: 0.7,
}}>
{match[2] || <br />}
</div>
);
}
return <div key={key}>{line || <br />}</div>;
})}
</div>
</Editable>
);
}
}

renderer.render(<BlockquoteEditable />, document.body);

Todo List

Checkbox prefixes with data-contentbefore. Lines starting with - [ ] or - [x] become todos.

Build content-area
Build Edit data structure
Write documentation
Take over the world
import type {Context} from "@b9g/crank";
import {renderer} from "@b9g/crank/dom";
import {Editable, EditableState} from "@b9g/crankeditable";

function getLineAt(val: string, pos: number) {
const start = val.lastIndexOf("\n", pos - 1) + 1;
const end = val.indexOf("\n", pos);
return {start, end: end === -1 ? val.length : end};
}

function* TodoEditable(this: Context) {
const state = new EditableState({
value: `- [x] Build content-area
- [x] Build Edit data structure
- [ ] Write documentation
- [ ] Take over the world
`,
});
for (const {} of this) {
const lines = state.value.split("\n");
if (lines[lines.length - 1] === "") lines.pop();
let cursor = 0;
yield (
<Editable state={state} onstatechange={() => this.refresh()}>
<div class="editable" contenteditable="true" spellcheck="false"
onkeydown={(ev: KeyboardEvent) => {
if (ev.shiftKey || ev.ctrlKey || ev.metaKey) return;
const area = (ev.currentTarget as HTMLElement)
.closest("content-area") as any;
const pos = area.selectionStart;
if (pos !== area.selectionEnd) return;
const val = state.value;
const {start, end} = getLineAt(val, pos);
const line = val.slice(start, end);
const match = line.match(/^(- \[[ x]\] )/);
if (!match) return;
if (ev.key === "Enter") {
ev.preventDefault();
if (line === match[1]) {
// Empty todo: remove prefix
state.setValue(
val.slice(0, start) + val.slice(start + match[1].length),
"user",
);
} else {
// Continue with new unchecked todo
state.setValue(
val.slice(0, pos) + "\n- [ ] " + val.slice(pos),
"user",
);
this.refresh();
area.setSelectionRange(pos + 7, pos + 7);
return;
}
} else if (ev.key === "Backspace") {
if (line === match[1]) {
// Empty todo: remove prefix
ev.preventDefault();
state.setValue(
val.slice(0, start) + val.slice(start + match[1].length),
"user",
);
} else if (pos === start + match[1].length) {
ev.preventDefault();
// Remove prefix and merge with previous line
state.setValue(
val.slice(0, Math.max(0, start - 1))
+ val.slice(start + match[1].length),
"user",
);
} else {
return;
}
} else {
return;
}
this.refresh();
}}
>
{lines.map((line) => {
const lineStart = cursor;
const key = state.keyer.keyAt(cursor);
cursor += line.length + 1;
const match = line.match(/^(- \[[ x]\] )([\s\S]*)$/);
if (match) {
const prefix = match[1];
const checked = prefix === "- [x] ";
return (
<div key={key} data-contentbefore={prefix}>
<input
type="checkbox"
checked={checked}
data-content=""
contenteditable="false"
onclick={() => {
const newPrefix = checked ? "- [ ] " : "- [x] ";
state.setValue(
state.value.slice(0, lineStart) +
newPrefix +
state.value.slice(lineStart + prefix.length),
"user",
);
this.refresh();
}}
/>
<span style={checked
? {textDecoration: "line-through", opacity: "0.5"}
: undefined}>
{match[2] || <br />}
</span>
</div>
);
}
return <div key={key}>{line || <br />}</div>;
})}
</div>
</Editable>
);
}
}

renderer.render(<TodoEditable />, document.body);