Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/static/js/broadcast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
71 changes: 39 additions & 32 deletions src/static/js/pad_mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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) {}
});
Expand Down Expand Up @@ -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);
Expand All @@ -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 = <T extends HTMLElement>(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<HTMLInputElement>(['options-colorscheck', 'padsettings-options-colorscheck'],
(cb) => inner.BroadcastSlider?.setShowAuthorColors?.(cb.checked));
bridgeView<HTMLSelectElement>(['viewfontmenu', 'padsettings-viewfontmenu'],
(sel) => inner.BroadcastSlider?.setPadFontFamily?.(sel.value));
bridgeView<HTMLInputElement>(['options-linenoscheck', 'padsettings-options-linenoscheck'],
(cb) => inner.BroadcastSlider?.setShowLineNumbers?.(cb.checked));
}

private setInnerRevision(rev: number): void {
Expand Down
37 changes: 35 additions & 2 deletions src/static/js/timeslider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 5 additions & 1 deletion src/static/js/vendors/nice-select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}));
Comment on lines +168 to +172
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Double change event firing 🐞 Bug ≡ Correctness

The nice-select option click handler triggers both trigger('change') and a native
dispatchEvent('change'), so change handlers can run twice for one user action. This can
duplicate side effects such as pad.setMyViewOption()pad.applyOptionsChange() when selecting
font/language/etc., and can also double-run the new addEventListener-based pad→timeslider bridge
handlers.
Agent Prompt
### Issue description
`nice-select` currently emits **two** change notifications for a single option click:
1) a jQuery `trigger('change')`
2) a native `dispatchEvent(new Event('change'))`

Because jQuery registers its `.on('change')` handlers via a native `addEventListener` (jQuery eventHandle), the native `dispatchEvent()` will also invoke jQuery handlers. The result is that jQuery handlers run once via `trigger()` and again via the native dispatch.

### Issue Context
This was added to support native `addEventListener` handlers (e.g., the new `pad_mode.ts` settings bridge), but keeping both calls causes duplicate handler execution.

### Fix Focus Areas
- src/static/js/vendors/nice-select.ts[157-173]

### Suggested fix
Update the option click handler to emit **only one** change event. The simplest fix is:
- Keep setting the value via `$nativeSelect.val(...)`
- **Remove** `.trigger('change')`
- Keep **only** the native `dispatchEvent(new Event('change', {bubbles: true}))`

This single native event will still trigger:
- native `addEventListener('change', ...)` listeners
- jQuery `.on('change', ...)` listeners (via jQuery’s native eventHandle)
- `onchange` property handlers

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

});

// Keyboard events
Expand Down
84 changes: 84 additions & 0 deletions src/tests/frontend-new/specs/timeslider_author_colors.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading