You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-11-29 22:48:10 +02:00
More refactoring to easily handle multiple renderers
This commit is contained in:
43
ReactNativeClient/lib/renderers/HtmlToHtml.js
Normal file
43
ReactNativeClient/lib/renderers/HtmlToHtml.js
Normal file
@@ -0,0 +1,43 @@
|
||||
const Resource = require('lib/models/Resource');
|
||||
const htmlUtils = require('lib/htmlUtils');
|
||||
const utils = require('./utils');
|
||||
const jsdom = require("jsdom");
|
||||
const { JSDOM } = jsdom;
|
||||
|
||||
class HtmlToHtml {
|
||||
|
||||
constructor(options) {
|
||||
this.resourceBaseUrl_ = 'resourceBaseUrl' in options ? options.resourceBaseUrl : null;
|
||||
}
|
||||
|
||||
render(markup, theme, options) {
|
||||
const dom = new JSDOM(markup);
|
||||
|
||||
// Replace all the image resource IDs by path to local files
|
||||
const imgs = dom.window.document.getElementsByTagName('img');
|
||||
for (const img of imgs) {
|
||||
if (!img.src) continue;
|
||||
const r = utils.imageReplacement(img.src, options.resources, this.resourceBaseUrl_);
|
||||
if (!r) continue;
|
||||
|
||||
if (typeof r === 'string') {
|
||||
img.outerHTML = r;
|
||||
} else {
|
||||
for (const n in r) {
|
||||
img.setAttribute(n, r[n]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// We need this extra style so that the images don't overflow
|
||||
const extraStyle = '<style>img {max-width: 100%;height: auto;}</style>'
|
||||
|
||||
return {
|
||||
html: extraStyle + htmlUtils.headAndBodyHtml(dom.window.document),
|
||||
cssFiles: [],
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = HtmlToHtml;
|
||||
35
ReactNativeClient/lib/renderers/MarkupToHtml.js
Normal file
35
ReactNativeClient/lib/renderers/MarkupToHtml.js
Normal file
@@ -0,0 +1,35 @@
|
||||
const MdToHtml = require('./MdToHtml');
|
||||
const HtmlToHtml = require('./HtmlToHtml');
|
||||
const Note = require('lib/models/Note');
|
||||
|
||||
class MarkupToHtml {
|
||||
|
||||
constructor(options) {
|
||||
this.options_ = options;
|
||||
this.renderers_ = {};
|
||||
}
|
||||
|
||||
renderer(markupLanguage) {
|
||||
if (this.renderers_[markupLanguage]) return this.renderers_[markupLanguage];
|
||||
|
||||
let RendererClass = null;
|
||||
|
||||
if (markupLanguage === Note.MARKUP_LANGUAGE_MARKDOWN) {
|
||||
RendererClass = MdToHtml;
|
||||
} else if (markupLanguage === Note.MARKUP_LANGUAGE_HTML) {
|
||||
RendererClass = HtmlToHtml;
|
||||
} else {
|
||||
throw new Error('Invalid markup language: ' + markupLanguage);
|
||||
}
|
||||
|
||||
this.renderers_[markupLanguage] = new RendererClass(this.options_);
|
||||
return this.renderers_[markupLanguage];
|
||||
}
|
||||
|
||||
render(markupLanguage, markup, theme, options) {
|
||||
return this.renderer(markupLanguage).render(markup, theme, options);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = MarkupToHtml;
|
||||
197
ReactNativeClient/lib/renderers/MdToHtml.js
Normal file
197
ReactNativeClient/lib/renderers/MdToHtml.js
Normal file
@@ -0,0 +1,197 @@
|
||||
const MarkdownIt = require('markdown-it');
|
||||
const Entities = require('html-entities').AllHtmlEntities;
|
||||
const htmlentities = (new Entities()).encode;
|
||||
const Resource = require('lib/models/Resource.js');
|
||||
const { shim } = require('lib/shim.js');
|
||||
const { _ } = require('lib/locale');
|
||||
const md5 = require('md5');
|
||||
const StringUtils = require('lib/string-utils.js');
|
||||
const noteStyle = require('./noteStyle');
|
||||
const Setting = require('lib/models/Setting.js');
|
||||
const rules = {
|
||||
image: require('./MdToHtml/rules/image'),
|
||||
checkbox: require('./MdToHtml/rules/checkbox'),
|
||||
katex: require('./MdToHtml/rules/katex'),
|
||||
link_open: require('./MdToHtml/rules/link_open'),
|
||||
html_image: require('./MdToHtml/rules/html_image'),
|
||||
highlight_keywords: require('./MdToHtml/rules/highlight_keywords'),
|
||||
code_inline: require('./MdToHtml/rules/code_inline'),
|
||||
};
|
||||
const setupLinkify = require('./MdToHtml/setupLinkify');
|
||||
const hljs = require('highlight.js');
|
||||
const markdownItAnchor = require('markdown-it-anchor');
|
||||
// The keys must match the corresponding entry in Setting.js
|
||||
const plugins = {
|
||||
mark: {module: require('markdown-it-mark')},
|
||||
footnote: {module: require('markdown-it-footnote')},
|
||||
sub: {module: require('markdown-it-sub')},
|
||||
sup: {module: require('markdown-it-sup')},
|
||||
deflist: {module: require('markdown-it-deflist')},
|
||||
abbr: {module: require('markdown-it-abbr')},
|
||||
emoji: {module: require('markdown-it-emoji')},
|
||||
insert: {module: require('markdown-it-ins')},
|
||||
multitable: {module: require('markdown-it-multimd-table'), options: { enableMultilineRows: true, enableRowspan: true }},
|
||||
toc: {module: require('markdown-it-toc-done-right'), options: { listType: 'ul' }},
|
||||
};
|
||||
|
||||
class MdToHtml {
|
||||
|
||||
constructor(options = null) {
|
||||
if (!options) options = {};
|
||||
|
||||
// Must include last "/"
|
||||
this.resourceBaseUrl_ = ('resourceBaseUrl' in options) ? options.resourceBaseUrl : null;
|
||||
|
||||
this.cachedOutputs_ = {};
|
||||
|
||||
this.lastCodeHighlightCacheKey_ = null;
|
||||
this.cachedHighlightedCode_ = {};
|
||||
}
|
||||
|
||||
render(body, style, options = null) {
|
||||
if (!options) options = {};
|
||||
if (!options.postMessageSyntax) options.postMessageSyntax = 'postMessage';
|
||||
if (!options.paddingBottom) options.paddingBottom = '0';
|
||||
if (!options.highlightedKeywords) options.highlightedKeywords = [];
|
||||
|
||||
// The "codeHighlightCacheKey" option indicates what set of cached object should be
|
||||
// associated with this particular Markdown body. It is only used to allow us to
|
||||
// clear the cache whenever switching to a different note.
|
||||
// If "codeHighlightCacheKey" is not specified, code highlighting won't be cached.
|
||||
if (options.codeHighlightCacheKey !== this.lastCodeHighlightCacheKey_ || !options.codeHighlightCacheKey) {
|
||||
this.cachedHighlightedCode_ = {};
|
||||
this.lastCodeHighlightCacheKey_ = options.codeHighlightCacheKey;
|
||||
}
|
||||
|
||||
const breaks_ = Setting.value('markdown.softbreaks') ? false : true;
|
||||
|
||||
const cacheKey = md5(escape(body + JSON.stringify(options) + JSON.stringify(style)));
|
||||
const cachedOutput = this.cachedOutputs_[cacheKey];
|
||||
if (cachedOutput) return cachedOutput;
|
||||
|
||||
const context = {
|
||||
css: {},
|
||||
cssFiles: {},
|
||||
assetLoaders: {},
|
||||
};
|
||||
|
||||
const markdownIt = new MarkdownIt({
|
||||
breaks: breaks_,
|
||||
linkify: true,
|
||||
html: true,
|
||||
highlight: (str, lang) => {
|
||||
try {
|
||||
let hlCode = '';
|
||||
|
||||
const cacheKey = md5(str + '_' + lang);
|
||||
|
||||
if (options.codeHighlightCacheKey && this.cachedHighlightedCode_[cacheKey]) {
|
||||
hlCode = this.cachedHighlightedCode_[cacheKey];
|
||||
} else {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
hlCode = hljs.highlight(lang, str, true).value;
|
||||
} else {
|
||||
hlCode = hljs.highlightAuto(str).value;
|
||||
}
|
||||
this.cachedHighlightedCode_[cacheKey] = hlCode;
|
||||
}
|
||||
|
||||
if (shim.isReactNative()) {
|
||||
context.css['hljs'] = shim.loadCssFromJs(options.codeTheme);
|
||||
} else {
|
||||
context.cssFiles['hljs'] = 'highlight/styles/' + options.codeTheme;
|
||||
}
|
||||
|
||||
return '<pre class="hljs"><code>' + hlCode + '</code></pre>';
|
||||
} catch (error) {
|
||||
return '<pre class="hljs"><code>' + markdownIt.utils.escapeHtml(str) + '</code></pre>';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const ruleOptions = Object.assign({}, options, { resourceBaseUrl: this.resourceBaseUrl_ });
|
||||
|
||||
// To add a plugin, there are three options:
|
||||
//
|
||||
// 1. If the plugin does not need any application specific data, use the standard way:
|
||||
//
|
||||
// const someMarkdownPlugin = require('someMarkdownPlugin');
|
||||
// markdownIt.use(someMarkdownPlugin);
|
||||
//
|
||||
// 2. If the plugin does not need any application specific data, and you want the user
|
||||
// to be able to toggle the plugin:
|
||||
//
|
||||
// Add the plugin to the plugins object
|
||||
// const plugins = {
|
||||
// plugin: require('someMarkdownPlugin'),
|
||||
// }
|
||||
//
|
||||
// And add a corresponding entry into Setting.js
|
||||
// 'markdown.plugin.mark': {value: true, type: Setting.TYPE_BOOL, section: 'plugins', public: true, appTypes: ['mobile', 'desktop'], label: () => _('Enable ==mark== syntax')},
|
||||
//
|
||||
// 3. If the plugin needs application data (in ruleOptions) or needs to pass data (CSS, files to load, etc.) back
|
||||
// to the application (using the context object), use the application-specific way:
|
||||
//
|
||||
// const imagePlugin = require('./MdToHtml/rules/image');
|
||||
// markdownIt.use(imagePlugin(context, ruleOptions));
|
||||
//
|
||||
// Using the `context` object, a plugin can send back either CSS strings (in .css) or CSS files that need
|
||||
// to be loaded (in .cssFiles). In general, the desktop app will load the CSS files and the mobile app
|
||||
// will load the CSS strings.
|
||||
|
||||
markdownIt.use(rules.image(context, ruleOptions));
|
||||
markdownIt.use(rules.checkbox(context, ruleOptions));
|
||||
markdownIt.use(rules.link_open(context, ruleOptions));
|
||||
markdownIt.use(rules.html_image(context, ruleOptions));
|
||||
if (Setting.value('markdown.plugin.katex')) markdownIt.use(rules.katex(context, ruleOptions));
|
||||
markdownIt.use(rules.highlight_keywords(context, ruleOptions));
|
||||
markdownIt.use(rules.code_inline(context, ruleOptions));
|
||||
markdownIt.use(markdownItAnchor)
|
||||
|
||||
for (let key in plugins) {
|
||||
if (Setting.value('markdown.plugin.' + key)) markdownIt.use(plugins[key].module, plugins[key].options);
|
||||
}
|
||||
|
||||
setupLinkify(markdownIt);
|
||||
|
||||
const renderedBody = markdownIt.render(body);
|
||||
|
||||
const cssStrings = noteStyle(style, options);
|
||||
|
||||
for (let k in context.css) {
|
||||
if (!context.css.hasOwnProperty(k)) continue;
|
||||
cssStrings.push(context.css[k]);
|
||||
}
|
||||
|
||||
for (let k in context.assetLoaders) {
|
||||
if (!context.assetLoaders.hasOwnProperty(k)) continue;
|
||||
context.assetLoaders[k]().catch(error => {
|
||||
console.warn('MdToHtml: Error loading assets for ' + k + ': ', error.message);
|
||||
});
|
||||
}
|
||||
|
||||
if (options.userCss) cssStrings.push(options.userCss);
|
||||
|
||||
const styleHtml = '<style>' + cssStrings.join('\n') + '</style>';
|
||||
|
||||
const html = styleHtml + '<div id="rendered-md">' + renderedBody + '</div>';
|
||||
|
||||
const output = {
|
||||
html: html,
|
||||
cssFiles: Object.keys(context.cssFiles).map(k => context.cssFiles[k]),
|
||||
};
|
||||
|
||||
// Fow now, we keep only the last entry in the cache
|
||||
this.cachedOutputs_ = {};
|
||||
this.cachedOutputs_[cacheKey] = output;
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
injectedJavaScript() {
|
||||
return '';
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = MdToHtml;
|
||||
138
ReactNativeClient/lib/renderers/MdToHtml/rules/checkbox.js
Normal file
138
ReactNativeClient/lib/renderers/MdToHtml/rules/checkbox.js
Normal file
@@ -0,0 +1,138 @@
|
||||
const Entities = require('html-entities').AllHtmlEntities;
|
||||
const htmlentities = (new Entities()).encode;
|
||||
const Resource = require('lib/models/Resource.js');
|
||||
const utils = require('../../utils');
|
||||
|
||||
let checkboxIndex_ = -1;
|
||||
|
||||
const checkboxStyle = `
|
||||
/* Remove the indentation from the checkboxes at the root of the document
|
||||
(otherwise they are too far right), but keep it for their children to allow
|
||||
nested lists. Make sure this value matches the UL margin. */
|
||||
|
||||
#rendered-md > ul > li.md-checkbox {
|
||||
margin-left: -1.7em;
|
||||
}
|
||||
|
||||
li.md-checkbox {
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
li.md-checkbox input[type=checkbox] {
|
||||
margin-right: 1em;
|
||||
}
|
||||
`;
|
||||
|
||||
function createPrefixTokens(Token, id, checked, label, postMessageSyntax, sourceToken) {
|
||||
let token = null;
|
||||
const tokens = [];
|
||||
|
||||
// A bit hard to handle errors here and it's unlikely that the token won't have a valid
|
||||
// map parameter, but if it does set it to a very high value, which will be more easy to notice
|
||||
// in calling code.
|
||||
const lineIndex = sourceToken.map && sourceToken.map.length ? sourceToken.map[0] : 99999999;
|
||||
const checkedString = checked ? 'checked' : 'unchecked';
|
||||
|
||||
const labelId = 'cb-label-' + id;
|
||||
|
||||
const js = `
|
||||
${postMessageSyntax}('checkboxclick:${checkedString}:${lineIndex}');
|
||||
const label = document.getElementById("${labelId}");
|
||||
label.classList.remove(this.checked ? 'checkbox-label-unchecked' : 'checkbox-label-checked');
|
||||
label.classList.add(this.checked ? 'checkbox-label-checked' : 'checkbox-label-unchecked');
|
||||
return true;
|
||||
`;
|
||||
|
||||
token = new Token('checkbox_input', 'input', 0);
|
||||
token.attrs = [
|
||||
['type', 'checkbox'],
|
||||
['id', id],
|
||||
['onclick', js],
|
||||
];
|
||||
if (checked) token.attrs.push(['checked', 'true']);
|
||||
tokens.push(token);
|
||||
|
||||
token = new Token('label_open', 'label', 1);
|
||||
token.attrs = [
|
||||
['id', labelId],
|
||||
['for', id],
|
||||
['class', 'checkbox-label-' + checkedString],
|
||||
];
|
||||
tokens.push(token);
|
||||
|
||||
if (label) {
|
||||
token = new Token('text', '', 0);
|
||||
token.content = label;
|
||||
tokens.push(token);
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function createSuffixTokens(Token) {
|
||||
return [new Token('label_close', 'label', -1)];
|
||||
}
|
||||
|
||||
function installRule(markdownIt, mdOptions, ruleOptions, context) {
|
||||
markdownIt.core.ruler.push('checkbox', state => {
|
||||
const tokens = state.tokens;
|
||||
const Token = state.Token;
|
||||
|
||||
const checkboxPattern = /^\[([x|X| ])\] (.*)$/
|
||||
let currentListItem = null;
|
||||
let processedFirstInline = false;
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const token = tokens[i];
|
||||
|
||||
if (token.type === 'list_item_open') {
|
||||
currentListItem = token;
|
||||
processedFirstInline = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.type === 'list_item_close') {
|
||||
currentListItem = null;
|
||||
processedFirstInline = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentListItem && !processedFirstInline && token.type === 'inline') {
|
||||
processedFirstInline = true;
|
||||
const firstChild = token.children && token.children.length ? token.children[0] : null;
|
||||
if (!firstChild) continue;
|
||||
|
||||
const matches = checkboxPattern.exec(firstChild.content);
|
||||
if (!matches || matches.length < 2) continue;
|
||||
|
||||
checkboxIndex_++;
|
||||
const checked = matches[1] !== ' ';
|
||||
const id = 'md-checkbox-' + checkboxIndex_;
|
||||
const label = matches.length >= 3 ? matches[2] : '';
|
||||
|
||||
// Prepend the text content with the checkbox markup and the opening <label> tag
|
||||
// then append the </label> tag at the end of the text content.
|
||||
|
||||
const prefix = createPrefixTokens(Token, id, checked, label, ruleOptions.postMessageSyntax, token);
|
||||
const suffix = createSuffixTokens(Token);
|
||||
|
||||
token.children = markdownIt.utils.arrayReplaceAt(token.children, 0, prefix);
|
||||
token.children = token.children.concat(suffix);
|
||||
|
||||
// Add a class to the <li> container so that it can be targetted with CSS.
|
||||
|
||||
let itemClass = currentListItem.attrGet('class');
|
||||
if (!itemClass) itemClass = '';
|
||||
itemClass += ' md-checkbox';
|
||||
currentListItem.attrSet('class', itemClass.trim());
|
||||
|
||||
context.css['checkbox'] = checkboxStyle;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = function(context, ruleOptions) {
|
||||
return function(md, mdOptions) {
|
||||
installRule(md, mdOptions, ruleOptions, context);
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
function installRule(markdownIt, mdOptions, ruleOptions) {
|
||||
const defaultRender = markdownIt.renderer.rules.code_inline || function(tokens, idx, options, env, self) {
|
||||
return self.renderToken(tokens, idx, options);
|
||||
};
|
||||
|
||||
markdownIt.renderer.rules.code_inline = (tokens, idx, options, env, self) => {
|
||||
const token = tokens[idx];
|
||||
let tokenClass = token.attrGet('class');
|
||||
if (!tokenClass) tokenClass = '';
|
||||
tokenClass += ' inline-code';
|
||||
token.attrSet('class', tokenClass.trim());
|
||||
return defaultRender(tokens, idx, options, env, self);
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = function(context, ruleOptions) {
|
||||
return function(md, mdOptions) {
|
||||
installRule(md, mdOptions, ruleOptions);
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
const Entities = require('html-entities').AllHtmlEntities;
|
||||
const htmlentities = (new Entities()).encode;
|
||||
const Resource = require('lib/models/Resource.js');
|
||||
const utils = require('../../utils');
|
||||
const StringUtils = require('lib/string-utils.js');
|
||||
const md5 = require('md5');
|
||||
|
||||
function createHighlightedTokens(Token, splitted) {
|
||||
let token;
|
||||
const output = [];
|
||||
|
||||
for (let i = 0; i < splitted.length; i++) {
|
||||
const text = splitted[i];
|
||||
if (!text) continue;
|
||||
|
||||
if (i % 2 === 0) {
|
||||
token = new Token('text', '', 0);
|
||||
token.content = text;
|
||||
output.push(token);
|
||||
} else {
|
||||
token = new Token('highlighted_keyword_open', 'span', 1);
|
||||
token.attrs = [['class', 'highlighted-keyword']];
|
||||
output.push(token);
|
||||
|
||||
token = new Token('text', '', 0);
|
||||
token.content = text;
|
||||
output.push(token);
|
||||
|
||||
token = new Token('highlighted_keyword_close', 'span', -1);
|
||||
output.push(token);
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function installRule(markdownIt, mdOptions, ruleOptions) {
|
||||
const divider = md5(Date.now().toString() + Math.random().toString());
|
||||
|
||||
markdownIt.core.ruler.push('highlight_keywords', state => {
|
||||
const keywords = ruleOptions.highlightedKeywords;
|
||||
if (!keywords || !keywords.length) return;
|
||||
|
||||
const tokens = state.tokens;
|
||||
const Token = state.Token;
|
||||
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const token = tokens[i];
|
||||
|
||||
if (token.type !== 'inline') continue;
|
||||
|
||||
for (let j = 0; j < token.children.length; j++) {
|
||||
const child = token.children[j];
|
||||
if (child.type !== 'text') continue;
|
||||
|
||||
const splitted = StringUtils.surroundKeywords(keywords, child.content, divider, divider).split(divider);
|
||||
const splittedTokens = createHighlightedTokens(Token, splitted);
|
||||
if (splittedTokens.length <= 1) continue;
|
||||
|
||||
token.children = markdownIt.utils.arrayReplaceAt(token.children, j, splittedTokens);
|
||||
j += splittedTokens.length - 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = function(context, ruleOptions) {
|
||||
return function(md, mdOptions) {
|
||||
installRule(md, mdOptions, ruleOptions);
|
||||
};
|
||||
};
|
||||
49
ReactNativeClient/lib/renderers/MdToHtml/rules/html_image.js
Normal file
49
ReactNativeClient/lib/renderers/MdToHtml/rules/html_image.js
Normal file
@@ -0,0 +1,49 @@
|
||||
const Entities = require('html-entities').AllHtmlEntities;
|
||||
const htmlentities = (new Entities()).encode;
|
||||
const Resource = require('lib/models/Resource.js');
|
||||
const htmlUtils = require('lib/htmlUtils.js');
|
||||
const utils = require('../../utils');
|
||||
|
||||
function renderImageHtml(before, src, after, ruleOptions) {
|
||||
const r = utils.imageReplacement(src, ruleOptions.resources, ruleOptions.resourceBaseUrl);
|
||||
if (typeof r === 'string') return r;
|
||||
if (r) return '<img ' + before + ' ' + htmlUtils.attributesHtml(r) + ' ' + after + '/>';
|
||||
return '[Image: ' + htmlentities(resource.title) + ' (' + htmlentities(mime) + ')]';
|
||||
}
|
||||
|
||||
function installRule(markdownIt, mdOptions, ruleOptions) {
|
||||
const htmlBlockDefaultRender = markdownIt.renderer.rules.html_block || function(tokens, idx, options, env, self) {
|
||||
return self.renderToken(tokens, idx, options);
|
||||
};
|
||||
|
||||
const htmlInlineDefaultRender = markdownIt.renderer.rules.html_inline || function(tokens, idx, options, env, self) {
|
||||
return self.renderToken(tokens, idx, options);
|
||||
};
|
||||
|
||||
const imageRegex = /<img(.*?)src=["'](.*?)["'](.*?)>/gi
|
||||
|
||||
const handleImageTags = function(defaultRender) {
|
||||
return function(tokens, idx, options, env, self) {
|
||||
const token = tokens[idx];
|
||||
const content = token.content;
|
||||
|
||||
if (!content.match(imageRegex)) return defaultRender(tokens, idx, options, env, self);
|
||||
|
||||
return content.replace(imageRegex, (v, before, src, after) => {
|
||||
if (!Resource.isResourceUrl(src)) return defaultRender(tokens, idx, options, env, self);
|
||||
return renderImageHtml(before, src, after, ruleOptions);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// It seems images sometimes are inline, sometimes a block
|
||||
// to make sure they both render correctly.
|
||||
markdownIt.renderer.rules.html_block = handleImageTags(htmlBlockDefaultRender);
|
||||
markdownIt.renderer.rules.html_inline = handleImageTags(htmlInlineDefaultRender);
|
||||
}
|
||||
|
||||
module.exports = function(context, ruleOptions) {
|
||||
return function(md, mdOptions) {
|
||||
installRule(md, mdOptions, ruleOptions);
|
||||
};
|
||||
};
|
||||
29
ReactNativeClient/lib/renderers/MdToHtml/rules/image.js
Normal file
29
ReactNativeClient/lib/renderers/MdToHtml/rules/image.js
Normal file
@@ -0,0 +1,29 @@
|
||||
const Entities = require('html-entities').AllHtmlEntities;
|
||||
const htmlentities = (new Entities()).encode;
|
||||
const Resource = require('lib/models/Resource.js');
|
||||
const utils = require('../../utils');
|
||||
const htmlUtils = require('lib/htmlUtils.js');
|
||||
|
||||
function installRule(markdownIt, mdOptions, ruleOptions) {
|
||||
const defaultRender = markdownIt.renderer.rules.image;
|
||||
|
||||
markdownIt.renderer.rules.image = (tokens, idx, options, env, self) => {
|
||||
const token = tokens[idx];
|
||||
const src = utils.getAttr(token.attrs, 'src');
|
||||
const title = utils.getAttr(token.attrs, 'title');
|
||||
|
||||
if (!Resource.isResourceUrl(src)) return defaultRender(tokens, idx, options, env, self);
|
||||
|
||||
const r = utils.imageReplacement(src, ruleOptions.resources, ruleOptions.resourceBaseUrl);
|
||||
if (typeof r === 'string') return r;
|
||||
if (r) return '<img data-from-md ' + htmlUtils.attributesHtml(Object.assign({}, r, { title: title })) + '/>';
|
||||
|
||||
return defaultRender(tokens, idx, options, env, self);
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = function(context, ruleOptions) {
|
||||
return function(md, mdOptions) {
|
||||
installRule(md, mdOptions, ruleOptions);
|
||||
};
|
||||
};
|
||||
259
ReactNativeClient/lib/renderers/MdToHtml/rules/katex.js
Normal file
259
ReactNativeClient/lib/renderers/MdToHtml/rules/katex.js
Normal file
@@ -0,0 +1,259 @@
|
||||
// Based on https://github.com/waylonflinn/markdown-it-katex
|
||||
|
||||
'use strict';
|
||||
|
||||
const { shim } = require('lib/shim');
|
||||
const Setting = require('lib/models/Setting');
|
||||
var katex = require('katex');
|
||||
const katexCss = require('lib/csstojs/katex.css.js');
|
||||
const md5 = require('md5');
|
||||
|
||||
// const style = `
|
||||
// /*
|
||||
// This is to fix https://github.com/laurent22/joplin/issues/764
|
||||
// Without this, the tag attached to an equation float at an absolute position of the page,
|
||||
// instead of a position relative to the container.
|
||||
// 2018-03-13: No longer needed??
|
||||
// */
|
||||
|
||||
// /*
|
||||
// .katex-display>.katex>.katex-html {
|
||||
// position: relative;
|
||||
// }
|
||||
// */
|
||||
// `
|
||||
|
||||
// Test if potential opening or closing delimieter
|
||||
// Assumes that there is a "$" at state.src[pos]
|
||||
function isValidDelim(state, pos) {
|
||||
var prevChar, nextChar,
|
||||
max = state.posMax,
|
||||
can_open = true,
|
||||
can_close = true;
|
||||
|
||||
prevChar = pos > 0 ? state.src.charCodeAt(pos - 1) : -1;
|
||||
nextChar = pos + 1 <= max ? state.src.charCodeAt(pos + 1) : -1;
|
||||
|
||||
// Check non-whitespace conditions for opening and closing, and
|
||||
// check that closing delimeter isn't followed by a number
|
||||
if (prevChar === 0x20/* " " */ || prevChar === 0x09/* \t */ ||
|
||||
(nextChar >= 0x30/* "0" */ && nextChar <= 0x39/* "9" */)) {
|
||||
can_close = false;
|
||||
}
|
||||
if (nextChar === 0x20/* " " */ || nextChar === 0x09/* \t */) {
|
||||
can_open = false;
|
||||
}
|
||||
|
||||
return {
|
||||
can_open: can_open,
|
||||
can_close: can_close
|
||||
};
|
||||
}
|
||||
|
||||
function math_inline(state, silent) {
|
||||
var start, match, token, res, pos, esc_count;
|
||||
|
||||
if (state.src[state.pos] !== "$") { return false; }
|
||||
|
||||
res = isValidDelim(state, state.pos);
|
||||
if (!res.can_open) {
|
||||
if (!silent) { state.pending += "$"; }
|
||||
state.pos += 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
// First check for and bypass all properly escaped delimieters
|
||||
// This loop will assume that the first leading backtick can not
|
||||
// be the first character in state.src, which is known since
|
||||
// we have found an opening delimieter already.
|
||||
start = state.pos + 1;
|
||||
match = start;
|
||||
while ( (match = state.src.indexOf("$", match)) !== -1) {
|
||||
// Found potential $, look for escapes, pos will point to
|
||||
// first non escape when complete
|
||||
pos = match - 1;
|
||||
while (state.src[pos] === "\\") { pos -= 1; }
|
||||
|
||||
// Even number of escapes, potential closing delimiter found
|
||||
if ( ((match - pos) % 2) == 1 ) { break; }
|
||||
match += 1;
|
||||
}
|
||||
|
||||
// No closing delimter found. Consume $ and continue.
|
||||
if (match === -1) {
|
||||
if (!silent) { state.pending += "$"; }
|
||||
state.pos = start;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if we have empty content, ie: $$. Do not parse.
|
||||
if (match - start === 0) {
|
||||
if (!silent) { state.pending += "$$"; }
|
||||
state.pos = start + 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for valid closing delimiter
|
||||
res = isValidDelim(state, match);
|
||||
if (!res.can_close) {
|
||||
if (!silent) { state.pending += "$"; }
|
||||
state.pos = start;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
token = state.push('math_inline', 'math', 0);
|
||||
token.markup = "$";
|
||||
token.content = state.src.slice(start, match);
|
||||
}
|
||||
|
||||
state.pos = match + 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
function math_block(state, start, end, silent){
|
||||
var firstLine, lastLine, next, lastPos, found = false, token,
|
||||
pos = state.bMarks[start] + state.tShift[start],
|
||||
max = state.eMarks[start]
|
||||
|
||||
if(pos + 2 > max){ return false; }
|
||||
if(state.src.slice(pos,pos+2)!=='$$'){ return false; }
|
||||
|
||||
pos += 2;
|
||||
firstLine = state.src.slice(pos,max);
|
||||
|
||||
if(silent){ return true; }
|
||||
if(firstLine.trim().slice(-2)==='$$'){
|
||||
// Single line expression
|
||||
firstLine = firstLine.trim().slice(0, -2);
|
||||
found = true;
|
||||
}
|
||||
|
||||
for(next = start; !found; ){
|
||||
|
||||
next++;
|
||||
|
||||
if(next >= end){ break; }
|
||||
|
||||
pos = state.bMarks[next]+state.tShift[next];
|
||||
max = state.eMarks[next];
|
||||
|
||||
if(pos < max && state.tShift[next] < state.blkIndent){
|
||||
// non-empty line with negative indent should stop the list:
|
||||
break;
|
||||
}
|
||||
|
||||
if(state.src.slice(pos,max).trim().slice(-2)==='$$'){
|
||||
lastPos = state.src.slice(0,max).lastIndexOf('$$');
|
||||
lastLine = state.src.slice(pos,lastPos);
|
||||
found = true;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
state.line = next + 1;
|
||||
|
||||
token = state.push('math_block', 'math', 0);
|
||||
token.block = true;
|
||||
token.content = (firstLine && firstLine.trim() ? firstLine + '\n' : '')
|
||||
+ state.getLines(start + 1, next, state.tShift[start], true)
|
||||
+ (lastLine && lastLine.trim() ? lastLine : '');
|
||||
token.map = [ start, state.line ];
|
||||
token.markup = '$$';
|
||||
return true;
|
||||
}
|
||||
|
||||
let assetsLoaded_ = false;
|
||||
let cache_ = {};
|
||||
|
||||
module.exports = function(context, ruleOptions) {
|
||||
// Keep macros that persist across Katex blocks to allow defining a macro
|
||||
// in one block and re-using it later in other blocks.
|
||||
// https://github.com/laurent22/joplin/issues/1105
|
||||
context.__katex = { macros: {} };
|
||||
|
||||
const addContextAssets = () => {
|
||||
context.css['katex'] = katexCss;
|
||||
context.assetLoaders['katex'] = async () => {
|
||||
if (assetsLoaded_) return;
|
||||
|
||||
// In node, the fonts are simply copied using copycss to where Katex expects to find them, which is under app/gui/note-viewer/fonts
|
||||
|
||||
// In React Native, it's more complicated and we need to download and copy them to the right directory. Ideally, we should embed
|
||||
// them as an asset and copy them from there (or load them from there by modifying Katex CSS), but for now that will do.
|
||||
|
||||
if (shim.isReactNative()) {
|
||||
// Fonts must go under the resourceDir directory because this is the baseUrl of NoteBodyViewer
|
||||
const baseDir = Setting.value('resourceDir');
|
||||
await shim.fsDriver().mkdir(baseDir + '/fonts');
|
||||
|
||||
await shim.fetchBlob('https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.9.0-beta1/fonts/KaTeX_Main-Regular.woff2', { overwrite: false, path: baseDir + '/fonts/KaTeX_Main-Regular.woff2' });
|
||||
await shim.fetchBlob('https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.9.0-beta1/fonts/KaTeX_Math-Italic.woff2', { overwrite: false, path: baseDir + '/fonts/KaTeX_Math-Italic.woff2' });
|
||||
await shim.fetchBlob('https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.9.0-beta1/fonts/KaTeX_Size1-Regular.woff2', { overwrite: false, path: baseDir + '/fonts/KaTeX_Size1-Regular.woff2' });
|
||||
}
|
||||
|
||||
assetsLoaded_ = true;
|
||||
};
|
||||
}
|
||||
|
||||
function renderToStringWithCache(latex, options) {
|
||||
const cacheKey = md5(escape(latex) + escape(JSON.stringify(options)));
|
||||
if (cacheKey in cache_) {
|
||||
return cache_[cacheKey];
|
||||
} else {
|
||||
const beforeMacros = JSON.stringify(options.macros);
|
||||
const output = katex.renderToString(latex, options);
|
||||
const afterMacros = JSON.stringify(options.macros);
|
||||
|
||||
// Don't cache the formulas that add macros, otherwise
|
||||
// they won't be added on second run.
|
||||
if (beforeMacros === afterMacros) cache_[cacheKey] = output;
|
||||
return output;
|
||||
}
|
||||
}
|
||||
|
||||
return function(md, options) {
|
||||
// Default options
|
||||
|
||||
options = options || {};
|
||||
options.macros = context.__katex.macros;
|
||||
|
||||
// set KaTeX as the renderer for markdown-it-simplemath
|
||||
var katexInline = function(latex){
|
||||
options.displayMode = false;
|
||||
try{
|
||||
return renderToStringWithCache(latex, options);
|
||||
} catch(error){
|
||||
if(options.throwOnError){ console.log(error); }
|
||||
return latex;
|
||||
}
|
||||
};
|
||||
|
||||
var inlineRenderer = function(tokens, idx){
|
||||
addContextAssets();
|
||||
return katexInline(tokens[idx].content);
|
||||
};
|
||||
|
||||
var katexBlock = function(latex){
|
||||
options.displayMode = true;
|
||||
try{
|
||||
return "<p>" + renderToStringWithCache(latex, options) + "</p>";
|
||||
} catch(error){
|
||||
if(options.throwOnError){ console.log(error); }
|
||||
return latex;
|
||||
}
|
||||
}
|
||||
|
||||
var blockRenderer = function(tokens, idx){
|
||||
addContextAssets();
|
||||
return katexBlock(tokens[idx].content) + '\n';
|
||||
}
|
||||
|
||||
md.inline.ruler.after('escape', 'math_inline', math_inline);
|
||||
md.block.ruler.after('blockquote', 'math_block', math_block, {
|
||||
alt: [ 'paragraph', 'reference', 'blockquote', 'list' ]
|
||||
});
|
||||
md.renderer.rules.math_inline = inlineRenderer;
|
||||
md.renderer.rules.math_block = blockRenderer;
|
||||
};
|
||||
};
|
||||
50
ReactNativeClient/lib/renderers/MdToHtml/rules/link_open.js
Normal file
50
ReactNativeClient/lib/renderers/MdToHtml/rules/link_open.js
Normal file
@@ -0,0 +1,50 @@
|
||||
const Entities = require('html-entities').AllHtmlEntities;
|
||||
const htmlentities = (new Entities()).encode;
|
||||
const Resource = require('lib/models/Resource.js');
|
||||
const utils = require('../../utils');
|
||||
|
||||
const loaderImage = '<?xml version="1.0" encoding="UTF-8" standalone="no"?><svg xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.0" width="16px" height="16px" viewBox="0 0 128 128" xml:space="preserve"><g><circle cx="16" cy="64" r="16" fill="#000000" fill-opacity="1"/><circle cx="16" cy="64" r="16" fill="#555555" fill-opacity="0.67" transform="rotate(45,64,64)"/><circle cx="16" cy="64" r="16" fill="#949494" fill-opacity="0.42" transform="rotate(90,64,64)"/><circle cx="16" cy="64" r="16" fill="#cccccc" fill-opacity="0.2" transform="rotate(135,64,64)"/><circle cx="16" cy="64" r="16" fill="#e1e1e1" fill-opacity="0.12" transform="rotate(180,64,64)"/><circle cx="16" cy="64" r="16" fill="#e1e1e1" fill-opacity="0.12" transform="rotate(225,64,64)"/><circle cx="16" cy="64" r="16" fill="#e1e1e1" fill-opacity="0.12" transform="rotate(270,64,64)"/><circle cx="16" cy="64" r="16" fill="#e1e1e1" fill-opacity="0.12" transform="rotate(315,64,64)"/><animateTransform attributeName="transform" type="rotate" values="0 64 64;315 64 64;270 64 64;225 64 64;180 64 64;135 64 64;90 64 64;45 64 64" calcMode="discrete" dur="720ms" repeatCount="indefinite"></animateTransform></g></svg>';
|
||||
|
||||
function installRule(markdownIt, mdOptions, ruleOptions) {
|
||||
markdownIt.renderer.rules.link_open = function (tokens, idx, options, env, self) {
|
||||
const token = tokens[idx];
|
||||
let href = utils.getAttr(token.attrs, 'href');
|
||||
const text = utils.getAttr(token.attrs, 'text');
|
||||
const isResourceUrl = Resource.isResourceUrl(href);
|
||||
const title = isResourceUrl ? utils.getAttr(token.attrs, 'title') : href;
|
||||
|
||||
let resourceIdAttr = "";
|
||||
let icon = "";
|
||||
let hrefAttr = '#';
|
||||
if (isResourceUrl) {
|
||||
const resourceId = Resource.pathToId(href);
|
||||
|
||||
const result = ruleOptions.resources[resourceId];
|
||||
const resource = result ? result.item : null;
|
||||
const resourceStatus = utils.resourceStatus(result);
|
||||
|
||||
if (result && resourceStatus !== 'ready') {
|
||||
const icon = utils.resourceStatusFile(resourceStatus);
|
||||
return '<a class="not-loaded-resource resource-status-' + resourceStatus + '" data-resource-id="' + resourceId + '">' + '<img src="data:image/svg+xml;utf8,' + htmlentities(icon) + '"/>';
|
||||
} else {
|
||||
href = "joplin://" + resourceId;
|
||||
resourceIdAttr = "data-resource-id='" + resourceId + "'";
|
||||
icon = '<span class="resource-icon"></span>';
|
||||
}
|
||||
} else {
|
||||
// If the link is a plain URL (as opposed to a resource link), set the href to the actual
|
||||
// link. This allows the link to be exported too when exporting to PDF.
|
||||
hrefAttr = href;
|
||||
}
|
||||
|
||||
let js = ruleOptions.postMessageSyntax + "(" + JSON.stringify(href) + "); return false;";
|
||||
if (hrefAttr.indexOf('#') === 0 && href.indexOf('#') === 0) js = ''; // If it's an internal anchor, don't add any JS since the webview is going to handle navigating to the right place
|
||||
return "<a data-from-md " + resourceIdAttr + " title='" + htmlentities(title) + "' href='" + hrefAttr + "' onclick='" + js + "'>" + icon;
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = function(context, ruleOptions) {
|
||||
return function(md, mdOptions) {
|
||||
installRule(md, mdOptions, ruleOptions);
|
||||
};
|
||||
};
|
||||
29
ReactNativeClient/lib/renderers/MdToHtml/setupLinkify.js
Normal file
29
ReactNativeClient/lib/renderers/MdToHtml/setupLinkify.js
Normal file
@@ -0,0 +1,29 @@
|
||||
module.exports = function(markdownIt) {
|
||||
// Add `file:` protocol in linkify to allow text in the format of "file://..." to translate into
|
||||
// file-URL links in html view
|
||||
markdownIt.linkify.add('file:', {
|
||||
validate: function (text, pos, self) {
|
||||
var tail = text.slice(pos);
|
||||
if (!self.re.file) {
|
||||
// matches all local file URI on Win/Unix/MacOS systems including reserved characters in some OS (i.e. no OS specific sanity check)
|
||||
self.re.file = new RegExp('^[\\/]{2,3}[\\S]+');
|
||||
}
|
||||
if (self.re.file.test(tail)) {
|
||||
return tail.match(self.re.file)[0].length;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
// enable file link URLs in MarkdownIt. Keeps other URL restrictions of MarkdownIt untouched.
|
||||
// Format [link name](file://...)
|
||||
markdownIt.validateLink = function (url) {
|
||||
var BAD_PROTO_RE = /^(vbscript|javascript|data):/;
|
||||
var GOOD_DATA_RE = /^data:image\/(gif|png|jpeg|webp);/;
|
||||
|
||||
// url should be normalized at this point, and existing entities are decoded
|
||||
var str = url.trim().toLowerCase();
|
||||
|
||||
return BAD_PROTO_RE.test(str) ? (GOOD_DATA_RE.test(str) ? true : false) : true;
|
||||
}
|
||||
}
|
||||
206
ReactNativeClient/lib/renderers/noteStyle.js
Normal file
206
ReactNativeClient/lib/renderers/noteStyle.js
Normal file
@@ -0,0 +1,206 @@
|
||||
module.exports = function(style, options) {
|
||||
// https://necolas.github.io/normalize.css/
|
||||
const normalizeCss = `
|
||||
html{line-height:1.15;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}
|
||||
article,aside,footer,header,nav,section{display:block}h1{font-size:2em;margin:.67em 0}hr{box-sizing:content-box;height:0;overflow:visible}
|
||||
pre{font-family:monospace,monospace;font-size:1em}a{background-color:transparent;-webkit-text-decoration-skip:objects}
|
||||
b,strong{font-weight:bolder}small{font-size:80%}img{border-style:none}
|
||||
`;
|
||||
|
||||
const fontFamily = "'Avenir', 'Arial', sans-serif";
|
||||
const listMarginLeft = '1.7em';
|
||||
|
||||
const css = `
|
||||
body {
|
||||
font-size: ` + style.htmlFontSize + `;
|
||||
color: ` + style.htmlColor + `;
|
||||
line-height: ` + style.htmlLineHeight + `;
|
||||
background-color: ` + style.htmlBackgroundColor + `;
|
||||
font-family: ` + fontFamily + `;
|
||||
padding-bottom: ` + options.paddingBottom + `;
|
||||
}
|
||||
strong {
|
||||
color: ` + style.colorBright + `
|
||||
}
|
||||
::-webkit-scrollbar {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
}
|
||||
::-webkit-scrollbar-corner {
|
||||
background: none;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
border: none;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(100, 100, 100, 0.3);
|
||||
border-radius: 5px;
|
||||
}
|
||||
::-webkit-scrollbar-track:hover {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(100, 100, 100, 0.7);
|
||||
}
|
||||
|
||||
/* Remove top padding and margin from first child so that top of rendered text is aligned to top of text editor text */
|
||||
#rendered-md h1:first-child,
|
||||
#rendered-md h2:first-child,
|
||||
#rendered-md h3:first-child,
|
||||
#rendered-md h4:first-child,
|
||||
#rendered-md ul:first-child,
|
||||
#rendered-md ol:first-child,
|
||||
#rendered-md table:first-child,
|
||||
#rendered-md blockquote:first-child,
|
||||
#rendered-md img:first-child,
|
||||
#rendered-md p:first-child {
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
p, h1, h2, h3, h4, h5, h6, ul, table {
|
||||
margin-top: .6em;
|
||||
margin-bottom: .65em;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
line-height: 1.5em;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.5em;
|
||||
font-weight: bold;
|
||||
border-bottom: 1px solid ` + style.htmlDividerColor + `;
|
||||
padding-bottom: .3em;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.3em;
|
||||
font-weight: bold;
|
||||
padding-bottom: .1em; */
|
||||
}
|
||||
h3 {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
h4, h5, h6 {
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
}
|
||||
a {
|
||||
color: ` + style.htmlLinkColor + `;
|
||||
}
|
||||
ul, ol {
|
||||
padding-left: 0;
|
||||
margin-left: 1.7em;
|
||||
}
|
||||
li {
|
||||
margin-bottom: .4em;
|
||||
}
|
||||
li p {
|
||||
margin-top: 0.2em;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.resource-icon {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
top: .5em;
|
||||
text-decoration: none;
|
||||
width: 1.15em;
|
||||
height: 1.45em;
|
||||
margin-right: 0.4em;
|
||||
background-color: ` + style.htmlColor + `;
|
||||
/* Awesome Font file */
|
||||
-webkit-mask: url("data:image/svg+xml;utf8,<svg viewBox='0 0 1536 1892' xmlns='http://www.w3.org/2000/svg'><path d='M288 128C129 128 0 257 0 416v960c0 159 129 288 288 288h960c159 0 288-129 288-288V416c0-159-129-288-288-288H288zm449.168 236.572l263.434.565 263.431.562.584 73.412.584 73.412-42.732 1.504c-23.708.835-47.002 2.774-52.322 4.36-14.497 4.318-23.722 12.902-29.563 27.51l-5.12 12.802-1.403 291.717c-1.425 295.661-1.626 302.586-9.936 343.043-15.2 74-69.604 150.014-142.197 198.685-58.287 39.08-121.487 60.47-208.155 70.45-22.999 2.648-122.228 2.636-141.976-.024l-.002.006c-69.785-9.377-108.469-20.202-154.848-43.332-85.682-42.73-151.778-116.991-177.537-199.469-10.247-32.81-11.407-40.853-11.375-78.754.026-31.257.76-39.15 5.024-54.043 8.94-31.228 20.912-51.733 43.56-74.62 27.312-27.6 55.812-40.022 95.524-41.633 37.997-1.542 63.274 5.024 87.23 22.66 15.263 11.235 30.828 33.238 39.537 55.884 5.52 14.355 5.949 18.31 7.549 69.569 1.675 53.648 3.05 63.99 11.674 87.785 11.777 32.499 31.771 55.017 61.46 69.22 26.835 12.838 47.272 16.785 80.56 15.56 21.646-.798 30.212-2.135 43.208-6.741 38.682-13.708 70.96-44.553 86.471-82.635 16.027-39.348 15.995-38.647 15.947-361.595-.042-283.26-.09-286.272-4.568-296.153-10.958-24.171-22.488-28.492-81.074-30.377l-42.969-1.38v-147.95z'/></svg>");
|
||||
}
|
||||
blockquote {
|
||||
border-left: 4px solid ` + style.htmlCodeBorderColor + `;
|
||||
padding-left: 1.2em;
|
||||
margin-left: 0;
|
||||
opacity: .7;
|
||||
}
|
||||
table {
|
||||
text-align: left-align;
|
||||
border-collapse: collapse;
|
||||
border: 1px solid ` + style.htmlCodeBorderColor + `;
|
||||
background-color: ` + style.htmlBackgroundColor + `;
|
||||
}
|
||||
td, th {
|
||||
padding: .5em 1em .5em 1em;
|
||||
font-size: ` + style.htmlFontSize + `;
|
||||
color: ` + style.htmlColor + `;
|
||||
font-family: ` + fontFamily + `;
|
||||
}
|
||||
td {
|
||||
border: 1px solid ` + style.htmlCodeBorderColor + `;
|
||||
}
|
||||
th {
|
||||
border: 1px solid ` + style.htmlCodeBorderColor + `;
|
||||
border-bottom: 2px solid ` + style.htmlCodeBorderColor + `;
|
||||
background-color: ` + style.htmlTableBackgroundColor + `;
|
||||
}
|
||||
tr:nth-child(even) {
|
||||
background-color: ` + style.htmlTableBackgroundColor + `;
|
||||
}
|
||||
tr:hover {
|
||||
background-color: ` + style.raisedBackgroundColor + `;
|
||||
}
|
||||
hr {
|
||||
border: none;
|
||||
border-bottom: 2px solid ` + style.htmlDividerColor + `;
|
||||
}
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.inline-code {
|
||||
border: 1px solid ` + style.htmlCodeBorderColor + `;
|
||||
background-color: ` + style.htmlCodeBackgroundColor + `;
|
||||
padding-right: .2em;
|
||||
padding-left: .2em;
|
||||
border-radius: .25em;
|
||||
color: ` + style.htmlCodeColor + `;
|
||||
font-size: ` + style.htmlCodeFontSize + `;
|
||||
}
|
||||
|
||||
.highlighted-keyword {
|
||||
background-color: #F3B717;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.not-loaded-resource img {
|
||||
width: 1.15em;
|
||||
height: 1.15em;
|
||||
background: white;
|
||||
padding: 2px !important;
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 1px 3px #000000aa;
|
||||
}
|
||||
|
||||
a.not-loaded-resource img {
|
||||
margin-right: .2em;
|
||||
}
|
||||
|
||||
a.not-loaded-resource {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.checkbox-label-checked {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body {
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.code, .inline-code {
|
||||
border: 1px solid #CBCBCB;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
return [normalizeCss, css];
|
||||
}
|
||||
118
ReactNativeClient/lib/renderers/utils.js
Normal file
118
ReactNativeClient/lib/renderers/utils.js
Normal file
@@ -0,0 +1,118 @@
|
||||
const Resource = require('lib/models/Resource.js');
|
||||
|
||||
const utils = {};
|
||||
|
||||
utils.getAttr = function(attrs, name, defaultValue = null) {
|
||||
for (let i = 0; i < attrs.length; i++) {
|
||||
if (attrs[i][0] === name) return attrs[i].length > 1 ? attrs[i][1] : null;
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
utils.notDownloadedResource = function() {
|
||||
return `
|
||||
<svg width="1700" height="1536" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1280 1344c0-35-29-64-64-64s-64 29-64 64 29 64 64 64 64-29 64-64zm256 0c0-35-29-64-64-64s-64 29-64 64 29 64 64 64 64-29 64-64zm128-224v320c0 53-43 96-96 96H96c-53 0-96-43-96-96v-320c0-53 43-96 96-96h465l135 136c37 36 85 56 136 56s99-20 136-56l136-136h464c53 0 96 43 96 96zm-325-569c10 24 5 52-14 70l-448 448c-12 13-29 19-45 19s-33-6-45-19L339 621c-19-18-24-46-14-70 10-23 33-39 59-39h256V64c0-35 29-64 64-64h256c35 0 64 29 64 64v448h256c26 0 49 16 59 39z"/>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
utils.notDownloadedImage = function() {
|
||||
// https://github.com/ForkAwesome/Fork-Awesome/blob/master/src/icons/svg/file-image-o.svg
|
||||
// Height changed to 1795
|
||||
return `
|
||||
<svg width="1925" height="1792" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M640 576c0 106-86 192-192 192s-192-86-192-192 86-192 192-192 192 86 192 192zm1024 384v448H256v-192l320-320 160 160 512-512zm96-704H160c-17 0-32 15-32 32v1216c0 17 15 32 32 32h1600c17 0 32-15 32-32V288c0-17-15-32-32-32zm160 32v1216c0 88-72 160-160 160H160c-88 0-160-72-160-160V288c0-88 72-160 160-160h1600c88 0 160 72 160 160z"/>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
utils.notDownloadedFile = function() {
|
||||
// https://github.com/ForkAwesome/Fork-Awesome/blob/master/src/icons/svg/file-o.svg
|
||||
return `
|
||||
<svg width="1925" height="1792" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1468 380c37 37 68 111 68 164v1152c0 53-43 96-96 96H96c-53 0-96-43-96-96V96C0 43 43 0 96 0h896c53 0 127 31 164 68zm-444-244v376h376c-6-17-15-34-22-41l-313-313c-7-7-24-16-41-22zm384 1528V640H992c-53 0-96-43-96-96V128H128v1536h1280z"/>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
utils.errorImage = function() {
|
||||
// https://github.com/ForkAwesome/Fork-Awesome/blob/master/src/icons/svg/times-circle.svg
|
||||
return `
|
||||
<svg width="1795" height="1795" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1149 1122c0-17-7-33-19-45L949 896l181-181c12-12 19-28 19-45s-7-34-19-46l-90-90c-12-12-29-19-46-19s-33 7-45 19L768 715 587 534c-12-12-28-19-45-19s-34 7-46 19l-90 90c-12 12-19 29-19 46s7 33 19 45l181 181-181 181c-12 12-19 28-19 45s7 34 19 46l90 90c12 12 29 19 46 19s33-7 45-19l181-181 181 181c12 12 28 19 45 19s34-7 46-19l90-90c12-12 19-29 19-46zm387-226c0 424-344 768-768 768S0 1320 0 896s344-768 768-768 768 344 768 768z"/>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
utils.loaderImage = function() {
|
||||
// https://github.com/ForkAwesome/Fork-Awesome/blob/master/src/icons/svg/hourglass-half.svg
|
||||
return `
|
||||
<svg width="1536" height="1790" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1408 128c0 370-177 638-373 768 196 130 373 398 373 768h96c18 0 32 14 32 32v64c0 18-14 32-32 32H32c-18 0-32-14-32-32v-64c0-18 14-32 32-32h96c0-370 177-638 373-768-196-130-373-398-373-768H32c-18 0-32-14-32-32V32C0 14 14 0 32 0h1472c18 0 32 14 32 32v64c0 18-14 32-32 32h-96zm-128 0H256c0 146 33 275 85 384h854c52-109 85-238 85-384zm-57 1216c-74-193-207-330-340-384H653c-133 54-266 191-340 384h910z"/>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
utils.resourceStatusImage = function(state) {
|
||||
if (state === 'notDownloaded') return utils.notDownloadedResource();
|
||||
return utils.resourceStatusFile(state);
|
||||
}
|
||||
|
||||
utils.resourceStatusFile = function(state) {
|
||||
if (state === 'notDownloaded') return utils.notDownloadedResource();
|
||||
if (state === 'downloading') return utils.loaderImage();
|
||||
if (state === 'encrypted') return utils.loaderImage();
|
||||
if (state === 'error') return utils.errorImage();
|
||||
|
||||
throw new Error('Unknown state: ' + state);
|
||||
}
|
||||
|
||||
utils.resourceStatus = function(resourceInfo) {
|
||||
let resourceStatus = 'ready';
|
||||
|
||||
if (resourceInfo) {
|
||||
const resource = resourceInfo.item;
|
||||
const localState = resourceInfo.localState;
|
||||
|
||||
if (localState.fetch_status === Resource.FETCH_STATUS_IDLE) {
|
||||
resourceStatus = 'notDownloaded';
|
||||
} else if (localState.fetch_status === Resource.FETCH_STATUS_STARTED) {
|
||||
resourceStatus = 'downloading';
|
||||
} else if (localState.fetch_status === Resource.FETCH_STATUS_DONE) {
|
||||
if (resource.encryption_blob_encrypted || resource.encryption_applied) {
|
||||
resourceStatus = 'encrypted';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
resourceStatus = 'notDownloaded';
|
||||
}
|
||||
|
||||
return resourceStatus;
|
||||
}
|
||||
|
||||
utils.imageReplacement = function(src, resources, resourceBaseUrl) {
|
||||
const resourceId = Resource.urlToId(src);
|
||||
const result = resources[resourceId];
|
||||
const resource = result ? result.item : null;
|
||||
const resourceStatus = utils.resourceStatus(result);
|
||||
|
||||
if (resourceStatus !== 'ready') {
|
||||
const icon = utils.resourceStatusImage(resourceStatus);
|
||||
return '<div class="not-loaded-resource resource-status-' + resourceStatus + '" data-resource-id="' + resourceId + '">' + '<img src="data:image/svg+xml;utf8,' + htmlentities(icon) + '"/>' + '</div>';
|
||||
}
|
||||
|
||||
const mime = resource.mime ? resource.mime.toLowerCase() : '';
|
||||
if (Resource.isSupportedImageMimeType(mime)) {
|
||||
let newSrc = './' + Resource.filename(resource);
|
||||
if (resourceBaseUrl) newSrc = resourceBaseUrl + newSrc;
|
||||
return {
|
||||
'data-resource-id': resource.id,
|
||||
'src': newSrc,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = utils;
|
||||
110
ReactNativeClient/lib/renderers/webviewLib.js
Normal file
110
ReactNativeClient/lib/renderers/webviewLib.js
Normal file
@@ -0,0 +1,110 @@
|
||||
const webviewLib = {};
|
||||
|
||||
let manualDownloadResourceElements = [];
|
||||
|
||||
webviewLib.onUnloadedResourceClick = function(event) {
|
||||
const resourceId = event.currentTarget.getAttribute('data-resource-id');
|
||||
webviewLib.options_.postMessage('markForDownload:' + resourceId);
|
||||
}
|
||||
|
||||
webviewLib.setupResourceManualDownload = function() {
|
||||
for (const element of manualDownloadResourceElements) {
|
||||
element.style.cursor = 'default';
|
||||
element.removeEventListener('click', webviewLib.onUnloadedResourceClick);
|
||||
}
|
||||
|
||||
manualDownloadResourceElements = [];
|
||||
|
||||
const elements = document.getElementsByClassName('resource-status-notDownloaded');
|
||||
|
||||
for (const element of elements) {
|
||||
element.style.cursor = 'pointer';
|
||||
element.addEventListener('click', webviewLib.onUnloadedResourceClick);
|
||||
manualDownloadResourceElements.push(element);
|
||||
}
|
||||
}
|
||||
|
||||
webviewLib.handleInternalLink = function(event, anchorNode) {
|
||||
const href = anchorNode.getAttribute('href');
|
||||
if (!href) return false;
|
||||
|
||||
if (href.indexOf('#') === 0) {
|
||||
event.preventDefault();
|
||||
let old_hash = location.hash;
|
||||
|
||||
location.hash = href;
|
||||
|
||||
// HACK
|
||||
// For some reason anchors at the bottom cause the webview to move itself
|
||||
// so that the content is aligned with the top of the screen
|
||||
// This basically refreshes the scroll view so that is returns to a normal
|
||||
// position, the scroll positions stays correct though
|
||||
// Additionally an anchor could not be clicked twice because the location
|
||||
// would not change, this fixes that also
|
||||
setTimeout(function() { location.hash = old_hash; }, 10);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
webviewLib.getParentAnchorElement = function(element) {
|
||||
let counter = 0;
|
||||
while (true) {
|
||||
|
||||
if (counter++ >= 10000) {
|
||||
console.warn('been looping for too long - exiting')
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!element) return null;
|
||||
if (element.nodeName === 'A') return element;
|
||||
element = element.parentElement;
|
||||
}
|
||||
}
|
||||
|
||||
webviewLib.cloneError = function(error) {
|
||||
return {
|
||||
message: error.message,
|
||||
stack: error.stack
|
||||
};
|
||||
}
|
||||
|
||||
webviewLib.logEnabledEventHandler = function(fn) {
|
||||
return function(event) {
|
||||
try {
|
||||
return fn(event);
|
||||
} catch (error) {
|
||||
webviewLib.options_.postMessage('error:' + JSON.stringify(webviewLib.cloneError(error)));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
webviewLib.initialize = function(options) {
|
||||
webviewLib.options_ = options;
|
||||
}
|
||||
|
||||
document.addEventListener('click', function(event) {
|
||||
const anchor = webviewLib.getParentAnchorElement(event.target);
|
||||
if (!anchor) return;
|
||||
|
||||
// Prevent URLs added via <a> tags from being opened within the application itself
|
||||
// otherwise it would open the whole website within the WebView.
|
||||
|
||||
// Note that we already handle some links in html_inline.js, however not all of them
|
||||
// go through this plugin, in particular links coming from third-party packages such
|
||||
// as Katex.
|
||||
if (!anchor.hasAttribute('data-from-md')) {
|
||||
if (webviewLib.handleInternalLink(event, anchor)) return;
|
||||
event.preventDefault();
|
||||
if (anchor.getAttribute('href')) webviewLib.options_.postMessage(anchor.getAttribute('href'));
|
||||
return;
|
||||
}
|
||||
|
||||
// If this is an internal link, jump to the anchor directly
|
||||
if (anchor.hasAttribute('data-from-md')) {
|
||||
if (webviewLib.handleInternalLink(event, anchor)) return;
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user