From 923f19134b726d547abc3fbba648d8dd38e14e9f Mon Sep 17 00:00:00 2001 From: John McLear Date: Sat, 9 May 2026 12:13:56 +0100 Subject: [PATCH 01/13] feat(pad): scrub history in-place on the pad URL (#7659) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/locales/en.json | 3 + src/node/hooks/express/specialpages.ts | 17 +- src/static/css/pad.css | 48 ++++ src/static/css/timeslider.css | 19 ++ src/static/js/pad.ts | 2 + src/static/js/pad_editbar.ts | 9 +- src/static/js/pad_mode.ts | 248 ++++++++++++++++++ src/templates/pad.html | 21 +- src/templates/timeslider.html | 2 +- src/tests/backend/specs/timesliderRedirect.ts | 45 ++++ src/tests/frontend-new/helper/timeslider.ts | 6 +- src/tests/frontend-new/specs/padmode.spec.ts | 88 +++++++ .../frontend-new/specs/timeslider.spec.ts | 39 +-- .../timeslider_identity_changeset.spec.ts | 6 +- .../specs/timeslider_line_numbers.spec.ts | 4 +- .../specs/timeslider_playback_speed.spec.ts | 6 +- 16 files changed, 533 insertions(+), 30 deletions(-) create mode 100644 src/static/js/pad_mode.ts create mode 100644 src/tests/backend/specs/timesliderRedirect.ts create mode 100644 src/tests/frontend-new/specs/padmode.spec.ts diff --git a/src/locales/en.json b/src/locales/en.json index a8602ad35e0..042ccb14ac3 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -206,6 +206,9 @@ "timeslider.followContents": "Follow pad content updates", "timeslider.pageTitle": "{{appTitle}} Timeslider", "timeslider.toolbar.returnbutton": "Return to pad", + "pad.historyMode.banner": "Viewing history", + "pad.historyMode.return": "Return to live", + "pad.historyMode.revisionLabel": "Revision {{rev}}", "timeslider.toolbar.authors": "Authors:", "timeslider.toolbar.authorsList": "No Authors", "timeslider.toolbar.exportlink.title": "Export", diff --git a/src/node/hooks/express/specialpages.ts b/src/node/hooks/express/specialpages.ts index 660f2b4d426..308eedbb928 100644 --- a/src/node/hooks/express/specialpages.ts +++ b/src/node/hooks/express/specialpages.ts @@ -228,8 +228,14 @@ const handleLiveReload = async (args: ArgsExpressType, padString: string, timeSl }) setRouteHandler("/p/:pad/timeslider", (req: any, res: any, next: Function) => { + // Direct visits (legacy bookmarks) get redirected back to the pad, + // where the in-pad PadModeController handles entering history mode. + // The iframe used by history mode requests this URL with ?embed=1 + // and gets the full timeslider HTML rendered for embedded use. + if (!req.query.embed) { + return res.redirect(302, `../${encodeURIComponent(req.params.pad)}`); + } ensureAuthorTokenCookie(req, res, settings); - console.log("Reloading pad") // The below might break for pads being rewritten const isReadOnly = !webaccess.userCanModify(req.params.pad, req); @@ -246,6 +252,7 @@ const handleLiveReload = async (args: ArgsExpressType, padString: string, timeSl req, toolbar, isReadOnly, + embed: true, entrypoint: proxyPath + '/watch/timeslider?hash=' + hash, settings: settings.getPublicSettings(), socialMetaHtml, @@ -392,6 +399,13 @@ exports.expressCreateServer = async (_hookName: string, args: ArgsExpressType, c // serve timeslider.html under /p/$padname/timeslider args.app.get('/p/:pad/timeslider', (req: any, res: any, next: Function) => { + // Direct visits (legacy bookmarks) get redirected back to the pad, + // where the in-pad PadModeController handles entering history mode. + // The iframe used by history mode requests this URL with ?embed=1 + // and gets the full timeslider HTML rendered for embedded use. + if (!req.query.embed) { + return res.redirect(302, `../${encodeURIComponent(req.params.pad)}`); + } ensureAuthorTokenCookie(req, res, settings); hooks.callAll('padInitToolbar', { toolbar, @@ -403,6 +417,7 @@ exports.expressCreateServer = async (_hookName: string, args: ArgsExpressType, c res.send(eejs.require('ep_etherpad-lite/templates/timeslider.html', { req, toolbar, + embed: true, entrypoint: "../../"+fileNameTimeSlider, settings: settings.getPublicSettings(), socialMetaHtml, diff --git a/src/static/css/pad.css b/src/static/css/pad.css index 754337557f1..b5f2eac0b36 100644 --- a/src/static/css/pad.css +++ b/src/static/css/pad.css @@ -107,3 +107,51 @@ input { } #version-badge[data-level="severe"] { background: #fff3cd; color: #664d03; border: 1px solid #ffe69c; } #version-badge[data-level="vulnerable"] { background: #f8d7da; color: #58151c; border: 1px solid #f1aeb5; } + +/* ----------------------------------------------------------------------- */ +/* History mode (issue #7659): timeslider rendered in-place inside the */ +/* pad page. The live editor stays mounted but hidden; a sibling iframe */ +/* hosts the existing timeslider replay code. */ +/* ----------------------------------------------------------------------- */ + +.history-banner { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 16px; + background: #fff8e1; + border-bottom: 1px solid #f0d27a; + color: #5b4b00; + font-size: 14px; + z-index: 5; +} +.history-banner[hidden] { display: none; } +.history-banner-label { font-weight: 600; } +.history-banner-rev, +.history-banner-date { opacity: 0.85; } +.history-banner #history-banner-return { margin-left: auto; } + +.history-frame-mount { + display: none; + flex: 1 1 auto; + min-height: 0; + border: 0; +} +.history-frame-mount[hidden] { display: none; } +.history-frame-mount > iframe { + width: 100%; + height: 100%; + border: 0; + display: block; +} + +/* While in history mode, hide the live ACE editor and show the history */ +/* iframe in its place. Apply a light sepia tint to the surrounding pad */ +/* shell so it is unmistakable that this is not the live document. The */ +/* toolbar itself stays interactive so chat / settings / users / share */ +/* still work — only the editing-command icons are dimmed visually. */ +body.history-mode #editorcontainer { display: none; } +body.history-mode .history-frame-mount { display: flex; } +body.history-mode #editbar { + filter: sepia(0.3) saturate(0.7); +} diff --git a/src/static/css/timeslider.css b/src/static/css/timeslider.css index 65506021ec0..e033a44a391 100644 --- a/src/static/css/timeslider.css +++ b/src/static/css/timeslider.css @@ -3,6 +3,25 @@ display: block; } +/* When the timeslider is embedded as an iframe inside a pad page, the outer + * pad provides the banner, title, and "return to live" affordance. Hide the + * embedded copy's redundant title and the return-to-pad toolbar entry; the + * slider itself, the settings dropdown, and export links stay reachable so + * users can still tweak line numbers / playback speed / export from the + * historical revision they're viewing. */ +body.embedded-history-frame .timeslider-title-container { + display: none !important; +} +body.embedded-history-frame [data-key="timeslider_returnToPad"] { + display: none !important; +} +body.embedded-history-frame #editbar { + padding: 4px 10px; + background: transparent; + box-shadow: none; + border-bottom: 1px solid rgba(0, 0, 0, 0.08); +} + .timeslider-bar { display: flex; flex-direction: row; diff --git a/src/static/js/pad.ts b/src/static/js/pad.ts index 6070fb8944f..4a3dcc1a127 100644 --- a/src/static/js/pad.ts +++ b/src/static/js/pad.ts @@ -42,6 +42,7 @@ const getCollabClient = require('./collab_client').getCollabClient; const padconnectionstatus = require('./pad_connectionstatus').padconnectionstatus; const padcookie = require('./pad_cookie').padcookie; const padeditbar = require('./pad_editbar').padeditbar; +const padMode = require('./pad_mode').padMode; const padeditor = require('./pad_editor').padeditor; const padimpexp = require('./pad_impexp').padimpexp; const padmodals = require('./pad_modals').padmodals; @@ -671,6 +672,7 @@ const pad = { $('#colorpicker').farbtastic({callback: '#mycolorpickerpreview', width: 220}); $('#readonlyinput').on('click', () => { padeditbar.setEmbedLinks(); }); padcookie.init(); + padMode.init(); await handshake(); this._afterHandshake(); })()); diff --git a/src/static/js/pad_editbar.ts b/src/static/js/pad_editbar.ts index a44f0fd8489..f9f581d28a5 100644 --- a/src/static/js/pad_editbar.ts +++ b/src/static/js/pad_editbar.ts @@ -500,7 +500,14 @@ exports.padeditbar = new class { }); this.registerCommand('showTimeSlider', () => { - document.location = `${document.location.pathname}/timeslider`; + // Issue #7659: enter history in-place rather than navigating away. The + // PadModeController owns the iframe lifecycle, banner, and URL hash. + try { + require('./pad_mode').padMode.enterHistory(); + } catch (_e) { + // Fallback for the unlikely case the controller failed to load. + document.location = `${document.location.pathname}/timeslider`; + } }); const aceAttributeCommand = (cmd, ace) => { diff --git a/src/static/js/pad_mode.ts b/src/static/js/pad_mode.ts new file mode 100644 index 00000000000..4de96d63fb7 --- /dev/null +++ b/src/static/js/pad_mode.ts @@ -0,0 +1,248 @@ +// Copyright 2026 Etherpad contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// SPDX-License-Identifier: Apache-2.0 + +// PadModeController — issue #7659. +// +// Lets the user enter/leave the timeslider in-place on the pad URL. The +// existing /p/:pad/timeslider stack is reused unmodified inside an iframe; +// this controller handles the outer DOM, browser history, and a tiny bridge +// between the inner slider's hash/state and the outer URL/banner. + +'use strict'; + +type Mode = 'live' | 'history'; + +const HASH_PREFIX = '#rev/'; + +// Parse the outer-page hash. Accepts both the new "#rev/N" form and the +// legacy "#NN" shortlink form so old timeslider bookmarks keep working +// after the server-side redirect drops the path component. +const parseRevFromHash = (hash: string): number | null => { + if (!hash || hash.length < 2) return null; + if (hash.startsWith(HASH_PREFIX)) { + const rest = hash.slice(HASH_PREFIX.length); + if (rest === 'latest') return -1; + const n = Number(rest); + return Number.isInteger(n) && n >= 0 ? n : null; + } + // Legacy "#NN" form — preserved across the 302 redirect from the old + // /p/:pad/timeslider#NN URL. + if (/^#\d+$/.test(hash)) return Number(hash.slice(1)); + return null; +}; + +const buildOuterHash = (rev: number | null): string => + rev == null || rev < 0 ? `${HASH_PREFIX}latest` : `${HASH_PREFIX}${rev}`; + +class PadModeController { + private mode: Mode = 'live'; + private iframe: HTMLIFrameElement | null = null; + private banner: HTMLElement; + private mount: HTMLElement; + private revLabel: HTMLElement; + private dateLabel: HTMLElement; + private padId: string; + private innerHashChangeHandler: (() => void) | null = null; + private revObserver: MutationObserver | null = null; + private syncingHash = false; + + constructor() { + this.banner = document.getElementById('history-banner')!; + this.mount = document.getElementById('history-frame-mount')!; + this.revLabel = document.getElementById('history-banner-rev')!; + this.dateLabel = document.getElementById('history-banner-date')!; + // /p/:pad → ['', 'p', ':pad']. + const parts = window.location.pathname.split('/').filter(Boolean); + this.padId = decodeURIComponent(parts[parts.length - 1] || ''); + + document.getElementById('history-banner-return')! + .addEventListener('click', () => { this.exitHistory(); }); + + window.addEventListener('hashchange', () => { this.onOuterHashChange(); }); + window.addEventListener('popstate', () => { this.onOuterHashChange(); }); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && this.mode === 'history') this.exitHistory(); + }); + } + + // Called once after pad.init() so an initial #rev/N (or legacy #NN) on + // page load enters history mode without an extra round-trip. + bootstrapFromHash(): void { + const rev = parseRevFromHash(window.location.hash); + if (rev != null) this.enterHistory(rev); + } + + getMode(): Mode { return this.mode; } + + enterHistory(rev: number | null = null): void { + if (this.mode === 'history') { + // Already in history — just retarget the inner slider. + if (rev != null && this.iframe) this.setInnerRevision(rev); + return; + } + this.mode = 'history'; + document.body.classList.add('history-mode'); + this.banner.removeAttribute('hidden'); + this.mount.removeAttribute('hidden'); + + // Push the new state. If the user lands here from the toolbar button we + // pushState so browser back exits history; if they arrived via a direct + // hash (bootstrap path) the current entry already represents history. + const desiredHash = buildOuterHash(rev); + if (window.location.hash !== desiredHash) { + this.syncingHash = true; + try { + history.pushState(null, '', `${window.location.pathname}${desiredHash}`); + } finally { + this.syncingHash = false; + } + } + this.mountIframe(rev); + } + + exitHistory(): void { + if (this.mode === 'live') return; + this.mode = 'live'; + this.unmountIframe(); + this.banner.setAttribute('hidden', ''); + this.mount.setAttribute('hidden', ''); + document.body.classList.remove('history-mode'); + if (window.location.hash) { + this.syncingHash = true; + try { + history.replaceState(null, '', window.location.pathname); + } finally { + this.syncingHash = false; + } + } + this.revLabel.textContent = ''; + this.dateLabel.textContent = ''; + } + + private mountIframe(rev: number | null): void { + const innerHash = rev == null || rev < 0 ? '' : `#${rev}`; + const src = + `${encodeURIComponent(this.padId)}/timeslider?embed=1${innerHash}`; + const iframe = document.createElement('iframe'); + iframe.id = 'history-frame'; + iframe.title = 'Pad history viewer'; + iframe.src = src; + iframe.addEventListener('load', () => { this.attachInnerBridges(iframe); }); + this.mount.appendChild(iframe); + this.iframe = iframe; + } + + private unmountIframe(): void { + if (this.revObserver) { + this.revObserver.disconnect(); + this.revObserver = null; + } + if (this.iframe) { + try { + if (this.innerHashChangeHandler && this.iframe.contentWindow) { + this.iframe.contentWindow.removeEventListener( + 'hashchange', this.innerHashChangeHandler); + } + } catch (_e) { /* cross-origin shouldn't happen, but be defensive */ } + this.iframe.remove(); + this.iframe = null; + } + this.innerHashChangeHandler = null; + } + + private attachInnerBridges(iframe: HTMLIFrameElement): void { + const win = iframe.contentWindow; + const doc = iframe.contentDocument; + if (!win || !doc) return; + + // When the inner slider moves it sets its own location.hash (#NN). Mirror + // the change to the outer URL so the user's address bar stays canonical. + this.innerHashChangeHandler = () => { + if (this.syncingHash) return; + const innerHash = win.location.hash; + const rev = innerHash.startsWith('#') ? Number(innerHash.slice(1)) : NaN; + if (Number.isFinite(rev)) this.setOuterRev(rev); + }; + win.addEventListener('hashchange', this.innerHashChangeHandler); + + // The inner template populates #revision_label / #revision_date from JS + // each time the slider moves. Mirror them into the outer banner via a + // MutationObserver so the user always sees the current revision. + const innerLabel = doc.getElementById('revision_label'); + const innerDate = doc.getElementById('revision_date'); + if (innerLabel || innerDate) { + const sync = () => { + if (innerLabel) this.revLabel.textContent = innerLabel.textContent || ''; + if (innerDate) this.dateLabel.textContent = innerDate.textContent || ''; + }; + sync(); + this.revObserver = new MutationObserver(sync); + if (innerLabel) { + this.revObserver.observe(innerLabel, {childList: true, subtree: true, characterData: true}); + } + if (innerDate) { + this.revObserver.observe(innerDate, {childList: true, subtree: true, characterData: true}); + } + } + } + + private setInnerRevision(rev: number): void { + if (!this.iframe || !this.iframe.contentWindow) return; + try { + this.iframe.contentWindow.location.hash = `#${rev}`; + } catch (_e) { /* same-origin guaranteed; ignore the unlikely failure */ } + } + + private setOuterRev(rev: number): void { + const desired = buildOuterHash(rev); + if (window.location.hash === desired) return; + this.syncingHash = true; + try { + history.replaceState(null, '', `${window.location.pathname}${desired}`); + } finally { + this.syncingHash = false; + } + } + + private onOuterHashChange(): void { + if (this.syncingHash) return; + const rev = parseRevFromHash(window.location.hash); + if (rev == null) { + if (this.mode === 'history') this.exitHistory(); + return; + } + if (this.mode === 'live') { + this.enterHistory(rev); + } else { + this.setInnerRevision(rev < 0 ? 0 : rev); + } + } +} + +let singleton: PadModeController | null = null; + +export const padMode = { + init(): void { + if (singleton) return; + singleton = new PadModeController(); + singleton.bootstrapFromHash(); + }, + enterHistory(rev: number | null = null): void { + singleton?.enterHistory(rev); + }, + exitHistory(): void { + singleton?.exitHistory(); + }, + getMode(): 'live' | 'history' { + return singleton ? singleton.getMode() : 'live'; + }, +}; + +export default padMode; diff --git a/src/templates/pad.html b/src/templates/pad.html index 8a4e3ac3c80..79ac83c118d 100644 --- a/src/templates/pad.html +++ b/src/templates/pad.html @@ -14,13 +14,16 @@ var configuredColor = skinColors.configuredToolbarColor(settings.skinName, settings.skinVariants); %> - + <% e.begin_block("htmlHead"); %> <% e.end_block(); %> <%=settings.title%> <%- typeof socialMetaHtml !== 'undefined' ? socialMetaHtml : '' %> + + +