Merge pull request #5 from schmelczer/asch/better-api

Prepare to publish
This commit is contained in:
Andras Schmelczer 2025-07-06 22:43:58 +01:00 committed by GitHub
commit 752f685a7f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 12171 additions and 5421 deletions

View file

@ -5,17 +5,17 @@
version: 2 version: 2
updates: updates:
- package-ecosystem: "cargo" - package-ecosystem: 'cargo'
directories: ["**"] directories: ['**']
schedule: schedule:
interval: "daily" interval: 'daily'
- package-ecosystem: "github-actions" - package-ecosystem: 'github-actions'
directories: ["**"] directories: ['**']
schedule: schedule:
interval: "daily" interval: 'daily'
- package-ecosystem: "npm" - package-ecosystem: 'npm'
directories: ["/reconcile-js"] directories: ['/reconcile-js', '/examples/website']
schedule: schedule:
interval: "daily" interval: 'daily'

10
.prettierrc Normal file
View file

@ -0,0 +1,10 @@
{
"trailingComma": "es5",
"printWidth": 90,
"tabWidth": 2,
"singleQuote": true,
"endOfLine": "lf",
"importOrder": ["^[./]", ".*", ".scss$"],
"importOrderSeparation": true,
"importOrderSortSpecifiers": true
}

View file

@ -9,4 +9,4 @@
"rust-analyzer.cargo.features": [ "rust-analyzer.cargo.features": [
"all" "all"
] ]
} }

View file

@ -34,7 +34,7 @@ console_error_panic_hook = [ "dep:console_error_panic_hook" ]
insta = "1.42.2" insta = "1.42.2"
pretty_assertions = "1.4.1" pretty_assertions = "1.4.1"
serde = { version = "1.0.219", features = ["derive"] } serde = { version = "1.0.219", features = ["derive"] }
serde_yaml ="0.9.34" serde_yaml = "0.9.34"
test-case = "3.3.1" test-case = "3.3.1"
wasm-bindgen-test = "0.3.49" wasm-bindgen-test = "0.3.49"
@ -42,7 +42,7 @@ wasm-bindgen-test = "0.3.49"
codegen-units = 1 codegen-units = 1
lto = true lto = true
opt-level = 3 opt-level = 3
strip="symbols" strip = "symbols"
[package.metadata.wasm-pack.profile.release] [package.metadata.wasm-pack.profile.release]
wasm-opt = ['-O4', '--enable-bulk-memory'] wasm-opt = ['-O4', '--enable-bulk-memory']
@ -84,7 +84,7 @@ large_stack_arrays = { level = "allow", priority = 1 } # https://github.com/rust
# Silly lints # Silly lints
implicit_return = { level = "allow", priority = 1 } implicit_return = { level = "allow", priority = 1 }
question_mark_used = { level = "allow", priority = 1 } question_mark_used = { level = "allow", priority = 1 }
struct_field_names = { level = "allow", priority = 1 } struct_field_names = { level = "allow", priority = 1 }
single_char_lifetime_names = { level = "allow", priority = 1 } single_char_lifetime_names = { level = "allow", priority = 1 }
single_call_fn = { level = "allow", priority = 1 } single_call_fn = { level = "allow", priority = 1 }

View file

