WIP
This commit is contained in:
parent
d23c3e7a67
commit
fe405cb393
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,26 +586,76 @@ 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);
|
||||||
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) {
|
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, ' ');
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 ────────────────────────────────────────────────
|
// ── 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}`);
|
||||||
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user