Add mergeTextWithHistory function

This commit is contained in:
Andras Schmelczer 2025-06-22 20:49:11 +01:00
commit 779579d38f
No known key found for this signature in database
GPG key ID: FC8F2C3D3D1A718C
18 changed files with 285 additions and 100 deletions

View file

@ -29,26 +29,53 @@
</header> </header>
<main> <main>
<div class="text-area diamond-parent"> <div class="text-area-card diamond-parent">
<label for="original">Original</label> <label for="original">Original</label>
<textarea id="original" name="original"></textarea> <textarea id="original" name="original"></textarea>
</div> </div>
<div class="text-area diamond-left"> <div class="text-area-card diamond-left">
<label for="left">First concurrent edit</label> <label for="left">
First concurrent edit
<div class="box Left"></div>
</label>
<textarea id="left" name="left"></textarea> <textarea id="left" name="left"></textarea>
</div> </div>
<div class="text-area diamond-right"> <div class="text-area-card diamond-right">
<label for="right">Second concurrent edit</label> <label for="right"
>Second concurrent edit
<div
class="box Right"
title="Indicates changes from the second concurrent edit"
></div>
</label>
<textarea id="right" name="right"></textarea> <textarea id="right" name="right"></textarea>
</div> </div>
<button id="merge-button" type="button">Merge</button> <div class="text-area-card diamond-result">
<label
<div class="text-area diamond-result"> ><svg
<label for="merged">Deconflicted result (readonly)</label> xmlns="http://www.w3.org/2000/svg"
<textarea id="merged" name="merged" readonly></textarea> 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="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>
Deconflicted result (readonly)</label
>
<div id="merged"></div>
</div> </div>
</main> </main>
@ -64,9 +91,15 @@
width="24" width="24"
height="24" height="24"
viewBox="0 0 24 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 <path
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" 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> </svg>
</a> </a>

View file

