/*******************************************************************************
* pretext_add_on.js
*******************************************************************************
* Javascript for supplementary material in PreTeXt documents.
*
* Homepage: pretextbook.org
* Repository: https://github.com/PreTeXtBook/JS_core
*
* Authors: David Farmer, Rob Beezer, Alex Jordan
*
*******************************************************************************
*/
// stub for i18next to future-proof the code. We don't actually use it for
// anything right now, but it will be needed if we want to localize the
// accessibility search status messages.
window.i18next = window.i18next || {
t(key, params = {}) {
for (const param in params) {
key = key.replace(`{{${param}}}`, params[param]);
}
return key;
}
};
/*
copy permalink address to clipboard
requires browser support, otherwise does nothing
*/
async function copyPermalink(linkNode) {
// structure borrowed from https://flaviocopes.com/clipboard-api/
if (!navigator.clipboard) {
// Clipboard API not available
console.log("Error: Clipboard API not available");
return
}
console.log("copying permalink for", linkNode);
var elem = linkNode.parentElement
if (!linkNode) {
console.log("Error: Something went wrong finding permalink URL")
return
}
const this_permalink_url = linkNode.href;
const this_permalink_description = elem.getAttribute('data-description');
var link = "" + this_permalink_description + "";
var msg_link = "" + this_permalink_description + "";
var text_fallback = this_permalink_description + " \r\n" + this_permalink_url;
var copy_success = true;
try {
// NOTE: this method will only work in Firefox if the user has
// dom.events.asyncClipboard.clipboardItem
// set to true in their about:config.
// Annoyingly, this setting is turned off by default.
// If that setting is off, this try block will fail and we'll use the
// fallback method lower down instead.
await navigator.clipboard.write([
new ClipboardItem({
'text/html': new Blob([link], { type: 'text/html' }),
'text/plain': new Blob([text_fallback], { type: 'text/plain' }),
})
]);
} catch (err) {
console.log('Permalink-to-clipboard using ClipboardItem failed, falling back to clipboard.writeText', err);
copy_success = false;
}
if (! copy_success) {
try {
await navigator.clipboard.writeText(text_fallback);
} catch (err) {
console.log('Permalink-to-clipboard using clipboard.writeText failed', err);
console.error('Failed to copy link to clipboard!');
return
}
}
console.log(`copied '${this_permalink_url}' to clipboard`);
// temporary element to alert user that link was copied
let copied_msg = document.createElement('p');
copied_msg.setAttribute('role', 'alert');
copied_msg.className = "permalink-alert";
copied_msg.innerHTML = "Link to " + msg_link + " copied to clipboard";
elem.parentElement.insertBefore(copied_msg, elem);
// show confirmation for a couple seconds
await new Promise((resolve, reject) => setTimeout(resolve, 1500));
copied_msg.remove();
}
// Add event listener to add onClick handler for permalinks
window.addEventListener("DOMContentLoaded", function() {
const permalinks = document.querySelectorAll('.autopermalink > a');
permalinks.forEach(link => {
link.addEventListener('click', function(event) {
event.preventDefault(); // Prevent default anchor behavior
copyPermalink(link);
});
});
});
window.addEventListener("load",function(event) {
/* click an image to magnify */
$('body').on('click','.image-box > img:not(.draw_on_me):not(.mag_popup), .sbspanel > img:not(.draw_on_me):not(.mag_popup), figure > img:not(.draw_on_me):not(.mag_popup), figure > div > img:not(.draw_on_me):not(.mag_popup)', function(){
var img_big = document.createElement('div');
const content_element = document.getElementById('ptx-content');
img_big.setAttribute('class', 'mag_popup_container');
img_big.innerHTML = ``;
// place_to_put_big_img = $(this).parents(".sbsrow, figure, li").last();
place_to_put_big_img = $(this).parents(".image-box, .sbsrow, figure, li, .cols2 article:nth-of-type(2n)").last();
// for .cols2, the even ones have to go inside the previous odd one
if (place_to_put_big_img.prop("tagName") == "ARTICLE") {
place_to_put_big_img = place_to_put_big_img.prev().children().first();
}
// find ancestor so that place_to_put_big_img's position is relative to that ancestor
var img_big_parent = place_to_put_big_img[0].parentElement;
while (img_big_parent.id !== "ptx-content") {
const computed_position = getComputedStyle(img_big_parent).position;
if (computed_position !== "static") {
break;
}
img_big_parent = img_big_parent.parentElement;
}
const content_element_computed_style = getComputedStyle(content_element);
const content_padding_left = parseFloat(content_element_computed_style.paddingLeft );
const content_padding_right = parseFloat(content_element_computed_style.paddingRight);
const img_big_offset = content_element.getBoundingClientRect().left - img_big_parent.getBoundingClientRect().left + content_padding_left;
const doc_width = content_element.offsetWidth - content_padding_left - content_padding_right;
img_big.setAttribute('style', `width:${doc_width.toString()}px; left:${img_big_offset.toString()}px;`);
$(img_big).insertBefore(place_to_put_big_img);
});
/* click the big image to make it go away */
$('body').on('click','img.mag_popup', function(){
this.parentNode.remove();
});
/* add ids to p that have none */
p_no_id = document.querySelectorAll('.main p:not([id])');
for (var n=p_no_id.length - 1; n >= 0; --n) {
e = p_no_id[n];
if (e.hasAttribute('id')) {
/*
console.log(e, "was id'd in a previous round");
*/
continue
}
/*
console.log("this is e", e);
*/
if (e.classList.contains('watermark')) {
console.log(e, "skipping the watermark");
continue
}
/*
console.log("\n XXXXXXXXX p with no id", e);
*/
prev_p = $(e).prevAll("p");
console.log("prev_p", prev_p, "xx");
if(prev_p.length == 0) {
console.log(" PPP problem: prev_p has no length:", prev_p);
continue
}
console.log("which has id", prev_p[0].id);
var parts_found = 1;
var parts_to_id = [e];
for (var i=0; i < prev_p.length; ++i) {
this_previous = prev_p[i];
console.log("i", i, "this_previous", this_previous, "id", this_previous.id, "???", this_previous.hasAttribute('id'))
if (!this_previous.hasAttribute('id')) {
parts_to_id.unshift(this_previous)
}
else {
base_id = this_previous.id;
console.log("base_id", base_id);
console.log("ready to add id to", parts_to_id);
for (var j=0; j < parts_to_id.length; ++j) {
++parts_found;
var next_id = base_id + "-part" + parts_found.toString();
console.log("parts_found", parts_found, "next_id", next_id);
parts_to_id[j].setAttribute("id", next_id);
}
break // because we found the id that is the base for the missing ids
}
}
}
console.log("adding video popouts");
all_iframes = document.querySelectorAll('body iframeXXXX');
// for now, we just want the iframes that hace youtube in the src
for (var i = 0; i < all_iframes.length; i++) {
this_item = all_iframes[i];
this_item_src = this_item.src;
// console.log("this_item_src", this_item_src);
if(this_item_src.includes("youtube")) {
this_item_id = this_item.id;
this_item_width = this_item.width;
this_item_height = this_item.height;
if(this_item_height < 150) { continue }
console.log("found a youtube video on", this_item_id);
var empty_div = document.createElement('div');
var this_videomag_container = document.createElement('div');
parent_tag = this_item.parentElement.tagName;
if(parent_tag == "FIGURE") {
this_videomag_container.setAttribute("class", "videobig");
} else {
this_videomag_container.setAttribute("class", "videobig nofigure");
}
/*
this_videomag_container.setAttribute('class', 'videobig');
*/
this_videomag_container.setAttribute('video-id', this_item_id);
this_videomag_container.setAttribute('data-width', this_item_width);
this_videomag_container.setAttribute('data-height', this_item_height);
this_videomag_container.innerHTML = 'fit width';
/* replace this with a surrounding div, for placement, containing a inline-block so the background looks right */
this_item.insertAdjacentElement("beforebegin", empty_div); // because of hard-coded permalinks being inline-block */
this_item.insertAdjacentElement("beforebegin", this_videomag_container);
this_item.insertAdjacentElement("beforebegin", empty_div); // because of hard-coded permalinks being inline-block */
}
}
/* replace this with a single class fo rthe button, with supplementary classes that say to shrink or grow */
$(".videobig").click(function(){
parent_video_id = this.getAttribute("video-id");
console.log("clicked videobig for", parent_video_id);
this_video = document.getElementById(parent_video_id);
console.log("make big: ", this_video);
original_width = this.getAttribute("data-width");
original_height = this.getAttribute("data-height");
browser_width = $(window).width();
width_ratio = browser_width/original_width;
console.log("the browser is wider by a factor of",width_ratio);
this_video.setAttribute("width", width_ratio*original_width);
this_video.setAttribute("height", width_ratio*original_height);
this_video.setAttribute("style", "position:relative; left:-260px; z-index:1000");
this.setAttribute("class", "videosmall");
this.innerHTML = "make small";
$(".videosmall").click(function(){
console.log("clicked videosmall");
parent_video_id = this.getAttribute("video-id");
this_video = document.getElementById(parent_video_id);
original_width = this.getAttribute("data-width");
original_height = this.getAttribute("data-height");
this_video.removeAttribute("style");
this_video.setAttribute("width", original_width);
this_video.setAttribute("height", original_height);
this.setAttribute("class", "videobig");
this.innerHTML = "fit width";
});
});
},
false);
/* for the random WW problems */
function updateURLParameter(url, param, paramVal){
var newAdditionalURL = "";
var tempArray = url.split("?");
var baseURL = tempArray[0];
var additionalURL = tempArray[1];
var temp = "";
if (additionalURL) {
tempArray = additionalURL.split("&");
for (var i=0; i {
for (let entry of entries) {
if (entry.target === calcDialogElement && applet && applet.getAppletObject()) {
const width = entry.contentRect.width;
const height = entry.contentRect.height;
const topBarHeight = calcDialogElement.querySelector('.ptx-dialog-topbar').clientHeight || 0;
applet.getAppletObject().setSize(width, height - topBarHeight);
applet.getAppletObject().recalculateEnvironments();
}
}
});
resizeObserver.observe(calcDialogElement);
});
window.addEventListener("load",function(event) {
document.onkeyup = function(event)
{
var e = (!event) ? window.event : event;
switch(e.keyCode)
{
case 13: //CR
just_hit_escape = false;
if ($(document.activeElement).hasClass("workspace")) {
process_workspace()
}
case 27: //esc
// var parent_sage_cell = $(this).closest(".sagecell_editor");
var parent_sage_cell = document.activeElement.closest(".sagecell_editor");
if (parent_sage_cell && !just_hit_escape) {
console.log("staying in the sage cell", parent_sage_cell, document.activeElement)
just_hit_escape = true;
setTimeout(function(){ just_hit_escape = false }, 1000);
} else
if(knowl_focus_stack.length > 0 ) {
most_recently_opened = knowl_focus_stack.pop();
knowl_focus_stack_uid.pop();
most_recently_opened.focus();
console.log("moved back one knowl");
} else {
console.log("no open knowls being tracked");
break;
}
break;
}
};
},
false);
// when the anchor is a knowl, open it
window.addEventListener("load",function(event) {
if (window.location.hash.length) {
let id = window.location.hash.substring(1);
var the_anchor = document.getElementById(id);
console.log("id", id, "the_anchor", the_anchor);
if (the_anchor.tagName == "ARTICLE") {
var contained_knowl = the_anchor.querySelector("a[data-knowl]");
if (contained_knowl && contained_knowl.parentElement == the_anchor) {
console.log("found a knowl", contained_knowl);
// knowl_click_handler($(contained_knowl))
contained_knowl.click()
}
} else if (the_anchor.hasAttribute("data-knowl")) {
the_anchor.click()
} else {
// if it is a hidden knowl, find the knowl and open it
var this_hidden_content = the_anchor.closest(".hidden-content");
if (this_hidden_content) {
console.log("linked to a hidden knowl with this_hidden_content", this_hidden_content);
var the_refid = this_hidden_content.id;
var this_knowl = document.querySelector('[data-refid="' + the_refid + '"]');
this_knowl.click()
}
}
}
});
// The new method for creating pages and adjusting workspace //
// Unwrap section.paragraphs containers so their children flow directly
// into the parent, enabling CSS page breaks between the inner elements.
function flattenParagraphsSections(printout) {
const paragraphsSections = printout.querySelectorAll('section.paragraphs');
paragraphsSections.forEach(section => {
const parent = section.parentNode;
// Move all children out of the section wrapper and into the parent
while (section.firstChild) {
parent.insertBefore(section.firstChild, section);
}
// Remove the now-empty section wrapper
parent.removeChild(section);
});
}
// Wait for all images inside a container to finish loading.
// Returns a promise that resolves when every has loaded (or on timeout).
function waitForImages(container, timeoutMs = 5000) {
const images = container.querySelectorAll('img');
const promises = [];
for (const img of images) {
if (!img.complete) {
promises.push(new Promise(resolve => {
img.addEventListener('load', resolve, { once: true });
img.addEventListener('error', resolve, { once: true });
}));
}
}
if (promises.length === 0) return Promise.resolve();
// Race all image loads against a timeout so broken images don't block forever
return Promise.race([
Promise.all(promises),
new Promise(resolve => setTimeout(resolve, timeoutMs))
]);
}
// This is used multiple places to set height of workspace divs to their author-provided heights
function setInitialWorkspaceHeights() {
const workspaces = document.querySelectorAll('.workspace');
workspaces.forEach(ws => {
ws.style.height = ws.getAttribute('data-space') || '0px';
ws.setAttribute("contenteditable", "true");
});
}
// If a printout (worksheet or handout) includes authored pages, we only need to put content before the first page and after the last page into the first and last pages, respectively.
function adjustPrintoutPages() {
console.log("*** Adjusting printout pages.");
const printout = document.querySelector('section.worksheet, section.handout');
if (!printout) {
console.warn("No printout found, exiting adjustPrintoutPages.");
return;
}
const pages = printout.querySelectorAll('.onepage');
if (pages.length === 0) {
console.warn("No pages found in printout, exiting adjustPrintoutPages.");
return;
}
// Find all children before the first .onepage element:
const firstPage = pages[0];
const lastPage = pages[pages.length - 1];
// Move all children before the first page into the first page
const pageFirstChild = firstPage.firstChild;
let currentChild = printout.firstChild;
while (currentChild && currentChild !== firstPage) {
const nextChild = currentChild.nextSibling; // Save the next sibling before removing
firstPage.insertBefore(currentChild, pageFirstChild); // Move to the first page
currentChild = nextChild; // Move to the next child
}
// Now find all children after the last .onepage element:
let nextChild = lastPage.nextSibling;
while (nextChild) {
const tempChild = nextChild;
nextChild = nextChild.nextSibling;
lastPage.appendChild(tempChild);
}
console.log("Moved all content before the first page and after the last page into the respective pages.");
}
// This is the main function we will call then a printout does not come from the XSL with pages already defined (for now, the XSL will keep the behavior as an option).
function createPrintoutPages(margins) {
console.log("*** Creating printout pages with margins:", margins);
// Assumptions: needs to work for both letter (8.5in x 11in) and a4 (210mm x 297mm) paper sizes. We will work in pixels (96/in): those are 816px x 1056px and 794px x 1122.5px respectively (1 inch = 96 px, 1 cm = 37.8 px). We assume that the printing interface of the browser will do the right thing with these.
// For purposes of finding page breaks, we will use 794 as our width and 1056 as our height (so A4 width and letter height). Then we will rescale workspace on each page to fit the actual paper size selected.
const conservativeContentHeight = 1056 - (margins.top + margins.bottom); // in pixels
const conservativeContentWidth = 794 - (margins.left + margins.right); // in pixels
const printout = document.querySelector('section.worksheet, section.handout');
if (!printout) {
console.warn("No printout found, exiting createPrintoutPages.");
return;
}
printout.style.width = toString(conservativeContentWidth + margins.left + margins.right) + 'px';
// Set the height of each workspace based on its data-space attribute
setInitialWorkspaceHeights(printout);
// We want to consider each "block" of the printout. Some of these will be direct children of the printout, some will be nested inside these children. So first create a list of the elements that we consider blocks.
let rows = [];
for (const child of printout.children) {
if (child.classList.contains('sidebyside')) {
// sidebyside could have tasks, but we don't want to dive further into them.
rows.push(child);
} else if (child.querySelector('.task')) {
// Keep the child as a block, but put each task after the first one as its own row:
rows.push(child);
const tasks = child.querySelectorAll('.task, .conclusion');
//Determine how many levels of nesting each task has. If parent is an .exercise, leave alone. If parent is a .task, add .subtask class. If grandparent is .task, add .subsubtask to it so it can be indented by css:
for (let i = 0; i < tasks.length; i++) {
let parent = tasks[i].parentElement;
let grandparent = parent.parentElement;
if (grandparent.classList.contains('task')) {
tasks[i].classList.add('subsubtask');
} else if (parent.classList.contains('task')) {
tasks[i].classList.add('subtask');
}
}
for (let i = tasks.length-1; i > 0; i--) {
// Move the task out of the original child and place it directly after it in the printout. We do this in reverse order so when every task is moved, they return to the original order. They will then be added to the rows list as their own blocks.
printout.insertBefore(tasks[i], child.nextSibling);
}
// Skipping separate treatment of exercisegroups for now.
} else {
rows.push(child);
}
}
// Loop through the blocks and create a list of objects including the block, its height, and its workspace height. Only include blocks that have height (this will remove autopermalinks, as desired).
let blockList = [];
for (const row of rows) {
let blockHeight = getElementTotalHeight(row);
if (blockHeight === 0) {
console.log("Skipping row with zero height:", row);
continue;
}
let totalWorkspaceHeight = 0;
if (row.querySelector('.workspace')) {
// Workspace height is not just sum of workspace heights; we need to be careful with sidebyside and columns
totalWorkspaceHeight = getElemWorkspaceHeight(row);
}
blockList.push({elem: row, height: blockHeight, workspaceHeight: totalWorkspaceHeight});
}
// Now find pageBreaks so that extra workspace is as uniform as possible.
const pageBreaks = findPageBreaks(blockList, conservativeContentHeight);
// Create page divs and insert rows into them
for (let i = 0; i < pageBreaks.length; i++) {
const pageDiv = document.createElement('section');
pageDiv.classList.add('onepage');
if (i === 0) {
pageDiv.classList.add('firstpage');
}
// A single page will be both first and last
if (i === pageBreaks.length - 1) {
pageDiv.classList.add('lastpage');
}
// The pageBreaks array gives the indices of blocks that should start a page.
// So we will want to look for go through the blocks selecting those starting with the previous index (or 0) up to but not including the current index.
const start = pageBreaks[i-1] || 0;
const end = pageBreaks[i];
for (let j = start; j < end; j++) {
const row = blockList[j].elem;
pageDiv.appendChild(row);
}
printout.appendChild(pageDiv);
}
// remove any old content that is not in a page
for (const child of printout.children) {
if (!child.classList.contains('onepage')) {
console.log("Removing old child not in a page:", child);
printout.removeChild(child);
}
}
}
// Add headers and footers to all pages in a printout. Start with this set to be hidden by default; a toggle later will show/hide them.
function addHeadersAndFootersToPrintout() {
const printout = document.querySelector('section.worksheet, section.handout');
if (!printout) {
console.warn("No printout found, exiting addHeadersAndFootersToPrintout.");
return;
}
const pages = printout.querySelectorAll('.onepage');
// Loop through pages and add header and footer divs
pages.forEach((page, index) => {
const isFirstPage = index === 0;
// Add header
const headerDiv = document.createElement('div');
headerDiv.classList.add(isFirstPage ? 'first-page-header' : 'running-header', 'hidden');
headerDiv.innerHTML = ``;
page.insertBefore(headerDiv, page.firstChild);
// Add footer
const footerDiv = document.createElement('div');
footerDiv.classList.add(isFirstPage ? 'first-page-footer' : 'running-footer', 'hidden');
footerDiv.innerHTML = ``;
page.appendChild(footerDiv);
});
// Add content based on local storage if available, otherwise from data-attributes on the printout
const headerFooterKeys = ['header-first-left', 'header-first-center', 'header-first-right', 'footer-first-left', 'footer-first-center', 'footer-first-right', 'header-running-left', 'header-running-center', 'header-running-right', 'footer-running-left', 'footer-running-center', 'footer-running-right'];
const headerFooterContent = {};
headerFooterKeys.forEach(key => {
headerFooterContent[key] = localStorage.getItem(key) || printout.getAttribute(`data-${key}`) || '';
});
// First page header and footer
document.querySelector('.first-page-header').querySelector('.header-left').innerHTML = headerFooterContent['header-first-left'];
document.querySelector('.first-page-header').querySelector('.header-center').innerHTML = headerFooterContent['header-first-center'];
document.querySelector('.first-page-header').querySelector('.header-right').innerHTML = headerFooterContent['header-first-right'];
document.querySelector('.first-page-footer').querySelector('.footer-left').innerHTML = headerFooterContent['footer-first-left'];
document.querySelector('.first-page-footer').querySelector('.footer-center').innerHTML = headerFooterContent['footer-first-center'];
document.querySelector('.first-page-footer').querySelector('.footer-right').innerHTML = headerFooterContent['footer-first-right'];
// Running headers and footers
document.querySelectorAll('.running-header').forEach(headerDiv => {
headerDiv.querySelector('.header-left').innerHTML = headerFooterContent['header-running-left'];
headerDiv.querySelector('.header-center').innerHTML = headerFooterContent['header-running-center'];
headerDiv.querySelector('.header-right').innerHTML = headerFooterContent['header-running-right'];
});
document.querySelectorAll('.running-footer').forEach(footerDiv => {
footerDiv.querySelector('.footer-left').innerHTML = headerFooterContent['footer-running-left'];
footerDiv.querySelector('.footer-center').innerHTML = headerFooterContent['footer-running-center'];
footerDiv.querySelector('.footer-right').innerHTML = headerFooterContent['footer-running-right'];
});
// Add event listeners to update local storage when content is edited
headerFooterKeys.forEach(key => {
const selectorMap = {
'header-first-left': '.first-page-header .header-left',
'header-first-center': '.first-page-header .header-center',
'header-first-right': '.first-page-header .header-right',
'footer-first-left': '.first-page-footer .footer-left',
'footer-first-center': '.first-page-footer .footer-center',
'footer-first-right': '.first-page-footer .footer-right',
'header-running-left': '.running-header .header-left',
'header-running-center': '.running-header .header-center',
'header-running-right': '.running-header .header-right',
'footer-running-left': '.running-footer .footer-left',
'footer-running-center': '.running-footer .footer-center',
'footer-running-right': '.running-footer .footer-right'
};
const elements = document.querySelectorAll(selectorMap[key]);
elements.forEach(elem => {
elem.addEventListener('input', () => {
localStorage.setItem(key, elem.innerHTML);
});
});
});
}
// We look at each page and adjust the heights of the workspaces to fit it nicely into the page.
// The width and height of the page will now depend on the letter or a4 setting.
function adjustWorkspaceToFitPage({paperSize, margins}) {
console.log("*** Adjusting workspace to fit page size:", paperSize, "with margins:", margins);
// Toggle off workspace highlight if it is on, so it doesn't interfere with resizing
const highlightWorkspaceCheckbox = document.getElementById("highlight-workspace-checkbox");
const wasHighlighted = highlightWorkspaceCheckbox && highlightWorkspaceCheckbox.checked;
if (wasHighlighted) {
toggleWorkspaceHighlight(false);
}
let paperWidth, paperHeight;
if (paperSize === 'a4' || document.body.classList.contains('a4')) {
console.log("Setting page size to A4");
paperWidth = 794; // 210mm in px
paperHeight = 1122.5; // 297mm in px 794px x 1122.5px
} else {
console.log("Setting page size to Letter");
paperWidth = 816; // 8.5in in px
paperHeight = 1056; // 11in in px
}
const paperContentHeight = paperHeight - (margins.top + margins.bottom);
// Reset the heights of workspace divs to their author-provided heights
setInitialWorkspaceHeights();
const pages = document.querySelectorAll('.onepage');
pages.forEach(page => {
console.log("Adjusting workspace height for page:", page);
// Set width to get accurate calculations
page.style.width = paperWidth + 'px';
const rows = page.children;
let totalContentHeight = 0;
let totalWorkspaceHeight = 0;
for (const row of rows) {
totalContentHeight += getElementTotalHeight(row);
totalWorkspaceHeight += getElemWorkspaceHeight(row);
}
if (totalWorkspaceHeight === 0) {
console.log("No workspaces on this page, skipping workspace adjustment.");
// Reset the style for the page
page.style.width = "";
return;
}
const extraHeight = paperContentHeight - totalContentHeight;
console.log("Extra height to distribute across workspaces:", extraHeight, "px.");
// Determine the factor by which to multiply each workspace to make the total height fit the paperContentHeight
const workspaceAdjustmentFactor = (totalWorkspaceHeight + extraHeight) / totalWorkspaceHeight;
console.log("Workspace adjustment factor for page:", workspaceAdjustmentFactor);
// Now adjust each workspace in the page by this factor
const pageWorkspaces = page.querySelectorAll('.workspace');
pageWorkspaces.forEach(ws => {
const originalHeight = ws.offsetHeight;
const newHeight = originalHeight * workspaceAdjustmentFactor;
ws.style.height = newHeight + "px";
});
// Reset the style for the page
page.style.width = "";
});
console.log("Set page sizes to content area of paper size.");
// Reset the highlight workspace checkbox state
if (wasHighlighted) {
toggleWorkspaceHighlight(true);
}
}
// Helper functions for calculating heights and workspace sizes
function getElementTotalHeight(elem) {
// Calculate the total height of the element, including padding, border, and top margin.
const style = getComputedStyle(elem);
const marginTop = parseFloat(style.marginTop);
const marginBottom = parseFloat(style.marginBottom);
const height = elem.offsetHeight;
return height + marginTop + marginBottom;
}
function getElemWorkspaceHeight(elem) {
// Calculate the total height of all workspaces in the element.
// This is easy for elements stacked vertically, but we must be careful for side-by-side workspaces. Since we will multiply each workspace by a factor to fit the page, taking the largest workspace height should give us an upper bound for the amount of vertical space that is workspace.
// Note that this won't work well if we need to reduce the workspace, since there we would want to take the minimum heights.
if (elem.classList.contains('sidebyside')) {
const sbspanels = elem.querySelectorAll('.sbspanel');
let max = 0;
sbspanels.forEach(panel => {
const workspaces = panel.querySelectorAll('.workspace');
let totalHeight = 0;
workspaces.forEach(workspace => {
const workspaceHeight = workspace.offsetHeight;
if (workspaceHeight) {
totalHeight += workspaceHeight;
}
});
if (totalHeight > max) {
max = totalHeight; // Take the maximum height of workspaces in sidebyside
}
});
return max; // Return the maximum height of workspaces in sidebyside
}
// We can take care of exercisegroups and single colomn regular layout together.
let columns = 1;
if (elem.classList.contains('exercisegroup')) {
// Check for column classes and set columns accordingly
for (let i = 2; i <= 6; i++) {
if (elem.querySelector(`.cols${i}`)) {
columns = i;
console.log("Found exercisegroup with columns:", columns);
break;
}
}
}
const workspaces = elem.querySelectorAll('.workspace');
let totalHeight = 0;
workspaces.forEach(ws => {
const workspaceHeight = ws.offsetHeight;
if (workspaceHeight) {
totalHeight += workspaceHeight;
}
});
return totalHeight / columns; // Divide by columns if sidebyside to get average height per column
}
// Functions for finding the optimal page breaks
function findPageBreaks(rows, pageHeight) {
console.log("*** Finding page breaks for", rows.length, "rows with page height:", pageHeight);
// An array for the page breaks. The nth element will be the index of the first row on page n+1.
let pageBreaks = [];
// An array for the minimum cost possible for rows i to the end.
let minCost = Array(rows.length + 1).fill(Infinity);
minCost[rows.length] = 0; // No cost for no rows
// An array to keep track of the next row to start a new page after i in minCost.
let nextPageBreak = Array(rows.length).fill(-1);
// Now loop through the rows in reverse order to find the optimal page breaks.
for (let i = rows.length - 1; i >= 0; i--) {
let cumulativeHeight = 0;
let cumulativeWorkspaceHeight = 0;
// Loop through the rows starting from i to find the best page break
for (let j = i; j < rows.length; j++) {
cumulativeHeight += rows[j].height;
cumulativeWorkspaceHeight += rows[j].workspaceHeight;
if (cumulativeHeight > pageHeight) {
if (j === i) {
// The page height is too big for a single row. We make this row its own page and move on.
console.log("Row", i, "exceeds page height by itself, setting as its own page.");
minCost[i] = 0; // No cost for a single row
nextPageBreak[i] = i + 1; // The next page break is after this row
break; // Move to the next row
} else {
// We have already set minCost and NextPageBreak at an earlier point in the loop. This means we have done the best we can for this row so we stop and move to the next earlier row.
break; // Stop if we exceed the page height
}
}
const cost = (pageHeight - cumulativeHeight)**2 + minCost[j+1]; // Cost is how much space is left on the page, plus the cost of the following pages.
if (cost < minCost[i]) {
minCost[i] = cost;
nextPageBreak[i] = j+1; // Set the next page break to be after row j
}
}
}
// Backtrack to find the actual page breaks based on nextPageBreak
// Note: the nextPage = 1 is not an indexing mistake; we always assume that row 0 is a title and will go on the same page as row 1.
let nextPage = 1;
while (nextPage < rows.length) {
pageBreaks.push(nextPageBreak[nextPage]);
nextPage = nextPageBreak[nextPage];
}
return pageBreaks;
}
// Function to set CSS variables and @page rules for page geometry. This will be called whenever the paper size or margins change (in practice, only when page size changes, since margins are fixed for now).
function setPageGeometryCSS({paperSize, margins}) {
console.log("*** Setting page geometry CSS for paper size:", paperSize, "with margins:", margins);
// Remove any existing geometry CSS to avoid duplicates
const existingStyle = document.getElementById("page-geometry-css");
if (existingStyle) {
existingStyle.remove();
}
let wsWidth = paperSize === "letter" ? "816px" : "794px"; // 8.5in for Letter, 210mm for A4
let wsHeight = paperSize === "letter" ? "1056px" : "1123px"; // 11in for Letter, 297mm for A4
// Create a new style element for geometry CSS
const style = document.createElement("style");
// Add an identifier to the style element to avoid conflicts
style.id = "page-geometry-css";
// NB we need to add the fallback values for the margins in @page because some browsers do not support CSS variables in @page rules.
style.textContent = `
:root {
--ws-width: ${wsWidth};
--ws-height: ${wsHeight};
--ws-top-margin: ${margins.top}px;
--ws-right-margin: ${margins.right}px;
--ws-bottom-margin: ${margins.bottom}px;
--ws-left-margin: ${margins.left}px;
}
@page {
margin: var(--ws-top-margin, ${margins.top}px) var(--ws-right-margin, ${margins.right}px) var(--ws-bottom-margin, ${margins.bottom}px) var(--ws-left-margin, ${margins.left}px);
}
`;
document.head.appendChild(style);
}
function toggleWorkspaceHighlight(isChecked) {
if (isChecked) {
// Toggle the highlight class on the body based on the checkbox state
document.body.classList.add("highlight-workspace");
// If we haven't already inserted divs to show the original workspace heights, do that now
if (!document.querySelector('.workspace-container')) {
console.log("adding original workspace divs");
// Insert divs to show the original workspace
document.querySelectorAll('.workspace').forEach(workspace => {
// Create a container div to hold the workspace div and the original div
const container = document.createElement('div');
container.classList.add('workspace-container');
// Set the container height to the current workspace height
container.style.height = window.getComputedStyle(workspace).height;
const original = document.createElement('div');
original.classList.add('original-workspace');
const originalHeight = workspace.getAttribute('data-space') || '0px';
original.setAttribute('title', 'Author-specified workspace height (' + originalHeight + ')');
// Use the data-space attribute for height of original workspace
original.style.height = originalHeight;
// insert original div before the workspace content
container.appendChild(original);
// Add a warning class if the original height is greater than the current height
if (original.offsetHeight > workspace.offsetHeight) {
original.classList.add('warning');
}
// Move the workspace into the container
workspace.parentNode.insertBefore(container, workspace);
container.appendChild(workspace);
});
}
} else {
document.body.classList.remove("highlight-workspace");
// Remove the original workspace divs. We don't want to keep these in, as they interfere with changing page sizes and workspace heights.
document.querySelectorAll('.workspace-container').forEach(container => {
const workspace = container.querySelector('.workspace');
// Move the workspace out of the container
container.parentNode.insertBefore(workspace, container);
// Remove the container
container.remove();
});
}
}
function getPaperSize() {
let paperSize = localStorage.getItem("papersize");
if (paperSize) {
return paperSize;
} else {
// Try to set papersize based on user's geographic region
// Default to 'letter' for North and South America, 'a4' elsewhere
try {
fetch('https://ipapi.co/json/')
.then(response => response.json())
.then(data => {
let continent = data && data.continent_code ? data.continent_code : "";
paperSize = (continent === "NA" || continent === "SA") ? "letter" : "a4";
const radio = document.querySelector(`input[name="papersize"][value="${paperSize}"]`);
if (radio) {
radio.checked = true;
localStorage.setItem("papersize", paperSize);
}
document.body.classList.remove("a4", "letter");
document.body.classList.add(paperSize);
console.log("Setting papersize to", paperSize);
})
.catch((err) => {
// rethrow to be caught by the outer catch
throw err;
});
} catch (e) {
// fallback: default to letter
const radio = document.querySelector(`input[name="papersize"][value="letter"]`);
if (radio) radio.checked = true;
}
}
return paperSize || "letter";
}
// Function to load the printout section and switch to print stylesheet. This will run whenever a user clicks on a print preview link (which adds ?printpreview=sectionID to the URL).
async function loadPrintout(printableSectionID) {
// Switch to print-worksheet.css for print preview
const themeStylesheetLink = document.querySelector('link[rel="stylesheet"][href*="theme"]');
// get the href of the theme stylesheet link
const themeStylesheetHref = themeStylesheetLink ? themeStylesheetLink.getAttribute('href') : null;
if (themeStylesheetHref) {
// replace 'theme.css' with 'print-worksheet.css' in the href
const printStylesheetHref = themeStylesheetHref.replace(/theme.*\.css/, 'print-worksheet.css');
// update the href of the theme stylesheet link
themeStylesheetLink.setAttribute('href', printStylesheetHref);
// Wait for the new stylesheet to load. This is important to ensure the styles are applied before the calling function tries to compute workspace sizes.
await new Promise((resolve) => {
themeStylesheetLink.addEventListener('load', resolve, { once: true });
});
}
// Find the section with this ID
const printableSection = document.getElementById(printableSectionID);
if (!printableSection) {
console.error("No section found with ID:", printableSectionID);
return;
}
// Remove any existing sections from .ptx-content and add only the printable section
const ptxContent = document.querySelector('.ptx-content');
const existingSections = ptxContent.querySelectorAll(':scope > section');
existingSections.forEach(sec => ptxContent.removeChild(sec));
ptxContent.appendChild(printableSection);
}
// Function to redo solutions details to divs with summary as title
function rewriteSolutions() {
var born_hidden_knowls = document.querySelectorAll('.worksheet details, .handout details');
born_hidden_knowls.forEach(function(detail) {
const summary = detail.querySelector('summary');
const content = detail.innerHTML.replace(summary.outerHTML, '');
const div = document.createElement('div');
div.classList = detail.classList;
if (summary) {
const title = document.createElement('h5');
title.innerHTML = summary.innerHTML;
div.appendChild(title);
}
const body = document.createElement('div');
body.innerHTML = content;
div.appendChild(body);
detail.parentNode.replaceChild(div, detail);
});
}
// Utility to convert various CSS length units to pixels
function toPixels(value) {
if (typeof value === "number") return value;
if (typeof value !== "string") return 0;
value = value.trim();
if (value.endsWith("px")) {
return parseFloat(value);
} else if (value.endsWith("in")) {
return Math.floor(parseFloat(value) * 96);
} else if (value.endsWith("cm")) {
return Math.floor(parseFloat(value) * 37.8);
} else if (value.endsWith("mm")) {
return Math.floor(parseFloat(value) * 3.78);
} else if (value.endsWith("pt")) {
return Math.floor(parseFloat(value) * (96 / 72));
} else {
// fallback: try to parse as px
return parseFloat(value) || 0;
}
}
// Event listener for page load to handle print preview setup
window.addEventListener("DOMContentLoaded", async function(event) {
const urlParams = new URLSearchParams(window.location.search);
// We condition on the existence of the papersize radio buttons, which only appear in the printout print preview.
if (urlParams.has("printpreview")) {
const printableSectionID = urlParams.get("printpreview");
await loadPrintout(printableSectionID);
// First, get the margins for pages to be passed around as needed.
const marginList = document.querySelector('section.worksheet, section.handout').getAttribute('data-margins').split(' ');
// Convert margin values to pixels if they are not already numbers
const margins = {
top: toPixels(marginList[0] || "0.75in"), // Default to 0.75in if not specified
right: toPixels(marginList[1] || "0.75in"),
bottom: toPixels(marginList[2] || "0.75in"),
left: toPixels(marginList[3] || "0.75in")
}
// Transform all solutions details elements to divs with the summary as a title
rewriteSolutions();
// Get the papersize from localStorage or set it based on user's geographic region. This will always return a value (defaulting to 'letter' if all else fails).
let paperSize = getPaperSize();
if (paperSize) {
const radio = document.querySelector(`input[name="papersize"][value="${paperSize}"]`);
if (radio) {
radio.checked = true;
}
// Set the papersize class on body
document.body.classList.remove("a4", "letter");
document.body.classList.add(paperSize);
setPageGeometryCSS({paperSize: paperSize, margins: margins});
} else {
console.warning("Bug: paperSize should always have a value here.");
}
// Add event listeners to the papersize radio buttons to handle changes
const papersizeRadios = document.querySelectorAll('input[name="papersize"]');
papersizeRadios.forEach(radio => {
radio.addEventListener('change', function() {
if (this.checked) {
document.body.classList.remove("a4", "letter");
document.body.classList.add(this.value);
localStorage.setItem("papersize", this.value);
setPageGeometryCSS({paperSize: this.value, margins: margins});
adjustWorkspaceToFitPage({paperSize: this.value, margins: margins});
}
});
});
// Add event listeners to the hide hints/answers/solutions checkboxes
for (const solutionType of ["hint", "answer", "solution"]) {
const checkbox = document.getElementById(`hide-${solutionType}-checkbox`);
if (checkbox) {
const storageKey = `hide-${solutionType}`;
// by default, hide answer and solution divs
if (solutionType === "answer" || solutionType === "solution") {
if (!localStorage.getItem(storageKey)) {
checkbox.checked = true;
localStorage.setItem(storageKey, "true");
}
}
// Now adjust based on local storage
// set visibility based on current checkbox state
checkbox.checked = localStorage.getItem(storageKey) === "true";
document.querySelectorAll(`div.${solutionType}`).forEach(elem => {
// add hidden to class list
if (checkbox.checked) {
elem.classList.add("hidden");
} else {
elem.classList.remove("hidden");
}
});
// Add event listener to toggle visibility
checkbox.addEventListener("change", function() {
localStorage.setItem(storageKey, this.checked);
// toggle visibility of solution divs
document.querySelectorAll(`div.${solutionType}`).forEach(elem => {
if (checkbox.checked) {
elem.classList.add("hidden");
} else {
elem.classList.remove("hidden");
}
//adjustPrintoutPages();
adjustWorkspaceToFitPage({paperSize: paperSize, margins: margins});
});
});
}
}
// Finally, with everything set up, we create or adjust the printout pages as needed.
// Flatten paragraphs sections so page breaks can occur inside them.
const printoutSection = document.querySelector('section.worksheet, section.handout');
if (printoutSection) {
flattenParagraphsSections(printoutSection);
}
// Wait for all images to load so height measurements are accurate.
if (printoutSection) {
await waitForImages(printoutSection);
}
// If the printout has authored pages, there will be at least one .onepage element.
if (document.querySelector('.onepage')) {
adjustPrintoutPages();
} else {
createPrintoutPages(margins);
}
// Add headers and footers to all pages in the printout
addHeadersAndFootersToPrintout();
// Add event listeners to the print header/footer checkboxes
for (const hf of ["first-page-header", "running-header", "first-page-footer", "running-footer"]) {
const checkbox = document.getElementById(`print-${hf}-checkbox`);
if (checkbox) {
// set visibility based on current checkbox state
checkbox.checked = localStorage.getItem(`print-${hf}`) === "true";
document.querySelectorAll(`.${hf}`).forEach(elem => {
// add hidden to class list
if (checkbox.checked) {
elem.classList.remove("hidden");
} else {
elem.classList.add("hidden");
}
});
// Add event listener to toggle visibility
checkbox.addEventListener("change", function() {
localStorage.setItem(`print-${hf}`, this.checked);
// toggle visibility of header/footer divs
document.querySelectorAll(`.${hf}`).forEach(elem => {
if (checkbox.checked) {
elem.classList.remove("hidden");
} else {
elem.classList.add("hidden");
}
adjustWorkspaceToFitPage({paperSize: paperSize, margins: margins});
});
});
}
}
// After pages are set up, we adjust the workspace heights to fit the page (based on the paper size).
adjustWorkspaceToFitPage({paperSize: paperSize, margins: margins});
// Get the 'highlight workspace' checkbox state from localStorage or set it to false by default
// NB we need to do this after the adjustment of workspace heights so that the additional original workspace divs don't throw off the calculations when the page is reloaded.
const highlightWorkspaceCheckbox = document.getElementById("highlight-workspace-checkbox");
if (highlightWorkspaceCheckbox) {
highlightWorkspaceCheckbox.checked = localStorage.getItem("highlightWorkspace") === "true";
highlightWorkspaceCheckbox.addEventListener("change", function() {
localStorage.setItem("highlightWorkspace", this.checked);
toggleWorkspaceHighlight(this.checked);
});
// Initial toggle to apply the highlight class if checked
toggleWorkspaceHighlight(highlightWorkspaceCheckbox.checked);
}
console.log("finished adjusting workspace");
}
});
// START Support for code-copy button functionality
document.addEventListener("click", (ev) => {
const codeBox = ev.target.closest(".clipboardable");
if (!navigator.clipboard || !codeBox) return;
const button = ev.target.closest(".code-copy");
// Copy a clone with "unselectable" content removed (e.g. a console prompt),
// so the copied text matches what a manual selection would capture.
const pre = codeBox.querySelector("pre").cloneNode(true);
pre.querySelectorAll(".unselectable").forEach((el) => el.remove());
const preContent = pre.textContent;
navigator.clipboard.writeText(preContent);
button.classList.toggle("copied")
setTimeout(() => button.classList.toggle("copied"), 1000);
});
document.addEventListener("DOMContentLoaded", () => {
const elements = document.querySelectorAll(".clipboardable");
for (el of elements) {
const div = document.createElement("div");
div.classList.add("clipboardable");
el.classList.remove("clipboardable");
el.replaceWith(div);
div.insertAdjacentElement("afterbegin", el);
div.insertAdjacentHTML("beforeend", `
`.trim());
}
});
// END Support for code-copy button functionality
window.addEventListener("DOMContentLoaded", () => {
const userDropdownButton = document.getElementById("ptx-user-dropdown-button");
const userDropdownContent = document.getElementById("ptx-user-dropdown-content");
if (userDropdownButton && userDropdownContent) {
new PTXDropdown(userDropdownContent, userDropdownButton);
}
});