diff --git a/static/js/postAceInit.js b/static/js/postAceInit.js index 62e896b..f8c3640 100644 --- a/static/js/postAceInit.js +++ b/static/js/postAceInit.js @@ -41,9 +41,14 @@ exports.postAceInit = () => { } const toc = getToc(); if (!toc) return; + toc.bindShareUrlSync(); const state = tocToggle.init({ - onChange: (enabled) => { enabled ? toc.enable() : toc.disable(); }, + onChange: (enabled) => { + enabled ? toc.enable() : toc.disable(); + toc.syncLocationUrl(enabled); + toc.syncShareUrls(enabled); + }, }); // ?toc=true / ?toc=false URL parameter still overrides the resolved @@ -52,6 +57,7 @@ exports.postAceInit = () => { if (tocParam === true || tocParam === false) { tocParam ? toc.enable() : toc.disable(); $('#options-toc').prop('checked', tocParam); + toc.syncShareUrls(tocParam); } else { // No URL override — make sure the editor matches the helper's effective // value. (init() already fired onChange once, so this is belt-and-braces.) diff --git a/static/js/toc.js b/static/js/toc.js index c453ede..75255b9 100644 --- a/static/js/toc.js +++ b/static/js/toc.js @@ -57,6 +57,29 @@ const getOutlineEntries = (toc) => { }); }; +const ETHERPAD_FIELD_REWRITE_DELAY_MS = 0; +const getWindowHref = () => (typeof window !== 'undefined' ? window.location.href : undefined); + +const setBooleanUrlParam = (rawUrl, name, enabled, contextUrl = getWindowHref()) => { + let url; + try { + url = contextUrl == null ? new URL(rawUrl) : new URL(rawUrl, contextUrl); + } catch (err) { + return rawUrl; + } + url.searchParams.set(name, String(enabled)); + return url.toString(); +}; + +const setEmbedCodeUrlParam = ( + embedCode, name, enabled, contextUrl = getWindowHref() +) => embedCode.replace( + /\bsrc=(['"])([^'"]+)\1/g, + (match, quote, rawUrl) => { + const urlWithParam = setBooleanUrlParam(rawUrl, name, enabled, contextUrl); + return `src=${quote}${urlWithParam}${quote}`; + }); + if (typeof $ !== 'undefined') { $('#tocButton').click(() => { $('#toc').toggle(); @@ -81,6 +104,48 @@ const tableOfContents = globalThis.tableOfContents = { $('#toc').hide(); }, + syncShareUrls: (enabled) => { + const $linkInput = $('#linkinput'); + if ($linkInput.length > 0) { + const linkValue = $linkInput.val(); + if (typeof linkValue === 'string' && linkValue !== '') { + $linkInput.val(setBooleanUrlParam(linkValue, 'toc', enabled)); + } + } + + const $embedInput = $('#embedinput'); + if ($embedInput.length > 0) { + const embedValue = $embedInput.val(); + if (typeof embedValue === 'string' && embedValue.includes(' { + if (!window.history || typeof window.history.replaceState !== 'function') return; + const nextUrl = setBooleanUrlParam(window.location.href, 'toc', enabled); + window.history.replaceState(window.history.state, document.title, nextUrl); + }, + + bindShareUrlSync: () => { + if (tableOfContents._shareUrlSyncBound) return; + tableOfContents._shareUrlSyncBound = true; + + $(document).on('focusin.ep_table_of_contents', '#linkinput, #embedinput', () => { + tableOfContents.syncShareUrls($('#options-toc').is(':checked')); + }); + + $(document).on('change.ep_table_of_contents', '#readonlyinput', () => { + // Etherpad rewrites the share/embed fields after the readonly checkbox + // change handler runs, so wait a tick before patching the generated URLs. + setTimeout( + () => tableOfContents.syncShareUrls($('#options-toc').is(':checked')), + ETHERPAD_FIELD_REWRITE_DELAY_MS, + ); + }); + }, + // Find Tags findTags: () => { const toc = []; @@ -213,4 +278,6 @@ const tableOfContents = globalThis.tableOfContents = { return true; }, + _shareUrlSyncBound: false, + }; diff --git a/tests/toc-numbering.test.js b/tests/toc-numbering.test.js index e7b62ae..5a8ba0d 100644 --- a/tests/toc-numbering.test.js +++ b/tests/toc-numbering.test.js @@ -12,18 +12,99 @@ const loadTocHelpers = () => { const sandbox = { console, URLSearchParams, + URL, globalThis: {}, + window: { + location: {href: 'https://example.com/p/test-pad?showChat=false#section'}, + }, $: () => ({ - click() {}, + click: () => {}, }), }; sandbox.globalThis = sandbox; - vm.runInNewContext(`${source} -globalThis.__tocTestExports = {getHeadingLevel, getOutlineEntries};`, sandbox, {filename: tocPath}); + vm.runInNewContext( + `${source} +globalThis.__tocTestExports = { + getHeadingLevel, + getOutlineEntries, + setBooleanUrlParam, + setEmbedCodeUrlParam, +};`, + sandbox, + {filename: tocPath}, + ); return sandbox.__tocTestExports; }; -const {getOutlineEntries} = loadTocHelpers(); +const {getOutlineEntries, setBooleanUrlParam, setEmbedCodeUrlParam} = loadTocHelpers(); + +const loadTocRuntime = () => { + const tocPath = path.join(__dirname, '..', 'static', 'js', 'toc.js'); + const source = fs.readFileSync(tocPath, 'utf8'); + const fields = { + '#linkinput': 'https://example.com/p/test-pad?showChat=false', + '#embedinput': '', + '#options-toc': true, + }; + const sandbox = { + console, + URLSearchParams, + URL, + globalThis: {}, + document: {title: 'Test Pad'}, + historyCalls: [], + window: { + location: {href: 'https://example.com/p/test-pad?showChat=false'}, + history: { + state: {padId: 'test-pad'}, + replaceState(state, title, url) { + sandbox.historyCalls.push({state, title, url}); + sandbox.window.location.href = url; + }, + }, + }, + }; + sandbox.$ = (selector) => { + if (selector === '#linkinput' || selector === '#embedinput') { + return { + length: 1, + val(value) { + if (value === undefined) return fields[selector]; + fields[selector] = value; + return this; + }, + }; + } + if (selector === '#options-toc') { + return { + is(query) { + assert.equal(query, ':checked'); + return fields[selector]; + }, + }; + } + if (selector === '#tocButton') { + return {click: () => {}}; + } + if (selector === sandbox.document) { + return {on: () => {}}; + } + throw new Error(`Unexpected selector: ${selector}`); + }; + sandbox.globalThis = sandbox; + vm.runInNewContext( + `${source} +globalThis.__tocRuntimeExports = {tableOfContents};`, + sandbox, + {filename: tocPath}, + ); + return { + tableOfContents: sandbox.__tocRuntimeExports.tableOfContents, + fields, + historyCalls: sandbox.historyCalls, + window: sandbox.window, + }; +}; const makeEntries = (tags) => tags.map((tag, index) => ({ tag, @@ -88,3 +169,52 @@ test('keeps numbering stable across many sibling headings', () => { assert.deepEqual(summary[99], {depth: 1, numbering: '100'}); assert.deepEqual(summary[199], {depth: 1, numbering: '200'}); }); + +test('adds the toc state to shared pad links without dropping existing params', () => { + assert.equal( + setBooleanUrlParam('https://example.com/p/test-pad?showChat=false#section', 'toc', true), + 'https://example.com/p/test-pad?showChat=false&toc=true#section', + ); + assert.equal( + setBooleanUrlParam('https://example.com/p/test-pad?showChat=false#section', 'toc', false), + 'https://example.com/p/test-pad?showChat=false&toc=false#section', + ); +}); + +test('adds the toc state to embed iframe src urls', () => { + const embedCode = + ''; + assert.equal( + setEmbedCodeUrlParam(embedCode, 'toc', true), + '', + ); + assert.equal( + setEmbedCodeUrlParam(embedCode, 'toc', false), + '', + ); +}); + +test('syncShareUrls updates both share fields for the active toc state', () => { + const {tableOfContents, fields} = loadTocRuntime(); + + tableOfContents.syncShareUrls(true); + assert.equal(fields['#linkinput'], 'https://example.com/p/test-pad?showChat=false&toc=true'); + assert.match(fields['#embedinput'], /src="https:\/\/example\.com\/p\/test-pad\?showControls=true&showChat=true&toc=true"/); + + tableOfContents.syncShareUrls(false); + assert.equal(fields['#linkinput'], 'https://example.com/p/test-pad?showChat=false&toc=false'); + assert.match(fields['#embedinput'], /src="https:\/\/example\.com\/p\/test-pad\?showControls=true&showChat=true&toc=false"/); +}); + +test('syncLocationUrl keeps the current pad url in sync with toc state', () => { + const {tableOfContents, historyCalls, window} = loadTocRuntime(); + + tableOfContents.syncLocationUrl(true); + tableOfContents.syncLocationUrl(false); + + assert.deepEqual(historyCalls.map(({url}) => url), [ + 'https://example.com/p/test-pad?showChat=false&toc=true', + 'https://example.com/p/test-pad?showChat=false&toc=false', + ]); + assert.equal(window.location.href, 'https://example.com/p/test-pad?showChat=false&toc=false'); +});