diff --git a/src/ts/ribbit-editor.ts b/src/ts/ribbit-editor.ts index c326164..bf13d58 100644 --- a/src/ts/ribbit-editor.ts +++ b/src/ts/ribbit-editor.ts @@ -401,7 +401,13 @@ export class RibbitEditor extends Ribbit { prefixSpan.className = rule.isList ? LIST_PREFIX_CLASS : DELIM_CLASS; prefixSpan.textContent = line.slice(0, prefixLength); block.appendChild(prefixSpan); - block.appendChild(this.#parseInline(line.slice(prefixLength))); + + const content = line.slice(prefixLength); + if (content) { + block.appendChild(this.#parseInline(content)); + } else { + block.appendChild(document.createTextNode('\u200B')); + } return block; } @@ -542,27 +548,38 @@ export class RibbitEditor extends Ribbit { */ #updateCurrentBlock(): void { const block = this.#findCurrentBlock(); - if (!block) { - return; - } - - // Preserve the caret position across the rebuild + if (!block) return; + const caretOffset = this.#getCaretOffset(block); - - // Normalize   that browsers insert in contentEditable. - // Without this, NBSP characters in textContent break pattern - // matching for block classifiers like "## " or "> ". const lineText = block.textContent!.replace(/\u00A0/g, ' '); const newBlock = this.#buildBlock(lineText); block.className = newBlock.className; block.innerHTML = ''; - while (newBlock.firstChild) { - block.appendChild(newBlock.firstChild); - } + while (newBlock.firstChild) block.appendChild(newBlock.firstChild); + + // Place caret after any prefix span, never inside it + const prefixSpan = block.firstElementChild; + const prefixLen = (prefixSpan?.classList.contains(DELIM_CLASS) || + prefixSpan?.classList.contains(LIST_PREFIX_CLASS)) + ? prefixSpan.textContent!.length : 0; - this.#restoreCaret(block, caretOffset); - this.#updateEditingContext(); + if (caretOffset <= prefixLen && prefixSpan) { + + const sel = window.getSelection()!; + const range = document.createRange(); + const next = prefixSpan.nextSibling; + if (next && next.nodeType === 3) { + range.setStart(next as Text, 0); + } else { + range.setStartAfter(prefixSpan); + } + range.collapse(true); + sel.removeAllRanges(); + sel.addRange(range); + } else { + this.#restoreCaret(block, caretOffset); + } } // ── Keyboard handling ────────────────────────────────────────────────────── @@ -831,7 +848,8 @@ export class RibbitEditor extends Ribbit { if (block.dataset.macro) { return block.dataset.source || ''; } - return block.textContent || ''; + return block.textContent!.replace(/\u200B/g, ''); + } } diff --git a/test/integration/test_wysiwyg.js b/test/integration/test_wysiwyg.js index 0b8eca0..39a9d76 100644 --- a/test/integration/test_wysiwyg.js +++ b/test/integration/test_wysiwyg.js @@ -26,7 +26,6 @@ const PORT = (() => { const portArg = process.argv.find(arg => arg.startsWith('--port=')); return portArg ? parseInt(portArg.split('=')[1]) : 5023; })(); -const USE_DEV_SERVER = process.argv.includes('--port'); const DELAY = 20; // ms between keystrokes // ── State ───────────────────────────────────────────────────────────────────── @@ -37,6 +36,30 @@ const errors = []; // ── Setup / teardown ────────────────────────────────────────────────────────── +async function getCaretInfo() { + return page.evaluate(() => { + const sel = window.getSelection(); + if (!sel || !sel.rangeCount) return 'no selection'; + const range = sel.getRangeAt(0); + return { + container: range.startContainer.nodeType === 3 + ? `text:"${range.startContainer.textContent}"` + : `element:${range.startContainer.nodeName}.${range.startContainer.className}`, + offset: range.startOffset, + parentClass: range.startContainer.parentElement?.className, + }; + }); +} + +async function isPortInUse(port) { + return new Promise((resolve) => { + const net = require('net'); + const tester = net.createServer() + .once('error', () => resolve(true)) + .once('listening', () => tester.close(() => resolve(false))) + .listen(port, '127.0.0.1'); + }); +} async function serverStart() { var liveServer = require("live-server"); @@ -60,18 +83,18 @@ async function serverStart() { async function setup() { - if (!USE_DEV_SERVER) { + if (!await isPortInUse(PORT)) { await serverStart(); + await new Promise(resolve => setTimeout(resolve, 500)); } - browser = await chromium.launch({ headless: HEADLESS }); + browser = await chromium.launch({ headless: HEADLESS, channel: 'chromium' }); page = await browser.newPage(); await page.goto(`http://localhost:${PORT}`); await page.waitForFunction(() => window.__ribbitReady === true, { timeout: 10000 }); } async function teardown() { - //if (browser) { await browser.close(); } - //if (server) { await server.stop(); } + if (browser) { await browser.close(); } } // ── Editor helpers ──────────────────────────────────────────────────────────── @@ -83,8 +106,10 @@ async function teardown() { async function resetEditor() { await page.evaluate(() => { const editor = window.__ribbitEditor; - editor.wysiwyg(); + editor.view(); + editor.sourceMarkdown = ''; editor.element.innerHTML = ''; + editor.wysiwyg(); }); await page.focus('#ribbit'); await page.waitForTimeout(30); @@ -96,7 +121,11 @@ async function resetEditor() { */ async function typeString(text) { for (const character of text) { - await page.keyboard.type(character); + if (character == ' ') { + await page.keyboard.press('Space'); + } else { + await page.keyboard.insertText(character); + } await page.waitForTimeout(DELAY); } } @@ -161,14 +190,6 @@ async function runTests() { console.log('Block classification:'); - await test('plain text becomes md-paragraph', async () => { - await resetEditor(); - await typeString("hello\n\n"); - const classes = await getBlockClasses(); - assert(classes.some(c => c.includes('md-paragraph')), `Expected md-paragraph, got: ${classes}`); - }); - - /* await test('# space becomes md-h1', async () => { await resetEditor(); await typeString('#'); @@ -184,6 +205,13 @@ async function runTests() { assert(markdown.includes('# Title'), `Expected "# Title" in markdown: ${markdown}`); }); + await test('plain text becomes md-paragraph', async () => { + await resetEditor(); + await typeString("hello\n\n"); + const classes = await getBlockClasses(); + assert(classes.some(c => c.includes('md-paragraph')), `Expected md-paragraph, got: ${classes}`); + }); + await test('## space becomes md-h2', async () => { await resetEditor(); await typeString('## '); @@ -502,7 +530,9 @@ async function runTests() { const markdown = await getMarkdown(); assert(markdown === 'first\n\nsecond', `Expected "first\\n\\nsecond", got: "${markdown}"`); }); - */ + + //*/ + } // ── Main ────────────────────────────────────────────────────────────────────── @@ -524,6 +554,10 @@ async function runTests() { console.log(` ${message}`); }); } + if (!HEADLESS) { + console.log('\nPress Enter to close...'); + await new Promise(resolve => process.stdin.once('data', resolve)); + } await teardown(); process.exit(failed > 0 ? 1 : 0); }