wiki/list-editor/index.js
Peter Stuifzand 39dd45b65d
All checks were successful
continuous-integration/drone/push Build is passing
Rotate items to moveBefore
2020-10-30 23:43:00 +01:00

558 lines
17 KiB
JavaScript

import _ from 'lodash'
import $ from 'jquery'
import he from 'he'
import dragula from 'dragula'
import textareaAutosizeInit from "./textarea.autosize"
import createCursor from './cursor'
import createSelection from './selection'
import Store from './store'
textareaAutosizeInit($)
function editor(root, inputData, options) {
let cursor = createCursor()
let selection = createSelection()
let store = createStore(inputData);
let drake = null;
function createStore(inputData) {
let data = [
{indented: 0, text: '', fold: 'open'},
];
if (inputData.length) {
data = inputData
}
return Store(data);
}
function save() {
return new Promise(function (resolve, reject) {
if (store.hasChanged()) {
resolve(store.debug().result)
store.clearChanged()
} else {
reject()
}
});
}
function saveTree(from) {
return new Promise(function (resolve, reject) {
if (store.hasChanged()) {
resolve(store.tree(from))
store.clearChanged()
}
});
}
function treeForId(id) {
return new Promise(function (resolve, reject) {
resolve(store.tree(id))
})
}
function copy(element, opt) {
let item = $(element).parents('.list-item')
let id = item.attr('data-id')
if (opt.recursive) {
return saveTree(id)
}
return new Promise(function (resolve, reject) {
resolve(store.value(id));
});
}
function zoomin(element, opt) {
let item = $(element).parents('.list-item')
let id = item.attr('data-id')
return new Promise(function (resolve, reject) {
resolve(id);
});
}
function on(evt, handler) {
events[evt].push(handler)
}
function trigger(event) {
let args = [...arguments]
args.splice(0, 1)
_.each(events[event], function (handler) {
handler(...args)
})
}
function start() {
disableDragging(drake)
render(root, store);
drake = enableDragging(root)
cursor.set(0)
startEditing(root, store, cursor)
}
function replaceChildren(id, children) {
store.replaceChildren(id, children)
}
function renderUpdate() {
disableDragging(drake)
render(root, store);
drake = enableDragging(root)
}
let EDITOR = {
on,
save,
saveTree,
copy,
update,
start,
zoomin,
treeForId,
replaceChildren,
render: renderUpdate
};
root.classList.add('root')
root.setAttribute('tabindex', '-1')
let defaults = {
transform(text, element) {
element.html(he.encode(text))
}
}
options = _.merge(defaults, options)
let events = {
change: [],
'start-editing': [],
'stop-editing': [],
'rendering': [],
'rendered': []
}
let editing = false
let currentEditor = null;
function newListItem(indented) {
return {indented: indented, text: '', fold: 'open', hidden: false}
}
function newItem(value) {
let el = $('<div class="list-item">')
.attr('data-id', value.id)
.data('indented', value.indented)
.css('margin-left', (value.indented * 32) + 'px')
let line = $('<div class="line">')
let content = $('<div class="content">')
line.prepend(content)
options.transform(value.text, content, value.id, EDITOR)
line.prepend($('<span class="marker"></span>'))
line.prepend($('<span class="fold">&#9654;</span>'))
el.prepend(line)
return el;
}
/**
* @param {Element} rootElement
* @param rootData
*/
function render(rootElement, rootData) {
trigger('rendering')
let first = 0;
let last = rootData.length();
let elements = $(rootElement).children('div.list-item');
let $enter = elements.slice(first, last);
let enterData = rootData.slice(first, $enter.length);
let exitData = rootData.slice($enter.length);
let $exitEl = elements.slice($enter.length)
let hideLevel = 99999;
$enter.each(function (index, li) {
let storeId = enterData[index]
let value = rootData.value(storeId)
let hasChildren = false;
if (index + 1 < last) {
let next = rootData.afterValue(storeId)
hasChildren = next && (value.indented < next.indented)
}
let $li = $(li)
.attr('data-id', value.id)
.toggleClass('selected', cursor.atPosition(index))
.toggleClass('selection-first', selection.isSelectedFirst(index))
.toggleClass('selection-last', selection.isSelectedLast(index))
.toggleClass('selection', selection.isSelected(index))
.toggleClass('hidden', value.indented >= hideLevel)
.toggleClass('border', value.indented >= 1)
.css('margin-left', (value.indented * 32) + 'px')
value.hidden = value.indented >= hideLevel
options.transform(value.text, $li.find('.content'), value.id, EDITOR)
if (value.indented < hideLevel) {
if (value.fold !== 'open') {
hideLevel = value.indented + 1
} else {
hideLevel = 99999;
}
}
$('.fold', $(li))
.toggleClass('open', value.fold === 'open')
.toggleClass('no-children', !hasChildren)
$li.toggleClass('no-children', !hasChildren)
.toggleClass('open', value.fold === 'open')
});
_.each(exitData, function (storeId, index) {
let value = rootData.value(storeId)
let $li = newItem(value)
.css('margin-left', (value.indented * 32) + 'px')
.toggleClass('selection-first', selection.isSelectedFirst(index))
.toggleClass('selection-last', selection.isSelectedLast(index))
.toggleClass('selection', selection.isSelected(index))
.toggleClass('selected', cursor.atPosition(index + $enter.length))
.toggleClass('border', value.indented >= 1)
.toggleClass('hidden', value.indented >= hideLevel);
value.hidden = value.indented >= hideLevel
let hasChildren = false;
if (enterData.length + index + 1 < last) {
let next = rootData.afterValue(storeId)
hasChildren = next && (value.indented < next.indented)
}
if (value.indented < hideLevel) {
if (value.fold === 'open') {
hideLevel = 99999;
} else {
hideLevel = value.indented + 1
}
}
$('.fold', $li)
.toggleClass('open', value.fold === 'open')
.toggleClass('no-children', !hasChildren)
$li.toggleClass('no-children', !hasChildren)
.toggleClass('open', value.fold === 'open')
$(rootElement).append($li)
})
$exitEl.remove()
trigger('rendered')
}
function disableDragging(drake) {
if (drake) drake.destroy();
}
function enableDragging(rootElement) {
let drake = dragula([rootElement], {
moves: function (el, container, handle) {
return handle.classList.contains('marker')
}
});
let start = -1;
let startID = null;
drake.on('drag', function (el, source) {
startID = $(el).attr('data-id')
})
drake.on('drop', function (el, target, source, sibling) {
stopEditing(root, store, currentEditor)
let stopID = $(sibling).attr('data-id')
if (startID === stopID) {
return
}
let id = store.moveBefore(startID, stopID)
if (id === startID) {
return
}
let position = store.index(id);
cursor.set(position)
selection.selectOne(position, store)
_.defer(renderUpdate)
trigger('change')
})
return drake;
}
function stopEditing(rootElement, store, element) {
if (!editing) return
if (element === null) {
editing = false
currentEditor = null
return
}
let text = element.val()
element.closest('.list-item').removeClass('editor');
let id = element.data('id')
store.update(element.data('id'), (value) => {
return _.merge(value, {
text: text
})
})
if (store.hasChanged()) {
trigger('change')
store.clearChanged()
}
let $span = $('<div class="content">');
options.transform(text, $span, id, EDITOR)
element.replaceWith($span);
trigger('stop-editing', currentEditor[0], id)
editing = false
currentEditor = null
$(root).focus()
}
/**
* @param {Element} rootElement
* @param {Store} store
* @param cursor
* @returns {jQuery|HTMLElement}
*/
function startEditing(rootElement, store, cursor) {
if (editing) return
editing = true
let elements = $(rootElement).children('div.list-item');
let $textarea = $('<textarea rows=1 class="input-line" spellcheck="false">');
$textarea.val(cursor.getCurrent(store).text).trigger('input')
let currentElement = cursor.getCurrentElement(elements);
currentElement.find('.content').replaceWith($textarea)
currentElement.addClass('editor');
$textarea.focus()
$textarea.data(cursor.getCurrent(store))
$textarea.textareaAutoSize()
currentEditor = $textarea
trigger('start-editing', currentEditor[0])
return $textarea
}
$(root).on('paste', '.input-line', function (event) {
let tag = event.target.tagName.toLowerCase();
if (tag === 'textarea' && currentEditor[0].value.substring(0, 3) === '```') {
return true
}
let parentItem = $(this).parents('.list-item')
let index = $(root).children('div.list-item').index(parentItem)
let pastedData = event.originalEvent.clipboardData.getData('text')
let lines = pastedData.split(/\n/)
if (lines.length === 1) {
return true;
}
let currentID = store.currentID(index);
let baseIndent = store.value(currentID).indented
let newItems = _.filter(_.map(lines, function (line) {
if (line.length === 0) return;
let matches = line.match(/(\s{4})/g)
let relIndent = matches ? matches.length : 0;
let newItem = newListItem(baseIndent + relIndent)
newItem.text = line.replace(/^\s+/, '')
return newItem
}))
store.insertAfter(currentID, ...newItems)
disableDragging(drake)
render(root, store);
drake = enableDragging(root)
return false
});
$(root).on('keydown', '.input-line', function (event) {
if (event.key === 'Escape') {
stopEditing(root, store, $(this))
selection.selectOne(cursor.get(), store)
return false
}
return true
});
$(root).on('keydown', function (event) {
let tag = event.target.tagName.toLowerCase();
if (tag === 'textarea' && currentEditor[0].value.substring(0, 3) === '```') {
return true
}
let next = true
let prevSelected = cursor.save();
if (event.key === 'ArrowUp') {
cursor.moveUp(store);
if (event.shiftKey) {
selection.include(cursor.get(), store)
} else {
selection.selectNothing(cursor.get())
}
next = false
} else if (event.key === 'ArrowDown') {
cursor.moveDown(store);
if (event.shiftKey) {
selection.include(cursor.get(), store)
} else {
selection.selectNothing(cursor.get())
}
next = false
} else if (event.shiftKey && event.key === 'Delete') {
stopEditing(root, store, currentEditor);
if (selection.hasSelection()) {
selection.remove(store)
// FIXME: adjust cursor
} else {
cursor.remove(store)
}
next = false
trigger('change');
} else if (event.key === 'Enter') {
stopEditing(root, store, currentEditor);
next = false
if (event.ctrlKey) {
let id = store.currentID(cursor.get())
let current = store.value(id)
let indent = current.indented
let item = newListItem(indent)
cursor.insertAbove(store, item)
} else {
let insertion = cursor.save()
let currentValue = store.value(store.currentID(cursor.get()));
let current = currentValue ? currentValue.indented : 0
let next = cursor.get() + 1 < store.length() ? store.value(store.currentID(cursor.get() + 1)).indented : current
let indent = next > current ? next : current
let item = newListItem(indent)
if (currentValue.text.match(/^#\[\[TODO\]\]/)) {
item.text = '#[[TODO]] ';
}
cursor.insertBelow(store, item)
}
selection.selectOne(cursor.get(), store)
trigger('change')
} else if (event.key === 'Tab') {
store.indent(cursor.get(), store.lastHigherIndented(cursor.get()) - cursor.get(), event.shiftKey ? -1 : 1)
next = false
} else {
return true
}
disableDragging(drake)
render(root, store);
drake = enableDragging(root)
if (cursor.hasMoved(prevSelected)) {
if (!selection.hasSelection() && editing && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) {
stopEditing(root, store, currentEditor);
startEditing(root, store, cursor);
return false
} else if (selection.hasSelection()) {
stopEditing(root, store, currentEditor);
}
}
if (event.key === 'Enter') {
startEditing(root, store, cursor);
return false
} else if (event.key === 'Escape') {
stopEditing(root, store, currentEditor);
return false
}
return next
})
$(root).on('click', '.marker', function () {
stopEditing(root, store, $(this).next('textarea'));
return false;
});
$(root).on('click', '.content a', function (event) {
event.stopPropagation()
return true
})
$(root).on('click', '.content', function (event) {
if ($(event.target).hasClass('checkbox')) return true;
let currentIndex = $(root).children('div.list-item').index($(this).parents('.list-item')[0])
if (cursor.atPosition(currentIndex) && currentEditor !== null && currentEditor.closest('.list-item')[0] === this) {
return true
}
stopEditing(root, store, currentEditor)
cursor.set(currentIndex)
selection.selectOne(cursor.get(), store)
disableDragging(drake)
render(root, store);
drake = enableDragging(root)
const $input = startEditing(root, store, cursor)
$input.trigger('input')
return false
})
$(root).on('click', '.fold', function () {
let open = !$(this).hasClass('open');
$(this).toggleClass('open', open)
$(this).toggleClass('closed', !open)
let item = $(this).parents('.list-item')
let elements = $(root).children('div.list-item');
let index = elements.index(item)
store.update(item.attr('data-id'), function (item) {
item.fold = open ? 'open' : 'closed'
return item
})
disableDragging(drake)
render(root, store);
drake = enableDragging(root)
});
function update(id, callback) {
let changed = false
store.update(id, function (item, prev, next) {
let before = Object.assign({}, item)
item = callback(item, prev, next)
changed = item.text !== before.text || item.indented !== before.indented || item.fold !== before.fold
return item
})
if (changed) {
trigger('change')
}
}
return EDITOR;
}
export default editor