synapse-react-client
Version:
[](https://travis-ci.com/Sage-Bionetworks/Synapse-React-Client) [](https://badge.fury.io/js/synaps
629 lines • 29.2 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
var tslib_1 = require("tslib");
var React = (0, tslib_1.__importStar)(require("react"));
var utils_1 = require("../utils");
var UserCard_1 = (0, tslib_1.__importDefault)(require("./UserCard"));
var Bookmarks_1 = (0, tslib_1.__importDefault)(require("./widgets/Bookmarks"));
var SynapseImage_1 = (0, tslib_1.__importDefault)(require("./widgets/SynapseImage"));
var SynapsePlot_1 = (0, tslib_1.__importDefault)(require("./widgets/SynapsePlot"));
var SynapseVideo_1 = (0, tslib_1.__importDefault)(require("./widgets/SynapseVideo"));
var ErrorBanner_1 = require("./ErrorBanner");
var react_bootstrap_1 = require("react-bootstrap");
var SynapseContext_1 = require("../utils/SynapseContext");
var TOC_CLASS = {
1: 'toc-indent1',
2: 'toc-indent2',
3: 'toc-indent3',
4: 'toc-indent4',
5: 'toc-indent5',
6: 'toc-indent6',
};
var md = markdownit({ html: true });
/**
* Basic Markdown functionality for Synapse, supporting Images/Plots/References/Bookmarks/buttonlinks
*
* @class Markdown
* @extends {React.Component}
*/
var MarkdownSynapse = /** @class */ (function (_super) {
(0, tslib_1.__extends)(MarkdownSynapse, _super);
/**
* Creates an instance of Markdown.
* @param {*} props
*/
function MarkdownSynapse(props) {
var _this = _super.call(this, props) || this;
// markdownitSynapse wraps around markdownit object and uses its own dependencies
markdownitSynapse.init_markdown_it(md, markdownitSub, markdownitSup, markdownitCentertext, markdownitSynapseHeading, markdownitSynapseTable, markdownitStrikethroughAlt, markdownitContainer, markdownitEmphasisAlt, markdownitInlineComments, markdownitBr);
var mathSuffix = '';
// Update the internal markdownit object with the wrapped synapse object
md.use(markdownitSynapse, mathSuffix, 'https://synapse.org').use(markdownitMath, mathSuffix);
var data = {};
if (_this.props.markdown) {
data.markdown = _this.props.markdown;
}
_this.state = {
md: md,
error: undefined,
fileHandles: undefined,
data: data,
isLoading: true,
};
_this.markupRef = React.createRef();
_this.handleLinkClicks = _this.handleLinkClicks.bind(_this);
// handle widgets and math markdown
_this.renderMarkdown = _this.renderMarkdown.bind(_this);
_this.recursiveRender = _this.recursiveRender.bind(_this);
_this.processMath = _this.processMath.bind(_this);
// handle init calls to get wiki related items
_this.getWikiAttachments = _this.getWikiAttachments.bind(_this);
_this.getWikiPageMarkdown = _this.getWikiPageMarkdown.bind(_this);
// handle rendering widgets
_this.renderWidget = _this.renderWidget.bind(_this);
_this.renderSynapseButton = _this.renderSynapseButton.bind(_this);
_this.renderSynapseImage = _this.renderSynapseImage.bind(_this);
_this.renderVideo = _this.renderVideo.bind(_this);
_this.renderSynapsePlot = _this.renderSynapsePlot.bind(_this);
_this.renderSynapseTOC = _this.renderSynapseTOC.bind(_this);
_this.createHTML = _this.createHTML.bind(_this);
_this.addBookmarks = _this.addBookmarks.bind(_this);
_this.addIdsToReferenceWidgets = _this.addIdsToReferenceWidgets.bind(_this);
_this.addIdsToTocWidgets = _this.addIdsToTocWidgets.bind(_this);
return _this;
}
MarkdownSynapse.prototype.componentWillUnmount = function () {
// @ts-ignore TODO: find better documentation on typescript/react event params
this.markupRef.current &&
// @ts-ignore TODO: find better documentation on typescript/react event params
this.markupRef.current.removeEventListener('click', this.handleLinkClicks);
};
// Manually handle clicks to anchor tags where the scrollto isn't handled by page hash
MarkdownSynapse.prototype.handleLinkClicks = function (event) {
var genericElement = event.target;
if (genericElement.tagName === 'A' || genericElement.tagName === 'BUTTON') {
var anchor = event.target;
if (anchor.id.substring(0, 3) === 'ref') {
event.preventDefault();
// its a reference, so we scroll to the appropriate bookmark
var referenceNumber = Number(event.currentTarget.id.substring(3)); // e.g. ref2 => '2'
var goTo = this.markupRef.current.querySelector("#bookmark" + referenceNumber);
try {
goTo.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'center',
});
}
catch (e) {
console.log('error on scroll', e);
}
}
else if (event.currentTarget.id !== null &&
anchor.getAttribute('data-anchor')) {
event.preventDefault();
// handle table of contents widget
var idOfContent = anchor.getAttribute('data-anchor');
var goTo = this.markupRef.current.querySelector("#" + idOfContent);
try {
goTo.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'center',
});
}
catch (e) {
console.log('error on scroll', e);
}
}
}
};
/**
* Given input text, generate markdown object to be passed onto inner html of some container.
* @param {String} markdown The text being written in plain markdown
* @returns {Object} Dictionary to be passed into dangerouslySetInnerHTML with markdown text
*/
MarkdownSynapse.prototype.createHTML = function (markdown) {
if (!markdown) {
return { __html: '' };
}
// Note - renderInline parses out any block level elements contained in the markdown
var initText = this.props.renderInline
? this.state.md.renderInline(markdown)
: this.state.md.render(markdown);
var cleanText = sanitizeHtml(initText, {
allowedAttributes: {
a: ['href', 'target'],
button: ['class'],
div: ['class'],
h1: ['toc'],
h2: ['toc'],
h3: ['toc'],
h4: ['toc'],
h5: ['toc'],
h6: ['toc'],
li: ['class'],
ol: ['class'],
span: ['*'],
table: ['class'],
th: ['colspan'],
thead: ['class'],
ul: ['class'],
img: ['src', 'alt'],
},
allowedTags: [
'span',
'code',
'h1',
'h2',
'h3',
'h4',
'h5',
'p',
'b',
'i',
'em',
'strong',
'a',
'id',
'table',
'tr',
'td',
'tbody',
'th',
'thead',
'button',
'div',
'img',
'image',
'ol',
'ul',
'li',
'svg',
'g',
'br',
'hr',
'summary',
'details',
'strong',
],
});
return { __html: cleanText };
};
/**
* Find all math identified elements of the form [id^=\"mathjax-\"]
* (e.g. <dom element id="mathjax-10"> text </dom element>)
* and transform them to their math markedown equivalents
*/
MarkdownSynapse.prototype.processMath = function () {
if (!this.markupRef.current) {
return;
}
// use regex to grab all elements
var mathExpressions = this.markupRef.current.querySelectorAll('[id^="mathjax-"]');
// go through all obtained elements and transform them with katex
var regEx = new RegExp(/\\[()[\]]/, 'g'); // Look for a '\' followed by either '(', ')', '[', or ']'. We delete these strings since they interfere with katex processing.
mathExpressions.forEach(function (element) {
if (element.textContent && !element.getAttribute('processed')) {
// only process a math element once, used to double/triple process
element.setAttribute('processed', 'true');
var textContent = element.textContent.replace(regEx, '');
return katex.render(textContent, element, {
// @ts-ignore
output: 'html',
throwOnError: false,
});
}
});
};
/**
* Process all the corresponding bookmark tags of the references made throughout the page
*
* @memberof MarkdownSynapse
*/
MarkdownSynapse.prototype.addBookmarks = function () {
markdownitSynapse.resetFootnotes();
this.createHTML(this.state.data.markdown);
var footnotesHtml = this.createHTML(markdownitSynapse.footnotes()).__html;
if (footnotesHtml.length > 0) {
return React.createElement(Bookmarks_1.default, { footnotes: footnotesHtml });
}
return;
};
/**
* Get wiki page markdown and file attachment handles
*/
MarkdownSynapse.prototype.getWikiPageMarkdown = function () {
return (0, tslib_1.__awaiter)(this, void 0, void 0, function () {
var _a, ownerId, wikiId, objectType, wikiPage, fileHandles, fileHandlesErr_1, err_1;
return (0, tslib_1.__generator)(this, function (_b) {
switch (_b.label) {
case 0:
_a = this.props, ownerId = _a.ownerId, wikiId = _a.wikiId, objectType = _a.objectType;
if (!ownerId && !wikiId) {
return [2 /*return*/];
}
_b.label = 1;
case 1:
_b.trys.push([1, 7, , 8]);
return [4 /*yield*/, utils_1.SynapseClient.getEntityWiki(this.context.accessToken, ownerId, wikiId, objectType)];
case 2:
wikiPage = _b.sent();
_b.label = 3;
case 3:
_b.trys.push([3, 5, , 6]);
return [4 /*yield*/, this.getWikiAttachments(wikiId ? wikiId : wikiPage.id)];
case 4:
fileHandles = _b.sent();
this.setState({
data: wikiPage,
fileHandles: fileHandles,
error: undefined,
});
return [3 /*break*/, 6];
case 5:
fileHandlesErr_1 = _b.sent();
console.error('fileHandlesErr = ', fileHandlesErr_1);
return [3 /*break*/, 6];
case 6: return [3 /*break*/, 8];
case 7:
err_1 = _b.sent();
console.error('Error on wiki markdown load\n', err_1);
this.setState({
error: err_1,
});
return [3 /*break*/, 8];
case 8: return [2 /*return*/];
}
});
});
};
MarkdownSynapse.prototype.getWikiAttachments = function (wikiId) {
return (0, tslib_1.__awaiter)(this, void 0, void 0, function () {
var _a, ownerId, objectType;
return (0, tslib_1.__generator)(this, function (_b) {
switch (_b.label) {
case 0:
_a = this.props, ownerId = _a.ownerId, objectType = _a.objectType;
if (!ownerId) {
console.error('Cannot get wiki attachments without ownerId on Markdown Component');
return [2 /*return*/, undefined];
}
return [4 /*yield*/, utils_1.SynapseClient.getWikiAttachmentsFromEntity(this.context.accessToken, ownerId, wikiId, objectType)
.then(function (data) {
return data;
})
.catch(function (err) {
console.error('Error on wiki attachment load ', err);
return undefined;
})];
case 1: return [2 /*return*/, _b.sent()];
}
});
});
};
MarkdownSynapse.prototype.addIdsToReferenceWidgets = function (text) {
var referenceRegex = /<span id="wikiReference.*?<span data-widgetparams.*?span>/g;
var referenceCount = 1;
return text.replace(referenceRegex, function () {
// replace all reference tags with id's of the form id="ref<number>"" that we can read onClick
var current = referenceCount;
referenceCount += 1;
return "<a href=\"\" id=\"ref" + current + "\">[" + current + "]</a>";
});
};
MarkdownSynapse.prototype.addIdsToTocWidgets = function (text) {
var tocId = 'SRC-header-';
var tocIdCount = 1;
var TOC_HEADER_REGEX = /<h[1-6] toc="true">.*<\/h[1-6]>/gm;
return text.replace(TOC_HEADER_REGEX, function (match) {
// replace with id of the form id="toc" so we can read them with onclick events
var curTocId = tocIdCount;
tocIdCount += 1;
var matchWithId = match.substring(0, 3) + " id=\"" + tocId + curTocId + "\"" + match.substring(3);
return matchWithId;
});
};
/**
* The 'main' method of this class that process all the markdown and transforms it to the appropriate
* Synapse widgets.
*
* @returns JSX of the markdown into widgets
* @memberof MarkdownSynapse
*/
MarkdownSynapse.prototype.renderMarkdown = function () {
// create initial markup
var markup = this.createHTML(this.state.data.markdown).__html;
// process reference widgets
markup = this.addIdsToReferenceWidgets(markup);
// process table of contents widgets
markup = this.addIdsToTocWidgets(markup);
if (markup.length > 0) {
var domParser = new DOMParser();
var document_1 = domParser.parseFromString(markup, 'text/html');
return React.createElement(React.Fragment, null, this.recursiveRender(document_1.body, markup));
}
return;
};
/**
* recursiveRender will render react tree from HTML tree
*
* @param {Node} element This will be either a text Node or an HTMLElement
* @param {string} markdown The original markdown, its kept as a special case for the table of contents widget
* @returns {*}
* @memberof MarkdownSynapse
*/
MarkdownSynapse.prototype.recursiveRender = function (element, markdown) {
var _this = this;
/*
Recursively render the html tree created from the markdown, there are a few cases:
1. element is Node and is text in which case it is simply rendered
2. element is an HTMLElement and is: a self closing tag, has no children (e.g. <br>), or its a synapse widget and is
rendered accordingly
3. element is an HTMLElement and has children so we loop through its childNodes, recurively render those, and then render its own tag
as the parent of those child nodes. Note - childNodes was specifically chosen over .children because text Nodes
would not come through .children
*/
if (element.nodeType === Node.TEXT_NODE) {
// case 1.
return React.createElement(React.Fragment, null,
" ",
element.textContent,
" ");
}
else if (element.nodeType === Node.ELEMENT_NODE &&
element instanceof HTMLElement) {
var tagName = element.tagName.toLowerCase() === 'body'
? 'span'
: element.tagName.toLowerCase();
var widgetParams = element.getAttribute('data-widgetparams');
if (widgetParams) {
// case 2
// process widget
return this.processHTMLWidgetMapping(widgetParams, markdown);
}
// manually add on props, depending on what comes through the markdown their could
// be unforseen issues with attributes being misnamed according to what react will respect
// e.g. class instead of className
var attributes = element.attributes;
var props = {};
for (var i = 0; i < attributes.length; i++) {
var name_1 = '';
var value = '';
var attribute = attributes.item(i);
if (attribute) {
name_1 = attribute.name;
value = attribute.value;
}
if (name_1 && value) {
props[name_1] = value;
}
}
if (element.childNodes.length === 0) {
// case 2
// e.g. self closing tag like <br/> or <img>
return React.createElement(tagName, props);
}
// case 3
// recursively render children
var children = Array.from(element.childNodes).map(function (el, index) {
return (React.createElement(React.Fragment, { key: index }, _this.recursiveRender(el, markdown)));
});
// Render tagName as parent element of the children below
return React.createElement(tagName, props, React.createElement(React.Fragment, null, children));
}
};
/**
* When the markdown string is transfered over the network certain characters get transformed,
* this does a simple transformation back to the original user's string.
*
* @param {string} xml
* @returns
* @memberof MarkdownSynapse
*/
MarkdownSynapse.prototype.decodeXml = function (xml) {
var escapedOneToXmlSpecialMap = {
'&': '&',
'>': '>',
'<': '<',
'"': '"',
};
return xml.replace(/("|<|>|&)/g, function (str, item) {
return escapedOneToXmlSpecialMap[item];
});
};
/**
* Given widgetMap renders it in a React component (or originalMarkup in special cases.)
*
* @param {string} widgetMatch The synapse widget to be rendered
* @param {string} originalMarkup The original markup text, this is a special case for widgets that
* are html specific.
* @returns JSX of the widget to render
* @memberof MarkdownSynapse
*/
MarkdownSynapse.prototype.processHTMLWidgetMapping = function (widgetParams, originalMarkup) {
// General workflow -
// 1. Capture widget parameters
// 2. Transform any widget xml parameters to standard text
// 3. Split those parameters into a map
// 4. Render that widget based on its parameters
// steps 1,2
var decodedWidgetParams = this.decodeXml(widgetParams);
// decodedWidgetParams look like {<widget>?param1=xxx¶m2=yyy}
var questionIndex = decodedWidgetParams.indexOf('?');
if (questionIndex === -1) {
// e.g. toc is passed, there are no params
return this.renderWidget(decodedWidgetParams, {}, originalMarkup);
}
var widgetType = decodedWidgetParams.substring(0, questionIndex);
var widgetparamsMapped = {};
// map out params and their values
decodedWidgetParams
.substring(questionIndex + 1)
.split('&')
.forEach(function (keyPair) {
var _a = keyPair.split('='), key = _a[0], value = _a[1];
value = decodeURIComponent(value);
widgetparamsMapped[key] = value;
});
return this.renderWidget(widgetType, widgetparamsMapped, originalMarkup);
};
/**
* Given widgetType renders the apppropriate widget
*
* @param {string} widgetType The type of synapse widget. (e.g. 'image', 'plot')
* @param {*} widgetparamsMapped The parameters for this widget
* @param {string} originalMarkup The original markup.
* @returns
* @memberof MarkdownSynapse
*/
MarkdownSynapse.prototype.renderWidget = function (widgetType, widgetparamsMapped, originalMarkup) {
// we make keys out of the widget params
var key = JSON.stringify(widgetparamsMapped);
widgetparamsMapped.reactKey = key;
switch (widgetType) {
case 'buttonlink':
return this.renderSynapseButton(widgetparamsMapped);
case 'image':
return this.renderSynapseImage(widgetparamsMapped);
case 'plot':
return this.renderSynapsePlot(widgetparamsMapped);
case 'toc':
return this.renderSynapseTOC(originalMarkup);
case 'badge':
return this.renderUserBadge(widgetparamsMapped);
case 'video':
case 'vimeo':
case 'youtube':
return this.renderVideo(widgetparamsMapped);
default:
return;
}
};
MarkdownSynapse.prototype.renderSynapseButton = function (widgetparamsMapped) {
var buttonClasses = 'pill-xl ';
var _a = widgetparamsMapped.align, align = _a === void 0 ? '' : _a, _b = widgetparamsMapped.highlight, highlight = _b === void 0 ? '' : _b;
var alignLowerCase = align.toLowerCase();
if (alignLowerCase === 'left') {
buttonClasses += 'floatLeft ';
}
if (alignLowerCase === 'right') {
buttonClasses += 'floatright ';
}
var buttonVariant = highlight === 'true' ? 'secondary' : 'light-secondary';
if (alignLowerCase === 'center') {
return (React.createElement("div", { key: widgetparamsMapped.reactKey, className: "bootstrap-4-backport", style: { textAlign: 'center' } },
React.createElement(react_bootstrap_1.Button, { href: widgetparamsMapped.url, className: buttonClasses, variant: buttonVariant }, widgetparamsMapped.text)));
}
return (React.createElement("span", { className: "bootstrap-4-backport" },
React.createElement(react_bootstrap_1.Button, { href: widgetparamsMapped.url, className: buttonClasses, variant: buttonVariant }, widgetparamsMapped.text)));
};
MarkdownSynapse.prototype.renderSynapsePlot = function (widgetparamsMapped) {
return (React.createElement(SynapsePlot_1.default, { key: widgetparamsMapped.reactKey, ownerId: this.props.ownerId, wikiId: this.props.wikiId || this.state.data.id, widgetparamsMapped: widgetparamsMapped }));
};
MarkdownSynapse.prototype.renderVideo = function (widgetparamsMapped) {
return React.createElement(SynapseVideo_1.default, { params: widgetparamsMapped });
};
MarkdownSynapse.prototype.renderSynapseImage = function (widgetparamsMapped) {
var reactKey = widgetparamsMapped.reactKey;
if (widgetparamsMapped.fileName) {
if (!this.state.fileHandles) {
// ensure files are loaded
return;
}
// if file name is attached then the fileHandle ID is located
// in this wiki's file attachment list
return (React.createElement(SynapseImage_1.default, { params: widgetparamsMapped, key: reactKey, fileName: widgetparamsMapped.fileName, wikiId: this.props.wikiId || this.state.data.id, fileResults: this.state.fileHandles.list }));
}
if (widgetparamsMapped.synapseId) {
// otherwise this image's fileHandle ID is not located
// in the file attachment list and will be loaded first
return (React.createElement(SynapseImage_1.default, { params: widgetparamsMapped, key: reactKey, synapseId: widgetparamsMapped.synapseId }));
}
return;
};
MarkdownSynapse.prototype.renderSynapseTOC = function (originalMarkup) {
var elements = [];
var TOC_HEADER_REGEX_WITH_ID = /<h([1-6]) id="(.*)" .*toc="true">(.*)<\/h[1-6]>/gm;
var text = '';
originalMarkup.replace(TOC_HEADER_REGEX_WITH_ID, function (p1, p2, p3, p4) {
text += p4;
elements.push(React.createElement("div", { key: p4 },
React.createElement("a", { className: "link " + TOC_CLASS[Number(p2)], "data-anchor": p3 }, p4)));
return '';
});
return React.createElement("div", { key: text }, elements);
};
MarkdownSynapse.prototype.renderUserBadge = function (widgetparamsMapped) {
return (React.createElement(UserCard_1.default, { key: JSON.stringify(widgetparamsMapped), size: utils_1.SynapseConstants.SMALL_USER_CARD, alias: widgetparamsMapped.alias }));
};
MarkdownSynapse.prototype.componentDidMount = function () {
return (0, tslib_1.__awaiter)(this, void 0, void 0, function () {
return (0, tslib_1.__generator)(this, function (_a) {
switch (_a.label) {
case 0:
if (this.state.data.markdown) {
this.setState({ isLoading: false });
return [2 /*return*/];
}
// we use this.markupRef.current && because in testing environment refs aren't defined
// @ts-ignore
this.markupRef.current &&
// @ts-ignore
this.markupRef.current.addEventListener('click', this.handleLinkClicks);
// unpack and set default value if not specified
// get wiki attachments
return [4 /*yield*/, this.getWikiPageMarkdown()];
case 1:
// unpack and set default value if not specified
// get wiki attachments
_a.sent();
this.processMath();
this.setState({ isLoading: false });
return [2 /*return*/];
}
});
});
};
// on component update find and re-render the math/widget items accordingly
MarkdownSynapse.prototype.componentDidUpdate = function (prevProps) {
return (0, tslib_1.__awaiter)(this, void 0, void 0, function () {
var shouldUpdate;
return (0, tslib_1.__generator)(this, function (_a) {
switch (_a.label) {
case 0:
shouldUpdate = this.props.ownerId !== prevProps.ownerId;
shouldUpdate = shouldUpdate || this.props.wikiId !== prevProps.wikiId;
if (!shouldUpdate) return [3 /*break*/, 2];
return [4 /*yield*/, this.getWikiPageMarkdown()];
case 1:
_a.sent();
_a.label = 2;
case 2:
this.processMath();
return [2 /*return*/];
}
});
});
};
MarkdownSynapse.prototype.render = function () {
var renderInline = this.props.renderInline;
var _a = this.state, isLoading = _a.isLoading, error = _a.error;
if (error) {
return React.createElement(ErrorBanner_1.ErrorBanner, { error: error });
}
var bookmarks = this.addBookmarks();
var content = (React.createElement(React.Fragment, null,
isLoading && React.createElement("span", { className: "spinner" }),
this.renderMarkdown(),
bookmarks && React.createElement("div", null, this.addBookmarks())));
if (renderInline) {
return (React.createElement("span", { className: "markdown markdown-inline", ref: this.markupRef }, content));
}
return (React.createElement("div", { className: "markdown", ref: this.markupRef }, content));
};
MarkdownSynapse.contextType = SynapseContext_1.SynapseContext;
return MarkdownSynapse;
}(React.Component));
exports.default = MarkdownSynapse;
//# sourceMappingURL=MarkdownSynapse.js.map