@ -1,161 +1,213 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta <meta
name="viewport" name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover" content="width=device-width, initial-scale=1.0, viewport-fit=cover"
/> />
<meta <meta
name="description" name="description"
content="Easily merge three versions of a text document with this 3-way text merge tool." content="Easily merge three versions of a text document with this 3-way text merge tool."
/> />
<meta property="og:title" content="3-Way Text Merge" /> <meta property="og:title" content="3-Way Text Merge" />
<meta <meta
property="og:description" property="og:description"
content="Easily merge three versions of a text document with this 3-way text merge tool." content="Easily merge three versions of a text document with this 3-way text merge tool."
/> />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta <meta property="og:url" content="https://github.com/schmelczer/reconcile" />
property="og:url" <meta property="og:image" content="/favicon.ico" />
content="https://github.com/schmelczer/reconcile" <link rel="icon" type="image/x-icon" href="favicon.ico" />
/> <title>3-Way Text Merge</title>
<meta property="og:image" content="/favicon.ico" /> <link inline inline-asset="index.css" inline-asset-delete />
<link rel="icon" type="image/x-icon" href="favicon.ico" /> </head>
<title>3-Way Text Merge</title> <body>
<link rel="stylesheet" href="style.css" /> <div class="background"></div>
</head>
<body>
<div class="background"></div>
<div class="page-wrapper"> <div class="scroll-container">
<header> <div class="page-wrapper">
<h1>3-Way Text Merge</h1> <header>
<p> <h1>Reconcile: automated 3-way text merge</h1>
The <p>
<a The
href="https://github.com/schmelczer/reconcile" <a
target="_blank" href="https://github.com/schmelczer/reconcile"
>reconcile</a target="_blank"
> rel="noopener noreferrer"
solves a fundamental challenge in collaborative editing: >reconcile</a
what happens when multiple people edit the same text >
simultaneously? library solves a fundamental challenge in collaborative editing: what happens
<code when multiple users edit the same text simultaneously but we can only capture
>reconcile(parent: str, left: str, right: str) -> the end result, not the intermediary edits? Essentially, it's
str</code <a
> href="https://www.gnu.org/software/diffutils/manual/html_node/Invoking-diff3.html"
takes conflicting concurrent edits and intelligently merges target="_blank"
them into a unified result. Beyond basic conflict rel="noopener noreferrer"
resolution, it offers sophisticated merging heuristics, >diff3</a
flexible tokenization options, and cursor position tracking. >
</p> (or <code>git merge</code>) but with automatic conflict resolution.
<p> </p>
The algorithm begins with your chosen tokenizer, then <p>
applies Myers' diff algorithm to compare the original text The
with both conflicting versions. These diffs undergo <code>reconcile(parent: str, left: str, right: str) -> str</code>
transformation to preserve meaningful change sequences, takes conflicting concurrent edits and intelligently merges them into a
before a final merge strategy—inspired by Operational unified result. Beyond basic conflict resolution, it offers sophisticated
Transformation (OT)—reconciles all conflicting modifications merging heuristics, flexible tokenization options, and cursor position
without losing any edits. tracking.
</p> </p>
<p> <p>
For more details, see the The algorithm begins with your chosen tokenizer, then applies Myers' diff
<a algorithm to compare the original text with both conflicting versions. These
href="https://github.com/schmelczer/reconcile" diffs undergo transformation to preserve meaningful change sequences, before a
target="_blank" final merge strategy—inspired by Operational Transformation reconciles all
>README</a conflicting modifications without losing any edits.
>. </p>
</p> <p>
</header> For more details, see the
<a href="https://github.com/schmelczer/reconcile" target="_blank">README</a>.
</p>
<main> <p>
<div class="text-area-card diamond-parent"> Use the tokenization options below to experiment with different strategies.
<label The library supports user-defined tokenizers as well.
for="original" </p>
title="The text document's content before any concurrent edits occurred." </header>
>Original</label
> <main>
<textarea id="original" name="original"></textarea> <section class="tokenizer-selector">
<div class="radio-group" role="radiogroup" aria-label="Tokenization strategy">
<label class="radio-option">
<input
type="radio"
name="tokenizer"
value="Character"
id="tokenizer-character"
/>
<span class="radio-custom" aria-hidden="true"></span>
<div class="radio-content">
<span class="radio-label">Character</span>
<span class="radio-description">Split by individual characters</span>
</div> </div>
</label>
<div class="text-area-card diamond-left"> <label class="radio-option">
<label <input
for="left" type="radio"
title="Colour-coded tokens mark the origin of each token in the result. This text box is marked with the colour green." name="tokenizer"
> value="Word"
First concurrent edit id="tokenizer-word"
<div class="box Left"></div> checked
</label> />
<textarea id="left" name="left"></textarea> <span class="radio-custom" aria-hidden="true"></span>
<div class="radio-content">
<span class="radio-label">Word</span>
<span class="radio-description">Split by words (default)</span>
</div> </div>
</label>
<div class="text-area-card diamond-right"> <label class="radio-option">
<label <input type="radio" name="tokenizer" value="Line" id="tokenizer-line" />
for="right" <span class="radio-custom" aria-hidden="true"></span>
title="Colour-coded tokens mark the origin of each token in the result. This text box is marked with the colour blue." <div class="radio-content">
> <span class="radio-label">Line</span>
Second concurrent edit <span class="radio-description"
<div class="box Right"></div> >Split by lines similarly to <code>git merge</code></span
</label> >
<textarea id="right" name="right"></textarea>
</div> </div>
</label>
</div>
</section>
<div class="text-area-card diamond-result"> <div class="text-area-card diamond-parent">
<label <label
title="Read-only. Change the above text boxes to change the content of this box." for="original"
> title="The text document's content before any concurrent edits occurred."
Deconflicted result >Original</label
<svg >
xmlns="http://www.w3.org/2000/svg" <textarea id="original" name="original"></textarea>
width="24" </div>
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M10 10l-6 6v4h4l6 -6m1.99 -1.99l2.504 -2.504a2.828 2.828 0 1 0 -4 -4l-2.5 2.5"
/>
<path d="M13.5 6.5l4 4" />
<path d="M3 3l18 18" />
</svg>
</label>
<div id="merged"></div>
</div>
</main>
<footer> <div class="text-area-card diamond-left">
<p>2025 Andras Schmelczer</p> <label
<a for="left"
href="https://github.com/schmelczer/reconcile" title="Colour-coded tokens mark the origin of each token in the result. This text box is marked with the colour green."
class="github-link" >
aria-label="GitHub repository" First concurrent edit
> <div class="box Left"></div>
<svg </label>
xmlns="http://www.w3.org/2000/svg" <textarea id="left" name="left"></textarea>
width="24" </div>
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M9 19c-4.3 1.4 -4.3 -2.5 -6 -3m12 5v-3.5c0 -1 .1 -1.4 -.5 -2c2.8 -.3 5.5 -1.4 5.5 -6a4.6 4.6 0 0 0 -1.3 -3.2a4.2 4.2 0 0 0 -.1 -3.2s-1.1 -.3 -3.5 1.3a12.3 12.3 0 0 0 -6.2 0c-2.4 -1.6 -3.5 -1.3 -3.5 -1.3a4.2 4.2 0 0 0 -.1 3.2a4.6 4.6 0 0 0 -1.3 3.2c0 4.6 2.7 5.7 5.5 6c-.6 .6 -.6 1.2 -.5 2v3.5"
/>
</svg>
</a>
</footer>
</div>
<script type="module" src="script.js"></script> <div class="text-area-card diamond-right">
</body> <label
for="right"
title="Colour-coded tokens mark the origin of each token in the result. This text box is marked with the colour blue."
>
Second concurrent edit
<div class="box Right"></div>
</label>
<textarea id="right" name="right"></textarea>
</div>
<div class="text-area-card diamond-result">
<label
for="merged"
title="Read-only. Change the above text boxes to change the content of this box."
>
Deconflicted result
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path
d="M10 10l-6 6v4h4l6 -6m1.99 -1.99l2.504 -2.504a2.828 2.828 0 1 0 -4 -4l-2.5 2.5"
></path>
<path d="M13.5 6.5l4 4"></path>
<path d="M3 3l18 18"></path>
</svg>
</label>
<div id="merged" role="textbox" aria-readonly="true" aria-live="polite"></div>
</div>
</main>
<footer>
<p>2025 Andras Schmelczer</p>
<a
href="https://github.com/schmelczer/reconcile"
class="github-link"
aria-label="GitHub repository"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M9 19c-4.3 1.4 -4.3 -2.5 -6 -3m12 5v-3.5c0 -1 .1 -1.4 -.5 -2c2.8 -.3 5.5 -1.4 5.5 -6a4.6 4.6 0 0 0 -1.3 -3.2a4.2 4.2 0 0 0 -.1 -3.2s-1.1 -.3 -3.5 1.3a12.3 12.3 0 0 0 -6.2 0c-2.4 -1.6 -3.5 -1.3 -3.5 -1.3a4.2 4.2 0 0 0 -.1 3.2a4.6 4.6 0 0 0 -1.3 3.2c0 4.6 2.7 5.7 5.5 6c-.6 .6 -.6 1.2 -.5 2v3.5"
/>
</svg>
</a>
</footer>
</div>
</div>
<noscript>JavaScript is required for this website to function properly.</noscript>
<script inline inline-asset="index.js" inline-asset-delete></script>
</body>
</html> </html>

