Revise.js

Rich text editing primitives for the web

Simple Editable

Plain text with undo/redo. The minimal setup.

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

function* SimpleEditable(this: Context) {
const state = new EditableState({
value: `Hello World!
Try typing here.
Undo with ${/Win|Linux/.test(navigator.platform) ? "Ctrl" : "Cmd"}+Z.
`,
});
for (const {} of this) {
const lines = state.value.split("\n");
if (lines[lines.length - 1] === "") lines.pop();
let cursor = 0;
yield (
<CrankEditable 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>
</CrankEditable>
);
}
}

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 {CrankEditable, EditableState} from "@b9g/crankeditable";

const COLORS = [
"#FF0000", "#FFA500", "#FFDC00",
"#008000", "#0000FF", "#4B0082", "#800080",
];

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 (
<CrankEditable 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>
</CrankEditable>
);
}
}

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

Social Highlighting

Clickable #hashtags, @mentions, and links.

Check out #revise by @bikeshaving
It's at https://revise.js.org
#javascript #editing @everyone
import type {Context, Element} from "@b9g/crank";
import {renderer} from "@b9g/crank/dom";
import {CrankEditable, 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 (
<CrankEditable 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>
</CrankEditable>
);
}
}

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 {CrankEditable, 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 (
<CrankEditable 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>
</CrankEditable>
);
}
}

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 {CrankEditable, 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 (
<CrankEditable 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>
</CrankEditable>
);
}
}

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