Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions doc/skins.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,18 @@ A skin is a directory located under `static/skins/<skin_name>`, 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:
Expand Down
11 changes: 9 additions & 2 deletions doc/skins.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,15 @@ A skin is a directory located under `static/skins/<skin_name>`, 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`

Expand Down
11 changes: 11 additions & 0 deletions src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,17 @@
"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.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",
Expand Down
14 changes: 12 additions & 2 deletions src/node/handler/PadMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))) {
Expand Down Expand Up @@ -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);
Expand Down
22 changes: 21 additions & 1 deletion src/node/hooks/express/specialpages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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,
Expand Down Expand Up @@ -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)}`);
}
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
ensureAuthorTokenCookie(req, res, settings);
hooks.callAll('padInitToolbar', {
toolbar,
Expand All @@ -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,
Expand Down
147 changes: 147 additions & 0 deletions src/static/css/pad.css
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,150 @@ 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 <li><a> 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; }
.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;
}
14 changes: 14 additions & 0 deletions src/static/css/timeslider.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 4 additions & 1 deletion src/static/js/broadcast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions src/static/js/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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($('<div>').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));
Expand Down
2 changes: 2 additions & 0 deletions src/static/js/pad.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -671,6 +672,7 @@ const pad = {
$('#colorpicker').farbtastic({callback: '#mycolorpickerpreview', width: 220});
$('#readonlyinput').on('click', () => { padeditbar.setEmbedLinks(); });
padcookie.init();
padMode.init();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. History mode lacks feature flag 📘 Rule violation ☼ Reliability

The new in-pad history mode is initialized and reachable by default without a feature flag. This
violates the requirement that new features be gated and disabled by default to preserve the
pre-change behavior when the flag is off.
Agent Prompt
## Issue description
In-pad history mode is a new feature but it is enabled by default (initialized on every pad load and entered via the toolbar). Compliance requires a feature flag with default-off behavior that preserves the pre-change code path.

## Issue Context
The change introduces a new controller (`pad_mode`) and new routing/UX behavior. When the flag is off, clicking the history button should follow the legacy navigation behavior and the pad should not initialize/mount history-mode UI.

## Fix Focus Areas
- src/static/js/pad.ts[45-45]
- src/static/js/pad.ts[675-675]
- src/static/js/pad_editbar.ts[502-510]
- src/static/js/pad_mode.ts[231-246]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

await handshake();
this._afterHandshake();
})());
Expand Down
9 changes: 8 additions & 1 deletion src/static/js/pad_editbar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
Loading
Loading