Add cursors to example

This commit is contained in:
Andras Schmelczer 2025-07-10 22:14:15 +01:00
commit f73cd057be
No known key found for this signature in database
GPG key ID: FC8F2C3D3D1A718C
5 changed files with 242 additions and 40 deletions

View file

@ -28,7 +28,7 @@
<div class="scroll-container"> <div class="scroll-container">
<div class="page-wrapper"> <div class="page-wrapper">
<header> <header>
<h1>Reconcile: conflict-free 3-way text merging</h1> <h1>Reconcile-text: conflict-free 3-way text merging</h1>
<p> <p>
Think Think
<a <a
@ -96,9 +96,7 @@
<span class="radio-custom" aria-hidden="true"></span> <span class="radio-custom" aria-hidden="true"></span>
<div class="radio-content"> <div class="radio-content">
<span class="radio-label">Character</span> <span class="radio-label">Character</span>
<span class="radio-description" <span class="radio-description">Fine-grained merging</span>
>Fine-grained character-level merging</span
>
</div> </div>
</label> </label>
<label class="radio-option"> <label class="radio-option">
@ -121,7 +119,7 @@
<div class="radio-content"> <div class="radio-content">
<span class="radio-label">Line</span> <span class="radio-label">Line</span>
<span class="radio-description" <span class="radio-description"
>Line-by-line like <code>git merge</code></span >Line-by-line, like <code>git merge</code></span
> >
</div> </div>
</label> </label>

View file

