timeslider: respect author colors, font, line numbers from pad editor

- nice-select.ts: dispatch native change event after jQuery trigger so
  addEventListener-based bridges (pad_mode.ts) fire (jQuery 3.7.1
  trigger() does not dispatch native DOM events)
- timeslider.ts: fix font-family reset (jQuery 3 ignores null css value);
  add showAuthorColors + showLineNumbers + padFontFamily support;
  wire cookie read/write for all three settings
- broadcast.ts: gate author color CSS on .authorColors class
- pad_mode.ts: bridge author colors, font family, line numbers checkboxes
  from pad settings into embedded timeslider iframe
- add Playwright test for showAuthorshipColors
This commit is contained in:
John McLear 2026-06-05 12:37:26 +01:00
parent 2209e37857
commit 226487b009
5 changed files with 167 additions and 4 deletions

View file

@ -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

View file

@ -64,6 +64,9 @@ class PadModeController {
private chatHeaderEl: HTMLElement | null = null;
private playbackChangeListener: ((e: Event) => void) | null = null;
private followChangeListener: ((e: Event) => void) | null = null;
private authorColorsListeners: Array<{el: HTMLElement; type: string; fn: EventListener}> = [];
private fontFamilyListeners: Array<{el: HTMLElement; type: string; fn: EventListener}> = [];
private lineNumbersListeners: Array<{el: HTMLElement; type: string; fn: EventListener}> = [];
// Outer history controls (#history-controls) — bridge listeners.
private outerControlListeners: Array<{el: HTMLElement; type: string; fn: EventListener}> = [];
@ -229,6 +232,12 @@ class PadModeController {
// is destroyed on exit so any callbacks die with it.
this.outerControlListeners.forEach(({el, type, fn}) => el.removeEventListener(type, fn));
this.outerControlListeners = [];
this.authorColorsListeners.forEach(({el, type, fn}) => el.removeEventListener(type, fn));
this.authorColorsListeners = [];
this.fontFamilyListeners.forEach(({el, type, fn}) => el.removeEventListener(type, fn));
this.fontFamilyListeners = [];
this.lineNumbersListeners.forEach(({el, type, fn}) => el.removeEventListener(type, fn));
this.lineNumbersListeners = [];
}
private mountIframe(rev: number | null): void {
@ -532,6 +541,39 @@ class PadModeController {
};
followCb.addEventListener('change', this.followChangeListener);
}
const bridgeAuthorColors = (cb: HTMLInputElement | null) => {
if (!cb) return;
const listener = () => {
try { inner.BroadcastSlider?.setShowAuthorColors?.(cb.checked); } catch (_e) {}
};
cb.addEventListener('change', listener);
this.authorColorsListeners.push({el: cb, type: 'change', fn: listener});
};
bridgeAuthorColors(document.getElementById('options-colorscheck') as HTMLInputElement | null);
bridgeAuthorColors(document.getElementById('padsettings-options-colorscheck') as HTMLInputElement | null);
const bridgePadFontFamily = (sel: HTMLSelectElement | null) => {
if (!sel) return;
const listener = () => {
try { inner.BroadcastSlider?.setPadFontFamily?.(sel.value); } catch (_e) {}
};
sel.addEventListener('change', listener);
this.fontFamilyListeners.push({el: sel, type: 'change', fn: listener});
};
bridgePadFontFamily(document.getElementById('viewfontmenu') as HTMLSelectElement | null);
bridgePadFontFamily(document.getElementById('padsettings-viewfontmenu') as HTMLSelectElement | null);
const bridgeLineNumbers = (cb: HTMLInputElement | null) => {
if (!cb) return;
const listener = () => {
try { inner.BroadcastSlider?.setShowLineNumbers?.(cb.checked); } catch (_e) {}
};
cb.addEventListener('change', listener);
this.lineNumbersListeners.push({el: cb, type: 'change', fn: listener});
};
bridgeLineNumbers(document.getElementById('options-linenoscheck') as HTMLInputElement | null);
bridgeLineNumbers(document.getElementById('padsettings-options-linenoscheck') as HTMLInputElement | null);
}
private setInnerRevision(rev: number): void {

View file

@ -67,6 +67,11 @@ const applyShowLineNumbers = (showLineNumbers) => {
window.requestAnimationFrame(() => $(window).trigger('resize'));
};
const applyShowAuthorColors = (showAuthorColors) => {
$('#innerdocbody').toggleClass('authorColors', showAuthorColors);
$('#sidedivinner').toggleClass('authorColors', showAuthorColors);
};
const init = () => {
padutils.setupGlobalExceptionHandler();
$(document).ready(() => {
@ -193,6 +198,14 @@ const handleClientVars = (message) => {
// load all script that doesn't work without the clientVars
BroadcastSlider = require('./broadcast_slider')
.loadBroadcastSliderJS(fireWhenAllScriptsAreLoaded);
BroadcastSlider.setShowAuthorColors = (showAuthorColors) => {
applyShowAuthorColors(showAuthorColors);
setPadPref('showAuthorshipColors', showAuthorColors);
};
BroadcastSlider.setShowLineNumbers = (showLineNumbers) => {
applyShowLineNumbers(showLineNumbers);
setPadPref('showLineNumbers', showLineNumbers);
};
// Exposed on window so the outer pad shell (issue #7659 in-place history
// mode) can subscribe to slider movement without postMessage round-trips.
(window as any).BroadcastSlider = BroadcastSlider;
@ -240,11 +253,31 @@ const handleClientVars = (message) => {
});
applyShowLineNumbers(readPadPrefs().showLineNumbers !== false);
// font family change
// Read authorship colors preference from cookie (set by pad editor)
applyShowAuthorColors(readPadPrefs().showAuthorshipColors !== false);
// font family
const applyPadFontFamily = (fontFamily) => {
if (fontFamily) {
$('#innerdocbody').css('font-family', fontFamily);
} else {
$('#innerdocbody').css('font-family', '');
}
};
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);
});
BroadcastSlider.setPadFontFamily = (fontFamily) => {
applyPadFontFamily(fontFamily);
setPadPref('padFontFamily', fontFamily);
};
const savedPlaybackSpeed = Cookies.get(`${cp}${playbackSpeedCookie}`) || '100';
$('#playbackspeed').val(savedPlaybackSpeed);
BroadcastSlider.setPlaybackSpeed(savedPlaybackSpeed);

View file

@ -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

View file

@ -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');
});
});