88
examples/website/index.ts Normal file
View file

@ -0,0 +1,88 @@
import { init, reconcileWithHistory } from 'reconcile';
import type { Tokenizer } from 'reconcile';
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\` Rust library is embedded on this page as a WASM module and powers these text boxes. Experiment with changing the "Original", "First concurrent edit", and "Second concurrent edit" text boxes to see competing changes get merged in real-time within the "Deconflicted 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 tokenization strategy, for example, deciding how casing or whitespace is taken into account.`;
async function main(): Promise<void> {
await init();
originalTextArea.addEventListener('input', updateMergedText);
leftTextArea.addEventListener('input', updateMergedText);
rightTextArea.addEventListener('input', updateMergedText);
window.addEventListener('resize', resizeTextAreas);
tokenizerRadios.forEach((radio) => {
radio.addEventListener('change', updateMergedText);
});
loadSample();
updateMergedText();
focusTextArea(leftTextArea);
}
function loadSample(): void {
originalTextArea.value = sampleText;
leftTextArea.value =
sampleText.replace('color', 'colour') +
" Check out what's the most complex conflict you can come up with!";
rightTextArea.value = sampleText
.replace(', for example,', ' such as')
.replace('WASM', 'WebAssembly');
}
function updateMergedText(): void {
resizeTextAreas();
const original = originalTextArea.value;
const left = leftTextArea.value;
const right = rightTextArea.value;
const selectedTokenizer = getSelectedTokenizer();
const results = reconcileWithHistory(original, left, right, selectedTokenizer);
mergedTextArea.innerHTML = '';
for (const { text, history } of results.history) {
const span = document.createElement('span');
span.className = history;
span.textContent = text;
mergedTextArea.appendChild(span);
}
}
function getSelectedTokenizer(): Tokenizer {
const selectedRadio = Array.from(tokenizerRadios).find((radio) => radio.checked);
return selectedRadio?.value as Tokenizer;
}
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 = textarea.value.length;
textarea.selectionEnd = textarea.value.length;
}
main();