@ -16,6 +16,12 @@ async function main(): Promise<void> {
originalTextArea.addEventListener('input', updateMergedText); originalTextArea.addEventListener('input', updateMergedText);
leftTextArea.addEventListener('input', updateMergedText); leftTextArea.addEventListener('input', updateMergedText);
rightTextArea.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); window.addEventListener('resize', resizeTextAreas);
tokenizerRadios.forEach((radio) => { tokenizerRadios.forEach((radio) => {
@ -27,6 +33,7 @@ async function main(): Promise<void> {
focusTextArea(leftTextArea); focusTextArea(leftTextArea);
} }
// Edit the instructions to generate example edits
function loadSample(): void { function loadSample(): void {
originalTextArea.value = sampleText; originalTextArea.value = sampleText;
leftTextArea.value = leftTextArea.value =
@ -46,16 +53,97 @@ function updateMergedText(): void {
const selectedTokenizer = getSelectedTokenizer(); 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 = ''; mergedTextArea.innerHTML = '';
for (const { text, history } of results.history) { let currentPosition = 0;
const span = document.createElement('span'); if (selectionEnd === 0) {
span.className = history; mergedTextArea.appendChild(createCaret(selectionSide === 'left'));
span.textContent = text;
mergedTextArea.appendChild(span);
} }
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 { function getSelectedTokenizer(): Tokenizer {

View file

@ -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; box-sizing: border-box;
margin: 0; margin: 0;
@ -11,7 +29,7 @@ body {
body { body {
font-family: 'Segoe UI', Arial, sans-serif; font-family: 'Segoe UI', Arial, sans-serif;
color: #23272f; color: $text-primary;
} }
.scroll-container { .scroll-container {
@ -21,7 +39,7 @@ body {
} }
.background { .background {
background: linear-gradient(135deg, #f8fafc 0%, #e0e7ef 100%); background: linear-gradient(135deg, $light-bg 0%, $gradient-end 100%);
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
@ -46,7 +64,7 @@ header {
header > h1 { header > h1 {
font-size: 2.5rem; font-size: 2.5rem;
font-weight: 700; font-weight: 700;
color: #2451a6; color: $primary-blue;
margin-bottom: 24px; margin-bottom: 24px;
text-align: center; text-align: center;
} }
@ -57,8 +75,8 @@ p * {
} }
code { code {
background: #61769a; background: $code-bg;
color: #e2e8f0; color: $code-text;
padding: 2px 6px; padding: 2px 6px;
border-radius: 4px; border-radius: 4px;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Consolas', monospace; font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Consolas', monospace;
@ -67,7 +85,7 @@ code {
} }
header > p { header > p {
color: #5a6272; color: $text-secondary;
font-size: 1.1rem; font-size: 1.1rem;
margin-bottom: 0; margin-bottom: 0;
} }
@ -105,9 +123,9 @@ main {
align-items: center; align-items: center;
gap: 12px; gap: 12px;
padding: 16px 20px; padding: 16px 20px;
background: #fff; background: $white;
border-radius: 12px; 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; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
border: 2px solid transparent; border: 2px solid transparent;
@ -116,14 +134,14 @@ main {
} }
.radio-option:hover { .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); transform: translateY(-2px);
} }
.radio-option:has(input:checked) { .radio-option:has(input:checked) {
background: #f0f7ff; background: $gradient-end;
border-color: #2451a6; border-color: $primary-blue;
box-shadow: 0 4px 16px rgba(36, 81, 166, 0.16); box-shadow: 0 4px 16px selection-colour($primary-blue, 0.16);
} }
.radio-option input[type='radio'] { .radio-option input[type='radio'] {
@ -135,7 +153,7 @@ main {
.radio-custom { .radio-custom {
width: 20px; width: 20px;
height: 20px; height: 20px;
border: 2px solid #d1d5db; border: 2px solid $border-grey;
border-radius: 50%; border-radius: 50%;
position: relative; position: relative;
transition: all 0.2s ease; transition: all 0.2s ease;
@ -143,8 +161,8 @@ main {
} }
.radio-option:has(input:checked) .radio-custom { .radio-option:has(input:checked) .radio-custom {
border-color: #2451a6; border-color: $primary-blue;
background: #2451a6; background: $primary-blue;
} }
.radio-custom::after { .radio-custom::after {
@ -172,13 +190,13 @@ main {
.radio-label { .radio-label {
font-weight: 600; font-weight: 600;
color: #2451a6; color: $primary-blue;
font-size: 0.95rem; font-size: 0.95rem;
} }
.radio-description { .radio-description {
font-size: 0.8rem; font-size: 0.8rem;
color: #6b7280; color: $text-primary;
line-height: 1.2; line-height: 1.2;
} }
@ -216,9 +234,9 @@ main {
.text-area-card { .text-area-card {
width: 100%; width: 100%;
height: 100%; height: 100%;
background: #fff; background: $white;
border-radius: 10px; 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; padding: 18px 20px 16px 20px;
margin-bottom: 0; margin-bottom: 0;
} }
@ -227,7 +245,7 @@ label {
display: inline-block; display: inline-block;
margin-bottom: 8px; margin-bottom: 8px;
font-weight: 600; font-weight: 600;
color: #2451a6; color: $primary-blue;
cursor: help; cursor: help;
} }
@ -245,7 +263,7 @@ textarea {
border: none; border: none;
font-size: 1rem; font-size: 1rem;
font-family: inherit; font-family: inherit;
color: #23272f; color: $text-primary;
box-sizing: border-box; box-sizing: border-box;
resize: none; resize: none;
outline: none; outline: none;
@ -256,6 +274,10 @@ textarea {
#merged { #merged {
width: 100%; width: 100%;
user-select: text; user-select: text;
> * {
position: relative;
}
} }
.Unchanged { .Unchanged {
@ -266,14 +288,32 @@ textarea {
.AddedFromLeft, .AddedFromLeft,
.RemovedFromLeft { .RemovedFromLeft {
user-select: text; 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, .Right,
.AddedFromRight, .AddedFromRight,
.RemovedFromRight { .RemovedFromRight {
user-select: text; user-select: text;
background: #85bff7; background: $light-blue;
} }
.RemovedFromLeft, .RemovedFromLeft,
@ -282,6 +322,80 @@ textarea {
text-decoration: line-through; 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) { @media (max-width: 900px) {
header { header {
padding: 32px 18px 0 18px; padding: 32px 18px 0 18px;
@ -349,12 +463,12 @@ footer {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
color: #5a6272; color: $text-secondary;
} }
.github-link > svg { .github-link > svg {
position: absolute; position: absolute;
color: #5a6272; color: $text-secondary;
top: 50%; top: 50%;
right: 36px; right: 36px;
transform: translateY(-50%); transform: translateY(-50%);

View file

@ -7,9 +7,11 @@
"esModuleInterop": true, "esModuleInterop": true,
"moduleResolution": "bundler", "moduleResolution": "bundler",
"outDir": "./dist", "outDir": "./dist",
"rootDir": ".", "rootDir": "./src",
"skipLibCheck": true, "skipLibCheck": true,
"inlineSourceMap": true "inlineSourceMap": true
}, },
"exclude": ["./dist"] "exclude": [
} "./dist"
]
}

View file

@ -7,7 +7,7 @@ const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = (_env, argv) => ({ module.exports = (_env, argv) => ({
devtool: argv.mode === 'development' ? 'inline-source-map' : false, devtool: argv.mode === 'development' ? 'inline-source-map' : false,
entry: { entry: {
index: './index.ts', index: './src/index.ts',
}, },
devServer: { devServer: {
allowedHosts: 'all', allowedHosts: 'all',
@ -31,7 +31,7 @@ module.exports = (_env, argv) => ({
}, },
plugins: [ plugins: [
new HtmlWebpackPlugin({ new HtmlWebpackPlugin({
template: './index.html', template: './src/index.html',
}), }),
new MiniCssExtractPlugin(), new MiniCssExtractPlugin(),
argv.mode === 'production' argv.mode === 'production'