diff --git a/DESCRIPTION b/DESCRIPTION index 6b8caa05..9d285a29 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -21,8 +21,9 @@ Description: Use the paged media properties in CSS and the JavaScript running headers, etc. Applications of this package include books, letters, reports, papers, business cards, resumes, and posters. Imports: rmarkdown (>= 1.16), bookdown (>= 0.8), htmltools, jsonlite, later (>= 1.0.0), - processx, servr (>= 0.18), httpuv, xfun, websocket -Suggests: promises, testit, xaringan, pdftools, revealjs + processx, servr (>= 0.13), httpuv, xfun, knitr, htmlwidgets (>= 0.7), xml2, + websocket +Suggests: promises, testit, xaringan, pdftools, revealjs, leaflet License: MIT + file LICENSE URL: https://github.com/rstudio/pagedown BugReports: https://github.com/rstudio/pagedown/issues diff --git a/R/paged.R b/R/paged.R index b1d3676c..06bb597d 100644 --- a/R/paged.R +++ b/R/paged.R @@ -163,10 +163,104 @@ html_format = function( pagedown_dependency(xfun::with_ext(css2, '.css'), .pagedjs, .test) )) } - html_document2( + format = html_document2( ..., self_contained = self_contained, anchor_sections = anchor_sections, mathjax = mathjax, css = css, template = template, pandoc_args = pandoc_args ) + if (isTRUE(.pagedjs)) format$knitr$opts_chunk[['render']] = paged_render(self_contained) + iframe_file(reset = TRUE) + format +} + +paged_render = function(self_contained) { + function(x, options, ...) { + if (inherits(x, 'htmlwidget')) { + class(x) = c('iframehtmlwidget', class(x)) + } + knitr::knit_print(x, options, ..., self_contained = self_contained) + } +} + +knit_print.iframehtmlwidget = function(x, options, ..., self_contained) { + class(x) = tail(class(x), -1) + d = options$fig.path + if (!dir.exists(d)) { + dir.create(d, recursive = TRUE) + if (self_contained) on.exit(unlink(normalizePath(d), recursive = TRUE), add = TRUE) + } + f = xfun::in_dir(d, save_widget(x, options)) + f = paste0(d, f) + src = NULL + srcdoc = NULL + if (self_contained) { + srcdoc = xfun::file_string(f) + file.remove(f) + } else { + src = f + } + knitr::knit_print(autoscaling_iframe( + src = src, srcdoc = srcdoc, + class = paste(class(x), collapse = ' '), + width = options$out.width.px, height = options$out.height.px, + extra.attr = options$out.extra + )) +} + +save_widget = function(widget, options) { + f = iframe_file() + htmlwidgets::saveWidget( + widget = widget, file = f, + # since chrome_print() does not handle network requests, use a self contained html file + # In order to use selcontained = FALSE, we should implement a networkidle option in chrome_print() + selfcontained = TRUE, + knitrOptions = options + ) + f +} + +iframe_file = local({ + n = 0L + function(reset = FALSE) { + if (reset) n <<- -1L + n <<- n + 1L + sprintf('iframe%i.html', n) + } +}) + +autoscaling_iframe = function(width = NULL, height = NULL, ..., extra.attr = '') { + if (length(extra.attr) == 0) extra.attr = '' + extra.attr = as_html_attrs(extra.attr) + tag = htmltools::tag( + 'autoscaling-iframe', + c(extra.attr, + list(...), + list(htmltools::p("This browser does not support this feature.")) + ) + ) + width = css_declaration('width', htmltools::validateCssUnit(width)) + height = css_declaration('height', htmltools::validateCssUnit(height)) + tag = do.call( + htmltools::tagAppendAttributes, + c(list(tag = tag), list(style = width, style = height)) + ) + htmltools::attachDependencies( + tag, + htmltools::htmlDependency( + 'autoscalingiframe', packageVersion('pagedown'), src = pkg_resource(), + script = 'js/autoscaling_iframe.js', all_files = FALSE + ) + ) +} + +as_html_attrs = function(string) { + doc = xml2::read_html(sprintf('

