feat: add timeslider line numbers (#7542)

* feat: add timeslider line numbers

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* perf: coalesce timeslider line-number updates

Addresses Qodo review: updateLineNumbers() was called synchronously
from applyChangeset() on every changeset, forcing full-document layout
reads/writes during timeslider scrubbing/playback. scheduleLineNumberUpdate()
also queued a fresh double-rAF pair for every resize tick. Add a pending
flag so only one rAF pair is in flight, and route applyChangeset() through
the scheduler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
John McLear 2026-04-19 11:18:54 +01:00 committed by GitHub
parent e0ccdb4d9f
commit e58dfa4752
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 223 additions and 5 deletions

View File

@ -120,15 +120,33 @@
overflow-y: auto;
}
#outerdocbody {
display: block;
display: flex;
flex-direction: row;
justify-content: center;
align-items: flex-start;
width: 100%;
}
#outerdocbody > #sidediv {
flex: 0 0 auto;
padding-top: 30px;
padding-bottom: 30px;
}
#outerdocbody > #innerdocbody {
flex: 1 1 auto;
min-width: 0;
padding-right: calc(var(--editor-horizontal-padding, 0px) + 15px);
padding-top: 30px;
padding-left: calc(var(--editor-horizontal-padding, 0px) + 15px);
padding-bottom: 30px;
}
#innerdocbody {
white-space: normal;
word-break: break-word;
width: 100%;
margin: 0 auto;
margin: 0;
height: auto;
}
@ -151,4 +169,4 @@
display: flex;
flex-wrap: wrap;
}
}
}

View File

