diff --git a/doc/skins.adoc b/doc/skins.adoc index df3c7168e4e..33999e94862 100644 --- a/doc/skins.adoc +++ b/doc/skins.adoc @@ -6,11 +6,18 @@ A skin is a directory located under `static/skins/`, with the followi * `index.css`: stylesheet affecting `/` * `pad.js`: javascript that will be run in `/p/:padid` * `pad.css`: stylesheet affecting `/p/:padid` -* `timeslider.js`: javascript that will be run in `/p/:padid/timeslider` -* `timeslider.css`: stylesheet affecting `/p/:padid/timeslider` +* `timeslider.js`: javascript that will be run in the embedded timeslider iframe +* `timeslider.css`: stylesheet affecting the embedded timeslider iframe * `favicon.ico`: overrides the default favicon * `robots.txt`: overrides the default `robots.txt` +Since Etherpad *2.7*, the timeslider is rendered in-place inside the pad +page (issue #7659). Direct visits to `/p/:padid/timeslider` 302-redirect to +`/p/:padid` so the in-pad `PadModeController` can take over via a `#rev/N` +URL hash. The full timeslider HTML is still served at +`/p/:padid/timeslider?embed=1` -- that is the URL the in-pad iframe loads, +and the URL to use if you embed the timeslider in your own page. + You can choose a skin changing the parameter `skinName` in `settings.json`. Since Etherpad **1.7.5**, two skins are included: diff --git a/doc/skins.md b/doc/skins.md index 954179f788d..ea5035caef5 100644 --- a/doc/skins.md +++ b/doc/skins.md @@ -6,8 +6,15 @@ A skin is a directory located under `static/skins/`, with the followi * `index.css`: stylesheet affecting `/` * `pad.js`: javascript that will be run in `/p/:padid` * `pad.css`: stylesheet affecting `/p/:padid` -* `timeslider.js`: javascript that will be run in `/p/:padid/timeslider` -* `timeslider.css`: stylesheet affecting `/p/:padid/timeslider` +* `timeslider.js`: javascript that will be run in the embedded timeslider iframe +* `timeslider.css`: stylesheet affecting the embedded timeslider iframe + +Since Etherpad **2.7**, the timeslider is rendered in-place inside the pad +page (issue #7659). Direct visits to `/p/:padid/timeslider` 302-redirect to +`/p/:padid` so the in-pad PadModeController can take over via a `#rev/N` +URL hash. The full timeslider HTML is still served at +`/p/:padid/timeslider?embed=1` — that is the URL the in-pad iframe loads, +and the URL to use if you embed the timeslider in your own page. * `favicon.ico`: overrides the default favicon * `robots.txt`: overrides the default `robots.txt` diff --git a/src/locales/en.json b/src/locales/en.json index a8602ad35e0..f263542d8db 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -206,6 +206,19 @@ "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}}", + "pad.historyMode.controlsLabel": "Pad history controls", + "pad.historyMode.sliderLabel": "Pad revision", + "pad.historyMode.settings.title": "History playback", + "pad.historyMode.settings.follow": "Follow pad content updates", + "pad.historyMode.settings.followShort": "Follow", + "pad.historyMode.followOn": "Following pad changes — click to stop following", + "pad.historyMode.followOff": "Not following pad changes — click to follow", + "pad.historyMode.settings.playbackSpeed": "Playback speed:", + "pad.historyMode.chat.replayHeader": "Chat as of {{time}}", + "pad.historyMode.users.authorsHeader": "Authors at this revision", "timeslider.toolbar.authors": "Authors:", "timeslider.toolbar.authorsList": "No Authors", "timeslider.toolbar.exportlink.title": "Export", diff --git a/src/node/handler/PadMessageHandler.ts b/src/node/handler/PadMessageHandler.ts index 65ac9d7626d..ed1f6a8f555 100644 --- a/src/node/handler/PadMessageHandler.ts +++ b/src/node/handler/PadMessageHandler.ts @@ -406,6 +406,12 @@ exports.handleMessage = async (socket:any, message: ClientVarMessage) => { padID: message.padId, token: resolvedToken, }; + // Issue #7659: connections from the in-place history iframe must not + // trigger the duplicate-author kick — they share the parent's author + // by design, and kicking the parent on iframe load would tear down + // the live editor mid-session. The iframe sets `embed=1` in its + // socket.io handshake query. + thisSession.embed = socket.handshake?.query?.embed === '1'; // Pad does not exist, so we need to sanitize the id if (!(await padManager.doesPadExist(thisSession.auth.padID))) { @@ -1039,12 +1045,16 @@ const handleClientReady = async (socket:any, message: ClientReadyMessage) => { // stable identity across windows and devices, so concurrent same-author // sessions are legitimate and must not be kicked. const roomSockets = _getRoomSockets(pad.id); - if (user == null) { + if (user == null && !sessionInfo.embed) { for (const otherSocket of roomSockets) { // The user shouldn't have joined the room yet, but check anyway just in case. if (otherSocket.id === socket.id) continue; const sinfo = sessioninfos[otherSocket.id]; - if (sinfo && sinfo.author === sessionInfo.author) { + // Embedded sessions (issue #7659 — in-place history iframe) share + // the parent's author by design, so they neither kick same-author + // sockets nor get kicked by them. Only non-embedded same-author + // duplicates (real stale tabs) hit the kick path. + if (sinfo && sinfo.author === sessionInfo.author && !sinfo.embed) { // fix user's counter, works on page refresh or if user closes browser window and then rejoins sessioninfos[otherSocket.id] = {}; otherSocket.leave(sessionInfo.padId); diff --git a/src/node/hooks/express/specialpages.ts b/src/node/hooks/express/specialpages.ts index 660f2b4d426..5db7526e0e3 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 !== '1') { + 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,18 @@ 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 !== '1') { + // Absolute path (not relative `../`) so Firefox and Chrome resolve + // it identically — relative redirects from /p/:pad/timeslider are + // technically well-defined but Firefox dropped a trailing-slash + // case once that flaked the legacy-URL test (#7710). + const proxyPath = sanitizeProxyPath(req); + return res.redirect(302, `${proxyPath}/p/${encodeURIComponent(req.params.pad)}`); + } ensureAuthorTokenCookie(req, res, settings); hooks.callAll('padInitToolbar', { toolbar, @@ -403,6 +422,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..aa1fcbbeacc 100644 --- a/src/static/css/pad.css +++ b/src/static/css/pad.css @@ -107,3 +107,182 @@ 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; } + +/* The history iframe takes the place of the live ACE editor. We use the */ +/* same positioning model (absolute, fill the editor area) so the page */ +/* layout is identical between modes. */ +.history-frame-mount { + display: none; + position: absolute; + inset: 0; + border: 0; + background: var(--bg-color, #f2f3f4); + z-index: 4; +} +.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. The formatting menu on the left side of the */ +/* toolbar (Bold/Italic/Lists/Indent/Undo/etc.) targets the hidden live */ +/* editor, so we swap it for the history controls (slider + play/step */ +/* buttons) that drive the iframe. The right-side menu (Settings / Share / */ +/* Users / Chat / Home) stays fully interactive across modes. */ +body.history-mode #editorcontainer { display: none; } +body.history-mode #editorcontainerbox { position: relative; } +body.history-mode .history-frame-mount { display: block; } +body.history-mode #editbar .menu_left { display: none; } +body.history-mode #editbar .show-more-icon-btn { display: none; } +body.history-mode #history-controls { display: flex; } + +/* History toolbar controls (issue #7659): a slider + play/pause/step */ +/* buttons + Follow/Speed controls that remote-control the embedded */ +/* timeslider iframe. Take the place of the formatting menu while */ +/* scrubbing. align-items + min-height keep the toolbar the same vertical */ +/* size as in live mode so swapping modes doesn't reflow the layout. */ +.history-controls { + display: none; + flex: 1 1 auto; + align-items: center; + gap: 8px; + padding: 0 12px; + min-width: 0; + min-height: 40px; +} +.history-controls[hidden] { display: none; } +.history-controls button.buttonicon { + flex: 0 0 auto; + /* Match the live-toolbar buttonicon visual weight (no
  • wrapper */ + /* gives us a smaller default; bump padding so the icon hit area lines */ + /* up vertically with menu_right's settings/share/users/etc. icons). */ + padding: 6px 8px; + background: transparent; + border: 0; + cursor: pointer; + font-size: 15px; + color: inherit; +} +.history-controls button.buttonicon:hover { background: rgba(0,0,0,0.06); border-radius: 4px; } +.history-controls button.buttonicon.buttonicon-play.pause::before { + content: "\e829"; +} +.history-slider-input { + flex: 1 1 auto; + min-width: 80px; + margin: 0 6px; + cursor: pointer; +} +.history-timer { + flex: 0 0 auto; + font-size: 12px; + font-variant-numeric: tabular-nums; + opacity: 0.85; + white-space: nowrap; +} +.history-toggle { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 13px; + white-space: nowrap; + cursor: pointer; +} +.history-toggle input[type="checkbox"] { margin: 0; } + +/* Follow toggle — eye icon, with a diagonal slash that appears only when + * the underlying checkbox is unchecked (auto-follow disabled). The hidden + * input still drives state (so pad_mode.ts's bridge code reads .checked + * and the existing label-for relationship handles click). */ +.history-follow-toggle { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + cursor: pointer; + border-radius: 4px; + color: inherit; +} +.history-follow-toggle:hover { background: rgba(0,0,0,0.06); } +.history-follow-eye-slash { display: none; } +#history-options-followContents:not(:checked) + .history-follow-toggle .history-follow-eye-slash { + display: inline; +} +#history-options-followContents:not(:checked) + .history-follow-toggle { + opacity: 0.55; +} +/* Keep the checkbox in the DOM (label-for needs a present target) but + * hidden visually + accessibly redundant since the label conveys state. */ +#history-options-followContents.sr-only { + position: absolute; + width: 1px; height: 1px; + margin: -1px; padding: 0; border: 0; + clip: rect(0 0 0 0); overflow: hidden; +} +.history-speed { + flex: 0 0 auto; + font-size: 13px; + padding: 2px 4px; + max-width: 130px; +} + +/* Responsive — Follow + Speed inherit .hide-for-mobile (already collapses */ +/* at <=800px). Pack the remaining play/slider/step buttons tighter so */ +/* they always fit. At ultra-narrow widths the step buttons compact too. */ +@media (max-width: 800px) { + .history-controls { padding: 0 6px; gap: 4px; min-height: 36px; } + .history-controls button.buttonicon { padding: 4px 6px; min-width: 32px; } + .history-slider-input { min-width: 60px; margin: 0 2px; } +} +@media (max-width: 480px) { + .history-controls #history-leftstep, + .history-controls #history-rightstep { min-width: 28px; padding: 2px; } +} + +/* Chat replay header — appears above the chat log while scrubbing so the */ +/* user knows the message list is filtered to a historical timestamp. */ +.history-chat-header { + display: none; + padding: 6px 10px; + font-size: 12px; + font-weight: 600; + background: #fff8e1; + color: #5b4b00; + border-bottom: 1px solid #f0d27a; +} +body.history-mode .history-chat-header { display: block; } +body.history-mode .history-authors-row { + font-style: italic; + opacity: 0.85; + padding: 6px 8px; +} diff --git a/src/static/css/timeslider.css b/src/static/css/timeslider.css index 65506021ec0..800ad6a8a6e 100644 --- a/src/static/css/timeslider.css +++ b/src/static/css/timeslider.css @@ -3,6 +3,20 @@ display: block; } +/* When the timeslider is embedded as an iframe inside a pad page (the + * parent pad's history mode — issue #7659), the outer pad's toolbar, + * banner, slider, and Settings/Export popups own all chrome, and the + * iframe is purely the editor surface. The .iframe-mode class is added + * by timeslider.ts only when window.parent !== window, so direct visits + * to /p/:pad/timeslider?embed=1 (existing test/legacy entry points) + * keep their full chrome and stay independently usable. */ +body.embedded-history-frame.iframe-mode #editbar, +body.embedded-history-frame.iframe-mode #import_export, +body.embedded-history-frame.iframe-mode #connectivity, +body.embedded-history-frame.iframe-mode #settings { + display: none !important; +} + .timeslider-bar { display: flex; flex-direction: row; diff --git a/src/static/js/broadcast.ts b/src/static/js/broadcast.ts index 37d98a6e8aa..8551f1d0cfa 100644 --- a/src/static/js/broadcast.ts +++ b/src/static/js/broadcast.ts @@ -50,7 +50,10 @@ const loadBroadcastJS = (socket, sendSocketMsg, fireWhenAllScriptsAreLoaded, Bro } }; - const padContents = { + // Exposed on `window` so the outer pad shell (issue #7659 in-place + // history mode) can read `currentTime` after each scrub to drive chat + // replay and other revision-anchored UI without postMessage round-trips. + const padContents: any = (window as any).padContents = { currentRevision: clientVars.collab_client_vars.rev, currentTime: clientVars.collab_client_vars.time, currentLines: @@ -155,12 +158,14 @@ const loadBroadcastJS = (socket, sendSocketMsg, fireWhenAllScriptsAreLoaded, Bro 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; - } + // Use the consistent (next - current) formula for every line, + // including the first. The previous first-line special case + // subtracted innerdocbody.padding-top from nextDocLine.offsetTop, + // which only works when innerdocbody is the offsetParent. In the + // in-pad history iframe (#7659) it isn't (its outerdocbody has + // padding-top of its own), so the first gutter row was 20px too + // tall and every subsequent row drifted out of alignment. + height = nextDocLine.offsetTop - docLine.offsetTop; } else { height = docLine.clientHeight || docLine.offsetHeight; } diff --git a/src/static/js/chat.ts b/src/static/js/chat.ts index 35b0e96b0b1..b47c8da6354 100644 --- a/src/static/js/chat.ts +++ b/src/static/js/chat.ts @@ -198,6 +198,10 @@ exports.chat = (() => { // ctx.text was HTML-escaped before calling the hook. Hook functions are trusted to not // introduce an XSS vulnerability by adding unescaped user input. .append($('
    ').html(ctx.text).contents()); + // The outer pad's history mode (issue #7659) filters rendered messages + // by this attribute when scrubbing; a missing attribute would always + // show the message regardless of timestamp. + chatMsg.attr('data-timestamp', String(msg.time)); if (isHistoryAdd) chatMsg.insertAfter('#chatloadmessagesbutton'); else $('#chattext').append(chatMsg); chatMsg.each((i, e) => html10n.translateElement(html10n.translations, e)); 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..b4902962826 --- /dev/null +++ b/src/static/js/pad_mode.ts @@ -0,0 +1,603 @@ +// 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}`; + +// Map of an outer export anchor to its live-mode `href`, captured on entry to +// history mode so we can restore on exit. +type HrefSnapshot = Map; + +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; + + // History-mode bridges — populated on enter, torn down on exit. + private exportSnapshot: HrefSnapshot | null = null; + private usersSnapshot: string | null = null; + private chatHeaderSnapshot: {parent: HTMLElement; sibling: Node | null} | null = null; + private chatHeaderEl: HTMLElement | null = null; + private playbackChangeListener: ((e: Event) => void) | null = null; + private followChangeListener: ((e: Event) => void) | null = null; + // Outer history controls (#history-controls) — bridge listeners. + private outerControlListeners: Array<{el: HTMLElement; type: string; fn: EventListener}> = []; + + 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 { + this.localizeControls(); + const rev = parseRevFromHash(window.location.hash); + if (rev != null) this.enterHistory(rev); + } + + // The icon buttons have no text content; we set their `title` (hover + // tooltip) and `aria-label` (screen reader name) from html10n once it + // has loaded. Re-runs on the html10n `localized` event so language + // switches at runtime stay in sync. + private localizeControls(): void { + const html10n: any = (window as any).html10n; + if (!html10n || typeof html10n.get !== 'function') return; + const apply = () => { + const setLabel = (id: string, key: string) => { + const el = document.getElementById(id); + if (!el) return; + const txt = html10n.get(key); + if (!txt) return; + el.setAttribute('title', txt); + el.setAttribute('aria-label', txt); + }; + setLabel('history-playpause', 'timeslider.playPause'); + setLabel('history-leftstep', 'timeslider.backRevision'); + setLabel('history-rightstep', 'timeslider.forwardRevision'); + setLabel('history-slider-input', 'pad.historyMode.sliderLabel'); + const ctrl = document.getElementById('history-controls'); + const ctrlLabel = html10n.get('pad.historyMode.controlsLabel'); + if (ctrl && ctrlLabel) ctrl.setAttribute('aria-label', ctrlLabel); + // Follow toggle is rendered as an eye icon — title (hover tooltip) + // and aria-label are populated from html10n and updated whenever + // state flips so screen readers + tooltip both narrate the action + // the click would take. + const followInput = document.getElementById('history-options-followContents') as HTMLInputElement | null; + const followLabel = document.querySelector('.history-follow-toggle'); + const updateFollowLabel = () => { + if (!followLabel) return; + const key = followInput && followInput.checked + ? 'pad.historyMode.followOn' + : 'pad.historyMode.followOff'; + const txt = html10n.get(key); + if (!txt) return; + followLabel.setAttribute('title', txt); + followLabel.setAttribute('aria-label', txt); + }; + updateFollowLabel(); + if (followInput && !(followInput as any)._padModeFollowBound) { + followInput.addEventListener('change', updateFollowLabel); + (followInput as any)._padModeFollowBound = true; + } + }; + apply(); + if (typeof html10n.bind === 'function') { + html10n.bind('localized', apply); + } + } + + 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'); + const ctrl = document.getElementById('history-controls'); + if (ctrl) ctrl.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.teardownBridges(); + this.unmountIframe(); + this.banner.setAttribute('hidden', ''); + this.mount.setAttribute('hidden', ''); + const ctrl = document.getElementById('history-controls'); + if (ctrl) ctrl.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 = ''; + } + + // Restore everything entry-time we stashed: chat message visibility, the + // chat replay header, the live users-panel HTML, original export hrefs, + // and any DOM listeners we attached to outer Settings controls. + private teardownBridges(): void { + document.querySelectorAll('#chattext > p[data-timestamp]') + .forEach((p) => { p.style.display = ''; }); + if (this.chatHeaderEl) this.chatHeaderEl.remove(); + this.chatHeaderEl = null; + this.chatHeaderSnapshot = null; + if (this.usersSnapshot != null) { + const tbl = document.getElementById('otheruserstable'); + if (tbl) tbl.innerHTML = this.usersSnapshot; + this.usersSnapshot = null; + } + if (this.exportSnapshot) { + this.exportSnapshot.forEach((href, anchor) => { anchor.setAttribute('href', href); }); + this.exportSnapshot = null; + } + if (this.playbackChangeListener) { + const sel = document.getElementById('history-playbackspeed'); + if (sel) sel.removeEventListener('change', this.playbackChangeListener); + this.playbackChangeListener = null; + } + if (this.followChangeListener) { + const cb = document.getElementById('history-options-followContents'); + if (cb) cb.removeEventListener('change', this.followChangeListener); + this.followChangeListener = null; + } + // Inner BroadcastSlider has no removeCallback API, but the whole iframe + // is destroyed on exit so any callbacks die with it. + this.outerControlListeners.forEach(({el, type, fn}) => el.removeEventListener(type, fn)); + this.outerControlListeners = []; + } + + 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}); + } + } + + // Register a single slider callback that drives the outer pad's + // historical-state UI: chat replay, authors-at-this-revision, and + // export href rewriting. The callback fires once on initial setup + // plus on every scrub. + const inner: any = win as any; + const registerHook = () => { + const BS = inner.BroadcastSlider; + if (!BS || typeof BS.onSlider !== 'function') { + // Slider not initialized yet — try again on next frame. + win.requestAnimationFrame(registerHook); + return; + } + BS.onSlider((revno: number) => { this.onRevChange(revno, win); }); + // Drive the initial sync (the slider may have already fired before + // we got here on a fast load). + this.onRevChange(BS.getSliderPosition?.() ?? 0, win); + }; + registerHook(); + + this.snapshotForHistory(); + this.wireSettingsBridges(win); + this.wireHistoryControls(win); + } + + // Bind the outer #history-controls (slider input + play/pause/step + // buttons) as a remote control for the embedded timeslider's + // BroadcastSlider. The inner slider DOM stays present (the embed CSS + // hides it) so its existing drag/click handlers continue to work — the + // outer controls just push state into the same BroadcastSlider via its + // public methods. + private wireHistoryControls(innerWin: Window): void { + const inner: any = innerWin as any; + const sliderInput = document.getElementById('history-slider-input') as HTMLInputElement | null; + const playBtn = document.getElementById('history-playpause') as HTMLButtonElement | null; + const leftStep = document.getElementById('history-leftstep') as HTMLButtonElement | null; + const rightStep = document.getElementById('history-rightstep') as HTMLButtonElement | null; + const timer = document.getElementById('history-timer') as HTMLElement | null; + + const bind = (el: HTMLElement | null, type: string, fn: EventListener) => { + if (!el) return; + el.addEventListener(type, fn); + this.outerControlListeners.push({el, type, fn}); + }; + + bind(sliderInput, 'input', () => { + if (!sliderInput) return; + const target = Math.max(0, Math.floor(Number(sliderInput.value) || 0)); + try { inner.BroadcastSlider?.setSliderPosition?.(target); } catch (_e) {} + }); + bind(playBtn, 'click', () => { + try { inner.BroadcastSlider?.playpause?.(); } catch (_e) {} + }); + // Inner #leftstep / #rightstep already wire all the step logic; just + // forward the click so we share the same code path. + bind(leftStep, 'click', () => { + try { (innerWin.document.getElementById('leftstep') as HTMLElement | null)?.click(); } + catch (_e) {} + }); + bind(rightStep, 'click', () => { + try { (innerWin.document.getElementById('rightstep') as HTMLElement | null)?.click(); } + catch (_e) {} + }); + + // Mirror inner state into the outer controls. We register a + // BroadcastSlider.onSlider callback (called on every position change) + // and poll the inner #playpause_button_icon.pause class for play state. + const sync = (revno: number) => { + const max = inner.BroadcastSlider?.getSliderLength?.(); + if (sliderInput && typeof max === 'number' && Number(sliderInput.max) !== max) { + sliderInput.max = String(max); + } + if (sliderInput && Number(sliderInput.value) !== revno) { + sliderInput.value = String(revno); + } + if (timer) { + const innerTimer = innerWin.document.getElementById('timer'); + if (innerTimer) timer.textContent = innerTimer.textContent || ''; + } + if (playBtn) { + const innerPlay = innerWin.document.getElementById('playpause_button_icon'); + const playing = !!innerPlay && innerPlay.classList.contains('pause'); + playBtn.classList.toggle('pause', playing); + playBtn.setAttribute('aria-pressed', playing ? 'true' : 'false'); + } + }; + // The hook registered earlier in attachInnerBridges already calls + // onRevChange — piggyback on it for slider input/timer updates by + // chaining through the same listener path. + const registerSync = () => { + const BS = inner.BroadcastSlider; + if (!BS || typeof BS.onSlider !== 'function') { + innerWin.requestAnimationFrame(registerSync); + return; + } + BS.onSlider(sync); + sync(BS.getSliderPosition?.() ?? 0); + }; + registerSync(); + } + + // Capture the live state we'll restore on exit: live chat message + // visibility (just the timestamps — actual messages stay), live users + // panel HTML, and current Export hrefs. + private snapshotForHistory(): void { + if (this.usersSnapshot == null) { + const tbl = document.getElementById('otheruserstable'); + if (tbl) this.usersSnapshot = tbl.innerHTML; + } + if (this.exportSnapshot == null) { + this.exportSnapshot = new Map(); + document.querySelectorAll( + '#exportColumn a.exportlink, #export a.exportlink', + ).forEach((a) => { + if (a.hasAttribute('href')) this.exportSnapshot!.set(a, a.getAttribute('href') || ''); + }); + } + // Inject the chat replay header above #chattext on first entry. + if (!this.chatHeaderEl) { + const chattext = document.getElementById('chattext'); + if (chattext && chattext.parentNode) { + const header = document.createElement('div'); + header.id = 'history-chat-header'; + header.className = 'history-chat-header'; + header.setAttribute('data-l10n-id', 'pad.historyMode.chat.replayHeader'); + header.textContent = 'Chat as of —'; + this.chatHeaderSnapshot = { + parent: chattext.parentNode as HTMLElement, + sibling: chattext, + }; + chattext.parentNode.insertBefore(header, chattext); + this.chatHeaderEl = header; + } + } + } + + // Called on every revision change while in history mode. Drives: + // - chat replay (filter rendered messages by timestamp) + // - authors-at-this-revision panel (mirrors inner #authorsList) + // - outer Export hrefs (point at /p/PAD//export/) + private onRevChange(revno: number, innerWin: Window): void { + const inner: any = innerWin as any; + const ts = inner.padContents?.currentTime as number | undefined; + if (typeof ts === 'number') { + this.filterChatByTimestamp(ts); + this.updateChatHeader(ts); + } + this.syncAuthorsPanel(innerWin); + this.syncExportHrefs(revno); + } + + private filterChatByTimestamp(asOf: number): void { + document.querySelectorAll('#chattext > p[data-timestamp]') + .forEach((p) => { + const t = Number(p.getAttribute('data-timestamp')); + p.style.display = Number.isFinite(t) && t > asOf ? 'none' : ''; + }); + } + + private updateChatHeader(asOf: number): void { + if (!this.chatHeaderEl) return; + const d = new Date(asOf); + const z = (n: number) => String(n).padStart(2, '0'); + const time = `${z(d.getHours())}:${z(d.getMinutes())}`; + // html10n.get is not always loaded; fall back to a literal string. + const html10n: any = (window as any).html10n; + const label = (html10n && typeof html10n.get === 'function') + ? html10n.get('pad.historyMode.chat.replayHeader', {time}) + : `Chat as of ${time}`; + this.chatHeaderEl.textContent = label; + } + + // Mirror the inner timeslider's #authorsList (rendered by broadcast.ts) + // into the outer users panel. We replace the live user table while in + // history mode and restore it on exit. + private syncAuthorsPanel(innerWin: Window): void { + const innerAuthors = (innerWin.document as Document).getElementById('authorsList'); + const tbl = document.getElementById('otheruserstable'); + if (!innerAuthors || !tbl) return; + const text = innerAuthors.textContent || ''; + tbl.innerHTML = ''; + const tr = document.createElement('tr'); + const td = document.createElement('td'); + td.className = 'history-authors-row'; + td.textContent = text; + tr.appendChild(td); + tbl.appendChild(tr); + } + + // Rewrite outer export anchors so a click downloads the historical + // revision instead of the live document. Etherpad already supports + // /p/:pad//export/. + private syncExportHrefs(revno: number): void { + if (!this.exportSnapshot) return; + this.exportSnapshot.forEach((origHref, anchor) => { + const m = origHref.match(/^(.*\/p\/[^/]+)\/export\/([^/?#]+)/); + if (!m) return; + anchor.setAttribute('href', `${m[1]}/${revno}/export/${m[2]}`); + }); + } + + // Outer Settings popup grew a "History playback" section. Drive the inner + // BroadcastSlider state from those controls so the user sees one set of + // controls regardless of mode. + private wireSettingsBridges(innerWin: Window): void { + const speedSel = document.getElementById('history-playbackspeed') as HTMLSelectElement | null; + const followCb = document.getElementById('history-options-followContents') as HTMLInputElement | null; + const inner: any = innerWin as any; + + if (speedSel) { + // Initial sync: read existing inner cookie/setting if available. + const innerSpeed = inner.document.getElementById('playbackspeed') as HTMLSelectElement | null; + if (innerSpeed && innerSpeed.value) speedSel.value = innerSpeed.value; + this.playbackChangeListener = () => { + const v = speedSel.value || '100'; + try { + inner.BroadcastSlider?.setPlaybackSpeed?.(v); + if (innerSpeed) { + innerSpeed.value = v; + innerSpeed.dispatchEvent(new Event('change')); + } + } catch (_e) {} + }; + speedSel.addEventListener('change', this.playbackChangeListener); + } + + if (followCb) { + const innerFollow = inner.document.getElementById('options-followContents') as HTMLInputElement | null; + if (innerFollow) followCb.checked = !!innerFollow.checked; + this.followChangeListener = () => { + if (!innerFollow) return; + innerFollow.checked = followCb.checked; + innerFollow.dispatchEvent(new Event('change')); + }; + followCb.addEventListener('change', this.followChangeListener); + } + } + + private setInnerRevision(rev: number): void { + if (!this.iframe || !this.iframe.contentWindow) return; + // The embedded timeslider treats #N as "go to revision N", so we must + // NOT write #-1 (or #0 as a stand-in for "latest"); for "latest" we + // jump to the slider's current upper bound, which broadcast_slider + // exposes via its sliderLength on the iframe's `BroadcastSlider`. + try { + if (rev < 0) { + const inner: any = this.iframe.contentWindow as any; + const upper = inner?.BroadcastSlider?.getSliderLength?.(); + if (typeof upper === 'number') { + this.iframe.contentWindow.location.hash = `#${upper}`; + } + // If BroadcastSlider isn't ready yet, leave the iframe alone — its + // own init reads its hash and starts at the latest revision. + return; + } + 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); + } + } +} + +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/static/js/skin_variants.ts b/src/static/js/skin_variants.ts index 99d6af3b6ec..22c055bf817 100644 --- a/src/static/js/skin_variants.ts +++ b/src/static/js/skin_variants.ts @@ -30,6 +30,19 @@ const updateSkinVariantsClasses = (newClasses) => { $('iframe[name=ace_outer]').contents().find('iframe[name=ace_inner]').contents().find('html'), ]; + // Issue #7659: when in-place history mode is active, the historical pad + // renders inside #history-frame (its own document, with its own + // ace_outer/ace_inner). Propagate skin tokens through the same path so a + // user toggling dark mode while scrubbing sees the iframe re-theme. + const $hist = $('#history-frame'); + if ($hist.length) { + domsToUpdate.push($hist.contents().find('html')); + domsToUpdate.push($hist.contents().find('iframe[name=ace_outer]').contents().find('html')); + domsToUpdate.push( + $hist.contents().find('iframe[name=ace_outer]').contents() + .find('iframe[name=ace_inner]').contents().find('html')); + } + colors.forEach((color) => { containers.forEach((container) => { domsToUpdate.forEach((el) => { el.removeClass(`${color}-${container}`); }); diff --git a/src/static/js/timeslider.ts b/src/static/js/timeslider.ts index ac15aca2388..5b2f1e79ade 100644 --- a/src/static/js/timeslider.ts +++ b/src/static/js/timeslider.ts @@ -73,6 +73,26 @@ const init = () => { // start the custom js if (typeof customStart === 'function') customStart(); // eslint-disable-line no-undef + // Issue #7659: when this timeslider is mounted as the in-place history + // iframe inside a pad page, mark the body so CSS can hide the inner + // editbar (the outer pad's toolbar owns the slider) and inherit the + // parent's skin tokens so dark mode (and any other skinVariants the + // user toggled at runtime) is applied immediately on first paint. + // Direct visits to /p/:pad/timeslider?embed=1 (existing test/legacy + // entry points) keep their full chrome because parent === window. + try { + if (window.parent !== window) { + document.body.classList.add('iframe-mode'); + const parentClasses = window.parent.document.documentElement.className || ''; + const tokens = parentClasses.split(/\s+/).filter((c) => + /^(super-light|light|dark|super-dark)-(toolbar|editor|background)$/.test(c) || + c === 'full-width-editor'); + if (tokens.length) { + document.documentElement.classList.add(...tokens); + } + } + } catch (_e) { /* cross-origin parent — leave defaults */ } + // get the padId out of the url const urlParts = document.location.pathname.split('/'); padId = decodeURIComponent(urlParts[urlParts.length - 2]); @@ -85,7 +105,19 @@ const init = () => { // or writes it; the server picks it up from the socket.io handshake. cp = (window as any).clientVars?.cookiePrefix || ''; - socket = socketio.connect(exports.baseURL, '/', {query: {padId}}); + // Pass `embed` to the server when this timeslider is the in-place + // history iframe inside a pad page (issue #7659). Without this the + // server's duplicate-author kick treats the iframe's connection as a + // stale tab and disconnects the parent pad's live socket. + const embed = (() => { + try { + if (window.parent === window) return false; + const params = new URLSearchParams(window.location.search); + return params.get('embed') === '1'; + } catch (_e) { return false; } + })(); + socket = socketio.connect( + exports.baseURL, '/', {query: embed ? {padId, embed: '1'} : {padId}}); // send the ready message once we're connected socket.on('connect', () => { @@ -159,6 +191,9 @@ const handleClientVars = (message) => { // load all script that doesn't work without the clientVars BroadcastSlider = require('./broadcast_slider') .loadBroadcastSliderJS(fireWhenAllScriptsAreLoaded); + // Exposed on window so the outer pad shell (issue #7659 in-place history + // mode) can subscribe to slider movement without postMessage round-trips. + (window as any).BroadcastSlider = BroadcastSlider; require('./broadcast_revisions').loadBroadcastRevisionsJS(); changesetLoader = require('./broadcast') diff --git a/src/templates/pad.html b/src/templates/pad.html index 8a4e3ac3c80..adc9df0cf11 100644 --- a/src/templates/pad.html +++ b/src/templates/pad.html @@ -78,6 +78,55 @@ <%- toolbar.menu(settings.toolbar.left, isReadOnly, 'left', 'pad') %> <% e.end_block(); %> + +