diff --git a/src/static/js/broadcast.ts b/src/static/js/broadcast.ts index 8551f1d0cfa..3e25ad6e36f 100644 --- a/src/static/js/broadcast.ts +++ b/src/static/js/broadcast.ts @@ -565,7 +565,7 @@ const loadBroadcastJS = (socket, sendSocketMsg, fireWhenAllScriptsAreLoaded, Bro const bgcolor = typeof data.colorId === 'number' ? clientVars.colorPalette[data.colorId] : data.colorId; if (bgcolor) { - const selector = dynamicCSS.selectorStyle(`.${linestylefilter.getAuthorClassName(author)}`); + const selector = dynamicCSS.selectorStyle(`.authorColors .${linestylefilter.getAuthorClassName(author)}`); selector.backgroundColor = bgcolor; selector.color = (colorutils.luminosity(colorutils.css2triple(bgcolor)) < 0.5) ? '#ffffff' : '#000000'; // see ace2_inner.js for the other part diff --git a/src/static/js/pad_mode.ts b/src/static/js/pad_mode.ts index b4902962826..499d9832b80 100644 --- a/src/static/js/pad_mode.ts +++ b/src/static/js/pad_mode.ts @@ -62,9 +62,8 @@ class PadModeController { private usersSnapshot: string | null = null; private chatHeaderSnapshot: {parent: HTMLElement; sibling: Node | null} | null = null; private chatHeaderEl: HTMLElement | null = null; - private playbackChangeListener: ((e: Event) => void) | null = null; - private followChangeListener: ((e: Event) => void) | null = null; - // Outer history controls (#history-controls) — bridge listeners. + // Every listener we attach to an outer Settings / history control is + // tracked here so teardownBridges() can remove them all in one pass. private outerControlListeners: Array<{el: HTMLElement; type: string; fn: EventListener}> = []; constructor() { @@ -215,22 +214,22 @@ class PadModeController { this.exportSnapshot.forEach((href, anchor) => { anchor.setAttribute('href', href); }); this.exportSnapshot = null; } - if (this.playbackChangeListener) { - const sel = document.getElementById('history-playbackspeed'); - if (sel) sel.removeEventListener('change', this.playbackChangeListener); - this.playbackChangeListener = null; - } - if (this.followChangeListener) { - const cb = document.getElementById('history-options-followContents'); - if (cb) cb.removeEventListener('change', this.followChangeListener); - this.followChangeListener = null; - } - // Inner BroadcastSlider has no removeCallback API, but the whole iframe - // is destroyed on exit so any callbacks die with it. + // Every outer Settings/history control we bound is tracked in one list, + // so a single pass tears them all down. (The inner BroadcastSlider has no + // removeCallback API, but the whole iframe is destroyed on exit so any + // callbacks die with it.) this.outerControlListeners.forEach(({el, type, fn}) => el.removeEventListener(type, fn)); this.outerControlListeners = []; } + // Attach a listener to an outer control and register it for teardown on + // exit. No-ops if the element is missing so callers can stay terse. + private bindOuter(el: HTMLElement | null, type: string, fn: EventListener): void { + if (!el) return; + el.addEventListener(type, fn); + this.outerControlListeners.push({el, type, fn}); + } + private mountIframe(rev: number | null): void { const innerHash = rev == null || rev < 0 ? '' : `#${rev}`; const src = @@ -335,27 +334,21 @@ class PadModeController { const rightStep = document.getElementById('history-rightstep') as HTMLButtonElement | null; const timer = document.getElementById('history-timer') as HTMLElement | null; - const bind = (el: HTMLElement | null, type: string, fn: EventListener) => { - if (!el) return; - el.addEventListener(type, fn); - this.outerControlListeners.push({el, type, fn}); - }; - - bind(sliderInput, 'input', () => { + this.bindOuter(sliderInput, 'input', () => { if (!sliderInput) return; const target = Math.max(0, Math.floor(Number(sliderInput.value) || 0)); try { inner.BroadcastSlider?.setSliderPosition?.(target); } catch (_e) {} }); - bind(playBtn, 'click', () => { + this.bindOuter(playBtn, 'click', () => { try { inner.BroadcastSlider?.playpause?.(); } catch (_e) {} }); // Inner #leftstep / #rightstep already wire all the step logic; just // forward the click so we share the same code path. - bind(leftStep, 'click', () => { + this.bindOuter(leftStep, 'click', () => { try { (innerWin.document.getElementById('leftstep') as HTMLElement | null)?.click(); } catch (_e) {} }); - bind(rightStep, 'click', () => { + this.bindOuter(rightStep, 'click', () => { try { (innerWin.document.getElementById('rightstep') as HTMLElement | null)?.click(); } catch (_e) {} }); @@ -501,15 +494,15 @@ class PadModeController { // BroadcastSlider state from those controls so the user sees one set of // controls regardless of mode. private wireSettingsBridges(innerWin: Window): void { + const inner: any = innerWin as any; const speedSel = document.getElementById('history-playbackspeed') as HTMLSelectElement | null; const followCb = document.getElementById('history-options-followContents') as HTMLInputElement | null; - const inner: any = innerWin as any; if (speedSel) { // Initial sync: read existing inner cookie/setting if available. const innerSpeed = inner.document.getElementById('playbackspeed') as HTMLSelectElement | null; if (innerSpeed && innerSpeed.value) speedSel.value = innerSpeed.value; - this.playbackChangeListener = () => { + this.bindOuter(speedSel, 'change', () => { const v = speedSel.value || '100'; try { inner.BroadcastSlider?.setPlaybackSpeed?.(v); @@ -518,20 +511,34 @@ class PadModeController { innerSpeed.dispatchEvent(new Event('change')); } } catch (_e) {} - }; - speedSel.addEventListener('change', this.playbackChangeListener); + }); } if (followCb) { const innerFollow = inner.document.getElementById('options-followContents') as HTMLInputElement | null; if (innerFollow) followCb.checked = !!innerFollow.checked; - this.followChangeListener = () => { + this.bindOuter(followCb, 'change', () => { if (!innerFollow) return; innerFollow.checked = followCb.checked; innerFollow.dispatchEvent(new Event('change')); - }; - followCb.addEventListener('change', this.followChangeListener); + }); } + + // Authorship colours, font family and line numbers each appear in two + // places in the outer Settings UI (the legacy popup ids and the + // `#padsettings-…` pane), so bridge every id to the embedded slider's + // matching view-setting method. + const bridgeView = (ids: string[], apply: (el: T) => void) => + ids.forEach((id) => { + const el = document.getElementById(id) as T | null; + this.bindOuter(el, 'change', () => { try { apply(el!); } catch (_e) {} }); + }); + bridgeView(['options-colorscheck', 'padsettings-options-colorscheck'], + (cb) => inner.BroadcastSlider?.setShowAuthorColors?.(cb.checked)); + bridgeView(['viewfontmenu', 'padsettings-viewfontmenu'], + (sel) => inner.BroadcastSlider?.setPadFontFamily?.(sel.value)); + bridgeView(['options-linenoscheck', 'padsettings-options-linenoscheck'], + (cb) => inner.BroadcastSlider?.setShowLineNumbers?.(cb.checked)); } private setInnerRevision(rev: number): void { diff --git a/src/static/js/timeslider.ts b/src/static/js/timeslider.ts index dacc93b934b..155786e4e17 100644 --- a/src/static/js/timeslider.ts +++ b/src/static/js/timeslider.ts @@ -67,6 +67,17 @@ const applyShowLineNumbers = (showLineNumbers) => { window.requestAnimationFrame(() => $(window).trigger('resize')); }; +const applyShowAuthorColors = (showAuthorColors) => { + $('#innerdocbody').toggleClass('authorColors', showAuthorColors); + $('#sidedivinner').toggleClass('authorColors', showAuthorColors); +}; + +// Pass '' (not null) to clear the rule — jQuery 3 ignores a null css value, +// so the inline font-family would otherwise stick on reset. +const applyPadFontFamily = (fontFamily) => { + $('#innerdocbody').css('font-family', fontFamily || ''); +}; + const init = () => { padutils.setupGlobalExceptionHandler(); $(document).ready(() => { @@ -240,11 +251,33 @@ const handleClientVars = (message) => { }); applyShowLineNumbers(readPadPrefs().showLineNumbers !== false); - // font family change + // Honour the view preferences the pad editor saved to the cookie so the + // first paint matches the user's pad settings. + applyShowAuthorColors(readPadPrefs().showAuthorshipColors !== false); + const padFontFamily = readPadPrefs().padFontFamily; + if (padFontFamily) $('#viewfontmenu').val(padFontFamily); + applyPadFontFamily(padFontFamily); $('#viewfontmenu').on('change', function () { - $('#innerdocbody').css('font-family', $(this).val() || ''); + const fontFamily = $(this).val() || ''; + setPadPref('padFontFamily', fontFamily); + applyPadFontFamily(fontFamily); }); + // Entry points for the outer pad shell (#7659 in-place history mode) to push + // view settings into this iframe live when the user changes them on the pad. + BroadcastSlider.setShowAuthorColors = (showAuthorColors) => { + applyShowAuthorColors(showAuthorColors); + setPadPref('showAuthorshipColors', showAuthorColors); + }; + BroadcastSlider.setShowLineNumbers = (showLineNumbers) => { + applyShowLineNumbers(showLineNumbers); + setPadPref('showLineNumbers', showLineNumbers); + }; + BroadcastSlider.setPadFontFamily = (fontFamily) => { + applyPadFontFamily(fontFamily); + setPadPref('padFontFamily', fontFamily); + }; + const savedPlaybackSpeed = Cookies.get(`${cp}${playbackSpeedCookie}`) || '100'; $('#playbackspeed').val(savedPlaybackSpeed); BroadcastSlider.setPlaybackSpeed(savedPlaybackSpeed); diff --git a/src/static/js/vendors/nice-select.ts b/src/static/js/vendors/nice-select.ts index 4f1ff8ab16d..0d2b9a03e57 100644 --- a/src/static/js/vendors/nice-select.ts +++ b/src/static/js/vendors/nice-select.ts @@ -165,7 +165,11 @@ var text = $option.data('display') || $option.text(); $dropdown.find('.current').text(text); - $dropdown.prev('select').val($option.data('value')).trigger('change'); + const $nativeSelect = $dropdown.prev('select'); + $nativeSelect.val($option.data('value')).trigger('change'); + // Fire native event for handlers attached via addEventListener (e.g. + // the pad_mode.ts settings bridge to the embedded timeslider iframe). + $nativeSelect[0]?.dispatchEvent(new Event('change', {bubbles: true})); }); // Keyboard events diff --git a/src/tests/frontend-new/specs/timeslider_author_colors.spec.ts b/src/tests/frontend-new/specs/timeslider_author_colors.spec.ts new file mode 100644 index 00000000000..9e1fe250c87 --- /dev/null +++ b/src/tests/frontend-new/specs/timeslider_author_colors.spec.ts @@ -0,0 +1,84 @@ +import {expect, test} from "@playwright/test"; +import {clearPadContent, goToNewPad, writeToPad} from "../helper/padHelper"; + +test.describe('timeslider authorship colors', function () { + test.beforeEach(async ({context}) => { + await context.clearCookies(); + }); + + test('respects showAuthorshipColors=false cookie from pad editor', async function ({page}) { + const padId = await goToNewPad(page); + await clearPadContent(page); + await writeToPad(page, 'Hello from author one'); + + await page.context().addCookies([{ + name: 'prefsHttp', + value: encodeURIComponent(JSON.stringify({showAuthorshipColors: false})), + url: 'http://localhost:9001', + }]); + + await page.goto(`http://localhost:9001/p/${padId}/timeslider?embed=1`); + await page.waitForSelector('#timeslider-wrapper', {state: 'visible'}); + await page.waitForTimeout(500); + + await expect(page.locator('#innerdocbody')).not.toHaveClass(/authorColors/); + }); + + test('shows author colors by default (cookie unset)', async function ({page}) { + const padId = await goToNewPad(page); + await clearPadContent(page); + await writeToPad(page, 'Hello from author one'); + + await page.goto(`http://localhost:9001/p/${padId}/timeslider?embed=1`); + await page.waitForSelector('#timeslider-wrapper', {state: 'visible'}); + await page.waitForTimeout(500); + + await expect(page.locator('#innerdocbody')).toHaveClass(/authorColors/); + }); + + test('font type selector applies font-family to innerdocbody', async function ({page}) { + const padId = await goToNewPad(page); + await clearPadContent(page); + await writeToPad(page, 'Test content'); + + await page.goto(`http://localhost:9001/p/${padId}/timeslider?embed=1`); + await page.waitForSelector('#timeslider-wrapper', {state: 'visible'}); + await page.waitForTimeout(500); + + // Use evaluate() to trigger font change via jQuery, bypassing the + // nice-select UI and settings-popup open/close lifecycle. + await page.evaluate(() => { + const el = document.getElementById('viewfontmenu') as HTMLSelectElement; + if (el) { + el.value = 'RobotoMono'; + el.dispatchEvent(new Event('change', {bubbles: true})); + } + }); + await page.waitForTimeout(200); + + const fontFamily = await page.locator('#innerdocbody').evaluate( + (el) => getComputedStyle(el).fontFamily); + expect(fontFamily).toContain('RobotoMono'); + }); + + test('font-type selection persists and restores from cookie', async function ({page, context}) { + const padId = await goToNewPad(page); + await clearPadContent(page); + await writeToPad(page, 'Test content'); + + // Set font cookie before loading timeslider + await context.addCookies([{ + name: 'prefsHttp', + value: encodeURIComponent(JSON.stringify({padFontFamily: 'Alegreya'})), + url: 'http://localhost:9001', + }]); + + await page.goto(`http://localhost:9001/p/${padId}/timeslider?embed=1`); + await page.waitForSelector('#timeslider-wrapper', {state: 'visible'}); + await page.waitForTimeout(500); + + const fontFamily = await page.locator('#innerdocbody').evaluate( + (el) => getComputedStyle(el).fontFamily); + expect(fontFamily).toContain('Alegreya'); + }); +});