This commit is contained in:
evilchili 2026-06-19 17:11:10 -07:00
parent d23c3e7a67
commit fe405cb393
3 changed files with 308 additions and 173 deletions

View File

@ -34,12 +34,26 @@
*/ */
.md-delim { .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 { .ribbit-editing {
display: inline; background: #EEE;
}
.ribbit-editing .md-delim {
opacity: 0.8; 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-weight: normal;
font-style: normal; font-style: normal;

View File

@ -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 = /\[(?<text>[^\]]+)\]\((?<href>[^)]+)\)/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: /\*\*\*(?<content>.+?)\*\*\*/g,
},
{
cls: 'md-bold',
delimiter: '**',
pattern: /\*\*(?<content>.+?)\*\*/g,
},
{
cls: 'md-italic',
delimiter: '*',
pattern: /\*(?<content>.+?)\*/g,
},
{
cls: 'md-strikethrough',
delimiter: '~~',
pattern: /~~(?<content>.+?)~~/g,
},
{
cls: 'md-code',
delimiter: '`',
pattern: /`(?<content>[^`]+)`/g,
},
];
// ─── RibbitEditor ───────────────────────────────────────────────────────────── // ─── RibbitEditor ─────────────────────────────────────────────────────────────
/** /**
@ -240,12 +191,33 @@ export class RibbitEditor extends Ribbit {
}, 300); }, 300);
}); });
/*
this.element.addEventListener('keydown', (event: KeyboardEvent) => { this.element.addEventListener('keydown', (event: KeyboardEvent) => {
if (this.state !== this.states.WYSIWYG) { if (this.state !== this.states.WYSIWYG) {
return; return;
} }
this.#dispatchKeydown(event); 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 <br>
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) => { this.element.addEventListener('keyup', (event: KeyboardEvent) => {
if (this.state !== this.states.WYSIWYG) { if (this.state !== this.states.WYSIWYG) {
@ -417,6 +389,38 @@ export class RibbitEditor extends Ribbit {
return block; 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 * Parse an inline markdown string into a DocumentFragment of text
* nodes and styled <span> elements. Each span wraps its delimiters * nodes and styled <span> elements. Each span wraps its delimiters
@ -426,104 +430,38 @@ export class RibbitEditor extends Ribbit {
* this.#parseInline('hello **world** and `code`') * this.#parseInline('hello **world** and `code`')
*/ */
#parseInline(text: string): DocumentFragment { #parseInline(text: string): DocumentFragment {
// Stage 1: tokenise into raw-text segments and matched parts. const classes: Record<string, string> = {
// We walk all rules left-to-right, splitting segments as we go. '***': 'md-bold-italic',
// Each segment is either raw (unmatched) or a matched inline rule. '**': 'md-bold',
interface RawSegment { raw: true; text: string } '*': 'md-italic',
interface RuleMatch { raw: false; rule: InlineRule; content: string; fullMatch: string } '~~': 'md-strikethrough',
interface LinkMatch { raw: false; isLink: true; text: string; href: string; fullMatch: string } '`': 'md-code',
type Segment = RawSegment | RuleMatch | LinkMatch; };
let segments: Segment[] = [{ raw: true, text }]; // 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(
'\\[(?<linkLabel>[^\\]]+)\\]\\((?<linkHref>[^)]+)\\)' +
'|' +
'(?<delimiter>\\*{1,3}|~~|`)(?<content>.+?)(?<closer>\\k<delimiter>|$)',
'g'
);
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 fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
for (const segment of withLinks) { let lastIndex = 0;
if (segment.raw) { let match: RegExpExecArray | null;
fragment.appendChild(document.createTextNode(segment.text));
continue; while ((match = INLINE_PATTERN.exec(text)) !== null) {
if (match.index > lastIndex) {
fragment.appendChild(document.createTextNode(text.slice(lastIndex, match.index)));
}
const className = match.groups!.linkLabel !== undefined ? 'md-link' : classes[match.groups!.delimiter];
fragment.appendChild(this.#createSpanForMatch(match, className));
lastIndex = match.index + match[0].length;
} }
const span = document.createElement('span'); if (lastIndex < text.length) {
span.setAttribute(INLINE_SPAN_ATTR, '1'); fragment.appendChild(document.createTextNode(text.slice(lastIndex)));
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);
} }
return fragment; return fragment;
@ -546,12 +484,13 @@ export class RibbitEditor extends Ribbit {
* *
* Complexity: O(block length) per keystroke, not O(document length). * Complexity: O(block length) per keystroke, not O(document length).
*/ */
#updateCurrentBlock(): void { #updateCurrentBlock(): void {
const block = this.#findCurrentBlock(); const block = this.#findCurrentBlock();
if (!block) return; if (!block) return;
const caretOffset = this.#getCaretOffset(block); 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); const newBlock = this.#buildBlock(lineText);
block.className = newBlock.className; block.className = newBlock.className;
@ -560,6 +499,17 @@ export class RibbitEditor extends Ribbit {
block.appendChild(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 // Place caret after any prefix span, never inside it
const prefixSpan = block.firstElementChild; const prefixSpan = block.firstElementChild;
const prefixLen = (prefixSpan?.classList.contains(DELIM_CLASS) || const prefixLen = (prefixSpan?.classList.contains(DELIM_CLASS) ||
@ -567,7 +517,6 @@ export class RibbitEditor extends Ribbit {
? prefixSpan.textContent!.length : 0; ? prefixSpan.textContent!.length : 0;
if (caretOffset <= prefixLen && prefixSpan) { if (caretOffset <= prefixLen && prefixSpan) {
const sel = window.getSelection()!; const sel = window.getSelection()!;
const range = document.createRange(); const range = document.createRange();
const next = prefixSpan.nextSibling; const next = prefixSpan.nextSibling;
@ -580,15 +529,10 @@ export class RibbitEditor extends Ribbit {
sel.removeAllRanges(); sel.removeAllRanges();
sel.addRange(range); sel.addRange(range);
} else { } 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.#restoreCaret(block, caretOffset);
} }
this.#updateEditingContext();
} }
// ── Keyboard handling ────────────────────────────────────────────────────── // ── Keyboard handling ──────────────────────────────────────────────────────
@ -642,27 +586,77 @@ export class RibbitEditor extends Ribbit {
*/ */
#handleEnter(): void { #handleEnter(): void {
const block = this.#findCurrentBlock(); 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(/^(?<num>\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; return;
} }
const offset = this.#getCaretOffset(block);
const text = block.textContent!.replace(/\u00A0/g, ' ');
const before = text.slice(0, offset); const before = text.slice(0, offset);
const after = text.slice(offset); const after = text.slice(offset);
const firstBlock = this.#buildBlock(before); const firstBlock = this.#buildBlock(before);
const secondBlock = this.#buildBlock(after || ''); const secondBlock = this.#buildBlock(after ? prefix + after : prefix);
block.className = firstBlock.className; block.className = firstBlock.className;
block.innerHTML = ''; block.innerHTML = '';
while (firstBlock.firstChild) { while (firstBlock.firstChild) block.appendChild(firstBlock.firstChild);
block.appendChild(firstBlock.firstChild);
}
block.after(secondBlock); block.after(secondBlock);
// 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); this.#restoreCaret(secondBlock, 0);
} }
}
/** /**
* Backspace at offset 0: merge the current block with the previous one. * Backspace at offset 0: merge the current block with the previous one.
@ -858,7 +852,7 @@ export class RibbitEditor extends Ribbit {
if (block.dataset.macro) { if (block.dataset.macro) {
return block.dataset.source || ''; return block.dataset.source || '';
} }
return block.textContent!.replace(/\u200B/g, ''); return block.textContent!.replace(/\u200B/g, '').replace(/\u00A0/g, ' ');
} }
} }

View File

@ -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('<span class="md-delim"># </span>') ||
html.includes('class="md-delim">#&nbsp;'),
`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 ──────────────────────────────────────────────── // ── getMarkdown round-trips ────────────────────────────────────────────────
console.log('\ngetMarkdown round-trips:'); console.log('\ngetMarkdown round-trips:');
@ -359,6 +393,65 @@ async function runTests() {
console.log('\nEnter key behaviour:'); 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 test('Enter splits current block into two blocks', async () => {
await resetEditor(); await resetEditor();
await typeString('hello'); await typeString('hello');
@ -418,6 +511,24 @@ async function runTests() {
console.log('\nBackspace key behaviour:'); 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 test('Backspace at start of block merges with previous block', async () => {
await resetEditor(); await resetEditor();
await typeString('foo'); await typeString('foo');
@ -531,7 +642,23 @@ async function runTests() {
assert(markdown === 'first\n\nsecond', `Expected "first\\n\\nsecond", got: "${markdown}"`); 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}`);
});
} }