Merge pull request #5 from schmelczer/asch/better-api
Prepare to publish
This commit is contained in:
commit
752f685a7f
21 changed files with 12171 additions and 5421 deletions
18
.github/dependabot.yml
vendored
18
.github/dependabot.yml
vendored
|
|
@ -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
10
.prettierrc
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"printWidth": 90,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"singleQuote": true,
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"importOrder": ["^[./]", ".*", ".scss$"],
|
||||||
|
"importOrderSeparation": true,
|
||||||
|
"importOrderSortSpecifiers": true
|
||||||
|
}
|
||||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
|
|
@ -9,4 +9,4 @@
|
||||||
"rust-analyzer.cargo.features": [
|
"rust-analyzer.cargo.features": [
|
||||||
"all"
|
"all"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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
88
examples/website/index.ts
Normal 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
5555
examples/website/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
48
examples/website/package.json
Normal file
48
examples/website/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
|
||||||
|
|
@ -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
359
examples/website/style.scss
Normal 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);
|
||||||
|
}
|
||||||
19
examples/website/tsconfig.json
Normal file
19
examples/website/tsconfig.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
81
examples/website/webpack.config.js
Normal file
81
examples/website/webpack.config.js
Normal 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
6
package-lock.json
generated
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"name": "reconcile",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {}
|
||||||
|
}
|
||||||
10645
reconcile-js/package-lock.json
generated
10645
reconcile-js/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue