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: `HelloWorldRainbowText`,});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.
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 @bikeshavingVisit 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(<imgdata-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 sufferHamlet, 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 prefixstate.setValue(val.slice(0, start) + val.slice(start + 2),"user",);} else {// Continue blockquotestate.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 prefixev.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 linestate.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 prefixstate.setValue(val.slice(0, start) + val.slice(start + match[1].length),"user",);} else {// Continue with new unchecked todostate.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 prefixev.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 linestate.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}><inputtype="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);