Add cursors to example
This commit is contained in:
parent
9c79ebc653
commit
f73cd057be
5 changed files with 243 additions and 41 deletions
222
examples/website/src/index.html
Normal file
222
examples/website/src/index.html
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
|
||||
/>
|
||||
<meta
|
||||
name="description"
|
||||
content="3-way text merging that automatically resolves conflicts. No more Git conflict markers — just clean, merged results."
|
||||
/>
|
||||
<meta property="og:title" content="3-Way Text Merge" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="3-way text merging that automatically resolves conflicts. No more Git conflict markers — just clean, merged results."
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://schmelczer.dev/reconcile" />
|
||||
<meta property="og:image" content="/favicon.ico" />
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||
<title>Reconcile: conflict-free text merging</title>
|
||||
<link inline inline-asset="index.css" inline-asset-delete />
|
||||
</head>
|
||||
<body>
|
||||
<div class="background"></div>
|
||||
|
||||
<div class="scroll-container">
|
||||
<div class="page-wrapper">
|
||||
<header>
|
||||
<h1>Reconcile-text: conflict-free 3-way text merging</h1>
|
||||
<p>
|
||||
Think
|
||||
<a
|
||||
href="https://www.gnu.org/software/diffutils/manual/html_node/Invoking-diff3.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>diff3</a
|
||||
>
|
||||
or <code>git merge</code>, but with intelligent conflict resolution that
|
||||
requires no user intervention. The
|
||||
<a
|
||||
href="https://github.com/schmelczer/reconcile"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>Reconcile</a
|
||||
>
|
||||
library tackles a fundamental challenge in collaborative editing: what happens
|
||||
when multiple users edit the same text simultaneously, but the conflict
|
||||
resolver only has access to the final results, not the intermediate steps?
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Where traditional merge tools leave you with conflict markers to resolve
|
||||
manually, Reconcile automatically weaves changes together. The
|
||||
<code>reconcile(parent, left, right)</code> function takes conflicting edits
|
||||
and produces clean, unified results using an algorithm inspired by Operational
|
||||
Transformation. No more <code><<<<<<<</code> markers
|
||||
cluttering your text.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
The process starts with your chosen tokenisation strategy, then applies Myers'
|
||||
diff algorithm to compare the original with both modified versions. These
|
||||
diffs are optimised and transformed to preserve meaningful changes, before a
|
||||
final merge strategy combines all modifications without losing any edits.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Ready to dive deeper? Check out the
|
||||
<a
|
||||
href="https://github.com/schmelczer/reconcile"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>documentation</a
|
||||
>
|
||||
or try editing the text boxes below to see Reconcile in action.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Use the tokenisation options below to experiment with different approaches —
|
||||
the library also supports custom tokenisers.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<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">Fine-grained merging</span>
|
||||
</div>
|
||||
</label>
|
||||
<label class="radio-option">
|
||||
<input
|
||||
type="radio"
|
||||
name="tokenizer"
|
||||
value="Word"
|
||||
id="tokenizer-word"
|
||||
checked
|
||||
/>
|
||||
<span class="radio-custom" aria-hidden="true"></span>
|
||||
<div class="radio-content">
|
||||
<span class="radio-label">Word</span>
|
||||
<span class="radio-description">Retain full words (default)</span>
|
||||
</div>
|
||||
</label>
|
||||
<label class="radio-option">
|
||||
<input type="radio" name="tokenizer" value="Line" id="tokenizer-line" />
|
||||
<span class="radio-custom" aria-hidden="true"></span>
|
||||
<div class="radio-content">
|
||||
<span class="radio-label">Line</span>
|
||||
<span class="radio-description"
|
||||
>Line-by-line, like <code>git merge</code></span
|
||||
>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="text-area-card diamond-parent">
|
||||
<label
|
||||
for="original"
|
||||
title="The original text before any concurrent edits were made."
|
||||
>Original</label
|
||||
>
|
||||
<textarea id="original" name="original"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="text-area-card diamond-left">
|
||||
<label
|
||||
for="left"
|
||||
title="First user's edits — changes from this box appear in green in the result."
|
||||
>
|
||||
First user's edits
|
||||
<div class="box Left"></div>
|
||||
</label>
|
||||
<textarea id="left" name="left"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="text-area-card diamond-right">
|
||||
<label
|
||||
for="right"
|
||||
title="Second user's edits — changes from this box appear in blue in the result."
|
||||
>
|
||||
Second user's edits
|
||||
<div class="box Right"></div>
|
||||
</label>
|
||||
<textarea id="right" name="right"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="text-area-card diamond-result">
|
||||
<label
|
||||
for="merged"
|
||||
title="The automatically merged result — edit the boxes above to see changes in real-time."
|
||||
>
|
||||
Merged 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>
|
||||
174
examples/website/src/index.ts
Normal file
174
examples/website/src/index.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import { reconcileWithHistory } from 'reconcile-text';
|
||||
import type { Tokenizer } from 'reconcile-text';
|
||||
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> {
|
||||
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) => {
|
||||
radio.addEventListener('change', updateMergedText);
|
||||
});
|
||||
|
||||
loadSample();
|
||||
updateMergedText();
|
||||
focusTextArea(leftTextArea);
|
||||
}
|
||||
|
||||
// Edit the instructions to generate example edits
|
||||
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 { 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 = '';
|
||||
|
||||
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 {
|
||||
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();
|
||||
483
examples/website/src/style.scss
Normal file
483
examples/website/src/style.scss
Normal file
|
|
@ -0,0 +1,483 @@
|
|||
// 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;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Arial, sans-serif;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
.scroll-container {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.background {
|
||||
background: linear-gradient(135deg, $light-bg 0%, $gradient-end 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: $primary-blue;
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
p,
|
||||
p * {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
code {
|
||||
background: $code-bg;
|
||||
color: $code-text;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Consolas', monospace;
|
||||
font-size: 0.875em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
header > p {
|
||||
color: $text-secondary;
|
||||
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: $white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px selection-colour($primary-blue, 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 selection-colour($primary-blue, 0.12);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.radio-option:has(input:checked) {
|
||||
background: $gradient-end;
|
||||
border-color: $primary-blue;
|
||||
box-shadow: 0 4px 16px selection-colour($primary-blue, 0.16);
|
||||
}
|
||||
|
||||
.radio-option input[type='radio'] {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.radio-custom {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid $border-grey;
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.radio-option:has(input:checked) .radio-custom {
|
||||
border-color: $primary-blue;
|
||||
background: $primary-blue;
|
||||
}
|
||||
|
||||
.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: $primary-blue;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.radio-description {
|
||||
font-size: 0.8rem;
|
||||
color: $text-primary;
|
||||
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: $white;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 12px 0 selection-colour($primary-blue, 0.06);
|
||||
padding: 18px 20px 16px 20px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
label {
|
||||
display: inline-block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
color: $primary-blue;
|
||||
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: $text-primary;
|
||||
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;
|
||||
|
||||
> * {
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
.Unchanged {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.Left,
|
||||
.AddedFromLeft,
|
||||
.RemovedFromLeft {
|
||||
user-select: text;
|
||||
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: $light-blue;
|
||||
}
|
||||
|
||||
.RemovedFromLeft,
|
||||
.RemovedFromRight {
|
||||
user-select: none;
|
||||
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;
|
||||
}
|
||||
|
||||
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: $text-secondary;
|
||||
}
|
||||
|
||||
.github-link > svg {
|
||||
position: absolute;
|
||||
color: $text-secondary;
|
||||
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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue