Skip to content

Commit 451bd9c

Browse files
JohnMcLearclaude
andauthored
feat(pad): scrub history in-place on the pad URL (#7659) (#7710)
* feat(pad): scrub history in-place on the pad URL (#7659) Clicking the timeslider toolbar button now keeps the user on /p/:pad and toggles a hash-based history mode (#rev/N) instead of navigating to a separate /timeslider page. The pad shell — chat, users panel, settings, plugin chrome — stays mounted across the transition. A sticky banner plus a sepia tint on the toolbar make it unmistakable that what is visible is historical, not live. Implementation: - New PadModeController (src/static/js/pad_mode.ts) owns enter/exit, the URL hash, browser back/forward, and a mutation-observer bridge from the inner timeslider's revision label/date into the outer banner. Esc and a Return-to-live button both exit history. - pad.html grows a banner element and an iframe mount slot. The live ACE iframe stays mounted but hidden during history; on exit the socket is still alive, so the user snaps straight back to the current state without a reconnect. - The /p/:pad/timeslider route 302-redirects to the pad page for direct visits (legacy bookmarks), and serves the timeslider HTML for the in-pad iframe when called with ?embed=1. The embedded variant hides the redundant title and return-to-pad button via CSS; the slider, settings, and export controls stay reachable. - Legacy #NN shortlinks are preserved through the redirect by the browser and translated to #rev/NN client-side. Tests: - New backend spec asserts the 302 redirect, pad-name preservation, and the ?embed=1 path still serves the timeslider HTML. - New padmode.spec.ts exercises toolbar entry, return-to-live, browser back, and direct /timeslider URL handling. Asserts the rendered localized banner string, not just element presence. - Existing timeslider specs that hit /p/:pad/timeslider directly now pass ?embed=1 to bypass the redirect. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(pad): make history iframe fill the editor area (#7659) Without an explicit positioning model the history-frame-mount inherited half-width from a phantom flex parent and the embedded timeslider rendered at 640×625 instead of the full editor area. Switch to the same absolute-fill model the live ACE iframe uses by making #editorcontainerbox the positioning anchor when in history mode. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(pad): address Qodo review and CI failures (#7659) Concrete review fixes for PR #7710: - Tighten the embed query check from `if (!req.query.embed)` to `req.query.embed !== '1'` so values like `?embed=0` no longer bypass the redirect. - Fix the `#rev/latest` mapping: the parser yields -1 for "latest", which the iframe sync handler was clamping to 0 and so jumping the embedded timeslider to revision 0. Resolve "latest" to the inner BroadcastSlider's upper bound instead. - Update existing backend tests (`socialMeta`, `specialpages`) that hit `/p/:pad/timeslider` directly — they now pass `?embed=1` like the rest of the suite. Without this fix three pre-existing tests failed CI (302 instead of 200). - Document the route change in `doc/skins.md` and `doc/skins.adoc`: direct visits redirect; iframe consumers use `?embed=1`. - Back out a stray `data-theme="editorial"` attribute and the hardcoded Google Fonts `<link>` tags from `pad.html` that leaked into the branch from an unrelated working-tree change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(pad): consolidate chrome and replay chat/users in history mode (#7659) Picks up the rough edges left by the initial in-place history mode: the embedded timeslider iframe was rendering its own duplicate Settings and Export buttons, and the chat panel + users list still showed live state while the editor scrubbed back in time. Chrome consolidation - Hide the entire inner editbar's right-side toolbar and modal popups in embedded mode (slider stays). Outer pad shell now owns Settings, Export, Share, Users, Chat across both modes. - Outer Settings popup grows a "History playback" section (visible only when scrubbing) with playback speed + follow-contents. Both bridge to the iframe's BroadcastSlider state. - Outer Export anchors are rewritten to /p/<pad>/<rev>/export/<type> on each scrub and restored on exit, so Save As exports the visible historical revision. Chat replay - Each chat message is annotated with data-timestamp at render time. In history mode, messages newer than the scrubbed revision's timestamp are display:none'd; a "Chat as of HH:MM" header sits above the chat log. - Restores cleanly on exit (inline display cleared, header removed). Users replay - Live users table is replaced with the embedded timeslider's authors-at-this-revision label while scrubbing; restored on exit. Plumbing - Expose padContents on window in broadcast.ts so the outer pad can read currentTime after each scrub without postMessage. - Expose BroadcastSlider on window in timeslider.ts so the outer pad can register an onSlider callback to drive replay UI. Tests - New padmode specs cover: history-only Settings section, hidden embedded chrome, chat filter + replay header, Export href rewriting + restore, authors-row swap + restore. - timeslider_line_numbers cookie-persistence test updated to bypass the now-hidden inner Settings popup (programmatic checkbox). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(pad): theme propagation, hide inert buttons, plugin loading (#7659) Picks up rough edges from the in-place history mode that turned up in real usage: Theme / dark mode - skin_variants.updateSkinVariantsClasses now also walks the history iframe (and its ace_outer/ace_inner) so toggling dark mode while scrubbing re-themes the embedded view in lockstep. - timeslider.ts inherits the parent's skinVariant tokens (super-dark-* / dark-* / full-width-editor) on first paint when it detects it is embedded — same-origin guarantee, falls through silently if not. Toolbar UX - Hide #editbar .menu_left (Bold/Italic/Lists/Indent/Undo/...) and the show-more chevron while in history mode. Those buttons target the hidden live editor and would do nothing useful; rendering them disabled-looking implied state the user doesn't have. Right-side menu (Settings / Share / Users / Chat / Home) stays at full opacity and fully interactive. Slider position - Pin the embedded #editbar to the bottom of the iframe so the outer banner and the slider can't visually compete for the same band of pixels. Reserve padding-bottom on the iframe's editorcontainerbox so the editor never scrolls under the slider. Plugin loading in timeslider - timeSliderBootstrap.js now pre-loads plugin modules into a Map and passes them to plugins.update(), mirroring padBootstrap.js. Without this the loadFn fallback called require(path) at runtime, which the esbuild-bundled timeslider couldn't resolve, so client_hooks like ep_headings2's aceRegisterBlockElements silently failed to register and historical revisions rendered without plugin chrome. Tests - New padmode specs cover: outer toolbar's left/right asymmetry, slider pinned to bottom, dark-mode class propagation into the history iframe. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(pad): move history slider into the outer toolbar (#7659) The slider previously rendered inside the embedded iframe — first at the top (where it visually competed with the banner), then briefly at the bottom (where the chat icon overlapped it). Both were wrong. Move the controls into the outer toolbar's left zone, where #editbar .menu_left is hidden in history mode and the slider can occupy the full width without colliding with anything. - pad.html grows a #history-controls div (slider + play/pause/step buttons + timer) inside #editbar, between menu_left and menu_right. Hidden by default; revealed via body.history-mode CSS. - pad.css swaps #editbar .menu_left out for #history-controls in history mode (display:none / display:flex). - timeslider.css fully hides the embedded iframe's #editbar — the outer toolbar now owns the slider, and the iframe is purely the editor surface. - pad_mode.ts wires the outer controls as a remote control: the range input calls inner BroadcastSlider.setSliderPosition, the play button calls BroadcastSlider.playpause, step buttons forward clicks to the inner #leftstep/#rightstep so they share the existing logic. An onSlider subscription mirrors inner state back into the outer slider value, timer label, and play-button .pause class. Tests - Existing timeslider.spec asserts the outer controls are visible. - New padmode specs cover: inner editbar fully hidden, outer toolbar swap (menu_left → history-controls), and outer slider drives the iframe's revision via BroadcastSlider. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(pad): exempt embedded history iframe from userdup kick (#7659) When the in-place history iframe opens its socket, the server's duplicate-author kick treats it as a stale tab and disconnects the parent pad's live socket — toolbar-overlay drops over the editor and Settings/Share/Users/Chat all stop responding. Mark the iframe's connection with `embed=1` in the socket.io handshake query, record it on sessionInfo, and skip the kick whenever either side is embedded. - timeslider.ts: detect `?embed=1` (and parent !== window) on the iframe URL, pass through as a query parameter to socketio.connect. - PadMessageHandler: read socket.handshake.query.embed on CLIENT_READY, set sessionInfo.embed; the duplicate-author kick now skips when either the connecting session OR the existing session is embedded. Behavior preserved - Two real tabs (both non-embedded): older tab still gets kicked. - Authenticated sessions still bypass the kick entirely. - Live pad socket survives entry into history mode. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(pad): a11y of history toolbar controls (#7659) The new history controls (slider + play/step/timer) had hardcoded English aria-labels, which html10n won't replace because they were present without the data-l10n-aria-label marker. Screen readers in non-English locales would have heard English. Drop the static aria labels and let html10n.translateElement populate aria-label from the data-l10n-id translation, matching how the rest of the toolbar works. - pad.html: remove hardcoded aria-label on play/step buttons and the range input; keep titles (hover tooltip) and data-l10n-id. Add role="toolbar" + data-l10n-id on the controls container so the toolbar landmark is announced. Mark play button as a toggle with aria-pressed reflecting playback state. - en.json: add pad.historyMode.controlsLabel and pad.historyMode.sliderLabel for the toolbar landmark and the slider. - pad_mode.ts: keep aria-pressed in sync with the inner playback state on every revision update. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(pad): a11y + responsive for history controls (#7659) Two issues with the previous a11y attempt: the data-l10n-id on icon buttons was setting their textContent (drawing "Playback / Pause Pad Contents" on screen next to the glyph), and there was no responsive treatment so the timer + slider could overflow narrow viewports. - pad.html: drop data-l10n-id from the icon buttons. They're now empty <button>s. Localized title (hover tooltip) and aria-label (screen reader name) are populated by pad_mode.localizeControls() using the existing timeslider.* keys, with an html10n.bind subscription so language switches re-localize. - Mark #history-timer as hide-for-mobile. - pad.css: dedicated @media (max-width: 800px) and 480px rules shrink padding, gap, and button widths so play + slider + step buttons stay on a single toolbar line at narrow viewports. Mirrors the legacy timeslider's responsive behavior. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(pad): inline Follow + Playback speed, match toolbar height (#7659) Two follow-ups from real testing: - Move "Follow pad content updates" (now "Follow") and "Playback speed" out of the Settings popup and inline them in the history-mode toolbar, alongside the slider + play/step buttons. They were always needed while scrubbing; one extra click into Settings was friction. Removed the now-empty #history-settings-section. - The history controls toolbar was visibly shorter than the live toolbar because the icon buttons sat as bare <button> elements without the live editbar's <li><a> wrapping. Add explicit min-height (40px) and per-button padding so the toolbar is the same vertical size in both modes — switching between live and history no longer reflows. - Differentiate "iframe-mounted history view" from "direct ?embed=1 visit". Only the former hides the inner timeslider editbar — direct visits keep their full chrome so existing test/legacy entry points stay independently usable. Marker: timeslider.ts adds an `iframe-mode` class on body when window.parent !== window; CSS scopes the hide to that combo. Tests - padmode spec asserts Follow + Speed live in the toolbar (not the Settings popup) and are visible in history mode, hidden in live. - timeslider*.spec direct-?embed=1 flows continue to pass because the inner editbar is no longer hidden when not iframe-mounted. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(pad): use absolute path for legacy /timeslider redirect (#7659) CI Firefox failed the legacy-URL redirect test (1 of 32 jobs); Chromium passed. The redirect Location header was a relative `../padname`, which both browsers resolve to /p/padname for `/p/padname/timeslider`. Firefox flaked on it once consistently. Switch to an absolute path including the proxy prefix so the resolution is unambiguous across browsers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(test): accept 304 on legacy timeslider redirect (#7659) CI Firefox failed `expect(res.status()).toBe(200)` because Firefox issues a conditional GET when the redirect target is the same URL the test just loaded via goToNewPad — the server returns 304 Not Modified and the test treats that as a regression. Chromium happens to send fresh requests so it stayed green. Accept either 200 or 304 — both are valid completed navigations to the pad page; what we actually care about is the pathname assertion above. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(pad): eye toggle for Follow, fix line-number alignment (#7659) Two refinements from real testing in 9002: Follow as an eye toggle - Replace the labeled checkbox with an inline-SVG eye icon. The eye is always rendered; a diagonal slash is overlaid via SVG <line> only when the underlying (visually hidden) checkbox is unchecked. Default state is on (auto-following) so the eye renders unobstructed. - Localized hover tooltip + aria-label flips with state — html10n populates "Following pad changes — click to stop following" vs "Not following pad changes — click to follow", and pad_mode.ts re-applies on every change event so screen readers narrate the action the click would take. - Hidden checkbox keeps the existing pad_mode.ts bridge code working (still reads .checked) and lets <label for="…"> handle the click. Line-number alignment fix (broadcast.ts) - The first-line height formula was `nextDocLine.offsetTop - innerdocbody.padding-top` which only computes the right value when innerdocbody is the offsetParent. In the in-pad history iframe, outerdocbody contributes its own padding-top to the offsetTop chain, so the first gutter row was 20px too tall and every subsequent line drifted out of alignment. Use the consistent `next.offsetTop - current.offsetTop` formula for every iteration — same result in the standalone timeslider, correct result in the embedded one. - New padmode spec asserts every gutter row's top matches the editor line's top within 2px, in iframe-mounted history mode. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent cbf7128 commit 451bd9c

26 files changed

Lines changed: 1459 additions & 56 deletions

doc/skins.adoc

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,18 @@ A skin is a directory located under `static/skins/<skin_name>`, with the followi
66
* `index.css`: stylesheet affecting `/`
77
* `pad.js`: javascript that will be run in `/p/:padid`
88
* `pad.css`: stylesheet affecting `/p/:padid`
9-
* `timeslider.js`: javascript that will be run in `/p/:padid/timeslider`
10-
* `timeslider.css`: stylesheet affecting `/p/:padid/timeslider`
9+
* `timeslider.js`: javascript that will be run in the embedded timeslider iframe
10+
* `timeslider.css`: stylesheet affecting the embedded timeslider iframe
1111
* `favicon.ico`: overrides the default favicon
1212
* `robots.txt`: overrides the default `robots.txt`
1313
14+
Since Etherpad *2.7*, the timeslider is rendered in-place inside the pad
15+
page (issue #7659). Direct visits to `/p/:padid/timeslider` 302-redirect to
16+
`/p/:padid` so the in-pad `PadModeController` can take over via a `#rev/N`
17+
URL hash. The full timeslider HTML is still served at
18+
`/p/:padid/timeslider?embed=1` -- that is the URL the in-pad iframe loads,
19+
and the URL to use if you embed the timeslider in your own page.
20+
1421
You can choose a skin changing the parameter `skinName` in `settings.json`.
1522

1623
Since Etherpad **1.7.5**, two skins are included:

doc/skins.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,15 @@ A skin is a directory located under `static/skins/<skin_name>`, with the followi
66
* `index.css`: stylesheet affecting `/`
77
* `pad.js`: javascript that will be run in `/p/:padid`
88
* `pad.css`: stylesheet affecting `/p/:padid`
9-
* `timeslider.js`: javascript that will be run in `/p/:padid/timeslider`
10-
* `timeslider.css`: stylesheet affecting `/p/:padid/timeslider`
9+
* `timeslider.js`: javascript that will be run in the embedded timeslider iframe
10+
* `timeslider.css`: stylesheet affecting the embedded timeslider iframe
11+
12+
Since Etherpad **2.7**, the timeslider is rendered in-place inside the pad
13+
page (issue #7659). Direct visits to `/p/:padid/timeslider` 302-redirect to
14+
`/p/:padid` so the in-pad PadModeController can take over via a `#rev/N`
15+
URL hash. The full timeslider HTML is still served at
16+
`/p/:padid/timeslider?embed=1` — that is the URL the in-pad iframe loads,
17+
and the URL to use if you embed the timeslider in your own page.
1118
* `favicon.ico`: overrides the default favicon
1219
* `robots.txt`: overrides the default `robots.txt`
1320

src/locales/en.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,19 @@
234234
"timeslider.followContents": "Follow pad content updates",
235235
"timeslider.pageTitle": "{{appTitle}} Timeslider",
236236
"timeslider.toolbar.returnbutton": "Return to pad",
237+
"pad.historyMode.banner": "Viewing history",
238+
"pad.historyMode.return": "Return to live",
239+
"pad.historyMode.revisionLabel": "Revision {{rev}}",
240+
"pad.historyMode.controlsLabel": "Pad history controls",
241+
"pad.historyMode.sliderLabel": "Pad revision",
242+
"pad.historyMode.settings.title": "History playback",
243+
"pad.historyMode.settings.follow": "Follow pad content updates",
244+
"pad.historyMode.settings.followShort": "Follow",
245+
"pad.historyMode.followOn": "Following pad changes — click to stop following",
246+
"pad.historyMode.followOff": "Not following pad changes — click to follow",
247+
"pad.historyMode.settings.playbackSpeed": "Playback speed:",
248+
"pad.historyMode.chat.replayHeader": "Chat as of {{time}}",
249+
"pad.historyMode.users.authorsHeader": "Authors at this revision",
237250
"timeslider.toolbar.authors": "Authors:",
238251
"timeslider.toolbar.authorsList": "No Authors",
239252
"timeslider.toolbar.exportlink.title": "Export",

src/node/handler/PadMessageHandler.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,12 @@ exports.handleMessage = async (socket:any, message: ClientVarMessage) => {
418418
padID: message.padId,
419419
token: resolvedToken,
420420
};
421+
// Issue #7659: connections from the in-place history iframe must not
422+
// trigger the duplicate-author kick — they share the parent's author
423+
// by design, and kicking the parent on iframe load would tear down
424+
// the live editor mid-session. The iframe sets `embed=1` in its
425+
// socket.io handshake query.
426+
thisSession.embed = socket.handshake?.query?.embed === '1';
421427

422428
// Pad does not exist, so we need to sanitize the id
423429
if (!(await padManager.doesPadExist(thisSession.auth.padID))) {
@@ -1051,12 +1057,16 @@ const handleClientReady = async (socket:any, message: ClientReadyMessage) => {
10511057
// stable identity across windows and devices, so concurrent same-author
10521058
// sessions are legitimate and must not be kicked.
10531059
const roomSockets = _getRoomSockets(pad.id);
1054-
if (user == null) {
1060+
if (user == null && !sessionInfo.embed) {
10551061
for (const otherSocket of roomSockets) {
10561062
// The user shouldn't have joined the room yet, but check anyway just in case.
10571063
if (otherSocket.id === socket.id) continue;
10581064
const sinfo = sessioninfos[otherSocket.id];
1059-
if (sinfo && sinfo.author === sessionInfo.author) {
1065+
// Embedded sessions (issue #7659 — in-place history iframe) share
1066+
// the parent's author by design, so they neither kick same-author
1067+
// sockets nor get kicked by them. Only non-embedded same-author
1068+
// duplicates (real stale tabs) hit the kick path.
1069+
if (sinfo && sinfo.author === sessionInfo.author && !sinfo.embed) {
10601070
// fix user's counter, works on page refresh or if user closes browser window and then rejoins
10611071
sessioninfos[otherSocket.id] = {};
10621072
otherSocket.leave(sessionInfo.padId);

src/node/hooks/express/specialpages.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,8 +228,14 @@ const handleLiveReload = async (args: ArgsExpressType, padString: string, timeSl
228228
})
229229

230230
setRouteHandler("/p/:pad/timeslider", (req: any, res: any, next: Function) => {
231+
// Direct visits (legacy bookmarks) get redirected back to the pad,
232+
// where the in-pad PadModeController handles entering history mode.
233+
// The iframe used by history mode requests this URL with ?embed=1
234+
// and gets the full timeslider HTML rendered for embedded use.
235+
if (req.query.embed !== '1') {
236+
return res.redirect(302, `../${encodeURIComponent(req.params.pad)}`);
237+
}
231238
ensureAuthorTokenCookie(req, res, settings);
232-
console.log("Reloading pad")
233239
// The below might break for pads being rewritten
234240
const isReadOnly = !webaccess.userCanModify(req.params.pad, req);
235241

@@ -246,6 +252,7 @@ const handleLiveReload = async (args: ArgsExpressType, padString: string, timeSl
246252
req,
247253
toolbar,
248254
isReadOnly,
255+
embed: true,
249256
entrypoint: proxyPath + '/watch/timeslider?hash=' + hash,
250257
settings: settings.getPublicSettings(),
251258
socialMetaHtml,
@@ -392,6 +399,18 @@ exports.expressCreateServer = async (_hookName: string, args: ArgsExpressType, c
392399

393400
// serve timeslider.html under /p/$padname/timeslider
394401
args.app.get('/p/:pad/timeslider', (req: any, res: any, next: Function) => {
402+
// Direct visits (legacy bookmarks) get redirected back to the pad,
403+
// where the in-pad PadModeController handles entering history mode.
404+
// The iframe used by history mode requests this URL with ?embed=1
405+
// and gets the full timeslider HTML rendered for embedded use.
406+
if (req.query.embed !== '1') {
407+
// Absolute path (not relative `../`) so Firefox and Chrome resolve
408+
// it identically — relative redirects from /p/:pad/timeslider are
409+
// technically well-defined but Firefox dropped a trailing-slash
410+
// case once that flaked the legacy-URL test (#7710).
411+
const proxyPath = sanitizeProxyPath(req);
412+
return res.redirect(302, `${proxyPath}/p/${encodeURIComponent(req.params.pad)}`);
413+
}
395414
ensureAuthorTokenCookie(req, res, settings);
396415
hooks.callAll('padInitToolbar', {
397416
toolbar,
@@ -403,6 +422,7 @@ exports.expressCreateServer = async (_hookName: string, args: ArgsExpressType, c
403422
res.send(eejs.require('ep_etherpad-lite/templates/timeslider.html', {
404423
req,
405424
toolbar,
425+
embed: true,
406426
entrypoint: "../../"+fileNameTimeSlider,
407427
settings: settings.getPublicSettings(),
408428
socialMetaHtml,

src/static/css/pad.css

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,3 +107,182 @@ input {
107107
}
108108
#version-badge[data-level="severe"] { background: #fff3cd; color: #664d03; border: 1px solid #ffe69c; }
109109
#version-badge[data-level="vulnerable"] { background: #f8d7da; color: #58151c; border: 1px solid #f1aeb5; }
110+
111+
/* ----------------------------------------------------------------------- */
112+
/* History mode (issue #7659): timeslider rendered in-place inside the */
113+
/* pad page. The live editor stays mounted but hidden; a sibling iframe */
114+
/* hosts the existing timeslider replay code. */
115+
/* ----------------------------------------------------------------------- */
116+
117+
.history-banner {
118+
display: flex;
119+
align-items: center;
120+
gap: 12px;
121+
padding: 10px 16px;
122+
background: #fff8e1;
123+
border-bottom: 1px solid #f0d27a;
124+
color: #5b4b00;
125+
font-size: 14px;
126+
z-index: 5;
127+
}
128+
.history-banner[hidden] { display: none; }
129+
.history-banner-label { font-weight: 600; }
130+
.history-banner-rev,
131+
.history-banner-date { opacity: 0.85; }
132+
.history-banner #history-banner-return { margin-left: auto; }
133+
134+
/* The history iframe takes the place of the live ACE editor. We use the */
135+
/* same positioning model (absolute, fill the editor area) so the page */
136+
/* layout is identical between modes. */
137+
.history-frame-mount {
138+
display: none;
139+
position: absolute;
140+
inset: 0;
141+
border: 0;
142+
background: var(--bg-color, #f2f3f4);
143+
z-index: 4;
144+
}
145+
.history-frame-mount[hidden] { display: none; }
146+
.history-frame-mount > iframe {
147+
width: 100%;
148+
height: 100%;
149+
border: 0;
150+
display: block;
151+
}
152+
153+
/* While in history mode, hide the live ACE editor and show the history */
154+
/* iframe in its place. The formatting menu on the left side of the */
155+
/* toolbar (Bold/Italic/Lists/Indent/Undo/etc.) targets the hidden live */
156+
/* editor, so we swap it for the history controls (slider + play/step */
157+
/* buttons) that drive the iframe. The right-side menu (Settings / Share / */
158+
/* Users / Chat / Home) stays fully interactive across modes. */
159+
body.history-mode #editorcontainer { display: none; }
160+
body.history-mode #editorcontainerbox { position: relative; }
161+
body.history-mode .history-frame-mount { display: block; }
162+
body.history-mode #editbar .menu_left { display: none; }
163+
body.history-mode #editbar .show-more-icon-btn { display: none; }
164+
body.history-mode #history-controls { display: flex; }
165+
166+
/* History toolbar controls (issue #7659): a slider + play/pause/step */
167+
/* buttons + Follow/Speed controls that remote-control the embedded */
168+
/* timeslider iframe. Take the place of the formatting menu while */
169+
/* scrubbing. align-items + min-height keep the toolbar the same vertical */
170+
/* size as in live mode so swapping modes doesn't reflow the layout. */
171+
.history-controls {
172+
display: none;
173+
flex: 1 1 auto;
174+
align-items: center;
175+
gap: 8px;
176+
padding: 0 12px;
177+
min-width: 0;
178+
min-height: 40px;
179+
}
180+
.history-controls[hidden] { display: none; }
181+
.history-controls button.buttonicon {
182+
flex: 0 0 auto;
183+
/* Match the live-toolbar buttonicon visual weight (no <li><a> wrapper */
184+
/* gives us a smaller default; bump padding so the icon hit area lines */
185+
/* up vertically with menu_right's settings/share/users/etc. icons). */
186+
padding: 6px 8px;
187+
background: transparent;
188+
border: 0;
189+
cursor: pointer;
190+
font-size: 15px;
191+
color: inherit;
192+
}
193+
.history-controls button.buttonicon:hover { background: rgba(0,0,0,0.06); border-radius: 4px; }
194+
.history-controls button.buttonicon.buttonicon-play.pause::before {
195+
content: "\e829";
196+
}
197+
.history-slider-input {
198+
flex: 1 1 auto;
199+
min-width: 80px;
200+
margin: 0 6px;
201+
cursor: pointer;
202+
}
203+
.history-timer {
204+
flex: 0 0 auto;
205+
font-size: 12px;
206+
font-variant-numeric: tabular-nums;
207+
opacity: 0.85;
208+
white-space: nowrap;
209+
}
210+
.history-toggle {
211+
flex: 0 0 auto;
212+
display: inline-flex;
213+
align-items: center;
214+
gap: 4px;
215+
font-size: 13px;
216+
white-space: nowrap;
217+
cursor: pointer;
218+
}
219+
.history-toggle input[type="checkbox"] { margin: 0; }
220+
221+
/* Follow toggle — eye icon, with a diagonal slash that appears only when
222+
* the underlying checkbox is unchecked (auto-follow disabled). The hidden
223+
* input still drives state (so pad_mode.ts's bridge code reads .checked
224+
* and the existing label-for relationship handles click). */
225+
.history-follow-toggle {
226+
flex: 0 0 auto;
227+
display: inline-flex;
228+
align-items: center;
229+
justify-content: center;
230+
width: 30px;
231+
height: 30px;
232+
cursor: pointer;
233+
border-radius: 4px;
234+
color: inherit;
235+
}
236+
.history-follow-toggle:hover { background: rgba(0,0,0,0.06); }
237+
.history-follow-eye-slash { display: none; }
238+
#history-options-followContents:not(:checked) + .history-follow-toggle .history-follow-eye-slash {
239+
display: inline;
240+
}
241+
#history-options-followContents:not(:checked) + .history-follow-toggle {
242+
opacity: 0.55;
243+
}
244+
/* Keep the checkbox in the DOM (label-for needs a present target) but
245+
* hidden visually + accessibly redundant since the label conveys state. */
246+
#history-options-followContents.sr-only {
247+
position: absolute;
248+
width: 1px; height: 1px;
249+
margin: -1px; padding: 0; border: 0;
250+
clip: rect(0 0 0 0); overflow: hidden;
251+
}
252+
.history-speed {
253+
flex: 0 0 auto;
254+
font-size: 13px;
255+
padding: 2px 4px;
256+
max-width: 130px;
257+
}
258+
259+
/* Responsive — Follow + Speed inherit .hide-for-mobile (already collapses */
260+
/* at <=800px). Pack the remaining play/slider/step buttons tighter so */
261+
/* they always fit. At ultra-narrow widths the step buttons compact too. */
262+
@media (max-width: 800px) {
263+
.history-controls { padding: 0 6px; gap: 4px; min-height: 36px; }
264+
.history-controls button.buttonicon { padding: 4px 6px; min-width: 32px; }
265+
.history-slider-input { min-width: 60px; margin: 0 2px; }
266+
}
267+
@media (max-width: 480px) {
268+
.history-controls #history-leftstep,
269+
.history-controls #history-rightstep { min-width: 28px; padding: 2px; }
270+
}
271+
272+
/* Chat replay header — appears above the chat log while scrubbing so the */
273+
/* user knows the message list is filtered to a historical timestamp. */
274+
.history-chat-header {
275+
display: none;
276+
padding: 6px 10px;
277+
font-size: 12px;
278+
font-weight: 600;
279+
background: #fff8e1;
280+
color: #5b4b00;
281+
border-bottom: 1px solid #f0d27a;
282+
}
283+
body.history-mode .history-chat-header { display: block; }
284+
body.history-mode .history-authors-row {
285+
font-style: italic;
286+
opacity: 0.85;
287+
padding: 6px 8px;
288+
}

src/static/css/timeslider.css

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,20 @@
33
display: block;
44
}
55

6+
/* When the timeslider is embedded as an iframe inside a pad page (the
7+
* parent pad's history mode — issue #7659), the outer pad's toolbar,
8+
* banner, slider, and Settings/Export popups own all chrome, and the
9+
* iframe is purely the editor surface. The .iframe-mode class is added
10+
* by timeslider.ts only when window.parent !== window, so direct visits
11+
* to /p/:pad/timeslider?embed=1 (existing test/legacy entry points)
12+
* keep their full chrome and stay independently usable. */
13+
body.embedded-history-frame.iframe-mode #editbar,
14+
body.embedded-history-frame.iframe-mode #import_export,
15+
body.embedded-history-frame.iframe-mode #connectivity,
16+
body.embedded-history-frame.iframe-mode #settings {
17+
display: none !important;
18+
}
19+
620
.timeslider-bar {
721
display: flex;
822
flex-direction: row;

src/static/js/broadcast.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,10 @@ const loadBroadcastJS = (socket, sendSocketMsg, fireWhenAllScriptsAreLoaded, Bro
5050
}
5151
};
5252

53-
const padContents = {
53+
// Exposed on `window` so the outer pad shell (issue #7659 in-place
54+
// history mode) can read `currentTime` after each scrub to drive chat
55+
// replay and other revision-anchored UI without postMessage round-trips.
56+
const padContents: any = (window as any).padContents = {
5457
currentRevision: clientVars.collab_client_vars.rev,
5558
currentTime: clientVars.collab_client_vars.time,
5659
currentLines:
@@ -155,12 +158,14 @@ const loadBroadcastJS = (socket, sendSocketMsg, fireWhenAllScriptsAreLoaded, Bro
155158
let height;
156159
const nextDocLine = docLine.nextElementSibling;
157160
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-
}
161+
// Use the consistent (next - current) formula for every line,
162+
// including the first. The previous first-line special case
163+
// subtracted innerdocbody.padding-top from nextDocLine.offsetTop,
164+
// which only works when innerdocbody is the offsetParent. In the
165+
// in-pad history iframe (#7659) it isn't (its outerdocbody has
166+
// padding-top of its own), so the first gutter row was 20px too
167+
// tall and every subsequent row drifted out of alignment.
168+
height = nextDocLine.offsetTop - docLine.offsetTop;
164169
} else {
165170
height = docLine.clientHeight || docLine.offsetHeight;
166171
}

src/static/js/chat.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,10 @@ exports.chat = (() => {
198198
// ctx.text was HTML-escaped before calling the hook. Hook functions are trusted to not
199199
// introduce an XSS vulnerability by adding unescaped user input.
200200
.append($('<div>').html(ctx.text).contents());
201+
// The outer pad's history mode (issue #7659) filters rendered messages
202+
// by this attribute when scrubbing; a missing attribute would always
203+
// show the message regardless of timestamp.
204+
chatMsg.attr('data-timestamp', String(msg.time));
201205
if (isHistoryAdd) chatMsg.insertAfter('#chatloadmessagesbutton');
202206
else $('#chattext').append(chatMsg);
203207
chatMsg.each((i, e) => html10n.translateElement(html10n.translations, e));

0 commit comments

Comments
 (0)