WIP
This commit is contained in:
parent
d23c3e7a67
commit
fe405cb393
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
@ -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 <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) => {
|
||||
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 <span> elements. Each span wraps its delimiters
|
||||
|
|
@ -426,104 +430,38 @@ 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;
|
||||
const classes: Record<string, string> = {
|
||||
'***': 'md-bold-italic',
|
||||
'**': 'md-bold',
|
||||
'*': 'md-italic',
|
||||
'~~': 'md-strikethrough',
|
||||
'`': 'md-code',
|
||||
};
|
||||
|
||||
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();
|
||||
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 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');
|
||||
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);
|
||||
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;
|
||||
|
|
@ -560,6 +499,17 @@ export class RibbitEditor extends Ribbit {
|
|||
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(/^(?<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;
|
||||
}
|
||||
|
||||
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, ' ');
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"># '),
|
||||
`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}`);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user