', string)) + node = xml2::xml_find_first(doc, './/p') + xml2::xml_attrs(node) +} + +css_declaration = function(property, value) { + if (is.null(value)) return('') + paste0(property, ':', value, ';') } chapter_name = function() { diff --git a/inst/examples/index.Rmd b/inst/examples/index.Rmd index 943351e0..586aeb6c 100644 --- a/inst/examples/index.Rmd +++ b/inst/examples/index.Rmd @@ -892,6 +892,23 @@ Table \@ref(tab:test-table): knitr::kable(head(iris[, -5]), caption = 'An example table.') ``` +## HTMLWidgets + +HTMLWidgets keep their interactivity: you can insert a widget, interactively modify it and then print the document. As any other figures, you can modify their dimensions and styles using the chunk options `out.width`, `out.height` and `out.extra`. +Note that one of these dimensions is adjusted to keep the aspect ratio of the HTML widget constant without leaving extra space. + +```{r, out.width="4in", out.height="4in", out.extra='style="margin:auto;"', echo=FALSE} +library(leaflet) + +leaflet() %>% addTiles() %>% setView(-93.65, 42.0285, zoom = 2) %>% + addWMSTiles( + "http://mesonet.agron.iastate.edu/cgi-bin/wms/nexrad/n0r.cgi", + layers = "nexrad-n0r-900913", + options = WMSTileOptions(format = "image/png", transparent = TRUE), + attribution = "Weather data © 2012 IEM Nexrad" + ) +``` + # Bibliography {-} ```{r, include=FALSE} diff --git a/inst/resources/css/default.css b/inst/resources/css/default.css index 4a16d80f..19afd267 100644 --- a/inst/resources/css/default.css +++ b/inst/resources/css/default.css @@ -89,6 +89,9 @@ pre[class] { abbr { text-decoration: none; } +.htmlwidget { + max-width: 100%; +} @media screen { div.sourceCode { diff --git a/inst/resources/js/autoscaling_iframe.js b/inst/resources/js/autoscaling_iframe.js new file mode 100644 index 00000000..44572c15 --- /dev/null +++ b/inst/resources/js/autoscaling_iframe.js @@ -0,0 +1,138 @@ +// An auto-scaling iframe +// This object emits the following events: +// - initialize: when the iframe is ready to load a document +// - clear: when the iframe sources are removed +// - load: this is the same event as the iframe +// - resize: this event fires when the auto-scaling has finished +// +// TODO crosstalk support +// setters/getters for width/height +if (customElements) {customElements.define('autoscaling-iframe', + class extends HTMLElement { + constructor() { + super(); // compulsory + let shadowRoot = this.attachShadow({mode: 'open'}); + // Populate the shadow DOM: + shadowRoot.innerHTML = ` + + + `; + let iframe = shadowRoot.querySelector('iframe'); + + // the first load event throws the initialize event + iframe.addEventListener( + 'load', + () => this.dispatchEvent(new Event('initialize')), + {once: true} + ); + + this.initialized = new Promise(resolve => { + if (this.hasAttribute('initialized')) { + resolve(this); + } else { + this.addEventListener('initialize', e => { + this.setAttribute('initialized', ''); + resolve(e.currentTarget); + }); + } + }); + } + + connectedCallback() { + // Be aware that the connectedCallback() function can be called multiple times, + // see https://developer.mozilla.org/docs/Web/Web_Components/Using_custom_elements#Using_the_lifecycle_callbacks + this.ready = new Promise($ => this.addEventListener('resize', e => $(e.currentTarget), {once: true})); + return this.initialized.then(() => this.clear()) + .then(() => this.loadSource()) + .then(() => this.resize()); + } + + clear() { + let iframe = this.shadowRoot.querySelector('iframe'); + + const clearSource = (attr) => { + let pr; + if (iframe.hasAttribute(attr)) { + pr = new Promise($ => iframe.addEventListener('load', e => $(e.currentTarget), {once: true, capture: true})); + iframe.removeAttribute(attr); + } else { + pr = Promise.resolve(this); + } + return pr; + }; + + // clear srcdoc first (important) + let res = clearSource('srcdoc').then(() => clearSource('src')); + res.then(() => this.dispatchEvent(new Event('clear'))); + return res; + } + + loadSource() { + let iframe = this.shadowRoot.querySelector('iframe'); + + const load = (attr) => { + let pr; + if (this.hasAttribute(attr)) { + pr = new Promise($ => iframe.addEventListener('load', e => $(e.currentTarget), {once: true, capture: true})); + iframe.setAttribute(attr, this.getAttribute(attr)); + } else { + pr = Promise.resolve(); + } + return pr; + }; + + // load src first (important) + const res = load('src').then(() => load('srcdoc')); + res.then(() => this.dispatchEvent(new Event('load'))); + return res; + } + + resize() { + let iframe = this.shadowRoot.querySelector('iframe'); + let contentHeight, contentWidth; + try { + // this works only with a same-origin url + // with a cross-origin url, we get an error + let docEl = iframe.contentWindow.document.documentElement; + contentWidth = docEl.scrollWidth; + contentHeight = docEl.scrollHeight; + } + catch(e) { + // cross-origin url: + // we cannot find the size of the html page + // use a default resolution + contentWidth = 1024; + contentHeight = 768; + } + finally { + let widthScaleFactor = this.clientWidth / contentWidth; + let heightScaleFactor = this.clientHeight / contentHeight; + let scaleFactor = Math.min(widthScaleFactor, heightScaleFactor); + scaleFactor = Math.floor(scaleFactor * 1e6) / 1e6; + iframe.style.transform = "scale(" + scaleFactor + ")"; + iframe.width = contentWidth; + iframe.height = contentHeight; + + this.style.width = iframe.getBoundingClientRect().width + 'px'; + this.style.height = iframe.getBoundingClientRect().height + 'px'; + this.style.boxSizing = "content-box"; + } + this.dispatchEvent(new Event('resize')); + return Promise.resolve(this); + } + } +);} diff --git a/inst/resources/js/chrome_print.js b/inst/resources/js/chrome_print.js index 581687b8..d37cd4d0 100644 --- a/inst/resources/js/chrome_print.js +++ b/inst/resources/js/chrome_print.js @@ -59,10 +59,18 @@ ); }); + let responsiveIFramesReady = new Promise(resolve => { + window.addEventListener('load', () => { + let responsiveIFrames = document.getElementsByTagName('autoscaling-iframe'); + Promise.all([...responsiveIFrames].map(el => {return el['ready'];})).then(resolve()); + }); + }); + window.pagedownReady = Promise.all([ RevealReady, MathJaxReady, HTMLWidgetsReady, - document.fonts.ready + document.fonts.ready, + responsiveIFramesReady ]); } diff --git a/inst/resources/js/config.js b/inst/resources/js/config.js index 21261b87..293027a7 100644 --- a/inst/resources/js/config.js +++ b/inst/resources/js/config.js @@ -43,6 +43,10 @@ insertCSSForCover('back-cover'); insertPageBreaksCSS(); + let iframeHTMLWidgets = document.getElementsByTagName('autoscaling-iframe'); + let widgetsReady = Promise.all([...iframeHTMLWidgets].map(el => {return el['ready'];})); + await widgetsReady; + if (beforePaged) await beforePaged(); }; @@ -104,38 +108,42 @@ return result; }; window.PagedConfig.after = (flow) => { - // force redraw, see https://github.com/rstudio/pagedown/issues/35#issuecomment-475905361 - // and https://stackoverflow.com/a/24753578/6500804 - document.body.style.display = 'none'; - document.body.offsetHeight; - document.body.style.display = ''; - - // run previous PagedConfig.after function if defined - if (afterPaged) afterPaged(flow); - - // pagedownListener is a binding added by the chrome_print function - // this binding exists only when chrome_print opens the html file - if (window.pagedownListener) { - // the html file is opened for printing - // call the binding to signal to the R session that Paged.js has finished - const tocList = flow.source.querySelector('.toc > ul'); - const tocInfos = tocEntriesInfos(tocList); - pagedownListener(JSON.stringify({ - pagedjs: true, - pages: flow.total, - elapsedtime: flow.performance, - tocInfos: tocInfos - })); - return; - } - if (sessionStorage.getItem('pagedown-scroll')) { - // scroll to the last position before the page is reloaded - window.scrollTo(0, sessionStorage.getItem('pagedown-scroll')); - return; - } - if (window.location.hash) { - const id = decodeURIComponent(window.location.hash).replace(/^#/, ''); - document.getElementById(id).scrollIntoView({behavior: 'smooth'}); - } + let iframeHTMLWidgets = document.getElementsByTagName('autoscaling-iframe'); + let widgetsReady = Promise.all([...iframeHTMLWidgets].map(el => {return el['ready'];})); + widgetsReady.then(() => { + // force redraw, see https://github.com/rstudio/pagedown/issues/35#issuecomment-475905361 + // and https://stackoverflow.com/a/24753578/6500804 + document.body.style.display = 'none'; + document.body.offsetHeight; + document.body.style.display = ''; + + // run previous PagedConfig.after function if defined + if (afterPaged) afterPaged(flow); + + // pagedownListener is a binding added by the chrome_print function + // this binding exists only when chrome_print opens the html file + if (window.pagedownListener) { + // the html file is opened for printing + // call the binding to signal to the R session that Paged.js has finished + const tocList = flow.source.querySelector('.toc > ul'); + const tocInfos = tocEntriesInfos(tocList); + pagedownListener(JSON.stringify({ + pagedjs: true, + pages: flow.total, + elapsedtime: flow.performance, + tocInfos: tocInfos + })); + return; + } + if (sessionStorage.getItem('pagedown-scroll')) { + // scroll to the last position before the page is reloaded + window.scrollTo(0, sessionStorage.getItem('pagedown-scroll')); + return; + } + if (window.location.hash) { + const id = decodeURIComponent(window.location.hash).replace(/^#/, ''); + document.getElementById(id).scrollIntoView({behavior: 'smooth'}); + } + }); }; })();