5555
examples/website/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,48 @@
{
"name": "portfolio",
"description": "An easily configurable timeline of projects.",
"private": true,
"scripts": {
"start": "webpack serve --open --mode development",
"format": "prettier --write \"src/**/*.(ts|scss|json|html)\"",
"build": "webpack --mode production"
},
"repository": {
"type": "git",
"url": "git+https://github.com/schmelczer/schmelczer.github.io.git"
},
"keywords": [
"CV",
"curriculum",
"vitae",
"portfolio",
"resumé"
],
"author": "Andras Schmelczer",
"license": "GPL-3.0-or-later",
"bugs": {
"url": "https://github.com/schmelczer/schmelczer.github.io/issues"
},
"browserslist": [
"defaults"
],
"homepage": "https://github.com/schmelczer/schmelczer.github.io#readme",
"devDependencies": {
"reconcile": "file:../../reconcile-js",
"css-loader": "^7.1.2",
"html-webpack-plugin": "^5.6.3",
"inline-source-webpack-plugin": "^3.0.1",
"mini-css-extract-plugin": "^2.9.2",
"prettier": "^3.6.2",
"resolve-url-loader": "^5.0.0",
"sass": "^1.89.2",
"sass-loader": "^16.0.5",
"svg-inline-loader": "^0.8.2",
"terser-webpack-plugin": "^5.3.14",
"ts-loader": "^9.5.2",
"typescript": "^5.8.3",
"webpack": "^5.99.9",
"webpack-cli": "^6.0.1",
"webpack-dev-server": "^5.2.2"
}
}

View file

@ -1,71 +0,0 @@
import { init, reconcileWithHistory } from "./dist/index.js";
const originalTextArea = document.getElementById("original");
const leftTextArea = document.getElementById("left");
const rightTextArea = document.getElementById("right");
const mergedTextArea = document.getElementById("merged");
const sampleText = `The \`reconcile\` Rust library is embedded on this page a WASM module and it powers these text boxes. Experiment with the "Original", "First concurrent edit", and "Second concurrent edit" text boxes to watch competing changes merge in real-time within the "Deconflicted 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 tokenization strategy, for example, deciding how casing or white-spacing is taken into account.`;
async function main() {
await init();
originalTextArea.addEventListener("input", updateMergedText);
leftTextArea.addEventListener("input", updateMergedText);
rightTextArea.addEventListener("input", updateMergedText);
window.addEventListener("resize", resizeTextAreas);
loadSample();
updateMergedText();
focusTextArea(leftTextArea);
}
function loadSample() {
originalTextArea.value = sampleText;
leftTextArea.value =
sampleText.replace("color", "colour") +
" Check out what's the most complex conflict you can come up with!";
rightTextArea.value = sampleText
.replace(", for example,", " such as")
.replace("WASM", "WebAssembly");
}
function updateMergedText() {
resizeTextAreas();
const original = originalTextArea.value;
const left = leftTextArea.value;
const right = rightTextArea.value;
const results = reconcileWithHistory(original, left, right);
mergedTextArea.innerHTML = "";
for (const {text, history} of results.history) {
const span = document.createElement("span");
span.className = history;
span.textContent = text;
mergedTextArea.appendChild(span);
}
}
function resizeTextAreas() {
if (!CSS.supports("field-sizing", "content")) {
autoResize(originalTextArea);
autoResize(leftTextArea);
autoResize(rightTextArea);
}
}
function autoResize(textarea) {
textarea.style.height = "auto";
textarea.style.height = textarea.scrollHeight + "px";
}
function focusTextArea(textarea) {
textarea.focus();
textarea.selectionStart = textarea.value.length;
textarea.selectionEnd = textarea.value.length;
}
main();

View file

