From f832fd7c2518191d6e1a7bf12e0c47fa5da838f1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 10:13:26 +0000 Subject: [PATCH 1/4] Initial plan From 23ad5406c1883409f15bbb1c469c774f5599ccfd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 10:22:14 +0000 Subject: [PATCH 2/4] fix: preserve toc state in shared urls Agent-Logs-Url: https://github.com/ether/ep_table_of_contents/sessions/5f0f5716-b682-49ff-a18e-534e67b3de76 Co-authored-by: JohnMcLear <220864+JohnMcLear@users.noreply.github.com> --- static/js/postAceInit.js | 8 +++++- static/js/toc.js | 55 ++++++++++++++++++++++++++++++++++++- tests/toc-numbering.test.js | 45 +++++++++++++++++++++++++++--- 3 files changed, 102 insertions(+), 6 deletions(-) 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..d85b625 100644 --- a/static/js/toc.js +++ b/static/js/toc.js @@ -57,6 +57,20 @@ const getOutlineEntries = (toc) => { }); }; +const setBooleanUrlParam = (rawUrl, name, enabled, baseUrl = window.location.href) => { + const url = new URL(rawUrl, baseUrl); + url.searchParams.set(name, String(enabled)); + return url.toString(); +}; + +const setEmbedCodeUrlParam = (embedCode, name, enabled, baseUrl = window.location.href) => { + return embedCode.replace( + /\bsrc=(['"])([^'"]+)\1/, + (match, quote, rawUrl) => + `src=${quote}${setBooleanUrlParam(rawUrl, name, enabled, baseUrl)}${quote}`, + ); +}; + if (typeof $ !== 'undefined') { $('#tocButton').click(() => { $('#toc').toggle(); @@ -81,6 +95,43 @@ 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('src=')) { + $embedInput.val(setEmbedCodeUrlParam(embedValue, 'toc', enabled)); + } + } + }, + + syncLocationUrl: (enabled) => { + 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', () => { + setTimeout(() => tableOfContents.syncShareUrls($('#options-toc').is(':checked'))); + }); + }, + // Find Tags findTags: () => { const toc = []; @@ -162,7 +213,7 @@ const tableOfContents = globalThis.tableOfContents = { }); $('.tocItem').removeClass('activeTOC'); - if (activeTocIndex === null) return; + if (activeTocIndex == null) return; $(`.tocItem[data-toc-index="${activeTocIndex}"]`).addClass('activeTOC'); }, @@ -213,4 +264,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..a8a30a4 100644 --- a/tests/toc-numbering.test.js +++ b/tests/toc-numbering.test.js @@ -12,18 +12,31 @@ const loadTocHelpers = () => { const sandbox = { console, URLSearchParams, + URL, globalThis: {}, + window: { + location: {href: 'https://example.test/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 makeEntries = (tags) => tags.map((tag, index) => ({ tag, @@ -88,3 +101,27 @@ 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.test/p/test-pad?showChat=false#section', 'toc', true), + 'https://example.test/p/test-pad?showChat=false&toc=true#section', + ); + assert.equal( + setBooleanUrlParam('https://example.test/p/test-pad?showChat=false#section', 'toc', false), + 'https://example.test/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), + '', + ); +}); From 7a894508d3c26a95392c1f2d04fc9075eb10e02b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 10:24:39 +0000 Subject: [PATCH 3/4] test: cover toc url parameter rewrites Agent-Logs-Url: https://github.com/ether/ep_table_of_contents/sessions/5f0f5716-b682-49ff-a18e-534e67b3de76 Co-authored-by: JohnMcLear <220864+JohnMcLear@users.noreply.github.com> --- static/js/toc.js | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/static/js/toc.js b/static/js/toc.js index d85b625..f500fb2 100644 --- a/static/js/toc.js +++ b/static/js/toc.js @@ -57,19 +57,22 @@ const getOutlineEntries = (toc) => { }); }; -const setBooleanUrlParam = (rawUrl, name, enabled, baseUrl = window.location.href) => { +const getBaseUrl = () => (typeof window !== 'undefined' ? window.location.href : undefined); + +const setBooleanUrlParam = (rawUrl, name, enabled, baseUrl = getBaseUrl()) => { const url = new URL(rawUrl, baseUrl); url.searchParams.set(name, String(enabled)); return url.toString(); }; -const setEmbedCodeUrlParam = (embedCode, name, enabled, baseUrl = window.location.href) => { - return embedCode.replace( - /\bsrc=(['"])([^'"]+)\1/, - (match, quote, rawUrl) => - `src=${quote}${setBooleanUrlParam(rawUrl, name, enabled, baseUrl)}${quote}`, - ); -}; +const setEmbedCodeUrlParam = ( + embedCode, name, enabled, baseUrl = getBaseUrl() +) => embedCode.replace( + /\bsrc=(['"])([^'"]+)\1/, + (match, quote, rawUrl) => { + const urlWithParam = setBooleanUrlParam(rawUrl, name, enabled, baseUrl); + return `src=${quote}${urlWithParam}${quote}`; + }); if (typeof $ !== 'undefined') { $('#tocButton').click(() => { @@ -128,6 +131,8 @@ const tableOfContents = globalThis.tableOfContents = { }); $(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'))); }); }, From d1a375fd86c5cbb3a95fc7c7fbea1afd1c4c15d1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 10:29:02 +0000 Subject: [PATCH 4/4] test: verify toc url syncing behavior Agent-Logs-Url: https://github.com/ether/ep_table_of_contents/sessions/5f0f5716-b682-49ff-a18e-534e67b3de76 Co-authored-by: JohnMcLear <220864+JohnMcLear@users.noreply.github.com> --- static/js/toc.js | 29 ++++++---- tests/toc-numbering.test.js | 109 +++++++++++++++++++++++++++++++++--- 2 files changed, 120 insertions(+), 18 deletions(-) diff --git a/static/js/toc.js b/static/js/toc.js index f500fb2..75255b9 100644 --- a/static/js/toc.js +++ b/static/js/toc.js @@ -57,20 +57,26 @@ const getOutlineEntries = (toc) => { }); }; -const getBaseUrl = () => (typeof window !== 'undefined' ? window.location.href : undefined); - -const setBooleanUrlParam = (rawUrl, name, enabled, baseUrl = getBaseUrl()) => { - const url = new URL(rawUrl, baseUrl); +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, baseUrl = getBaseUrl() + embedCode, name, enabled, contextUrl = getWindowHref() ) => embedCode.replace( - /\bsrc=(['"])([^'"]+)\1/, + /\bsrc=(['"])([^'"]+)\1/g, (match, quote, rawUrl) => { - const urlWithParam = setBooleanUrlParam(rawUrl, name, enabled, baseUrl); + const urlWithParam = setBooleanUrlParam(rawUrl, name, enabled, contextUrl); return `src=${quote}${urlWithParam}${quote}`; }); @@ -110,7 +116,7 @@ const tableOfContents = globalThis.tableOfContents = { const $embedInput = $('#embedinput'); if ($embedInput.length > 0) { const embedValue = $embedInput.val(); - if (typeof embedValue === 'string' && embedValue.includes('src=')) { + if (typeof embedValue === 'string' && embedValue.includes(' { // 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'))); + setTimeout( + () => tableOfContents.syncShareUrls($('#options-toc').is(':checked')), + ETHERPAD_FIELD_REWRITE_DELAY_MS, + ); }); }, @@ -218,7 +227,7 @@ const tableOfContents = globalThis.tableOfContents = { }); $('.tocItem').removeClass('activeTOC'); - if (activeTocIndex == null) return; + if (activeTocIndex === null) return; $(`.tocItem[data-toc-index="${activeTocIndex}"]`).addClass('activeTOC'); }, diff --git a/tests/toc-numbering.test.js b/tests/toc-numbering.test.js index a8a30a4..5a8ba0d 100644 --- a/tests/toc-numbering.test.js +++ b/tests/toc-numbering.test.js @@ -15,7 +15,7 @@ const loadTocHelpers = () => { URL, globalThis: {}, window: { - location: {href: 'https://example.test/p/test-pad?showChat=false#section'}, + location: {href: 'https://example.com/p/test-pad?showChat=false#section'}, }, $: () => ({ click: () => {}, @@ -38,6 +38,74 @@ globalThis.__tocTestExports = { 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, text: `Heading ${index + 1}`, @@ -104,24 +172,49 @@ test('keeps numbering stable across many sibling headings', () => { test('adds the toc state to shared pad links without dropping existing params', () => { assert.equal( - setBooleanUrlParam('https://example.test/p/test-pad?showChat=false#section', 'toc', true), - 'https://example.test/p/test-pad?showChat=false&toc=true#section', + 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.test/p/test-pad?showChat=false#section', 'toc', false), - 'https://example.test/p/test-pad?showChat=false&toc=false#section', + 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'); +});