From 24560a4d21a3ef1ef9a6210323a1d9f7757d36f6 Mon Sep 17 00:00:00 2001 From: gsb Date: Thu, 30 Apr 2026 07:25:30 +0000 Subject: [PATCH] bug fixes and wysiwyg behaviour improvements --- src/ts/ribbit-editor.ts | 314 ++++++++++++++++++- src/ts/ribbit.ts | 1 + src/ts/tags.ts | 515 ++++++++++++++++++++++++++++++- src/ts/tokenizer.ts | 6 +- src/ts/types.ts | 7 + test/hopdown.test.ts | 15 + test/integration/test_wysiwyg.js | 181 +++++++++++ 7 files changed, 1014 insertions(+), 25 deletions(-) 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
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, ): void { if (!opener) { - block.innerHTML = markdown; + block.innerHTML = markdown.replace(/\n/g, '
\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

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 = '
'; + } + 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

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 boolean> = {}; + + handleKeydown(element: HTMLElement, event: KeyboardEvent, selection: Selection, editor: any): boolean { + const handlerName = 'on' + event.key; + const handler = this.eventHandlers[handlerName]; + if (handler) { + return handler.call(this, element, selection, editor); + } + // Default Enter behavior: insert
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

after the current block. + */ + /** + * Default Backspace handler. When the cursor is at the start of + * a line after a
(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```')
  *   // 
let x = 1;
*/ -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('---') // '
' */ -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') * //

hello\nworld

*/ -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

inside the blockquote is a paragraph group. + // Within a

,
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

  • (line continuation) + * 2. Double Enter in non-empty
  • → new
  • sibling + * 3. Double Enter in empty
  • → exit list, create

    after it + */ + private onEnter(listElement: HTMLElement, selection: Selection, _editor: any): boolean { + // Find the

  • containing the cursor + let listItem: HTMLElement | null = null; + let node: Node | null = selection.anchorNode; + while (node && node !== listElement) { + if (node.nodeType === 1 && (node as HTMLElement).tagName === 'LI') { + listItem = node as HTMLElement; + break; + } + node = node.parentNode; + } + if (!listItem) { + return false; + } + + const zeroWidthSpace = /\u200B/g; + const itemText = (listItem.textContent || '').replace(zeroWidthSpace, '').trim(); + const range = selection.getRangeAt(0); + const container = range.startContainer; + const offset = range.startOffset; + + // Detect empty line after
    (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
  • — exit the list + listItem.remove(); + const newParagraph = document.createElement('p'); + newParagraph.innerHTML = '
    '; + 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
  • with empty line — create new
  • + // Remove the trailing
    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') // '

    hello world

    ' */ -class ParagraphTag implements Tag { +class ParagraphTag extends BaseTag implements Tag { name = 'paragraph'; selector = 'P'; diff --git a/src/ts/tokenizer.ts b/src/ts/tokenizer.ts index d14e798..bfcb41b 100644 --- a/src/ts/tokenizer.ts +++ b/src/ts/tokenizer.ts @@ -50,7 +50,7 @@ export interface DelimiterDef { * and followed by non-whitespace. Right-flanking is the reverse. */ const PUNCTUATION = new Set( - ' \t\n.,;:!?\'"()[]{}/<>\\-~#@&^|*`_'.split('') + ' \t\n\u00A0.,;:!?\'"()[]{}/<>\\-~#@&^|*`_'.split('') ); /** @@ -416,9 +416,9 @@ export class InlineTokenizer { const charAfter = source[position + delimiter.length]; const leftFlanking = (charBefore === undefined || PUNCTUATION.has(charBefore) || charBefore === '\n') - && charAfter !== undefined && charAfter !== ' ' && charAfter !== '\n' && charAfter !== '\t'; + && charAfter !== undefined && charAfter !== ' ' && charAfter !== '\n' && charAfter !== '\t' && charAfter !== '\u00A0'; - const rightFlanking = charBefore !== undefined && charBefore !== ' ' && charBefore !== '\n' && charBefore !== '\t' + const rightFlanking = charBefore !== undefined && charBefore !== ' ' && charBefore !== '\n' && charBefore !== '\t' && charBefore !== '\u00A0' && (charAfter === undefined || PUNCTUATION.has(charAfter) || charAfter === '\n'); if (leftFlanking) { diff --git a/src/ts/types.ts b/src/ts/types.ts index ec3ffc9..59257d2 100644 --- a/src/ts/types.ts +++ b/src/ts/types.ts @@ -70,6 +70,13 @@ export interface Tag { template?: string; replaceSelection?: boolean; button?: ToolbarButton; + /** Keyboard event handlers for WYSIWYG mode. Keys are event names + * like 'onEnter', 'onBackspace', 'onTab'. The handler receives + * the tag's element, the current selection, and the editor instance. */ + eventHandlers?: Record boolean>; + /** Dispatch a keydown event to the appropriate handler in + * eventHandlers. Provided by BaseTag; override for custom logic. */ + handleKeydown?: (element: HTMLElement, event: KeyboardEvent, selection: Selection, editor: any) => boolean; } /** diff --git a/test/hopdown.test.ts b/test/hopdown.test.ts index f092068..4a51c4a 100644 --- a/test/hopdown.test.ts +++ b/test/hopdown.test.ts @@ -534,3 +534,18 @@ describe('Backslash-escaped HTML tags', () => { expect(rehtml).toBe(rehtml2); }); }); + +describe('Table cell round-trip', () => { + it('inline formatting in cells survives round-trip', () => { + const html = '
    AB
    bolditalic
    '; + expect(H(M(html))).toBe(html); + }); + it('code in cells survives round-trip', () => { + const html = '
    A
    x
    '; + expect(H(M(html))).toBe(html); + }); + it('literal * in cells survives round-trip', () => { + const html = '
    A
    2 * 3
    '; + 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(' ": ${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

    , 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
    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 () => {