@ -1,241 +0,0 @@
* {
box-sizing: border-box;
margin: 0;
user-select: none;
}
html,
body {
height: 100%;
}
body {
font-family: "Segoe UI", Arial, sans-serif;
color: #23272f;
overflow-y: auto;
}
.background {
background: linear-gradient(135deg, #f8fafc 0%, #e0e7ef 100%);
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: -1;
}
.page-wrapper {
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 100%;
max-width: 1000px;
margin: 0 auto;
}
header {
padding: 32px 32px 0 32px;
}
header > h1 {
font-size: 2.5rem;
font-weight: 700;
color: #2451a6;
margin-bottom: 24px;
text-align: center;
}
p,
p * {
user-select: text;
}
header > p {
color: #5a6272;
font-size: 1.1rem;
margin-bottom: 0;
}
header > p:not(:first-of-type) {
margin-top: 16px;
}
main {
display: grid;
grid-template-rows: min-content;
grid-template-columns: 1fr 1fr;
gap: 20px;
justify-items: center;
align-items: center;
padding: 32px;
}
.diamond-parent {
grid-column: 1 / -1;
}
.diamond-left {
grid-column: 1;
grid-row: 2;
}
.diamond-right {
grid-column: 2;
grid-row: 2;
}
.diamond-result {
grid-column: 1 / -1;
grid-row: 3;
}
.diamond-result label {
display: flex;
align-items: center;
}
.diamond-result svg {
width: 20px;
height: 20px;
margin-left: 8px;
}
.text-area-card {
width: 100%;
height: 100%;
background: #fff;
border-radius: 10px;
box-shadow: 0 2px 12px 0 rgba(36, 81, 166, 0.06);
padding: 18px 20px 16px 20px;
margin-bottom: 0;
}
label {
display: inline-block;
margin-bottom: 8px;
font-weight: 600;
color: #2451a6;
cursor: help;
}
.box {
width: 1ch;
height: 1ch;
border-radius: 50%;
margin-left: 6px;
display: inline-block;
transform: scale(1.5);
}
textarea {
width: 100%;
border: none;
font-size: 1rem;
font-family: inherit;
color: #23272f;
box-sizing: border-box;
resize: none;
outline: none;
margin-bottom: 0;
field-sizing: content; /* Doesn't work in Safari yet */
}
#merged {
width: 100%;
user-select: text;
}
.Unchanged {
user-select: text;
}
.AddedFromLeft,
.RemovedFromLeft {
user-select: text;
background: #12d197;
}
.Right,
.AddedFromRight,
.RemovedFromRight {
user-select: text;
background: #85bff7;
}
.RemovedFromLeft,
.RemovedFromRight {
user-select: none;
text-decoration: line-through;
}
@media (max-width: 900px) {
header {
padding: 32px 18px 0 18px;
}
header > h1 {
margin-bottom: 18px;
}
header > p {
font-size: 1rem;
}
main {
padding: 18px;
}
}
@media (max-width: 768px) {
main {
grid-template-columns: 1fr;
grid-template-rows: auto auto auto auto;
}
.diamond-parent {
grid-column: 1;
grid-row: 1;
}
.diamond-left {
grid-column: 1;
grid-row: 2;
}
.diamond-right {
grid-column: 1;
grid-row: 3;
}
.diamond-result {
grid-column: 1;
grid-row: 4;
}
}
footer {
padding: 16px;
width: 100%;
position: relative;
display: flex;
justify-content: center;
align-items: center;
color: #5a6272;
}
.github-link > svg {
position: absolute;
color: #5a6272;
top: 50%;
right: 36px;
transform: translateY(-50%);
width: 32px;
height: 32px;
transition: transform 0.2s;
}
.github-link > svg:hover {
cursor: pointer;
transform: translateY(-50%) scale(1.15);
}

359
examples/website/style.scss Normal file
View file