@ -132,6 +132,72 @@ const loadBroadcastJS = (socket, sendSocketMsg, fireWhenAllScriptsAreLoaded, Bro
},
};
const targetBody = document.getElementById('innerdocbody');
const sideDiv = document.getElementById('sidediv');
const sideDivInner = document.getElementById('sidedivinner');
const appendNewSideDivLine = () => {
const lineDiv = document.createElement('div');
sideDivInner.appendChild(lineDiv);
const lineSpan = document.createElement('span');
lineSpan.classList.add('line-number');
lineSpan.appendChild(document.createTextNode(sideDivInner.children.length));
lineDiv.appendChild(lineSpan);
};
const updateLineNumbers = () => {
if (!targetBody || !sideDiv || !sideDivInner) return;
const lineOffsets = [];
const lineHeights = [];
const innerdocbodyStyles = getComputedStyle(targetBody);
const defaultLineHeight = parseInt(innerdocbodyStyles.lineHeight);
for (const docLine of targetBody.children) {
let height;
const nextDocLine = docLine.nextElementSibling;
if (nextDocLine) {
if (lineOffsets.length === 0) {
height = nextDocLine.offsetTop - parseInt(
innerdocbodyStyles.getPropertyValue('padding-top'));
} else {
height = nextDocLine.offsetTop - docLine.offsetTop;
}
} else {
height = docLine.clientHeight || docLine.offsetHeight;
}
lineOffsets.push(height);
if (docLine.clientHeight !== defaultLineHeight && docLine.firstElementChild != null) {
const elementStyle = window.getComputedStyle(docLine.firstElementChild);
const lineHeight = parseInt(elementStyle.getPropertyValue('line-height'));
const marginBottom = parseInt(elementStyle.getPropertyValue('margin-bottom'));
lineHeights.push(lineHeight + marginBottom);
} else {
lineHeights.push(defaultLineHeight);
}
}
const newNumLines = Math.max(targetBody.children.length, 1);
while (sideDivInner.children.length < newNumLines) appendNewSideDivLine();
while (sideDivInner.children.length > newNumLines) sideDivInner.lastElementChild.remove();
for (const [i, sideDivLine] of Array.prototype.entries.call(sideDivInner.children)) {
sideDivLine.style.height = `${lineOffsets[i]}px`;
sideDivLine.style.lineHeight = `${lineHeights[i]}px`;
}
$(sideDiv).addClass('sidedivdelayed');
};
let lineNumberUpdatePending = false;
const scheduleLineNumberUpdate = () => {
if (lineNumberUpdatePending) return;
lineNumberUpdatePending = true;
window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => {
lineNumberUpdatePending = false;
updateLineNumbers();
});
});
};
const applyChangeset = (changeset, revision, preventSliderMovement, timeDelta) => {
// disable the next 'gotorevision' call handled by a timeslider update
if (!preventSliderMovement) {
@ -194,6 +260,7 @@ const loadBroadcastJS = (socket, sendSocketMsg, fireWhenAllScriptsAreLoaded, Bro
padContents.currentRevision = revision;
padContents.currentTime += timeDelta;
scheduleLineNumberUpdate();
updateTimer();
@ -465,6 +532,12 @@ const loadBroadcastJS = (socket, sendSocketMsg, fireWhenAllScriptsAreLoaded, Bro
padContents.currentDivs.push(div);
$('#innerdocbody').append(div);
}
updateLineNumbers();
scheduleLineNumberUpdate();
$(window).on('resize', scheduleLineNumberUpdate);
window.addEventListener('load', scheduleLineNumberUpdate, {once: true});
document.fonts?.ready?.then(scheduleLineNumberUpdate);
$('#viewfontmenu').on('change', () => window.setTimeout(scheduleLineNumberUpdate, 0));
});
// this is necessary to keep infinite loops of events firing,

View File

@ -36,6 +36,37 @@ let token, padId, exportLinks, socket, changesetLoader, BroadcastSlider;
let cp = '';
const playbackSpeedCookie = 'timesliderPlaybackSpeed';
const getPrefsCookieName = () => `${cp}${window.location.protocol === 'https:' ? 'prefs' : 'prefsHttp'}`;
const readPadPrefs = () => {
try {
let json = Cookies.get(getPrefsCookieName());
if (json == null) {
const unprefixed = window.location.protocol === 'https:' ? 'prefs' : 'prefsHttp';
if (unprefixed !== getPrefsCookieName()) json = Cookies.get(unprefixed);
}
return json == null ? {} : JSON.parse(json);
} catch (err) {
return {};
}
};
const writePadPrefs = (prefs) => {
Cookies.set(getPrefsCookieName(), JSON.stringify(prefs), {expires: 365 * 100});
};
const setPadPref = (prefName, value) => {
const prefs = readPadPrefs();
prefs[prefName] = value;
writePadPrefs(prefs);
};
const applyShowLineNumbers = (showLineNumbers) => {
padutils.setCheckbox($('#options-linenoscheck'), showLineNumbers);
$('body').toggleClass('line-numbers-hidden', !showLineNumbers);
window.requestAnimationFrame(() => $(window).trigger('resize'));
};
const init = () => {
padutils.setupGlobalExceptionHandler();
$(document).ready(() => {
@ -113,7 +144,7 @@ const fireWhenAllScriptsAreLoaded = [];
const handleClientVars = (message) => {
// save the client Vars
window.clientVars = message.data;
cp = window.clientVars.cookiePrefix || '';
cp = (window as any).clientVars?.cookiePrefix || '';
if (window.clientVars.sessionRefreshInterval) {
const ping =
@ -169,6 +200,12 @@ const handleClientVars = (message) => {
$('#playpause_button_icon').attr('title', html10n.get('timeslider.playPause'));
$('#leftstep').attr('title', html10n.get('timeslider.backRevision'));
$('#rightstep').attr('title', html10n.get('timeslider.forwardRevision'));
padutils.bindCheckboxChange($('#options-linenoscheck'), () => {
const showLineNumbers = padutils.getCheckbox('#options-linenoscheck');
setPadPref('showLineNumbers', showLineNumbers);
applyShowLineNumbers(showLineNumbers);
});
applyShowLineNumbers(readPadPrefs().showLineNumbers !== false);
// font family change
$('#viewfontmenu').on('change', function () {

View File

@ -82,6 +82,16 @@
font-size: .9em;
}
.timeslider #outerdocbody > #sidediv {
padding-top: 30px;
padding-bottom: 30px;
}
.timeslider #outerdocbody > #innerdocbody {
padding-top: 30px;
padding-bottom: 30px;
}
@media (max-width: 800px) {
#slider-btn-container {
@ -95,4 +105,4 @@
#slider-btn-container #playpause_button_icon:before {
font-size: 18px;
}
}
}

View File

@ -110,8 +110,12 @@
<!----------------------------->
<div id="outerdocbody">
<div id="sidediv" class="sidediv">
<div id="sidedivinner" class="sidedivinner"></div>
</div>
<div id="innerdocbody">
</div>
<div id="linemetricsdiv">x</div>
</div>
@ -248,6 +252,10 @@
<option value="1000" data-l10n-id="timeslider.settings.playbackSpeed.1000ms">1000 ms</option>
</select>
</p>
<p>
<input type="checkbox" id="options-linenoscheck">
<label for="options-linenoscheck" data-l10n-id="pad.settings.linenocheck"></label>
</p>
</div></div>
</div>
</body>

View File

@ -0,0 +1,72 @@
import {expect, test} from "@playwright/test";
import {clearPadContent, goToNewPad, writeToPad} from "../helper/padHelper";
import {showSettings} from "../helper/settingsHelper";
test.describe('timeslider line numbers', function () {
test.beforeEach(async ({context}) => {
await context.clearCookies();
});
test('shows line numbers aligned with the rendered document lines', async function ({page}) {
const padId = await goToNewPad(page);
await clearPadContent(page);
await writeToPad(page, 'One\nTwo\nThree');
await page.waitForTimeout(1000);
await page.goto(`http://localhost:9001/p/${padId}/timeslider`);
await page.waitForSelector('#timeslider-wrapper', {state: 'visible'});
await page.waitForSelector('#sidediv.sidedivdelayed', {state: 'attached'});
await page.waitForTimeout(1000);
await expect(page.locator('#sidediv')).toBeVisible();
await expect(page.locator('#sidediv .line-number').nth(0)).toHaveText('1');
await expect(page.locator('#sidediv .line-number').nth(1)).toHaveText('2');
await expect(page.locator('#sidediv .line-number').nth(2)).toHaveText('3');
const counts = await page.evaluate(() => ({
docLines: document.querySelector('#innerdocbody')?.children.length,
gutterLines: document.querySelector('#sidedivinner')?.children.length,
}));
expect(counts.gutterLines).toBe(counts.docLines);
const alignment = await page.evaluate(() => {
const innerdocbody = document.querySelector('#innerdocbody');
const sidediv = document.querySelector('#sidediv');
const docLines = [...document.querySelectorAll('#innerdocbody > div')];
const gutterLines = [...document.querySelectorAll('#sidedivinner > div')];
const sideRect = sidediv?.getBoundingClientRect();
const innerRect = innerdocbody?.getBoundingClientRect();
return {
gap: sideRect && innerRect ? Math.abs(innerRect.left - sideRect.right) : null,
};
});
expect(alignment.gap).not.toBeNull();
expect(alignment.gap!).toBeLessThanOrEqual(2);
});
test('inherits and persists the line-number preference from the shared cookie', async function ({page}) {
const padId = await goToNewPad(page);
await page.context().addCookies([{
name: 'prefsHttp',
value: encodeURIComponent(JSON.stringify({showLineNumbers: false})),
url: 'http://localhost:9001',
}]);
await page.goto(`http://localhost:9001/p/${padId}/timeslider`);
await page.waitForSelector('#timeslider-wrapper', {state: 'visible'});
await showSettings(page);
await expect(page.locator('#options-linenoscheck')).not.toBeChecked();
await expect(page.locator('body')).toHaveClass(/line-numbers-hidden/);
await page.locator('label[for="options-linenoscheck"]').click();
await expect(page.locator('#options-linenoscheck')).toBeChecked();
await expect(page.locator('body')).not.toHaveClass(/line-numbers-hidden/);
await page.reload();
await page.waitForSelector('#timeslider-wrapper', {state: 'visible'});
await expect(page.locator('#options-linenoscheck')).toBeChecked();
await expect(page.locator('body')).not.toHaveClass(/line-numbers-hidden/);
});
});