diff --git a/src/ts/ribbit-editor.ts b/src/ts/ribbit-editor.ts
index 9f56621..3009f3f 100644
--- a/src/ts/ribbit-editor.ts
+++ b/src/ts/ribbit-editor.ts
@@ -90,9 +90,7 @@ export class RibbitEditor extends Ribbit {
if (this.state !== this.states.WYSIWYG) {
return;
}
- if (event.key === 'Enter') {
- this.handleEnter(event);
- }
+ this.dispatchKeydown(event);
});
this.element.addEventListener('keyup', (event: KeyboardEvent) => {
@@ -212,7 +210,9 @@ export class RibbitEditor extends Ribbit {
while (node && node !== this.element) {
if (node.nodeType === 1) {
const element = node as HTMLElement;
- if (element.tagName === 'LI' || element.parentNode === this.element) {
+ if (element.tagName === 'LI'
+ || (element.tagName === 'P' && element.parentElement?.tagName === 'BLOCKQUOTE')
+ || element.parentNode === this.element) {
return element;
}
}
@@ -235,6 +235,17 @@ export class RibbitEditor extends Ribbit {
// Normalize → space so patterns like "- " and "> " match
const text = (block.textContent || '').replace(/\u00A0/g, ' ');
+ // If the block contains
elements, check the current line
+ // (text after the last
before the cursor). Block-level
+ // patterns at the start of a line after
should split the
+ // block and transform the new portion.
+ const currentLineText = this.getCurrentLineText(block);
+ if (currentLineText !== null && currentLineText !== text) {
+ if (this.tryBlockTransformOnCurrentLine(block, currentLineText)) {
+ return;
+ }
+ }
+
const headingMatch = text.match(/^(#{1,6})\s/);
if (headingMatch) {
const level = headingMatch[1].length;
@@ -315,6 +326,12 @@ export class RibbitEditor extends Ribbit {
return '';
}
const element = node as HTMLElement;
+
+ // Preserve
as a newline so blockquote line breaks survive
+ if (element.tagName === 'BR') {
+ return '\n';
+ }
+
const specDelim = element.getAttribute('data-speculative');
if (specDelim) {
@@ -358,6 +375,167 @@ export class RibbitEditor extends Ribbit {
* Sentinel markers (\x01...\x02) prevent the regex from matching
* delimiters that belong to already-transformed elements.
*/
+ /**
+ * Get the text of the current line within a block — the text
+ * after the last
before the cursor. Returns null if there
+ * are no
elements (single-line block).
+ */
+ private getCurrentLineText(block: HTMLElement): string | null {
+ const hasBr = block.querySelector('br');
+ if (!hasBr) {
+ return null;
+ }
+ const selection = window.getSelection();
+ if (!selection || selection.rangeCount === 0) {
+ return null;
+ }
+ // Collect text from the cursor's text node backward to the
+ // nearest
or start of block
+ let node: Node | null = selection.anchorNode;
+ if (!node || !block.contains(node)) {
+ return null;
+ }
+ // Get text from cursor position to start of current text node
+ let lineText = '';
+ if (node.nodeType === 3) {
+ lineText = (node.textContent || '').slice(0, selection.anchorOffset);
+ }
+ // Walk backward through siblings collecting text until we hit a
+ let sibling: Node | null = node.nodeType === 3
+ ? node.previousSibling
+ : null;
+ while (sibling) {
+ if (sibling.nodeType === 1 && (sibling as HTMLElement).tagName === 'BR') {
+ break;
+ }
+ lineText = (sibling.textContent || '') + lineText;
+ sibling = sibling.previousSibling;
+ }
+ // Only return if we actually found a
(meaning this is a
+ // subsequent line, not the first line of the block)
+ if (!sibling) {
+ return null;
+ }
+ return lineText.replace(/\u00A0/g, ' ').replace(/\u200B/g, '');
+ }
+
+ /**
+ * Check if a block-level pattern matches on the current line text.
+ * If so, split the block at the
before this line and transform
+ * the new block.
+ */
+ private tryBlockTransformOnCurrentLine(block: HTMLElement, lineText: string): boolean {
+ let newTag: string | null = null;
+ let prefixLength = 0;
+
+ const headingMatch = lineText.match(/^(#{1,6})\s/);
+ if (headingMatch) {
+ newTag = 'H' + headingMatch[1].length;
+ prefixLength = headingMatch[0].length;
+ } else if (lineText.startsWith('> ')) {
+ newTag = 'BLOCKQUOTE';
+ prefixLength = 2;
+ } else if (/^[-*+]\s/.test(lineText)) {
+ return this.splitAndTransformList(block, 'ul', lineText);
+ } else if (/^\d+\.\s/.test(lineText)) {
+ return this.splitAndTransformList(block, 'ol', lineText);
+ } else if (/^(\*{3,}|-{3,}|_{3,})\s*$/.test(lineText)) {
+ this.splitAtCurrentLine(block);
+ // The split created a new
with the hr text — transform it
+ const newBlock = block.nextElementSibling as HTMLElement;
+ if (newBlock) {
+ const hr = document.createElement('hr');
+ const paragraph = document.createElement('p');
+ paragraph.innerHTML = '
';
+ newBlock.replaceWith(hr, paragraph);
+ const range = document.createRange();
+ range.setStart(paragraph, 0);
+ range.collapse(true);
+ const selection = window.getSelection()!;
+ selection.removeAllRanges();
+ selection.addRange(range);
+ }
+ return true;
+ }
+
+ if (!newTag) {
+ return false;
+ }
+
+ this.splitAtCurrentLine(block);
+ const newBlock = block.nextElementSibling as HTMLElement;
+ if (newBlock) {
+ this.replaceBlock(newBlock, newTag, prefixLength);
+ }
+ return true;
+ }
+
+ /**
+ * Split a block at the
before the current line. Everything
+ * after the
becomes a new
element after the original block.
+ */
+ private splitAtCurrentLine(block: HTMLElement): void {
+ const selection = window.getSelection();
+ if (!selection || selection.rangeCount === 0) {
+ return;
+ }
+ let node: Node | null = selection.anchorNode;
+
+ // Find the
before the current line
+ let brNode: Node | null = null;
+ if (node?.nodeType === 3) {
+ let sibling: Node | null = node.previousSibling;
+ while (sibling) {
+ if (sibling.nodeType === 1 && (sibling as HTMLElement).tagName === 'BR') {
+ brNode = sibling;
+ break;
+ }
+ sibling = sibling.previousSibling;
+ }
+ }
+
+ if (!brNode) {
+ return;
+ }
+
+ // Collect all nodes after the
into a new
+ const newParagraph = document.createElement('p');
+ let nextNode: Node | null = brNode.nextSibling;
+ while (nextNode) {
+ const following = nextNode.nextSibling;
+ newParagraph.appendChild(nextNode);
+ nextNode = following;
+ }
+ // Strip leading ZWS from the first text node — it was a cursor
+ // anchor from the Enter handler, not real content
+ const firstChild = newParagraph.firstChild;
+ if (firstChild && firstChild.nodeType === 3) {
+ firstChild.textContent = (firstChild.textContent || '').replace(/^\u200B+/, '');
+ }
+ brNode.parentNode?.removeChild(brNode);
+
+ // Remove trailing empty text nodes and elements so Enter can create
+ // sibling paragraphs within the quote
+ if (newTag === 'BLOCKQUOTE') {
+ const paragraph = document.createElement('p');
+ if (content) {
+ paragraph.textContent = content;
+ } else {
+ paragraph.innerHTML = ' elements inside container blocks (blockquote, li)
+ // so the container's handler runs instead.
+ const containerTags = new Set(['BLOCKQUOTE', 'LI']);
+ let node: Node | null = selection.anchorNode;
+ while (node && node !== this.element) {
+ if (node.nodeType === 1) {
+ const element = node as HTMLElement;
+ // Skip inside containers — let the container handle it
+ if (element.tagName === 'P' && element.parentElement
+ && containerTags.has(element.parentElement.tagName)) {
+ node = node.parentNode;
+ continue;
+ }
+ const tag = this.converter.getBlockTags().find(
+ blockTag => typeof blockTag.selector === 'string'
+ && blockTag.selector.split(',').some(
+ selector => element.tagName === selector.trim()
+ )
+ );
+ if (tag?.handleKeydown) {
+ const handled = tag.handleKeydown(element, event, selection, this);
+ if (handled) {
+ event.preventDefault();
+ return;
+ }
+ }
+ }
+ node = node.parentNode;
}
}
@@ -705,7 +989,11 @@ export class RibbitEditor extends Ribbit {
return decodeHtmlEntities(html);
}
if (this.getState() === this.states.WYSIWYG || this.getState() === this.states.VIEW) {
- return this.htmlToMarkdown(this.element.innerHTML);
+ // Normalize non-breaking spaces and strip zero-width spaces
+ // that the WYSIWYG transform inserts for cursor positioning
+ return this.htmlToMarkdown(this.element.innerHTML)
+ .replace(/\u00A0/g, ' ')
+ .replace(/\u200B/g, '');
}
// Before run() — element has raw markdown as text
return this.element.textContent || '';
@@ -720,6 +1008,8 @@ export class RibbitEditor extends Ribbit {
wysiwyg(): void {
if (this.getState() === this.states.WYSIWYG) return;
const wasEditing = this.getState() === this.states.EDIT;
+ // Invalidate cache so getHTML() re-converts from current content
+ this.invalidateCache();
this.vim?.detach();
this.collaboration?.connect();
if (wasEditing && this.collaboration?.isPaused()) {
diff --git a/src/ts/ribbit.ts b/src/ts/ribbit.ts
index 4c85a6f..54c89c1 100644
--- a/src/ts/ribbit.ts
+++ b/src/ts/ribbit.ts
@@ -271,6 +271,7 @@ export class Ribbit {
*/
view(): void {
if (this.getState() === this.states.VIEW) return;
+ this.invalidateCache();
this.collaboration?.disconnect();
this.element.innerHTML = this.getHTML();
this.setState(this.states.VIEW);
diff --git a/src/ts/tags.ts b/src/ts/tags.ts
index 34b9788..ec805af 100644
--- a/src/ts/tags.ts
+++ b/src/ts/tags.ts
@@ -132,13 +132,219 @@ export function inlineTag(def: InlineTagDef): Tag {
};
}
+/**
+ * Base class for block-level tags. Provides keyboard event dispatch
+ * for WYSIWYG mode: subclasses populate `eventHandlers` with named
+ * handlers (e.g. 'onEnter', 'onBackspace'), and `handleKeydown`
+ * routes events to the matching handler.
+ *
+ * class MyTag extends BaseTag implements Tag {
+ * eventHandlers = { 'onEnter': this.onEnter };
+ * private onEnter(element: HTMLElement, selection: Selection): boolean { ... }
+ * }
+ */
+class BaseTag {
+ eventHandlers: Record after the current block.
+ */
+ /**
+ * Default Backspace handler. When the cursor is at the start of
+ * a line after a hello\nworld inside the blockquote is a paragraph group.
+ // Within a , after it
+ */
+ private onEnter(listElement: HTMLElement, selection: Selection, _editor: any): boolean {
+ // Find the hello world , so two separate paragraphs
+ const paragraphCount = (html.match(/ ]/g) || []).length;
+ assert(paragraphCount >= 2, `Expected 2+ paragraphs, got ${paragraphCount}: ${html}`);
+ });
+
+ await test('backspace at start of line after Enter joins lines', async () => {
+ await resetEditor();
+ await typeString('foo');
+ await typeChar(Key.ENTER);
+ await typeChar(Key.BACK_SPACE);
+ await driver.sleep(50);
+ const html = await getHTML();
+ // The
from the original block
+ while (block.lastChild
+ && ((block.lastChild.nodeType === 3
+ && (block.lastChild.textContent || '').replace(/\u200B/g, '').trim() === '')
+ || (block.lastChild.nodeType === 1
+ && (block.lastChild as HTMLElement).tagName === 'BR'))) {
+ block.removeChild(block.lastChild);
+ }
+
+ block.after(newParagraph);
+ }
+
+ private splitAndTransformList(block: HTMLElement, listTag: string, lineText: string): boolean {
+ const prefixLength = lineText.indexOf(' ') + 1;
+ this.splitAtCurrentLine(block);
+ const newBlock = block.nextElementSibling as HTMLElement;
+ if (newBlock) {
+ this.replaceBlockWithList(newBlock, listTag, prefixLength);
+ }
+ return true;
+ }
+
private transformInline(block: HTMLElement): void {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
@@ -369,6 +547,11 @@ export class RibbitEditor extends Ribbit {
return;
}
+ // Normalize flanking underscores to asterisks so _ triggers
+ // the same live preview as *. The CSS pseudo-elements will
+ // show * even though the user typed _.
+ markdown = this.normalizeUnderscores(markdown);
+
// Nesting rules: which elements must not appear inside which
const forbiddenChildren = RibbitEditor.forbiddenNesting;
@@ -413,6 +596,38 @@ export class RibbitEditor extends Ribbit {
* If an unclosed opener was found, wrap the trailing content in
* a speculative element; otherwise set innerHTML directly.
*/
+ /**
+ * Convert flanking underscore runs to asterisks so the delimiter
+ * matching treats _ the same as *. Non-flanking underscores (like
+ * foo_bar) are left alone. Backslash-escaped underscores (\_) are
+ * protected from normalization.
+ */
+ private normalizeUnderscores(text: string): string {
+ const escapePlaceholder = '\x00U\x00';
+ const safeText = text.replace(/\\_/g, escapePlaceholder);
+
+ const punctuation = `[\\s.,;:!?'"()\\[\\]{}<>\\-/\\\\~#@&^|*\`]`;
+ const openRun = new RegExp(
+ `(?<=^|${punctuation})` +
+ `(_+)` +
+ `(?=\\S)`,
+ 'g'
+ );
+ const closeRun = new RegExp(
+ `(?<=\\S)` +
+ `(_+)` +
+ `(?=$|${punctuation})`,
+ 'g'
+ );
+ const toAsterisks = (_match: string, run: string) =>
+ '*'.repeat(run.length);
+ const normalized = safeText
+ .replace(openRun, toAsterisks)
+ .replace(closeRun, toAsterisks);
+
+ return normalized.replace(/\x00U\x00/g, '\\_');
+ }
+
private rebuildBlock(
block: HTMLElement,
markdown: string,
@@ -420,7 +635,7 @@ export class RibbitEditor extends Ribbit {
forbiddenChildren: Record
\u200B');
this.sanitizeNesting(block);
this.appendZwsIfNeeded(block);
this.placeCursorAtEnd(block);
@@ -434,7 +649,7 @@ export class RibbitEditor extends Ribbit {
const probe = document.createElement('div');
probe.innerHTML = inside;
if (banned && banned.some(tag => probe.querySelector(tag))) {
- block.innerHTML = markdown;
+ block.innerHTML = markdown.replace(/\n/g, '
\u200B');
this.sanitizeNesting(block);
this.appendZwsIfNeeded(block);
this.placeCursorAtEnd(block);
@@ -501,6 +716,30 @@ export class RibbitEditor extends Ribbit {
private replaceBlock(block: HTMLElement, newTag: string, prefixLength: number): void {
const newEl = document.createElement(newTag);
const content = (block.textContent || '').slice(prefixLength);
+ // Blockquotes need inner
';
+ }
+ newEl.appendChild(paragraph);
+ block.replaceWith(newEl);
+ newEl.classList.add('ribbit-editing');
+ const range = document.createRange();
+ if (paragraph.firstChild && paragraph.firstChild.nodeType === 3) {
+ range.setStart(paragraph.firstChild, 0);
+ } else {
+ range.setStart(paragraph, 0);
+ }
+ range.collapse(true);
+ const selection = window.getSelection()!;
+ selection.removeAllRanges();
+ selection.addRange(range);
+ return;
+ }
if (content) {
newEl.textContent = content;
} else {
@@ -554,11 +793,56 @@ export class RibbitEditor extends Ribbit {
* On Enter, strip editing decorations from the current block so
* the browser's default newline behavior creates a clean element.
*/
- private handleEnter(_event: KeyboardEvent): void {
- const prev = this.element.querySelector('.ribbit-editing');
- if (prev) {
- prev.classList.remove('ribbit-editing');
- prev.removeAttribute('data-speculative');
+ /**
+ * Dispatch a keydown event to the tag that contains the cursor.
+ * Walks up from the cursor to find a tag with handleKeydown,
+ * which routes to named handlers in the tag's eventHandlers map.
+ * If no tag handles the event, the browser's default runs.
+ */
+ private dispatchKeydown(event: KeyboardEvent): void {
+ // Strip editing decorations on Enter regardless of tag handling
+ if (event.key === 'Enter') {
+ const prev = this.element.querySelector('.ribbit-editing');
+ if (prev) {
+ prev.classList.remove('ribbit-editing');
+ prev.removeAttribute('data-speculative');
+ }
+ }
+
+ const selection = window.getSelection();
+ if (!selection || selection.rangeCount === 0) {
+ return;
+ }
+
+ // Walk up from the cursor to find a tag with handleKeydown.
+ // Skip
for single Enter,
+ // exit block for double Enter (empty line after
)
+ if (event.key === 'Enter') {
+ return this.defaultOnEnter(element, selection, editor);
+ }
+ if (event.key === 'Backspace') {
+ return this.defaultOnBackspace(element, selection);
+ }
+ return false;
+ }
+
+ /**
+ * Default Enter handler for all block tags. Inserts a
for
+ * line continuation. If the cursor is on an empty line (right
+ * after a
with no content following), removes the trailing
+ *
and creates a new
(i.e. on the ZWS cursor anchor), remove
+ * the
and ZWS to join the lines. Otherwise let the browser
+ * handle it.
+ */
+ private defaultOnBackspace(element: HTMLElement, selection: Selection): boolean {
+ const range = selection.getRangeAt(0);
+ const container = range.startContainer;
+ const offset = range.startOffset;
+
+ if (container.nodeType !== 3) {
+ return false;
+ }
+
+ const zeroWidthSpace = /\u200B/g;
+ const textBefore = (container.textContent || '').slice(0, offset).replace(zeroWidthSpace, '');
+
+ // Only intercept if cursor is at the start of the line
+ // (nothing but ZWS before the cursor in this text node)
+ if (textBefore !== '') {
+ return false;
+ }
+
+ // Walk back past empty text nodes to find a
+ let previous: Node | null = container.previousSibling;
+ while (previous && previous.nodeType === 3
+ && (previous.textContent || '').replace(zeroWidthSpace, '').trim() === '') {
+ previous = previous.previousSibling;
+ }
+
+ if (!previous || previous.nodeType !== 1 || (previous as HTMLElement).tagName !== 'BR') {
+ return false;
+ }
+
+ // Remove the
and any ZWS/empty text nodes between it
+ // and the cursor's text node
+ let nodeToRemove: Node | null = container.previousSibling;
+ while (nodeToRemove && nodeToRemove !== previous) {
+ const prev = nodeToRemove.previousSibling;
+ nodeToRemove.parentNode?.removeChild(nodeToRemove);
+ nodeToRemove = prev;
+ }
+ previous.parentNode?.removeChild(previous);
+
+ // Remove the ZWS from the cursor's text node
+ if (container.textContent?.replace(zeroWidthSpace, '') === '') {
+ // Text node is only ZWS — remove it entirely
+ const nextSibling = container.nextSibling;
+ const parentNode = container.parentNode;
+ container.parentNode?.removeChild(container);
+ // Place cursor at end of previous text node
+ const prevText = parentNode?.lastChild;
+ if (prevText && prevText.nodeType === 3) {
+ const newRange = document.createRange();
+ newRange.setStart(prevText, prevText.textContent?.length || 0);
+ newRange.collapse(true);
+ selection.removeAllRanges();
+ selection.addRange(newRange);
+ }
+ }
+
+ return true;
+ }
+
+ private defaultOnEnter(element: HTMLElement, selection: Selection, _editor: any): boolean {
+ const range = selection.getRangeAt(0);
+ const container = range.startContainer;
+ const offset = range.startOffset;
+
+ // Detect empty line: cursor is at a position where the
+ // previous sibling is a
and there's no text after it
+ const zeroWidthSpace = /\u200B/g;
+ let lineIsEmpty = false;
+
+ if (container.nodeType === 3) {
+ const textBefore = (container.textContent || '').slice(0, offset).replace(zeroWidthSpace, '').trim();
+ const textAfter = (container.textContent || '').slice(offset).replace(zeroWidthSpace, '').trim();
+ if (textBefore === '' && textAfter === '') {
+ // Walk back past empty text nodes to find a
+ let previous: Node | null = container.previousSibling;
+ while (previous && previous.nodeType === 3
+ && (previous.textContent || '').replace(zeroWidthSpace, '').trim() === '') {
+ previous = previous.previousSibling;
+ }
+ if (previous && previous.nodeType === 1 && (previous as HTMLElement).tagName === 'BR') {
+ lineIsEmpty = true;
+ }
+ }
+ } else if (container.nodeType === 1) {
+ const childAtCursor = (container as HTMLElement).childNodes[offset - 1];
+ if (childAtCursor && childAtCursor.nodeType === 1 && (childAtCursor as HTMLElement).tagName === 'BR') {
+ // Only treat as empty line if there's real content before
+ // the
— an empty paragraph's placeholder
doesn't count
+ const textBefore = Array.from((container as HTMLElement).childNodes)
+ .slice(0, offset - 1)
+ .map(node => node.textContent || '')
+ .join('')
+ .replace(zeroWidthSpace, '')
+ .trim();
+ const textAfter = Array.from((container as HTMLElement).childNodes)
+ .slice(offset)
+ .map(node => node.textContent || '')
+ .join('')
+ .replace(zeroWidthSpace, '')
+ .trim();
+ if (textAfter === '' && textBefore !== '') {
+ lineIsEmpty = true;
+ }
+ }
+ }
+
+ if (lineIsEmpty) {
+ // Double Enter: remove the trailing
, any empty text
+ // nodes after it, and the cursor's text node
+ let nodeToRemove: Node | null = container.nodeType === 3
+ ? container.previousSibling
+ : (container as HTMLElement).childNodes[offset - 1];
+ // Walk past empty text nodes to find the
+ while (nodeToRemove && nodeToRemove.nodeType === 3
+ && (nodeToRemove.textContent || '').replace(zeroWidthSpace, '').trim() === '') {
+ const previous = nodeToRemove.previousSibling;
+ nodeToRemove.parentNode?.removeChild(nodeToRemove);
+ nodeToRemove = previous;
+ }
+ // Remove the
itself
+ if (nodeToRemove && nodeToRemove.nodeType === 1
+ && (nodeToRemove as HTMLElement).tagName === 'BR') {
+ nodeToRemove.parentNode?.removeChild(nodeToRemove);
+ }
+ // Remove the cursor's empty text node
+ if (container.nodeType === 3
+ && container.textContent?.replace(zeroWidthSpace, '').trim() === '') {
+ container.parentNode?.removeChild(container);
+ }
+
+ const newParagraph = document.createElement('p');
+ newParagraph.innerHTML = '
';
+ // Find the top-level block to insert after
+ let block: Node = element;
+ while (block.parentNode && block.parentNode !== _editor.element) {
+ block = block.parentNode;
+ }
+ (block as HTMLElement).after(newParagraph);
+ const newRange = document.createRange();
+ newRange.setStart(newParagraph, 0);
+ newRange.collapse(true);
+ selection.removeAllRanges();
+ selection.addRange(newRange);
+ } else {
+ // Single Enter: insert
followed by a zero-width space.
+ // The ZWS gives the browser a text node to place the cursor
+ // in — without it, Firefox removes the
when the user
+ // types the next character.
+ const brElement = document.createElement('br');
+ const cursorAnchor = document.createTextNode('\u200B');
+ range.deleteContents();
+ range.insertNode(cursorAnchor);
+ range.insertNode(brElement);
+ const newRange = document.createRange();
+ newRange.setStart(cursorAnchor, 1);
+ newRange.collapse(true);
+ selection.removeAllRanges();
+ selection.addRange(newRange);
+ }
+ return true;
+ }
+}
+
/**
* Fenced code blocks: lines between ``` delimiters become .
*
* converter.toHTML('```js\nlet x = 1;\n```')
* //
*/
-class FencedCodeTag implements Tag {
+class FencedCodeTag extends BaseTag implements Tag {
name = 'fencedCode';
selector = 'PRE';
button = {
@@ -192,7 +398,7 @@ class FencedCodeTag implements Tag {
*
* converter.toHTML('---') // 'let x = 1;
'
*/
-class HorizontalRuleTag implements Tag {
+class HorizontalRuleTag extends BaseTag implements Tag {
name = 'hr';
selector = 'HR';
button = {
@@ -229,9 +435,12 @@ class HorizontalRuleTag implements Tag {
*
* converter.toHTML('## Hello') // Hello
*/
-class HeadingTag implements Tag {
+class HeadingTag extends BaseTag implements Tag {
name = 'heading';
selector = 'H1,H2,H3,H4,H5,H6';
+ eventHandlers = {
+ 'onEnter': this.onEnter,
+ };
button = {
show: false,
label: 'Heading',
@@ -287,6 +496,22 @@ class HeadingTag implements Tag {
* Generate a PascalCase anchor ID from heading text so that
* in-page links like #MyHeading work without manual IDs.
*/
+ /**
+ * Headings always exit on Enter — you don't continue a heading
+ * across multiple lines.
+ */
+ private onEnter(heading: HTMLElement, selection: Selection, _editor: any): boolean {
+ const newParagraph = document.createElement('p');
+ newParagraph.innerHTML = '
';
+ heading.after(newParagraph);
+ const range = document.createRange();
+ range.setStart(newParagraph, 0);
+ range.collapse(true);
+ selection.removeAllRanges();
+ selection.addRange(range);
+ return true;
+ }
+
private anchorId(text: string): string {
return text.trim().split(/\s+/).map(word =>
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
@@ -301,7 +526,7 @@ class HeadingTag implements Tag {
* converter.toHTML('> hello\n> world')
* //
*/
-class BlockquoteTag implements Tag {
+class BlockquoteTag extends BaseTag implements Tag {
name = 'blockquote';
selector = 'BLOCKQUOTE';
button = {
@@ -311,6 +536,9 @@ class BlockquoteTag implements Tag {
};
template = '> Quote\n> continues here';
replaceSelection = true;
+ eventHandlers = {
+ 'onEnter': this.onEnter,
+ };
match(context: MatchContext): SourceToken | null {
const quotePrefix = /^>\s?/;
@@ -330,12 +558,153 @@ class BlockquoteTag implements Tag {
}
toHTML(token: SourceToken, convert: Converter): string {
- return '' + convert.block(token.content) + '
';
+ // Within a blockquote, consecutive lines without a blank line
+ // between them should produce
line breaks (not merge into
+ // one paragraph). Insert hard break markers before block parsing
+ // so the inline processor creates
elements.
+ const withHardBreaks = token.content.replace(
+ /([^\n])\n(?!\n)/g, // \n not followed by another \n
+ '$1 \n' // trailing two spaces = hard break
+ );
+ return '' + convert.block(withHardBreaks) + '
';
}
toMarkdown(element: HTMLElement, convert: Converter): string {
- const lines = convert.children(element).trim().split('\n');
- return '\n\n' + lines.map(line => '> ' + line).join('\n') + '\n\n';
+ // Each
elements create line breaks.
+ // Separate paragraphs get a blank > line between them.
+ const paragraphs: string[] = [];
+ for (const child of Array.from(element.childNodes)) {
+ if (child.nodeType === 1 && (child as HTMLElement).tagName === 'P') {
+ const lines = this.paragraphToLines(child as HTMLElement, convert);
+ paragraphs.push(lines.map(line => '> ' + line).join('\n'));
+ } else {
+ const text = convert.node(child).trim();
+ if (text) {
+ paragraphs.push('> ' + text);
+ }
+ }
+ }
+ return '\n\n' + paragraphs.join('\n>\n') + '\n\n';
+ }
+
+ /**
+ * Split a paragraph's content into lines at
elements.
+ */
+ private paragraphToLines(paragraph: HTMLElement, convert: Converter): string[] {
+ const lines: string[] = [];
+ let currentLine = '';
+ for (const child of Array.from(paragraph.childNodes)) {
+ if (child.nodeType === 1 && (child as HTMLElement).tagName === 'BR') {
+ lines.push(currentLine);
+ currentLine = '';
+ } else {
+ currentLine += convert.node(child);
+ }
+ }
+ if (currentLine.trim()) {
+ lines.push(currentLine);
+ }
+ return lines;
+ }
+
+ /**
+ * Enter inside a blockquote adds a new paragraph within the quote.
+ * Double-Enter (empty line) exits the blockquote and creates a
+ * new paragraph after it — matching the behavior users expect
+ * from list editing.
+ */
+ private onEnter(blockquote: HTMLElement, selection: Selection, _editor: any): boolean {
+ // Find the text content after the cursor to determine if
+ // the current line is empty (for double-Enter exit detection)
+ const range = selection.getRangeAt(0);
+ const container = range.startContainer;
+ const offset = range.startOffset;
+
+ // Check if the cursor is at an empty line: either the entire
+ // paragraph is empty, or the cursor is right after a
with
+ // nothing after it
+ let lineIsEmpty = false;
+ const paragraph = container.nodeType === 1
+ ? container as HTMLElement
+ : container.parentElement;
+
+ if (paragraph) {
+ const zeroWidthSpace = /\u200B/g;
+ const textAfterCursor = container.nodeType === 3
+ ? (container.textContent || '').slice(offset).replace(zeroWidthSpace, '').trim()
+ : '';
+ const textBeforeCursor = container.nodeType === 3
+ ? (container.textContent || '').slice(0, offset).replace(zeroWidthSpace, '').trim()
+ : '';
+
+ // Empty if: whole paragraph is empty, or cursor is at the
+ // start of an empty text node after a
+ const fullText = paragraph.textContent?.replace(zeroWidthSpace, '').trim() || '';
+ if (fullText === '') {
+ lineIsEmpty = true;
+ } else if (textBeforeCursor === '' && textAfterCursor === '') {
+ // Walk back past empty text nodes to find a
+ let previous: Node | null = container.nodeType === 3
+ ? container.previousSibling
+ : (container as HTMLElement).childNodes[offset - 1];
+ while (previous && previous.nodeType === 3
+ && (previous.textContent || '').replace(zeroWidthSpace, '').trim() === '') {
+ previous = previous.previousSibling;
+ }
+ if (previous && previous.nodeType === 1 && (previous as HTMLElement).tagName === 'BR') {
+ lineIsEmpty = true;
+ }
+ }
+ }
+
+ if (lineIsEmpty) {
+ // Double-Enter: clean up the trailing
and empty nodes,
+ // then exit the blockquote
+ const lastParagraph = blockquote.querySelector('p:last-child');
+ if (lastParagraph) {
+ // Remove trailing
and empty text nodes
+ const zwsPattern = /\u200B/g;
+ while (lastParagraph.lastChild) {
+ const child = lastParagraph.lastChild;
+ if (child.nodeType === 1 && (child as HTMLElement).tagName === 'BR') {
+ child.remove();
+ break;
+ }
+ if (child.nodeType === 3
+ && (child.textContent || '').replace(zwsPattern, '').trim() === '') {
+ child.remove();
+ continue;
+ }
+ break;
+ }
+ // If the paragraph is now empty, remove it entirely
+ if (lastParagraph.textContent?.replace(zwsPattern, '').trim() === '') {
+ lastParagraph.remove();
+ }
+ }
+ const newParagraph = document.createElement('p');
+ newParagraph.innerHTML = '
';
+ blockquote.after(newParagraph);
+ const newRange = document.createRange();
+ newRange.setStart(newParagraph, 0);
+ newRange.collapse(true);
+ selection.removeAllRanges();
+ selection.addRange(newRange);
+ } else {
+ // Single Enter: insert
+ ZWS cursor anchor
+ const brElement = document.createElement('br');
+ const cursorAnchor = document.createTextNode('\u200B');
+ range.deleteContents();
+ range.insertNode(cursorAnchor);
+ range.insertNode(brElement);
+ const newRange = document.createRange();
+ newRange.setStart(cursorAnchor, 1);
+ newRange.collapse(true);
+ selection.removeAllRanges();
+ selection.addRange(newRange);
+ }
+ return true;
}
}
@@ -345,9 +714,12 @@ class BlockquoteTag implements Tag {
*
* converter.toHTML('- one\n- two\n 1. nested')
*/
-class ListTag implements Tag {
+class ListTag extends BaseTag implements Tag {
name = 'list';
selector = 'UL,OL';
+ eventHandlers = {
+ 'onEnter': this.onEnter,
+ };
button = {
show: false,
label: 'List',
@@ -387,6 +759,129 @@ class ListTag implements Tag {
* Count how many consecutive lines belong to this list, including
* nested sublists (detected by increased indentation).
*/
+ /**
+ * List Enter behavior has three tiers:
+ * 1. Single Enter →
within current
(same logic as defaultOnEnter)
+ let lineIsEmpty = false;
+ if (container.nodeType === 3) {
+ const textBefore = (container.textContent || '').slice(0, offset).replace(zeroWidthSpace, '').trim();
+ const textAfter = (container.textContent || '').slice(offset).replace(zeroWidthSpace, '').trim();
+ if (textBefore === '' && textAfter === '') {
+ let previous: Node | null = container.previousSibling;
+ while (previous && previous.nodeType === 3
+ && (previous.textContent || '').replace(zeroWidthSpace, '').trim() === '') {
+ previous = previous.previousSibling;
+ }
+ if (previous && previous.nodeType === 1 && (previous as HTMLElement).tagName === 'BR') {
+ lineIsEmpty = true;
+ }
+ }
+ } else if (container.nodeType === 1) {
+ const childAtCursor = (container as HTMLElement).childNodes[offset - 1];
+ if (childAtCursor && childAtCursor.nodeType === 1 && (childAtCursor as HTMLElement).tagName === 'BR') {
+ const textBefore = Array.from((container as HTMLElement).childNodes)
+ .slice(0, offset - 1)
+ .map(child => child.textContent || '')
+ .join('')
+ .replace(zeroWidthSpace, '')
+ .trim();
+ const textAfter = Array.from((container as HTMLElement).childNodes)
+ .slice(offset)
+ .map(child => child.textContent || '')
+ .join('')
+ .replace(zeroWidthSpace, '')
+ .trim();
+ if (textAfter === '' && textBefore !== '') {
+ lineIsEmpty = true;
+ }
+ }
+ }
+
+ if (lineIsEmpty && itemText === '') {
+ // Tier 3: empty
';
+ listElement.after(newParagraph);
+ const newRange = document.createRange();
+ newRange.setStart(newParagraph, 0);
+ newRange.collapse(true);
+ selection.removeAllRanges();
+ selection.addRange(newRange);
+ return true;
+ }
+
+ if (lineIsEmpty) {
+ // Tier 2: non-empty
and empty nodes
+ let nodeToRemove: Node | null = container.nodeType === 3
+ ? container.previousSibling
+ : (container as HTMLElement).childNodes[offset - 1];
+ while (nodeToRemove && nodeToRemove.nodeType === 3
+ && (nodeToRemove.textContent || '').replace(zeroWidthSpace, '').trim() === '') {
+ const previous = nodeToRemove.previousSibling;
+ nodeToRemove.parentNode?.removeChild(nodeToRemove);
+ nodeToRemove = previous;
+ }
+ if (nodeToRemove && nodeToRemove.nodeType === 1
+ && (nodeToRemove as HTMLElement).tagName === 'BR') {
+ nodeToRemove.parentNode?.removeChild(nodeToRemove);
+ }
+ if (container.nodeType === 3
+ && container.textContent?.replace(zeroWidthSpace, '').trim() === '') {
+ container.parentNode?.removeChild(container);
+ }
+
+ const newItem = document.createElement('li');
+ newItem.innerHTML = '
';
+ listItem.after(newItem);
+ const newRange = document.createRange();
+ newRange.setStart(newItem, 0);
+ newRange.collapse(true);
+ selection.removeAllRanges();
+ selection.addRange(newRange);
+ return true;
+ }
+
+ // Tier 1: single Enter — insert
+ ZWS cursor anchor
+ const brElement = document.createElement('br');
+ const cursorAnchor = document.createTextNode('\u200B');
+ range.deleteContents();
+ range.insertNode(cursorAnchor);
+ range.insertNode(brElement);
+ const newRange = document.createRange();
+ newRange.setStart(cursorAnchor, 1);
+ newRange.collapse(true);
+ selection.removeAllRanges();
+ selection.addRange(newRange);
+ return true;
+ }
+
private countListLines(lines: string[], start: number): number {
const indentedUnordered = /^(\s*)[*\-+]\s/;
const indentedOrdered = /^(\s*)\d+\.\s/;
@@ -501,7 +996,7 @@ class ListTag implements Tag {
*
* converter.toHTML('| A | B |\n|---|---|\n| 1 | 2 |')
*/
-class TableTag implements Tag {
+class TableTag extends BaseTag implements Tag {
name = 'table';
selector = 'TABLE';
button = {
@@ -611,7 +1106,7 @@ class TableTag implements Tag {
*
* converter.toHTML('hello world') // '
';
+ expect(H(M(html))).toBe(html);
+ });
+ it('code in cells survives round-trip', () => {
+ const html = 'A B bold italic
';
+ expect(H(M(html))).toBe(html);
+ });
+ it('literal * in cells survives round-trip', () => {
+ const html = 'A x
';
+ expect(H(M(html))).toBe(html);
+ });
+});
diff --git a/test/integration/test_wysiwyg.js b/test/integration/test_wysiwyg.js
index a438e11..c982de6 100644
--- a/test/integration/test_wysiwyg.js
+++ b/test/integration/test_wysiwyg.js
@@ -318,6 +318,67 @@ async function runTests() {
assert(html.includes('A 2 * 3 ": ${html}`);
});
+ await test('enter inside blockquote adds new line', async () => {
+ await resetEditor();
+ await typeString('> first line');
+ let html = await getHTML();
+ assert(html.includes('
foo\n> bar" — continuation, no blank lines
+ const markdown = await getMarkdown();
+ assert(markdown.includes('> first line'), `Missing > first line in markdown: ${markdown}`);
+ assert(markdown.includes('> second line'), `Missing > second line in markdown: ${markdown}`);
+ assert(!markdown.includes('>\n>'), `Unwanted blank > line in markdown: ${markdown}`);
+ });
+
+ await test('blockquote paragraphs survive mode round-trip', async () => {
+ await resetEditor();
+ await typeString('> foo');
+ await typeChar(Key.ENTER);
+ await typeString('bar');
+ await driver.sleep(50);
+
+ // Switch to source and back to wysiwyg twice
+ await driver.executeScript('window.__ribbitEditor.edit()');
+ await driver.sleep(50);
+ await driver.executeScript('window.__ribbitEditor.wysiwyg()');
+ await driver.sleep(50);
+ await driver.executeScript('window.__ribbitEditor.edit()');
+ await driver.sleep(50);
+
+ const markdown = await driver.executeScript('return document.getElementById("ribbit").textContent');
+ assert(markdown.includes('> foo'), `Missing > foo: ${markdown}`);
+ assert(markdown.includes('> bar'), `Missing > bar — second line lost its prefix: ${markdown}`);
+ });
+
+ await test('double enter exits blockquote', async () => {
+ await resetEditor();
+ await typeString('> quoted');
+ await typeChar(Key.ENTER);
+ await typeChar(Key.ENTER);
+ await typeString('after');
+ const html = await getHTML();
+ assert(html.includes('
afterBlockquote, `"after" is inside blockquote: ${html}`);
+ });
+
// ── Horizontal rule ──
console.log(' Horizontal rule:');
@@ -412,6 +473,99 @@ async function runTests() {
assert(!html.includes('data-speculative'), `Speculative not closed: ${html}`);
});
+ console.log(' Enter behavior:');
+
+ await test('block pattern after Enter splits and transforms', async () => {
+ await resetEditor();
+ await typeString('foo');
+ await typeChar(Key.ENTER);
+ await typeString('> bar');
+ await driver.sleep(50);
+ const html = await getHTML();
+ assert(html.includes('
after Enter did not create blockquote: ${html}`);
+ assert(html.includes('foo'), `Lost content before split: ${html}`);
+ assert(html.includes('bar'), `Lost content after split: ${html}`);
+ });
+
+ await test('single Enter in paragraph inserts line break', async () => {
+ await resetEditor();
+ await typeString('line one');
+ await typeChar(Key.ENTER);
+ await typeString('line two');
+ const markdown = await getMarkdown();
+ // Single Enter = one \n, not \n\n
+ assert(markdown.includes('line one'), `Missing line one: ${markdown}`);
+ assert(markdown.includes('line two'), `Missing line two: ${markdown}`);
+ assert(!markdown.includes('line one\n\nline two'), `Got paragraph break instead of line break: ${markdown}`);
+ });
+
+ await test('double Enter in paragraph creates new block', async () => {
+ await resetEditor();
+ await typeString('first paragraph');
+ await typeChar(Key.ENTER);
+ await typeChar(Key.ENTER);
+ await typeString('second paragraph');
+ const html = await getHTML();
+ // Double Enter = new
should be removed, cursor at end of "foo"
+ assert(!html.includes('
not removed: ${html}`);
+ assert(html.includes('foo'), `Content lost: ${html}`);
+ });
+
+ await test('single Enter in list item inserts line break', async () => {
+ await resetEditor();
+ await typeString('- line one');
+ await typeChar(Key.ENTER);
+ await typeString('line two');
+ const markdown = await getMarkdown();
+ // Both lines in the same list item
+ assert(markdown.includes('- line one'), `Missing list marker: ${markdown}`);
+ assert(markdown.includes('line two'), `Missing line two: ${markdown}`);
+ // Should NOT create a second list item
+ const markerCount = (markdown.match(/^- /gm) || []).length;
+ assert(markerCount === 1, `Expected 1 list marker, got ${markerCount}: ${markdown}`);
+ });
+
+ await test('double Enter in list item creates new item', async () => {
+ await resetEditor();
+ await typeString('- first');
+ await typeChar(Key.ENTER);
+ await typeChar(Key.ENTER);
+ await typeString('second');
+ const markdown = await getMarkdown();
+ const markerCount = (markdown.match(/^- /gm) || []).length;
+ assert(markerCount === 2, `Expected 2 list markers, got ${markerCount}: ${markdown}`);
+ assert(markdown.includes('- first'), `Missing first item: ${markdown}`);
+ assert(markdown.includes('- second'), `Missing second item: ${markdown}`);
+ });
+
+ await test('double Enter on empty list item exits list', async () => {
+ await resetEditor();
+ await typeString('- item');
+ await typeChar(Key.ENTER);
+ await typeChar(Key.ENTER);
+ await typeChar(Key.ENTER);
+ await typeChar(Key.ENTER);
+ await typeString('after list');
+ const html = await getHTML();
+ assert(html.includes('after list'), `Missing text after list: ${html}`);
+ // "after list" should NOT be inside the
+ const ulEnd = html.indexOf('
');
+ const afterPos = html.indexOf('after list');
+ assert(afterPos > ulEnd, `"after list" is inside the list: ${html}`);
+ });
+
// ── Complex document ──
console.log(' Complex document:');
@@ -422,6 +576,7 @@ async function runTests() {
await typeChar(Key.ENTER);
await typeString('Some **bold** text.');
await typeChar(Key.ENTER);
+ await typeChar(Key.ENTER);
await typeString('## Section');
await typeChar(Key.ENTER);
await typeString('- item one');
@@ -474,6 +629,32 @@ async function runTests() {
assert(html.includes(' {
+ await resetEditor();
+ await typeString('_hello_');
+ const html = await getHTML();
+ assert(html.includes(' after _hello_: ${html}`);
+ assert(!html.includes('data-speculative'), `Still speculative: ${html}`);
+ });
+
+ await test('_text shows speculative italic', async () => {
+ await resetEditor();
+ await typeString('_hel');
+ const html = await getHTML();
+ assert(html.includes(' after _hel: ${html}`);
+ assert(html.includes('data-speculative'), `Not speculative: ${html}`);
+ });
+
+ await test('__text__ transforms to bold', async () => {
+ await resetEditor();
+ await typeString('__hello__');
+ const html = await getHTML();
+ assert(html.includes(' after __hello__: ${html}`);
+ assert(!html.includes('data-speculative'), `Still speculative: ${html}`);
+ });
+
console.log(' Backslash escapes:');
await test('backslash is just a character in WYSIWYG', async () => {