etherpad-lite/src/static/js/pad.ts
John McLear bfaf442935
fix(pad): URL view-option params lost to padeditor.init race (#7840) (#7843)
* fix(pad): URL view-option params lost to padeditor.init race (#7840)

`?showLineNumbers=false` and `?useMonospaceFont=true` were being
silently clobbered shortly after pad load. Same race that affected
`?rtl=false` before #7464:

  1. _afterHandshake → getParams() sets settings.LineNumbersDisabled
     (or useMonospaceFontGlobal, noColors).
  2. _afterHandshake calls padeditor.init(view).then(postAceInit) —
     async; ace iframes still loading.
  3. Sync tail of _afterHandshake hits the URL-param overrides and
     calls changeViewOption('showLineNumbers', false) etc. These
     queue setProperty('showslinenumbers', false) in Ace2Editor's
     actionsPendingInit queue (loaded=false).
  4. ace.init resolves → loaded=true → queue flushes → URL-driven
     value applied.
  5. padeditor.init resumes past its own await and calls
     setViewOptions(initialViewOptions) — initialViewOptions is
     built from clientVars.initialOptions.view (server defaults
     ∨ cookie), which does NOT carry the URL preference. The
     resulting setProperty('showslinenumbers', true) runs against
     loaded=true ace and immediately re-shows the gutter.

#7464 noticed this race for RTL and moved the override into
postAceInit. The neighbouring blocks for showLineNumbers / noColors
/ useMonospaceFontGlobal were left at the synchronous-tail site —
generalise the same fix to all three.

Direct-browser users typically had a `prefs` cookie with
showLineNumbers=false from a prior in-pad toggle, so the
initialViewOptions value happened to match the URL param and the
race was unobservable. Cross-context iframe embeds (the reporter's
configuration) start with no cookie, so the server default true
fights the URL false and the race becomes visible.

Adds src/tests/frontend-new/specs/url_view_options.spec.ts covering
the showLineNumbers=false / =true / useMonospaceFont=true cases on
initial-load navigation (the path where the race actually fires).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(types): annotate navigateWithParam helper params

Fixes CI ts-check: parameters page/padId/param implicitly had `any`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:10:12 +02:00

1094 lines
40 KiB
TypeScript

// @ts-nocheck
'use strict';
const skinVariants = require('./skin_variants');
/**
* This code is mostly from the old Etherpad. Please help us to comment this code.
* This helps other people to understand this code better and helps them to improve it.
* TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED
*/
/**
* Copyright 2009 Google Inc., 2011 Peter 'Pita' Martischka (Primary Technology Ltd)
*
* 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
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS-IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
let socket;
// These jQuery things should create local references, but for now `require()`
// assigns to the global `$` and augments it with plugins.
require('./vendors/jquery');
require('./vendors/farbtastic');
require('./vendors/gritter');
import html10n from './vendors/html10n'
import {Cookies} from "./pad_utils";
const chat = require('./chat').chat;
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;
const padsavedrevs = require('./pad_savedrevs');
const paduserlist = require('./pad_userlist').paduserlist;
import padutils from './pad_utils'
const colorutils = require('./colorutils').colorutils;
import {randomString} from "./pad_utils";
const socketio = require('./socketio');
const hooks = require('./pluginfw/hooks');
import {showPrivacyBannerIfEnabled} from './privacy_banner';
import {maybeShowOutdatedNotice} from './pad_outdated_notice';
// This array represents all GET-parameters which can be used to change a setting.
// name: the parameter-name, eg `?noColors=true` => `noColors`
// checkVal: the callback is only executed when
// * the parameter was supplied and matches checkVal
// * the parameter was supplied and checkVal is null
// callback: the function to call when all above succeeds, `val` is the value supplied by the user
const getParameters = [
{
name: 'noColors',
checkVal: 'true',
callback: (val) => {
settings.noColors = true;
$('#clearAuthorship').hide();
},
},
{
name: 'fadeInactiveAuthorColors',
checkVal: 'false',
callback: (val) => {
if (!clientVars.initialOptions) return;
if (!clientVars.initialOptions.view) clientVars.initialOptions.view = {};
clientVars.initialOptions.view.fadeInactiveAuthorColors = false;
},
},
{
name: 'showControls',
checkVal: 'true',
callback: (val) => {
$('#editbar').css('display', 'flex');
},
},
{
// showMenuRight accepts 'true' or 'false'. Explicit 'false' hides the
// right-side toolbar (import/export/timeslider/settings/embed/home/
// users) — useful for iframe-embedded readonly pads where the menu
// is just chrome (issue #5182). Explicit 'true' forces the menu
// visible (a no-op against the default, kept for symmetry and for
// overriding any future caller-applied hide). Any other value is a
// no-op — the menu stays in its default state.
name: 'showMenuRight',
checkVal: null,
callback: (val) => {
if (val === 'false') {
$('#editbar .menu_right').hide();
} else if (val === 'true') {
$('#editbar .menu_right').show();
}
},
},
{
name: 'showChat',
checkVal: null,
callback: (val) => {
clientVars.initialOptions.showChat = val !== 'false';
if (val === 'false') {
settings.hideChat = true;
chat.hide();
$('#chaticon').hide();
} else {
settings.hideChat = false;
}
},
},
{
name: 'showLineNumbers',
checkVal: 'false',
callback: (val) => {
settings.LineNumbersDisabled = true;
},
},
{
name: 'useMonospaceFont',
checkVal: 'true',
callback: (val) => {
settings.useMonospaceFontGlobal = true;
},
},
{
name: 'userName',
checkVal: null,
callback: (val) => {
// The default for globalUserName/globalUserColor is the boolean `false`
// (sentinel meaning "no enforced value"). Older settings.json files used
// boolean `false` for these options too, which getParams() coerces to
// the string "false" — that fooled the !== false sentinel checks at
// _afterHandshake and shipped the literal string "false" as the user's
// name and color (#7686). Reject the sentinel string here so URL
// parameters like ?userName=false also no-op.
if (!val || val === 'false') return;
settings.globalUserName = val;
clientVars.userName = val;
},
},
{
name: 'userColor',
checkVal: null,
callback: (val) => {
if (!val || val === 'false') return;
settings.globalUserColor = val;
clientVars.userColor = val;
},
},
{
name: 'rtl',
checkVal: null,
callback: (val, fromUrl) => {
settings.rtlIsTrue = val === 'true';
if (fromUrl) settings.rtlIsExplicit = true;
},
},
{
name: 'alwaysShowChat',
checkVal: 'true',
callback: (val) => {
if (!settings.hideChat) chat.stickToScreen();
},
},
{
name: 'chatAndUsers',
checkVal: 'true',
callback: (val) => {
chat.chatAndUsers();
},
},
{
name: 'lang',
checkVal: null,
callback: (val) => {
console.log('Val is', val)
html10n.localize([val, 'en']);
const prefix = (window as any).clientVars?.cookiePrefix || '';
Cookies.set(`${prefix}language`, val);
},
},
];
const getParams = () => {
const params = getUrlVars();
for (const setting of getParameters) {
// URL query params take priority over server-enforced options.
// This prevents race conditions where both fire async callbacks
// (e.g., lang setting triggers html10n.localize twice).
const urlValue = params.get(setting.name);
if (urlValue && (urlValue === setting.checkVal || setting.checkVal == null)) {
setting.callback(urlValue, true);
continue;
}
// Fall back to server-enforced option
let serverValue = clientVars.padOptions[setting.name];
if (serverValue == null) continue;
serverValue = serverValue.toString();
if (serverValue === setting.checkVal || setting.checkVal == null) {
setting.callback(serverValue, false);
}
}
};
const getUrlVars = () => new URL(window.location.href).searchParams;
const getCookieLanguage = () => {
const cp = (window as any).clientVars?.cookiePrefix || '';
return Cookies.get(`${cp}language`) || Cookies.get('language');
};
const getMyViewOverrides = () => {
const language = getCookieLanguage();
const overrides = {
showChat: padcookie.getPref('showChat'),
alwaysShowChat: padcookie.getPref('chatAlwaysVisible'),
chatAndUsers: padcookie.getPref('chatAndUsers'),
lang: language,
view: {
showAuthorColors: padcookie.getPref('showAuthorshipColors'),
showLineNumbers: padcookie.getPref('showLineNumbers'),
rtlIsTrue: padcookie.getPref('rtlIsTrue'),
padFontFamily: padcookie.getPref('padFontFamily'),
fadeInactiveAuthorColors: padcookie.getPref('fadeInactiveAuthorColors'),
},
};
if (language == null) delete overrides.lang;
return overrides;
};
const normalizeChatOptions = (options) => {
if (options.showChat === false) {
options.alwaysShowChat = false;
options.chatAndUsers = false;
}
if (options.chatAndUsers === true) {
options.showChat = true;
options.alwaysShowChat = true;
} else if (options.alwaysShowChat === true) {
options.showChat = true;
}
return options;
};
// Surfaces the one-time pad deletion token when the server sends it in
// clientVars (creator session, first CLIENT_READY). The token is cleared from
// clientVars on acknowledgement so it is not re-exposed to later code paths.
const showDeletionTokenModalIfPresent = () => {
const token: string | null = (window as any).clientVars?.padDeletionToken;
if (!token) return;
const $modal = $('#deletiontoken-modal');
const $input = $('#deletiontoken-value');
const $copy = $('#deletiontoken-copy');
const $ack = $('#deletiontoken-ack');
if ($modal.length === 0) return;
$input.val(token);
const previouslyFocused = document.activeElement as HTMLElement | null;
$modal.prop('hidden', false).addClass('popup-show');
// Focus the token input so screen readers announce the dialog body and the
// user lands on the value they need to copy.
setTimeout(() => ($input[0] as HTMLInputElement)?.focus(), 0);
$copy.off('click.gdpr').on('click.gdpr', async () => {
try {
await navigator.clipboard.writeText(token);
} catch (_e) {
($input[0] as HTMLInputElement).select();
document.execCommand('copy');
}
$copy.text(html10n.get('pad.deletionToken.copied'));
});
$ack.off('click.gdpr').on('click.gdpr', () => {
$input.val('');
$modal.prop('hidden', true).removeClass('popup-show');
(window as any).clientVars.padDeletionToken = null;
if (previouslyFocused && document.body.contains(previouslyFocused)) {
previouslyFocused.focus();
}
});
};
const sendClientReady = (isReconnect) => {
let padId = document.location.pathname.substring(document.location.pathname.lastIndexOf('/') + 1);
// unescape necessary due to Safari and Opera interpretation of spaces
padId = decodeURIComponent(padId);
if (!isReconnect) {
const titleArray = document.title.split('|');
const title = titleArray[titleArray.length - 1];
document.title = `${padId.replace(/_+/g, ' ')} | ${title}`;
}
// The author token lives in an HttpOnly cookie set by the server (GDPR PR3 /
// ether/etherpad#6701). The integrator-set `sessionID` cookie can also be
// HttpOnly now (issue #7045). The browser never reads or writes either; the
// server reads them from the socket.io handshake inside handleClientReady.
// If known, propagate the display name and color to the server in the CLIENT_READY message. This
// allows the server to include the values in its reply CLIENT_VARS message (which avoids
// initialization race conditions) and in the USER_NEWINFO messages sent to the other users on the
// pad (which enables them to display a user join notification with the correct name).
const params = getUrlVars();
const userInfo = {
colorId: params.get('userColor'),
name: params.get('userName'),
};
// The integrator-set `sessionID` cookie is read server-side from the
// socket.io handshake (issue #7045) so it can be HttpOnly. We no longer
// forward it via the CLIENT_READY payload.
const msg: any = {
component: 'pad',
type: 'CLIENT_READY',
padId,
userInfo,
};
const overrides = getMyViewOverrides();
const viewOverrides = Object.fromEntries(
Object.entries(overrides.view || {}).filter(([, v]) => v != null));
const hasTopLevelOverrides = ['showChat', 'alwaysShowChat', 'chatAndUsers', 'lang']
.some((k) => overrides[k] != null);
if (Object.keys(viewOverrides).length > 0 || hasTopLevelOverrides) {
if (Object.keys(viewOverrides).length > 0) overrides.view = viewOverrides;
else delete overrides.view;
msg.padSettingsDefaults = overrides;
}
// this is a reconnect, lets tell the server our revisionnumber
if (isReconnect) {
msg.client_rev = pad.collabClient.getCurrentRevisionNumber();
msg.reconnect = true;
}
socket.emit("message", msg);
};
const handshake = async () => {
let receivedClientVars = false;
let padId = document.location.pathname.substring(document.location.pathname.lastIndexOf('/') + 1);
// unescape necessary due to Safari and Opera interpretation of spaces
padId = decodeURIComponent(padId);
// padId is used here for sharding / scaling. We prefix the padId with padId: so it's clear
// to the proxy/gateway/whatever that this is a pad connection and should be treated as such
socket = pad.socket = socketio.connect(exports.baseURL, '/', {
query: {padId},
reconnectionAttempts: 5,
reconnection: true,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
});
socket.once('connect', () => {
sendClientReady(false);
});
socket.io.on('reconnect', () => {
// pad.collabClient might be null if the hanshake failed (or it never got that far).
if (pad.collabClient != null) {
pad.collabClient.setChannelState('CONNECTED');
}
sendClientReady(receivedClientVars);
});
const socketReconnecting = () => {
// pad.collabClient might be null if the hanshake failed (or it never got that far).
if (pad.collabClient != null) {
pad.collabClient.setStateIdle();
pad.collabClient.setIsPendingRevision(true);
pad.collabClient.setChannelState('RECONNECTING');
}
};
socket.on('disconnect', (reason) => {
// The socket.io client will automatically try to reconnect for all reasons other than "io
// server disconnect".
console.log(`Socket disconnected: ${reason}`)
//if (reason !== 'io server disconnect' || reason !== 'ping timeout') return;
socketReconnecting();
});
socket.on('shout', (obj) => {
if(obj.type === "COLLABROOM") {
const payload = obj.data.payload;
const msgObj = payload?.message || {};
// Pad-deletion denial shouts are surfaced inline by pad_editor.ts as an
// alert tied to the delete action; suppress the global "Admin message"
// gritter so the user doesn't see a confusing duplicate.
if (typeof msgObj.messageKey === 'string'
&& msgObj.messageKey.startsWith('pad.deletionToken.')) return;
// Updater drain announcements get their own title and dodge the generic
// "Admin message" framing so the user knows it's a system event.
const isUpdate = typeof msgObj.messageKey === 'string'
&& msgObj.messageKey.startsWith('update.drain.');
const text = msgObj.messageKey
? html10n.get(msgObj.messageKey, msgObj.values || {})
: msgObj.message;
if (!text) return;
const date = new Date(payload.timestamp);
$.gritter.add({
title: isUpdate ? html10n.get('update.banner.title') : 'Admin message',
text: '[' + date.toLocaleTimeString() + ']: ' + text,
sticky: !!msgObj.sticky
});
}
})
socket.io.on('reconnect_attempt', socketReconnecting);
socket.io.on('reconnect_failed', (error) => {
// pad.collabClient might be null if the hanshake failed (or it never got that far).
if (pad.collabClient != null) {
pad.collabClient.setChannelState('DISCONNECTED', 'reconnect_timeout');
} else {
throw new Error('Reconnect timed out');
}
});
socket.on('error', (error) => {
// pad.collabClient might be null if the error occurred before the hanshake completed.
if (pad.collabClient != null) {
pad.collabClient.setStateIdle();
pad.collabClient.setIsPendingRevision(true);
}
// Don't throw an exception. Error events do not indicate problems that are not already
// addressed by reconnection logic, so throwing an exception each time there's a socket.io error
// just annoys users and fills logs.
});
socket.on('message', (obj) => {
// the access was not granted, give the user a message
if (obj.accessStatus) {
if (obj.accessStatus === 'deny') {
$('#loading').hide();
$('#permissionDenied').show();
if (receivedClientVars) {
// got kicked
$('#editorcontainer').hide();
$('#editorloadingbox').show();
}
}
} else if (!receivedClientVars && obj.type === 'CLIENT_VARS') {
receivedClientVars = true;
window.clientVars = obj.data;
if (window.clientVars.sessionRefreshInterval) {
const ping =
() => $.ajax('../_extendExpressSessionLifetime', {method: 'PUT'}).catch(() => {});
setInterval(ping, window.clientVars.sessionRefreshInterval);
}
if(window.clientVars.mode === "development") {
console.warn('Enabling development mode with live update')
socket.on('liveupdate', ()=>{
console.log('Live reload update received')
location.reload()
})
}
} else if (obj.disconnect) {
padconnectionstatus.disconnected(obj.disconnect);
socket.disconnect();
// block user from making any change to the pad
padeditor.disable();
padeditbar.disable();
padimpexp.disable();
return;
} else {
pad._messageQ.enqueue(obj);
}
});
await Promise.all([
new Promise((resolve) => {
const h = (obj) => {
if (obj.accessStatus || obj.type !== 'CLIENT_VARS') return;
socket.off('message', h);
resolve();
};
socket.on('message', h);
}),
// This hook is only intended to be used by test code. If a plugin would like to use this hook,
// the hook must first be promoted to officially supported by deleting the leading underscore
// from the name, adding documentation to `doc/api/hooks_client-side.md`, and deleting this
// comment.
hooks.aCallAll('_socketCreated', {socket}),
]);
};
/** Defers message handling until setCollabClient() is called with a non-null value. */
class MessageQueue {
constructor() {
this._q = [];
this._cc = null;
}
setCollabClient(cc) {
this._cc = cc;
this.enqueue(); // Flush.
}
enqueue(...msgs) {
if (this._cc == null) {
this._q.push(...msgs);
} else {
while (this._q.length > 0) this._cc.handleMessageFromServer(this._q.shift());
for (const msg of msgs) this._cc.handleMessageFromServer(msg);
}
}
}
const pad = {
// don't access these directly from outside this file, except
// for debugging
collabClient: null,
myUserInfo: null,
diagnosticInfo: {},
initTime: 0,
clientTimeOffset: null,
padOptions: {},
_messageQ: new MessageQueue(),
// these don't require init; clientVars should all go through here
getPadId: () => clientVars.padId,
// Retained as a plugin-compat shim. The server no longer populates
// clientIp on clientVars (value was always '127.0.0.1'; see #6701 /
// privacy audit). pad_utils.uniqueId still consumes this as a prefix.
getClientIp: () => '127.0.0.1',
getColorPalette: () => clientVars.colorPalette,
getPrivilege: (name) => clientVars.accountPrivs[name],
canEditPadSettings: () => !!clientVars.canEditPadSettings,
getUserId: () => pad.myUserInfo.userId,
getUserName: () => pad.myUserInfo.name,
userList: () => paduserlist.users(),
isPadSettingsEnforcedForMe: () => !!pad.padOptions.enforceSettings && !pad.canEditPadSettings(),
sendClientMessage: (msg) => {
pad.collabClient.sendClientMessage(msg);
},
getEffectivePadOptions: () => {
const effectiveOptions = $.extend(true, {}, pad.padOptions);
if (pad.isPadSettingsEnforcedForMe()) return normalizeChatOptions(effectiveOptions);
const overrides = getMyViewOverrides();
for (const key of ['showChat', 'alwaysShowChat', 'chatAndUsers', 'lang']) {
if (overrides[key] != null) effectiveOptions[key] = overrides[key];
}
if (!effectiveOptions.view) effectiveOptions.view = {};
for (const [key, value] of Object.entries(overrides.view)) {
if (value != null) effectiveOptions.view[key] = value;
}
return normalizeChatOptions(effectiveOptions);
},
refreshPadSettingsControls: () => {
const padOptions = normalizeChatOptions($.extend(true, {}, pad.padOptions || {}));
const view = padOptions.view || {};
$('#padsettings-options-disablechat').prop('checked', padOptions.showChat === false);
$('#padsettings-options-stickychat').prop('checked', !!padOptions.alwaysShowChat);
$('#padsettings-options-chatandusers').prop('checked', !!padOptions.chatAndUsers);
$('#padsettings-options-colorscheck').prop('checked', view.showAuthorColors !== false);
$('#padsettings-options-fadeauthorcheck')
.prop('checked', view.fadeInactiveAuthorColors !== false);
$('#padsettings-options-linenoscheck').prop('checked', view.showLineNumbers !== false);
$('#padsettings-options-rtlcheck').prop('checked', !!view.rtlIsTrue);
$('#padsettings-viewfontmenu').val(view.padFontFamily || '');
$('#padsettings-languagemenu').val(padOptions.lang || 'en');
$('#padsettings-enforcecheck').prop('checked', !!padOptions.enforceSettings);
$('#padsettings-options-stickychat, #padsettings-options-chatandusers')
.prop('disabled', padOptions.showChat === false);
if ($('select').niceSelect) $('select').niceSelect('update');
},
refreshMyViewControls: () => {
const effectiveOptions = pad.getEffectivePadOptions();
const disabled = pad.isPadSettingsEnforcedForMe();
$('#options-disablechat').prop('checked', effectiveOptions.showChat === false);
$('#options-stickychat').prop('checked', !!effectiveOptions.alwaysShowChat);
$('#options-chatandusers').prop('checked', !!effectiveOptions.chatAndUsers);
$('#options-colorscheck').prop('checked', effectiveOptions.view?.showAuthorColors !== false);
$('#options-fadeauthorcheck')
.prop('checked', effectiveOptions.view?.fadeInactiveAuthorColors !== false);
$('#options-linenoscheck').prop('checked', effectiveOptions.view?.showLineNumbers !== false);
$('#options-rtlcheck').prop('checked', !!effectiveOptions.view?.rtlIsTrue);
$('#viewfontmenu').val(effectiveOptions.view?.padFontFamily || '');
$('#languagemenu').val(effectiveOptions.lang || 'en');
$('#settings input[id^="options-"]').prop('disabled', disabled);
$('#viewfontmenu, #languagemenu').prop('disabled', disabled);
$('#options-stickychat, #options-chatandusers')
.prop('disabled', disabled || effectiveOptions.showChat === false);
$('#enforce-settings-notice').prop('hidden', !disabled);
if ($('select').niceSelect) $('select').niceSelect('update');
},
setMyViewOption: (key, value) => {
switch (key) {
case 'showChat':
padcookie.setPref('showChat', value);
if (!value) {
padcookie.setPref('chatAlwaysVisible', false);
padcookie.setPref('chatAndUsers', false);
}
break;
case 'alwaysShowChat':
padcookie.setPref('chatAlwaysVisible', value);
if (value) padcookie.setPref('showChat', true);
break;
case 'chatAndUsers':
padcookie.setPref('chatAndUsers', value);
if (value) padcookie.setPref('chatAlwaysVisible', true);
if (value) padcookie.setPref('showChat', true);
break;
case 'showAuthorColors':
padcookie.setPref('showAuthorshipColors', value);
break;
default:
padcookie.setPref(key, value);
break;
}
pad.refreshMyViewControls();
pad.applyOptionsChange();
},
setMyViewLanguage: (lang) => {
const cp = (window as any).clientVars?.cookiePrefix || '';
Cookies.set(`${cp}language`, lang);
pad.refreshMyViewControls();
pad.applyOptionsChange();
},
applyShowChat: (enabled) => {
settings.hideChat = !enabled;
if (enabled) {
if (!window.clientVars.readonly) $('#chaticon').show();
} else {
$('#users, .sticky-container').removeClass('chatAndUsers popup-show stickyUsers');
$('#chatbox').removeClass('chatAndUsersChat stickyChat visible').hide();
$('#options-stickychat, #options-chatandusers').prop('checked', false);
$('#chaticon').hide();
}
},
applyStickyChat: (enabled) => {
const isSticky = $('#chatbox').hasClass('stickyChat');
$('#options-stickychat').prop('checked', enabled);
if (enabled !== isSticky) chat.stickToScreen(enabled, false);
if (!enabled) $('#options-stickychat').prop('disabled', false);
},
applyChatAndUsers: (enabled) => {
const isEnabled = $('#users').hasClass('chatAndUsers');
$('#options-chatandusers').prop('checked', enabled);
if (enabled !== isEnabled) chat.chatAndUsers(enabled, false);
if (!enabled) $('#options-stickychat').prop('disabled', false);
},
applyLanguage: (lang) => {
html10n.localize([lang, 'en']);
$('#languagemenu').val(lang);
if ($('select').niceSelect) $('select').niceSelect('update');
},
init() {
padutils.setupGlobalExceptionHandler();
// $(handler), $().ready(handler), $.wait($.ready).then(handler), etc. don't work if handler is
// an async function for some bizarre reason, so the async function is wrapped in a non-async
// function.
$(() => (async () => {
if (window.customStart != null) window.customStart();
$('#colorpicker').farbtastic({callback: '#mycolorpickerpreview', width: 220});
$('#readonlyinput').on('click', () => { padeditbar.setEmbedLinks(); });
padcookie.init();
padMode.init();
await handshake();
this._afterHandshake();
})());
},
_afterHandshake() {
pad.clientTimeOffset = Date.now() - clientVars.serverTimestamp;
// initialize the chat
chat.init(this);
getParams();
padcookie.init(); // initialize the cookies
pad.initTime = +(new Date());
pad.padOptions = clientVars.initialOptions;
pad.myUserInfo = {
userId: clientVars.userId,
name: clientVars.userName,
ip: pad.getClientIp(),
colorId: clientVars.userColor,
};
const postAceInit = () => {
padeditbar.init();
// Skip link (a11y, ether/etherpad#7255): href="#editorcontainer" gives
// a working no-JS fallback, but the real focus target is the inner
// contenteditable inside two nested iframes — route through ace_focus.
$('#skip-to-content').on('click', (e) => {
e.preventDefault();
padeditor.ace.focus();
});
// Auto-focusing the editor on load traps Tab inside the editor iframe
// (Tab inserts an indent there, not bubbling out), which makes the
// skip link above unreachable via Tab from the URL bar — i.e., the
// standard WCAG 2.4.1 entry path. Users now click or Tab into the
// editor; the skip link is the first tabbable element.
pad.refreshPadSettingsControls();
pad.applyOptionsChange();
pad.refreshMyViewControls();
// The following view overrides MUST run inside postAceInit (after
// padeditor.init resolves), not in the synchronous tail of
// _afterHandshake. Running them sync queues a setProperty in the
// Ace2Editor pending-init queue; the queue is flushed when ace
// finishes loading, but padeditor.init's own
// setViewOptions(initialViewOptions) call runs immediately *after*
// that flush and clobbers the URL-driven value. See #7464 for the
// RTL incarnation of this race (now generalised here for #7840).
if (settings.rtlIsExplicit) {
// URL or server config explicitly set RTL — takes priority over cookie
pad.changeViewOption('rtlIsTrue', settings.rtlIsTrue === true);
}
if (settings.LineNumbersDisabled === true) {
pad.changeViewOption('showLineNumbers', false);
}
if (settings.noColors === true) {
pad.changeViewOption('noColors', true);
}
if (settings.useMonospaceFontGlobal === true) {
pad.changeViewOption('padFontFamily', 'RobotoMono');
}
// Prevent sticky chat or chat and users to be checked for mobiles
const checkChatAndUsersVisibility = (x) => {
if (x.matches) { // If media query matches
$('#options-chatandusers:checked').trigger('click');
$('#options-stickychat:checked').trigger('click');
}
};
const mobileMatch = window.matchMedia('(max-width: 800px)');
mobileMatch.addListener(checkChatAndUsersVisibility); // check if window resized
setTimeout(() => { checkChatAndUsersVisibility(mobileMatch); }, 0); // check now after load
$('#editorcontainer').addClass('initialized');
if (window.location.hash.toLowerCase() !== '#skinvariantsbuilder' && window.clientVars.enableDarkMode && (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) && !skinVariants.isWhiteModeEnabledInLocalStorage()) {
skinVariants.updateSkinVariantsClasses(['super-dark-editor', 'dark-background', 'super-dark-toolbar']);
}
if (window.clientVars.enableDarkMode) {
$('#theme-toggle-row').prop('hidden', false);
$('#options-darkmode').prop('checked', skinVariants.isDarkMode());
}
showDeletionTokenModalIfPresent();
showPrivacyBannerIfEnabled((clientVars as any).privacyBanner);
void maybeShowOutdatedNotice();
hooks.aCallAll('postAceInit', {ace: padeditor.ace, clientVars, pad});
};
// order of inits is important here:
padimpexp.init(this);
padsavedrevs.init(this);
padeditor.init(pad.getEffectivePadOptions().view || {}, this).then(postAceInit);
paduserlist.init(pad.myUserInfo, this);
padconnectionstatus.init();
padmodals.init(this);
pad.collabClient = getCollabClient(
padeditor.ace, clientVars.collab_client_vars, pad.myUserInfo,
{colorPalette: pad.getColorPalette()}, pad);
this._messageQ.setCollabClient(this.collabClient);
pad.collabClient.setOnUserJoin(pad.handleUserJoin);
pad.collabClient.setOnUpdateUserInfo(pad.handleUserUpdate);
pad.collabClient.setOnUserLeave(pad.handleUserLeave);
pad.collabClient.setOnClientMessage(pad.handleClientMessage);
pad.collabClient.setOnChannelStateChange(pad.handleChannelStateChange);
pad.collabClient.setOnInternalAction(pad.handleCollabAction);
// load initial chat-messages
if (clientVars.chatHead !== -1) {
const chatHead = clientVars.chatHead;
const start = Math.max(chatHead - 100, 0);
pad.collabClient.sendMessage({type: 'GET_CHAT_MESSAGES', start, end: chatHead});
} else {
// there are no messages
$('#chatloadmessagesbutton').css('display', 'none');
}
if (window.clientVars.readonly) {
chat.hide();
$('#myusernameedit').attr('disabled', true);
$('#chatinput').attr('disabled', true);
$('#chaticon').hide();
$('#options-chatandusers').parent().hide();
$('#options-stickychat').parent().hide();
// The right-side toolbar stays visible on readonly pads. The
// server-side `toolbar.menu(buttons, isReadOnly)` (see
// src/node/utils/toolbar.ts) already strips `savedrevision`, and
// `.readonly .acl-write { display: none }` hides the Import column
// inside the import/export popup, so the remaining controls
// (export, timeslider, settings, embed, home, showusers) are all
// safe for readonly viewers — and the userlist is the surface that
// plugins like ep_guest hang their "Log In" button off, so hiding
// it traps guests in readonly with no way out. Iframe-embed use
// cases that want a clean look (issue #5182) opt in to the hide
// via `?showMenuRight=false`, or hide the whole editbar via
// `?showControls=false`.
} else if (!settings.hideChat) { $('#chaticon').show(); }
$('body').addClass(window.clientVars.readonly ? 'readonly' : 'readwrite');
padeditor.ace.callWithAce((ace) => {
ace.ace_setEditable(!window.clientVars.readonly);
});
// if the globalUserName value is set we need to tell the server and
// the client about the new authorname
if (settings.globalUserName !== false) {
this.notifyChangeName(settings.globalUserName); // Notifies the server
this.myUserInfo.name = settings.globalUserName;
$('#myusernameedit').val(settings.globalUserName); // Updates the current users UI
}
if (settings.globalUserColor !== false && colorutils.isCssHex(settings.globalUserColor)) {
// Add a 'globalUserColor' property to myUserInfo,
// so collabClient knows we have a query parameter.
this.myUserInfo.globalUserColor = settings.globalUserColor;
this.notifyChangeColor(settings.globalUserColor); // Updates this.myUserInfo.colorId
paduserlist.setMyUserInfo(this.myUserInfo);
}
},
dispose: () => {
padeditor.dispose();
},
notifyChangeName: (newName) => {
pad.myUserInfo.name = newName;
pad.collabClient.updateUserInfo(pad.myUserInfo);
},
notifyChangeColor: (newColorId) => {
pad.myUserInfo.colorId = newColorId;
pad.collabClient.updateUserInfo(pad.myUserInfo);
},
changePadOption: (key, value) => {
const options = {};
options[key] = value;
pad.applyPadSettings(options);
pad.collabClient.sendClientMessage(
{
type: 'padoptions',
options,
changedBy: pad.myUserInfo.name || 'unnamed',
});
},
changePadViewOption: (key, value) => {
const options = {
view: {},
};
options.view[key] = value;
pad.applyPadSettings(options);
pad.collabClient.sendClientMessage(
{
type: 'padoptions',
options,
changedBy: pad.myUserInfo.name || 'unnamed',
});
},
changeViewOption: (key, value) => {
const effectiveOptions = pad.getEffectivePadOptions();
if (!effectiveOptions.view) effectiveOptions.view = {};
effectiveOptions.view[key] = value;
padeditor.setViewOptions(effectiveOptions.view);
},
applyPadSettings: (opts = {}) => {
// opts object is a full set of options or just
// some options to change
for (const key of ['enforceSettings', 'showChat', 'alwaysShowChat', 'chatAndUsers', 'lang']) {
if (opts[key] == null) continue;
pad.padOptions[key] = key === 'lang' ? opts[key] : `${opts[key]}` === 'true';
}
if (opts.view) {
if (!pad.padOptions.view) {
pad.padOptions.view = {};
}
for (const [k, v] of Object.entries(opts.view)) {
pad.padOptions.view[k] = v;
}
}
// Plugin-namespaced keys (ep_*) are passed through verbatim so plugins
// can ride the existing padoptions broadcast/persist rail. Gated on
// settings.enablePluginPadOptions (mirrored to clientVars by
// getPublicSettings). Server-side normalizePadSettings preserves the
// same keys symmetrically.
if (clientVars.enablePluginPadOptions) {
for (const [k, v] of Object.entries(opts)) {
if (/^ep_[a-z0-9_]+$/.test(k)) pad.padOptions[k] = v;
}
}
normalizeChatOptions(pad.padOptions);
pad.refreshPadSettingsControls();
pad.applyOptionsChange();
},
applyOptionsChange: () => {
const effectiveOptions = pad.getEffectivePadOptions();
padeditor.setViewOptions(effectiveOptions.view || {});
pad.applyShowChat(effectiveOptions.showChat !== false);
if (effectiveOptions.showChat !== false) {
if (effectiveOptions.lang) pad.applyLanguage(effectiveOptions.lang);
pad.applyChatAndUsers(!!effectiveOptions.chatAndUsers);
if (!effectiveOptions.chatAndUsers) pad.applyStickyChat(!!effectiveOptions.alwaysShowChat);
}
pad.refreshMyViewControls();
},
handleOptionsChange: (opts) => {
pad.applyPadSettings(opts);
},
// caller shouldn't mutate the object
getPadOptions: () => pad.padOptions,
suggestUserName: (userId, name) => {
pad.collabClient.sendClientMessage(
{
type: 'suggestUserName',
unnamedId: userId,
newName: name,
});
},
handleUserJoin: (userInfo) => {
paduserlist.userJoinOrUpdate(userInfo);
},
handleUserUpdate: (userInfo) => {
paduserlist.userJoinOrUpdate(userInfo);
},
handleUserLeave: (userInfo) => {
paduserlist.userLeave(userInfo);
},
handleClientMessage: (msg) => {
if (msg.type === 'suggestUserName') {
if (msg.unnamedId === pad.myUserInfo.userId && msg.newName && !pad.myUserInfo.name) {
pad.notifyChangeName(msg.newName);
paduserlist.setMyUserInfo(pad.myUserInfo);
}
} else if (msg.type === 'newRevisionList') {
padsavedrevs.newRevisionList(msg.revisionList);
} else if (msg.type === 'revisionLabel') {
padsavedrevs.newRevisionList(msg.revisionList);
} else if (msg.type === 'padoptions') {
const opts = msg.options;
pad.handleOptionsChange(opts);
}
},
showUnacceptedCommitWarning: () => {
$.gritter.add({
title: html10n.get('pad.gritter.unacceptedCommit.title'),
text: html10n.get('pad.gritter.unacceptedCommit.text'),
sticky: true,
class_name: 'disconnected unsaved-warning',
});
},
handleChannelStateChange: (newState, message) => {
const oldFullyConnected = !!padconnectionstatus.isFullyConnected();
const wasConnecting = (padconnectionstatus.getStatus().what === 'connecting');
if (newState === 'CONNECTED') {
padeditor.enable();
padeditbar.enable();
padimpexp.enable();
padconnectionstatus.connected();
} else if (newState === 'RECONNECTING') {
padeditor.disable();
padeditbar.disable();
padimpexp.disable();
padconnectionstatus.reconnecting();
} else if (newState === 'DISCONNECTED') {
pad.diagnosticInfo.disconnectedMessage = message;
pad.diagnosticInfo.padId = pad.getPadId();
pad.diagnosticInfo.socket = {};
// we filter non objects from the socket object and put them in the diagnosticInfo
// this ensures we have no cyclic data - this allows us to stringify the data
for (const [i, value] of Object.entries(socket.socket || {})) {
const type = typeof value;
if (type === 'string' || type === 'number') {
pad.diagnosticInfo.socket[i] = value;
}
}
pad.asyncSendDiagnosticInfo();
if (typeof window.ajlog === 'string') {
window.ajlog += (`Disconnected: ${message}\n`);
}
padeditor.disable();
padeditbar.disable();
padimpexp.disable();
padconnectionstatus.disconnected(message);
if (pad.collabClient.hasUnacceptedCommit()) pad.showUnacceptedCommitWarning();
}
const newFullyConnected = !!padconnectionstatus.isFullyConnected();
if (newFullyConnected !== oldFullyConnected) {
pad.handleIsFullyConnected(newFullyConnected, wasConnecting);
}
},
handleIsFullyConnected: (isConnected, isInitialConnect) => {
pad.refreshMyViewControls();
setTimeout(() => {
padeditbar.toggleDropDown('none');
}, 1000);
},
determineChatVisibility: (asNowConnectedFeedback) => {
pad.refreshMyViewControls();
},
determineChatAndUsersVisibility: (asNowConnectedFeedback) => {
pad.refreshMyViewControls();
},
determineAuthorshipColorsVisibility: () => {
pad.refreshMyViewControls();
},
handleCollabAction: (action) => {
if (action === 'commitPerformed') {
padeditbar.setSyncStatus('syncing');
} else if (action === 'newlyIdle') {
padeditbar.setSyncStatus('done');
}
},
asyncSendDiagnosticInfo: () => {
const currentUrl = window.location.href;
fetch(`${exports.baseURL}ep/pad/connection-diagnostic-info`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
diagnosticInfo: pad.diagnosticInfo,
}),
}).catch((error) => {
console.error('Error sending diagnostic info:', error);
})
},
forceReconnect: () => {
$('form#reconnectform input.padId').val(pad.getPadId());
pad.diagnosticInfo.collabDiagnosticInfo = pad.collabClient.getDiagnosticInfo();
$('form#reconnectform input.diagnosticInfo').val(JSON.stringify(pad.diagnosticInfo));
$('form#reconnectform input.missedChanges')
.val(JSON.stringify(pad.collabClient.getMissedChanges()));
$('form#reconnectform').trigger('submit');
},
callWhenNotCommitting: (f) => {
pad.collabClient.callWhenNotCommitting(f);
},
getCollabRevisionNumber: () => pad.collabClient.getCurrentRevisionNumber(),
isFullyConnected: () => padconnectionstatus.isFullyConnected(),
addHistoricalAuthors: (data) => {
if (!pad.collabClient) {
window.setTimeout(() => {
pad.addHistoricalAuthors(data);
}, 1000);
} else {
pad.collabClient.addHistoricalAuthors(data);
}
},
};
const init = () => pad.init();
const settings = {
LineNumbersDisabled: false,
noColors: false,
useMonospaceFontGlobal: false,
globalUserName: false,
globalUserColor: false,
rtlIsTrue: false,
rtlIsExplicit: false,
};
pad.settings = settings;
exports.baseURL = '';
exports.settings = settings;
exports.randomString = randomString;
exports.getParams = getParams;
exports.pad = pad;
exports.init = init;