Compare commits
2 Commits
5b2bd94388
...
bfc20f56bf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfc20f56bf | ||
|
|
24560a4d21 |
|
|
@ -14,6 +14,7 @@
|
|||
"build:core": "esbuild src/ts/ribbit-core.ts --bundle --format=iife --global-name=ribbit --sourcemap --outfile=dist/ribbit/ribbit-core.js",
|
||||
"build:core-min": "esbuild src/ts/ribbit-core.ts --bundle --format=iife --global-name=ribbit --minify --outfile=dist/ribbit/ribbit-core.min.js",
|
||||
"build:css": "cp src/static/ribbit-core.css dist/ribbit/ && cp -r src/static/themes dist/ribbit/",
|
||||
"dev": "npm run build && node test/integration/dev-server.js",
|
||||
"test": "npm run build && jest --verbose",
|
||||
"test:integration": "npm run build && node test/integration/test.js && node test/integration/test_wysiwyg.js",
|
||||
"test:coverage": "npm run build && jest --coverage"
|
||||
|
|
|
|||
|
|
@ -90,9 +90,7 @@ export class RibbitEditor extends Ribbit {
|
|||
if (this.state !== this.states.WYSIWYG) {
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
this.handleEnter(event);
|
||||
}
|
||||
this.dispatchKeydown(event);
|
||||
});
|
||||
|
||||
this.element.addEventListener('keyup', (event: KeyboardEvent) => {
|
||||
|
|
@ -212,7 +210,9 @@ export class RibbitEditor extends Ribbit {
|
|||
while (node && node !== this.element) {
|
||||
if (node.nodeType === 1) {
|
||||
const element = node as HTMLElement;
|
||||
if (element.tagName === 'LI' || element.parentNode === this.element) {
|
||||
if (element.tagName === 'LI'
|
||||
|| (element.tagName === 'P' && element.parentElement?.tagName === 'BLOCKQUOTE')
|
||||
|| element.parentNode === this.element) {
|
||||
return element;
|
||||
}
|
||||
}
|
||||
|
|
@ -235,6 +235,17 @@ export class RibbitEditor extends Ribbit {
|
|||
// Normalize → space so patterns like "- " and "> " match
|
||||
const text = (block.textContent || '').replace(/\u00A0/g, ' ');
|
||||
|
||||
// If the block contains <br> elements, check the current line
|
||||
// (text after the last <br> before the cursor). Block-level
|
||||
// patterns at the start of a line after <br> should split the
|
||||
// block and transform the new portion.
|
||||
const currentLineText = this.getCurrentLineText(block);
|
||||
if (currentLineText !== null && currentLineText !== text) {
|
||||
if (this.tryBlockTransformOnCurrentLine(block, currentLineText)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const headingMatch = text.match(/^(#{1,6})\s/);
|
||||
if (headingMatch) {
|
||||
const level = headingMatch[1].length;
|
||||
|
|
@ -315,6 +326,12 @@ export class RibbitEditor extends Ribbit {
|
|||
return '';
|
||||
}
|
||||
const element = node as HTMLElement;
|
||||
|
||||
// Preserve <br> as a newline so blockquote line breaks survive
|
||||
if (element.tagName === 'BR') {
|
||||
return '\n';
|
||||
}
|
||||
|
||||
const specDelim = element.getAttribute('data-speculative');
|
||||
|
||||
if (specDelim) {
|
||||
|
|
@ -358,6 +375,167 @@ export class RibbitEditor extends Ribbit {
|
|||
* Sentinel markers (\x01...\x02) prevent the regex from matching
|
||||
* delimiters that belong to already-transformed elements.
|
||||
*/
|
||||
/**
|
||||
* Get the text of the current line within a block — the text
|
||||
* after the last <br> before the cursor. Returns null if there
|
||||
* are no <br> elements (single-line block).
|
||||
*/
|
||||
private getCurrentLineText(block: HTMLElement): string | null {
|
||||
const hasBr = block.querySelector('br');
|
||||
if (!hasBr) {
|
||||
return null;
|
||||
}
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
return null;
|
||||
}
|
||||
// Collect text from the cursor's text node backward to the
|
||||
// nearest <br> or start of block
|
||||
let node: Node | null = selection.anchorNode;
|
||||
if (!node || !block.contains(node)) {
|
||||
return null;
|
||||
}
|
||||
// Get text from cursor position to start of current text node
|
||||
let lineText = '';
|
||||
if (node.nodeType === 3) {
|
||||
lineText = (node.textContent || '').slice(0, selection.anchorOffset);
|
||||
}
|
||||
// Walk backward through siblings collecting text until we hit a <br>
|
||||
let sibling: Node | null = node.nodeType === 3
|
||||
? node.previousSibling
|
||||
: null;
|
||||
while (sibling) {
|
||||
if (sibling.nodeType === 1 && (sibling as HTMLElement).tagName === 'BR') {
|
||||
break;
|
||||
}
|
||||
lineText = (sibling.textContent || '') + lineText;
|
||||
sibling = sibling.previousSibling;
|
||||
}
|
||||
// Only return if we actually found a <br> (meaning this is a
|
||||
// subsequent line, not the first line of the block)
|
||||
if (!sibling) {
|
||||
return null;
|
||||
}
|
||||
return lineText.replace(/\u00A0/g, ' ').replace(/\u200B/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a block-level pattern matches on the current line text.
|
||||
* If so, split the block at the <br> before this line and transform
|
||||
* the new block.
|
||||
*/
|
||||
private tryBlockTransformOnCurrentLine(block: HTMLElement, lineText: string): boolean {
|
||||
let newTag: string | null = null;
|
||||
let prefixLength = 0;
|
||||
|
||||
const headingMatch = lineText.match(/^(#{1,6})\s/);
|
||||
if (headingMatch) {
|
||||
newTag = 'H' + headingMatch[1].length;
|
||||
prefixLength = headingMatch[0].length;
|
||||
} else if (lineText.startsWith('> ')) {
|
||||
newTag = 'BLOCKQUOTE';
|
||||
prefixLength = 2;
|
||||
} else if (/^[-*+]\s/.test(lineText)) {
|
||||
return this.splitAndTransformList(block, 'ul', lineText);
|
||||
} else if (/^\d+\.\s/.test(lineText)) {
|
||||
return this.splitAndTransformList(block, 'ol', lineText);
|
||||
} else if (/^(\*{3,}|-{3,}|_{3,})\s*$/.test(lineText)) {
|
||||
this.splitAtCurrentLine(block);
|
||||
// The split created a new <p> with the hr text — transform it
|
||||
const newBlock = block.nextElementSibling as HTMLElement;
|
||||
if (newBlock) {
|
||||
const hr = document.createElement('hr');
|
||||
const paragraph = document.createElement('p');
|
||||
paragraph.innerHTML = '<br>';
|
||||
newBlock.replaceWith(hr, paragraph);
|
||||
const range = document.createRange();
|
||||
range.setStart(paragraph, 0);
|
||||
range.collapse(true);
|
||||
const selection = window.getSelection()!;
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!newTag) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.splitAtCurrentLine(block);
|
||||
const newBlock = block.nextElementSibling as HTMLElement;
|
||||
if (newBlock) {
|
||||
this.replaceBlock(newBlock, newTag, prefixLength);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a block at the <br> before the current line. Everything
|
||||
* after the <br> becomes a new <p> element after the original block.
|
||||
*/
|
||||
private splitAtCurrentLine(block: HTMLElement): void {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
return;
|
||||
}
|
||||
let node: Node | null = selection.anchorNode;
|
||||
|
||||
// Find the <br> before the current line
|
||||
let brNode: Node | null = null;
|
||||
if (node?.nodeType === 3) {
|
||||
let sibling: Node | null = node.previousSibling;
|
||||
while (sibling) {
|
||||
if (sibling.nodeType === 1 && (sibling as HTMLElement).tagName === 'BR') {
|
||||
brNode = sibling;
|
||||
break;
|
||||
}
|
||||
sibling = sibling.previousSibling;
|
||||
}
|
||||
}
|
||||
|
||||
if (!brNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect all nodes after the <br> into a new <p>
|
||||
const newParagraph = document.createElement('p');
|
||||
let nextNode: Node | null = brNode.nextSibling;
|
||||
while (nextNode) {
|
||||
const following = nextNode.nextSibling;
|
||||
newParagraph.appendChild(nextNode);
|
||||
nextNode = following;
|
||||
}
|
||||
// Strip leading ZWS from the first text node — it was a cursor
|
||||
// anchor from the Enter handler, not real content
|
||||
const firstChild = newParagraph.firstChild;
|
||||
if (firstChild && firstChild.nodeType === 3) {
|
||||
firstChild.textContent = (firstChild.textContent || '').replace(/^\u200B+/, '');
|
||||
}
|
||||
brNode.parentNode?.removeChild(brNode);
|
||||
|
||||
// Remove trailing empty text nodes and <br> from the original block
|
||||
while (block.lastChild
|
||||
&& ((block.lastChild.nodeType === 3
|
||||
&& (block.lastChild.textContent || '').replace(/\u200B/g, '').trim() === '')
|
||||
|| (block.lastChild.nodeType === 1
|
||||
&& (block.lastChild as HTMLElement).tagName === 'BR'))) {
|
||||
block.removeChild(block.lastChild);
|
||||
}
|
||||
|
||||
block.after(newParagraph);
|
||||
}
|
||||
|
||||
private splitAndTransformList(block: HTMLElement, listTag: string, lineText: string): boolean {
|
||||
const prefixLength = lineText.indexOf(' ') + 1;
|
||||
this.splitAtCurrentLine(block);
|
||||
const newBlock = block.nextElementSibling as HTMLElement;
|
||||
if (newBlock) {
|
||||
this.replaceBlockWithList(newBlock, listTag, prefixLength);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private transformInline(block: HTMLElement): void {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
|
|
@ -369,6 +547,11 @@ export class RibbitEditor extends Ribbit {
|
|||
return;
|
||||
}
|
||||
|
||||
// Normalize flanking underscores to asterisks so _ triggers
|
||||
// the same live preview as *. The CSS pseudo-elements will
|
||||
// show * even though the user typed _.
|
||||
markdown = this.normalizeUnderscores(markdown);
|
||||
|
||||
// Nesting rules: which elements must not appear inside which
|
||||
const forbiddenChildren = RibbitEditor.forbiddenNesting;
|
||||
|
||||
|
|
@ -413,6 +596,38 @@ export class RibbitEditor extends Ribbit {
|
|||
* If an unclosed opener was found, wrap the trailing content in
|
||||
* a speculative element; otherwise set innerHTML directly.
|
||||
*/
|
||||
/**
|
||||
* Convert flanking underscore runs to asterisks so the delimiter
|
||||
* matching treats _ the same as *. Non-flanking underscores (like
|
||||
* foo_bar) are left alone. Backslash-escaped underscores (\_) are
|
||||
* protected from normalization.
|
||||
*/
|
||||
private normalizeUnderscores(text: string): string {
|
||||
const escapePlaceholder = '\x00U\x00';
|
||||
const safeText = text.replace(/\\_/g, escapePlaceholder);
|
||||
|
||||
const punctuation = `[\\s.,;:!?'"()\\[\\]{}<>\\-/\\\\~#@&^|*\`]`;
|
||||
const openRun = new RegExp(
|
||||
`(?<=^|${punctuation})` +
|
||||
`(_+)` +
|
||||
`(?=\\S)`,
|
||||
'g'
|
||||
);
|
||||
const closeRun = new RegExp(
|
||||
`(?<=\\S)` +
|
||||
`(_+)` +
|
||||
`(?=$|${punctuation})`,
|
||||
'g'
|
||||
);
|
||||
const toAsterisks = (_match: string, run: string) =>
|
||||
'*'.repeat(run.length);
|
||||
const normalized = safeText
|
||||
.replace(openRun, toAsterisks)
|
||||
.replace(closeRun, toAsterisks);
|
||||
|
||||
return normalized.replace(/\x00U\x00/g, '\\_');
|
||||
}
|
||||
|
||||
private rebuildBlock(
|
||||
block: HTMLElement,
|
||||
markdown: string,
|
||||
|
|
@ -420,7 +635,7 @@ export class RibbitEditor extends Ribbit {
|
|||
forbiddenChildren: Record<string, string[]>,
|
||||
): void {
|
||||
if (!opener) {
|
||||
block.innerHTML = markdown;
|
||||
block.innerHTML = markdown.replace(/\n/g, '<br>\u200B');
|
||||
this.sanitizeNesting(block);
|
||||
this.appendZwsIfNeeded(block);
|
||||
this.placeCursorAtEnd(block);
|
||||
|
|
@ -434,7 +649,7 @@ export class RibbitEditor extends Ribbit {
|
|||
const probe = document.createElement('div');
|
||||
probe.innerHTML = inside;
|
||||
if (banned && banned.some(tag => probe.querySelector(tag))) {
|
||||
block.innerHTML = markdown;
|
||||
block.innerHTML = markdown.replace(/\n/g, '<br>\u200B');
|
||||
this.sanitizeNesting(block);
|
||||
this.appendZwsIfNeeded(block);
|
||||
this.placeCursorAtEnd(block);
|
||||
|
|
@ -501,6 +716,30 @@ export class RibbitEditor extends Ribbit {
|
|||
private replaceBlock(block: HTMLElement, newTag: string, prefixLength: number): void {
|
||||
const newEl = document.createElement(newTag);
|
||||
const content = (block.textContent || '').slice(prefixLength);
|
||||
// Blockquotes need inner <p> elements so Enter can create
|
||||
// sibling paragraphs within the quote
|
||||
if (newTag === 'BLOCKQUOTE') {
|
||||
const paragraph = document.createElement('p');
|
||||
if (content) {
|
||||
paragraph.textContent = content;
|
||||
} else {
|
||||
paragraph.innerHTML = '<br>';
|
||||
}
|
||||
newEl.appendChild(paragraph);
|
||||
block.replaceWith(newEl);
|
||||
newEl.classList.add('ribbit-editing');
|
||||
const range = document.createRange();
|
||||
if (paragraph.firstChild && paragraph.firstChild.nodeType === 3) {
|
||||
range.setStart(paragraph.firstChild, 0);
|
||||
} else {
|
||||
range.setStart(paragraph, 0);
|
||||
}
|
||||
range.collapse(true);
|
||||
const selection = window.getSelection()!;
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
return;
|
||||
}
|
||||
if (content) {
|
||||
newEl.textContent = content;
|
||||
} else {
|
||||
|
|
@ -554,7 +793,15 @@ export class RibbitEditor extends Ribbit {
|
|||
* On Enter, strip editing decorations from the current block so
|
||||
* the browser's default newline behavior creates a clean element.
|
||||
*/
|
||||
private handleEnter(_event: KeyboardEvent): void {
|
||||
/**
|
||||
* Dispatch a keydown event to the tag that contains the cursor.
|
||||
* Walks up from the cursor to find a tag with handleKeydown,
|
||||
* which routes to named handlers in the tag's eventHandlers map.
|
||||
* If no tag handles the event, the browser's default runs.
|
||||
*/
|
||||
private dispatchKeydown(event: KeyboardEvent): void {
|
||||
// Strip editing decorations on Enter regardless of tag handling
|
||||
if (event.key === 'Enter') {
|
||||
const prev = this.element.querySelector('.ribbit-editing');
|
||||
if (prev) {
|
||||
prev.classList.remove('ribbit-editing');
|
||||
|
|
@ -562,6 +809,43 @@ export class RibbitEditor extends Ribbit {
|
|||
}
|
||||
}
|
||||
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Walk up from the cursor to find a tag with handleKeydown.
|
||||
// Skip <p> elements inside container blocks (blockquote, li)
|
||||
// so the container's handler runs instead.
|
||||
const containerTags = new Set(['BLOCKQUOTE', 'LI']);
|
||||
let node: Node | null = selection.anchorNode;
|
||||
while (node && node !== this.element) {
|
||||
if (node.nodeType === 1) {
|
||||
const element = node as HTMLElement;
|
||||
// Skip <p> inside containers — let the container handle it
|
||||
if (element.tagName === 'P' && element.parentElement
|
||||
&& containerTags.has(element.parentElement.tagName)) {
|
||||
node = node.parentNode;
|
||||
continue;
|
||||
}
|
||||
const tag = this.converter.getBlockTags().find(
|
||||
blockTag => typeof blockTag.selector === 'string'
|
||||
&& blockTag.selector.split(',').some(
|
||||
selector => element.tagName === selector.trim()
|
||||
)
|
||||
);
|
||||
if (tag?.handleKeydown) {
|
||||
const handled = tag.handleKeydown(element, event, selection, this);
|
||||
if (handled) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
node = node.parentNode;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace an element with its children. Used to dissolve speculative
|
||||
* wrappers and fix forbidden nesting — the formatting is removed
|
||||
|
|
@ -705,7 +989,11 @@ export class RibbitEditor extends Ribbit {
|
|||
return decodeHtmlEntities(html);
|
||||
}
|
||||
if (this.getState() === this.states.WYSIWYG || this.getState() === this.states.VIEW) {
|
||||
return this.htmlToMarkdown(this.element.innerHTML);
|
||||
// Normalize non-breaking spaces and strip zero-width spaces
|
||||
// that the WYSIWYG transform inserts for cursor positioning
|
||||
return this.htmlToMarkdown(this.element.innerHTML)
|
||||
.replace(/\u00A0/g, ' ')
|
||||
.replace(/\u200B/g, '');
|
||||
}
|
||||
// Before run() — element has raw markdown as text
|
||||
return this.element.textContent || '';
|
||||
|
|
@ -720,6 +1008,8 @@ export class RibbitEditor extends Ribbit {
|
|||
wysiwyg(): void {
|
||||
if (this.getState() === this.states.WYSIWYG) return;
|
||||
const wasEditing = this.getState() === this.states.EDIT;
|
||||
// Invalidate cache so getHTML() re-converts from current content
|
||||
this.invalidateCache();
|
||||
this.vim?.detach();
|
||||
this.collaboration?.connect();
|
||||
if (wasEditing && this.collaboration?.isPaused()) {
|
||||
|
|
|
|||
|
|
@ -271,6 +271,7 @@ export class Ribbit {
|
|||
*/
|
||||
view(): void {
|
||||
if (this.getState() === this.states.VIEW) return;
|
||||
this.invalidateCache();
|
||||
this.collaboration?.disconnect();
|
||||
this.element.innerHTML = this.getHTML();
|
||||
this.setState(this.states.VIEW);
|
||||
|
|
|
|||
515
src/ts/tags.ts
515
src/ts/tags.ts
|
|
@ -132,13 +132,219 @@ export function inlineTag(def: InlineTagDef): Tag {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class for block-level tags. Provides keyboard event dispatch
|
||||
* for WYSIWYG mode: subclasses populate `eventHandlers` with named
|
||||
* handlers (e.g. 'onEnter', 'onBackspace'), and `handleKeydown`
|
||||
* routes events to the matching handler.
|
||||
*
|
||||
* class MyTag extends BaseTag implements Tag {
|
||||
* eventHandlers = { 'onEnter': this.onEnter };
|
||||
* private onEnter(element: HTMLElement, selection: Selection): boolean { ... }
|
||||
* }
|
||||
*/
|
||||
class BaseTag {
|
||||
eventHandlers: Record<string, (element: HTMLElement, selection: Selection, editor: any) => boolean> = {};
|
||||
|
||||
handleKeydown(element: HTMLElement, event: KeyboardEvent, selection: Selection, editor: any): boolean {
|
||||
const handlerName = 'on' + event.key;
|
||||
const handler = this.eventHandlers[handlerName];
|
||||
if (handler) {
|
||||
return handler.call(this, element, selection, editor);
|
||||
}
|
||||
// Default Enter behavior: insert <br> for single Enter,
|
||||
// exit block for double Enter (empty line after <br>)
|
||||
if (event.key === 'Enter') {
|
||||
return this.defaultOnEnter(element, selection, editor);
|
||||
}
|
||||
if (event.key === 'Backspace') {
|
||||
return this.defaultOnBackspace(element, selection);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default Enter handler for all block tags. Inserts a <br> for
|
||||
* line continuation. If the cursor is on an empty line (right
|
||||
* after a <br> with no content following), removes the trailing
|
||||
* <br> and creates a new <p> after the current block.
|
||||
*/
|
||||
/**
|
||||
* Default Backspace handler. When the cursor is at the start of
|
||||
* a line after a <br> (i.e. on the ZWS cursor anchor), remove
|
||||
* the <br> and ZWS to join the lines. Otherwise let the browser
|
||||
* handle it.
|
||||
*/
|
||||
private defaultOnBackspace(element: HTMLElement, selection: Selection): boolean {
|
||||
const range = selection.getRangeAt(0);
|
||||
const container = range.startContainer;
|
||||
const offset = range.startOffset;
|
||||
|
||||
if (container.nodeType !== 3) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const zeroWidthSpace = /\u200B/g;
|
||||
const textBefore = (container.textContent || '').slice(0, offset).replace(zeroWidthSpace, '');
|
||||
|
||||
// Only intercept if cursor is at the start of the line
|
||||
// (nothing but ZWS before the cursor in this text node)
|
||||
if (textBefore !== '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Walk back past empty text nodes to find a <br>
|
||||
let previous: Node | null = container.previousSibling;
|
||||
while (previous && previous.nodeType === 3
|
||||
&& (previous.textContent || '').replace(zeroWidthSpace, '').trim() === '') {
|
||||
previous = previous.previousSibling;
|
||||
}
|
||||
|
||||
if (!previous || previous.nodeType !== 1 || (previous as HTMLElement).tagName !== 'BR') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Remove the <br> and any ZWS/empty text nodes between it
|
||||
// and the cursor's text node
|
||||
let nodeToRemove: Node | null = container.previousSibling;
|
||||
while (nodeToRemove && nodeToRemove !== previous) {
|
||||
const prev = nodeToRemove.previousSibling;
|
||||
nodeToRemove.parentNode?.removeChild(nodeToRemove);
|
||||
nodeToRemove = prev;
|
||||
}
|
||||
previous.parentNode?.removeChild(previous);
|
||||
|
||||
// Remove the ZWS from the cursor's text node
|
||||
if (container.textContent?.replace(zeroWidthSpace, '') === '') {
|
||||
// Text node is only ZWS — remove it entirely
|
||||
const nextSibling = container.nextSibling;
|
||||
const parentNode = container.parentNode;
|
||||
container.parentNode?.removeChild(container);
|
||||
// Place cursor at end of previous text node
|
||||
const prevText = parentNode?.lastChild;
|
||||
if (prevText && prevText.nodeType === 3) {
|
||||
const newRange = document.createRange();
|
||||
newRange.setStart(prevText, prevText.textContent?.length || 0);
|
||||
newRange.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(newRange);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private defaultOnEnter(element: HTMLElement, selection: Selection, _editor: any): boolean {
|
||||
const range = selection.getRangeAt(0);
|
||||
const container = range.startContainer;
|
||||
const offset = range.startOffset;
|
||||
|
||||
// Detect empty line: cursor is at a position where the
|
||||
// previous sibling is a <br> and there's no text after it
|
||||
const zeroWidthSpace = /\u200B/g;
|
||||
let lineIsEmpty = false;
|
||||
|
||||
if (container.nodeType === 3) {
|
||||
const textBefore = (container.textContent || '').slice(0, offset).replace(zeroWidthSpace, '').trim();
|
||||
const textAfter = (container.textContent || '').slice(offset).replace(zeroWidthSpace, '').trim();
|
||||
if (textBefore === '' && textAfter === '') {
|
||||
// Walk back past empty text nodes to find a <br>
|
||||
let previous: Node | null = container.previousSibling;
|
||||
while (previous && previous.nodeType === 3
|
||||
&& (previous.textContent || '').replace(zeroWidthSpace, '').trim() === '') {
|
||||
previous = previous.previousSibling;
|
||||
}
|
||||
if (previous && previous.nodeType === 1 && (previous as HTMLElement).tagName === 'BR') {
|
||||
lineIsEmpty = true;
|
||||
}
|
||||
}
|
||||
} else if (container.nodeType === 1) {
|
||||
const childAtCursor = (container as HTMLElement).childNodes[offset - 1];
|
||||
if (childAtCursor && childAtCursor.nodeType === 1 && (childAtCursor as HTMLElement).tagName === 'BR') {
|
||||
// Only treat as empty line if there's real content before
|
||||
// the <br> — an empty paragraph's placeholder <br> doesn't count
|
||||
const textBefore = Array.from((container as HTMLElement).childNodes)
|
||||
.slice(0, offset - 1)
|
||||
.map(node => node.textContent || '')
|
||||
.join('')
|
||||
.replace(zeroWidthSpace, '')
|
||||
.trim();
|
||||
const textAfter = Array.from((container as HTMLElement).childNodes)
|
||||
.slice(offset)
|
||||
.map(node => node.textContent || '')
|
||||
.join('')
|
||||
.replace(zeroWidthSpace, '')
|
||||
.trim();
|
||||
if (textAfter === '' && textBefore !== '') {
|
||||
lineIsEmpty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lineIsEmpty) {
|
||||
// Double Enter: remove the trailing <br>, any empty text
|
||||
// nodes after it, and the cursor's text node
|
||||
let nodeToRemove: Node | null = container.nodeType === 3
|
||||
? container.previousSibling
|
||||
: (container as HTMLElement).childNodes[offset - 1];
|
||||
// Walk past empty text nodes to find the <br>
|
||||
while (nodeToRemove && nodeToRemove.nodeType === 3
|
||||
&& (nodeToRemove.textContent || '').replace(zeroWidthSpace, '').trim() === '') {
|
||||
const previous = nodeToRemove.previousSibling;
|
||||
nodeToRemove.parentNode?.removeChild(nodeToRemove);
|
||||
nodeToRemove = previous;
|
||||
}
|
||||
// Remove the <br> itself
|
||||
if (nodeToRemove && nodeToRemove.nodeType === 1
|
||||
&& (nodeToRemove as HTMLElement).tagName === 'BR') {
|
||||
nodeToRemove.parentNode?.removeChild(nodeToRemove);
|
||||
}
|
||||
// Remove the cursor's empty text node
|
||||
if (container.nodeType === 3
|
||||
&& container.textContent?.replace(zeroWidthSpace, '').trim() === '') {
|
||||
container.parentNode?.removeChild(container);
|
||||
}
|
||||
|
||||
const newParagraph = document.createElement('p');
|
||||
newParagraph.innerHTML = '<br>';
|
||||
// Find the top-level block to insert after
|
||||
let block: Node = element;
|
||||
while (block.parentNode && block.parentNode !== _editor.element) {
|
||||
block = block.parentNode;
|
||||
}
|
||||
(block as HTMLElement).after(newParagraph);
|
||||
const newRange = document.createRange();
|
||||
newRange.setStart(newParagraph, 0);
|
||||
newRange.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(newRange);
|
||||
} else {
|
||||
// Single Enter: insert <br> followed by a zero-width space.
|
||||
// The ZWS gives the browser a text node to place the cursor
|
||||
// in — without it, Firefox removes the <br> when the user
|
||||
// types the next character.
|
||||
const brElement = document.createElement('br');
|
||||
const cursorAnchor = document.createTextNode('\u200B');
|
||||
range.deleteContents();
|
||||
range.insertNode(cursorAnchor);
|
||||
range.insertNode(brElement);
|
||||
const newRange = document.createRange();
|
||||
newRange.setStart(cursorAnchor, 1);
|
||||
newRange.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(newRange);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fenced code blocks: lines between ``` delimiters become <pre><code>.
|
||||
*
|
||||
* converter.toHTML('```js\nlet x = 1;\n```')
|
||||
* // <pre><code class="language-js">let x = 1;</code></pre>
|
||||
*/
|
||||
class FencedCodeTag implements Tag {
|
||||
class FencedCodeTag extends BaseTag implements Tag {
|
||||
name = 'fencedCode';
|
||||
selector = 'PRE';
|
||||
button = {
|
||||
|
|
@ -192,7 +398,7 @@ class FencedCodeTag implements Tag {
|
|||
*
|
||||
* converter.toHTML('---') // '<hr>'
|
||||
*/
|
||||
class HorizontalRuleTag implements Tag {
|
||||
class HorizontalRuleTag extends BaseTag implements Tag {
|
||||
name = 'hr';
|
||||
selector = 'HR';
|
||||
button = {
|
||||
|
|
@ -229,9 +435,12 @@ class HorizontalRuleTag implements Tag {
|
|||
*
|
||||
* converter.toHTML('## Hello') // <h2 id='Hello'>Hello</h2>
|
||||
*/
|
||||
class HeadingTag implements Tag {
|
||||
class HeadingTag extends BaseTag implements Tag {
|
||||
name = 'heading';
|
||||
selector = 'H1,H2,H3,H4,H5,H6';
|
||||
eventHandlers = {
|
||||
'onEnter': this.onEnter,
|
||||
};
|
||||
button = {
|
||||
show: false,
|
||||
label: 'Heading',
|
||||
|
|
@ -287,6 +496,22 @@ class HeadingTag implements Tag {
|
|||
* Generate a PascalCase anchor ID from heading text so that
|
||||
* in-page links like #MyHeading work without manual IDs.
|
||||
*/
|
||||
/**
|
||||
* Headings always exit on Enter — you don't continue a heading
|
||||
* across multiple lines.
|
||||
*/
|
||||
private onEnter(heading: HTMLElement, selection: Selection, _editor: any): boolean {
|
||||
const newParagraph = document.createElement('p');
|
||||
newParagraph.innerHTML = '<br>';
|
||||
heading.after(newParagraph);
|
||||
const range = document.createRange();
|
||||
range.setStart(newParagraph, 0);
|
||||
range.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
return true;
|
||||
}
|
||||
|
||||
private anchorId(text: string): string {
|
||||
return text.trim().split(/\s+/).map(word =>
|
||||
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
|
||||
|
|
@ -301,7 +526,7 @@ class HeadingTag implements Tag {
|
|||
* converter.toHTML('> hello\n> world')
|
||||
* // <blockquote><p>hello\nworld</p></blockquote>
|
||||
*/
|
||||
class BlockquoteTag implements Tag {
|
||||
class BlockquoteTag extends BaseTag implements Tag {
|
||||
name = 'blockquote';
|
||||
selector = 'BLOCKQUOTE';
|
||||
button = {
|
||||
|
|
@ -311,6 +536,9 @@ class BlockquoteTag implements Tag {
|
|||
};
|
||||
template = '> Quote\n> continues here';
|
||||
replaceSelection = true;
|
||||
eventHandlers = {
|
||||
'onEnter': this.onEnter,
|
||||
};
|
||||
|
||||
match(context: MatchContext): SourceToken | null {
|
||||
const quotePrefix = /^>\s?/;
|
||||
|
|
@ -330,12 +558,153 @@ class BlockquoteTag implements Tag {
|
|||
}
|
||||
|
||||
toHTML(token: SourceToken, convert: Converter): string {
|
||||
return '<blockquote>' + convert.block(token.content) + '</blockquote>';
|
||||
// Within a blockquote, consecutive lines without a blank line
|
||||
// between them should produce <br> line breaks (not merge into
|
||||
// one paragraph). Insert hard break markers before block parsing
|
||||
// so the inline processor creates <br> elements.
|
||||
const withHardBreaks = token.content.replace(
|
||||
/([^\n])\n(?!\n)/g, // \n not followed by another \n
|
||||
'$1 \n' // trailing two spaces = hard break
|
||||
);
|
||||
return '<blockquote>' + convert.block(withHardBreaks) + '</blockquote>';
|
||||
}
|
||||
|
||||
toMarkdown(element: HTMLElement, convert: Converter): string {
|
||||
const lines = convert.children(element).trim().split('\n');
|
||||
return '\n\n' + lines.map(line => '> ' + line).join('\n') + '\n\n';
|
||||
// Each <p> inside the blockquote is a paragraph group.
|
||||
// Within a <p>, <br> elements create line breaks.
|
||||
// Separate paragraphs get a blank > line between them.
|
||||
const paragraphs: string[] = [];
|
||||
for (const child of Array.from(element.childNodes)) {
|
||||
if (child.nodeType === 1 && (child as HTMLElement).tagName === 'P') {
|
||||
const lines = this.paragraphToLines(child as HTMLElement, convert);
|
||||
paragraphs.push(lines.map(line => '> ' + line).join('\n'));
|
||||
} else {
|
||||
const text = convert.node(child).trim();
|
||||
if (text) {
|
||||
paragraphs.push('> ' + text);
|
||||
}
|
||||
}
|
||||
}
|
||||
return '\n\n' + paragraphs.join('\n>\n') + '\n\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a paragraph's content into lines at <br> elements.
|
||||
*/
|
||||
private paragraphToLines(paragraph: HTMLElement, convert: Converter): string[] {
|
||||
const lines: string[] = [];
|
||||
let currentLine = '';
|
||||
for (const child of Array.from(paragraph.childNodes)) {
|
||||
if (child.nodeType === 1 && (child as HTMLElement).tagName === 'BR') {
|
||||
lines.push(currentLine);
|
||||
currentLine = '';
|
||||
} else {
|
||||
currentLine += convert.node(child);
|
||||
}
|
||||
}
|
||||
if (currentLine.trim()) {
|
||||
lines.push(currentLine);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter inside a blockquote adds a new paragraph within the quote.
|
||||
* Double-Enter (empty line) exits the blockquote and creates a
|
||||
* new paragraph after it — matching the behavior users expect
|
||||
* from list editing.
|
||||
*/
|
||||
private onEnter(blockquote: HTMLElement, selection: Selection, _editor: any): boolean {
|
||||
// Find the text content after the cursor to determine if
|
||||
// the current line is empty (for double-Enter exit detection)
|
||||
const range = selection.getRangeAt(0);
|
||||
const container = range.startContainer;
|
||||
const offset = range.startOffset;
|
||||
|
||||
// Check if the cursor is at an empty line: either the entire
|
||||
// paragraph is empty, or the cursor is right after a <br> with
|
||||
// nothing after it
|
||||
let lineIsEmpty = false;
|
||||
const paragraph = container.nodeType === 1
|
||||
? container as HTMLElement
|
||||
: container.parentElement;
|
||||
|
||||
if (paragraph) {
|
||||
const zeroWidthSpace = /\u200B/g;
|
||||
const textAfterCursor = container.nodeType === 3
|
||||
? (container.textContent || '').slice(offset).replace(zeroWidthSpace, '').trim()
|
||||
: '';
|
||||
const textBeforeCursor = container.nodeType === 3
|
||||
? (container.textContent || '').slice(0, offset).replace(zeroWidthSpace, '').trim()
|
||||
: '';
|
||||
|
||||
// Empty if: whole paragraph is empty, or cursor is at the
|
||||
// start of an empty text node after a <br>
|
||||
const fullText = paragraph.textContent?.replace(zeroWidthSpace, '').trim() || '';
|
||||
if (fullText === '') {
|
||||
lineIsEmpty = true;
|
||||
} else if (textBeforeCursor === '' && textAfterCursor === '') {
|
||||
// Walk back past empty text nodes to find a <br>
|
||||
let previous: Node | null = container.nodeType === 3
|
||||
? container.previousSibling
|
||||
: (container as HTMLElement).childNodes[offset - 1];
|
||||
while (previous && previous.nodeType === 3
|
||||
&& (previous.textContent || '').replace(zeroWidthSpace, '').trim() === '') {
|
||||
previous = previous.previousSibling;
|
||||
}
|
||||
if (previous && previous.nodeType === 1 && (previous as HTMLElement).tagName === 'BR') {
|
||||
lineIsEmpty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lineIsEmpty) {
|
||||
// Double-Enter: clean up the trailing <br> and empty nodes,
|
||||
// then exit the blockquote
|
||||
const lastParagraph = blockquote.querySelector('p:last-child');
|
||||
if (lastParagraph) {
|
||||
// Remove trailing <br> and empty text nodes
|
||||
const zwsPattern = /\u200B/g;
|
||||
while (lastParagraph.lastChild) {
|
||||
const child = lastParagraph.lastChild;
|
||||
if (child.nodeType === 1 && (child as HTMLElement).tagName === 'BR') {
|
||||
child.remove();
|
||||
break;
|
||||
}
|
||||
if (child.nodeType === 3
|
||||
&& (child.textContent || '').replace(zwsPattern, '').trim() === '') {
|
||||
child.remove();
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
// If the paragraph is now empty, remove it entirely
|
||||
if (lastParagraph.textContent?.replace(zwsPattern, '').trim() === '') {
|
||||
lastParagraph.remove();
|
||||
}
|
||||
}
|
||||
const newParagraph = document.createElement('p');
|
||||
newParagraph.innerHTML = '<br>';
|
||||
blockquote.after(newParagraph);
|
||||
const newRange = document.createRange();
|
||||
newRange.setStart(newParagraph, 0);
|
||||
newRange.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(newRange);
|
||||
} else {
|
||||
// Single Enter: insert <br> + ZWS cursor anchor
|
||||
const brElement = document.createElement('br');
|
||||
const cursorAnchor = document.createTextNode('\u200B');
|
||||
range.deleteContents();
|
||||
range.insertNode(cursorAnchor);
|
||||
range.insertNode(brElement);
|
||||
const newRange = document.createRange();
|
||||
newRange.setStart(cursorAnchor, 1);
|
||||
newRange.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(newRange);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -345,9 +714,12 @@ class BlockquoteTag implements Tag {
|
|||
*
|
||||
* converter.toHTML('- one\n- two\n 1. nested')
|
||||
*/
|
||||
class ListTag implements Tag {
|
||||
class ListTag extends BaseTag implements Tag {
|
||||
name = 'list';
|
||||
selector = 'UL,OL';
|
||||
eventHandlers = {
|
||||
'onEnter': this.onEnter,
|
||||
};
|
||||
button = {
|
||||
show: false,
|
||||
label: 'List',
|
||||
|
|
@ -387,6 +759,129 @@ class ListTag implements Tag {
|
|||
* Count how many consecutive lines belong to this list, including
|
||||
* nested sublists (detected by increased indentation).
|
||||
*/
|
||||
/**
|
||||
* List Enter behavior has three tiers:
|
||||
* 1. Single Enter → <br> within current <li> (line continuation)
|
||||
* 2. Double Enter in non-empty <li> → new <li> sibling
|
||||
* 3. Double Enter in empty <li> → exit list, create <p> after it
|
||||
*/
|
||||
private onEnter(listElement: HTMLElement, selection: Selection, _editor: any): boolean {
|
||||
// Find the <li> containing the cursor
|
||||
let listItem: HTMLElement | null = null;
|
||||
let node: Node | null = selection.anchorNode;
|
||||
while (node && node !== listElement) {
|
||||
if (node.nodeType === 1 && (node as HTMLElement).tagName === 'LI') {
|
||||
listItem = node as HTMLElement;
|
||||
break;
|
||||
}
|
||||
node = node.parentNode;
|
||||
}
|
||||
if (!listItem) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const zeroWidthSpace = /\u200B/g;
|
||||
const itemText = (listItem.textContent || '').replace(zeroWidthSpace, '').trim();
|
||||
const range = selection.getRangeAt(0);
|
||||
const container = range.startContainer;
|
||||
const offset = range.startOffset;
|
||||
|
||||
// Detect empty line after <br> (same logic as defaultOnEnter)
|
||||
let lineIsEmpty = false;
|
||||
if (container.nodeType === 3) {
|
||||
const textBefore = (container.textContent || '').slice(0, offset).replace(zeroWidthSpace, '').trim();
|
||||
const textAfter = (container.textContent || '').slice(offset).replace(zeroWidthSpace, '').trim();
|
||||
if (textBefore === '' && textAfter === '') {
|
||||
let previous: Node | null = container.previousSibling;
|
||||
while (previous && previous.nodeType === 3
|
||||
&& (previous.textContent || '').replace(zeroWidthSpace, '').trim() === '') {
|
||||
previous = previous.previousSibling;
|
||||
}
|
||||
if (previous && previous.nodeType === 1 && (previous as HTMLElement).tagName === 'BR') {
|
||||
lineIsEmpty = true;
|
||||
}
|
||||
}
|
||||
} else if (container.nodeType === 1) {
|
||||
const childAtCursor = (container as HTMLElement).childNodes[offset - 1];
|
||||
if (childAtCursor && childAtCursor.nodeType === 1 && (childAtCursor as HTMLElement).tagName === 'BR') {
|
||||
const textBefore = Array.from((container as HTMLElement).childNodes)
|
||||
.slice(0, offset - 1)
|
||||
.map(child => child.textContent || '')
|
||||
.join('')
|
||||
.replace(zeroWidthSpace, '')
|
||||
.trim();
|
||||
const textAfter = Array.from((container as HTMLElement).childNodes)
|
||||
.slice(offset)
|
||||
.map(child => child.textContent || '')
|
||||
.join('')
|
||||
.replace(zeroWidthSpace, '')
|
||||
.trim();
|
||||
if (textAfter === '' && textBefore !== '') {
|
||||
lineIsEmpty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lineIsEmpty && itemText === '') {
|
||||
// Tier 3: empty <li> — exit the list
|
||||
listItem.remove();
|
||||
const newParagraph = document.createElement('p');
|
||||
newParagraph.innerHTML = '<br>';
|
||||
listElement.after(newParagraph);
|
||||
const newRange = document.createRange();
|
||||
newRange.setStart(newParagraph, 0);
|
||||
newRange.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(newRange);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (lineIsEmpty) {
|
||||
// Tier 2: non-empty <li> with empty line — create new <li>
|
||||
// Remove the trailing <br> and empty nodes
|
||||
let nodeToRemove: Node | null = container.nodeType === 3
|
||||
? container.previousSibling
|
||||
: (container as HTMLElement).childNodes[offset - 1];
|
||||
while (nodeToRemove && nodeToRemove.nodeType === 3
|
||||
&& (nodeToRemove.textContent || '').replace(zeroWidthSpace, '').trim() === '') {
|
||||
const previous = nodeToRemove.previousSibling;
|
||||
nodeToRemove.parentNode?.removeChild(nodeToRemove);
|
||||
nodeToRemove = previous;
|
||||
}
|
||||
if (nodeToRemove && nodeToRemove.nodeType === 1
|
||||
&& (nodeToRemove as HTMLElement).tagName === 'BR') {
|
||||
nodeToRemove.parentNode?.removeChild(nodeToRemove);
|
||||
}
|
||||
if (container.nodeType === 3
|
||||
&& container.textContent?.replace(zeroWidthSpace, '').trim() === '') {
|
||||
container.parentNode?.removeChild(container);
|
||||
}
|
||||
|
||||
const newItem = document.createElement('li');
|
||||
newItem.innerHTML = '<br>';
|
||||
listItem.after(newItem);
|
||||
const newRange = document.createRange();
|
||||
newRange.setStart(newItem, 0);
|
||||
newRange.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(newRange);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Tier 1: single Enter — insert <br> + ZWS cursor anchor
|
||||
const brElement = document.createElement('br');
|
||||
const cursorAnchor = document.createTextNode('\u200B');
|
||||
range.deleteContents();
|
||||
range.insertNode(cursorAnchor);
|
||||
range.insertNode(brElement);
|
||||
const newRange = document.createRange();
|
||||
newRange.setStart(cursorAnchor, 1);
|
||||
newRange.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(newRange);
|
||||
return true;
|
||||
}
|
||||
|
||||
private countListLines(lines: string[], start: number): number {
|
||||
const indentedUnordered = /^(\s*)[*\-+]\s/;
|
||||
const indentedOrdered = /^(\s*)\d+\.\s/;
|
||||
|
|
@ -501,7 +996,7 @@ class ListTag implements Tag {
|
|||
*
|
||||
* converter.toHTML('| A | B |\n|---|---|\n| 1 | 2 |')
|
||||
*/
|
||||
class TableTag implements Tag {
|
||||
class TableTag extends BaseTag implements Tag {
|
||||
name = 'table';
|
||||
selector = 'TABLE';
|
||||
button = {
|
||||
|
|
@ -611,7 +1106,7 @@ class TableTag implements Tag {
|
|||
*
|
||||
* converter.toHTML('hello world') // '<p>hello world</p>'
|
||||
*/
|
||||
class ParagraphTag implements Tag {
|
||||
class ParagraphTag extends BaseTag implements Tag {
|
||||
name = 'paragraph';
|
||||
selector = 'P';
|
||||
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ export interface DelimiterDef {
|
|||
* and followed by non-whitespace. Right-flanking is the reverse.
|
||||
*/
|
||||
const PUNCTUATION = new Set(
|
||||
' \t\n.,;:!?\'"()[]{}/<>\\-~#@&^|*`_'.split('')
|
||||
' \t\n\u00A0.,;:!?\'"()[]{}/<>\\-~#@&^|*`_'.split('')
|
||||
);
|
||||
|
||||
/**
|
||||
|
|
@ -416,9 +416,9 @@ export class InlineTokenizer {
|
|||
const charAfter = source[position + delimiter.length];
|
||||
|
||||
const leftFlanking = (charBefore === undefined || PUNCTUATION.has(charBefore) || charBefore === '\n')
|
||||
&& charAfter !== undefined && charAfter !== ' ' && charAfter !== '\n' && charAfter !== '\t';
|
||||
&& charAfter !== undefined && charAfter !== ' ' && charAfter !== '\n' && charAfter !== '\t' && charAfter !== '\u00A0';
|
||||
|
||||
const rightFlanking = charBefore !== undefined && charBefore !== ' ' && charBefore !== '\n' && charBefore !== '\t'
|
||||
const rightFlanking = charBefore !== undefined && charBefore !== ' ' && charBefore !== '\n' && charBefore !== '\t' && charBefore !== '\u00A0'
|
||||
&& (charAfter === undefined || PUNCTUATION.has(charAfter) || charAfter === '\n');
|
||||
|
||||
if (leftFlanking) {
|
||||
|
|
|
|||
|
|
@ -70,6 +70,13 @@ export interface Tag {
|
|||
template?: string;
|
||||
replaceSelection?: boolean;
|
||||
button?: ToolbarButton;
|
||||
/** Keyboard event handlers for WYSIWYG mode. Keys are event names
|
||||
* like 'onEnter', 'onBackspace', 'onTab'. The handler receives
|
||||
* the tag's element, the current selection, and the editor instance. */
|
||||
eventHandlers?: Record<string, (element: HTMLElement, selection: Selection, editor: any) => boolean>;
|
||||
/** Dispatch a keydown event to the appropriate handler in
|
||||
* eventHandlers. Provided by BaseTag; override for custom logic. */
|
||||
handleKeydown?: (element: HTMLElement, event: KeyboardEvent, selection: Selection, editor: any) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -534,3 +534,18 @@ describe('Backslash-escaped HTML tags', () => {
|
|||
expect(rehtml).toBe(rehtml2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Table cell round-trip', () => {
|
||||
it('inline formatting in cells survives round-trip', () => {
|
||||
const html = '<table><thead><tr><th>A</th><th>B</th></tr></thead><tbody><tr><td><strong>bold</strong></td><td><em>italic</em></td></tr></tbody></table>';
|
||||
expect(H(M(html))).toBe(html);
|
||||
});
|
||||
it('code in cells survives round-trip', () => {
|
||||
const html = '<table><thead><tr><th>A</th></tr></thead><tbody><tr><td><code>x</code></td></tr></tbody></table>';
|
||||
expect(H(M(html))).toBe(html);
|
||||
});
|
||||
it('literal * in cells survives round-trip', () => {
|
||||
const html = '<table><thead><tr><th>A</th></tr></thead><tbody><tr><td>2 * 3</td></tr></tbody></table>';
|
||||
expect(H(M(html))).toBe(html);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
106
test/integration/dev-server.js
Normal file
106
test/integration/dev-server.js
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
/**
|
||||
* Development server with livereload.
|
||||
*
|
||||
* Serves the test page and ribbit dist files. Watches src/ for
|
||||
* changes, rebuilds automatically, and notifies connected browsers
|
||||
* to reload via a simple EventSource stream.
|
||||
*
|
||||
* Run: npm run dev
|
||||
*/
|
||||
const { createServer } = require('./server');
|
||||
const { execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PORT = 8080;
|
||||
const WATCH_DIRS = [
|
||||
path.join(__dirname, '..', '..', 'src'),
|
||||
path.join(__dirname, '..', '..', 'test', 'integration'),
|
||||
];
|
||||
const DEBOUNCE_MS = 300;
|
||||
|
||||
const server = createServer(PORT);
|
||||
const reloadClients = [];
|
||||
|
||||
// Patch the server to add the livereload endpoint
|
||||
const originalServer = require('http').createServer;
|
||||
const httpServer = server._server || (() => {
|
||||
// Access the internal server by starting and intercepting
|
||||
let captured = null;
|
||||
const origListen = require('http').Server.prototype.listen;
|
||||
require('http').Server.prototype.listen = function (...args) {
|
||||
captured = this;
|
||||
return origListen.apply(this, args);
|
||||
};
|
||||
server.start();
|
||||
require('http').Server.prototype.listen = origListen;
|
||||
return captured;
|
||||
})();
|
||||
|
||||
// Simpler approach: create a standalone livereload server
|
||||
const reloadServer = require('http').createServer((request, response) => {
|
||||
response.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
});
|
||||
reloadClients.push(response);
|
||||
request.on('close', () => {
|
||||
const index = reloadClients.indexOf(response);
|
||||
if (index >= 0) {
|
||||
reloadClients.splice(index, 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function notifyReload() {
|
||||
for (const client of reloadClients) {
|
||||
client.write('data: reload\n\n');
|
||||
}
|
||||
}
|
||||
|
||||
function rebuild() {
|
||||
try {
|
||||
console.log('\n🔨 Rebuilding...');
|
||||
execSync('npm run build:js && npm run build:css', {
|
||||
cwd: path.join(__dirname, '..', '..'),
|
||||
stdio: 'pipe',
|
||||
});
|
||||
console.log('✅ Build complete');
|
||||
notifyReload();
|
||||
} catch (error) {
|
||||
console.error('❌ Build failed:', error.stderr?.toString().slice(0, 500));
|
||||
}
|
||||
}
|
||||
|
||||
let debounceTimer = null;
|
||||
function onFileChange(filename) {
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
}
|
||||
console.log(`📝 Changed: ${filename}`);
|
||||
debounceTimer = setTimeout(rebuild, DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
// Watch source directories
|
||||
for (const directory of WATCH_DIRS) {
|
||||
if (fs.existsSync(directory)) {
|
||||
fs.watch(directory, { recursive: true }, (eventType, filename) => {
|
||||
if (filename && !filename.includes('node_modules')) {
|
||||
onFileChange(filename);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
server.start().then(() => {
|
||||
reloadServer.listen(PORT + 1, () => {
|
||||
console.log(`\n🐸 Ribbit dev server`);
|
||||
console.log(` Editor: http://localhost:${PORT}`);
|
||||
console.log(` Livereload: http://localhost:${PORT + 1} (EventSource)`);
|
||||
console.log(` Watching: src/, test/integration/`);
|
||||
console.log(`\n Add this to the page to enable livereload:`);
|
||||
console.log(` <script>new EventSource('http://localhost:${PORT + 1}').onmessage = () => location.reload()</script>\n`);
|
||||
});
|
||||
});
|
||||
|
|
@ -42,5 +42,10 @@
|
|||
editor.run();
|
||||
window.__ribbitEditor = editor;
|
||||
</script>
|
||||
<script>
|
||||
// Livereload — connects to dev server's EventSource endpoint.
|
||||
// Silently fails if the dev server isn't running.
|
||||
try { new EventSource('http://localhost:8081').onmessage = () => location.reload(); } catch(e) {}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -318,6 +318,67 @@ async function runTests() {
|
|||
assert(html.includes('<blockquote'), `No blockquote after "> ": ${html}`);
|
||||
});
|
||||
|
||||
await test('enter inside blockquote adds new line', async () => {
|
||||
await resetEditor();
|
||||
await typeString('> first line');
|
||||
let html = await getHTML();
|
||||
assert(html.includes('<blockquote'), `No blockquote: ${html}`);
|
||||
|
||||
await typeChar(Key.ENTER);
|
||||
await typeString('second line');
|
||||
html = await getHTML();
|
||||
|
||||
// Both lines must be inside the same blockquote
|
||||
const blockquoteCount = (html.match(/<blockquote/g) || []).length;
|
||||
assert(blockquoteCount === 1, `Expected 1 blockquote, got ${blockquoteCount}: ${html}`);
|
||||
assert(html.includes('first line'), `Missing first line: ${html}`);
|
||||
assert(html.includes('second line'), `Missing second line: ${html}`);
|
||||
// The two lines must be separate — not merged into one string
|
||||
assert(!html.includes('first linesecond'), `Lines merged without break: ${html}`);
|
||||
|
||||
// Markdown should be "> foo\n> bar" — continuation, no blank lines
|
||||
const markdown = await getMarkdown();
|
||||
assert(markdown.includes('> first line'), `Missing > first line in markdown: ${markdown}`);
|
||||
assert(markdown.includes('> second line'), `Missing > second line in markdown: ${markdown}`);
|
||||
assert(!markdown.includes('>\n>'), `Unwanted blank > line in markdown: ${markdown}`);
|
||||
});
|
||||
|
||||
await test('blockquote paragraphs survive mode round-trip', async () => {
|
||||
await resetEditor();
|
||||
await typeString('> foo');
|
||||
await typeChar(Key.ENTER);
|
||||
await typeString('bar');
|
||||
await driver.sleep(50);
|
||||
|
||||
// Switch to source and back to wysiwyg twice
|
||||
await driver.executeScript('window.__ribbitEditor.edit()');
|
||||
await driver.sleep(50);
|
||||
await driver.executeScript('window.__ribbitEditor.wysiwyg()');
|
||||
await driver.sleep(50);
|
||||
await driver.executeScript('window.__ribbitEditor.edit()');
|
||||
await driver.sleep(50);
|
||||
|
||||
const markdown = await driver.executeScript('return document.getElementById("ribbit").textContent');
|
||||
assert(markdown.includes('> foo'), `Missing > foo: ${markdown}`);
|
||||
assert(markdown.includes('> bar'), `Missing > bar — second line lost its prefix: ${markdown}`);
|
||||
});
|
||||
|
||||
await test('double enter exits blockquote', async () => {
|
||||
await resetEditor();
|
||||
await typeString('> quoted');
|
||||
await typeChar(Key.ENTER);
|
||||
await typeChar(Key.ENTER);
|
||||
await typeString('after');
|
||||
const html = await getHTML();
|
||||
assert(html.includes('<blockquote'), `No blockquote: ${html}`);
|
||||
assert(html.includes('quoted'), `Missing quoted text: ${html}`);
|
||||
assert(html.includes('after'), `Missing text after blockquote: ${html}`);
|
||||
// "after" should NOT be inside the blockquote
|
||||
const afterBlockquote = html.indexOf('</blockquote');
|
||||
const afterText = html.indexOf('after');
|
||||
assert(afterText > afterBlockquote, `"after" is inside blockquote: ${html}`);
|
||||
});
|
||||
|
||||
// ── Horizontal rule ──
|
||||
|
||||
console.log(' Horizontal rule:');
|
||||
|
|
@ -412,6 +473,99 @@ async function runTests() {
|
|||
assert(!html.includes('data-speculative'), `Speculative not closed: ${html}`);
|
||||
});
|
||||
|
||||
console.log(' Enter behavior:');
|
||||
|
||||
await test('block pattern after Enter splits and transforms', async () => {
|
||||
await resetEditor();
|
||||
await typeString('foo');
|
||||
await typeChar(Key.ENTER);
|
||||
await typeString('> bar');
|
||||
await driver.sleep(50);
|
||||
const html = await getHTML();
|
||||
assert(html.includes('<blockquote'), `> after Enter did not create blockquote: ${html}`);
|
||||
assert(html.includes('foo'), `Lost content before split: ${html}`);
|
||||
assert(html.includes('bar'), `Lost content after split: ${html}`);
|
||||
});
|
||||
|
||||
await test('single Enter in paragraph inserts line break', async () => {
|
||||
await resetEditor();
|
||||
await typeString('line one');
|
||||
await typeChar(Key.ENTER);
|
||||
await typeString('line two');
|
||||
const markdown = await getMarkdown();
|
||||
// Single Enter = one \n, not \n\n
|
||||
assert(markdown.includes('line one'), `Missing line one: ${markdown}`);
|
||||
assert(markdown.includes('line two'), `Missing line two: ${markdown}`);
|
||||
assert(!markdown.includes('line one\n\nline two'), `Got paragraph break instead of line break: ${markdown}`);
|
||||
});
|
||||
|
||||
await test('double Enter in paragraph creates new block', async () => {
|
||||
await resetEditor();
|
||||
await typeString('first paragraph');
|
||||
await typeChar(Key.ENTER);
|
||||
await typeChar(Key.ENTER);
|
||||
await typeString('second paragraph');
|
||||
const html = await getHTML();
|
||||
// Double Enter = new <p>, so two separate paragraphs
|
||||
const paragraphCount = (html.match(/<p[\s>]/g) || []).length;
|
||||
assert(paragraphCount >= 2, `Expected 2+ paragraphs, got ${paragraphCount}: ${html}`);
|
||||
});
|
||||
|
||||
await test('backspace at start of line after Enter joins lines', async () => {
|
||||
await resetEditor();
|
||||
await typeString('foo');
|
||||
await typeChar(Key.ENTER);
|
||||
await typeChar(Key.BACK_SPACE);
|
||||
await driver.sleep(50);
|
||||
const html = await getHTML();
|
||||
// The <br> should be removed, cursor at end of "foo"
|
||||
assert(!html.includes('<br'), `<br> not removed: ${html}`);
|
||||
assert(html.includes('foo'), `Content lost: ${html}`);
|
||||
});
|
||||
|
||||
await test('single Enter in list item inserts line break', async () => {
|
||||
await resetEditor();
|
||||
await typeString('- line one');
|
||||
await typeChar(Key.ENTER);
|
||||
await typeString('line two');
|
||||
const markdown = await getMarkdown();
|
||||
// Both lines in the same list item
|
||||
assert(markdown.includes('- line one'), `Missing list marker: ${markdown}`);
|
||||
assert(markdown.includes('line two'), `Missing line two: ${markdown}`);
|
||||
// Should NOT create a second list item
|
||||
const markerCount = (markdown.match(/^- /gm) || []).length;
|
||||
assert(markerCount === 1, `Expected 1 list marker, got ${markerCount}: ${markdown}`);
|
||||
});
|
||||
|
||||
await test('double Enter in list item creates new item', async () => {
|
||||
await resetEditor();
|
||||
await typeString('- first');
|
||||
await typeChar(Key.ENTER);
|
||||
await typeChar(Key.ENTER);
|
||||
await typeString('second');
|
||||
const markdown = await getMarkdown();
|
||||
const markerCount = (markdown.match(/^- /gm) || []).length;
|
||||
assert(markerCount === 2, `Expected 2 list markers, got ${markerCount}: ${markdown}`);
|
||||
assert(markdown.includes('- first'), `Missing first item: ${markdown}`);
|
||||
assert(markdown.includes('- second'), `Missing second item: ${markdown}`);
|
||||
});
|
||||
|
||||
await test('double Enter on empty list item exits list', async () => {
|
||||
await resetEditor();
|
||||
await typeString('- item');
|
||||
await typeChar(Key.ENTER);
|
||||
await typeChar(Key.ENTER);
|
||||
await typeChar(Key.ENTER);
|
||||
await typeChar(Key.ENTER);
|
||||
await typeString('after list');
|
||||
const html = await getHTML();
|
||||
assert(html.includes('after list'), `Missing text after list: ${html}`);
|
||||
// "after list" should NOT be inside the <ul>
|
||||
const ulEnd = html.indexOf('</ul>');
|
||||
const afterPos = html.indexOf('after list');
|
||||
assert(afterPos > ulEnd, `"after list" is inside the list: ${html}`);
|
||||
});
|
||||
|
||||
// ── Complex document ──
|
||||
|
||||
console.log(' Complex document:');
|
||||
|
|
@ -422,6 +576,7 @@ async function runTests() {
|
|||
await typeChar(Key.ENTER);
|
||||
await typeString('Some **bold** text.');
|
||||
await typeChar(Key.ENTER);
|
||||
await typeChar(Key.ENTER);
|
||||
await typeString('## Section');
|
||||
await typeChar(Key.ENTER);
|
||||
await typeString('- item one');
|
||||
|
|
@ -474,6 +629,32 @@ async function runTests() {
|
|||
assert(html.includes('<ul') || html.includes('<li'), `No list after "+ ": ${html}`);
|
||||
});
|
||||
|
||||
console.log(' Underscore emphasis:');
|
||||
|
||||
await test('_text_ transforms to italic (shows * delimiters)', async () => {
|
||||
await resetEditor();
|
||||
await typeString('_hello_');
|
||||
const html = await getHTML();
|
||||
assert(html.includes('<em'), `No <em> after _hello_: ${html}`);
|
||||
assert(!html.includes('data-speculative'), `Still speculative: ${html}`);
|
||||
});
|
||||
|
||||
await test('_text shows speculative italic', async () => {
|
||||
await resetEditor();
|
||||
await typeString('_hel');
|
||||
const html = await getHTML();
|
||||
assert(html.includes('<em'), `No <em> after _hel: ${html}`);
|
||||
assert(html.includes('data-speculative'), `Not speculative: ${html}`);
|
||||
});
|
||||
|
||||
await test('__text__ transforms to bold', async () => {
|
||||
await resetEditor();
|
||||
await typeString('__hello__');
|
||||
const html = await getHTML();
|
||||
assert(html.includes('<strong'), `No <strong> after __hello__: ${html}`);
|
||||
assert(!html.includes('data-speculative'), `Still speculative: ${html}`);
|
||||
});
|
||||
|
||||
console.log(' Backslash escapes:');
|
||||
|
||||
await test('backslash is just a character in WYSIWYG', async () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user