Add integration tests
This commit is contained in:
@@ -30,6 +30,11 @@
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
[data-speculative]::before,
|
||||
[data-speculative]::after {
|
||||
content: none !important;
|
||||
}
|
||||
|
||||
#ribbit.wysiwyg strong.ribbit-editing::before,
|
||||
#ribbit.wysiwyg strong.ribbit-editing::after {
|
||||
content: "**";
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Replace this file with your own theme to customize the look.
|
||||
*/
|
||||
|
||||
@import "../ribbit-core.css";
|
||||
@import "../../ribbit-core.css";
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
|
||||
+581
-103
@@ -57,65 +57,598 @@ export class RibbitEditor extends Ribbit {
|
||||
|
||||
#bindEvents(): void {
|
||||
let debounceTimer: number | undefined;
|
||||
let lastThrottle = 0;
|
||||
|
||||
this.element.addEventListener('input', () => {
|
||||
if (this.state === this.states.VIEW) {
|
||||
if (this.state !== this.states.WYSIWYG) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.invalidateCache();
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastThrottle >= 150) {
|
||||
lastThrottle = now;
|
||||
this.refreshPreview();
|
||||
}
|
||||
this.ensureBlockStructure();
|
||||
this.transformCurrentBlock();
|
||||
this.updateEditingContext();
|
||||
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = window.setTimeout(() => {
|
||||
this.refreshPreview();
|
||||
this.notifyChange();
|
||||
}, 150);
|
||||
}, 300);
|
||||
});
|
||||
|
||||
this.element.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (this.state !== this.states.WYSIWYG) {
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
this.handleEnter(e);
|
||||
}
|
||||
});
|
||||
|
||||
this.element.addEventListener('keyup', (e: KeyboardEvent) => {
|
||||
if (this.state !== this.states.WYSIWYG) {
|
||||
return;
|
||||
}
|
||||
if (e.key.startsWith('Arrow')) {
|
||||
this.closeOrphanedSpeculative();
|
||||
this.updateEditingContext();
|
||||
}
|
||||
});
|
||||
|
||||
this.element.addEventListener('blur', () => {
|
||||
if (this.state !== this.states.WYSIWYG) {
|
||||
return;
|
||||
}
|
||||
this.closeOrphanedSpeculative();
|
||||
});
|
||||
|
||||
this.element.addEventListener('focusout', () => {
|
||||
if (this.state !== this.states.WYSIWYG) {
|
||||
return;
|
||||
}
|
||||
this.closeOrphanedSpeculative();
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e: MouseEvent) => {
|
||||
if (this.state !== this.states.WYSIWYG) {
|
||||
return;
|
||||
}
|
||||
if (!this.element.contains(e.target as Node)) {
|
||||
this.closeAllSpeculative();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('selectionchange', () => {
|
||||
if (this.state !== this.states.WYSIWYG) {
|
||||
return;
|
||||
}
|
||||
this.closeOrphanedSpeculative();
|
||||
this.updateEditingContext();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-render the WYSIWYG preview from the current content.
|
||||
* Applies speculative rendering for unclosed inline delimiters
|
||||
* at the cursor position, and uses toHtmlPreview for visible syntax.
|
||||
* Find the block-level element containing the cursor.
|
||||
*/
|
||||
refreshPreview(): void {
|
||||
if (this.state !== this.states.WYSIWYG) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cursorInfo = this.getCursorInfo();
|
||||
const text = this.element.textContent || '';
|
||||
const lines = text.split('\n');
|
||||
|
||||
// Speculatively close unclosed delimiters on the cursor line
|
||||
if (cursorInfo) {
|
||||
const inlineTags = this.converter.getInlineTags();
|
||||
const sorted = [...inlineTags].sort((a, b) =>
|
||||
((a as any).precedence ?? 50) - ((b as any).precedence ?? 50)
|
||||
);
|
||||
for (const tag of sorted) {
|
||||
if (tag.openPattern && tag.delimiter) {
|
||||
const before = lines[cursorInfo.lineIndex].slice(0, cursorInfo.offset);
|
||||
const escaped = tag.delimiter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const re = new RegExp(escaped, 'g');
|
||||
const count = (before.match(re) || []).length;
|
||||
if (count % 2 === 1) {
|
||||
lines[cursorInfo.lineIndex] = lines[cursorInfo.lineIndex] + tag.delimiter;
|
||||
break;
|
||||
/**
|
||||
* Ensure the editor contains valid block structure.
|
||||
* Wraps bare <br> and <div> elements in <p> tags.
|
||||
*/
|
||||
private ensureBlockStructure(): void {
|
||||
for (const child of Array.from(this.element.childNodes)) {
|
||||
if (child.nodeType === 1) {
|
||||
const element = child as HTMLElement;
|
||||
if (element.tagName === 'BR') {
|
||||
const p = document.createElement('p');
|
||||
p.innerHTML = '<br>';
|
||||
element.replaceWith(p);
|
||||
} else if (element.tagName === 'DIV') {
|
||||
const p = document.createElement('p');
|
||||
while (element.firstChild) {
|
||||
p.appendChild(element.firstChild);
|
||||
}
|
||||
if (!p.firstChild) {
|
||||
p.innerHTML = '<br>';
|
||||
}
|
||||
element.replaceWith(p);
|
||||
// Restore cursor inside the new <p>
|
||||
const sel = window.getSelection();
|
||||
if (sel && sel.rangeCount > 0) {
|
||||
const range = document.createRange();
|
||||
const target = p.lastChild || p;
|
||||
if (target.nodeType === 3) {
|
||||
range.setStart(target, target.textContent?.length || 0);
|
||||
} else {
|
||||
range.selectNodeContents(target);
|
||||
range.collapse(false);
|
||||
}
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!this.element.firstChild) {
|
||||
this.element.innerHTML = '<p><br></p>';
|
||||
}
|
||||
}
|
||||
|
||||
const html = this.converter.toHTML(lines.join('\n'));
|
||||
this.updatePreview(html, cursorInfo);
|
||||
private findCurrentBlock(): HTMLElement | null {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.rangeCount === 0) {
|
||||
return null;
|
||||
}
|
||||
let node: Node | null = sel.anchorNode;
|
||||
|
||||
// If cursor is in a text node directly inside the editor,
|
||||
// wrap it in a <p> first (browsers don't always do this).
|
||||
if (node && node.nodeType === 3 && node.parentNode === this.element) {
|
||||
const p = document.createElement('p');
|
||||
node.parentNode.insertBefore(p, node);
|
||||
p.appendChild(node);
|
||||
// Restore cursor inside the new <p>
|
||||
const range = document.createRange();
|
||||
range.setStart(node, sel.anchorOffset);
|
||||
range.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
return p;
|
||||
}
|
||||
|
||||
while (node && node !== this.element) {
|
||||
if (node.nodeType === 1) {
|
||||
const element = node as HTMLElement;
|
||||
if (element.tagName === 'LI' || element.parentNode === this.element) {
|
||||
return element;
|
||||
}
|
||||
}
|
||||
node = node.parentNode;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the current block's text for markdown patterns and
|
||||
* transform the DOM element in-place if a pattern matches.
|
||||
*/
|
||||
private transformCurrentBlock(): void {
|
||||
const block = this.findCurrentBlock();
|
||||
if (!block) {
|
||||
return;
|
||||
}
|
||||
const text = (block.textContent || '').replace(/\u00A0/g, ' ');
|
||||
|
||||
// Heading: # through ######
|
||||
const headingMatch = text.match(/^(#{1,6})\s/);
|
||||
if (headingMatch) {
|
||||
const level = headingMatch[1].length;
|
||||
const targetTag = 'H' + level;
|
||||
if (block.tagName !== targetTag) {
|
||||
this.replaceBlock(block, targetTag, headingMatch[0].length);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Blockquote: >
|
||||
if (text.startsWith('> ') && block.tagName !== 'BLOCKQUOTE') {
|
||||
this.replaceBlock(block, 'BLOCKQUOTE', 2);
|
||||
return;
|
||||
}
|
||||
|
||||
// Horizontal rule: --- or *** or ___
|
||||
if (/^(\*{3,}|-{3,}|_{3,})\s*$/.test(text)) {
|
||||
const hr = document.createElement('hr');
|
||||
const p = document.createElement('p');
|
||||
p.innerHTML = '<br>';
|
||||
block.replaceWith(hr, p);
|
||||
const range = document.createRange();
|
||||
range.setStart(p, 0);
|
||||
range.collapse(true);
|
||||
const sel = window.getSelection()!;
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
return;
|
||||
}
|
||||
|
||||
// Unordered list: - or *
|
||||
if (/^[-*]\s/.test(text) && block.tagName !== 'LI') {
|
||||
this.replaceBlockWithList(block, 'ul', text.indexOf(' ') + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ordered list: 1.
|
||||
if (/^\d+\.\s/.test(text) && block.tagName !== 'LI') {
|
||||
this.replaceBlockWithList(block, 'ol', text.indexOf(' ') + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fenced code: ```
|
||||
if (text.startsWith('```') && block.tagName !== 'PRE') {
|
||||
const pre = document.createElement('pre');
|
||||
const code = document.createElement('code');
|
||||
code.textContent = '';
|
||||
pre.appendChild(code);
|
||||
block.replaceWith(pre);
|
||||
const range = document.createRange();
|
||||
range.setStart(code, 0);
|
||||
range.collapse(true);
|
||||
const sel = window.getSelection()!;
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
return;
|
||||
}
|
||||
|
||||
// Inline transforms: flatten to markdown, transform, rebuild DOM
|
||||
this.transformInline(block);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a block's DOM children to a mixed string where completed
|
||||
* inline elements are preserved as HTML and only speculative/text
|
||||
* content is flattened to markdown. Completed elements are wrapped
|
||||
* in sentinel markers so the regex engine skips them.
|
||||
*/
|
||||
private blockToMarkdown(block: HTMLElement): string {
|
||||
let md = '';
|
||||
for (const child of Array.from(block.childNodes)) {
|
||||
md += this.nodeToMarkdown(child);
|
||||
}
|
||||
return md;
|
||||
}
|
||||
|
||||
private nodeToMarkdown(node: Node): string {
|
||||
if (node.nodeType === 3) {
|
||||
return (node.textContent || '').replace(/\u200B/g, '');
|
||||
}
|
||||
if (node.nodeType !== 1) { return ''; }
|
||||
const element = node as HTMLElement;
|
||||
const specDelim = element.getAttribute('data-speculative');
|
||||
|
||||
if (specDelim) {
|
||||
// Speculative: restore opener delimiter + flatten children
|
||||
let inner = '';
|
||||
for (const child of Array.from(element.childNodes)) {
|
||||
inner += this.nodeToMarkdown(child);
|
||||
}
|
||||
return specDelim + inner;
|
||||
}
|
||||
|
||||
const tag = this.findTagForElement(element);
|
||||
if (tag?.delimiter) {
|
||||
// Completed element: preserve as HTML, wrapped in sentinels
|
||||
// so the complete-pair regex won't match across it
|
||||
return '\x01' + element.outerHTML + '\x02';
|
||||
}
|
||||
|
||||
// Unknown element: flatten children
|
||||
let inner = '';
|
||||
for (const child of Array.from(element.childNodes)) {
|
||||
inner += this.nodeToMarkdown(child);
|
||||
}
|
||||
return inner;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the Tag definition that matches an HTML element.
|
||||
*/
|
||||
private findTagForElement(el: HTMLElement): { delimiter?: string; name: string } | null {
|
||||
const inlineTags = this.converter.getInlineTags();
|
||||
for (const tag of inlineTags) {
|
||||
if (!tag.delimiter) continue;
|
||||
if (typeof tag.selector === 'string') {
|
||||
const selectors = tag.selector.split(',');
|
||||
if (selectors.some(s => el.tagName === s.trim())) {
|
||||
return tag;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten the block to markdown, find and apply inline transforms,
|
||||
* then rebuild the DOM from the result.
|
||||
*/
|
||||
private transformInline(block: HTMLElement): void {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.rangeCount === 0) return;
|
||||
|
||||
let md = this.blockToMarkdown(block);
|
||||
if (md.replace(/\s/g, '').length < 2) return;
|
||||
|
||||
const inlineTags = this.converter.getInlineTags();
|
||||
const sorted = [...inlineTags]
|
||||
.filter(tag => tag.delimiter)
|
||||
.sort((a, b) => (a.precedence ?? 50) - (b.precedence ?? 50));
|
||||
|
||||
// Build regex for each tag with exact-delimiter matching.
|
||||
// [^\x01\x02] prevents matching across preserved HTML elements.
|
||||
const tagRegexes = sorted.map(tag => {
|
||||
const delim = tag.delimiter!;
|
||||
const escaped = delim.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const ec = delim[0].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return {
|
||||
tag,
|
||||
complete: new RegExp(`(?<!${ec})${escaped}(?!${ec})([^\\x01\\x02]+?)(?<!${ec})${escaped}`),
|
||||
open: new RegExp(`(?<!${ec})${escaped}(?!${ec})([^\\x01\\x02]+)$`),
|
||||
};
|
||||
});
|
||||
|
||||
// Apply complete pairs repeatedly until none match
|
||||
const forbiddenChildren: Record<string, string[]> = {
|
||||
'strong': ['strong', 'b'],
|
||||
'em': ['em', 'i'],
|
||||
'code': ['code', 'strong', 'b', 'em', 'i', 'a'],
|
||||
};
|
||||
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const { tag, complete } of tagRegexes) {
|
||||
const match = md.match(complete);
|
||||
if (match && match.index !== undefined) {
|
||||
const tagName = tag.name === 'boldItalic' ? 'em' : (tag.selector as string).split(',')[0].toLowerCase();
|
||||
|
||||
// Skip if wrapping would create forbidden nesting
|
||||
const banned = forbiddenChildren[tagName];
|
||||
if (banned && banned.some(t => match[1].includes('<' + t))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = tagName === 'code'
|
||||
? match[1].replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
: match[1];
|
||||
const inner = tag.name === 'boldItalic'
|
||||
? `\x01<${tagName}><strong>${content}</strong></${tagName}>\x02`
|
||||
: `\x01<${tagName}>${content}</${tagName}>\x02`;
|
||||
md = md.slice(0, match.index) + inner + md.slice(match.index + match[0].length);
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strip sentinel markers now that complete-pair matching is done
|
||||
md = md.replace(/[\x01\x02]/g, '');
|
||||
|
||||
// Check for one unclosed opener (speculative)
|
||||
let speculativeTag: typeof sorted[0] | null = null;
|
||||
let speculativeMatch: RegExpMatchArray | null = null;
|
||||
for (const { tag, open } of tagRegexes) {
|
||||
const match = md.match(open);
|
||||
if (match && match.index !== undefined) {
|
||||
// Make sure this isn't inside an HTML tag we just created
|
||||
const before = md.slice(0, match.index);
|
||||
if (!before.endsWith('<') && !before.endsWith('/')) {
|
||||
speculativeTag = tag;
|
||||
speculativeMatch = match;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild the DOM
|
||||
if (speculativeMatch && speculativeTag) {
|
||||
const tagName = speculativeTag.name === 'boldItalic' ? 'em' : (speculativeTag.selector as string).split(',')[0].toLowerCase();
|
||||
const inside = md.slice(speculativeMatch.index! + speculativeTag.delimiter!.length);
|
||||
|
||||
// Check for forbidden nesting before wrapping
|
||||
const probe = document.createElement('div');
|
||||
probe.innerHTML = inside;
|
||||
const banned = forbiddenChildren[tagName];
|
||||
const wouldNest = banned && banned.some(tag => probe.querySelector(tag));
|
||||
|
||||
if (!wouldNest) {
|
||||
const before = md.slice(0, speculativeMatch.index!);
|
||||
const wrapper = document.createElement(tagName);
|
||||
wrapper.classList.add('ribbit-editing');
|
||||
wrapper.setAttribute('data-speculative', speculativeTag.delimiter!);
|
||||
wrapper.innerHTML = inside;
|
||||
this.sanitizeNesting(wrapper);
|
||||
|
||||
block.innerHTML = '';
|
||||
if (before) {
|
||||
block.appendChild(document.createTextNode(before));
|
||||
}
|
||||
block.appendChild(wrapper);
|
||||
// ZWS after wrapper so arrow-right can escape the element
|
||||
block.appendChild(document.createTextNode('\u200B'));
|
||||
|
||||
// Cursor at end of speculative element
|
||||
this.placeCursorAtEnd(wrapper);
|
||||
} else {
|
||||
// Forbidden nesting — fall through to plain innerHTML
|
||||
block.innerHTML = md;
|
||||
this.sanitizeNesting(block);
|
||||
if (block.lastChild && block.lastChild.nodeType === 1) {
|
||||
block.appendChild(document.createTextNode('\u200B'));
|
||||
}
|
||||
this.placeCursorAtEnd(block);
|
||||
}
|
||||
} else {
|
||||
block.innerHTML = md;
|
||||
this.sanitizeNesting(block);
|
||||
// If the block ends with an HTML element, append a ZWS text
|
||||
// node so the cursor lands outside the element, not inside it.
|
||||
if (block.lastChild && block.lastChild.nodeType === 1) {
|
||||
block.appendChild(document.createTextNode('\u200B'));
|
||||
}
|
||||
this.placeCursorAtEnd(block);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Place the cursor at the end of an element's content.
|
||||
*/
|
||||
private placeCursorAtEnd(el: HTMLElement): void {
|
||||
const sel = window.getSelection();
|
||||
if (!sel) return;
|
||||
const range = document.createRange();
|
||||
// Find the deepest last text node
|
||||
let target: Node = el;
|
||||
while (target.lastChild) {
|
||||
target = target.lastChild;
|
||||
}
|
||||
if (target.nodeType === 3) {
|
||||
range.setStart(target, target.textContent?.length || 0);
|
||||
} else {
|
||||
range.selectNodeContents(target);
|
||||
range.collapse(false);
|
||||
}
|
||||
range.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
/**
|
||||
* Replace a block element with a new tag, stripping the prefix
|
||||
* and preserving cursor position.
|
||||
*/
|
||||
private replaceBlock(block: HTMLElement, newTag: string, prefixLength: number): void {
|
||||
const newEl = document.createElement(newTag);
|
||||
const content = (block.textContent || '').slice(prefixLength);
|
||||
if (content) {
|
||||
newEl.textContent = content;
|
||||
} else {
|
||||
newEl.innerHTML = '<br>';
|
||||
}
|
||||
block.replaceWith(newEl);
|
||||
newEl.classList.add('ribbit-editing');
|
||||
|
||||
// Place cursor at start of content
|
||||
const range = document.createRange();
|
||||
if (newEl.firstChild && newEl.firstChild.nodeType === 3) {
|
||||
range.setStart(newEl.firstChild, 0);
|
||||
} else {
|
||||
range.setStart(newEl, 0);
|
||||
}
|
||||
range.collapse(true);
|
||||
const sel = window.getSelection()!;
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a block element with a list (ul/ol) containing one item.
|
||||
*/
|
||||
private replaceBlockWithList(block: HTMLElement, listTag: string, prefixLength: number): void {
|
||||
const list = document.createElement(listTag);
|
||||
const li = document.createElement('li');
|
||||
const content = (block.textContent || '').slice(prefixLength);
|
||||
if (content) {
|
||||
li.textContent = content;
|
||||
} else {
|
||||
li.innerHTML = '<br>';
|
||||
}
|
||||
list.appendChild(li);
|
||||
block.replaceWith(list);
|
||||
|
||||
const range = document.createRange();
|
||||
if (li.firstChild && li.firstChild.nodeType === 3) {
|
||||
range.setStart(li.firstChild, 0);
|
||||
} else {
|
||||
range.setStart(li, 0);
|
||||
}
|
||||
range.collapse(true);
|
||||
const sel = window.getSelection()!;
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Enter key: strip syntax decorations from the current
|
||||
* block before the browser creates a new line.
|
||||
*/
|
||||
private handleEnter(e: KeyboardEvent): void {
|
||||
const prev = this.element.querySelector('.ribbit-editing');
|
||||
if (prev) {
|
||||
prev.classList.remove('ribbit-editing');
|
||||
prev.removeAttribute('data-speculative');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close any speculative elements that the cursor is no longer inside.
|
||||
* Called on every selection change — handles arrow keys, clicks,
|
||||
* tab switches, and any other cursor movement.
|
||||
*/
|
||||
/**
|
||||
* Unwrap a speculative element, replacing it with its children.
|
||||
* An orphaned speculative element was never completed — it should
|
||||
* not become permanent formatting.
|
||||
*/
|
||||
private unwrapSpeculative(element: HTMLElement): void {
|
||||
this.unwrapElement(element);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace an element with its children, preserving content.
|
||||
*/
|
||||
private unwrapElement(element: HTMLElement): void {
|
||||
const parent = element.parentNode;
|
||||
if (!parent) { return; }
|
||||
while (element.firstChild) {
|
||||
parent.insertBefore(element.firstChild, element);
|
||||
}
|
||||
parent.removeChild(element);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove forbidden nesting from a block element.
|
||||
* For example, <em> inside <em>, <strong> inside <code>, etc.
|
||||
*/
|
||||
private sanitizeNesting(block: HTMLElement): void {
|
||||
const rules: Record<string, string[]> = {
|
||||
'STRONG': ['STRONG', 'B'],
|
||||
'B': ['STRONG', 'B'],
|
||||
'EM': ['EM', 'I'],
|
||||
'I': ['EM', 'I'],
|
||||
'CODE': ['CODE', 'STRONG', 'B', 'EM', 'I', 'A'],
|
||||
};
|
||||
let found = true;
|
||||
while (found) {
|
||||
found = false;
|
||||
for (const [parent, forbidden] of Object.entries(rules)) {
|
||||
const parents = block.querySelectorAll(parent.toLowerCase());
|
||||
for (const parentEl of Array.from(parents)) {
|
||||
for (const tag of forbidden) {
|
||||
const nested = parentEl.querySelector(tag.toLowerCase());
|
||||
if (nested && nested !== parentEl) {
|
||||
this.unwrapElement(nested as HTMLElement);
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private closeAllSpeculative(): void {
|
||||
for (const element of Array.from(this.element.querySelectorAll('[data-speculative]'))) {
|
||||
this.unwrapSpeculative(element as HTMLElement);
|
||||
}
|
||||
}
|
||||
|
||||
private closeOrphanedSpeculative(): void {
|
||||
const speculative = this.element.querySelectorAll('[data-speculative]');
|
||||
if (speculative.length === 0) { return; }
|
||||
|
||||
const sel = window.getSelection();
|
||||
const anchor = sel?.anchorNode;
|
||||
|
||||
for (const el of Array.from(speculative)) {
|
||||
const htmlEl = el as HTMLElement;
|
||||
let inside = false;
|
||||
let node: Node | null = anchor || null;
|
||||
while (node) {
|
||||
if (node === htmlEl) {
|
||||
inside = true;
|
||||
break;
|
||||
}
|
||||
node = node.parentNode;
|
||||
}
|
||||
if (!inside) {
|
||||
this.unwrapSpeculative(htmlEl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,79 +677,20 @@ export class RibbitEditor extends Ribbit {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cursor's line index and offset within that line.
|
||||
*/
|
||||
private getCursorInfo(): { lineIndex: number; offset: number; absoluteOffset: number } | null {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.rangeCount === 0) {
|
||||
return null;
|
||||
}
|
||||
const range = sel.getRangeAt(0);
|
||||
const preRange = document.createRange();
|
||||
preRange.selectNodeContents(this.element);
|
||||
preRange.setEnd(range.startContainer, range.startOffset);
|
||||
const absoluteOffset = preRange.toString().length;
|
||||
|
||||
const text = this.element.textContent || '';
|
||||
const beforeCursor = text.slice(0, absoluteOffset);
|
||||
const lineIndex = beforeCursor.split('\n').length - 1;
|
||||
const lineStart = beforeCursor.lastIndexOf('\n') + 1;
|
||||
const offset = absoluteOffset - lineStart;
|
||||
|
||||
return { lineIndex, offset, absoluteOffset };
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the editor's HTML and restore the cursor to its
|
||||
* previous text offset position.
|
||||
*/
|
||||
private updatePreview(html: string, cursorInfo: { absoluteOffset: number } | null): void {
|
||||
this.element.innerHTML = html;
|
||||
|
||||
if (!cursorInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
const walker = document.createTreeWalker(this.element, NodeFilter.SHOW_TEXT);
|
||||
let remaining = cursorInfo.absoluteOffset;
|
||||
let node: Text | null;
|
||||
|
||||
while ((node = walker.nextNode() as Text | null)) {
|
||||
if (remaining <= node.length) {
|
||||
const sel = window.getSelection()!;
|
||||
const range = document.createRange();
|
||||
range.setStart(node, remaining);
|
||||
range.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
break;
|
||||
}
|
||||
remaining -= node.length;
|
||||
}
|
||||
|
||||
this.updateEditingContext();
|
||||
}
|
||||
|
||||
htmlToMarkdown(html?: string): string {
|
||||
return this.converter.toMarkdown(html || this.element.innerHTML);
|
||||
}
|
||||
|
||||
getMarkdown(): string {
|
||||
if (this.cachedMarkdown !== null) {
|
||||
return this.cachedMarkdown;
|
||||
}
|
||||
if (this.getState() === this.states.EDIT) {
|
||||
let html = this.element.innerHTML;
|
||||
html = html.replace(/<(?:div|br)>/ig, '');
|
||||
html = html.replace(/<\/div>/ig, '\n');
|
||||
this.cachedMarkdown = decodeHtmlEntities(html);
|
||||
return decodeHtmlEntities(html);
|
||||
} else if (this.getState() === this.states.WYSIWYG) {
|
||||
this.cachedMarkdown = this.htmlToMarkdown(this.element.innerHTML);
|
||||
} else {
|
||||
this.cachedMarkdown = this.element.textContent || '';
|
||||
return this.htmlToMarkdown(this.element.innerHTML);
|
||||
}
|
||||
return this.cachedMarkdown;
|
||||
return this.element.textContent || '';
|
||||
}
|
||||
|
||||
wysiwyg(): void {
|
||||
@@ -229,6 +703,10 @@ export class RibbitEditor extends Ribbit {
|
||||
}
|
||||
this.element.contentEditable = 'true';
|
||||
this.element.innerHTML = this.getHTML();
|
||||
// Ensure there's at least one block element for the cursor
|
||||
if (!this.element.firstElementChild) {
|
||||
this.element.innerHTML = '<p><br></p>';
|
||||
}
|
||||
Array.from(this.element.querySelectorAll('.macro')).forEach(el => {
|
||||
const macroEl = el as HTMLElement;
|
||||
if (macroEl.dataset.editable === 'false') {
|
||||
|
||||
+3
-3
@@ -249,14 +249,14 @@ export class Ribbit {
|
||||
const revision = await this.collaboration.getRevision(id);
|
||||
if (!revision) return;
|
||||
this.cachedMarkdown = revision.content;
|
||||
this.cachedHTML = null;
|
||||
this.cachedHTML = this.markdownToHTML(revision.content);
|
||||
this.collaboration.sendUpdate(revision.content);
|
||||
if (this.getState() !== this.states.VIEW) {
|
||||
this.element.innerHTML = this.getHTML();
|
||||
this.element.innerHTML = this.cachedHTML;
|
||||
}
|
||||
this.emitter.emit('change', {
|
||||
markdown: revision.content,
|
||||
html: this.getHTML(),
|
||||
html: this.cachedHTML,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+18
-4
@@ -21,9 +21,24 @@ import type { Tag, Converter, ListItem, ListResult, InlineTagDef } from './types
|
||||
*/
|
||||
export function inlineTag(def: InlineTagDef): Tag {
|
||||
const escaped = def.delimiter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const matchPattern = new RegExp('^' + escaped + '(.+?)' + escaped);
|
||||
const globalPattern = new RegExp(escaped + '(.+?)' + escaped, 'g');
|
||||
const openPattern = new RegExp(escaped + '(.+)$');
|
||||
|
||||
/*
|
||||
* CommonMark flanking delimiter rules:
|
||||
* - Opening delimiter must be preceded by start/whitespace/punctuation
|
||||
* and followed by a non-whitespace character.
|
||||
* - Closing delimiter must be preceded by a non-whitespace character
|
||||
* and followed by end/whitespace/punctuation.
|
||||
*
|
||||
* This prevents mid-word matches like "2*3*4" from being treated as
|
||||
* emphasis while still allowing "**bold** text" and "*italic*." to work.
|
||||
*/
|
||||
const punctuation = `[\\s.,;:!?'"()\\[\\]{}<>\\-/\\\\~#@&^|]`;
|
||||
const ec = def.delimiter[0].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const openFlank = `(?:^|(?<=${punctuation}))${escaped}(?!${ec})(?=\\S)`;
|
||||
const closeFlank = `(?<=\\S)(?<!${ec})${escaped}(?=$|${punctuation}|(?!${ec}))`;
|
||||
|
||||
const matchPattern = new RegExp(`^${escaped}(?!${ec})(?=\\S)(.+?)(?<=\\S)(?<!${ec})${escaped}`);
|
||||
const globalPattern = new RegExp(openFlank + '(.+?)' + closeFlank, 'g');
|
||||
const upperTag = def.htmlTag.toUpperCase();
|
||||
const selector = [upperTag, ...(def.aliases || '').split(',').filter(Boolean)].join(',');
|
||||
const recursive = def.recursive !== false;
|
||||
@@ -33,7 +48,6 @@ export function inlineTag(def: InlineTagDef): Tag {
|
||||
precedence: def.precedence ?? 50,
|
||||
recursive,
|
||||
pattern: globalPattern,
|
||||
openPattern,
|
||||
delimiter: def.delimiter,
|
||||
template: `${def.delimiter}text${def.delimiter}`,
|
||||
replaceSelection: true,
|
||||
|
||||
@@ -75,7 +75,10 @@ export class ThemeManager {
|
||||
}
|
||||
const previous = this.active;
|
||||
this.active = theme;
|
||||
this.loadCSS(name);
|
||||
// Only load CSS when switching themes, not on initial set
|
||||
if (previous !== theme) {
|
||||
this.loadCSS(name);
|
||||
}
|
||||
this.onSwitch(theme, previous);
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,6 @@ export interface Tag {
|
||||
toHTML: (token: SourceToken, convert: Converter) => string;
|
||||
selector: string | ((element: HTMLElement) => boolean);
|
||||
toMarkdown: (element: HTMLElement, convert: Converter) => string;
|
||||
openPattern?: RegExp;
|
||||
delimiter?: string;
|
||||
precedence?: number;
|
||||
recursive?: boolean;
|
||||
|
||||
Reference in New Issue
Block a user