Skip to content

Commit e58dfa4

Browse files
JohnMcLearCopilotclaude
authored
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>
1 parent e0ccdb4 commit e58dfa4

6 files changed

Lines changed: 223 additions & 5 deletions

File tree

src/static/css/timeslider.css

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,15 +120,33 @@
120120
overflow-y: auto;
121121
}
122122
#outerdocbody {
123-
display: block;
123+
display: flex;
124+
flex-direction: row;
125+
justify-content: center;
126+
align-items: flex-start;
124127
width: 100%;
125128
}
126129

130+
#outerdocbody > #sidediv {
131+
flex: 0 0 auto;
132+
padding-top: 30px;
133+
padding-bottom: 30px;
134+
}
135+
136+
#outerdocbody > #innerdocbody {
137+
flex: 1 1 auto;
138+
min-width: 0;
139+
padding-right: calc(var(--editor-horizontal-padding, 0px) + 15px);
140+
padding-top: 30px;
141+
padding-left: calc(var(--editor-horizontal-padding, 0px) + 15px);
142+
padding-bottom: 30px;
143+
}
144+
127145
#innerdocbody {
128146
white-space: normal;
129147
word-break: break-word;
130148
width: 100%;
131-
margin: 0 auto;
149+
margin: 0;
132150
height: auto;
133151
}
134152

@@ -151,4 +169,4 @@
151169
display: flex;
152170
flex-wrap: wrap;
153171
}
154-
}
172+
}

src/static/js/broadcast.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,72 @@ const loadBroadcastJS = (socket, sendSocketMsg, fireWhenAllScriptsAreLoaded, Bro
132132
},
133133
};
134134

