diff --git a/package.json b/package.json
index 0949f55..f2adae3 100644
--- a/package.json
+++ b/package.json
@@ -15,7 +15,7 @@
"build:core-min": "esbuild src/ts/ribbit-core.ts --bundle --format=iife --global-name=ribbit --minify --outfile=dist/ribbit/ribbit-core.min.js",
"build:css": "cp src/static/ribbit-core.css dist/ribbit/ && cp -r src/static/themes dist/ribbit/",
"test": "npm run build && jest --verbose",
- "test:integration": "npm run build && node test/integration/test.js",
+ "test:integration": "npm run build && node test/integration/test.js && node test/integration/test_wysiwyg.js",
"test:coverage": "npm run build && jest --coverage"
},
"license": "MIT",
diff --git a/src/static/ribbit-core.css b/src/static/ribbit-core.css
index ba93025..e74db91 100644
--- a/src/static/ribbit-core.css
+++ b/src/static/ribbit-core.css
@@ -30,6 +30,11 @@
font-size: 0.85em;
}
+[data-speculative]::before,
+[data-speculative]::after {
+ content: none !important;
+}
+
#ribbit.wysiwyg strong.ribbit-editing::before,
#ribbit.wysiwyg strong.ribbit-editing::after {
content: "**";
diff --git a/src/static/themes/ribbit-default/theme.css b/src/static/themes/ribbit-default/theme.css
index ed2163a..c139fcf 100644
--- a/src/static/themes/ribbit-default/theme.css
+++ b/src/static/themes/ribbit-default/theme.css
@@ -4,7 +4,7 @@
* Replace this file with your own theme to customize the look.
*/
-@import "../ribbit-core.css";
+@import "../../ribbit-core.css";
a {
text-decoration: none;
diff --git a/src/ts/ribbit-editor.ts b/src/ts/ribbit-editor.ts
index b3b293c..e72996e 100644
--- a/src/ts/ribbit-editor.ts
+++ b/src/ts/ribbit-editor.ts
@@ -57,65 +57,598 @@ export class RibbitEditor extends Ribbit {
#bindEvents(): void {
let debounceTimer: number | undefined;
- let lastThrottle = 0;
this.element.addEventListener('input', () => {
- if (this.state === this.states.VIEW) {
+ if (this.state !== this.states.WYSIWYG) {
return;
}
-
- this.invalidateCache();
-
- const now = Date.now();
- if (now - lastThrottle >= 150) {
- lastThrottle = now;
- this.refreshPreview();
- }
+ this.ensureBlockStructure();
+ this.transformCurrentBlock();
+ this.updateEditingContext();
clearTimeout(debounceTimer);
debounceTimer = window.setTimeout(() => {
- this.refreshPreview();
this.notifyChange();
- }, 150);
+ }, 300);
+ });
+
+ this.element.addEventListener('keydown', (e: KeyboardEvent) => {
+ if (this.state !== this.states.WYSIWYG) {
+ return;
+ }
+ if (e.key === 'Enter') {
+ this.handleEnter(e);
+ }
+ });
+
+ this.element.addEventListener('keyup', (e: KeyboardEvent) => {
+ if (this.state !== this.states.WYSIWYG) {
+ return;
+ }
+ if (e.key.startsWith('Arrow')) {
+ this.closeOrphanedSpeculative();
+ this.updateEditingContext();
+ }
+ });
+
+ this.element.addEventListener('blur', () => {
+ if (this.state !== this.states.WYSIWYG) {
+ return;
+ }
+ this.closeOrphanedSpeculative();
+ });
+
+ this.element.addEventListener('focusout', () => {
+ if (this.state !== this.states.WYSIWYG) {
+ return;
+ }
+ this.closeOrphanedSpeculative();
+ });
+
+ document.addEventListener('click', (e: MouseEvent) => {
+ if (this.state !== this.states.WYSIWYG) {
+ return;
+ }
+ if (!this.element.contains(e.target as Node)) {
+ this.closeAllSpeculative();
+ }
+ });
+
+ document.addEventListener('selectionchange', () => {
+ if (this.state !== this.states.WYSIWYG) {
+ return;
+ }
+ this.closeOrphanedSpeculative();
+ this.updateEditingContext();
});
}
/**
- * Re-render the WYSIWYG preview from the current content.
- * Applies speculative rendering for unclosed inline delimiters
- * at the cursor position, and uses toHtmlPreview for visible syntax.
+ * Find the block-level element containing the cursor.
*/
- refreshPreview(): void {
- if (this.state !== this.states.WYSIWYG) {
- return;
- }
-
- const cursorInfo = this.getCursorInfo();
- const text = this.element.textContent || '';
- const lines = text.split('\n');
-
- // Speculatively close unclosed delimiters on the cursor line
- if (cursorInfo) {
- const inlineTags = this.converter.getInlineTags();
- const sorted = [...inlineTags].sort((a, b) =>
- ((a as any).precedence ?? 50) - ((b as any).precedence ?? 50)
- );
- for (const tag of sorted) {
- if (tag.openPattern && tag.delimiter) {
- const before = lines[cursorInfo.lineIndex].slice(0, cursorInfo.offset);
- const escaped = tag.delimiter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
- const re = new RegExp(escaped, 'g');
- const count = (before.match(re) || []).length;
- if (count % 2 === 1) {
- lines[cursorInfo.lineIndex] = lines[cursorInfo.lineIndex] + tag.delimiter;
- break;
+ /**
+ * Ensure the editor contains valid block structure.
+ * Wraps bare
and
tags.
+ */
+ private ensureBlockStructure(): void {
+ for (const child of Array.from(this.element.childNodes)) {
+ if (child.nodeType === 1) {
+ const element = child as HTMLElement;
+ if (element.tagName === 'BR') {
+ const p = document.createElement('p');
+ p.innerHTML = '
';
+ element.replaceWith(p);
+ } else if (element.tagName === 'DIV') {
+ const p = document.createElement('p');
+ while (element.firstChild) {
+ p.appendChild(element.firstChild);
+ }
+ if (!p.firstChild) {
+ p.innerHTML = '
';
+ }
+ element.replaceWith(p);
+ // Restore cursor inside the new
+ const sel = window.getSelection(); + if (sel && sel.rangeCount > 0) { + const range = document.createRange(); + const target = p.lastChild || p; + if (target.nodeType === 3) { + range.setStart(target, target.textContent?.length || 0); + } else { + range.selectNodeContents(target); + range.collapse(false); + } + sel.removeAllRanges(); + sel.addRange(range); } } } } + if (!this.element.firstChild) { + this.element.innerHTML = '
first (browsers don't always do this). + if (node && node.nodeType === 3 && node.parentNode === this.element) { + const p = document.createElement('p'); + node.parentNode.insertBefore(p, node); + p.appendChild(node); + // Restore cursor inside the new
+ const range = document.createRange();
+ range.setStart(node, sel.anchorOffset);
+ range.collapse(true);
+ sel.removeAllRanges();
+ sel.addRange(range);
+ return p;
+ }
+
+ while (node && node !== this.element) {
+ if (node.nodeType === 1) {
+ const element = node as HTMLElement;
+ if (element.tagName === 'LI' || element.parentNode === this.element) {
+ return element;
+ }
+ }
+ node = node.parentNode;
+ }
+ return null;
+ }
+
+ /**
+ * Check the current block's text for markdown patterns and
+ * transform the DOM element in-place if a pattern matches.
+ */
+ private transformCurrentBlock(): void {
+ const block = this.findCurrentBlock();
+ if (!block) {
+ return;
+ }
+ const text = (block.textContent || '').replace(/\u00A0/g, ' ');
+
+ // Heading: # through ######
+ const headingMatch = text.match(/^(#{1,6})\s/);
+ if (headingMatch) {
+ const level = headingMatch[1].length;
+ const targetTag = 'H' + level;
+ if (block.tagName !== targetTag) {
+ this.replaceBlock(block, targetTag, headingMatch[0].length);
+ return;
+ }
+ }
+
+ // Blockquote: >
+ if (text.startsWith('> ') && block.tagName !== 'BLOCKQUOTE') {
+ this.replaceBlock(block, 'BLOCKQUOTE', 2);
+ return;
+ }
+
+ // Horizontal rule: --- or *** or ___
+ if (/^(\*{3,}|-{3,}|_{3,})\s*$/.test(text)) {
+ const hr = document.createElement('hr');
+ const p = document.createElement('p');
+ p.innerHTML = '
';
+ block.replaceWith(hr, p);
+ const range = document.createRange();
+ range.setStart(p, 0);
+ range.collapse(true);
+ const sel = window.getSelection()!;
+ sel.removeAllRanges();
+ sel.addRange(range);
+ return;
+ }
+
+ // Unordered list: - or *
+ if (/^[-*]\s/.test(text) && block.tagName !== 'LI') {
+ this.replaceBlockWithList(block, 'ul', text.indexOf(' ') + 1);
+ return;
+ }
+
+ // Ordered list: 1.
+ if (/^\d+\.\s/.test(text) && block.tagName !== 'LI') {
+ this.replaceBlockWithList(block, 'ol', text.indexOf(' ') + 1);
+ return;
+ }
+
+ // Fenced code: ```
+ if (text.startsWith('```') && block.tagName !== 'PRE') {
+ const pre = document.createElement('pre');
+ const code = document.createElement('code');
+ code.textContent = '';
+ pre.appendChild(code);
+ block.replaceWith(pre);
+ const range = document.createRange();
+ range.setStart(code, 0);
+ range.collapse(true);
+ const sel = window.getSelection()!;
+ sel.removeAllRanges();
+ sel.addRange(range);
+ return;
+ }
+
+ // Inline transforms: flatten to markdown, transform, rebuild DOM
+ this.transformInline(block);
+ }
+
+ /**
+ * Convert a block's DOM children to a mixed string where completed
+ * inline elements are preserved as HTML and only speculative/text
+ * content is flattened to markdown. Completed elements are wrapped
+ * in sentinel markers so the regex engine skips them.
+ */
+ private blockToMarkdown(block: HTMLElement): string {
+ let md = '';
+ for (const child of Array.from(block.childNodes)) {
+ md += this.nodeToMarkdown(child);
+ }
+ return md;
+ }
+
+ private nodeToMarkdown(node: Node): string {
+ if (node.nodeType === 3) {
+ return (node.textContent || '').replace(/\u200B/g, '');
+ }
+ if (node.nodeType !== 1) { return ''; }
+ const element = node as HTMLElement;
+ const specDelim = element.getAttribute('data-speculative');
+
+ if (specDelim) {
+ // Speculative: restore opener delimiter + flatten children
+ let inner = '';
+ for (const child of Array.from(element.childNodes)) {
+ inner += this.nodeToMarkdown(child);
+ }
+ return specDelim + inner;
+ }
+
+ const tag = this.findTagForElement(element);
+ if (tag?.delimiter) {
+ // Completed element: preserve as HTML, wrapped in sentinels
+ // so the complete-pair regex won't match across it
+ return '\x01' + element.outerHTML + '\x02';
+ }
+
+ // Unknown element: flatten children
+ let inner = '';
+ for (const child of Array.from(element.childNodes)) {
+ inner += this.nodeToMarkdown(child);
+ }
+ return inner;
+ }
+
+ /**
+ * Find the Tag definition that matches an HTML element.
+ */
+ private findTagForElement(el: HTMLElement): { delimiter?: string; name: string } | null {
+ const inlineTags = this.converter.getInlineTags();
+ for (const tag of inlineTags) {
+ if (!tag.delimiter) continue;
+ if (typeof tag.selector === 'string') {
+ const selectors = tag.selector.split(',');
+ if (selectors.some(s => el.tagName === s.trim())) {
+ return tag;
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Flatten the block to markdown, find and apply inline transforms,
+ * then rebuild the DOM from the result.
+ */
+ private transformInline(block: HTMLElement): void {
+ const sel = window.getSelection();
+ if (!sel || sel.rangeCount === 0) return;
+
+ let md = this.blockToMarkdown(block);
+ if (md.replace(/\s/g, '').length < 2) return;
+
+ const inlineTags = this.converter.getInlineTags();
+ const sorted = [...inlineTags]
+ .filter(tag => tag.delimiter)
+ .sort((a, b) => (a.precedence ?? 50) - (b.precedence ?? 50));
+
+ // Build regex for each tag with exact-delimiter matching.
+ // [^\x01\x02] prevents matching across preserved HTML elements.
+ const tagRegexes = sorted.map(tag => {
+ const delim = tag.delimiter!;
+ const escaped = delim.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ const ec = delim[0].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ return {
+ tag,
+ complete: new RegExp(`(? = {
+ 'strong': ['strong', 'b'],
+ 'em': ['em', 'i'],
+ 'code': ['code', 'strong', 'b', 'em', 'i', 'a'],
+ };
+
+ let changed = true;
+ while (changed) {
+ changed = false;
+ for (const { tag, complete } of tagRegexes) {
+ const match = md.match(complete);
+ if (match && match.index !== undefined) {
+ const tagName = tag.name === 'boldItalic' ? 'em' : (tag.selector as string).split(',')[0].toLowerCase();
+
+ // Skip if wrapping would create forbidden nesting
+ const banned = forbiddenChildren[tagName];
+ if (banned && banned.some(t => match[1].includes('<' + t))) {
+ continue;
+ }
+
+ const content = tagName === 'code'
+ ? match[1].replace(/&/g, '&').replace(//g, '>')
+ : match[1];
+ const inner = tag.name === 'boldItalic'
+ ? `\x01<${tagName}>${content}${tagName}>\x02`
+ : `\x01<${tagName}>${content}${tagName}>\x02`;
+ md = md.slice(0, match.index) + inner + md.slice(match.index + match[0].length);
+ changed = true;
+ break;
+ }
+ }
+ }
+
+ // Strip sentinel markers now that complete-pair matching is done
+ md = md.replace(/[\x01\x02]/g, '');
+
+ // Check for one unclosed opener (speculative)
+ let speculativeTag: typeof sorted[0] | null = null;
+ let speculativeMatch: RegExpMatchArray | null = null;
+ for (const { tag, open } of tagRegexes) {
+ const match = md.match(open);
+ if (match && match.index !== undefined) {
+ // Make sure this isn't inside an HTML tag we just created
+ const before = md.slice(0, match.index);
+ if (!before.endsWith('<') && !before.endsWith('/')) {
+ speculativeTag = tag;
+ speculativeMatch = match;
+ break;
+ }
+ }
+ }
+
+ // Rebuild the DOM
+ if (speculativeMatch && speculativeTag) {
+ const tagName = speculativeTag.name === 'boldItalic' ? 'em' : (speculativeTag.selector as string).split(',')[0].toLowerCase();
+ const inside = md.slice(speculativeMatch.index! + speculativeTag.delimiter!.length);
+
+ // Check for forbidden nesting before wrapping
+ const probe = document.createElement('div');
+ probe.innerHTML = inside;
+ const banned = forbiddenChildren[tagName];
+ const wouldNest = banned && banned.some(tag => probe.querySelector(tag));
+
+ if (!wouldNest) {
+ const before = md.slice(0, speculativeMatch.index!);
+ const wrapper = document.createElement(tagName);
+ wrapper.classList.add('ribbit-editing');
+ wrapper.setAttribute('data-speculative', speculativeTag.delimiter!);
+ wrapper.innerHTML = inside;
+ this.sanitizeNesting(wrapper);
+
+ block.innerHTML = '';
+ if (before) {
+ block.appendChild(document.createTextNode(before));
+ }
+ block.appendChild(wrapper);
+ // ZWS after wrapper so arrow-right can escape the element
+ block.appendChild(document.createTextNode('\u200B'));
+
+ // Cursor at end of speculative element
+ this.placeCursorAtEnd(wrapper);
+ } else {
+ // Forbidden nesting — fall through to plain innerHTML
+ block.innerHTML = md;
+ this.sanitizeNesting(block);
+ if (block.lastChild && block.lastChild.nodeType === 1) {
+ block.appendChild(document.createTextNode('\u200B'));
+ }
+ this.placeCursorAtEnd(block);
+ }
+ } else {
+ block.innerHTML = md;
+ this.sanitizeNesting(block);
+ // If the block ends with an HTML element, append a ZWS text
+ // node so the cursor lands outside the element, not inside it.
+ if (block.lastChild && block.lastChild.nodeType === 1) {
+ block.appendChild(document.createTextNode('\u200B'));
+ }
+ this.placeCursorAtEnd(block);
+ }
+ }
+
+ /**
+ * Place the cursor at the end of an element's content.
+ */
+ private placeCursorAtEnd(el: HTMLElement): void {
+ const sel = window.getSelection();
+ if (!sel) return;
+ const range = document.createRange();
+ // Find the deepest last text node
+ let target: Node = el;
+ while (target.lastChild) {
+ target = target.lastChild;
+ }
+ if (target.nodeType === 3) {
+ range.setStart(target, target.textContent?.length || 0);
+ } else {
+ range.selectNodeContents(target);
+ range.collapse(false);
+ }
+ range.collapse(true);
+ sel.removeAllRanges();
+ sel.addRange(range);
+ }
+
+ /**
+
+ /**
+ * Replace a block element with a new tag, stripping the prefix
+ * and preserving cursor position.
+ */
+ private replaceBlock(block: HTMLElement, newTag: string, prefixLength: number): void {
+ const newEl = document.createElement(newTag);
+ const content = (block.textContent || '').slice(prefixLength);
+ if (content) {
+ newEl.textContent = content;
+ } else {
+ newEl.innerHTML = '
';
+ }
+ block.replaceWith(newEl);
+ newEl.classList.add('ribbit-editing');
+
+ // Place cursor at start of content
+ const range = document.createRange();
+ if (newEl.firstChild && newEl.firstChild.nodeType === 3) {
+ range.setStart(newEl.firstChild, 0);
+ } else {
+ range.setStart(newEl, 0);
+ }
+ range.collapse(true);
+ const sel = window.getSelection()!;
+ sel.removeAllRanges();
+ sel.addRange(range);
+ }
+
+ /**
+ * Replace a block element with a list (ul/ol) containing one item.
+ */
+ private replaceBlockWithList(block: HTMLElement, listTag: string, prefixLength: number): void {
+ const list = document.createElement(listTag);
+ const li = document.createElement('li');
+ const content = (block.textContent || '').slice(prefixLength);
+ if (content) {
+ li.textContent = content;
+ } else {
+ li.innerHTML = '
';
+ }
+ list.appendChild(li);
+ block.replaceWith(list);
+
+ const range = document.createRange();
+ if (li.firstChild && li.firstChild.nodeType === 3) {
+ range.setStart(li.firstChild, 0);
+ } else {
+ range.setStart(li, 0);
+ }
+ range.collapse(true);
+ const sel = window.getSelection()!;
+ sel.removeAllRanges();
+ sel.addRange(range);
+ }
+
+ /**
+ * Handle Enter key: strip syntax decorations from the current
+ * block before the browser creates a new line.
+ */
+ private handleEnter(e: KeyboardEvent): void {
+ const prev = this.element.querySelector('.ribbit-editing');
+ if (prev) {
+ prev.classList.remove('ribbit-editing');
+ prev.removeAttribute('data-speculative');
+ }
+ }
+
+ /**
+ * Close any speculative elements that the cursor is no longer inside.
+ * Called on every selection change — handles arrow keys, clicks,
+ * tab switches, and any other cursor movement.
+ */
+ /**
+ * Unwrap a speculative element, replacing it with its children.
+ * An orphaned speculative element was never completed — it should
+ * not become permanent formatting.
+ */
+ private unwrapSpeculative(element: HTMLElement): void {
+ this.unwrapElement(element);
+ }
+
+ /**
+ * Replace an element with its children, preserving content.
+ */
+ private unwrapElement(element: HTMLElement): void {
+ const parent = element.parentNode;
+ if (!parent) { return; }
+ while (element.firstChild) {
+ parent.insertBefore(element.firstChild, element);
+ }
+ parent.removeChild(element);
+ }
+
+ /**
+ * Remove forbidden nesting from a block element.
+ * For example, inside , inside , etc.
+ */
+ private sanitizeNesting(block: HTMLElement): void {
+ const rules: Record in HTML: ${html.slice(0, 200)}`);
+ });
+
await test('Ctrl+B shortcut works in wysiwyg', async () => {
// Switch to wysiwyg
await driver.executeScript('window.__ribbitEditor.wysiwyg()');
diff --git a/test/integration/test_fuzz.js b/test/integration/test_fuzz.js
new file mode 100644
index 0000000..52d669c
--- /dev/null
+++ b/test/integration/test_fuzz.js
@@ -0,0 +1,460 @@
+/**
+ * WYSIWYG fuzz test.
+ *
+ * Generates random keystroke sequences, types them char-by-char,
+ * and checks structural invariants after every keystroke. When a
+ * failure is found, the seed is logged for deterministic replay
+ * and the sequence is shrunk to a minimal reproducing case.
+ *
+ * Run:
+ * node test/integration/test_fuzz.js
+ * node test/integration/test_fuzz.js --seed 12345
+ * node test/integration/test_fuzz.js --rounds 200
+ * node test/integration/test_fuzz.js --seed 12345 --shrink
+ */
+const { Builder, By, Key } = require('selenium-webdriver');
+const firefox = require('selenium-webdriver/firefox');
+const { createServer } = require('./server');
+
+let server, driver;
+const DELAY = 20;
+
+/* ── Seeded PRNG (mulberry32) ── */
+
+function mulberry32(seed) {
+ return function () {
+ seed |= 0;
+ seed = (seed + 0x6d2b79f5) | 0;
+ let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+/* ── Keystroke generation ── */
+
+const PRINTABLE = 'abcdefghijklmnopqrstuvwxyz 0123456789.,!?';
+const DELIMITERS = ['*', '**', '***', '`', '~~'];
+const BLOCK_PREFIXES = ['# ', '## ', '### ', '- ', '1. ', '> ', '---'];
+const SPECIAL_KEYS = [
+ { name: 'Enter', keys: Key.ENTER, isSpecial: true },
+ { name: 'Backspace', keys: Key.BACK_SPACE, isSpecial: true },
+ { name: 'ArrowLeft', keys: Key.ARROW_LEFT, isSpecial: true },
+ { name: 'ArrowRight', keys: Key.ARROW_RIGHT, isSpecial: true },
+];
+
+/**
+ * Generate a random keystroke sequence.
+ * Returns array of { name, keys } where keys is a string or Key constant.
+ */
+function generateSequence(random, length) {
+ const sequence = [];
+ for (let i = 0; i < length; i++) {
+ const roll = random();
+ if (roll < 0.50) {
+ /* printable character */
+ const character = PRINTABLE[Math.floor(random() * PRINTABLE.length)];
+ sequence.push({ name: character === ' ' ? 'Space' : character, keys: character });
+ } else if (roll < 0.70) {
+ /* delimiter */
+ const delimiter = DELIMITERS[Math.floor(random() * DELIMITERS.length)];
+ sequence.push({ name: delimiter, keys: delimiter });
+ } else if (roll < 0.80) {
+ /* special key */
+ const special = SPECIAL_KEYS[Math.floor(random() * SPECIAL_KEYS.length)];
+ sequence.push(special);
+ } else if (roll < 0.88) {
+ /* block prefix (only useful at line start, but fuzz doesn't care) */
+ const prefix = BLOCK_PREFIXES[Math.floor(random() * BLOCK_PREFIXES.length)];
+ sequence.push({ name: `"${prefix.trim()}"`, keys: prefix });
+ } else if (roll < 0.94) {
+ /* repeated delimiter (stress test) */
+ const count = 2 + Math.floor(random() * 4);
+ const character = '*';
+ sequence.push({ name: character.repeat(count), keys: character.repeat(count) });
+ } else {
+ /* angle bracket / HTML-like content */
+ const fragments = ['<', '>', '
{
+ await resetEditor();
+ await typeString('##');
+ let html = await getHTML();
+ assert(!html.includes('
{
+ await resetEditor();
+ await typeString('# Title');
+ await typeChar(Key.ENTER);
+ await typeString('body');
+ const html = await getHTML();
+ assert(html.includes('
{
+ await resetEditor();
+ await typeString('**');
+ const html = await getHTML();
+ assert(!html.includes(' {
+ await resetEditor();
+ await typeString('**');
+ await typeChar('x');
+ const html = await getHTML();
+ assert(html.includes(' {
+ await resetEditor();
+ await typeString('**hello');
+ let html = await getHTML();
+ assert(html.includes('data-speculative'), `Not speculative during typing: ${html}`);
+
+ await typeString('**');
+ html = await getHTML();
+ assert(html.includes(' {
+ await resetEditor();
+ await typeString('**bold**');
+ await typeString(' after');
+ const html = await getHTML();
+ assert(html.includes('
+ const strongMatch = html.match(/]*>.*?<\/strong>/);
+ if (strongMatch) {
+ assert(!strongMatch[0].includes('after'),
+ `"after" is inside strong — cursor not placed correctly: ${html}`);
+ }
+ });
+
+ // ── Italic ──
+
+ console.log(' Italic:');
+
+ await test('*x starts speculative italic', async () => {
+ await resetEditor();
+ await typeChar('*');
+ let html = await getHTML();
+ assert(!html.includes(' {
+ await resetEditor();
+ await typeString('*hello');
+ let html = await getHTML();
+ assert(html.includes('data-speculative'), `Not speculative: ${html}`);
+
+ await typeChar('*');
+ html = await getHTML();
+ assert(html.includes(' {
+ await resetEditor();
+ await typeString('`hello`');
+ const html = await getHTML();
+ assert(html.includes('
{
+ await resetEditor();
+
+ // Type **
+ await typeString('**');
+ let html = await getHTML();
+ assert(!html.includes(' {
+ await resetEditor();
+ await typeString('**bold**');
+ let html = await getHTML();
+ assert(html.includes(' {
+ await resetEditor();
+ await typeChar('-');
+ let html = await getHTML();
+ assert(!html.includes(' {
+ await resetEditor();
+ await typeString('1.');
+ let html = await getHTML();
+ assert(!html.includes('
space transforms to blockquote', async () => {
+ await resetEditor();
+ await typeChar('>');
+ let html = await getHTML();
+ assert(!html.includes('
": ${html}`);
+ });
+
+ // ── Horizontal rule ──
+
+ console.log(' Horizontal rule:');
+
+ await test('--- transforms to hr', async () => {
+ await resetEditor();
+ await typeString('--');
+ let html = await getHTML();
+ assert(!html.includes('
{
+ await resetEditor();
+ await typeString('**hello**');
+ await driver.sleep(50);
+ const markdown = await getMarkdown();
+ assert(markdown.includes('**hello**'), `Expected **hello** in: ${markdown}`);
+ });
+
+ await test('# Title round-trips to markdown', async () => {
+ await resetEditor();
+ await typeString('# Title');
+ await driver.sleep(50);
+ const markdown = await getMarkdown();
+ assert(markdown.includes('# Title'), `Expected # Title in: ${markdown}`);
+ });
+
+ await test('mode switch preserves content', async () => {
+ await resetEditor();
+ await typeString('**bold**');
+ await typeString(' and ');
+ await typeString('*italic*');
+ await driver.sleep(50);
+
+ await driver.executeScript('window.__ribbitEditor.view()');
+ await driver.sleep(50);
+ await driver.executeScript('window.__ribbitEditor.wysiwyg()');
+ await driver.sleep(50);
+
+ const html = await getHTML();
+ assert(html.includes(' {
+ await resetEditor();
+ await typeString('**hello');
+ await driver.sleep(50);
+ let html = await getHTML();
+ assert(html.includes('data-speculative'), `No speculative: ${html}`);
+
+ await typeChar(Key.ARROW_RIGHT);
+ await driver.sleep(50);
+ html = await getHTML();
+ assert(!html.includes('data-speculative'), `Speculative not closed: ${html}`);
+ });
+
+ await test('click outside closes speculative', async () => {
+ await resetEditor();
+ await typeString('**hello');
+ await driver.sleep(50);
+ let html = await getHTML();
+ assert(html.includes('data-speculative'), `No speculative: ${html}`);
+
+ // Add an element outside the editor and click it
+ await driver.executeScript(`
+ if (!document.getElementById('outside')) {
+ var btn = document.createElement('button');
+ btn.id = 'outside';
+ btn.textContent = 'outside';
+ btn.style.display = 'block';
+ btn.style.padding = '20px';
+ document.body.appendChild(btn);
+ }
+ `);
+ await driver.findElement(By.id('outside')).click();
+ await driver.sleep(100);
+ html = await getHTML();
+ assert(!html.includes('data-speculative'), `Speculative not closed: ${html}`);
+ });
+
+ // ── Complex document ──
+
+ console.log(' Complex document:');
+
+ await test('multi-element document', async () => {
+ await resetEditor();
+ await typeString('# Title');
+ await typeChar(Key.ENTER);
+ await typeString('Some **bold** text.');
+ await typeChar(Key.ENTER);
+ await typeString('## Section');
+ await typeChar(Key.ENTER);
+ await typeString('- item one');
+
+ await driver.sleep(100);
+ const html = await getHTML();
+ assert(html.includes(' {
+ try {
+ await setup();
+ await runTests();
+ } catch (error) {
+ console.error('Setup failed:', error.message);
+ failed++;
+ } finally {
+ console.log(`\n${passed}/${passed + failed} passed — ${failed} failed`);
+ if (errors.length) {
+ console.log('\nFailed:');
+ errors.forEach(error => console.log(` • ${error}`));
+ }
+ await teardown();
+ process.exit(failed > 0 ? 1 : 0);
+ }
+})();