Add cursors to example

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

View file

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

View file

@ -16,6 +16,12 @@ async function main(): Promise<void> {
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<void> {
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 {

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;
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%);

View file

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

View file

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