Character
- Fine-grained character-level merging
+ Fine-grained merging
diff --git a/examples/website/index.ts b/examples/website/src/index.ts
similarity index 50%
rename from examples/website/index.ts
rename to examples/website/src/index.ts
index f056621..bab0523 100644
--- a/examples/website/index.ts
+++ b/examples/website/src/index.ts
@@ -16,6 +16,12 @@ async function main(): Promise {
originalTextArea.addEventListener('input', updateMergedText);
leftTextArea.addEventListener('input', updateMergedText);
rightTextArea.addEventListener('input', updateMergedText);
+
+ leftTextArea.addEventListener('selectionchange', updateMergedText);
+ rightTextArea.addEventListener('selectionchange', updateMergedText);
+ leftTextArea.addEventListener('select', updateMergedText);
+ rightTextArea.addEventListener('select', updateMergedText);
+
window.addEventListener('resize', resizeTextAreas);
tokenizerRadios.forEach((radio) => {
@@ -27,6 +33,7 @@ async function main(): Promise {
focusTextArea(leftTextArea);
}
+// Edit the instructions to generate example edits
function loadSample(): void {
originalTextArea.value = sampleText;
leftTextArea.value =
@@ -46,16 +53,97 @@ function updateMergedText(): void {
const selectedTokenizer = getSelectedTokenizer();
- const results = reconcileWithHistory(original, left, right, selectedTokenizer);
+ const { leftCursors, rightCursors } = getCursorsFromActiveTextArea();
+ const results = reconcileWithHistory(
+ original,
+ {
+ text: left,
+ cursors: leftCursors,
+ },
+ {
+ text: right,
+ cursors: rightCursors,
+ },
+ selectedTokenizer
+ );
+
+ let selectionStart: number = Number.NEGATIVE_INFINITY;
+ let selectionEnd: number = Number.NEGATIVE_INFINITY;
+ if (results.cursors?.length ?? 0 > 0) {
+ selectionStart = results.cursors![0].position;
+ selectionEnd = results.cursors![1].position;
+ }
+
+ const selectionSide = leftCursors ? '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);
+ let currentPosition = 0;
+ if (selectionEnd === 0) {
+ mergedTextArea.appendChild(createCaret(selectionSide === 'left'));
}
+
+ for (const { text, history } of results.history) {
+ for (const character of text) {
+ const span = document.createElement('span');
+ span.className = history;
+ span.textContent = character;
+
+ if (selectionStart <= currentPosition && currentPosition < selectionEnd) {
+ span.className += ` selection-${selectionSide}`;
+ }
+
+ mergedTextArea.appendChild(span);
+
+ if (currentPosition == selectionEnd - 1) {
+ mergedTextArea.appendChild(createCaret(selectionSide === 'left'));
+ }
+
+ if (history !== 'RemovedFromLeft' && history !== 'RemovedFromRight') {
+ // Only increment currentPosition for non-removed characters
+ currentPosition++;
+ }
+ }
+ }
+}
+
+function getCursorsFromActiveTextArea() {
+ const activeElement = document.activeElement;
+ let leftCursors = undefined;
+ let rightCursors = undefined;
+
+ if (activeElement === leftTextArea) {
+ leftCursors = [
+ { id: 1, position: leftTextArea.selectionStart },
+ { id: 2, position: leftTextArea.selectionEnd },
+ ];
+ } else if (activeElement === rightTextArea) {
+ rightCursors = [
+ { id: 1, position: rightTextArea.selectionStart },
+ { id: 2, position: rightTextArea.selectionEnd },
+ ];
+ }
+ return { leftCursors, rightCursors };
+}
+
+function createCaret(isLeft: boolean): HTMLSpanElement {
+ const caretSpan = document.createElement('span');
+ caretSpan.className = `selection-caret selection-caret-${isLeft ? 'left' : 'right'}`;
+
+ const stickDiv = document.createElement('div');
+ stickDiv.className = 'stick';
+ caretSpan.appendChild(stickDiv);
+
+ const dotDiv = document.createElement('div');
+ dotDiv.className = 'dot';
+ caretSpan.appendChild(dotDiv);
+
+ const infoDiv = document.createElement('div');
+ infoDiv.className = 'info';
+ infoDiv.textContent = isLeft ? "Left user's cursor" : "Right user's cursor";
+ caretSpan.appendChild(infoDiv);
+
+ return caretSpan;
}
function getSelectedTokenizer(): Tokenizer {
diff --git a/examples/website/style.scss b/examples/website/src/style.scss
similarity index 63%
rename from examples/website/style.scss
rename to examples/website/src/style.scss
index 8abe682..a60438f 100644
--- a/examples/website/style.scss
+++ b/examples/website/src/style.scss
@@ -1,3 +1,21 @@
+// Colour palette
+$primary-blue: #2451a6;
+$light-blue: #85bff7;
+$green: #12d197;
+$text-primary: #23272f;
+$text-secondary: #5a6272;
+$border-grey: #d1d5db;
+$code-bg: #61769a;
+$code-text: #e2e8f0;
+$white: #fff;
+$light-bg: #f8fafc;
+$gradient-end: #e0e7ef;
+
+// Function to create selection colour with opacity
+@function selection-colour($colour, $opacity: 0.3) {
+ @return rgba($colour, $opacity);
+}
+
* {
box-sizing: border-box;
margin: 0;
@@ -11,7 +29,7 @@ body {
body {
font-family: 'Segoe UI', Arial, sans-serif;
- color: #23272f;
+ color: $text-primary;
}
.scroll-container {
@@ -21,7 +39,7 @@ body {
}
.background {
- background: linear-gradient(135deg, #f8fafc 0%, #e0e7ef 100%);
+ background: linear-gradient(135deg, $light-bg 0%, $gradient-end 100%);
position: fixed;
top: 0;
left: 0;
@@ -46,7 +64,7 @@ header {
header > h1 {
font-size: 2.5rem;
font-weight: 700;
- color: #2451a6;
+ color: $primary-blue;
margin-bottom: 24px;
text-align: center;
}
@@ -57,8 +75,8 @@ p * {
}
code {
- background: #61769a;
- color: #e2e8f0;
+ background: $code-bg;
+ color: $code-text;
padding: 2px 6px;
border-radius: 4px;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Consolas', monospace;
@@ -67,7 +85,7 @@ code {
}
header > p {
- color: #5a6272;
+ color: $text-secondary;
font-size: 1.1rem;
margin-bottom: 0;
}
@@ -105,9 +123,9 @@ main {
align-items: center;
gap: 12px;
padding: 16px 20px;
- background: #fff;
+ background: $white;
border-radius: 12px;
- box-shadow: 0 2px 8px rgba(36, 81, 166, 0.08);
+ box-shadow: 0 2px 8px selection-colour($primary-blue, 0.08);
cursor: pointer;
transition: all 0.2s ease;
border: 2px solid transparent;
@@ -116,14 +134,14 @@ main {
}
.radio-option:hover {
- box-shadow: 0 4px 16px rgba(36, 81, 166, 0.12);
+ box-shadow: 0 4px 16px selection-colour($primary-blue, 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);
+ background: $gradient-end;
+ border-color: $primary-blue;
+ box-shadow: 0 4px 16px selection-colour($primary-blue, 0.16);
}
.radio-option input[type='radio'] {
@@ -135,7 +153,7 @@ main {
.radio-custom {
width: 20px;
height: 20px;
- border: 2px solid #d1d5db;
+ border: 2px solid $border-grey;
border-radius: 50%;
position: relative;
transition: all 0.2s ease;
@@ -143,8 +161,8 @@ main {
}
.radio-option:has(input:checked) .radio-custom {
- border-color: #2451a6;
- background: #2451a6;
+ border-color: $primary-blue;
+ background: $primary-blue;
}
.radio-custom::after {
@@ -172,13 +190,13 @@ main {
.radio-label {
font-weight: 600;
- color: #2451a6;
+ color: $primary-blue;
font-size: 0.95rem;
}
.radio-description {
font-size: 0.8rem;
- color: #6b7280;
+ color: $text-primary;
line-height: 1.2;
}
@@ -216,9 +234,9 @@ main {
.text-area-card {
width: 100%;
height: 100%;
- background: #fff;
+ background: $white;
border-radius: 10px;
- box-shadow: 0 2px 12px 0 rgba(36, 81, 166, 0.06);
+ box-shadow: 0 2px 12px 0 selection-colour($primary-blue, 0.06);
padding: 18px 20px 16px 20px;
margin-bottom: 0;
}
@@ -227,7 +245,7 @@ label {
display: inline-block;
margin-bottom: 8px;
font-weight: 600;
- color: #2451a6;
+ color: $primary-blue;
cursor: help;
}
@@ -245,7 +263,7 @@ textarea {
border: none;
font-size: 1rem;
font-family: inherit;
- color: #23272f;
+ color: $text-primary;
box-sizing: border-box;
resize: none;
outline: none;
@@ -256,6 +274,10 @@ textarea {
#merged {
width: 100%;
user-select: text;
+
+ > * {
+ position: relative;
+ }
}
.Unchanged {
@@ -266,14 +288,32 @@ textarea {
.AddedFromLeft,
.RemovedFromLeft {
user-select: text;
- background: #12d197;
+ background: $green;
+}
+
+.selection-left::after,
+.selection-right::after {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+}
+
+.selection-left::after {
+ background: selection-colour($green);
+}
+
+.selection-right::after {
+ background: selection-colour($light-blue);
}
.Right,
.AddedFromRight,
.RemovedFromRight {
user-select: text;
- background: #85bff7;
+ background: $light-blue;
}
.RemovedFromLeft,
@@ -282,6 +322,80 @@ textarea {
text-decoration: line-through;
}
+// Selection caret styles
+$CARET_WIDTH: 2;
+$DOT_RADIUS: 4;
+
+.selection-caret {
+ position: relative;
+
+ &.selection-caret-left {
+ background: $green;
+ }
+
+ &.selection-caret-right {
+ background: $light-blue;
+ }
+
+ > * {
+ position: absolute;
+ background-color: inherit;
+ }
+
+ > .stick {
+ left: 0;
+ top: 0;
+ transform: translateX(-50%);
+ width: #{$CARET_WIDTH}px;
+ height: 100%;
+ display: block;
+ border-radius: calc(#{$CARET_WIDTH} / 2 * 1px);
+ animation: blink-stick 1s steps(1) infinite;
+ }
+
+ > .dot {
+ border-radius: 50%;
+ width: #{$DOT_RADIUS * 2}px;
+ height: #{$DOT_RADIUS * 2}px;
+ top: -#{$DOT_RADIUS}px;
+ left: -#{$DOT_RADIUS}px;
+ transition: transform 0.3s ease-in-out;
+ transform-origin: bottom center;
+ box-sizing: border-box;
+ }
+
+ &:hover > .dot {
+ transform: scale(0);
+ }
+
+ > .info {
+ top: -1.3em;
+ left: calc(-#{$CARET_WIDTH} / 2 * 1px);
+ font-size: 0.9em;
+ user-select: none;
+ color: white;
+ padding: 0 2px;
+ transition: opacity 0.3s ease-in-out;
+ opacity: 0;
+ white-space: nowrap;
+ border-radius: 3px 3px 3px 0;
+ }
+
+ &:hover > .info {
+ opacity: 1;
+ }
+}
+
+@keyframes blink-stick {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0;
+ }
+}
+
@media (max-width: 900px) {
header {
padding: 32px 18px 0 18px;
@@ -349,12 +463,12 @@ footer {
display: flex;
justify-content: center;
align-items: center;
- color: #5a6272;
+ color: $text-secondary;
}
.github-link > svg {
position: absolute;
- color: #5a6272;
+ color: $text-secondary;
top: 50%;
right: 36px;
transform: translateY(-50%);
diff --git a/examples/website/tsconfig.json b/examples/website/tsconfig.json
index 5c7b64f..6cf10b0 100644
--- a/examples/website/tsconfig.json
+++ b/examples/website/tsconfig.json
@@ -7,9 +7,11 @@
"esModuleInterop": true,
"moduleResolution": "bundler",
"outDir": "./dist",
- "rootDir": ".",
+ "rootDir": "./src",
"skipLibCheck": true,
"inlineSourceMap": true
},
- "exclude": ["./dist"]
-}
+ "exclude": [
+ "./dist"
+ ]
+}
\ No newline at end of file
diff --git a/examples/website/webpack.config.js b/examples/website/webpack.config.js
index 3ed06e7..df1d3e0 100644
--- a/examples/website/webpack.config.js
+++ b/examples/website/webpack.config.js
@@ -7,7 +7,7 @@ const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = (_env, argv) => ({
devtool: argv.mode === 'development' ? 'inline-source-map' : false,
entry: {
- index: './index.ts',
+ index: './src/index.ts',
},
devServer: {
allowedHosts: 'all',
@@ -31,7 +31,7 @@ module.exports = (_env, argv) => ({
},
plugins: [
new HtmlWebpackPlugin({
- template: './index.html',
+ template: './src/index.html',
}),
new MiniCssExtractPlugin(),
argv.mode === 'production'