@ -1,10 +1,9 @@
import init, { mergeText } from "./reconcile.js"; import init, { mergeText, mergeTextWithHistory } from "./reconcile.js";
const originalTextArea = document.getElementById("original"); const originalTextArea = document.getElementById("original");
const leftTextArea = document.getElementById("left"); const leftTextArea = document.getElementById("left");
const rightTextArea = document.getElementById("right"); const rightTextArea = document.getElementById("right");
const mergedTextArea = document.getElementById("merged"); const mergedTextArea = document.getElementById("merged");
const mergeButton = document.getElementById("merge-button");
const sampleTexts = [ const sampleTexts = [
"The quick brown fox jumps over the lazy dog.", "The quick brown fox jumps over the lazy dog.",
@ -17,16 +16,35 @@ const sampleTexts = [
async function run() { async function run() {
await init(); await init();
mergeButton.addEventListener("click", () => { originalTextArea.addEventListener("input", updateMergedText);
const original = originalTextArea.value; leftTextArea.addEventListener("input", updateMergedText);
const left = leftTextArea.value; rightTextArea.addEventListener("input", updateMergedText);
const right = rightTextArea.value;
const result = mergeText(original, left, right);
mergedTextArea.value = result;
});
loadSample(); loadSample();
updateMergedText();
// Put cursor at the end of the text in leftTextArea
leftTextArea.focus();
leftTextArea.selectionStart = leftTextArea.value.length;
leftTextArea.selectionEnd = leftTextArea.value.length;
}
function updateMergedText() {
const original = originalTextArea.value;
const left = leftTextArea.value;
const right = rightTextArea.value;
const results = mergeTextWithHistory(original, left, right);
mergedTextArea.innerHTML = "";
for (const result of results) {
const span = document.createElement("span");
span.className = result.history();
span.textContent = result.text();
mergedTextArea.appendChild(span);
result.free();
}
} }
function loadSample() { function loadSample() {
@ -35,7 +53,6 @@ function loadSample() {
originalTextArea.value = text; originalTextArea.value = text;
leftTextArea.value = text; leftTextArea.value = text;
rightTextArea.value = text; rightTextArea.value = text;
mergedTextArea.value = "";
} }
run(); run();

View file

@ -1,6 +1,7 @@
* { * {
box-sizing: border-box; box-sizing: border-box;
margin: 0; margin: 0;
user-select: none;
} }
html, html,
@ -40,7 +41,7 @@ main {
flex: 1; flex: 1;
display: grid; display: grid;
grid-template-rows: auto auto auto; grid-template-rows: auto auto auto;
grid-template-columns: 1fr auto 1fr; grid-template-columns: 1fr 1fr;
gap: 20px; gap: 20px;
justify-items: center; justify-items: center;
align-items: center; align-items: center;
@ -58,34 +59,8 @@ main {
} }
.diamond-right { .diamond-right {
grid-column: 3;
grid-row: 2;
}
#merge-button {
grid-column: 2; grid-column: 2;
grid-row: 2; grid-row: 2;
padding: 12px 36px;
border: none;
border-radius: 8px;
background: linear-gradient(90deg, #2451a6 0%, #3486eb 100%);
color: #fff;
font-size: 1.15rem;
font-weight: 600;
box-shadow: 0 2px 12px 0 rgba(36, 81, 166, 0.08);
cursor: pointer;
align-self: center;
transition: transform 0.2s;
margin: 0 32px;
}
#merge-button:hover {
transform: scale(1.1);
}
.diamond-right {
grid-column: 3;
grid-row: 2;
} }
.diamond-result { .diamond-result {
@ -93,10 +68,9 @@ main {
grid-row: 3; grid-row: 3;
display: flex; display: flex;
align-items: center; align-items: center;
pointer-events: none;
} }
.text-area { .text-area-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@ -115,6 +89,15 @@ label {
color: #2451a6; color: #2451a6;
} }
.box {
width: 1ch;
height: 1ch;
border-radius: 50%;
margin-left: 6px;
display: inline-block;
transform: scale(1.5);
}
textarea { textarea {
width: 100%; width: 100%;
border: none; border: none;
@ -128,6 +111,29 @@ textarea {
height: 100%; height: 100%;
} }
#merged {
width: 100%;
text-align: left;
user-select: text;
}
.Left,
.AddedFromLeft,
.RemovedFromLeft {
background: #12d197;
}
.Right,
.AddedFromRight,
.RemovedFromRight {
background: #8575ed;
}
.RemovedFromLeft,
.RemovedFromRight {
text-decoration: line-through;
}
@media (max-width: 900px) { @media (max-width: 900px) {
main { main {
padding: 24px 2vw; padding: 24px 2vw;
@ -139,29 +145,25 @@ textarea {
grid-template-columns: 1fr; grid-template-columns: 1fr;
grid-template-rows: auto auto auto auto auto; grid-template-rows: auto auto auto auto auto;
} }
main > * {
grid-column: 1;
}
.diamond-parent { .diamond-parent {
grid-row: 1; grid-row: 1;
grid-column: 1;
} }
.diamond-left { .diamond-left {
grid-row: 2; grid-row: 2;
grid-column: 1;
} }
.diamond-right { .diamond-right {
grid-row: 3; grid-row: 3;
grid-column: 1;
}
#merge-button {
grid-row: 4;
grid-column: 1;
} }
.diamond-result { .diamond-result {
grid-row: 5; grid-row: 5;
grid-column: 1;
} }
} }
@ -178,6 +180,7 @@ footer {
.github-link > svg { .github-link > svg {
position: absolute; position: absolute;
color: #5a6272;
top: 50%; top: 50%;
right: 36px; right: 36px;
transform: translateY(-50%); transform: translateY(-50%);

View file

@ -1,5 +1,7 @@
#!/bin/bash #!/bin/bash
set -e
rm -rf pkg rm -rf pkg
wasm-pack build --target web --features wasm wasm-pack build --target web --features wasm

View file

@ -1,3 +1,5 @@
#![feature(stmt_expr_attributes)]
mod diffs; mod diffs;
mod operation_transformation; mod operation_transformation;
mod tokenizer; mod tokenizer;
@ -5,9 +7,10 @@ mod utils;
pub use operation_transformation::{ pub use operation_transformation::{
CursorPosition, EditedText, TextWithCursors, reconcile, reconcile_with_cursors, CursorPosition, EditedText, TextWithCursors, reconcile, reconcile_with_cursors,
reconcile_with_tokenizer, reconcile_with_history, reconcile_with_tokenizer,
}; };
pub use tokenizer::{Tokenizer, token::Token, word_tokenizer::word_tokenizer}; pub use tokenizer::{Tokenizer, token::Token, word_tokenizer::word_tokenizer};
pub use utils::{history::History, side::Side};
#[cfg(feature = "wasm")] #[cfg(feature = "wasm")]
pub mod wasm; pub mod wasm;

View file

@ -7,7 +7,10 @@ pub use cursor::{CursorPosition, TextWithCursors};
pub use edited_text::EditedText; pub use edited_text::EditedText;
pub use operation::Operation; pub use operation::Operation;
use crate::Tokenizer; use crate::{
Tokenizer,
utils::{history::History, side::Side},
};
#[must_use] #[must_use]
pub fn reconcile(original: &str, left: &str, right: &str) -> String { pub fn reconcile(original: &str, left: &str, right: &str) -> String {
@ -16,14 +19,22 @@ pub fn reconcile(original: &str, left: &str, right: &str) -> String {
.to_string() .to_string()
} }
#[must_use]
pub fn reconcile_with_history(original: &str, left: &str, right: &str) -> Vec<(History, String)> {
let left_operations = EditedText::from_strings(original, left.into(), Side::Left);
let right_operations = EditedText::from_strings(original, right.into(), Side::Right);
left_operations.merge(right_operations).apply_with_history()
}
#[must_use] #[must_use]
pub fn reconcile_with_cursors<'a>( pub fn reconcile_with_cursors<'a>(
original: &'a str, original: &'a str,
left: TextWithCursors<'a>, left: TextWithCursors<'a>,
right: TextWithCursors<'a>, right: TextWithCursors<'a>,
) -> TextWithCursors<'static> { ) -> TextWithCursors<'static> {
let left_operations = EditedText::from_strings(original, left); let left_operations = EditedText::from_strings(original, left, Side::Left);
let right_operations = EditedText::from_strings(original, right); let right_operations = EditedText::from_strings(original, right, Side::Right);
let merged_operations = left_operations.merge(right_operations); let merged_operations = left_operations.merge(right_operations);
@ -40,8 +51,10 @@ pub fn reconcile_with_tokenizer<'a, F, T>(
where where
T: PartialEq + Clone + std::fmt::Debug, T: PartialEq + Clone + std::fmt::Debug,
{ {
let left_operations = EditedText::from_strings_with_tokenizer(original, left, tokenizer); let left_operations =
let right_operations = EditedText::from_strings_with_tokenizer(original, right, tokenizer); EditedText::from_strings_with_tokenizer(original, left, tokenizer, Side::Left);
let right_operations =
EditedText::from_strings_with_tokenizer(original, right, tokenizer, Side::Right);
let merged_operations = left_operations.merge(right_operations); let merged_operations = left_operations.merge(right_operations);

View file

@ -8,7 +8,7 @@ use crate::{
cook_operations::cook_operations, elongate_operations::elongate_operations, cook_operations::cook_operations, elongate_operations::elongate_operations,
}, },
tokenizer::{Tokenizer, word_tokenizer::word_tokenizer}, tokenizer::{Tokenizer, word_tokenizer::word_tokenizer},
utils::{side::Side, string_builder::StringBuilder}, utils::{history::History, side::Side, string_builder::StringBuilder},
}; };
/// A text document and a sequence of operations that can be applied to the text /// A text document and a sequence of operations that can be applied to the text
@ -42,8 +42,8 @@ impl<'a> EditedText<'a, String> {
/// word tokenizer is used to tokenize the text which splits the text on /// word tokenizer is used to tokenize the text which splits the text on
/// whitespaces. /// whitespaces.
#[must_use] #[must_use]
pub fn from_strings(original: &'a str, updated: TextWithCursors<'a>) -> Self { pub fn from_strings(original: &'a str, updated: TextWithCursors<'a>, side: Side) -> Self {
Self::from_strings_with_tokenizer(original, updated, &word_tokenizer) Self::from_strings_with_tokenizer(original, updated, &word_tokenizer, side)
} }
} }
@ -60,6 +60,7 @@ where
original: &'a str, original: &'a str,
updated: TextWithCursors<'a>, updated: TextWithCursors<'a>,
tokenizer: &Tokenizer<T>, tokenizer: &Tokenizer<T>,
side: Side,
) -> Self { ) -> Self {
let original_tokens = (tokenizer)(original); let original_tokens = (tokenizer)(original);
let updated_tokens = (tokenizer)(&updated.text); let updated_tokens = (tokenizer)(&updated.text);
@ -68,7 +69,7 @@ where
Self::new( Self::new(
original, original,
cook_operations(elongate_operations(diff)).collect(), cook_operations(elongate_operations(diff), side).collect(),
updated.cursors, updated.cursors,
) )
} }
@ -223,6 +224,39 @@ where
builder.build() builder.build()
} }
#[must_use]
pub fn apply_with_history(&self) -> Vec<(History, String)> {
let mut builder: StringBuilder<'_> = StringBuilder::new(self.text);
let mut history = Vec::with_capacity(self.operations.len());
for operation in &self.operations {
builder = operation.apply(builder);
match operation {
Operation::Equal { .. } => history.push((History::Unchanged, builder.take())),
Operation::Insert { side, .. } => match side {
Side::Left => history.push((History::AddedFromLeft, builder.take())),
Side::Right => history.push((History::AddedFromRight, builder.take())),
},
Operation::Delete {
deleted_character_count,
order,
side,
..
} => {
let deleted = self.text[*order..*order + *deleted_character_count].to_string();
match side {
Side::Left => history.push((History::RemovedFromLeft, deleted)),
Side::Right => history.push((History::RemovedFromRight, deleted)),
}
}
}
}
history
}
} }
#[cfg(test)] #[cfg(test)]
@ -237,7 +271,7 @@ mod tests {
let left = "hello world! How are you? Adam"; let left = "hello world! How are you? Adam";
let right = "Hello, my friend! How are you doing? Albert"; let right = "Hello, my friend! How are you doing? Albert";
let operations = EditedText::from_strings(left, right.into()); let operations = EditedText::from_strings(left, right.into(), Side::Right);
insta::assert_debug_snapshot!(operations); insta::assert_debug_snapshot!(operations);
@ -249,7 +283,7 @@ mod tests {
fn test_calculate_operations_with_no_diff() { fn test_calculate_operations_with_no_diff() {
let text = "hello world!"; let text = "hello world!";
let operations = EditedText::from_strings(text, text.into()); let operations = EditedText::from_strings(text, text.into(), Side::Right);
assert_debug_snapshot!(operations); assert_debug_snapshot!(operations);
@ -264,8 +298,8 @@ mod tests {
let right = "Hello world! How are you?"; let right = "Hello world! How are you?";
let expected = "Hello world! How are you? I'm Andras."; let expected = "Hello world! How are you? I'm Andras.";
let operations_1 = EditedText::from_strings(original, left.into()); let operations_1 = EditedText::from_strings(original, left.into(), Side::Left);
let operations_2 = EditedText::from_strings(original, right.into()); let operations_2 = EditedText::from_strings(original, right.into(), Side::Right);
let operations = operations_1.merge(operations_2); let operations = operations_1.merge(operations_2);
assert_eq!(operations.apply(), expected); assert_eq!(operations.apply(), expected);

View file

@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize};
use crate::{ use crate::{
Token, Token,
utils::{ utils::{
find_longest_prefix_contained_within::find_longest_prefix_contained_within, find_longest_prefix_contained_within::find_longest_prefix_contained_within, side::Side,
string_builder::StringBuilder, string_builder::StringBuilder,
}, },
}; };
@ -27,11 +27,15 @@ where
}, },
Insert { Insert {
side: Side,
order: usize, order: usize,
text: Vec<Token<T>>, text: Vec<Token<T>>,
}, },
Delete { Delete {
side: Side,
order: usize, order: usize,
deleted_character_count: usize, deleted_character_count: usize,
@ -68,14 +72,15 @@ where
} }
/// Creates an insert operation with the given index and text. /// Creates an insert operation with the given index and text.
pub fn create_insert(order: usize, text: Vec<Token<T>>) -> Self { pub fn create_insert(order: usize, text: Vec<Token<T>>, side: Side) -> Self {
Operation::Insert { order, text } Operation::Insert { side, order, text }
} }
/// Creates a delete operation with the given index and number of /// Creates a delete operation with the given index and number of
/// to-be-deleted characters. /// to-be-deleted characters.
pub fn create_delete(order: usize, deleted_character_count: usize) -> Self { pub fn create_delete(order: usize, deleted_character_count: usize, side: Side) -> Self {
Operation::Delete { Operation::Delete {
side,
order, order,
deleted_character_count, deleted_character_count,
@ -84,8 +89,9 @@ where
} }
} }
pub fn create_delete_with_text(order: usize, text: String) -> Self { pub fn create_delete_with_text(order: usize, text: String, side: Side) -> Self {
Operation::Delete { Operation::Delete {
side,
order, order,
deleted_character_count: text.chars().count(), deleted_character_count: text.chars().count(),
@ -200,7 +206,7 @@ where
match (operation, previous_operation) { match (operation, previous_operation) {
( (
Operation::Insert { order, text }, Operation::Insert { side, order, text },
Some(Operation::Insert { Some(Operation::Insert {
text: previous_inserted_text, text: previous_inserted_text,
.. ..
@ -212,11 +218,12 @@ where
let offset_in_tokens = let offset_in_tokens =
find_longest_prefix_contained_within(previous_inserted_text, &text); find_longest_prefix_contained_within(previous_inserted_text, &text);
Operation::create_insert(order, text[offset_in_tokens..].to_vec()) Operation::create_insert(order, text[offset_in_tokens..].to_vec(), side)
} }
( (
Operation::Delete { Operation::Delete {
side,
order, order,
deleted_character_count, deleted_character_count,
@ -240,19 +247,20 @@ where
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
let updated_delete = deleted_text.as_ref().map_or_else( let updated_delete = deleted_text.as_ref().map_or_else(
|| Operation::create_delete(order + overlap, new_length), || Operation::create_delete(order + overlap, new_length, side),
|text| { |text| {
Operation::create_delete_with_text( Operation::create_delete_with_text(
order + overlap, order + overlap,
text.chars() text.chars()
.skip(deleted_character_count - new_length) .skip(deleted_character_count - new_length)
.collect::<String>(), .collect::<String>(),
side,
) )
}, },
); );
#[cfg(not(debug_assertions))] #[cfg(not(debug_assertions))]
let updated_delete = Operation::create_delete(order + overlap, new_length); let updated_delete = Operation::create_delete(order + overlap, new_length, side);
updated_delete updated_delete
} }
@ -334,6 +342,7 @@ where
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
text, text,
..
} => { } => {
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
write!( write!(
@ -349,7 +358,7 @@ where
Ok(()) Ok(())
} }
Operation::Insert { order, text } => { Operation::Insert { order, text, .. } => {
write!( write!(
f, f,
"<insert '{}' at {order}>", "<insert '{}' at {order}>",
@ -365,6 +374,7 @@ where
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
deleted_text, deleted_text,
..
} => { } => {
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
write!( write!(
@ -404,7 +414,8 @@ mod tests {
#[test] #[test]
fn test_apply_delete_with_create() { fn test_apply_delete_with_create() {
let builder = StringBuilder::new("hello world"); let builder = StringBuilder::new("hello world");
let delete_operation = Operation::<()>::create_delete_with_text(0, "hello ".to_owned()); let delete_operation =
Operation::<()>::create_delete_with_text(0, "hello ".to_owned(), Side::Left);
let retain_operation = Operation::<()>::create_equal(6, 5); let retain_operation = Operation::<()>::create_equal(6, 5);
let mut builder = delete_operation.apply(builder); let mut builder = delete_operation.apply(builder);
@ -418,7 +429,7 @@ mod tests {
let builder = StringBuilder::new("hello"); let builder = StringBuilder::new("hello");
let retain_operation = Operation::<()>::create_equal(0, 5); let retain_operation = Operation::<()>::create_equal(0, 5);
let insert_operation = Operation::create_insert(5, vec![" my friend".into()]); let insert_operation = Operation::create_insert(5, vec![" my friend".into()], Side::Right);
let mut builder = retain_operation.apply(builder); let mut builder = retain_operation.apply(builder);
builder = insert_operation.apply(builder); builder = insert_operation.apply(builder);

View file

@ -1,8 +1,10 @@
use crate::{diffs::raw_operation::RawOperation, operation_transformation::Operation}; use crate::{
diffs::raw_operation::RawOperation, operation_transformation::Operation, utils::side::Side,
};
/// Turn raw operations into ordered operations while keeping track of the /// Turn raw operations into ordered operations while keeping track of the
/// original token's indexes. /// original token's indexes.
pub fn cook_operations<I, T>(raw_operations: I) -> impl Iterator<Item = Operation<T>> pub fn cook_operations<I, T>(raw_operations: I, side: Side) -> impl Iterator<Item = Operation<T>>
where where
I: IntoIterator<Item = RawOperation<T>>, I: IntoIterator<Item = RawOperation<T>>,
T: PartialEq + Clone + std::fmt::Debug, T: PartialEq + Clone + std::fmt::Debug,
@ -27,15 +29,18 @@ where
op op
} }
RawOperation::Insert(tokens) => Operation::create_insert(original_text_index, tokens), RawOperation::Insert(tokens) => {
Operation::create_insert(original_text_index, tokens, side)
}
RawOperation::Delete(..) => { RawOperation::Delete(..) => {
let op = if cfg!(debug_assertions) { let op = if cfg!(debug_assertions) {
Operation::create_delete_with_text( Operation::create_delete_with_text(
original_text_index, original_text_index,
raw_operation.get_original_text(), raw_operation.get_original_text(),
side,
) )
} else { } else {
Operation::create_delete(original_text_index, length) Operation::create_delete(original_text_index, length, side)
}; };
original_text_index += length; original_text_index += length;

View file

@ -1,5 +1,6 @@
pub mod common_prefix_len; pub mod common_prefix_len;
pub mod common_suffix_len; pub mod common_suffix_len;
pub mod find_longest_prefix_contained_within; pub mod find_longest_prefix_contained_within;
pub mod history;
pub mod side; pub mod side;
pub mod string_builder; pub mod string_builder;

15
src/utils/history.rs Normal file
View file

@ -0,0 +1,15 @@
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
#[cfg(feature = "wasm")]
use wasm_bindgen::prelude::*;
#[cfg_attr(feature = "wasm", wasm_bindgen)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum History {
Unchanged = "Unchanged",
AddedFromLeft = "AddedFromLeft",
AddedFromRight = "AddedFromRight",
RemovedFromLeft = "RemovedFromLeft",
RemovedFromRight = "RemovedFromRight",
}

View file

@ -1,5 +1,9 @@
use std::fmt::Display; use std::fmt::Display;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Side { pub enum Side {
Left, Left,

View file

@ -35,7 +35,8 @@ impl StringBuilder<'_> {
self.original.nth(length - 1); self.original.nth(length - 1);
if cfg!(debug_assertions) { #[cfg(debug_assertions)]
{
self.remaining = self.remaining.chars().skip(length).collect(); self.remaining = self.remaining.chars().skip(length).collect();
} }
} }
@ -44,20 +45,28 @@ impl StringBuilder<'_> {
pub fn retain(&mut self, length: usize) { pub fn retain(&mut self, length: usize) {
self.buffer.extend(self.original.by_ref().take(length)); self.buffer.extend(self.original.by_ref().take(length));
if cfg!(debug_assertions) { #[cfg(debug_assertions)]
{
self.remaining = self.remaining.chars().skip(length).collect(); self.remaining = self.remaining.chars().skip(length).collect();
} }
} }
/// Returns the currently built buffer and clears it.
pub fn take(&mut self) -> String {
let result = self.buffer.clone();
self.buffer.clear();
result
}
/// Finish building the string after copying the remaining original string /// Finish building the string after copying the remaining original string
/// since the last insertion or deletion. /// since the last insertion or deletion.
pub fn build(self) -> String { self.buffer } pub fn build(self) -> String { self.buffer }
#[cfg(debug_assertions)]
/// Get a slice of the remaining original string. The slice starts from /// Get a slice of the remaining original string. The slice starts from
/// where the next delete/retain operation would start and is of length /// where the next delete/retain operation would start and is of length
/// `length`. The implementation is quite suboptimal but it's only used /// `length`. The implementation is quite suboptimal but it's only used
/// for debugging. /// for debugging.
#[cfg(debug_assertions)]
pub fn get_slice_from_remaining(&self, length: usize) -> String { pub fn get_slice_from_remaining(&self, length: usize) -> String {
let result = self.remaining.chars().take(length).collect::<String>(); let result = self.remaining.chars().take(length).collect::<String>();

View file

@ -1,2 +1,2 @@
pub mod cursor;
pub mod lib; pub mod lib;
pub mod types;

View file

@ -13,7 +13,7 @@ use core::str;
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
use crate::wasm::cursor::JsTextWithCursors; use crate::wasm::types::{JsTextWithCursors, JsTextWithHistory};
/// Merge two documents with a common parent. Relies on `reconcile::reconcile` /// Merge two documents with a common parent. Relies on `reconcile::reconcile`
/// for texts and returns the right document as-is if either of the updated /// for texts and returns the right document as-is if either of the updated
@ -58,6 +58,18 @@ pub fn merge_text(parent: &str, left: &str, right: &str) -> String {
crate::reconcile(parent, left, right) crate::reconcile(parent, left, right)
} }
/// WASM wrapper around `crate::reconcile` for merging text.
#[wasm_bindgen(js_name = mergeTextWithHistory)]
#[must_use]
pub fn merge_text_with_history(parent: &str, left: &str, right: &str) -> Vec<JsTextWithHistory> {
set_panic_hook();
crate::reconcile_with_history(parent, left, right)
.into_iter()
.map(Into::into)
.collect()
}
/// WASM wrapper around `reconcile::reconcile_with_cursors` for merging text. /// WASM wrapper around `reconcile::reconcile_with_cursors` for merging text.
#[wasm_bindgen(js_name = mergeTextWithCursors)] #[wasm_bindgen(js_name = mergeTextWithCursors)]
#[must_use] #[must_use]

View file

@ -1,5 +1,7 @@
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
use crate::History;
/// Wrapper type to expose `TextWithCursors` to JS. /// Wrapper type to expose `TextWithCursors` to JS.
#[wasm_bindgen] #[wasm_bindgen]
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
@ -86,3 +88,24 @@ impl From<crate::CursorPosition> for JsCursorPosition {
} }
} }
} }
/// Wrapper type to expose `(History, String)` to JS.
#[wasm_bindgen]
#[derive(Debug, Clone, PartialEq)]
pub struct JsTextWithHistory {
history: History,
text: String,
}
impl From<(History, String)> for JsTextWithHistory {
fn from((history, text): (History, String)) -> Self { JsTextWithHistory { history, text } }
}
#[wasm_bindgen]
impl JsTextWithHistory {
#[must_use]
pub fn history(&self) -> History { self.history }
#[must_use]
pub fn text(&self) -> String { self.text.clone() }
}

View file

@ -25,10 +25,10 @@ right: long with big and small
expected: long small expected: long small
--- ---
parent: long run of text where one barely has no changes but has cursors parent: long run of text where one barely has changes but has cursors
left: long| run of tex|t where one barely has no |changes but has |cursors left: long| run of tex|t where one barely has |changes but has |cursors
right: long run one barely has no changes cursors right: long run one barely has changes cursors
expected: long| ru|n one barely has no |changes |cursors expected: long| ru|n one barely has |changes |cursors
--- ---
parent: long text where the cursor has to be clamped after delete parent: long text where the cursor has to be clamped after delete

View file

@ -1,8 +1,8 @@
#![cfg(feature = "wasm")] #![cfg(feature = "wasm")]
use reconcile::wasm::{ use reconcile::wasm::{
cursor::{JsCursorPosition, JsTextWithCursors},
lib::{is_binary, is_file_type_mergable, merge, merge_text, merge_text_with_cursors}, lib::{is_binary, is_file_type_mergable, merge, merge_text, merge_text_with_cursors},
types::{JsCursorPosition, JsTextWithCursors},
}; };
use wasm_bindgen_test::*; use wasm_bindgen_test::*;