diff --git a/src/static/ribbit-core.css b/src/static/ribbit-core.css index 8962874..c7fe705 100644 --- a/src/static/ribbit-core.css +++ b/src/static/ribbit-core.css @@ -34,12 +34,26 @@ */ .md-delim { - display: none; + display:inline; + opacity: 0.3; + font-size: 0.85em; + font-weight: normal; + font-style: normal; + font-family: monospace; } -#ribbit.wysiwyg .ribbit-editing > .md-delim { - display: inline; +.ribbit-editing { + background: #EEE; +} + +.ribbit-editing .md-delim { opacity: 0.8; +} + +#ribbit.wysiwyg [class^="md-h"] > .md-delim, +#ribbit.wysiwyg .md-blockquote > .md-delim, +#ribbit.wysiwyg .md-list-prefix { + display: inline; /* font-weight: normal; font-style: normal; diff --git a/src/ts/ribbit-editor.ts b/src/ts/ribbit-editor.ts index 1ba0dfe..9112e1c 100644 --- a/src/ts/ribbit-editor.ts +++ b/src/ts/ribbit-editor.ts @@ -117,55 +117,6 @@ const BLOCK_RULES: BlockRule[] = [ }, ]; -// ─── Inline rules ───────────────────────────────────────────────────────────── - -// An inline rule describes how to detect and wrap a delimiter pair -// in a single line of text. Rules are applied left-to-right. -interface InlineRule { - // The CSS class applied to the wrapper span (e.g. 'md-bold'). - cls: string; - // The delimiter string on both sides (e.g. '**'). - delimiter: string; - // The regex to match a complete delimited run. Must have a - // named capture group 'content' for the text between delimiters. - pattern: RegExp; -} - -// Link is special: it has two delimiters and an href, so it gets -// its own handling in parseInline rather than going through InlineRule. -const LINK_PATTERN = /\[(?[^\]]+)\]\((?[^)]+)\)/g; - -// Inline rules in priority order (longer/higher-precedence delimiters first -// so *** is tried before ** which is tried before *). Derived from the -// existing tag definitions in defaultInlineTags wherever possible [C10]. -const INLINE_RULES: InlineRule[] = [ - { - cls: 'md-bold-italic', - delimiter: '***', - pattern: /\*\*\*(?.+?)\*\*\*/g, - }, - { - cls: 'md-bold', - delimiter: '**', - pattern: /\*\*(?.+?)\*\*/g, - }, - { - cls: 'md-italic', - delimiter: '*', - pattern: /\*(?.+?)\*/g, - }, - { - cls: 'md-strikethrough', - delimiter: '~~', - pattern: /~~(?.+?)~~/g, - }, - { - cls: 'md-code', - delimiter: '`', - pattern: /`(?[^`]+)`/g, - }, -]; - // ─── RibbitEditor ───────────────────────────────────────────────────────────── /** @@ -240,12 +191,33 @@ export class RibbitEditor extends Ribbit { }, 300); }); + /* this.element.addEventListener('keydown', (event: KeyboardEvent) => { if (this.state !== this.states.WYSIWYG) { return; } this.#dispatchKeydown(event); }); + */ + + this.element.addEventListener('keydown', (event: KeyboardEvent) => { + if (this.state !== this.states.WYSIWYG) return; + + if (event.key === 'Backspace') { + // Check if editor is already empty or about to become a bare
+ const children = Array.from(this.element.children); + const onlyChild = children.length === 1 ? children[0] as HTMLElement : null; + const isEmpty = onlyChild && + !onlyChild.textContent!.replace(/\u200B/g, '').trim() && + onlyChild.querySelector('br'); + if (isEmpty) { + event.preventDefault(); + return; + } + } + + this.#dispatchKeydown(event); + }); this.element.addEventListener('keyup', (event: KeyboardEvent) => { if (this.state !== this.states.WYSIWYG) { @@ -417,6 +389,38 @@ export class RibbitEditor extends Ribbit { return block; } + #createSpanForMatch(match: RegExpExecArray, className: string): Node { + const span = document.createElement('span'); + span.className = className; + span.setAttribute(INLINE_SPAN_ATTR, '1'); + + if (span.className == 'md-link') { + // Link: [label](href) + span.appendChild(this.#makeDelimSpan('[')); + const linkTextNode = document.createElement('span'); + linkTextNode.className = 'md-link-text'; + linkTextNode.textContent = match.groups!.linkLabel; + span.appendChild(linkTextNode); + span.appendChild(this.#makeDelimSpan(`](${match.groups!.linkHref})`)); + return span; + } + + // Delimiter run: **content**, *content*, `content`, ~~content~~ + span.appendChild(this.#makeDelimSpan(match.groups!.delimiter)); + + // Recurse: content may itself contain nested delimiters + // (e.g. "*italic*" inside a "**bold ... **" span). Each + // recursive call only ever sees the substring between this + // span's own delimiters, so nesting depth is naturally + // bounded by the original text's structure. + span.appendChild(this.#parseInline(match.groups!.content)); + if (match.groups!.closer) { + span.appendChild(this.#makeDelimSpan(match.groups!.closer)); + } + + return span; + } + /** * Parse an inline markdown string into a DocumentFragment of text * nodes and styled elements. Each span wraps its delimiters @@ -426,106 +430,40 @@ export class RibbitEditor extends Ribbit { * this.#parseInline('hello **world** and `code`') */ #parseInline(text: string): DocumentFragment { - // Stage 1: tokenise into raw-text segments and matched parts. - // We walk all rules left-to-right, splitting segments as we go. - // Each segment is either raw (unmatched) or a matched inline rule. - interface RawSegment { raw: true; text: string } - interface RuleMatch { raw: false; rule: InlineRule; content: string; fullMatch: string } - interface LinkMatch { raw: false; isLink: true; text: string; href: string; fullMatch: string } - type Segment = RawSegment | RuleMatch | LinkMatch; - - let segments: Segment[] = [{ raw: true, text }]; - - for (const rule of INLINE_RULES) { - const nextSegments: Segment[] = []; - for (const segment of segments) { - if (!segment.raw) { - nextSegments.push(segment); - continue; - } - let lastIndex = 0; - let match: RegExpExecArray | null; - rule.pattern.lastIndex = 0; - while ((match = rule.pattern.exec(segment.text)) !== null) { - if (match.index > lastIndex) { - nextSegments.push({ raw: true, text: segment.text.slice(lastIndex, match.index) }); - } - nextSegments.push({ - raw: false, - rule, - content: match.groups!.content, - fullMatch: match[0], - }); - lastIndex = match.index + match[0].length; - } - if (lastIndex < segment.text.length) { - nextSegments.push({ raw: true, text: segment.text.slice(lastIndex) }); - } - } - segments = nextSegments; - } - - // Handle links in a second pass over raw segments only [C14] - const withLinks: Segment[] = []; - for (const segment of segments) { - if (!segment.raw) { - withLinks.push(segment); - continue; - } - let lastIndex = 0; - let match: RegExpExecArray | null; - LINK_PATTERN.lastIndex = 0; - while ((match = LINK_PATTERN.exec(segment.text)) !== null) { - if (match.index > lastIndex) { - withLinks.push({ raw: true, text: segment.text.slice(lastIndex, match.index) }); - } - withLinks.push({ - raw: false, - isLink: true, - text: match.groups!.text, - href: match.groups!.href, - fullMatch: match[0], - }); - lastIndex = match.index + match[0].length; - } - if (lastIndex < segment.text.length) { - withLinks.push({ raw: true, text: segment.text.slice(lastIndex) }); - } - } - - // Stage 2: build DOM nodes from the token list + const classes: Record = { + '***': 'md-bold-italic', + '**': 'md-bold', + '*': 'md-italic', + '~~': 'md-strikethrough', + '`': 'md-code', + }; + + // Combined pattern: try a link first, then a delimiter run. + // Named groups are mutually exclusive per match (only one branch fires). + const INLINE_PATTERN = new RegExp( + '\\[(?[^\\]]+)\\]\\((?[^)]+)\\)' + + '|' + + '(?\\*{1,3}|~~|`)(?.+?)(?\\k|$)', + 'g' + ); + const fragment = document.createDocumentFragment(); - for (const segment of withLinks) { - if (segment.raw) { - fragment.appendChild(document.createTextNode(segment.text)); - continue; + let lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = INLINE_PATTERN.exec(text)) !== null) { + if (match.index > lastIndex) { + fragment.appendChild(document.createTextNode(text.slice(lastIndex, match.index))); } - - const span = document.createElement('span'); - span.setAttribute(INLINE_SPAN_ATTR, '1'); - - if ('isLink' in segment) { - // Link: [text](href) - // All three parts go into .md-delim spans so textContent - // reproduces the full markdown [text](href) syntax - span.className = 'md-link'; - span.appendChild(this.#makeDelimSpan('[')); - const linkTextNode = document.createElement('span'); - linkTextNode.className = 'md-link-text'; - linkTextNode.textContent = segment.text; - span.appendChild(linkTextNode); - span.appendChild(this.#makeDelimSpan(`](${segment.href})`)); - } else { - // Standard delimiter pair: **content** - span.className = segment.rule.cls; - span.appendChild(this.#makeDelimSpan(segment.rule.delimiter)); - span.appendChild(document.createTextNode(segment.content)); - span.appendChild(this.#makeDelimSpan(segment.rule.delimiter)); - } - - fragment.appendChild(span); + const className = match.groups!.linkLabel !== undefined ? 'md-link' : classes[match.groups!.delimiter]; + fragment.appendChild(this.#createSpanForMatch(match, className)); + lastIndex = match.index + match[0].length; } - + + if (lastIndex < text.length) { + fragment.appendChild(document.createTextNode(text.slice(lastIndex))); + } + return fragment; } @@ -546,12 +484,13 @@ export class RibbitEditor extends Ribbit { * * Complexity: O(block length) per keystroke, not O(document length). */ + #updateCurrentBlock(): void { const block = this.#findCurrentBlock(); if (!block) return; const caretOffset = this.#getCaretOffset(block); - const lineText = block.textContent!.replace(/\u00A0/g, ' '); + const lineText = block.textContent!.replace(/\u00A0/g, ' ').replace(/\u200B/g, ''); const newBlock = this.#buildBlock(lineText); block.className = newBlock.className; @@ -559,7 +498,18 @@ export class RibbitEditor extends Ribbit { while (newBlock.firstChild) { block.appendChild(newBlock.firstChild); } - + + // Chromium drops the caret-positioning effect of a trailing plain + // space in some cases; swapping it for a non-breaking space on the + // live text node (never via innerHTML) keeps the caret sticky + // without touching how lineText is computed on the next keystroke. + if (lineText.endsWith(' ')) { + const lastChild = block.lastChild; + if (lastChild) { + lastChild.textContent = lastChild.textContent!.replace(/ $/, '\u00A0'); + } + } + // Place caret after any prefix span, never inside it const prefixSpan = block.firstElementChild; const prefixLen = (prefixSpan?.classList.contains(DELIM_CLASS) || @@ -567,7 +517,6 @@ export class RibbitEditor extends Ribbit { ? prefixSpan.textContent!.length : 0; if (caretOffset <= prefixLen && prefixSpan) { - const sel = window.getSelection()!; const range = document.createRange(); const next = prefixSpan.nextSibling; @@ -580,15 +529,10 @@ export class RibbitEditor extends Ribbit { sel.removeAllRanges(); sel.addRange(range); } else { - - // switch spaces back to non-breaking spaces to avoid a chromium bug where a trailing space is ignored when - // positioning the caret. We only do this if the last character is a space; if we do it unconditionally it - // breaks detection of header nodes etc. - if (lineText.endsWith(' ')) { - block.innerHTML = block.innerHTML.replace(/\s/g, '\u00A0'); - } this.#restoreCaret(block, caretOffset); } + + this.#updateEditingContext(); } // ── Keyboard handling ────────────────────────────────────────────────────── @@ -642,26 +586,76 @@ export class RibbitEditor extends Ribbit { */ #handleEnter(): void { const block = this.#findCurrentBlock(); - if (!block) { + if (!block) return; + + const offset = this.#getCaretOffset(block); + const text = block.textContent!.replace(/\u00A0/g, ' ').replace(/\u200B/g, ''); + + // Detect prefix + const prefixSpan = block.firstElementChild; + const isListPrefix = prefixSpan?.classList.contains(LIST_PREFIX_CLASS); + const isBlockquote = prefixSpan?.classList.contains(DELIM_CLASS) && + block.classList.contains('md-blockquote'); + + // Determine what prefix the next line should carry + let prefix = ''; + if (isListPrefix) { + const orderedMatch = prefixSpan!.textContent!.match(/^(?\d+)\. /); + if (orderedMatch) { + const nextNum = parseInt(orderedMatch.groups!.num) + 1; + prefix = `${nextNum}. `; + } else { + prefix = prefixSpan!.textContent!; + } + } else if (isBlockquote) { + prefix = prefixSpan!.textContent!; + } + + //console.log('handleEnter text:', JSON.stringify(text), 'prefix:', JSON.stringify(prefix)); + + // Current prefix (before incrementing) + const currentPrefix = prefixSpan?.textContent?.replace(/\u200B/g, '') ?? ''; + + // Double Enter on empty prefixed line exits + if (prefix && (text === currentPrefix || text.trim() === currentPrefix.trim())) { + const emptyBlock = this.#buildBlock(''); + block.className = emptyBlock.className; + block.innerHTML = ''; + while (emptyBlock.firstChild) block.appendChild(emptyBlock.firstChild); + this.#restoreCaret(block, 0); return; } - - const offset = this.#getCaretOffset(block); - const text = block.textContent!.replace(/\u00A0/g, ' '); + const before = text.slice(0, offset); const after = text.slice(offset); - + const firstBlock = this.#buildBlock(before); - const secondBlock = this.#buildBlock(after || ''); - + const secondBlock = this.#buildBlock(after ? prefix + after : prefix); + block.className = firstBlock.className; block.innerHTML = ''; - while (firstBlock.firstChild) { - block.appendChild(firstBlock.firstChild); - } - + while (firstBlock.firstChild) block.appendChild(firstBlock.firstChild); + block.after(secondBlock); - this.#restoreCaret(secondBlock, 0); + + // Place caret after prefix in new block + const newPrefixSpan = secondBlock.firstElementChild; + if (newPrefixSpan && (newPrefixSpan.classList.contains(DELIM_CLASS) || + newPrefixSpan.classList.contains(LIST_PREFIX_CLASS))) { + const sel = window.getSelection()!; + const range = document.createRange(); + const next = newPrefixSpan.nextSibling; + if (next && next.nodeType === 3) { + range.setStart(next as Text, 0); + } else { + range.setStartAfter(newPrefixSpan); + } + range.collapse(true); + sel.removeAllRanges(); + sel.addRange(range); + } else { + this.#restoreCaret(secondBlock, 0); + } } /** @@ -858,7 +852,7 @@ export class RibbitEditor extends Ribbit { if (block.dataset.macro) { return block.dataset.source || ''; } - return block.textContent!.replace(/\u200B/g, ''); + return block.textContent!.replace(/\u200B/g, '').replace(/\u00A0/g, ' '); } } diff --git a/test/integration/test_wysiwyg.js b/test/integration/test_wysiwyg.js index 39a9d76..f0349b0 100644 --- a/test/integration/test_wysiwyg.js +++ b/test/integration/test_wysiwyg.js @@ -323,6 +323,40 @@ async function runTests() { ); }); + await test('bold followed by trailing space stays bold', async () => { + await resetEditor(); + await typeString('**bold** '); + const html = await getHTML(); + assert(html.includes('md-bold'), `Expected md-bold span to survive trailing space: ${html}`); + const markdown = await getMarkdown(); + assert( + markdown === '**bold** ', + `Expected "**bold** ", got: "${markdown}"` + ); + }); + + await test('heading prefix stays plain space, not nbsp, after rebuild', async () => { + await resetEditor(); + await typeString('# Title'); + const html = await getHTML(); + // The delimiter span's text must be a literal space (U+0020), + // not a non-breaking space, or block classification breaks on + // the next keystroke read of textContent. + assert( + html.includes('# ') || + html.includes('class="md-delim"># '), + `Unexpected delim content: ${html}` + ); + // The real check: typing more text after this should still + // classify as md-h1, not regress to md-paragraph. + await typeString(' more'); + const classes = await getBlockClasses(); + assert( + classes.some(c => c.includes('md-h1')), + `Lost md-h1 classification after additional typing: ${JSON.stringify(classes)}` + ); + }); + // ── getMarkdown round-trips ──────────────────────────────────────────────── console.log('\ngetMarkdown round-trips:'); @@ -359,6 +393,65 @@ async function runTests() { console.log('\nEnter key behaviour:'); + await test('double Enter exits list', async () => { + await resetEditor(); + await typeString('- item'); + await pressKey('Enter'); + await pressKey('Enter'); + const blocks = await getBlockClasses(); + assert( + blocks.some(c => c.includes('md-paragraph')), + `Expected paragraph after double Enter, got: ${JSON.stringify(blocks)}` + ); + }); + + await test('double Enter exits blockquote', async () => { + await resetEditor(); + await typeString('> quote'); + await pressKey('Enter'); + await pressKey('Enter'); + const blocks = await getBlockClasses(); + assert( + blocks.some(c => c.includes('md-paragraph')), + `Expected paragraph after double Enter, got: ${JSON.stringify(blocks)}` + ); + }); + + await test('ordered list increments', async () => { + await resetEditor(); + await typeString('1. first'); + await pressKey('Enter'); + const markdown = await getMarkdown(); + assert( + markdown.includes('2. '), + `Expected "2. " on second line, got: "${markdown}"` + ); + }); + + await test('heading followed by Enter creates paragraph', async () => { + await resetEditor(); + await typeString('# Title'); + await pressKey('Enter'); + await typeString('body'); + const blocks = await getBlockClasses(); + assert(blocks.some(c => c.includes('md-h1')), `No h1: ${blocks}`); + assert(blocks.some(c => c.includes('md-paragraph')), `No paragraph: ${blocks}`); + const markdown = await getMarkdown(); + assert(markdown === '# Title\nbody', `Expected "# Title\\nbody", got: "${markdown}"`); + }); + + await test('heading renders correctly after typing', async () => { + await resetEditor(); + await typeString('# foo'); + const blocks = await getBlockClasses(); + assert( + blocks.some(c => c.includes('md-h1')), + `Expected md-h1, got: ${JSON.stringify(blocks)}` + ); + const markdown = await getMarkdown(); + assert(markdown === '# foo', `Expected "# foo", got: "${markdown}"`); + }); + await test('Enter splits current block into two blocks', async () => { await resetEditor(); await typeString('hello'); @@ -418,6 +511,24 @@ async function runTests() { console.log('\nBackspace key behaviour:'); + await test('backspace on last empty block does not break editor', async () => { + await resetEditor(); + await typeString('foo'); + // Select all and delete + await page.keyboard.press('Control+a'); + await page.keyboard.press('Backspace'); + await page.keyboard.press('Backspace'); + // Editor should still be functional + await typeString('# bar'); + const blocks = await getBlockClasses(); + assert( + blocks.some(c => c.includes('md-h1')), + `Editor broken after double backspace, got: ${JSON.stringify(blocks)}` + ); + const markdown = await getMarkdown(); + assert(markdown === '# bar', `Expected "# bar", got: "${markdown}"`); + }); + await test('Backspace at start of block merges with previous block', async () => { await resetEditor(); await typeString('foo'); @@ -531,7 +642,23 @@ async function runTests() { assert(markdown === 'first\n\nsecond', `Expected "first\\n\\nsecond", got: "${markdown}"`); }); - //*/ + + await test('# space becomes md-h1', async () => { + await resetEditor(); + await typeString('#'); + let classes = await getBlockClasses(); + assert(!classes.some(c => c.includes('md-h')), `Premature heading after just #: ${classes}`); + + await typeString(' '); + const html = await page.evaluate(() => document.getElementById('ribbit').innerHTML); + + classes = await getBlockClasses(); + assert(classes.some(c => c.includes('md-h1')), `Expected md-h1 after "# ", got: ${classes}`); + + await typeString('Title'); + const markdown = await getMarkdown(); + assert(markdown.includes('# Title'), `Expected "# Title" in markdown: ${markdown}`); + }); }