src/jg/dataui.js
import _ from 'lodash';
import MarkdownIt from 'markdown-it';
import MarkdownItAttrs from 'markdown-it-attrs';
import nunjucks from 'nunjucks';
/**
* @external {MarkdownIt} https://github.com/markdown-it/markdown-it
*/
/** @ignore */
function normalizeIndent(text) {
if (!text) return text;
var lines = _.trimEnd(text).split('\n');
var indents = lines
.filter((l) => l !== '') // Ignore empty lines
.map((l) => l.match(/^\s+/))
.map(function (m) {
if (m === null) return '';
return m[0];
});
if (!indents.length) return text;
var smallestIndent = indents.reduce(function(max, curr) {
if (curr.length < max.length) return curr;
return max;
}); // Find the "bottom" indentation level
return lines.map(function (l) {
return l.replace(new RegExp('^' + smallestIndent), '');
}).join('\n');
}
/**
* Direct access to the HTML transcript.
*/
export default class ui {
/** @ignore */
constructor() {
/** @ignore */
this.content = [];
/** @ignore */
this.currentItemId = null;
/** @ignore */
this.currentGroupId = 0;
/** @ignore */
this.nextItemId = 0;
/** @ignore */
this.templateHelperGetters = {};
/**
* {@link MarkdownIt} instance used to render Markdown. You may use it
* to register additional plugins.
* @type {MarkdownIt}
*/
this.md = new MarkdownIt({html: true, linkify: false, typographer: true});
this.md.use(MarkdownItAttrs);
/**
* {@link nunjucks} instance used to render templates. You may use it
* to add custom tags, filters, or globals. (See [the "Environment" section
* of this page.](https://mozilla.github.io/nunjucks/api.html))
*/
this.nunjucks = new nunjucks.Environment([], {
autoescape: false,
tags: {
commentStart: '{##',
commentEnd: '##}',
},
});
this.nunjucks.addFilter('qualityName', (char, q) => {
return char.formatQualityName(q);
});
this.nunjucks.addFilter('quality', (char, q) => {
return char.formatQuality(q);
});
/**
* You may replace this property if you want to use a template
* language other than [Nunjucks](https://mozilla.github.io/nunjucks/).
*
* It is a function that takes a string and returns a function that takes
* attrs and returns a rendered string. Like this:
*
* ```
* (src) => (attrs) => render(src, attrs)
* ```
* @param {function(src: string): function} src
*/
this.createTemplate = (src) => (attrs) => {
return this.nunjucks.renderString(src, attrs);
};
/** @ignore */
this.templateHelperFunctions = {};
}
/**
* Make the given objects or values available to the template context.
* @param {Map<string, function>} fns
*/
addTemplateContext(fns) {
this.templateHelperFunctions = {...this.templateHelperFunctions, ...fns};
}
/**
* Whenever a template is rendered, evaluate all these functions and make their
* return values available to the template context.
*
* **All template getters are called whenever you render any template.**
*
* @example
*
* // If you add a getter like this:
* jumbogrove.jumbogrove('#game', {
* init: (model, ui) => {
* ui.addTemplateGetters({
* randomNumber: () => Math.random()
* });
* }
* });
*
* // Then you may use it in a template like this:
*
* `
* {{ randomNumber }}
* `
*
* // and it will show the function's return value (in this case, a random
* // number 0-1).
*
* @param {Map<string, function>} fns
*/
addTemplateGetters(fns) {
this.templateHelperGetters = {...this.templateHelperGetters, ...fns};
}
/** @ignore */
bind(director) {
this.director = director;
}
/** @ignore */
templateContext() {
const getters = {};
for (const k of Object.keys(this.templateHelperGetters)) {
getters[k] = this.templateHelperGetters[k]();
}
return {
...this.director.model.globalState,
...this.director.model,
...getters,
...this.templateHelperFunctions,
model: this.director.model,
ui: this,
};
}
/**
* Render the given Markdown text to HTML. Automatically dedents the text to the
* minimum indent level.
* @param {string} text
* @param {Boolean} inline If true, do not parse any block-level markup or wrap in a paragraph.
*/
renderMarkdown(text, inline = false) {
if (inline) {
// console.log('inline', text, '---', this.md.renderInline(normalizeIndent(text)))
return this.md.renderInline(normalizeIndent(text));
} else {
// console.log('div', text, '---', this.md.render(normalizeIndent(text)));
return this.md.render(normalizeIndent(text));
}
}
/**
* Process the text as a template and return the result.
* @param {string} src
* @param {Map<string,*>|null} args Additional template context
*/
renderTemplate(src, args = null) {
try {
return this.createTemplate(src)({...args, ...this.templateContext()});
} catch (e) {
console.error(src)
throw e;
}
}
/**
* Process the text as a template, render the resulting Markdown to HTML, and
* return the result. Automatically dedents the text to the minimum indent level.
* @param {string} src
* @param {Map<string,*>} args Additional template context
* @param {Boolean} inline If true, do not parse any block-level markup or wrap in a paragraph.
*/
renderMarkdownTemplate(src, args = null, inline = false) {
return this.renderMarkdown(this.renderTemplate(src, args), inline);
}
/**
* Like {@link renderMarkdownTemplate}, but automatically sets `inline` flag based on
* presence of line breaks.
* @param {string} src
* @param {Map<string,*>} args Additional template context
* @param {Boolean} inline If true, do not parse any block-level markup or wrap in a paragraph.
*/
renderMarkdownTemplateMaybeInline(src, args = null) {
const inline = src.indexOf('\n') === -1;
return this.renderMarkdownTemplate(src, args, inline);
}
/** @ignore */
nextGroup() {
this.currentGroupId += 1;
}
/** @ignore */
append(item) {
item.id = this.nextItemId;
this.nextItemId += 1;
item.groupId = this.currentGroupId;
this.content.push(item);
this.currentItemId = item.id;
}
/**
* Encode the given string so it doesn't mess up Markdown link parsing
* @param {String} s
* @ignore
*/
encode(s) {
return window.encodeURIComponent;
}
/**
* Render the given HTML as a template and write it to the transcript.
* Links are automatically bound to actions and situation transitions.
*
* The output comes after ALL HTML in the current section, If you are
* presenting a choice, the HTML will be written BELOW the choice.
* @param {string} html
* @param {Map<string,*>} args Additional template contet
*/
writeHTML(html, args = null) {
this.append({
'type': 'html',
html: this.renderTemplate(html, args),
});
}
/**
* Render the given string as a template, render the resulting Markdown as HTML, and
* write it to the transcript.
*
* The output comes after ALL HTML in the current section, If you are
* presenting a choice, the text will be written BELOW the choice.
* @param {string} markdown
* @param {Map<string,*>} args Additional template context
*/
writeMarkdown(markdown, args = null) {
this.append({
'type': 'html',
html: this.renderMarkdownTemplate(markdown, args)});
}
/**
* Render the given HTML as a template and write it to the transcript
* WITHIN THE CURRENT SECTION. If you are writing HTML from inside
* an action function, this is probably what you want.
*
* @param {string} markdown
* @param {Map<string,*>} args Additional template context
*/
write(markdown, args) {
if (this.director.activeItemId !== null) {
this.bus.$emit('write', {
'itemId': this.director.activeItemId,
'html': this.renderMarkdownTemplate(markdown, args),
});
} else {
this.writeMarkdown(markdown, args);
}
}
/**
* Remove all content from the transcript and start fresh.
*/
clear() {
this.content = [];
}
/**
* Given an array of tags or situation IDs (can be both in the same array), present
* the relevant choices in the transcript using the logic in {@link model.interpretChoices}.
* @param {string[]} arrayOfSituationIdsOrTags Array of strings containing either `#tags` or `situation-ids`.
*/
presentChoices(arrayOfSituationIdsOrTags) {
return new Promise((resolve, reject) => {
const item = {
'type': 'choice',
choices: this.director.model.interpretChoices(arrayOfSituationIdsOrTags),
};
item.callback = (situationId) => {
item.situationId = situationId;
resolve({situationId, itemId: item.id});
};
this.append(item);
});
}
/**
* Force the user to enter some text to continue.
* @param {Map<string,*>} options
* @param {string} options.placeholder Placeholder text for the input field
* @returns {Promise<string>}
*/
promptInput({placeholder}) {
return new Promise((resolve, reject) => {
this.append({'type': 'input', placeholder, callback: resolve});
})
}
}