@ -0,0 +1,359 @@
* {
box-sizing: border-box;
margin: 0;
user-select: none;
}
html,
body {
height: 100%;
}
body {
font-family: 'Segoe UI', Arial, sans-serif;
color: #23272f;
}
.scroll-container {
height: 100vh;
height: 100dvh;
overflow-y: auto;
}
.background {
background: linear-gradient(135deg, #f8fafc 0%, #e0e7ef 100%);
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: -1;
}
.page-wrapper {
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 100%;
max-width: 1000px;
margin: 0 auto;
}
header {
padding: 32px 32px 0 32px;
}
header > h1 {
font-size: 2.5rem;
font-weight: 700;
color: #2451a6;
margin-bottom: 24px;
text-align: center;
}
p,
p * {
user-select: text;
}
header > p {
color: #5a6272;
font-size: 1.1rem;
margin-bottom: 0;
}
header > p:not(:first-of-type) {
margin-top: 16px;
}
main {
display: grid;
grid-template-rows: min-content min-content min-content;
grid-template-columns: 1fr 1fr;
gap: 20px;
justify-items: center;
align-items: center;
padding: 32px;
}
.tokenizer-selector {
grid-column: 1 / -1;
grid-row: 1;
width: 100%;
margin-bottom: 8px;
}
.radio-group {
display: flex;
gap: 16px;
justify-content: center;
flex-wrap: wrap;
}
.radio-option {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(36, 81, 166, 0.08);
cursor: pointer;
transition: all 0.2s ease;
border: 2px solid transparent;
min-width: 180px;
position: relative;
}
.radio-option:hover {
box-shadow: 0 4px 16px rgba(36, 81, 166, 0.12);
transform: translateY(-2px);
}
.radio-option:has(input:checked) {
background: #f0f7ff;
border-color: #2451a6;
box-shadow: 0 4px 16px rgba(36, 81, 166, 0.16);
}
.radio-option input[type='radio'] {
position: absolute;
opacity: 0;
pointer-events: none;
}
.radio-custom {
width: 20px;
height: 20px;
border: 2px solid #d1d5db;
border-radius: 50%;
position: relative;
transition: all 0.2s ease;
flex-shrink: 0;
}
.radio-option:has(input:checked) .radio-custom {
border-color: #2451a6;
background: #2451a6;
}
.radio-custom::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0);
width: 8px;
height: 8px;
border-radius: 50%;
background: white;
transition: transform 0.2s ease;
}
.radio-option:has(input:checked) .radio-custom::after {
transform: translate(-50%, -50%) scale(1);
}
.radio-content {
display: flex;
flex-direction: column;
gap: 2px;
}
.radio-label {
font-weight: 600;
color: #2451a6;
font-size: 0.95rem;
}
.radio-description {
font-size: 0.8rem;
color: #6b7280;
line-height: 1.2;
}
.diamond-parent {
grid-column: 1 / -1;
grid-row: 2;
}
.diamond-left {
grid-column: 1;
grid-row: 3;
}
.diamond-right {
grid-column: 2;
grid-row: 3;
}
.diamond-result {
grid-column: 1 / -1;
grid-row: 4;
}
.diamond-result label {
display: flex;
align-items: center;
}
.diamond-result svg {
width: 20px;
height: 20px;
margin-left: 8px;
}
.text-area-card {
width: 100%;
height: 100%;
background: #fff;
border-radius: 10px;
box-shadow: 0 2px 12px 0 rgba(36, 81, 166, 0.06);
padding: 18px 20px 16px 20px;
margin-bottom: 0;
}
label {
display: inline-block;
margin-bottom: 8px;
font-weight: 600;
color: #2451a6;
cursor: help;
}
.box {
width: 1ch;
height: 1ch;
border-radius: 50%;
margin-left: 6px;
display: inline-block;
transform: scale(1.5);
}
textarea {
width: 100%;
border: none;
font-size: 1rem;
font-family: inherit;
color: #23272f;
box-sizing: border-box;
resize: none;
outline: none;
margin-bottom: 0;
field-sizing: content; /* Doesn't work in Safari yet */
}
#merged {
width: 100%;
user-select: text;
}
.Unchanged {
user-select: text;
}
.Left,
.AddedFromLeft,
.RemovedFromLeft {
user-select: text;
background: #12d197;
}
.Right,
.AddedFromRight,
.RemovedFromRight {
user-select: text;
background: #85bff7;
}
.RemovedFromLeft,
.RemovedFromRight {
user-select: none;
text-decoration: line-through;
}
@media (max-width: 900px) {
header {
padding: 32px 18px 0 18px;
}
header > h1 {
margin-bottom: 18px;
}
header > p {
font-size: 1rem;
}
main {
padding: 18px;
}
}
@media (max-width: 768px) {
main {
grid-template-columns: 1fr;
grid-template-rows: auto auto auto auto auto;
}
.tokenizer-selector {
grid-column: 1;
grid-row: 1;
}
.diamond-parent {
grid-column: 1;
grid-row: 2;
}
.diamond-left {
grid-column: 1;
grid-row: 3;
}
.diamond-right {
grid-column: 1;
grid-row: 4;
}
.radio-group {
flex-direction: column;
gap: 12px;
}
.radio-option {
min-width: unset;
width: 100%;
}
.diamond-result {
grid-column: 1;
grid-row: 5;
}
}
footer {
padding: 16px;
width: 100%;
position: relative;
display: flex;
justify-content: center;
align-items: center;
color: #5a6272;
}
.github-link > svg {
position: absolute;
color: #5a6272;
top: 50%;
right: 36px;
transform: translateY(-50%);
width: 32px;
height: 32px;
transition: transform 0.2s;
}
.github-link > svg:hover {
cursor: pointer;
transform: translateY(-50%) scale(1.15);
}

View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"module": "ESNext",
"target": "ESNext",
"strict": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"moduleResolution": "bundler",
"declaration": true,
"declarationDir": "./dist/types",
"outDir": "./dist",
"rootDir": ".",
"skipLibCheck": true,
"inlineSourceMap": true
},
"exclude": [
"./dist"
]
}

View file

