Files
shiny/srcts/extras/shiny-showcase.ts
2021-06-18 10:18:51 -04:00

315 lines
9.7 KiB
TypeScript

import type { ShinyType } from "../src/shiny";
type ShowcaseSrcMessage = {
srcref: Array<number>;
srcfile: string;
};
const animateMs = 400;
// Given a DOM node and a column (count of characters), walk recursively
// through the node's siblings counting characters until the given number
// of characters have been found.
//
// If the given count is bigger than the number of characters contained by
// the node and its siblings, returns a null node and the number of
// characters found.
function findTextColPoint(node: Node, col: number) {
let cols = 0;
if (node.nodeType === 3) {
const nchar = node.nodeValue.replace(/\n/g, "").length;
if (nchar >= col) {
return { element: node, offset: col };
} else {
cols += nchar;
}
} else if (node.nodeType === 1 && node.firstChild) {
const ret = findTextColPoint(node.firstChild, col);
if (ret.element !== null) {
return ret;
} else {
cols += ret.offset;
}
}
if (node.nextSibling) return findTextColPoint(node.nextSibling, col - cols);
else return { element: null, offset: cols };
}
// Returns an object indicating the element containing the given line and
// column of text, and the offset into that element where the text was found.
//
// If the given line and column are not found, returns a null element and
// the number of lines found.
function findTextPoint(el: Node, line: number, col: number) {
let newlines = 0;
for (let childId = 0; childId < el.childNodes.length; childId++) {
const child = el.childNodes[childId];
// If this is a text node, count the number of newlines it contains.
if (child.nodeType === 3) {
// TEXT_NODE
const newlinere = /\n/g;
let match: ReturnType<RegExp["exec"]>;
while ((match = newlinere.exec(child.nodeValue)) !== null) {
newlines++;
// Found the desired line, now find the column.
if (newlines === line) {
return findTextColPoint(child, match.index + col + 1);
}
}
}
// If this is not a text node, descend recursively to see how many
// lines it contains.
else if (child.nodeType === 1) {
// ELEMENT_NODE
const ret = findTextPoint(child, line - newlines, col);
if (ret.element !== null) return ret;
else newlines += ret.offset;
}
}
return { element: null, offset: newlines };
}
// Draw a highlight effect for the given source ref. srcref is assumed to be
// an integer array of length 6, following the standard R format for source
// refs.
function highlightSrcref(
srcref: ShowcaseSrcMessage["srcref"],
srcfile: ShowcaseSrcMessage["srcfile"]
) {
// Check to see if the browser supports text ranges (IE8 doesn't)
if (!document.createRange) return;
// Check to see if we already have a marker for this source ref
let el = document.getElementById("srcref_" + srcref);
if (!el) {
// We don't have a marker, create one
el = document.createElement("span");
el.id = "srcref_" + srcref;
const ref = srcref;
const code = document.getElementById(srcfile.replace(/\./g, "_") + "_code");
// if there is no code file (might be a shiny file), quit early
if (!code) return;
const start = findTextPoint(code, ref[0], ref[4]);
const end = findTextPoint(code, ref[2], ref[5]);
// If the insertion point can't be found, bail out now
if (start.element === null || end.element === null) return;
const range = document.createRange();
// If the text points are inside different <SPAN>s, we may not be able to
// surround them without breaking apart the elements to keep the DOM tree
// intact. Just move the selection points to encompass the contents of
// the SPANs.
if (
start.element.parentNode.nodeName === "SPAN" &&
start.element !== end.element
) {
range.setStartBefore(start.element.parentNode);
} else {
range.setStart(start.element, start.offset);
}
if (
end.element.parentNode.nodeName === "SPAN" &&
start.element !== end.element
) {
range.setEndAfter(end.element.parentNode);
} else {
range.setEnd(end.element, end.offset);
}
range.surroundContents(el);
}
// End any previous highlight before starting this one
$(el).stop(true, true).effect("highlight", null, 1600);
}
// If this is the main Shiny window, wire up our custom message handler.
// TODO-barret, this should work
if ((window as any).Shiny) {
((window as any).Shiny as ShinyType).addCustomMessageHandler(
"showcase-src",
function (message: ShowcaseSrcMessage) {
if (message.srcref && message.srcfile) {
highlightSrcref(message.srcref, message.srcfile);
}
}
);
}
let isCodeAbove = false;
const setCodePosition = function (above: boolean, animate: boolean) {
const animateCodeMs = animate ? animateMs : 1;
// set the source and targets for the tab move
const newHostElement = above
? document.getElementById("showcase-sxs-code")
: document.getElementById("showcase-code-inline");
const currentHostElement = above
? document.getElementById("showcase-code-inline")
: document.getElementById("showcase-sxs-code");
const metadataElement = document.getElementById("showcase-app-metadata");
if (metadataElement === null) {
// if there's no app metadata, show and hide the entire well container
// when the code changes position
const wellElement = $("#showcase-well");
if (above) {
wellElement.fadeOut(animateCodeMs);
} else {
wellElement.fadeIn(animateCodeMs);
}
}
// hide the new element before doing anything to it
$(newHostElement).hide();
$(currentHostElement).fadeOut(animateCodeMs, function () {
const tabs = document.getElementById("showcase-code-tabs");
currentHostElement.removeChild(tabs);
newHostElement.appendChild(tabs);
// remove or set the height of the code
if (above) {
setCodeHeightFromDocHeight();
} else {
document.getElementById("showcase-code-content").removeAttribute("style");
}
$(newHostElement).fadeIn(animateCodeMs);
if (!above) {
// remove the applied width and zoom on the app container, and
// scroll smoothly down to the code's new home
document
.getElementById("showcase-app-container")
.removeAttribute("style");
if (animate)
$(document.body).animate({
scrollTop: $(newHostElement).offset().top,
});
}
// if there's a readme, move it either alongside the code or beneath
// the app
const readme = document.getElementById("readme-md");
if (readme !== null) {
readme.parentElement.removeChild(readme);
if (above) {
currentHostElement.appendChild(readme);
$(currentHostElement).fadeIn(animateCodeMs);
} else
document.getElementById("showcase-app-metadata").appendChild(readme);
}
// change the text on the toggle button to reflect the new state
document.getElementById("showcase-code-position-toggle").innerHTML = above
? '<i class="fa fa-level-down"></i> show below'
: '<i class="fa fa-level-up"></i> show with app';
});
if (above) {
$(document.body).animate({ scrollTop: 0 }, animateCodeMs);
}
isCodeAbove = above;
setAppCodeSxsWidths(above && animate);
$(window).trigger("resize");
};
function setAppCodeSxsWidths(animate: boolean) {
const appTargetWidth = 960;
let appWidth = appTargetWidth;
let zoom = 1.0;
const totalWidth = document.getElementById("showcase-app-code").offsetWidth;
if (totalWidth / 2 > appTargetWidth) {
// If the app can use only half the available space and still meet its
// target, take half the available space.
appWidth = totalWidth / 2;
} else if (totalWidth * 0.66 > appTargetWidth) {
// If the app can meet its target by taking up more space (up to 66%
// of its container), take up more space.
appWidth = 960;
} else {
// The space is too narrow for the app and code to live side-by-side
// in a friendly way. Keep the app at 2/3 of the space but scale it.
appWidth = totalWidth * 0.66;
zoom = appWidth / appTargetWidth;
}
const app = document.getElementById("showcase-app-container");
$(app).animate(
{
width: appWidth + "px",
zoom: zoom * 100 + "%",
},
animate ? animateMs : 0
);
}
const toggleCodePosition = function () {
setCodePosition(!isCodeAbove, true);
};
// if the browser is sized to wider than 1350px, show the code next to the
// app by default
const setInitialCodePosition = function () {
if (document.body.offsetWidth > 1350) {
setCodePosition(true, false);
}
};
// make the code scrollable to about the height of the browser, less space
// for the tabs
function setCodeHeightFromDocHeight() {
document.getElementById("showcase-code-content").style.height =
$(window).height() + "px";
}
// if there's a block of markdown content, render it to HTML
function renderMarkdown() {
const mdContent = document.getElementById("showcase-markdown-content");
if (mdContent !== null) {
// IE8 puts the content of <script> tags into innerHTML but
// not innerText
const content = mdContent.innerText || mdContent.innerHTML;
const showdownConverter = (window as any).Showdown
.converter as showdown.ConverterStatic;
document.getElementById("readme-md").innerHTML =
new showdownConverter().makeHtml(content);
}
}
$(window).resize(function () {
if (isCodeAbove) {
setAppCodeSxsWidths(false);
setCodeHeightFromDocHeight();
}
});
declare global {
interface Window {
toggleCodePosition: () => void;
}
}
window.toggleCodePosition = toggleCodePosition;
$(window).on("load", setInitialCodePosition);
$(window).on("load", renderMarkdown);
if (window.hljs) window.hljs.initHighlightingOnLoad();
export {};