218 lines
6.6 KiB
TypeScript
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);
|
|
});
|