@ -0,0 +1,81 @@
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const TerserPlugin = require('terser-webpack-plugin');
const InlineSourceWebpackPlugin = require('inline-source-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = (_env, argv) => ({
devtool: argv.mode === 'development' ? 'inline-source-map' : false,
entry: {
index: './index.ts',
},
devServer: {
allowedHosts: 'all',
},
watchOptions: {
ignored: '**/node_modules',
},
optimization: {
minimizer: [
new TerserPlugin({
terserOptions: {
module: true,
},
}),
],
},
performance: {
assetFilter: (f) => !/\.(webm|mp4|pdf)$/.test(f),
maxEntrypointSize: 100000,
maxAssetSize: 512000,
},
plugins: [
new HtmlWebpackPlugin({
template: './index.html',
}),
new MiniCssExtractPlugin(),
argv.mode === 'production'
? new InlineSourceWebpackPlugin({
compress: true,
})
: null,
].filter(Boolean),
module: {
rules: [
{
test: /\.svg$/i,
use: 'svg-inline-loader',
},
{
test: /\.scss$/i,
use: [
MiniCssExtractPlugin.loader,
'css-loader',
'resolve-url-loader',
{
loader: 'sass-loader',
options: {
sourceMap: true, // required by resolve-url-loader
},
},
],
},
{
test: /\.ts$/,
use: 'ts-loader',
},
],
},
resolve: {
extensions: [
'.ts',
'.js', // required for development
],
},
output: {
clean: true,
filename: '[name].js',
path: path.resolve(__dirname, 'dist'),
publicPath: '',
},
});

6
package-lock.json generated Normal file
View file

