import { reconcileWithHistory } from 'reconcile-text'; import type { BuiltinTokenizer } from 'reconcile-text'; import './style.scss'; const originalTextArea = document.getElementById('original') as HTMLTextAreaElement; const leftTextArea = document.getElementById('left') as HTMLTextAreaElement; const rightTextArea = document.getElementById('right') as HTMLTextAreaElement; const mergedTextArea = document.getElementById('merged') as HTMLDivElement; const tokenizerRadios = document.querySelectorAll( 'input[name="tokenizer"]' ) as NodeListOf; const sampleText = `The reconcile-text library is embedded on this page as a WASM module and powers these text boxes. Experiment with changing the "Original", "First user's edit", and "Second user's edit" text boxes to see competing changes get merged in real-time within the "Merged result" box. Here, you will see color-coded tokens marking the origin of each token, including ones that got deleted. The result highly depends on the tokenisation strategy which may be: - Character-based - Word-based`; let pendingUpdate: number | null = null; function scheduleUpdate(): void { if (pendingUpdate === null) { pendingUpdate = requestAnimationFrame(() => { pendingUpdate = null; updateMergedText(); }); } } async function main(): Promise { originalTextArea.addEventListener('input', scheduleUpdate); leftTextArea.addEventListener('input', scheduleUpdate); rightTextArea.addEventListener('input', scheduleUpdate); document.addEventListener('selectionchange', () => { if ( document.activeElement === leftTextArea || document.activeElement === rightTextArea ) { scheduleUpdate(); } }); window.addEventListener('resize', resizeTextAreas); tokenizerRadios.forEach((radio) => { radio.addEventListener('change', scheduleUpdate); }); loadSample(); updateMergedText(); focusTextArea(leftTextArea); } // Edit the instructions to generate example edits function loadSample(): void { originalTextArea.value = sampleText; leftTextArea.value = sampleText.replace('color', 'colour') + "\n- Line-based\n\nCheck out what's the most complex conflict you can come up with!"; rightTextArea.value = sampleText.replace(', for example,', ' such as').replace('WASM', 'WebAssembly') + '\n- Or your custom tokeniser'; } function updateMergedText(): void { resizeTextAreas(); const original = originalTextArea.value; const left = leftTextArea.value; const right = rightTextArea.value; const selectedTokenizer = getSelectedTokenizer(); const { leftCursors, rightCursors } = getCursorsFromActiveTextArea(); const results = reconcileWithHistory( original, { text: left, cursors: leftCursors, }, { text: right, cursors: rightCursors, }, selectedTokenizer ); let selectionStart: number = Number.NEGATIVE_INFINITY; let selectionEnd: number = Number.NEGATIVE_INFINITY; if ((results.cursors?.length ?? 0) > 0) { selectionStart = results.cursors![0].position; selectionEnd = results.cursors![1].position; } const isSelection = selectionStart !== selectionEnd; const selectionSide = leftCursors ? 'left' : 'right'; const fragment = document.createDocumentFragment(); let currentPosition = 0; if (selectionEnd === 0) { fragment.appendChild(createSelectionOverlay(selectionSide === 'left', isSelection)); } for (const { text, history } of results.history) { const isDelete = history === 'RemovedFromLeft' || history === 'RemovedFromRight'; let spanChars: string[] = []; let currentClass = ''; const flushSpan = () => { if (spanChars.length > 0) { const span = document.createElement('span'); span.className = currentClass; span.textContent = spanChars.join(''); fragment.appendChild(span); spanChars = []; } }; for (const character of text) { let className = history; if ( !isDelete && selectionStart <= currentPosition && currentPosition < selectionEnd ) { className += ` selection-${selectionSide}`; } if (className !== currentClass) { flushSpan(); currentClass = className; } spanChars.push(character); if (!isDelete) { if (currentPosition === selectionEnd - 1) { flushSpan(); fragment.appendChild( createSelectionOverlay(selectionSide === 'left', isSelection) ); } currentPosition++; } } flushSpan(); } mergedTextArea.innerHTML = ''; mergedTextArea.appendChild(fragment); } function getCursorsFromActiveTextArea() { const activeElement = document.activeElement; let leftCursors = undefined; let rightCursors = undefined; if (activeElement === leftTextArea) { leftCursors = [ { id: 1, position: leftTextArea.selectionStart }, { id: 2, position: leftTextArea.selectionEnd }, ]; } else if (activeElement === rightTextArea) { rightCursors = [ { id: 1, position: rightTextArea.selectionStart }, { id: 2, position: rightTextArea.selectionEnd }, ]; } return { leftCursors, rightCursors }; } function createSelectionOverlay(isLeft: boolean, isSelection: boolean): HTMLSpanElement { const caretSpan = document.createElement('span'); caretSpan.className = `selection-caret selection-caret-${isLeft ? 'left' : 'right'}`; const stickDiv = document.createElement('div'); stickDiv.className = 'stick'; caretSpan.appendChild(stickDiv); const dotDiv = document.createElement('div'); dotDiv.className = 'dot'; caretSpan.appendChild(dotDiv); const infoDiv = document.createElement('div'); infoDiv.className = 'info'; const selectionType = isSelection ? 'selection' : 'cursor'; infoDiv.textContent = isLeft ? `Left user's ${selectionType}` : `Right user's ${selectionType}`; caretSpan.appendChild(infoDiv); return caretSpan; } function getSelectedTokenizer(): BuiltinTokenizer { const selectedRadio = Array.from(tokenizerRadios).find((radio) => radio.checked); return (selectedRadio?.value ?? 'Markdown') as BuiltinTokenizer; } function resizeTextAreas(): void { // Only auto-resize if field-sizing CSS property is not supported, like in Safari as of now if (!CSS.supports('field-sizing', 'content')) { autoResize(originalTextArea); autoResize(leftTextArea); autoResize(rightTextArea); } } function autoResize(textarea: HTMLTextAreaElement): void { textarea.style.height = 'auto'; textarea.style.height = textarea.scrollHeight + 'px'; } function focusTextArea(textarea: HTMLTextAreaElement): void { textarea.focus(); textarea.selectionStart = 0; textarea.selectionEnd = 0; } main().catch((error) => { document.body.textContent = 'Failed to load the application. Please ensure your browser supports WebAssembly.'; console.error(error); });