diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index d9f660907..822bd8b74 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,7 +1,7 @@
  1. 1
    1. nested
They are -->
  1. 1
    1. nested
diff --git a/src/static/js/domline.ts b/src/static/js/domline.ts index 900f60176..0cdc465da 100644 --- a/src/static/js/domline.ts +++ b/src/static/js/domline.ts @@ -62,8 +62,6 @@ domline.createDomLine = (nonEmpty, doesWrap, optBrowser, optDocument) => { if (document) { result.node = document.createElement('div'); - // JAWS and NVDA screen reader compatibility. Only needed if in a real browser. - result.node.setAttribute('aria-live', 'assertive'); } else { result.node = { innerHTML: '', diff --git a/src/static/js/l10n.ts b/src/static/js/l10n.ts index 929708b40..7e11adea6 100644 --- a/src/static/js/l10n.ts +++ b/src/static/js/l10n.ts @@ -3,7 +3,9 @@ import html10n from '../js/vendors/html10n'; // Set language for l10n let regexpLang: string | undefined; -let language = document.cookie.match(/language=((\w{2,3})(-\w+)?)/); +const cp = ((window as any).clientVars?.cookiePrefix || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +let language = document.cookie.match(new RegExp(`${cp}language=((\\w{2,3})(-\\w+)?)`)) + || document.cookie.match(/language=((\w{2,3})(-\w+)?)/); if (language) regexpLang = language[1]; html10n.mt.bind('indexed', () => { diff --git a/src/static/js/pad.ts b/src/static/js/pad.ts index 27447b31d..d9698f5e7 100644 --- a/src/static/js/pad.ts +++ b/src/static/js/pad.ts @@ -80,10 +80,13 @@ const getParameters = [ 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; } }, }, @@ -119,9 +122,10 @@ const getParameters = [ }, { name: 'rtl', - checkVal: 'true', - callback: (val) => { - settings.rtlIsTrue = true; + checkVal: null, + callback: (val, fromUrl) => { + settings.rtlIsTrue = val === 'true'; + if (fromUrl) settings.rtlIsExplicit = true; }, }, { @@ -144,34 +148,74 @@ const getParameters = [ callback: (val) => { console.log('Val is', val) html10n.localize([val, 'en']); - Cookies.set('language', val); + const prefix = (window as any).clientVars?.cookiePrefix || ''; + Cookies.set(`${prefix}language`, val); }, }, ]; const getParams = () => { - // Tries server enforced options first.. - for (const setting of getParameters) { - let value = clientVars.padOptions[setting.name]; - if (value == null) continue; - value = value.toString(); - if (value === setting.checkVal || setting.checkVal == null) { - setting.callback(value); - } - } - - // Then URL applied stuff const params = getUrlVars(); + for (const setting of getParameters) { - const value = params.get(setting.name); - if (value && (value === setting.checkVal || setting.checkVal == null)) { - setting.callback(value); + // 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'), + }, + }; + 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; +}; + const sendClientReady = (isReconnect) => { let padId = document.location.pathname.substring(document.location.pathname.lastIndexOf('/') + 1); // unescape necessary due to Safari and Opera interpretation of spaces @@ -183,10 +227,11 @@ const sendClientReady = (isReconnect) => { document.title = `${padId.replace(/_+/g, ' ')} | ${title}`; } - let token = Cookies.get('token'); + const cp = (window as any).clientVars?.cookiePrefix || ''; + let token = Cookies.get(`${cp}token`) || Cookies.get('token'); if (token == null || !padutils.isValidAuthorToken(token)) { token = padutils.generateAuthorToken(); - Cookies.set('token', token, {expires: 60}); + Cookies.set(`${cp}token`, token, {expires: 60}); } // If known, propagate the display name and color to the server in the CLIENT_READY message. This @@ -199,14 +244,24 @@ const sendClientReady = (isReconnect) => { name: params.get('userName'), }; - const msg = { + const msg: any = { component: 'pad', type: 'CLIENT_READY', padId, - sessionID: Cookies.get('sessionID'), + sessionID: Cookies.get(`${cp}sessionID`) || Cookies.get('sessionID'), token, 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) { @@ -400,12 +455,123 @@ const pad = { getClientIp: () => clientVars.clientIp, 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-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-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(); @@ -444,29 +610,13 @@ const pad = { setTimeout(() => { padeditor.ace.focus(); }, 0); - const optionsStickyChat = $('#options-stickychat'); - optionsStickyChat.on('click', () => { chat.stickToScreen(); }); - // if we have a cookie for always showing chat then show it - if (padcookie.getPref('chatAlwaysVisible')) { - chat.stickToScreen(true); // stick it to the screen - optionsStickyChat.prop('checked', true); // set the checkbox to on + pad.refreshPadSettingsControls(); + pad.applyOptionsChange(); + pad.refreshMyViewControls(); + if (settings.rtlIsExplicit) { + // URL or server config explicitly set RTL — takes priority over cookie + pad.changeViewOption('rtlIsTrue', settings.rtlIsTrue === true); } - // if we have a cookie for always showing chat then show it - if (padcookie.getPref('chatAndUsers')) { - chat.chatAndUsers(true); // stick it to the screen - $('#options-chatandusers').prop('checked', true); // set the checkbox to on - } - if (padcookie.getPref('showAuthorshipColors') === false) { - pad.changeViewOption('showAuthorColors', false); - } - if (padcookie.getPref('showLineNumbers') === false) { - pad.changeViewOption('showLineNumbers', false); - } - if (padcookie.getPref('rtlIsTrue') === true) { - pad.changeViewOption('rtlIsTrue', true); - } - pad.changeViewOption('padFontFamily', padcookie.getPref('padFontFamily')); - $('#viewfontmenu').val(padcookie.getPref('padFontFamily')).niceSelect('update'); // Prevent sticky chat or chat and users to be checked for mobiles const checkChatAndUsersVisibility = (x) => { @@ -481,13 +631,13 @@ const pad = { $('#editorcontainer').addClass('initialized'); - if (window.clientVars.enableDarkMode) { - $('#theme-switcher').attr('style', 'display: flex;'); - } - 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()); + } hooks.aCallAll('postAceInit', {ace: padeditor.ace, clientVars, pad}); }; @@ -495,7 +645,7 @@ const pad = { // order of inits is important here: padimpexp.init(this); padsavedrevs.init(this); - padeditor.init(pad.padOptions.view || {}, this).then(postAceInit); + padeditor.init(pad.getEffectivePadOptions().view || {}, this).then(postAceInit); paduserlist.init(pad.myUserInfo, this); padconnectionstatus.init(); padmodals.init(this); @@ -547,9 +697,8 @@ const pad = { this.changeViewOption('noColors', true); } - if (settings.rtlIsTrue === true) { - this.changeViewOption('rtlIsTrue', true); - } + // RTL override is applied in postAceInit (after padeditor.init resolves) + // to avoid a race where setViewOptions(initialViewOptions) overwrites it. // If the Monospacefont value is set to true then change it to monospace. if (settings.useMonospaceFontGlobal === true) { @@ -585,7 +734,20 @@ const pad = { changePadOption: (key, value) => { const options = {}; options[key] = value; - pad.handleOptionsChange(options); + 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', @@ -594,25 +756,43 @@ const pad = { }); }, changeViewOption: (key, value) => { - const options = { - view: {}, - }; - options.view[key] = value; - pad.handleOptionsChange(options); + const effectiveOptions = pad.getEffectivePadOptions(); + if (!effectiveOptions.view) effectiveOptions.view = {}; + effectiveOptions.view[key] = value; + padeditor.setViewOptions(effectiveOptions.view); }, - handleOptionsChange: (opts) => { + 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; - padcookie.setPref(k, v); } - padeditor.setViewOptions(pad.padOptions.view); } + 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, @@ -648,6 +828,14 @@ const pad = { 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'); @@ -685,6 +873,7 @@ const pad = { padimpexp.disable(); padconnectionstatus.disconnected(message); + if (pad.collabClient.hasUnacceptedCommit()) pad.showUnacceptedCommitWarning(); } const newFullyConnected = !!padconnectionstatus.isFullyConnected(); if (newFullyConnected !== oldFullyConnected) { @@ -692,39 +881,19 @@ const pad = { } }, handleIsFullyConnected: (isConnected, isInitialConnect) => { - pad.determineChatVisibility(isConnected && !isInitialConnect); - pad.determineChatAndUsersVisibility(isConnected && !isInitialConnect); - pad.determineAuthorshipColorsVisibility(); + pad.refreshMyViewControls(); setTimeout(() => { padeditbar.toggleDropDown('none'); }, 1000); }, determineChatVisibility: (asNowConnectedFeedback) => { - const chatVisCookie = padcookie.getPref('chatAlwaysVisible'); - if (chatVisCookie) { // if the cookie is set for chat always visible - chat.stickToScreen(true); // stick it to the screen - $('#options-stickychat').prop('checked', true); // set the checkbox to on - } else { - $('#options-stickychat').prop('checked', false); // set the checkbox for off - } + pad.refreshMyViewControls(); }, determineChatAndUsersVisibility: (asNowConnectedFeedback) => { - const chatAUVisCookie = padcookie.getPref('chatAndUsersVisible'); - if (chatAUVisCookie) { // if the cookie is set for chat always visible - chat.chatAndUsers(true); // stick it to the screen - $('#options-chatandusers').prop('checked', true); // set the checkbox to on - } else { - $('#options-chatandusers').prop('checked', false); // set the checkbox for off - } + pad.refreshMyViewControls(); }, determineAuthorshipColorsVisibility: () => { - const authColCookie = padcookie.getPref('showAuthorshipColors'); - if (authColCookie) { - pad.changeViewOption('showAuthorColors', true); - $('#options-colorscheck').prop('checked', true); - } else { - $('#options-colorscheck').prop('checked', false); - } + pad.refreshMyViewControls(); }, handleCollabAction: (action) => { if (action === 'commitPerformed') { @@ -735,7 +904,7 @@ const pad = { }, asyncSendDiagnosticInfo: () => { const currentUrl = window.location.href; - fetch('../ep/pad/connection-diagnostic-info', { + fetch(`${exports.baseURL}ep/pad/connection-diagnostic-info`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -780,6 +949,7 @@ const settings = { globalUserName: false, globalUserColor: false, rtlIsTrue: false, + rtlIsExplicit: false, }; pad.settings = settings; diff --git a/src/static/js/pad_cookie.ts b/src/static/js/pad_cookie.ts index bc624e962..0231a2466 100644 --- a/src/static/js/pad_cookie.ts +++ b/src/static/js/pad_cookie.ts @@ -21,7 +21,8 @@ import {Cookies} from "./pad_utils"; exports.padcookie = new class { constructor() { - this.cookieName_ = window.location.protocol === 'https:' ? 'prefs' : 'prefsHttp'; + const prefix = (window as any).clientVars?.cookiePrefix || ''; + this.cookieName_ = prefix + (window.location.protocol === 'https:' ? 'prefs' : 'prefsHttp'); } init() { @@ -43,7 +44,12 @@ exports.padcookie = new class { readPrefs_() { try { - const json = Cookies.get(this.cookieName_); + let json = Cookies.get(this.cookieName_); + // Fall back to unprefixed cookie for migration + if (json == null) { + const unprefixed = window.location.protocol === 'https:' ? 'prefs' : 'prefsHttp'; + if (unprefixed !== this.cookieName_) json = Cookies.get(unprefixed); + } if (json == null) return null; return JSON.parse(json); } catch (e) { diff --git a/src/static/js/pad_editbar.ts b/src/static/js/pad_editbar.ts index 810a9caac..cb8dae2a7 100644 --- a/src/static/js/pad_editbar.ts +++ b/src/static/js/pad_editbar.ts @@ -23,7 +23,6 @@ * limitations under the License. */ -const browser = require('./vendors/browser'); const hooks = require('./pluginfw/hooks'); import padutils from "./pad_utils"; const padeditor = require('./pad_editor').padeditor; @@ -158,15 +157,7 @@ exports.padeditbar = new class { ace: padeditor.ace, }); - /* - * On safari, the dropdown in the toolbar gets hidden because of toolbar - * overflow:hidden property. This is a bug from Safari: any children with - * position:fixed (like the dropdown) should be displayed no matter - * overflow:hidden on parent - */ - if (!browser.safari) { - $('select').niceSelect(); - } + $('select').niceSelect(); // When editor is scrolled, we add a class to style the editbar differently $('iframe[name="ace_outer"]').contents().on('scroll', (ev) => { @@ -365,7 +356,7 @@ exports.padeditbar = new class { this.registerDropdownCommand('import_export'); this.registerDropdownCommand('embed'); this.registerCommand('home', ()=>{ - window.location.href = window.location.href + "/../.." + window.location.href = new URL('../..', window.location.href).href }) this.registerCommand('settings', () => { diff --git a/src/static/js/pad_editor.ts b/src/static/js/pad_editor.ts index 3b1b11498..267ad5dd6 100644 --- a/src/static/js/pad_editor.ts +++ b/src/static/js/pad_editor.ts @@ -22,8 +22,7 @@ * limitations under the License. */ -import padutils,{Cookies} from "./pad_utils"; -const padcookie = require('./pad_cookie').padcookie; +import padutils from "./pad_utils"; const Ace2Editor = require('./ace').Ace2Editor; import html10n from '../js/vendors/html10n' const skinVariants = require('./skin_variants'); @@ -56,56 +55,120 @@ const padeditor = (() => { $('#viewbarcontents').show(); }, initViewOptions: () => { - // Line numbers + // My View + padutils.bindCheckboxChange($('#options-disablechat'), () => { + pad.setMyViewOption('showChat', !padutils.getCheckbox($('#options-disablechat'))); + }); + padutils.bindCheckboxChange($('#options-stickychat'), () => { + pad.setMyViewOption('alwaysShowChat', padutils.getCheckbox($('#options-stickychat'))); + }); + padutils.bindCheckboxChange($('#options-chatandusers'), () => { + pad.setMyViewOption('chatAndUsers', padutils.getCheckbox($('#options-chatandusers'))); + }); + padutils.bindCheckboxChange($('#options-colorscheck'), () => { + pad.setMyViewOption('showAuthorColors', padutils.getCheckbox($('#options-colorscheck'))); + }); padutils.bindCheckboxChange($('#options-linenoscheck'), () => { - pad.changeViewOption('showLineNumbers', padutils.getCheckbox($('#options-linenoscheck'))); + pad.setMyViewOption('showLineNumbers', padutils.getCheckbox($('#options-linenoscheck'))); + }); + padutils.bindCheckboxChange($('#options-rtlcheck'), () => { + pad.setMyViewOption('rtlIsTrue', padutils.getCheckbox($('#options-rtlcheck'))); + }); + $('#viewfontmenu').on('change', () => { + pad.setMyViewOption('padFontFamily', $('#viewfontmenu').val()); + }); + $('#languagemenu').on('change', () => { + pad.setMyViewLanguage($('#languagemenu').val()); + }); + + // Pad settings + padutils.bindCheckboxChange($('#padsettings-enforcecheck'), () => { + pad.changePadOption('enforceSettings', padutils.getCheckbox($('#padsettings-enforcecheck'))); + }); + padutils.bindCheckboxChange($('#padsettings-options-disablechat'), () => { + pad.changePadOption('showChat', !padutils.getCheckbox($('#padsettings-options-disablechat'))); + }); + padutils.bindCheckboxChange($('#padsettings-options-stickychat'), () => { + pad.changePadOption( + 'alwaysShowChat', padutils.getCheckbox($('#padsettings-options-stickychat'))); + }); + padutils.bindCheckboxChange($('#padsettings-options-chatandusers'), () => { + pad.changePadOption( + 'chatAndUsers', padutils.getCheckbox($('#padsettings-options-chatandusers'))); + }); + // Line numbers + padutils.bindCheckboxChange($('#padsettings-options-linenoscheck'), () => { + pad.changePadViewOption( + 'showLineNumbers', padutils.getCheckbox($('#padsettings-options-linenoscheck'))); }); // Author colors - padutils.bindCheckboxChange($('#options-colorscheck'), () => { - padcookie.setPref('showAuthorshipColors', padutils.getCheckbox('#options-colorscheck')); - pad.changeViewOption('showAuthorColors', padutils.getCheckbox('#options-colorscheck')); + padutils.bindCheckboxChange($('#padsettings-options-colorscheck'), () => { + pad.changePadViewOption( + 'showAuthorColors', padutils.getCheckbox('#padsettings-options-colorscheck')); }); // Right to left - padutils.bindCheckboxChange($('#options-rtlcheck'), () => { - pad.changeViewOption('rtlIsTrue', padutils.getCheckbox($('#options-rtlcheck'))); + padutils.bindCheckboxChange($('#padsettings-options-rtlcheck'), () => { + pad.changePadViewOption( + 'rtlIsTrue', padutils.getCheckbox($('#padsettings-options-rtlcheck'))); }); html10n.bind('localized', () => { - pad.changeViewOption('rtlIsTrue', ('rtl' === html10n.getDirection())); - padutils.setCheckbox($('#options-rtlcheck'), ('rtl' === html10n.getDirection())); + $('#languagemenu').val(html10n.getLanguage()); + $('#padsettings-languagemenu').val(html10n.getLanguage()); }); // font family change - $('#viewfontmenu').on('change', () => { - pad.changeViewOption('padFontFamily', $('#viewfontmenu').val()); + $('#padsettings-viewfontmenu').on('change', () => { + pad.changePadViewOption('padFontFamily', $('#padsettings-viewfontmenu').val()); + }); + + padutils.bindCheckboxChange($('#options-darkmode'), () => { + const isDark = padutils.getCheckbox($('#options-darkmode')); + skinVariants.setDarkModeInLocalStorage(isDark); + if (isDark) { + skinVariants.updateSkinVariantsClasses( + ['super-dark-editor', 'dark-background', 'super-dark-toolbar']); + } else { + skinVariants.updateSkinVariantsClasses( + ['super-light-toolbar super-light-editor light-background']); + } }); // delete pad $('#delete-pad').on('click', () => { if (window.confirm(html10n.get('pad.delete.confirm'))) { + // Wait for the server to confirm deletion before navigating away. + // Navigating immediately caused a race condition where the browser + // (especially Firefox) would close the WebSocket before the delete + // message reached the server. See #7306. + let handled = false; + pad.socket.on('message', (data: any) => { + if (data && data.disconnect === 'deleted') { + handled = true; + window.location.href = '/'; + } + }); + // If the user is not the pad creator, the server sends a shout + // message instead of deleting. Listen for it and show the error. + pad.socket.on('shout', (data: any) => { + handled = true; + const msg = data?.data?.payload?.message?.message; + if (msg) window.alert(msg); + }); pad.collabClient.sendMessage({type: 'PAD_DELETE', data:{padId: pad.getPadId()}}); - // redirect to home page after deletion - window.location.href = '/'; + // Fallback: if the server doesn't respond within 5 seconds + // (e.g. socket dropped), navigate away anyway. + setTimeout(() => { + if (!handled) window.location.href = '/'; + }, 5000); } }) - // theme switch - $('#theme-switcher').on('click',()=>{ - if (skinVariants.isDarkMode()) { - skinVariants.setDarkModeInLocalStorage(false); - skinVariants.updateSkinVariantsClasses(['super-light-toolbar super-light-editor light-background']); - } else { - skinVariants.setDarkModeInLocalStorage(true); - skinVariants.updateSkinVariantsClasses(['super-dark-editor', 'dark-background', 'super-dark-toolbar']); - } - }) - // Language html10n.bind('localized', () => { - $('#languagemenu').val(html10n.getLanguage()); // translate the value of 'unnamed' and 'Enter your name' textboxes in the userlist // this does not interfere with html10n's normal value-setting because @@ -119,14 +182,14 @@ const padeditor = (() => { } }); }); - $('#languagemenu').val(html10n.getLanguage()); - $('#languagemenu').on('change', () => { - Cookies.set('language', $('#languagemenu').val()); - html10n.localize([$('#languagemenu').val(), 'en']); - if ($('select').niceSelect) { - $('select').niceSelect('update'); - } + $('#padsettings-languagemenu').val(html10n.getLanguage()); + $('#padsettings-languagemenu').on('change', () => { + pad.changePadOption('lang', $('#padsettings-languagemenu').val()); }); + if (pad.canEditPadSettings()) { + $('#pad-settings-section').prop('hidden', false); + $('.settings-sections').addClass('has-pad-settings'); + } }, setViewOptions: (newOptions) => { const getOption = (key, defaultValue) => { @@ -158,6 +221,8 @@ const padeditor = (() => { } self.ace.setProperty('textface', newOptions.padFontFamily || ''); + $('#viewfontmenu').val(newOptions.padFontFamily || ''); + if ($('select').niceSelect) $('select').niceSelect('update'); }, dispose: () => { if (self.ace) { diff --git a/src/static/js/pad_impexp.ts b/src/static/js/pad_impexp.ts index 8b8575c81..de16213df 100644 --- a/src/static/js/pad_impexp.ts +++ b/src/static/js/pad_impexp.ts @@ -144,23 +144,23 @@ const padimpexp = (() => { $('#exportetherpada').attr('href', `${padRootPath}/export/etherpad`); $('#exportplaina').attr('href', `${padRootPath}/export/txt`); - // hide stuff thats not avaible if abiword/soffice is disabled + // hide stuff thats not avaible if soffice is disabled + const wordFormat = clientVars.docxExport ? 'docx' : 'doc'; if (clientVars.exportAvailable === 'no') { $('#exportworda').remove(); $('#exportpdfa').remove(); $('#exportopena').remove(); - - $('#importmessageabiword').show(); + $('#importmessagenoconverter').prop('hidden', false); } else if (clientVars.exportAvailable === 'withoutPDF') { $('#exportpdfa').remove(); - $('#exportworda').attr('href', `${padRootPath}/export/doc`); + $('#exportworda').attr('href', `${padRootPath}/export/${wordFormat}`); $('#exportopena').attr('href', `${padRootPath}/export/odt`); $('#importexport').css({height: '142px'}); $('#importexportline').css({height: '142px'}); } else { - $('#exportworda').attr('href', `${padRootPath}/export/doc`); + $('#exportworda').attr('href', `${padRootPath}/export/${wordFormat}`); $('#exportpdfa').attr('href', `${padRootPath}/export/pdf`); $('#exportopena').attr('href', `${padRootPath}/export/odt`); } diff --git a/src/static/js/pad_utils.ts b/src/static/js/pad_utils.ts index 0a66abf00..194974523 100644 --- a/src/static/js/pad_utils.ts +++ b/src/static/js/pad_utils.ts @@ -262,7 +262,7 @@ class PadUtils { } if (onEscape) { - node.on('keydown', (evt) => { + node.on('keydown', (evt: JQuery.KeyDownEvent) => { if (evt.which === 27) { onEscape(evt); } @@ -348,11 +348,7 @@ class PadUtils { getCheckbox = (node: string) => $(node).is(':checked') setCheckbox = (node: JQueryNode, value: boolean) => { - if (value) { - $(node).attr('checked', 'checked'); - } else { - $(node).prop('checked', false); - } + $(node).prop('checked', !!value); } bindCheckboxChange = (node: JQueryNode, func: Function) => { @@ -395,6 +391,7 @@ class PadUtils { * particular author. */ generateAuthorToken = () => `t.${randomString()}` + _seenErrors: Set = new Set(); setupGlobalExceptionHandler = () => { if (this.globalExceptionHandler == null) { this.globalExceptionHandler = (e: any) => { @@ -415,30 +412,51 @@ class PadUtils { if (err.name != null && msg !== err.name && !msg.startsWith(`${err.name}: `)) { msg = `${err.name}: ${msg}`; } + + // Ignore errors from browser extensions — they are unrelated to Etherpad + // and should not block the pad from loading. + // See https://github.com/ether/etherpad-lite/issues/6802 + const source = url || err.stack || ''; + if (/^(moz|chrome|safari)-extension:\/\//.test(source)) return; + const errorId = randomString(20); - let msgAlreadyVisible = false; - $('.gritter-item .error-msg').each(function () { - if ($(this).text() === msg) { - msgAlreadyVisible = true; - } - }); + const errorKey = `${type}:${msg}:${url}:${linenumber}`; + const msgAlreadyVisible = this._seenErrors.has(errorKey); if (!msgAlreadyVisible) { - const txt = document.createTextNode.bind(document); // Convenience shorthand. - const errorMsg = [ - $('

') - .append($('').text('Please press and hold Ctrl and press F5 to reload this page')), - $('

') - .text('If the problem persists, please send this error message to your webmaster:'), - $('

').css('text-align', 'left').css('font-size', '.8em').css('margin-top', '1em') - .append($('').addClass('error-msg').text(msg)).append($('
')) - .append(txt(`at ${url} at line ${linenumber}`)).append($('
')) - .append(txt(`ErrorId: ${errorId}`)).append($('
')) - .append(txt(type)).append($('
')) - .append(txt(`URL: ${window.location.href}`)).append($('
')) - .append(txt(`UserAgent: ${navigator.userAgent}`)).append($('
')), - ]; + this._seenErrors.add(errorKey); + // Hide internal error details from end users unless explicitly in development mode. + // Default to hiding details (secure by default) since clientVars.mode may not be + // available before the CLIENT_VARS handshake completes. + // See https://github.com/ether/etherpad-lite/issues/5765 + const isProduction = (window as any).clientVars?.mode !== 'development'; + + const errorMsg = isProduction + ? [ + $('

') + .append($('').text('Please press and hold Ctrl and press F5 to reload this page')), + $('

') + .text('If the problem persists, please contact your webmaster.') + .append($('
')) + .append($('').css('font-size', '.8em').text(`ErrorId: ${errorId}`)), + ] + : (() => { + const txt = document.createTextNode.bind(document); + return [ + $('

') + .append($('').text('Please press and hold Ctrl and press F5 to reload this page')), + $('

') + .text('If the problem persists, please send this error message to your webmaster:'), + $('

').css('text-align', 'left').css('font-size', '.8em').css('margin-top', '1em') + .append($('').addClass('error-msg').text(msg)).append($('
')) + .append(txt(`at ${url} at line ${linenumber}`)).append($('
')) + .append(txt(`ErrorId: ${errorId}`)).append($('
')) + .append(txt(type)).append($('
')) + .append(txt(`URL: ${window.location.href}`)).append($('
')) + .append(txt(`UserAgent: ${navigator.userAgent}`)).append($('
')), + ]; + })(); // @ts-ignore $.gritter.add({ diff --git a/src/static/js/pluginfw/LinkInstaller.ts b/src/static/js/pluginfw/LinkInstaller.ts index ddb8835ca..1c56a5c46 100644 --- a/src/static/js/pluginfw/LinkInstaller.ts +++ b/src/static/js/pluginfw/LinkInstaller.ts @@ -3,244 +3,243 @@ import path from "path"; import {node_modules, pluginInstallPath} from "./installer"; import {accessSync, constants, rmSync, symlinkSync, unlinkSync} from "node:fs"; import {dependencies, name} from '../../../package.json' -import {pathToFileURL} from 'node:url'; import settings from '../../../node/utils/Settings'; import {readFileSync} from "fs"; export class LinkInstaller { - private livePluginManager: PluginManager; - private loadedPlugins: IPluginInfo[] = []; - /* - * A map of dependencies to their dependents - * - */ - private readonly dependenciesMap: Map>; + private livePluginManager: PluginManager; + private loadedPlugins: IPluginInfo[] = []; + /* + * A map of dependencies to their dependents + * + */ + private readonly dependenciesMap: Map>; - constructor() { - this.livePluginManager = new PluginManager({ - pluginsPath: pluginInstallPath, - hostRequire: undefined, - cwd: path.join(settings.root, 'src') - }); - this.dependenciesMap = new Map(); + constructor() { + this.livePluginManager = new PluginManager({ + pluginsPath: pluginInstallPath, + hostRequire: undefined, + cwd: path.join(settings.root, 'src') + }); + this.dependenciesMap = new Map(); + } + + + public async init() { + // Insert Etherpad lite dependencies + for (let [dependency] of Object.entries(dependencies)) { + if (this.dependenciesMap.has(dependency)) { + this.dependenciesMap.get(dependency)?.add(name) + } else { + this.dependenciesMap.set(dependency, new Set([name])) + } } + } + public async installFromPath(path: string) { + const installedPlugin = await this.livePluginManager.installFromPath(path) + this.linkDependency(installedPlugin.name) + await this.checkLinkedDependencies(installedPlugin) + } - public async init() { - // Insert Etherpad lite dependencies - for (let [dependency] of Object.entries(dependencies)) { - if (this.dependenciesMap.has(dependency)) { - this.dependenciesMap.get(dependency)?.add(name) - } else { - this.dependenciesMap.set(dependency, new Set([name])) - } + public async installFromGitHub(repository: string) { + const installedPlugin = await this.livePluginManager.installFromGithub(repository) + this.linkDependency(installedPlugin.name) + await this.checkLinkedDependencies(installedPlugin) + } + + public async installPlugin(pluginName: string, version?: string) { + if (version) { + const installedPlugin = await this.livePluginManager.install(pluginName, version); + this.linkDependency(pluginName) + await this.checkLinkedDependencies(installedPlugin) + } else { + const installedPlugin = await this.livePluginManager.install(pluginName); + this.linkDependency(pluginName) + await this.checkLinkedDependencies(installedPlugin) + } + } + + public async listPlugins() { + const plugins = this.livePluginManager.list() + if (plugins && plugins.length > 0 && this.loadedPlugins.length == 0) { + this.loadedPlugins = plugins + // Check already installed plugins + for (let plugin of plugins) { + await this.checkLinkedDependencies(plugin) + } + } + return plugins + } + + public async uninstallPlugin(pluginName: string) { + const installedPlugin = this.livePluginManager.getInfo(pluginName) + if (installedPlugin) { + console.debug(`Uninstalling plugin ${pluginName}`) + await this.removeSymlink(installedPlugin) + await this.livePluginManager.uninstall(pluginName) + await this.removeSubDependencies(installedPlugin) + } + } + + private async removeSubDependencies(plugin: IPluginInfo) { + const pluginDependencies = Object.keys(plugin.dependencies) + console.debug("Removing sub dependencies",pluginDependencies) + for (let dependency of pluginDependencies) { + await this.removeSubDependency(plugin.name, dependency) + } + } + + private async removeSubDependency(_name: string, dependency:string) { + if (this.dependenciesMap.has(dependency)) { + console.debug(`Dependency ${dependency} is still being used by other plugins`) + return + } + // Read sub dependencies + try { + const json:IPluginInfo = JSON.parse( + readFileSync(path.join(pluginInstallPath, dependency, 'package.json'), 'utf-8')); + if(json.dependencies){ + for (let [subDependency] of Object.entries(json.dependencies)) { + await this.removeSubDependency(dependency, subDependency) } - } + } + } catch (e){} + this.uninstallDependency(dependency) + } - public async installFromPath(path: string) { - const installedPlugin = await this.livePluginManager.installFromPath(path) - this.linkDependency(installedPlugin.name) - await this.checkLinkedDependencies(installedPlugin) + private uninstallDependency(dependency: string) { + try { + console.debug(`Uninstalling dependency ${dependency}`) + // Check if the dependency is already installed + accessSync(path.join(pluginInstallPath, dependency), constants.F_OK) + rmSync(path.join(pluginInstallPath, dependency), { + force: true, + recursive: true + }) + } catch (err) { + // Symlink does not exist + // So nothing to do } + } - public async installFromGitHub(repository: string) { - const installedPlugin = await this.livePluginManager.installFromGithub(repository) - this.linkDependency(installedPlugin.name) - await this.checkLinkedDependencies(installedPlugin) + private async removeSymlink(plugin: IPluginInfo) { + try { + accessSync(path.join(node_modules, plugin.name), constants.F_OK) + await this.unlinkSubDependencies(plugin) + // Remove the plugin itself + this.unlinkDependency(plugin.name) + } catch (err) { + console.error(`Symlink for ${plugin.name} does not exist`) + // Symlink does not exist + // So nothing to do } + } - public async installPlugin(pluginName: string, version?: string) { - if (version) { - const installedPlugin = await this.livePluginManager.install(pluginName, version); - this.linkDependency(pluginName) - await this.checkLinkedDependencies(installedPlugin) - } else { - const installedPlugin = await this.livePluginManager.install(pluginName); - this.linkDependency(pluginName) - await this.checkLinkedDependencies(installedPlugin) + private async unlinkSubDependencies(plugin: IPluginInfo) { + const pluginDependencies = Object.keys(plugin.dependencies) + for (let dependency of pluginDependencies) { + this.dependenciesMap.get(dependency)?.delete(plugin.name) + await this.unlinkSubDependency(plugin.name, dependency) + } + } + + private async unlinkSubDependency(plugin: string, dependency: string) { + if (this.dependenciesMap.has(dependency)) { + this.dependenciesMap.get(dependency)?.delete(plugin) + if (this.dependenciesMap.get(dependency)!.size > 0) { + // We have other dependants so do not uninstall + return + } + } + this.unlinkDependency(dependency) + // Read sub dependencies + try { + const json:IPluginInfo = JSON.parse( + readFileSync(path.join(pluginInstallPath, dependency, 'package.json'), 'utf-8')); + if(json.dependencies){ + for (let [subDependency] of Object.entries(json.dependencies)) { + await this.unlinkSubDependency(dependency, subDependency) } - } + } + } catch (e){} - public async listPlugins() { - const plugins = this.livePluginManager.list() - if (plugins && plugins.length > 0 && this.loadedPlugins.length == 0) { - this.loadedPlugins = plugins - // Check already installed plugins - for (let plugin of plugins) { - await this.checkLinkedDependencies(plugin) - } - } - return plugins - } + console.debug("Unlinking sub dependency",dependency) + this.dependenciesMap.delete(dependency) + } - public async uninstallPlugin(pluginName: string) { - const installedPlugin = this.livePluginManager.getInfo(pluginName) - if (installedPlugin) { - console.debug(`Uninstalling plugin ${pluginName}`) - await this.removeSymlink(installedPlugin) - await this.livePluginManager.uninstall(pluginName) - await this.removeSubDependencies(installedPlugin) - } - } - private async removeSubDependencies(plugin: IPluginInfo) { - const pluginDependencies = Object.keys(plugin.dependencies) - console.debug("Removing sub dependencies",pluginDependencies) - for (let dependency of pluginDependencies) { - await this.removeSubDependency(plugin.name, dependency) - } + private async addSubDependencies(plugin: IPluginInfo) { + const pluginDependencies = Object.keys(plugin.dependencies) + for (let dependency of pluginDependencies) { + await this.addSubDependency(plugin.name, dependency) } + } - private async removeSubDependency(_name: string, dependency:string) { - if (this.dependenciesMap.has(dependency)) { - console.debug(`Dependency ${dependency} is still being used by other plugins`) - return - } + private async addSubDependency(plugin: string, dependency: string) { + if (this.dependenciesMap.has(dependency)) { + // We already added the sub dependency + this.dependenciesMap.get(dependency)?.add(plugin) + } else { + + try { + this.linkDependency(dependency) // Read sub dependencies - try { - const json:IPluginInfo = JSON.parse( - readFileSync(pathToFileURL(path.join(pluginInstallPath, dependency, 'package.json'))) as unknown as string); - if(json.dependencies){ - for (let [subDependency] of Object.entries(json.dependencies)) { - await this.removeSubDependency(dependency, subDependency) - } - } - } catch (e){} - this.uninstallDependency(dependency) - } - - private uninstallDependency(dependency: string) { - try { - console.debug(`Uninstalling dependency ${dependency}`) - // Check if the dependency is already installed - accessSync(path.join(pluginInstallPath, dependency), constants.F_OK) - rmSync(path.join(pluginInstallPath, dependency), { - force: true, - recursive: true - }) - } catch (err) { - // Symlink does not exist - // So nothing to do + const json:IPluginInfo = JSON.parse( + readFileSync(path.join(pluginInstallPath, dependency, 'package.json'), 'utf-8')); + if(json.dependencies){ + Object.keys(json.dependencies).forEach((subDependency: string) => { + this.addSubDependency(dependency, subDependency) + }) } + this.dependenciesMap.set(dependency, new Set([plugin])) + } catch (err) { + console.error(`Error reading package.json ${err} for ${path.join(pluginInstallPath, dependency, 'package.json')}`) + } } + } - private async removeSymlink(plugin: IPluginInfo) { - try { - accessSync(path.join(node_modules, plugin.name), constants.F_OK) - await this.unlinkSubDependencies(plugin) - // Remove the plugin itself - this.unlinkDependency(plugin.name) - } catch (err) { - console.error(`Symlink for ${plugin.name} does not exist`) - // Symlink does not exist - // So nothing to do - } - } - - private async unlinkSubDependencies(plugin: IPluginInfo) { - const pluginDependencies = Object.keys(plugin.dependencies) - for (let dependency of pluginDependencies) { - this.dependenciesMap.get(dependency)?.delete(plugin.name) - await this.unlinkSubDependency(plugin.name, dependency) - } - } - - private async unlinkSubDependency(plugin: string, dependency: string) { - if (this.dependenciesMap.has(dependency)) { - this.dependenciesMap.get(dependency)?.delete(plugin) - if (this.dependenciesMap.get(dependency)!.size > 0) { - // We have other dependants so do not uninstall - return - } - } - this.unlinkDependency(dependency) - // Read sub dependencies - try { - const json:IPluginInfo = JSON.parse( - readFileSync(pathToFileURL(path.join(pluginInstallPath, dependency, 'package.json'))) as unknown as string); - if(json.dependencies){ - for (let [subDependency] of Object.entries(json.dependencies)) { - await this.unlinkSubDependency(dependency, subDependency) - } - } - } catch (e){} - - console.debug("Unlinking sub dependency",dependency) - this.dependenciesMap.delete(dependency) - } - - - private async addSubDependencies(plugin: IPluginInfo) { - const pluginDependencies = Object.keys(plugin.dependencies) - for (let dependency of pluginDependencies) { - await this.addSubDependency(plugin.name, dependency) - } - } - - private async addSubDependency(plugin: string, dependency: string) { - if (this.dependenciesMap.has(dependency)) { - // We already added the sub dependency - this.dependenciesMap.get(dependency)?.add(plugin) + private linkDependency(dependency: string) { + try { + // Check if the dependency is already installed + accessSync(path.join(node_modules, dependency), constants.F_OK) + } catch (err) { + try { + if(dependency.startsWith("@")){ + const newDependency = dependency.split("@")[0] + symlinkSync(path.join(pluginInstallPath, dependency), path.join(node_modules, newDependency), 'dir') } else { - - try { - this.linkDependency(dependency) - // Read sub dependencies - const json:IPluginInfo = JSON.parse( - readFileSync(pathToFileURL(path.join(pluginInstallPath, dependency, 'package.json'))) as unknown as string); - if(json.dependencies){ - Object.keys(json.dependencies).forEach((subDependency: string) => { - this.addSubDependency(dependency, subDependency) - }) - } - } catch (err) { - console.error(`Error reading package.json ${err} for ${pathToFileURL(path.join(pluginInstallPath, dependency, 'package.json')).toString()}`) - } - this.dependenciesMap.set(dependency, new Set([plugin])) + symlinkSync(path.join(pluginInstallPath, dependency), path.join(node_modules, dependency), 'dir') } + } catch (e) { + // Nothing to do. We're all set + } } + } - private linkDependency(dependency: string) { - try { - // Check if the dependency is already installed - accessSync(path.join(node_modules, dependency), constants.F_OK) - } catch (err) { - try { - if(dependency.startsWith("@")){ - const newDependency = dependency.split("@")[0] - symlinkSync(path.join(pluginInstallPath, dependency), path.join(node_modules, newDependency), 'dir') - } else { - symlinkSync(path.join(pluginInstallPath, dependency), path.join(node_modules, dependency), 'dir') - } - } catch (e) { - // Nothing to do. We're all set - } - } - } - - private unlinkDependency(dependency: string) { - try { - // Check if the dependency is already installed - accessSync(path.join(node_modules, dependency), constants.F_OK) - unlinkSync(path.join(node_modules, dependency)) - } catch (err) { - // Symlink does not exist - // So nothing to do - } + private unlinkDependency(dependency: string) { + try { + // Check if the dependency is already installed + accessSync(path.join(node_modules, dependency), constants.F_OK) + unlinkSync(path.join(node_modules, dependency)) + } catch (err) { + // Symlink does not exist + // So nothing to do } + } - private async checkLinkedDependencies(plugin: IPluginInfo) { - // Check if the plugin really exists at source - try { - accessSync(path.join(pluginInstallPath, plugin.name), constants.F_OK) - // Skip if the plugin is already linked - } catch (err) { - // The plugin is not installed - console.debug(`Plugin ${plugin.name} is not installed`) - } - await this.addSubDependencies(plugin) - this.dependenciesMap.set(plugin.name, new Set()) + private async checkLinkedDependencies(plugin: IPluginInfo) { + // Check if the plugin really exists at source + try { + accessSync(path.join(pluginInstallPath, plugin.name), constants.F_OK) + // Skip if the plugin is already linked + } catch (err) { + // The plugin is not installed + console.debug(`Plugin ${plugin.name} is not installed`) } + await this.addSubDependencies(plugin) + this.dependenciesMap.set(plugin.name, new Set()) + } } diff --git a/src/static/js/pluginfw/installer.ts b/src/static/js/pluginfw/installer.ts index a85bc77f1..73f4195d5 100644 --- a/src/static/js/pluginfw/installer.ts +++ b/src/static/js/pluginfw/installer.ts @@ -124,7 +124,11 @@ export const checkForMigration = async () => { for (const plugin of installedPlugins.plugins) { if (plugin.name.startsWith(plugins.prefix) && plugin.name !== 'ep_etherpad-lite') { - await linkInstaller.installPlugin(plugin.name, plugin.version); + try { + await linkInstaller.installPlugin(plugin.name, plugin.version); + } catch (e) { + logger.error(`Error installing plugin ${plugin.name} with version ${plugin.version}: ${e}`); + } } } }; @@ -174,7 +178,18 @@ export const getAvailablePlugins = async (maxCacheAge: number | false) => { } const pluginsLoaded: AxiosResponse> = await axios.get(`${settings.updateServer}/plugins.json`, {headers}) - availablePlugins = pluginsLoaded.data; + const data = pluginsLoaded.data; + // Normalize: the registry may use numeric keys instead of plugin names + const normalized: MapArrayType = {}; + for (const key in data) { + const entry = data[key]; + if (entry && entry.name) { + normalized[entry.name] = entry; + } else { + normalized[key] = entry; + } + } + availablePlugins = normalized; cacheTimestamp = nowTimestamp; return availablePlugins; }; diff --git a/src/static/js/timeslider.ts b/src/static/js/timeslider.ts index befc4f8b7..d0e45973f 100644 --- a/src/static/js/timeslider.ts +++ b/src/static/js/timeslider.ts @@ -33,6 +33,39 @@ import padutils from './pad_utils' const socketio = require('./socketio'); import html10n from '../js/vendors/html10n' let token, padId, exportLinks, socket, changesetLoader, BroadcastSlider; +let cp = ''; +const playbackSpeedCookie = 'timesliderPlaybackSpeed'; + +const getPrefsCookieName = () => `${cp}${window.location.protocol === 'https:' ? 'prefs' : 'prefsHttp'}`; + +const readPadPrefs = () => { + try { + let json = Cookies.get(getPrefsCookieName()); + if (json == null) { + const unprefixed = window.location.protocol === 'https:' ? 'prefs' : 'prefsHttp'; + if (unprefixed !== getPrefsCookieName()) json = Cookies.get(unprefixed); + } + return json == null ? {} : JSON.parse(json); + } catch (err) { + return {}; + } +}; + +const writePadPrefs = (prefs) => { + Cookies.set(getPrefsCookieName(), JSON.stringify(prefs), {expires: 365 * 100}); +}; + +const setPadPref = (prefName, value) => { + const prefs = readPadPrefs(); + prefs[prefName] = value; + writePadPrefs(prefs); +}; + +const applyShowLineNumbers = (showLineNumbers) => { + padutils.setCheckbox($('#options-linenoscheck'), showLineNumbers); + $('body').toggleClass('line-numbers-hidden', !showLineNumbers); + window.requestAnimationFrame(() => $(window).trigger('resize')); +}; const init = () => { padutils.setupGlobalExceptionHandler(); @@ -48,10 +81,11 @@ const init = () => { document.title = `${padId.replace(/_+/g, ' ')} | ${document.title}`; // ensure we have a token - token = Cookies.get('token'); + cp = (window as any).clientVars?.cookiePrefix || ''; + token = Cookies.get(`${cp}token`) || Cookies.get('token'); if (token == null) { token = `t.${randomString()}`; - Cookies.set('token', token, {expires: 60}); + Cookies.set(`${cp}token`, token, {expires: 60}); } socket = socketio.connect(exports.baseURL, '/', {query: {padId}}); @@ -101,7 +135,7 @@ const sendSocketMsg = (type, data) => { data, padId, token, - sessionID: Cookies.get('sessionID'), + sessionID: Cookies.get(`${cp}sessionID`) || Cookies.get('sessionID'), }); }; @@ -110,6 +144,7 @@ const fireWhenAllScriptsAreLoaded = []; const handleClientVars = (message) => { // save the client Vars window.clientVars = message.data; + cp = (window as any).clientVars?.cookiePrefix || ''; if (window.clientVars.sessionRefreshInterval) { const ping = @@ -165,11 +200,26 @@ const handleClientVars = (message) => { $('#playpause_button_icon').attr('title', html10n.get('timeslider.playPause')); $('#leftstep').attr('title', html10n.get('timeslider.backRevision')); $('#rightstep').attr('title', html10n.get('timeslider.forwardRevision')); + padutils.bindCheckboxChange($('#options-linenoscheck'), () => { + const showLineNumbers = padutils.getCheckbox('#options-linenoscheck'); + setPadPref('showLineNumbers', showLineNumbers); + applyShowLineNumbers(showLineNumbers); + }); + applyShowLineNumbers(readPadPrefs().showLineNumbers !== false); // font family change $('#viewfontmenu').on('change', function () { $('#innerdocbody').css('font-family', $(this).val() || ''); }); + + const savedPlaybackSpeed = Cookies.get(`${cp}${playbackSpeedCookie}`) || '100'; + $('#playbackspeed').val(savedPlaybackSpeed); + BroadcastSlider.setPlaybackSpeed(savedPlaybackSpeed); + $('#playbackspeed').on('change', function () { + const speed = String($(this).val() || '100'); + Cookies.set(`${cp}${playbackSpeedCookie}`, speed); + BroadcastSlider.setPlaybackSpeed(speed); + }); }; exports.baseURL = ''; diff --git a/src/static/js/types/SocketIOMessage.ts b/src/static/js/types/SocketIOMessage.ts index f2b8cfc14..08be6a03e 100644 --- a/src/static/js/types/SocketIOMessage.ts +++ b/src/static/js/types/SocketIOMessage.ts @@ -73,20 +73,22 @@ export type ClientVarPayload = { chatHead: number, readonly: boolean, serverTimestamp: number, - initialOptions: MapArrayType, + initialOptions: PadOption, userId: string, + canEditPadSettings?: boolean, + enablePadWideSettings?: boolean, mode: string, randomVersionString: string, skinName: string skinVariants: string, exportAvailable: string + docxExport: boolean savedRevisions: PadRevision[], initialRevisionList: number[], padShortcutEnabled: MapArrayType, initialTitle: string, opts: {} numConnectedUsers: number - abiwordAvailable: string sofficeAvailable: string plugins: { plugins: MapArrayType @@ -184,6 +186,7 @@ export type ClientReadyMessage = { sessionID: string, token: string, userInfo: UserInfo, + padSettingsDefaults?: PadOption, reconnect?: boolean client_rev?: number } @@ -249,7 +252,7 @@ export type PadOption = { "alwaysShowChat"?: boolean, "chatAndUsers"?: boolean, "lang"?: null|string, - view? : MapArrayType + view? : MapArrayType } @@ -322,4 +325,3 @@ export type SocketClientReadyMessage = { reconnect?: boolean client_rev?: number } - diff --git a/src/static/js/undomodule.ts b/src/static/js/undomodule.ts index c3bdd2fab..542fb7157 100644 --- a/src/static/js/undomodule.ts +++ b/src/static/js/undomodule.ts @@ -212,13 +212,7 @@ const undoModule = (() => { } } if (!merged) { - /* - * Push the event on the undo stack only if it exists, and if it's - * not a "clearauthorship". This disallows undoing the removal of the - * authorship colors, but is a necessary stopgap measure against - * https://github.com/ether/etherpad-lite/issues/2802 - */ - if (event && (event.eventType !== 'clearauthorship')) { + if (event) { stack.pushEvent(event); } } diff --git a/src/static/js/vendors/html10n.ts b/src/static/js/vendors/html10n.ts index 156ab75f6..3f72de4c2 100644 --- a/src/static/js/vendors/html10n.ts +++ b/src/static/js/vendors/html10n.ts @@ -995,8 +995,7 @@ export default html10n // @ts-ignore window.html10n = html10n -// gettext-like shortcut -if (window._ === undefined){ - // @ts-ignore - window._ = html10n.get; -} +// gettext-like shortcut — always set this so plugins can use window._() for localization. +// Internal code uses underscore via require(), not window._, so this is safe. +// @ts-ignore +window._ = html10n.get; diff --git a/src/static/js/welcome.ts b/src/static/js/welcome.ts index dacaed7bf..f4e87427d 100644 --- a/src/static/js/welcome.ts +++ b/src/static/js/welcome.ts @@ -9,6 +9,8 @@ function getCookie(name: string) { } +const cp = (window as any).clientVars?.cookiePrefix || ''; + function handleTransferOfSession() { const transferNowButton = document.querySelector('[data-l10n-id="index.transferSessionNow"]')! as HTMLButtonElement; @@ -25,8 +27,8 @@ function handleTransferOfSession() { "Content-Type": "application/json" }, body: JSON.stringify({ - prefsHttp: getCookie('prefsHttp'), - token: getCookie('token'), + prefsHttp: getCookie(`${cp}prefsHttp`) || getCookie('prefsHttp'), + token: getCookie(`${cp}token`) || getCookie('token'), }) }) diff --git a/src/static/skins/colibris/src/components/buttons.css b/src/static/skins/colibris/src/components/buttons.css index c9c3e0c2a..aea8f1e56 100644 --- a/src/static/skins/colibris/src/components/buttons.css +++ b/src/static/skins/colibris/src/components/buttons.css @@ -55,3 +55,20 @@ button, .btn box-shadow: none; cursor: not-allowed; } + +.btn-danger { + background: #d1242f; + color: #fff; +} + +.btn-danger:hover { + background: #b71c26; + box-shadow: inset 0 1px 0 rgba(255,255,255,0.08), 0 1px 2px rgba(0,0,0,0.12); + transform: translateY(-1px); +} + +.btn-danger:disabled { + background: #aaa; + color: #fff; + cursor: not-allowed; +} diff --git a/src/static/skins/colibris/src/components/import-export.css b/src/static/skins/colibris/src/components/import-export.css index d8425d895..0465e20b5 100644 --- a/src/static/skins/colibris/src/components/import-export.css +++ b/src/static/skins/colibris/src/components/import-export.css @@ -1,15 +1,3 @@ -#importmessageabiword { - font-style: italic; - color: #64d29b; - color: var(--primary-color); -} -#importmessageabiword > a { - font-weight: bold; - text-decoration: underline; - color: #64d29b; - color: var(--primary-color); -} - #importmessagefail { margin-top: 10px; } diff --git a/src/static/skins/colibris/src/components/popup.css b/src/static/skins/colibris/src/components/popup.css index db6647431..381c10d87 100644 --- a/src/static/skins/colibris/src/components/popup.css +++ b/src/static/skins/colibris/src/components/popup.css @@ -26,6 +26,20 @@ color: var(--text-color); } +.settings-sections { + display: grid; + gap: 24px; +} + +.settings-section { + margin-bottom: 20px; + min-width: 0; +} + +#settings .settings-section > h2 { + margin-top: 0; +} + .popup:not(.comment-modal) p { margin: 10px 0; } @@ -41,6 +55,21 @@ min-width: 180px; } +.settings-notice { + color: var(--text-color); + font-size: 0.95rem; + line-height: 1.4; + border-left: 3px solid var(--primary-color); + padding-left: 10px; +} + +@media (min-width: 1100px) { + .settings-sections.has-pad-settings { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + align-items: start; + } +} + @media (prefers-reduced-motion) { .popup>.popup-content { transform: scale(1); @@ -83,45 +112,5 @@ } #delete-pad { - background-color: #ff7b72; -} - -#theme-switcher div { - position: relative; - width: 30px; - background-color: white; - height: 10px; - border-radius: 5px; - align-self: center; -} - -#theme-switcher { - display: flex; margin-top: 20px; - flex-direction: row; -} - -#theme-switcher div span { - width: 15px; - display: block; - height: 15px; - border-radius: 20px; - position: absolute; - top: -2px; - background-color: white; - transition: background-color 0.25s; -} - -html.super-light-editor #theme-switcher div { - background-color: #ccc; -} - -html.super-light-editor #theme-switcher div span { - left: 0; - background-color: var(--primary-color);; -} - -html.super-dark-editor #theme-switcher div span { - right: 0; - background-color: var(--primary-color);; } diff --git a/src/static/skins/colibris/src/plugins/brightcolorpicker.css b/src/static/skins/colibris/src/plugins/brightcolorpicker.css index 8db2880c1..f267888f7 100644 --- a/src/static/skins/colibris/src/plugins/brightcolorpicker.css +++ b/src/static/skins/colibris/src/plugins/brightcolorpicker.css @@ -1,14 +1,14 @@ #colorpicker a.brightColorPicker-cancelButton { - background: none; - padding: 0; - padding-top: 10px; - font-weight: bold; - border: none; + background: none; + padding: 0; + padding-top: 10px; + font-weight: bold; + border: none; } .brightColorPicker-colorPanel { - background-color: white !important; - box-shadow: 0 0 0 1px rgba(99, 114, 130, 0.16), 0 8px 16px rgba(27, 39, 51, 0.08) !important; - border-radius: 3px !important; - padding: 15px !important; + background-color: white !important; + box-shadow: 0 0 0 1px rgba(99, 114, 130, 0.16), 0 8px 16px rgba(27, 39, 51, 0.08) !important; + border-radius: 3px !important; + padding: 15px !important; } \ No newline at end of file diff --git a/src/static/skins/colibris/timeslider.css b/src/static/skins/colibris/timeslider.css index 263e0e592..dc52de73c 100644 --- a/src/static/skins/colibris/timeslider.css +++ b/src/static/skins/colibris/timeslider.css @@ -82,6 +82,16 @@ font-size: .9em; } +.timeslider #outerdocbody > #sidediv { + padding-top: 30px; + padding-bottom: 30px; +} + +.timeslider #outerdocbody > #innerdocbody { + padding-top: 30px; + padding-bottom: 30px; +} + @media (max-width: 800px) { #slider-btn-container { @@ -95,4 +105,4 @@ #slider-btn-container #playpause_button_icon:before { font-size: 18px; } -} \ No newline at end of file +} diff --git a/src/templates/indexBootstrap.js b/src/templates/indexBootstrap.js index 6836a460a..d8c2f8b98 100644 --- a/src/templates/indexBootstrap.js +++ b/src/templates/indexBootstrap.js @@ -1,5 +1,6 @@ (async () => { + window.clientVars = { cookiePrefix: <%-JSON.stringify(settings.cookie.prefix)%> }; window.$ = window.jQuery = require('ep_etherpad-lite/static/js/rjquery').jQuery; require('ep_etherpad-lite/static/js/l10n') require('ep_etherpad-lite/static/js/index') diff --git a/src/templates/javascript.html b/src/templates/javascript.html index d93692b09..f3b5e038b 100644 --- a/src/templates/javascript.html +++ b/src/templates/javascript.html @@ -1,51 +1,51 @@ - - JavaScript license information - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
jquery-3.0.1.min.jsExpatjquery.js
html10n.jsExpathtml10n.js
l10n.jsApache-2.0-onlyl10n.js
socket.io.jsExpatsocket.io.js
require-kernel.jsExpatrequire-kernel.js
Apache-2.0-only
Expat
Apache-2.0-only
Expat
- + + JavaScript license information + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
jquery-3.0.1.min.jsExpatjquery.js
html10n.jsExpathtml10n.js
l10n.jsApache-2.0-onlyl10n.js
socket.io.jsExpatsocket.io.js
require-kernel.jsExpatrequire-kernel.js
Apache-2.0-only
Expat
Apache-2.0-only
Expat
+ diff --git a/src/templates/pad.html b/src/templates/pad.html index eb93196e2..5e593f6d7 100644 --- a/src/templates/pad.html +++ b/src/templates/pad.html @@ -84,7 +84,7 @@ -
+
<% e.begin_block("permissionDenied"); %> @@ -115,68 +115,133 @@