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

@ -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']

View file

@ -1,4 +1,4 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
@ -16,60 +16,107 @@
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"
content="https://github.com/schmelczer/reconcile"
/>
<meta property="og:image" content="/favicon.ico" /> <meta property="og:image" content="/favicon.ico" />
<link rel="icon" type="image/x-icon" href="favicon.ico" /> <link rel="icon" type="image/x-icon" href="favicon.ico" />
<title>3-Way Text Merge</title> <title>3-Way Text Merge</title>
<link rel="stylesheet" href="style.css" /> <link inline inline-asset="index.css" inline-asset-delete />
</head> </head>
<body> <body>
<div class="background"></div> <div class="background"></div>
<div class="scroll-container">
<div class="page-wrapper"> <div class="page-wrapper">
<header> <header>
<h1>3-Way Text Merge</h1> <h1>Reconcile: automated 3-way text merge</h1>
<p> <p>
The The
<a <a
href="https://github.com/schmelczer/reconcile" href="https://github.com/schmelczer/reconcile"
target="_blank" target="_blank"
rel="noopener noreferrer"
>reconcile</a >reconcile</a
> >
solves a fundamental challenge in collaborative editing: library solves a fundamental challenge in collaborative editing: what happens
what happens when multiple people edit the same text when multiple users edit the same text simultaneously but we can only capture
simultaneously? the end result, not the intermediary edits? Essentially, it's
<code <a
>reconcile(parent: str, left: str, right: str) -> href="https://www.gnu.org/software/diffutils/manual/html_node/Invoking-diff3.html"
str</code target="_blank"
rel="noopener noreferrer"
>diff3</a
> >
takes conflicting concurrent edits and intelligently merges (or <code>git merge</code>) but with automatic conflict resolution.
them into a unified result. Beyond basic conflict
resolution, it offers sophisticated merging heuristics,
flexible tokenization options, and cursor position tracking.
</p> </p>
<p> <p>
The algorithm begins with your chosen tokenizer, then The
applies Myers' diff algorithm to compare the original text <code>reconcile(parent: str, left: str, right: str) -> str</code>
with both conflicting versions. These diffs undergo takes conflicting concurrent edits and intelligently merges them into a
transformation to preserve meaningful change sequences, unified result. Beyond basic conflict resolution, it offers sophisticated
before a final merge strategy—inspired by Operational merging heuristics, flexible tokenization options, and cursor position
Transformation (OT)—reconciles all conflicting modifications tracking.
without losing any edits. </p>
<p>
The algorithm begins with your chosen tokenizer, then applies Myers' diff
algorithm to compare the original text with both conflicting versions. These
diffs undergo transformation to preserve meaningful change sequences, before a
final merge strategy—inspired by Operational Transformation reconciles all
conflicting modifications without losing any edits.
</p> </p>
<p> <p>
For more details, see the For more details, see the
<a <a href="https://github.com/schmelczer/reconcile" target="_blank">README</a>.
href="https://github.com/schmelczer/reconcile" </p>
target="_blank"
>README</a <p>
>. Use the tokenization options below to experiment with different strategies.
The library supports user-defined tokenizers as well.
</p> </p>
</header> </header>
<main> <main>
<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>
</label>
<label class="radio-option">
<input
type="radio"
name="tokenizer"
value="Word"
id="tokenizer-word"
checked
/>
<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>
</label>
<label class="radio-option">
<input type="radio" name="tokenizer" value="Line" id="tokenizer-line" />
<span class="radio-custom" aria-hidden="true"></span>
<div class="radio-content">
<span class="radio-label">Line</span>
<span class="radio-description"
>Split by lines similarly to <code>git merge</code></span
>
</div>
</label>
</div>
</section>
<div class="text-area-card diamond-parent"> <div class="text-area-card diamond-parent">
<label <label
for="original" for="original"
@ -103,6 +150,7 @@
<div class="text-area-card diamond-result"> <div class="text-area-card diamond-result">
<label <label
for="merged"
title="Read-only. Change the above text boxes to change the content of this box." title="Read-only. Change the above text boxes to change the content of this box."
> >
Deconflicted result Deconflicted result
@ -116,16 +164,17 @@
stroke-width="2" stroke-width="2"
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
aria-hidden="true"
> >
<path stroke="none" d="M0 0h24v24H0z" fill="none" /> <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<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" 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 d="M13.5 6.5l4 4"></path>
<path d="M3 3l18 18" /> <path d="M3 3l18 18"></path>
</svg> </svg>
</label> </label>
<div id="merged"></div> <div id="merged" role="textbox" aria-readonly="true" aria-live="polite"></div>
</div> </div>
</main> </main>
@ -155,7 +204,10 @@
</a> </a>
</footer> </footer>
</div> </div>
</div>
<script type="module" src="script.js"></script> <noscript>JavaScript is required for this website to function properly.</noscript>
<script inline inline-asset="index.js" inline-asset-delete></script>
</body> </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

@ -11,10 +11,10 @@
"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",

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,