Skip to content
Merged
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
13 changes: 13 additions & 0 deletions src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,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",
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 @@ -418,6 +418,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 @@ -1051,12 +1057,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
179 changes: 179 additions & 0 deletions src/static/css/pad.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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; }

/* 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;
}
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
19 changes: 12 additions & 7 deletions 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 Expand Up @@ -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;
}
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
Loading
Loading