1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-06-30 23:44:55 +02:00

Desktop: Added support for checkboxes and fixed various issues with WYSIWYG editor

This commit is contained in:
Laurent Cozic
2020-03-23 00:47:25 +00:00
parent c607444c23
commit 41acdce165
79 changed files with 12340 additions and 877 deletions

View File

@ -1,153 +0,0 @@
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. */
.md-checkbox .checkbox-wrapper {
display: flex;
align-items: center;
}
li.md-checkbox {
list-style-type: none;
}
li.md-checkbox input[type=checkbox] {
margin-left: -1.71em;
margin-right: 0.7em;
}
`;
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 = `
try {
if (this.checked) {
this.setAttribute('checked', 'checked');
} else {
this.removeAttribute('checked');
}
${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');
} catch (error) {
console.warn('Checkbox ${checkedString}:${lineIndex} error', error);
}
return true;
`;
token = new Token('checkbox_wrapper_open', 'div', 1);
token.attrs = [['class', 'checkbox-wrapper']];
tokens.push(token);
token = new Token('checkbox_input', 'input', 0);
token.attrs = [['type', 'checkbox'], ['id', id], ['onclick', js]];
if (checked) token.attrs.push(['checked', 'checked']);
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),
new Token('checkbox_wrapper_close', 'div', -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;
}
// Note that we only support list items that start with "-" (not with "*")
if (currentListItem && currentListItem.markup === '-' && !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 joplin-checkbox';
currentListItem.attrSet('class', itemClass.trim());
if (!('checkbox' in context.pluginAssets)) {
context.pluginAssets['checkbox'] = [
{
inline: true,
text: checkboxStyle,
mime: 'text/css',
},
];
}
}
}
});
}
module.exports = function(context, ruleOptions) {
return function(md, mdOptions) {
installRule(md, mdOptions, ruleOptions, context);
};
};

View File

@ -0,0 +1,228 @@
let checkboxIndex_ = -1;
const pluginAssets:Function[] = [];
pluginAssets[1] = function() {
return [
{
inline: true,
mime: 'text/css',
text: `
/* 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. */
.md-checkbox .checkbox-wrapper {
display: flex;
align-items: center;
}
li.md-checkbox {
list-style-type: none;
}
li.md-checkbox input[type=checkbox] {
margin-left: -1.71em;
margin-right: 0.7em;
}`,
},
];
};
pluginAssets[2] = function(theme:any) {
return [
{
inline: true,
mime: 'text/css',
text: `
/* https://stackoverflow.com/questions/7478336/only-detect-click-event-on-pseudo-element#comment39751366_7478344 */
ul.joplin-checklist li {
pointer-events: none;
}
ul.joplin-checklist {
list-style:none;
}
ul.joplin-checklist li::before {
content:"\\f14a";
font-family:ForkAwesome;
background-size: 16px 16px;
pointer-events: all;
cursor: pointer;
width: 1em;
height: 1em;
margin-left: -1.3em;
position: absolute;
color: ${theme.htmlColor};
}
.joplin-checklist li:not(.checked)::before {
content:"\\f0c8";
}`,
},
];
};
function createPrefixTokens(Token:any, id:string, checked:boolean, label:string, postMessageSyntax:string, sourceToken:any):any[] {
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 = `
try {
if (this.checked) {
this.setAttribute('checked', 'checked');
} else {
this.removeAttribute('checked');
}
${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');
} catch (error) {
console.warn('Checkbox ${checkedString}:${lineIndex} error', error);
}
return true;
`;
token = new Token('checkbox_wrapper_open', 'div', 1);
token.attrs = [['class', 'checkbox-wrapper']];
tokens.push(token);
token = new Token('checkbox_input', 'input', 0);
token.attrs = [['type', 'checkbox'], ['id', id], ['onclick', js]];
if (checked) token.attrs.push(['checked', 'checked']);
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:any):any[] {
return [
new Token('label_close', 'label', -1),
new Token('checkbox_wrapper_close', 'div', -1),
];
}
// @ts-ignore: Keep the function signature as-is despite unusued arguments
function installRule(markdownIt:any, mdOptions:any, ruleOptions:any, context:any) {
const pluginOptions = { renderingType: 1, ...ruleOptions.plugins['checkbox'] };
markdownIt.core.ruler.push('checkbox', (state:any) => {
const tokens = state.tokens;
const Token = state.Token;
const checkboxPattern = /^\[([x|X| ])\] (.*)$/;
let currentListItem = null;
let processedFirstInline = false;
const lists = [];
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
if (token.type === 'bullet_list_open') {
lists.push(token);
continue;
}
if (token.type === 'bullet_list_close') {
lists.pop();
continue;
}
if (token.type === 'list_item_open') {
currentListItem = token;
processedFirstInline = false;
continue;
}
if (token.type === 'list_item_close') {
currentListItem = null;
processedFirstInline = false;
continue;
}
// Note that we only support list items that start with "-" (not with "*")
if (currentListItem && currentListItem.markup === '-' && !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;
const checked = matches[1] !== ' ';
const label = matches.length >= 3 ? matches[2] : '';
const currentList = lists[lists.length - 1];
if (pluginOptions.renderingType === 1) {
checkboxIndex_++;
const id = `md-checkbox-${checkboxIndex_}`;
// 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 joplin-checkbox';
currentListItem.attrSet('class', itemClass.trim());
} else {
const textToken = new Token('text', '', 0);
textToken.content = label;
const tokens = [];
tokens.push(textToken);
token.children = markdownIt.utils.arrayReplaceAt(token.children, 0, tokens);
const listClass = currentList.attrGet('class') || '';
if (listClass.indexOf('joplin-') < 0) currentList.attrSet('class', (`${listClass} joplin-checklist`).trim());
if (checked) {
currentListItem.attrSet('class', (`${currentListItem.attrGet('class') || ''} checked`).trim());
}
}
if (!('checkbox' in context.pluginAssets)) {
context.pluginAssets['checkbox'] = pluginAssets[pluginOptions.renderingType](ruleOptions.theme);
}
}
}
});
}
export default {
install: function(context:any, ruleOptions:any) {
return function(md:any, mdOptions:any) {
installRule(md, mdOptions, ruleOptions, context);
};
},
style: pluginAssets[2],
};

View File

@ -1,99 +1,106 @@
const fountain = require('../../vendor/fountain.min.js');
const fountainCss = `
.fountain {
font-family: monospace;
line-height: 107%;
max-width: 1000px;
margin-left: auto;
margin-right: auto;
}
const fountainCss = function() {
return [
{
inline: true,
mime: 'text/css',
text: `
.fountain {
font-family: monospace;
line-height: 107%;
max-width: 1000px;
margin-left: auto;
margin-right: auto;
}
.fountain .title-page,
.fountain .page {
box-shadow: 0 0 5px rgba(0,0,0,0.1);
border: 1px solid #d2d2d2;
padding: 10%;
margin-bottom: 2em;
}
.fountain .title-page,
.fountain .page {
box-shadow: 0 0 5px rgba(0,0,0,0.1);
border: 1px solid #d2d2d2;
padding: 10%;
margin-bottom: 2em;
}
.fountain h1,
.fountain h2,
.fountain h3,
.fountain h4,
.fountain p {
font-weight: normal;
line-height: 107%;
margin: 1em 0;
border: none;
font-size: 1em;
}
.fountain h1,
.fountain h2,
.fountain h3,
.fountain h4,
.fountain p {
font-weight: normal;
line-height: 107%;
margin: 1em 0;
border: none;
font-size: 1em;
}
.fountain .bold {
font-weight: bold;
}
.fountain .bold {
font-weight: bold;
}
.fountain .underline {
text-decoration: underline;
}
.fountain .underline {
text-decoration: underline;
}
.fountain .centered {
text-align: center;
}
.fountain .centered {
text-align: center;
}
.fountain h2 {
text-align: right;
}
.fountain h2 {
text-align: right;
}
.fountain .dialogue p.parenthetical {
margin-left: 11%;
}
.fountain .dialogue p.parenthetical {
margin-left: 11%;
}
.fountain .title-page .credit,
.fountain .title-page .authors,
.fountain .title-page .source {
text-align: center;
}
.fountain .title-page .credit,
.fountain .title-page .authors,
.fountain .title-page .source {
text-align: center;
}
.fountain .title-page h1 {
margin-bottom: 1.5em;
text-align: center;
}
.fountain .title-page h1 {
margin-bottom: 1.5em;
text-align: center;
}
.fountain .title-page .source {
margin-top: 1.5em;
}
.fountain .title-page .source {
margin-top: 1.5em;
}
.fountain .title-page .notes {
text-align: right;
margin: 3em 0;
}
.fountain .title-page .notes {
text-align: right;
margin: 3em 0;
}
.fountain .title-page h1 {
margin-bottom: 1.5em;
text-align: center;
}
.fountain .title-page h1 {
margin-bottom: 1.5em;
text-align: center;
}
.fountain .dialogue {
margin-left: 3em;
margin-right: 3em;
}
.fountain .dialogue {
margin-left: 3em;
margin-right: 3em;
}
.fountain .dialogue p,
.fountain .dialogue h1,
.fountain .dialogue h2,
.fountain .dialogue h3,
.fountain .dialogue h4 {
margin: 0;
}
.fountain .dialogue p,
.fountain .dialogue h1,
.fountain .dialogue h2,
.fountain .dialogue h3,
.fountain .dialogue h4 {
margin: 0;
}
.fountain .dialogue h1,
.fountain .dialogue h2,
.fountain .dialogue h3,
.fountain .dialogue h4 {
text-align: center;
}
`;
.fountain .dialogue h1,
.fountain .dialogue h2,
.fountain .dialogue h3,
.fountain .dialogue h4 {
text-align: center;
}`,
},
];
};
function renderFountainScript(markdownIt, content) {
const result = fountain.parse(content);
@ -114,13 +121,7 @@ function renderFountainScript(markdownIt, content) {
function addContextAssets(context) {
if ('fountain' in context.pluginAssets) return;
context.pluginAssets['fountain'] = [
{
inline: true,
text: fountainCss,
mime: 'text/css',
},
];
context.pluginAssets['fountain'] = fountainCss();
}
function installRule(markdownIt, mdOptions, ruleOptions, context) {
@ -136,8 +137,11 @@ function installRule(markdownIt, mdOptions, ruleOptions, context) {
};
}
module.exports = function(context, ruleOptions) {
return function(md, mdOptions) {
installRule(md, mdOptions, ruleOptions, context);
};
module.exports = {
install: function(context, ruleOptions) {
return function(md, mdOptions) {
installRule(md, mdOptions, ruleOptions, context);
};
},
style: fountainCss,
};

View File

@ -14,6 +14,14 @@ const stringifySafe = require('json-stringify-safe');
katex = mhchemModule(katex);
function katexStyle() {
return [
{ name: 'katex.css' },
// Note: Katex also requires a number of fonts but they don't need to be specified here
// since they will be loaded as needed from the CSS.
];
}
// Test if potential opening or closing delimieter
// Assumes that there is a "$" at state.src[pos]
function isValidDelim(state, pos) {
@ -184,97 +192,78 @@ function math_block(state, start, end, silent) {
const cache_ = {};
module.exports = function(context) {
// 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: {} };
module.exports = {
install: function(context) {
// 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.pluginAssets['katex'] = [
{ name: 'katex.css' },
{ name: 'fonts/KaTeX_Main-Regular.woff2' },
{ name: 'fonts/KaTeX_Main-Bold.woff2' },
{ name: 'fonts/KaTeX_Main-BoldItalic.woff2' },
{ name: 'fonts/KaTeX_Main-Italic.woff2' },
{ name: 'fonts/KaTeX_Math-Italic.woff2' },
{ name: 'fonts/KaTeX_Math-BoldItalic.woff2' },
{ name: 'fonts/KaTeX_Size1-Regular.woff2' },
{ name: 'fonts/KaTeX_Size2-Regular.woff2' },
{ name: 'fonts/KaTeX_Size3-Regular.woff2' },
{ name: 'fonts/KaTeX_Size4-Regular.woff2' },
{ name: 'fonts/KaTeX_AMS-Regular.woff2' },
{ name: 'fonts/KaTeX_Caligraphic-Bold.woff2' },
{ name: 'fonts/KaTeX_Caligraphic-Regular.woff2' },
{ name: 'fonts/KaTeX_Fraktur-Bold.woff2' },
{ name: 'fonts/KaTeX_Fraktur-Regular.woff2' },
{ name: 'fonts/KaTeX_SansSerif-Bold.woff2' },
{ name: 'fonts/KaTeX_SansSerif-Italic.woff2' },
{ name: 'fonts/KaTeX_SansSerif-Regular.woff2' },
{ name: 'fonts/KaTeX_Script-Regular.woff2' },
{ name: 'fonts/KaTeX_Typewriter-Regular.woff2' },
];
};
const addContextAssets = () => {
context.pluginAssets['katex'] = katexStyle();
};
function renderToStringWithCache(latex, options) {
const cacheKey = md5(escape(latex) + escape(stringifySafe(options)));
if (cacheKey in cache_) {
return cache_[cacheKey];
} else {
const beforeMacros = stringifySafe(options.macros);
const output = katex.renderToString(latex, options);
const afterMacros = stringifySafe(options.macros);
function renderToStringWithCache(latex, options) {
const cacheKey = md5(escape(latex) + escape(stringifySafe(options)));
if (cacheKey in cache_) {
return cache_[cacheKey];
} else {
const beforeMacros = stringifySafe(options.macros);
const output = katex.renderToString(latex, options);
const afterMacros = stringifySafe(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;
// 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
return function(md, options) {
// Default options
options = options || {};
options.macros = context.__katex.macros;
options.trust = true;
options = options || {};
options.macros = context.__katex.macros;
options.trust = true;
// set KaTeX as the renderer for markdown-it-simplemath
const katexInline = function(latex) {
options.displayMode = false;
try {
return `<span class="joplin-editable"><pre class="joplin-source" data-joplin-source-open="$" data-joplin-source-close="$">${latex}</pre>${renderToStringWithCache(latex, options)}</span>`;
} catch (error) {
console.error('Katex error for:', latex, error);
return latex;
}
// set KaTeX as the renderer for markdown-it-simplemath
const katexInline = function(latex) {
options.displayMode = false;
try {
return `<span class="joplin-editable"><span class="joplin-source" data-joplin-source-open="$" data-joplin-source-close="$">${latex}</span>${renderToStringWithCache(latex, options)}</span>`;
} catch (error) {
console.error('Katex error for:', latex, error);
return latex;
}
};
const inlineRenderer = function(tokens, idx) {
addContextAssets();
return katexInline(tokens[idx].content);
};
const katexBlock = function(latex) {
options.displayMode = true;
try {
return `<div class="joplin-editable"><pre class="joplin-source" data-joplin-source-open="$$&#10;" data-joplin-source-close="&#10;$$&#10;">${latex}</pre>${renderToStringWithCache(latex, options)}</div>`;
} catch (error) {
console.error('Katex error for:', latex, error);
return latex;
}
};
const 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;
};
const inlineRenderer = function(tokens, idx) {
addContextAssets();
return katexInline(tokens[idx].content);
};
const katexBlock = function(latex) {
options.displayMode = true;
try {
return `<div class="joplin-editable"><pre class="joplin-source" data-joplin-source-open="$$&#10;" data-joplin-source-close="&#10;$$&#10;">${latex}</pre>${renderToStringWithCache(latex, options)}</div>`;
} catch (error) {
console.error('Katex error for:', latex, error);
return latex;
}
};
const 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;
};
},
style: katexStyle,
};

View File

@ -1,7 +1,5 @@
function addContextAssets(context:any) {
if ('mermaid' in context.pluginAssets) return;
context.pluginAssets['mermaid'] = [
function style() {
return [
{ name: 'mermaid.min.js' },
{ name: 'mermaid_render.js' },
{
@ -15,6 +13,12 @@ function addContextAssets(context:any) {
];
}
function addContextAssets(context:any) {
if ('mermaid' in context.pluginAssets) return;
context.pluginAssets['mermaid'] = style();
}
// @ts-ignore: Keep the function signature as-is despite unusued arguments
function installRule(markdownIt:any, mdOptions:any, ruleOptions:any, context:any) {
const defaultRender:Function = markdownIt.renderer.rules.fence || function(tokens:any[], idx:number, options:any, env:any, self:any) {
@ -35,8 +39,11 @@ function installRule(markdownIt:any, mdOptions:any, ruleOptions:any, context:any
};
}
export default function(context:any, ruleOptions:any) {
return function(md:any, mdOptions:any) {
installRule(md, mdOptions, ruleOptions, context);
};
}
export default {
install: function(context:any, ruleOptions:any) {
return function(md:any, mdOptions:any) {
installRule(md, mdOptions, ruleOptions, context);
};
},
style: style,
};