Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

A proposal for HTML Widgets #87

Open
wants to merge 45 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
d7802dc
embed htmlwidgets in iframes
RLesur Mar 15, 2019
69f806e
resize widgets
RLesur Mar 15, 2019
67637b1
use responsive iframes
RLesur Mar 16, 2019
9669d00
resize the iframe in the connectedCallback() instead of in the constr…
RLesur Mar 17, 2019
0af5aae
store computed size
RLesur Mar 17, 2019
09fd137
always use self contained widgets: otherwise we should implement netw…
RLesur Mar 17, 2019
ab726bf
create the footprint div in the class declaration
RLesur Mar 17, 2019
ca05b10
wait until HTMLWidgets are built
RLesur Mar 17, 2019
ede1e3c
use the footprint div to size the iframe
RLesur Mar 17, 2019
31d3248
use self_contained and out.extra
RLesur Mar 18, 2019
6702aca
don't ajust width
RLesur Mar 18, 2019
a05e359
adjust sizing
RLesur Mar 19, 2019
91e277c
position elements
Mar 19, 2019
0ccb233
add a support for cross-origins urls
Mar 19, 2019
5e2ef15
simplify the custom element
RLesur Mar 19, 2019
6e34518
adjust custom element sizes
RLesur Mar 19, 2019
201a5da
remove this in the callback function
RLesur Mar 19, 2019
7a9e246
emit an event when resized
RLesur Mar 19, 2019
2e7058c
rename object
RLesur Mar 19, 2019
ff513fd
move the resize function to a method
Mar 20, 2019
979897b
implements methods
Mar 20, 2019
2fe59c1
reduce the exposed methods
Mar 20, 2019
cf281d9
factor out variable declarations
Mar 20, 2019
8c73b48
use more explicit names
RLesur Mar 20, 2019
02cf582
avoid overflows
RLesur Mar 20, 2019
2059d22
add an entry in the documentation
RLesur Mar 20, 2019
71338b6
knitrOptions was introduced in htmlwidgets::saveWidget in htmlwidgets…
RLesur Mar 20, 2019
675746d
typo
cderv Mar 21, 2019
2379ab3
fix sself contained documents
RLesur Mar 21, 2019
2e399bb
DT works now with RStudio 1.2.1330
RLesur Mar 22, 2019
1ce5867
wait until widgets are re-rendered, otherwise servr reload is broken …
RLesur Mar 22, 2019
a396f8a
reset the ready state when connected
RLesur Mar 23, 2019
6df5777
force Chrome to redraw
RLesur Mar 23, 2019
b703670
update from master
RLesur Jul 4, 2019
5cbafb0
update from master
RLesur Jan 17, 2020
000bbad
update from master
RLesur Jan 18, 2021
c380bfb
Use an absolute path
RLesur Aug 12, 2021
e474a58
Update R/paged.R
RLesur Aug 12, 2021
8f12b00
Update R/paged.R
RLesur Aug 12, 2021
cfba64b
Update R/paged.R
RLesur Aug 12, 2021
dcfe7ad
Update R/paged.R
RLesur Aug 12, 2021
d26d4a2
Use local()
RLesur Aug 12, 2021
d7b1ba9
do not change the value of d
RLesur Aug 12, 2021
80c2606
pass the classes of the widget
RLesur Aug 12, 2021
1c840a2
set a max width for htmlwidget
RLesur Aug 12, 2021
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
5 changes: 3 additions & 2 deletions DESCRIPTION
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
96 changes: 95 additions & 1 deletion R/paged.R
Original file line number Diff line number Diff line change
Expand Up @@ -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('<p %s>', string))
node = xml2::xml_find_first(doc, './/p')
xml2::xml_attrs(node)
}
Comment on lines +255 to +259
Copy link
Member

@yihui yihui Jan 22, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's an interesting hack. Since eventually you'd need a list, perhaps we could document it that out.extra is expected to be a list? So that we don't need to introduce a new dependency or use a hack.

I can also support list values in knitr. It is easier to go from a list to a string than the other way around, e.g., I can generate id="foo" class="bar" from out.extra = list(id = "foo", class = "bar") in knitr.

BTW, what was the use case on your mind for out.extra? If you don't have a significant use case yet, perhaps we can support this option in the future as users request for it.


css_declaration = function(property, value) {
if (is.null(value)) return('')
paste0(property, ':', value, ';')
}

chapter_name = function() {
Expand Down
17 changes: 17 additions & 0 deletions inst/examples/index.Rmd
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
3 changes: 3 additions & 0 deletions inst/resources/css/default.css
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ pre[class] {
abbr {
text-decoration: none;
}
.htmlwidget {
max-width: 100%;
}

@media screen {
div.sourceCode {
Expand Down
138 changes: 138 additions & 0 deletions inst/resources/js/autoscaling_iframe.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// An auto-scaling iframe
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW I used iFrame Resizer in Distill and was quite happy with it: http://davidjbradshaw.github.io/iframe-resizer/

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was unaware of iFrame Resizer. This looks awesome! I will give it a try. Thanks!

// 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 = `
<style>
:host {
break-inside: avoid;
display: block;
position: relative;
overflow: hidden;
}
iframe {
transform-origin: top left;
position: absolute;
top: 0;
left: 0;
}
</style>
<iframe frameborder="0">
</iframe>
`;
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);
}
}
);}
10 changes: 9 additions & 1 deletion inst/resources/js/chrome_print.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
]);
}
74 changes: 41 additions & 33 deletions inst/resources/js/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
};

Expand Down Expand Up @@ -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'});
}
});
};
})();