@ -0,0 +1,6 @@
{
"name": "reconcile",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

File diff suppressed because it is too large Load diff

View file

@ -1,24 +1,24 @@
{ {
"name": "reconcile", "name": "reconcile",
"version": "0.4.0", "version": "0.4.0",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/types/index.d.ts", "types": "dist/types/index.d.ts",
"files": [ "files": [
"dist/*" "dist/*"
], ],
"scripts": { "scripts": {
"build": "webpack --mode production", "build": "webpack --mode production",
"test": "NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" jest" "test": "NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" jest"
}, },
"devDependencies": { "devDependencies": {
"@types/jest": "^29.5.14", "@types/jest": "^30.0.0",
"jest": "^29.7.0", "jest": "^30.0.4",
"reconcile": "file:../pkg", "reconcile": "file:../pkg",
"ts-jest": "^29.3.4", "ts-jest": "^29.4.0",
"ts-loader": "^9.5.2", "ts-loader": "^9.5.2",
"tslib": "2.8.1", "tslib": "2.8.1",
"typescript": "5.8.3", "typescript": "5.8.3",
"webpack": "^5.99.9", "webpack": "^5.99.9",
"webpack-cli": "^6.0.1" "webpack-cli": "^6.0.1"
} }
} }

View file

@ -5,9 +5,5 @@ set -e
wasm-pack build --target web --features wasm,wee_alloc wasm-pack build --target web --features wasm,wee_alloc
cd reconcile-js cd reconcile-js
npm run build npm run build
mkdir -p ../examples/website/dist
cp -R dist/index.js ../examples/website/dist/index.js
cd ../examples/website cd ../examples/website
npm run start
python3 -m http.server $1 --bind 0.0.0.0

View file

@ -54,8 +54,8 @@
//! .map(|sentence| Token::new( //! .map(|sentence| Token::new(
//! sentence.to_string(), //! sentence.to_string(),
//! sentence.to_string(), //! sentence.to_string(),
//! false, // don't allow joining token with the preceeding on //! false, // don't allow joining token with the preceding one
//! false // don't allow joining token with the following one //! false, // don't allow joining token with the following one
//! )) //! ))
//! .collect::<Vec<_>>() //! .collect::<Vec<_>>()
//! }; //! };
@ -68,7 +68,7 @@
//! let result = reconcile(parent, &left.into(), &right.into(), &*BuiltinTokenizer::Word); //! let result = reconcile(parent, &left.into(), &right.into(), &*BuiltinTokenizer::Word);
//! assert_eq!(result.apply().text(), "Hello beautiful world. This is a great test."); //! assert_eq!(result.apply().text(), "Hello beautiful world. This is a great test.");
//! ``` //! ```
//! > By setting the joinability to `false`, longer runs of inserts with be //! > By setting the joinability to `false`, longer runs of inserts will be
//! > interleaved like LRLRLR instead of LLLRRR. //! > interleaved like LRLRLR instead of LLLRRR.
//! //!
//! ## Cursors and selection ranges //! ## Cursors and selection ranges

View file

@ -3,9 +3,9 @@ use std::fmt::Debug;
#[cfg(feature = "serde")] #[cfg(feature = "serde")]
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// A token is a string that has been normalised in some way. /// A token is a string that has been normalized in some way.
/// ///
/// A token consists of the normalised form is used for comparison, and the /// A token consists of the normalized form is used for comparison, and the
/// original form used for subsequently applying `Operation`-s to a text /// original form used for subsequently applying `Operation`-s to a text
/// document. /// document.
/// ///
@ -16,8 +16,8 @@ pub struct Token<T>
where where
T: PartialEq + Clone + Debug, T: PartialEq + Clone + Debug,
{ {
/// The normalised form of the token used deriving the diff. /// The normalized form of the token used deriving the diff.
normalised: T, normalized: T,
/// The original string, that should be inserted or deleted in the document. /// The original string, that should be inserted or deleted in the document.
original: String, original: String,
@ -29,7 +29,7 @@ where
pub is_right_joinable: bool, pub is_right_joinable: bool,
} }
/// Trivial implementation of Token when the normalised form is the same as the /// Trivial implementation of Token when the normalized form is the same as the
/// original string. /// original string.
impl From<&str> for Token<String> { impl From<&str> for Token<String> {
fn from(text: &str) -> Self { Token::new(text.to_owned(), text.to_owned(), true, true) } fn from(text: &str) -> Self { Token::new(text.to_owned(), text.to_owned(), true, true) }
@ -40,13 +40,13 @@ where
T: PartialEq + Clone + Debug, T: PartialEq + Clone + Debug,
{ {
pub fn new( pub fn new(
normalised: T, normalized: T,
original: String, original: String,
is_left_joinable: bool, is_left_joinable: bool,
is_right_joinable: bool, is_right_joinable: bool,
) -> Self { ) -> Self {
Token { Token {
normalised, normalized,
original, original,
is_left_joinable, is_left_joinable,
is_right_joinable, is_right_joinable,
@ -55,9 +55,9 @@ where
pub fn original(&self) -> &str { &self.original } pub fn original(&self) -> &str { &self.original }
pub fn set_normalised(&mut self, normalised: T) { self.normalised = normalised; } pub fn set_normalized(&mut self, normalized: T) { self.normalized = normalized; }
pub fn normalised(&self) -> &T { &self.normalised } pub fn normalized(&self) -> &T { &self.normalized }
pub fn get_original_length(&self) -> usize { self.original.chars().count() } pub fn get_original_length(&self) -> usize { self.original.chars().count() }
} }
@ -66,5 +66,5 @@ impl<T> PartialEq for Token<T>
where where
T: PartialEq + Clone + Debug, T: PartialEq + Clone + Debug,
{ {
fn eq(&self, other: &Self) -> bool { self.normalised == other.normalised } fn eq(&self, other: &Self) -> bool { self.normalized == other.normalized }
} }

View file

@ -1,7 +1,7 @@
use super::token::Token; use super::token::Token;
/// Splits text on word boundaries creating tokens of alternating words and /// Splits text on word boundaries, creating tokens of alternating words and
/// whitespaces with the whitespaces getting unique IDs. /// whitespace with the whitespace getting unique IDs.
/// ///
/// ## Example /// ## Example
/// ///
@ -9,7 +9,7 @@ use super::token::Token;
/// "Hi there!" -> ["Hi", " ", "there!"] /// "Hi there!" -> ["Hi", " ", "there!"]
/// ``` /// ```
pub fn word_tokenizer(text: &str) -> Vec<Token<String>> { pub fn word_tokenizer(text: &str) -> Vec<Token<String>> {
let mut result: Vec<Token<String>> = Vec::new(); let mut result = Vec::new();
let mut previous_boundary_index = 0; let mut previous_boundary_index = 0;
let mut previous_char_is_whitespace = text.chars().next().is_none_or(char::is_whitespace); let mut previous_char_is_whitespace = text.chars().next().is_none_or(char::is_whitespace);
@ -32,10 +32,11 @@ pub fn word_tokenizer(text: &str) -> Vec<Token<String>> {
return result; return result;
} }
// normalize whitespace tokens by concatenating with the following token
for i in 0..result.len() - 1 { for i in 0..result.len() - 1 {
if result[i].original().chars().all(char::is_whitespace) { if result[i].original().chars().all(char::is_whitespace) {
let normalised = result[i].normalised().to_owned() + result[i + 1].original(); let normalized = result[i].normalized().to_owned() + result[i + 1].original();
result[i].set_normalised(normalised); result[i].set_normalized(normalized);
} }
} }

View file

@ -44,7 +44,7 @@ where
let max_d = (old.len() + new.len()).div_ceil(2) + 1; let max_d = (old.len() + new.len()).div_ceil(2) + 1;
let mut vb = V::new(max_d); let mut vb = V::new(max_d);
let mut vf = V::new(max_d); let mut vf = V::new(max_d);
let mut result: Vec<RawOperation<T>> = vec![]; let mut result = Vec::new();
conquer( conquer(
old, old,