135+
const targetBody = document.getElementById('innerdocbody');
136+
const sideDiv = document.getElementById('sidediv');
137+
const sideDivInner = document.getElementById('sidedivinner');
138+
const appendNewSideDivLine = () => {
139+
const lineDiv = document.createElement('div');
140+
sideDivInner.appendChild(lineDiv);
141+
const lineSpan = document.createElement('span');
142+
lineSpan.classList.add('line-number');
143+
lineSpan.appendChild(document.createTextNode(sideDivInner.children.length));
144+
lineDiv.appendChild(lineSpan);
145+
};
146+
147+
const updateLineNumbers = () => {
148+
if (!targetBody || !sideDiv || !sideDivInner) return;
149+
const lineOffsets = [];
150+
const lineHeights = [];
151+
const innerdocbodyStyles = getComputedStyle(targetBody);
152+
const defaultLineHeight = parseInt(innerdocbodyStyles.lineHeight);
153+
154+
for (const docLine of targetBody.children) {
155+
let height;
156+
const nextDocLine = docLine.nextElementSibling;
157+
if (nextDocLine) {
158+
if (lineOffsets.length === 0) {
159+
height = nextDocLine.offsetTop - parseInt(
160+
innerdocbodyStyles.getPropertyValue('padding-top'));
161+
} else {
162+
height = nextDocLine.offsetTop - docLine.offsetTop;
163+
}
164+
} else {
165+
height = docLine.clientHeight || docLine.offsetHeight;
166+
}
167+
lineOffsets.push(height);
168+
169+
if (docLine.clientHeight !== defaultLineHeight && docLine.firstElementChild != null) {
170+
const elementStyle = window.getComputedStyle(docLine.firstElementChild);
171+
const lineHeight = parseInt(elementStyle.getPropertyValue('line-height'));
172+
const marginBottom = parseInt(elementStyle.getPropertyValue('margin-bottom'));
173+
lineHeights.push(lineHeight + marginBottom);
174+
} else {
175+
lineHeights.push(defaultLineHeight);
176+
}
177+
}
178+
179+
const newNumLines = Math.max(targetBody.children.length, 1);
180+
while (sideDivInner.children.length < newNumLines) appendNewSideDivLine();
181+
while (sideDivInner.children.length > newNumLines) sideDivInner.lastElementChild.remove();
182+
for (const [i, sideDivLine] of Array.prototype.entries.call(sideDivInner.children)) {
183+
sideDivLine.style.height = `${lineOffsets[i]}px`;
184+
sideDivLine.style.lineHeight = `${lineHeights[i]}px`;
185+
}
186+
$(sideDiv).addClass('sidedivdelayed');
187+
};
188+
189+
let lineNumberUpdatePending = false;
190+
const scheduleLineNumberUpdate = () => {
191+
if (lineNumberUpdatePending) return;
192+
lineNumberUpdatePending = true;
193+
window.requestAnimationFrame(() => {
194+
window.requestAnimationFrame(() => {
195+
lineNumberUpdatePending = false;
196+
updateLineNumbers();
197+
});
198+
});
199+
};
200+
135201
const applyChangeset = (changeset, revision, preventSliderMovement, timeDelta) => {
136202
// disable the next 'gotorevision' call handled by a timeslider update
137203
if (!preventSliderMovement) {
@@ -194,6 +260,7 @@ const loadBroadcastJS = (socket, sendSocketMsg, fireWhenAllScriptsAreLoaded, Bro
194260

195261
padContents.currentRevision = revision;
196262
padContents.currentTime += timeDelta;
263+
scheduleLineNumberUpdate();
197264

198265
updateTimer();
199266

@@ -465,6 +532,12 @@ const loadBroadcastJS = (socket, sendSocketMsg, fireWhenAllScriptsAreLoaded, Bro
465532
padContents.currentDivs.push(div);
466533
$('#innerdocbody').append(div);
467534
}
535+
updateLineNumbers();
536+
scheduleLineNumberUpdate();
537+
$(window).on('resize', scheduleLineNumberUpdate);
538+
window.addEventListener('load', scheduleLineNumberUpdate, {once: true});
539+
document.fonts?.ready?.then(scheduleLineNumberUpdate);
540+
$('#viewfontmenu').on('change', () => window.setTimeout(scheduleLineNumberUpdate, 0));
468541
});
469542

470543
// this is necessary to keep infinite loops of events firing,

src/static/js/timeslider.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,37 @@ let token, padId, exportLinks, socket, changesetLoader, BroadcastSlider;
3636
let cp = '';
3737
const playbackSpeedCookie = 'timesliderPlaybackSpeed';
3838

39+
const getPrefsCookieName = () => `${cp}${window.location.protocol === 'https:' ? 'prefs' : 'prefsHttp'}`;
40+
41+
const readPadPrefs = () => {
42+
try {
43+
let json = Cookies.get(getPrefsCookieName());
44+
if (json == null) {
45+
const unprefixed = window.location.protocol === 'https:' ? 'prefs' : 'prefsHttp';
46+
if (unprefixed !== getPrefsCookieName()) json = Cookies.get(unprefixed);
47+
}
48+
return json == null ? {} : JSON.parse(json);
49+
} catch (err) {
50+
return {};
51+
}
52+
};
53+
54+
const writePadPrefs = (prefs) => {
55+
Cookies.set(getPrefsCookieName(), JSON.stringify(prefs), {expires: 365 * 100});
56+
};
57+
58+
const setPadPref = (prefName, value) => {
59+
const prefs = readPadPrefs();
60+
prefs[prefName] = value;
61+
writePadPrefs(prefs);
62+
};
63+
64+
const applyShowLineNumbers = (showLineNumbers) => {
65+
padutils.setCheckbox($('#options-linenoscheck'), showLineNumbers);
66+
$('body').toggleClass('line-numbers-hidden', !showLineNumbers);
67+
window.requestAnimationFrame(() => $(window).trigger('resize'));
68+
};
69+
3970
const init = () => {
4071
padutils.setupGlobalExceptionHandler();
4172
$(document).ready(() => {
@@ -113,7 +144,7 @@ const fireWhenAllScriptsAreLoaded = [];
113144
const handleClientVars = (message) => {
114145
// save the client Vars
115146
window.clientVars = message.data;
116-
cp = window.clientVars.cookiePrefix || '';
147+
cp = (window as any).clientVars?.cookiePrefix || '';
117148

118149
if (window.clientVars.sessionRefreshInterval) {
119150
const ping =
@@ -169,6 +200,12 @@ const handleClientVars = (message) => {
169200
$('#playpause_button_icon').attr('title', html10n.get('timeslider.playPause'));
170201
$('#leftstep').attr('title', html10n.get('timeslider.backRevision'));
171202
$('#rightstep').attr('title', html10n.get('timeslider.forwardRevision'));
203+
padutils.bindCheckboxChange($('#options-linenoscheck'), () => {
204+
const showLineNumbers = padutils.getCheckbox('#options-linenoscheck');
205+
setPadPref('showLineNumbers', showLineNumbers);
206+
applyShowLineNumbers(showLineNumbers);
207+
});
208+
applyShowLineNumbers(readPadPrefs().showLineNumbers !== false);
172209

173210
// font family change
174211
$('#viewfontmenu').on('change', function () {

src/static/skins/colibris/timeslider.css

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,16 @@
8282
font-size: .9em;
8383
}
8484

85+
.timeslider #outerdocbody > #sidediv {
86+
padding-top: 30px;
87+
padding-bottom: 30px;
88+
}
89+
90+
.timeslider #outerdocbody > #innerdocbody {
91+
padding-top: 30px;
92+
padding-bottom: 30px;
93+
}
94+
8595
@media (max-width: 800px) {
8696

8797
#slider-btn-container {
@@ -95,4 +105,4 @@
95105
#slider-btn-container #playpause_button_icon:before {
96106
font-size: 18px;
97107
}
98-
}
108+
}

src/templates/timeslider.html

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,12 @@ <h1 class="timeslider-title">
110110
<!----------------------------->
111111

112112
<div id="outerdocbody">
113+
<div id="sidediv" class="sidediv">
114+
<div id="sidedivinner" class="sidedivinner"></div>
115+
</div>
113116
<div id="innerdocbody">
114117
</div>
118+
<div id="linemetricsdiv">x</div>
115119
</div>
116120

117121

@@ -248,6 +252,10 @@ <h1 data-l10n-id="pad.settings.padSettings"></h1>
248252
<option value="1000" data-l10n-id="timeslider.settings.playbackSpeed.1000ms">1000 ms</option>
249253
</select>
250254
</p>
255+
<p>
256+
<input type="checkbox" id="options-linenoscheck">
257+
<label for="options-linenoscheck" data-l10n-id="pad.settings.linenocheck"></label>
258+
</p>
251259
</div></div>
252260
</div>
253261
</body>
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import {expect, test} from "@playwright/test";
2+
import {clearPadContent, goToNewPad, writeToPad} from "../helper/padHelper";
3+
import {showSettings} from "../helper/settingsHelper";
4+
5+
test.describe('timeslider line numbers', function () {
6+
test.beforeEach(async ({context}) => {
7+
await context.clearCookies();
8+
});
9+
10+
test('shows line numbers aligned with the rendered document lines', async function ({page}) {
11+
const padId = await goToNewPad(page);
12+
await clearPadContent(page);
13+
await writeToPad(page, 'One\nTwo\nThree');
14+
await page.waitForTimeout(1000);
15+
16+
await page.goto(`http://localhost:9001/p/${padId}/timeslider`);
17+
await page.waitForSelector('#timeslider-wrapper', {state: 'visible'});
18+
await page.waitForSelector('#sidediv.sidedivdelayed', {state: 'attached'});
19+
await page.waitForTimeout(1000);
20+
21+
await expect(page.locator('#sidediv')).toBeVisible();
22+
await expect(page.locator('#sidediv .line-number').nth(0)).toHaveText('1');
23+
await expect(page.locator('#sidediv .line-number').nth(1)).toHaveText('2');
24+
await expect(page.locator('#sidediv .line-number').nth(2)).toHaveText('3');
25+
26+
const counts = await page.evaluate(() => ({
27+
docLines: document.querySelector('#innerdocbody')?.children.length,
28+
gutterLines: document.querySelector('#sidedivinner')?.children.length,
29+
}));
30+
expect(counts.gutterLines).toBe(counts.docLines);
31+
32+
const alignment = await page.evaluate(() => {
33+
const innerdocbody = document.querySelector('#innerdocbody');
34+
const sidediv = document.querySelector('#sidediv');
35+
const docLines = [...document.querySelectorAll('#innerdocbody > div')];
36+
const gutterLines = [...document.querySelectorAll('#sidedivinner > div')];
37+
const sideRect = sidediv?.getBoundingClientRect();
38+
const innerRect = innerdocbody?.getBoundingClientRect();
39+
return {
40+
gap: sideRect && innerRect ? Math.abs(innerRect.left - sideRect.right) : null,
41+
};
42+
});
43+
44+
expect(alignment.gap).not.toBeNull();
45+
expect(alignment.gap!).toBeLessThanOrEqual(2);
46+
});
47+
48+
test('inherits and persists the line-number preference from the shared cookie', async function ({page}) {
49+
const padId = await goToNewPad(page);
50+
await page.context().addCookies([{
51+
name: 'prefsHttp',
52+
value: encodeURIComponent(JSON.stringify({showLineNumbers: false})),
53+
url: 'http://localhost:9001',
54+
}]);
55+
56+
await page.goto(`http://localhost:9001/p/${padId}/timeslider`);
57+
await page.waitForSelector('#timeslider-wrapper', {state: 'visible'});
58+
await showSettings(page);
59+
60+
await expect(page.locator('#options-linenoscheck')).not.toBeChecked();
61+
await expect(page.locator('body')).toHaveClass(/line-numbers-hidden/);
62+
63+
await page.locator('label[for="options-linenoscheck"]').click();
64+
await expect(page.locator('#options-linenoscheck')).toBeChecked();
65+
await expect(page.locator('body')).not.toHaveClass(/line-numbers-hidden/);
66+
67+
await page.reload();
68+
await page.waitForSelector('#timeslider-wrapper', {state: 'visible'});
69+
await expect(page.locator('#options-linenoscheck')).toBeChecked();
70+
await expect(page.locator('body')).not.toHaveClass(/line-numbers-hidden/);
71+
});
72+
});

0 commit comments

Comments
 (0)