reconcile/examples/website/src/index.ts

218 lines
6.6 KiB
TypeScript

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<HTMLInputElement>;
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<void> {
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();
}
// 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';
}
main().catch((error) => {
document.body.textContent =
'Failed to load the application. Please ensure your browser supports WebAssembly.';
console.error(error);
});