UNPKG

carta-md

Version:

A lightweight, fully customizable, Markdown editor

222 lines (221 loc) 8.95 kB
import { diffChars } from 'diff'; const clonePosition = (position) => { return { line: position.line, span: position.span, char: position.char }; }; /** * Temporary updates the highlight overlay to reflect the changes between two text strings, * waiting for the actual update to be applied. This way, the user can immediately see the changes, * without a delay of around ~100ms. This makes the UI feel more responsive. * @param from Previous text. * @param to Current text. * @param currentHTML Current HTML content of the overlay. */ export function speculativeHighlightUpdate(from, to, currentHTML) { const diff = diffChars(from.replaceAll('\r\n', '\n'), to.replaceAll('\r\n', '\n')); const tree = document.createElement('div'); tree.innerHTML = currentHTML; const lines = Array.from(tree.querySelectorAll('.line')); let writingPosition = { line: 0, span: 0, char: 0 }; let readingPosition = { line: 0, span: 0, char: 0 }; if (lines.length === 0) return to; const advance = () => { writingPosition = clonePosition(readingPosition); writingPosition.char++; // Always advance the writing position // Cannot read past the end of the text if (!isAtEndOfText(readingPosition, lines)) { readingPosition = nextPosition(readingPosition, lines); } }; const onUnchangedCharacter = (char) => { if (char === '\n') { writingPosition.char = 0; writingPosition.span = 0; writingPosition.line++; readingPosition = clonePosition(writingPosition); return; } advance(); }; const onAddedCharacter = (char) => { if (char === '\n') { // We also need to move the text after the cursor to the next line const cutSpan = getCurrentSpan(writingPosition, lines); // All the following spans should be moved to the next line const nextSpans = []; while (true) { const span = nextSpans.at(-1) ?? cutSpan; if (!span.nextElementSibling) { break; } nextSpans.push(span.nextElementSibling); } const text = cutSpan.textContent ?? ''; const newText = text.slice(writingPosition.char); cutSpan.textContent = text.slice(0, writingPosition.char); const line = document.createElement('span'); line.classList.add('line'); const newlineNode = document.createTextNode('\n'); if (newText !== '') { // Create a new span for the text const span = document.createElement('span'); line.appendChild(span); span.textContent = newText; // Copy the styles from the cut span span.style.cssText = cutSpan.style.cssText; } // Move the other spans to the new line for (const span of nextSpans) { line.appendChild(span); } // Insert the new line after the current line const currentLine = lines[writingPosition.line]; currentLine.after(newlineNode, line); lines.splice(writingPosition.line + 1, 0, line); readingPosition = { line: readingPosition.line + 1, span: 0, char: 0 }; writingPosition = clonePosition(readingPosition); return; } const span = getCurrentSpan(writingPosition, lines); const text = span.textContent ?? ''; span.textContent = text.slice(0, writingPosition.char) + char + text.slice(writingPosition.char); writingPosition.char++; // Check if we wrote in the same span if (writingPosition.line === readingPosition.line && writingPosition.span === readingPosition.span) { readingPosition.char++; } }; const onRemovedCharacter = (char) => { if (char === '\n') { // Move all the spans from the next line to the current line const currentLine = lines[readingPosition.line - 1]; const nextLine = lines[readingPosition.line]; const nextLineSpans = Array.from(nextLine.children); // Move the spans from the next line to the current line for (const span of nextLineSpans) { currentLine.appendChild(span); } // Remove the next line and the newline node const newlineNode = currentLine.nextSibling; newlineNode?.remove(); nextLine.remove(); lines.splice(readingPosition.line, 1); // Move the reading position to the end of the previous line (writing position is already there) readingPosition = clonePosition(writingPosition); // make sure that the reading position is not in an invalid state readingPosition = checkPosition(readingPosition, lines); return; } const span = getCurrentSpan(readingPosition, lines); const text = span.textContent ?? ''; const removedChar = text[readingPosition.char]; if (removedChar !== char) { console.warn(`Character mismatch: "${char}" !== "${removedChar}" at: `, readingPosition, lines); } span.textContent = text.slice(0, readingPosition.char) + text.slice(readingPosition.char + 1); if (span.textContent === '') { span.remove(); } // make sure that the reading position is not in an invalid state readingPosition = checkPosition(readingPosition, lines); }; readingPosition = checkPosition(readingPosition, lines); // Make sure the starting position is valid for (const change of diff) { switch (true) { case change.added: for (const char of change.value) { onAddedCharacter(char); } break; case change.removed: for (const char of change.value) { onRemovedCharacter(char); } break; default: for (const char of change.value) { onUnchangedCharacter(char); } break; } } return tree.innerHTML; } /** * Create a new span at the end of the line element. * @param line The line element to append the span to. * @returns A new span element. */ function createSpan(line) { const span = document.createElement('span'); line.appendChild(span); return span; } /** * Check whether the current position is valid, which means that the * the character index is within the text of the span, the span index is within the line, * @param position The position to check * @param lines The lines to work on. * @returns The next valid position. */ function checkPosition(position, lines) { const { line, span, char } = position; const lineElement = lines[line]; const spanElement = Array.from(lineElement.children).at(span); const text = spanElement?.textContent ?? ''; const nextPosition = { line, span, char }; if (char >= text.length) { nextPosition.char = 0; nextPosition.span = span + 1; } if (nextPosition.span >= lineElement.children.length) { nextPosition.char = 0; nextPosition.span = 0; nextPosition.line = line + 1; } return nextPosition; } /** * Get the next position in the tree. * @param position Current position * @param lines The lines to work on. * @returns The next position. */ function nextPosition(position, lines) { const { line, span, char } = position; const lineElement = lines[line]; const spanElement = lineElement.children[span] ?? createSpan(lineElement); const text = spanElement.textContent ?? ''; const nextPosition = { line, span, char: char + 1 }; if (char + 1 >= text.length) { nextPosition.char = 0; nextPosition.span = span + 1; if (nextPosition.span >= lineElement.children.length) { nextPosition.span = 0; nextPosition.line = line + 1; } } return nextPosition; } function isAtEndOfText(position, lines) { return position.line >= lines.length - 1 && isAtEndOfLine(position, lines); } function isAtEndOfLine(position, lines) { const line = lines[position.line]; return position.span >= line.children.length - 1 && isAtEndOfSpan(position, lines); } function isAtEndOfSpan(position, lines) { const line = lines[position.line]; const span = line.children[position.span] ?? line; return position.char >= (span.textContent ?? '').length - 1; } function getCurrentSpan(position, lines) { const line = lines[position.line]; return line.children[position.span] ?? createSpan(line); }