Skip to content
Draft
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
8 changes: 7 additions & 1 deletion static/js/postAceInit.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.)
Expand Down
67 changes: 67 additions & 0 deletions static/js/toc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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('<iframe') && 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', () => {
// 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 = [];
Expand Down Expand Up @@ -213,4 +278,6 @@ const tableOfContents = globalThis.tableOfContents = {
return true;
},

_shareUrlSyncBound: false,

};
138 changes: 134 additions & 4 deletions tests/toc-numbering.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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': '<iframe name="embed_readwrite" src="https://example.com/p/test-pad?showControls=true&showChat=true" width="100%" height="600" frameborder="0"></iframe>',
'#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,
Expand Down Expand Up @@ -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 =
'<iframe name="embed_readwrite" src="https://example.com/p/test-pad?showControls=true&showChat=true" width="100%" height="600" frameborder="0"></iframe>';
assert.equal(
setEmbedCodeUrlParam(embedCode, 'toc', true),
'<iframe name="embed_readwrite" src="https://example.com/p/test-pad?showControls=true&showChat=true&toc=true" width="100%" height="600" frameborder="0"></iframe>',
);
assert.equal(
setEmbedCodeUrlParam(embedCode, 'toc', false),
'<iframe name="embed_readwrite" src="https://example.com/p/test-pad?showControls=true&showChat=true&toc=false" width="100%" height="600" frameborder="0"></iframe>',
);
});

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