All checks were successful
continuous-integration/drone/push Build is passing
703 lines
25 KiB
JavaScript
703 lines
25 KiB
JavaScript
import listEditor from "wiki-list-editor";
|
|
import menu from "./menu";
|
|
import createPageSearch from "./fuse";
|
|
import util from "./util";
|
|
import search from "./search";
|
|
import axios from 'axios';
|
|
import qs from 'querystring'
|
|
import $ from 'jquery';
|
|
import getCaretCoordinates from './caret-position'
|
|
import moment from 'moment'
|
|
import "moment/locale/nl"
|
|
// import mermaid from 'mermaid'
|
|
// import {Network, parseDOTNetwork} from "vis-network/peer";
|
|
// import PrismJS from 'prismjs'
|
|
// import 'prismjs/plugins/filter-highlight-all/prism-filter-highlight-all'
|
|
// import 'prismjs/components/prism-php'
|
|
// import 'prismjs/components/prism-go'
|
|
// import 'prismjs/components/prism-perl'
|
|
// import 'prismjs/components/prism-css'
|
|
// import 'prismjs/components/prism-markup-templating'
|
|
// import 'prismjs/components/prism-jq'
|
|
import MD from './markdown'
|
|
import actions from './actions'
|
|
|
|
moment.locale('nl')
|
|
|
|
let math;
|
|
|
|
function isMultiline(input) {
|
|
return input.value.startsWith("```", 0)
|
|
|| input.value.startsWith("$$", 0)
|
|
}
|
|
|
|
function addSaver(editor, saveUrl, page, beforeSave) {
|
|
return {
|
|
save() {
|
|
return editor.save()
|
|
.then(outputData => {
|
|
beforeSave(outputData)
|
|
let data = {
|
|
'json': 1,
|
|
'p': page,
|
|
'summary': "",
|
|
'content': JSON.stringify(outputData),
|
|
};
|
|
return axios.post(saveUrl, qs.encode(data))
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
function Indicator(element, timeout) {
|
|
let timeoutId;
|
|
|
|
return {
|
|
done() {
|
|
timeoutId = setTimeout(() => {
|
|
element.classList.add('hide')
|
|
element.classList.remove('error')
|
|
}, timeout * 1000);
|
|
},
|
|
|
|
addError(message) {
|
|
element.classList.add('error')
|
|
this.setText(message)
|
|
},
|
|
|
|
setText(text) {
|
|
if (timeoutId) {
|
|
clearTimeout(timeoutId);
|
|
timeoutId = null;
|
|
}
|
|
element.classList.remove('hide')
|
|
element.innerText = text;
|
|
},
|
|
}
|
|
}
|
|
|
|
function addIndicator(editor, indicator) {
|
|
return {
|
|
save() {
|
|
return editor.save()
|
|
.then(() => {
|
|
console.log('success while saving')
|
|
indicator.setText('saved!');
|
|
indicator.done();
|
|
return true
|
|
})
|
|
.catch(reason => {
|
|
console.warn('error while saving: ' + reason)
|
|
indicator.addError('error!')
|
|
indicator.done();
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
function showSearchResults(searchTool, query, input, value, resultType) {
|
|
return showSearchResultsExtended('#link-complete', 'link-template', searchTool, query, input, value, resultType, {
|
|
showOnlyResults: true,
|
|
belowCursor: true
|
|
})
|
|
}
|
|
|
|
function showSearchResultsExtended(element, template, searchTool, query, input, value, resultType, options) {
|
|
const $lc = $(element)
|
|
// FIXME(peter): we should not reload this every time we search
|
|
return searchTool(query).then(results => {
|
|
let opt = options || {};
|
|
if (opt.showOnlyResults && (query.length === 0 || !results.length)) {
|
|
$lc.hide()
|
|
return
|
|
}
|
|
$lc.data('result-type', resultType)
|
|
|
|
const visible = $(':visible', $lc).length
|
|
|
|
let selectedPos = 0;
|
|
let selected = $lc.find('li.selected');
|
|
if (selected) {
|
|
selectedPos = $lc.find('li').index(selected[0])
|
|
}
|
|
|
|
let $ul = el('ul',
|
|
_.map(results, (hit, i) => {
|
|
let div = el('div', []);
|
|
div.innerHTML = hit.text
|
|
const fragment = hit.text ? [div] : []
|
|
|
|
let children = [
|
|
document.createTextNode(hit.title || hit.item.label || hit.item.title),
|
|
...(fragment)
|
|
];
|
|
if (hit.ref) {
|
|
children = hit.ref ? [el('a', children)] : children;
|
|
children[0].setAttribute('href', '/edit/' + hit.ref)
|
|
}
|
|
const li = el('li', children)
|
|
if (selectedPos === i) li.classList.add('selected')
|
|
li.dataset['new_page'] = hit.title || hit.item.title
|
|
return li
|
|
})
|
|
)
|
|
|
|
$lc.show()
|
|
.html($ul)
|
|
|
|
if (opt.belowCursor) {
|
|
let pos = getCaretCoordinates(input, value.selectionEnd, {})
|
|
let off = $(input).offset()
|
|
$lc.offset({top: off.top + pos.top + pos.height, left: off.left + pos.left})
|
|
}
|
|
|
|
return results
|
|
}).catch(e => console.log('searchtool', e))
|
|
}
|
|
|
|
function formatLineResult(hits, key) {
|
|
return [
|
|
{
|
|
text: "[[" + key + "]]",
|
|
indented: 0,
|
|
fold: 'open',
|
|
hidden: false,
|
|
fleeting: true
|
|
},
|
|
..._.map(hits, (hit) => {
|
|
return {
|
|
text: hit.line,
|
|
indented: 1,
|
|
fold: 'open',
|
|
hidden: false,
|
|
fleeting: true
|
|
}
|
|
})
|
|
]
|
|
}
|
|
|
|
function formatTitleResult(hit) {
|
|
return [
|
|
{
|
|
text: "[[" + hit.title + "]]",
|
|
indented: 0,
|
|
fold: 'open',
|
|
hidden: false,
|
|
fleeting: true
|
|
}
|
|
]
|
|
}
|
|
|
|
function renderGraphs() {
|
|
$('code.language-dot').each(function (i, code) {
|
|
if (!code.innerText) {
|
|
return true
|
|
}
|
|
|
|
let data = parseDOTNetwork(code.innerText)
|
|
let network = new Network(code, data, {
|
|
layout: {
|
|
randomSeed: 1239043
|
|
}
|
|
});
|
|
$(code).on('click', function () {
|
|
return false
|
|
})
|
|
})
|
|
}
|
|
|
|
function el(tag, children = []) {
|
|
let el = document.createElement(tag)
|
|
_.each(children, item => {
|
|
if (item !== undefined) el.appendChild(item)
|
|
})
|
|
return el
|
|
}
|
|
|
|
function mflatten(rowData) {
|
|
return Promise.all(rowData.reduce(function (a, b) {
|
|
return a.concat(b);
|
|
}, []));
|
|
}
|
|
|
|
function flatten(rowData) {
|
|
return rowData.reduce(function (a, b) {
|
|
return a.concat(b);
|
|
}, []);
|
|
}
|
|
|
|
function Editor(holder, input) {
|
|
function renderInline(cellText) {
|
|
if (!cellText) return document.createTextNode('')
|
|
|
|
MD.options.html = true
|
|
let rendered = MD.renderInline(cellText)
|
|
let span = $("<span>" + rendered + "</span>")
|
|
MD.options.html = false
|
|
return span[0];
|
|
}
|
|
|
|
function promiseMapAll(p, f) {
|
|
return p.then(all => Promise.all(_.map(all, f)))
|
|
}
|
|
|
|
function transformTable(editor, id, element) {
|
|
editor.treeForId(id)
|
|
.then(tree => {
|
|
let header = _.find(tree[0].children, c => c.text === 'headers') || []
|
|
let rows = _.find(tree[0].children, c => c.text === 'rows') || []
|
|
|
|
let collection = _.map(rows.children, child => {
|
|
let res = child.text.match(/{{query(!?):\s*([^}]+)}}/)
|
|
if (res && res[1] === '!') {
|
|
return search.startQuery(res[2])
|
|
.then(hits => _.uniqBy(_.flatMap(hits, formatTitleResult), _.property('text')))
|
|
}
|
|
return Promise.resolve().then(() => [child])
|
|
});
|
|
|
|
Promise.all(collection)
|
|
.then(_.flatten)
|
|
.then(_.partialRight(_.map, _.property('text')))
|
|
.then(_.uniq)
|
|
.then(rowTexts => {
|
|
return _.map(rowTexts, rowText => {
|
|
let page = rowText.substring(2).substring(0, rowText.length - 4)
|
|
return fetch('/' + page + '?format=metakv')
|
|
.then(res => res.ok ? res.json() : {meta: {}})
|
|
.then(res => res.meta)
|
|
.then(rowData => {
|
|
rowData.title = rowData.title || rowText
|
|
return el("tr", [
|
|
el("td", [renderInline(rowText)]),
|
|
..._.map(header.children, col => {
|
|
let td = el("td")
|
|
let key = _.trim(col.text);
|
|
let value = _.get(rowData, key)
|
|
if (_.isObject(value) && value.line) {
|
|
value = value.line
|
|
}
|
|
if (col.children && col.children.length > 0) {
|
|
value = col.children[0].text
|
|
}
|
|
transform(typeof value === 'string' ? value.replace(/^:/, '=') : '', $(td), id, editor, {row: rowData})
|
|
return td
|
|
})
|
|
])
|
|
}).catch(e => console.log('while fetching metakv', e))
|
|
})
|
|
})
|
|
.then(mflatten)
|
|
.then(trs => {
|
|
return el("table", [
|
|
el("thead", [
|
|
el("tr", [
|
|
el("th", [
|
|
document.createTextNode("Title")
|
|
]),
|
|
..._.map(header.children, col => {
|
|
return el("th", [
|
|
document.createTextNode(col.text)
|
|
])
|
|
})
|
|
]),
|
|
]),
|
|
el("tbody", trs)
|
|
])
|
|
})
|
|
.then(table => {
|
|
table.classList.add('table')
|
|
table.classList.add('wiki-table')
|
|
|
|
let div = el('div', [table])
|
|
div.classList.add('table-wrapper')
|
|
|
|
return element.html(div);
|
|
}).catch(e => console.log('while creating table', e))
|
|
})
|
|
.catch(e => console.log('transformTable', e))
|
|
}
|
|
|
|
async function transformMathExpression(converted, scope) {
|
|
return Promise.all([
|
|
import(/* webpackChunkName: "mathjs" */ 'mathjs'),
|
|
import(/* webpackChunkName: "mathjs" */ './formula')
|
|
]).then((promises) => {
|
|
const mathjs = promises[0]
|
|
if (math === undefined) {
|
|
math = mathjs.create(mathjs.all)
|
|
math.import(promises[1])
|
|
}
|
|
let expr = converted.substring(1);
|
|
let parsedExpr = math.parse(expr)
|
|
let compiled = parsedExpr.compile()
|
|
let evaluated = compiled.evaluate(scope);
|
|
if (parsedExpr.isAssignmentNode) {
|
|
return parsedExpr.object.name + " = <i>" + evaluated.toString() + "</i>"
|
|
}
|
|
return "<span class='expression'>" + expr + " = </span><i>" + evaluated.toString() + "</i>"
|
|
}).catch(e => {
|
|
return converted + ' <span style="background: red; color: white;">' + e.message + '</span>';
|
|
})
|
|
}
|
|
|
|
function transform(text, element, id, editor, scope) {
|
|
if (text === undefined) {
|
|
return;
|
|
}
|
|
|
|
if (!scope) {
|
|
scope = editor.scope || {}
|
|
}
|
|
|
|
let converted = text
|
|
|
|
if (converted === '{{table}}') {
|
|
transformTable.call(this, editor, id, element);
|
|
return
|
|
} else if (converted.startsWith("```", 0) || converted.startsWith("$$", 0)) {
|
|
converted = MD.render(converted)
|
|
} else if (converted.startsWith("=", 0)) {
|
|
transformMathExpression(converted, scope)
|
|
.then(converted => {
|
|
MD.options.html = true
|
|
converted = MD.renderInline(converted)
|
|
MD.options.html = false
|
|
|
|
element.html(converted)
|
|
})
|
|
.catch(e => console.warn(e))
|
|
} else {
|
|
// let re = /^([A-Z0-9 ]+)::\s*(.*)$/i;
|
|
// let res = text.match(re)
|
|
// if (res) {
|
|
// converted = '<span class="metadata-key">[[' + res[1] + ']]</span>'
|
|
// if (res[2]) {
|
|
// converted += ': ' + res[2]
|
|
// }
|
|
// } else if (text.match(/#\[\[TODO]]/)) {
|
|
// converted = converted.replace('#[[TODO]]', '<input class="checkbox" type="checkbox" />')
|
|
// todo = true;
|
|
// } else if (text.match(/#\[\[DONE]]/)) {
|
|
// converted = converted.replace('#[[DONE]]', '<input class="checkbox" type="checkbox" checked />')
|
|
// todo = false;
|
|
// }
|
|
MD.options.html = true
|
|
converted = MD.renderInline(converted)
|
|
MD.options.html = false
|
|
}
|
|
|
|
try {
|
|
const todoItem = $.parseHTML(converted)
|
|
if (todoItem.length && $(todoItem[0]).is(':checkbox')) {
|
|
const todo = !$(todoItem[0]).is(':checked')
|
|
element.toggleClass('todo--done', todo === false)
|
|
element.toggleClass('todo--todo', todo === true)
|
|
} else {
|
|
element.removeClass(['todo--todo', 'todo--done'])
|
|
}
|
|
} catch (e) {
|
|
// problem with $(converted) is that it could be treated as a jQuery selector expression instead of normal text
|
|
// the wiki text is not quite like selectors
|
|
}
|
|
|
|
element.html(converted)
|
|
}
|
|
|
|
const options = {
|
|
transform
|
|
}
|
|
|
|
let inputData = input ? input : JSON.parse(holder.dataset.input)
|
|
|
|
let editor = listEditor(holder, inputData, options);
|
|
holder.$listEditor = editor
|
|
editor.scope = {}
|
|
editor.actions = actions
|
|
|
|
$(holder).on('click', '.content input[type="checkbox"]', function (event) {
|
|
let that = this
|
|
let li = $(this).parents('.list-item');
|
|
let id = li.attr('data-id')
|
|
editor.update(id, function (item, prev, next) {
|
|
if (that.checked) {
|
|
item.text = item.text.replace('#[[TODO]]', '#[[DONE]]')
|
|
} else {
|
|
item.text = item.text.replace('#[[DONE]]', '#[[TODO]]')
|
|
}
|
|
return item
|
|
});
|
|
return true
|
|
})
|
|
|
|
editor.on('change', function () {
|
|
let element = holder
|
|
let indicator = Indicator(document.getElementById('save-indicator'), 2);
|
|
let saveUrl = element.dataset.saveurl;
|
|
let page = element.dataset.page;
|
|
|
|
let beforeSave = (curDoc) => {
|
|
indicator.setText('saving...')
|
|
}
|
|
|
|
addIndicator(
|
|
addSaver(editor, saveUrl, page, beforeSave),
|
|
indicator
|
|
).save()
|
|
.then(() => indicator.done())
|
|
.catch(e => console.log('editor.change', e))
|
|
})
|
|
|
|
// editor.on('rendered', function () {
|
|
// PrismJS.highlightAll()
|
|
// mermaid.init()
|
|
// renderGraphs();
|
|
// })
|
|
|
|
menu.connectContextMenu(editor)
|
|
|
|
return createPageSearch().then(function ({titleSearch, commandSearch, commands}) {
|
|
editor.on('start-editing', function (input) {
|
|
const $lc = $('#link-complete');
|
|
|
|
$(input).parents('.list-item').addClass('active');
|
|
|
|
$lc.on('popup:selected', function (event, linkName, resultType, element) {
|
|
let value = input.value
|
|
let end = input.selectionEnd
|
|
if (resultType === 'link') {
|
|
let start = value.lastIndexOf("[[", end)
|
|
end += 2
|
|
let startAndLink = value.substring(0, start) + "[[" + _.trim(element[0].dataset['new_page']) + "]]"
|
|
input.value = startAndLink + value.substring(end)
|
|
input.selectionStart = startAndLink.length
|
|
input.selectionEnd = startAndLink.length
|
|
input.focus()
|
|
} else if (resultType === 'command') {
|
|
let start = value.lastIndexOf("/", end)
|
|
let commandResult = ""
|
|
let replace = ""
|
|
let prefix = false
|
|
let adjustment = 0
|
|
|
|
let now = moment()
|
|
|
|
if (linkName === "Current Time") {
|
|
commandResult = now.format('HH:mm')
|
|
} else if (linkName === "Today") {
|
|
commandResult = "[[" + now.format('D MMMM YYYY') + "]]"
|
|
} else if (linkName === "Tomorrow") {
|
|
commandResult = "[[" + now.add(1, 'day').format('D MMMM YYYY') + "]]"
|
|
} else if (linkName === "Yesterday") {
|
|
commandResult = "[[" + now.add(-1, 'day').format('D MMMM YYYY') + "]]"
|
|
} else if (linkName === "TODO") {
|
|
commandResult = "#[[TODO]] "
|
|
replace = "#[[DONE]] "
|
|
prefix = true
|
|
} else if (linkName === "DONE") {
|
|
commandResult = "#[[DONE]] "
|
|
replace = "#[[TODO]] "
|
|
prefix = true
|
|
} else if (linkName === "Page Reference") {
|
|
commandResult = "[[]]"
|
|
adjustment = -2
|
|
} else if (linkName === "Code Block") {
|
|
commandResult = "```\n\n```"
|
|
adjustment = -5
|
|
}
|
|
|
|
let startAndLink = prefix
|
|
? commandResult + value.substring(0, start).replace(replace, "")
|
|
: value.substring(0, start) + commandResult
|
|
|
|
input.value = startAndLink + value.substring(end)
|
|
|
|
input.selectionStart = startAndLink.length + adjustment
|
|
input.selectionEnd = startAndLink.length + adjustment
|
|
|
|
input.focus()
|
|
}
|
|
return true
|
|
})
|
|
|
|
$lc.on('popup:leave', function (event) {
|
|
input.focus()
|
|
})
|
|
|
|
$(input).on('keydown', function (event) {
|
|
const isVisible = $('#link-complete:visible').length > 0;
|
|
|
|
if (event.key === 'Escape' && isVisible) {
|
|
$lc.hide()
|
|
return false
|
|
} else if (event.key === 'Enter' && isVisible) {
|
|
const element = $lc.find('li.selected')
|
|
const linkName = element.text()
|
|
$lc.trigger('popup:selected', [linkName, $lc.data('result-type'), element])
|
|
$lc.hide()
|
|
return false
|
|
} else if (event.key === 'ArrowUp' && isVisible) {
|
|
const selected = $lc.find('li.selected')
|
|
const prev = selected.prev('li')
|
|
if (prev.length) {
|
|
prev.addClass('selected')
|
|
selected.removeClass('selected')
|
|
prev[0].scrollIntoView({block: 'center', inline: 'nearest'})
|
|
} else {
|
|
// move back from dropdown to input
|
|
$lc.trigger('popup:leave')
|
|
selected.removeClass('selected')
|
|
}
|
|
return false
|
|
} else if (event.key === 'ArrowDown' && isVisible) {
|
|
const selected = $lc.find('li.selected')
|
|
if (!selected.length) {
|
|
$lc.find('li:not(.selected):first-child').addClass('selected')
|
|
return false
|
|
}
|
|
|
|
const next = selected.next('li');
|
|
if (next.length) {
|
|
next.addClass('selected')
|
|
selected.removeClass('selected')
|
|
next[0].scrollIntoView({block: 'center', inline: 'nearest'})
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
let mirror = {
|
|
'[': ']',
|
|
'(': ')',
|
|
'{': '}',
|
|
}
|
|
|
|
if (!isMultiline(input) && mirror.hasOwnProperty(event.key)) {
|
|
let input = this
|
|
let val = input.value
|
|
let prefix = val.substring(0, input.selectionStart)
|
|
let selection = val.substring(input.selectionStart, input.selectionEnd)
|
|
let suffix = val.substring(input.selectionEnd)
|
|
input.value = prefix + event.key + selection + mirror[event.key] + suffix
|
|
input.selectionStart = prefix.length + event.key.length
|
|
input.selectionEnd = input.selectionStart + selection.length
|
|
$(input).trigger('input')
|
|
return false;
|
|
}
|
|
|
|
return true
|
|
})
|
|
|
|
let searchEnabled = false
|
|
|
|
$(input).on('keyup', function (event) {
|
|
if (event.key === '/') {
|
|
searchEnabled = true
|
|
}
|
|
if (searchEnabled && (event.key === 'Escape' || event.key === 'Space')) {
|
|
searchEnabled = false
|
|
return false;
|
|
}
|
|
const ignoreKeys = {
|
|
'ArrowUp': true,
|
|
'ArrowDown': true,
|
|
'Enter': true,
|
|
}
|
|
|
|
if (event.key in ignoreKeys) {
|
|
return false
|
|
}
|
|
|
|
let value = input.value
|
|
let end = input.selectionEnd
|
|
|
|
let [start, insideLink] = util.cursorInsideLink(value, end)
|
|
let insideSearch = false
|
|
|
|
if (searchEnabled && !insideLink) {
|
|
start = value.lastIndexOf("/", end)
|
|
insideSearch = start >= 0
|
|
}
|
|
|
|
if (insideSearch) {
|
|
let query = value.substring(start + 1, end);
|
|
showSearchResults(commandSearch, query, input, value, 'command')
|
|
.then(results => {
|
|
if (query.length > 0 && (!results || results.length === 0)) {
|
|
searchEnabled = false
|
|
}
|
|
}).catch(e => console.log('showSearchResults', e))
|
|
return true
|
|
} else if (insideLink) {
|
|
let query = value.substring(start, end);
|
|
showSearchResults(titleSearch, query, input, value, 'link');
|
|
return true
|
|
} else {
|
|
$('#link-complete').hide();
|
|
}
|
|
})
|
|
})
|
|
|
|
editor.on('stop-editing', function (input, id) {
|
|
let $input = $(input);
|
|
$input.parents('.list-item').removeClass('active')
|
|
$('#link-complete').off()
|
|
// PrismJS.highlightAll()
|
|
// mermaid.init()
|
|
// renderGraphs();
|
|
if (!$input.val()) return
|
|
|
|
let query = $input.val()
|
|
|
|
match(query, /{{query(!?):\s*([^}]+)}}/)
|
|
.then(res => {
|
|
if (res[1] === '!') {
|
|
return search.startQuery(res[2])
|
|
.then(hits => _.uniqBy(_.flatMap(hits, formatTitleResult), _.property('text')))
|
|
.then(results => editor.replaceChildren(id, results))
|
|
.catch(e => console.log('search query', e))
|
|
.finally(() => editor.render())
|
|
} else {
|
|
return search.startQuery(res[2])
|
|
.then(hits => _.groupBy(hits, (it) => it.title))
|
|
.then(hits => _.flatMap(hits, formatLineResult))
|
|
.then(results => editor.replaceChildren(id, results))
|
|
.catch(e => console.log('search query', e))
|
|
.finally(() => editor.render())
|
|
}
|
|
}).catch(e => {})
|
|
});
|
|
return editor
|
|
})
|
|
}
|
|
|
|
function match(s, re) {
|
|
return new Promise((resolve, reject) => {
|
|
let res = s.match(re)
|
|
if (res) resolve(res)
|
|
else reject(s + ' did not match ' + re)
|
|
});
|
|
}
|
|
|
|
let searchInput = document.getElementById('search-input');
|
|
|
|
_.tap(search.search(searchInput), searcher => {
|
|
let showSearch = _.debounce(function (searcher) {
|
|
let query = $(searchInput).val()
|
|
if (query === '') {
|
|
$('#autocomplete').hide()
|
|
return false
|
|
}
|
|
showSearchResultsExtended('#autocomplete', 'result-template', query => searcher.search(query), query, searchInput, query, 'search-result')
|
|
return true;
|
|
}, 200)
|
|
|
|
$(searchInput).on('keyup', function (event) {
|
|
showSearch.cancel()
|
|
showSearch(searcher)
|
|
return true
|
|
})
|
|
})
|
|
|
|
export default Editor;
|