mirror of
https://github.com/ether/etherpad-lite.git
synced 2026-05-04 11:51:21 +02:00
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:
parent
e0ccdb4d9f
commit
e58dfa4752
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 () {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
72
src/tests/frontend-new/specs/timeslider_line_numbers.spec.ts
Normal file
72
src/tests/frontend-new/specs/timeslider_line_numbers.spec.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user