@excellens/markdown-notepad
Version:
A simple but extensible markdown editor based on @excellens/elementary.
638 lines (495 loc) • 19.7 kB
JavaScript
/*
* MIT License
* Copyright (c) 2020 Excellens
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import * as Elementary from '@excellens/elementary'
import {Theme} from "../Theme";
import {FindOne} from "../Base";
import {Tab, TabState} from "../Component/Tab";
import {TabPane, TabPaneState} from "../Component/TabPane";
import {Button, ButtonState} from "../Component/Button";
import {Textarea, TextareaState} from "../Component/Textarea";
import {Preview, PreviewState} from "../Component/Preview";
import {EngineMd} from "../Service/Engine";
import {TextSnapshot} from "../Service/Action/Snapshot";
import {Action} from "../Service/Action";
import {History} from "../Service/Collection";
import {TextareaTransaction} from "../Service/Action/Snapshot/Transaction";
import {Configuration} from "../Configuration";
export function MarkdownNotepad(document, element, state) {
const self = (function (document, element, state) {
const presenter = new MarkdownNotepadPresenter(state);
return new Elementary.Component.Component(presenter, document, 'div', element);
}(document, element, state || new MarkdownNotepadState()));
const parent = {
initialize: self.initialize,
destroy: self.destroy,
};
this.theme = null;
this.tab = new Elementary.Collection.CollectionMap();
this.tabPane = new Elementary.Collection.CollectionMap();
let eventKeyDown = null;
let eventKeyUp = null;
let eventContextMenu = null;
this.textarea = null;
this.preview = null;
let eventMouseDown = new Elementary.Collection.CollectionMap();
let eventMouseUp = new Elementary.Collection.CollectionMap();
this.button = new Elementary.Collection.CollectionMap();
this.getConfigurationDefault = function () {
const configuration = new Configuration();
// Tab configuration.
configuration.tab.add('write');
configuration.tab.add('preview');
// Button configuration.
configuration.button.add('text-headline', 'text-headline');
configuration.button.add('text-bold', 'text-bold');
configuration.button.add('text-italic', 'text-italic');
configuration.button.add('text-quote', 'text-quote');
configuration.button.add('text-code', 'text-code');
configuration.button.add('text-link', 'text-link');
configuration.button.add('text-ul', 'text-ul');
configuration.button.add('text-ol', 'text-ol');
configuration.button.add('history-forward', 'history-forward');
configuration.button.add('history-backward', 'history-backward');
// Key configuration.
configuration.key.add('ctrl+shift+z', 'history-forward');
configuration.key.add('ctrl+z', 'history-backward');
return configuration;
};
this.initialize = function (configuration) {
if (false === !!configuration) {
configuration = this.getConfigurationDefault();
}
// Set the component information.
self.setAttribute('data-component', 'markdown-notepad');
// Set the style.
self.classList.add('markdown-notepad');
this.theme = new Theme(document, configuration.getTheme());
// Only use the theme if no element is given.
if (false === !!element) {
this.theme.initialize(this);
}
self.presenter.setConfiguration(configuration);
parent.initialize();
return this;
};
this.initializeTab = function (state) {
let name = state.getName();
let tab = new Tab(document, FindOne(self, `div[data-component="markdown-notepad.tab.${name}"]`), state);
tab.initialize();
// Set the style.
tab.classList.add('tab', name);
self.tab.add(name, tab);
this.initializeTabPane(state.getPane());
return this;
};
this.initializeTabPane = function (state) {
let name = state.getName();
let tabPane = new TabPane(document, FindOne(self, `div[data-component="markdown-notepad.tab-pane.${name}"]`), state);
tabPane.initialize();
// Set the style.
tabPane.classList.add('tab-pane', name);
self.tabPane.add(name, tabPane);
return this;
};
this.initializeTextarea = function (state) {
let textarea = new Textarea(document, FindOne(self, 'textarea[data-component="markdown-notepad.textarea"]'), state);
textarea.initialize();
// Set the style.
textarea.classList.add('markdown-textarea');
textarea.addEventListener('keydown', eventKeyDown = function (event) {
// Only continue, when ctrl or alt are down.
if (event.ctrlKey || event.altKey) {
// Create an action identifier in the format 'ctrl+alt+shift+key'.
const action = [
event.ctrlKey
? 'ctrl'
: '',
event.altKey
? 'alt'
: '',
event.shiftKey
? 'shift'
: '',
// Grab the current key in lower case.
(event.key).toLowerCase(),
].filter(function (value, index, array) {
// Filter any empty value and join them with a '+' sign.
return 0 !== value.length;
}).join('+');
try {
// Try to trigger the action, if it exists.
self.presenter.triggerAction(action, true);
event.preventDefault();
} catch (error) {
if ('ERR_ACTION' === error) {
// No-op, since no action was found.
} else {
throw error;
}
}
}
});
// textarea.addEventListener('keyup', eventKeyUp = function (event) {
// self.focusTextarea();
// });
textarea.addEventListener('contextmenu', eventContextMenu = function (event) {
event.preventDefault();
});
this.textarea = textarea;
return this;
};
this.initializePreview = function (state) {
const preview = new Preview(document, FindOne(self, 'div[data-component="markdown-notepad.preview"]'), state);
preview.initialize();
// Set the style.
preview.classList.add('markdown-preview');
this.preview = preview;
return this;
};
this.initializeButton = function (state) {
let name = state.getName();
const button = new Button(document, FindOne(self, `button[data-component="markdown-notepad.button.${name}"]`), state);
button.initialize();
// Set the style.
button.classList.add('button', name);
// Set click callback.
const onMouseDown = function (event) {
self.presenter.triggerAction(name, true);
};
eventMouseDown.add(name, onMouseDown);
button.addEventListener('mousedown', onMouseDown);
const onMouseUp = function (event) {
self.focusTextarea();
};
eventMouseUp.add(name, onMouseUp);
button.addEventListener('mouseup', onMouseUp);
self.button.add(name, button);
return this;
};
this.focusTextarea = function () {
if (null !== this.textarea) {
this.textarea.focus();
}
return this;
};
this.destroy = function () {
parent.destroy();
this.theme.destroy();
this.theme = null;
this.tab.each(function (value, key) {
value.destroy();
});
this.tab.clear();
this.tabPane.each(function (value, key) {
value.destroy();
});
this.tabPane.clear();
this.textarea.removeEventListener('keydown', eventKeyDown);
eventKeyDown = null;
this.textarea.removeEventListener('keyup', eventKeyUp);
eventKeyUp = null;
this.textarea.removeEventListener('contextmenu', eventContextMenu);
eventContextMenu = null;
this.textarea.destroy();
this.textarea = null;
this.preview.destroy();
this.preview = null;
this.button.each(function (value, key) {
value.destroy();
const onMouseDown = eventMouseDown.get(key);
value.removeEventListener('mousedown', onMouseDown);
const onMouseUp = eventMouseUp.get(key);
value.removeEventListener('mouseup', onMouseUp);
});
this.button.clear();
eventMouseDown.clear();
eventMouseUp.clear();
return this;
};
return Elementary.Base.Merge(this, self);
}
export function MarkdownNotepadPresenter(state) {
const self = new Elementary.Component.ComponentPresenter(state);
const parent = {
initialize: self.initialize,
destroy: self.destroy,
};
this.update = function (state, id) {
};
this.engine = null;
let timeoutReference = null;
let callback = null;
let callbackMirror = null;
let callbackHistory = null;
this.action = new Elementary.Collection.CollectionMap();
this.configuration = null;
this.getConfiguration = function () {
return this.configuration;
};
this.setConfiguration = function (configuration) {
this.configuration = configuration;
return this;
};
this.hasConfiguration = function () {
return !!this.configuration;
};
this.fresh = false;
this.initialize = function (component) {
this.engine = new EngineMd();
callback = new Elementary.Observe.Callback('TabChange', function (state, id) {
if (state.isActive()) {
self.state.tab.each(function (value, index) {
if (state.getName() === value.getName()) {
return;
}
value.setActive(false);
value.notify();
});
}
});
callbackMirror = new Elementary.Observe.Callback('Mirror', function (state, id) {
if (self.state.hasPreview()) {
let previewValue = state.getValue();
previewValue = self.engine.process(previewValue);
let preview = self.state.getPreview();
preview.setValue(previewValue);
preview.notify();
}
});
callbackHistory = new Elementary.Observe.Callback('History', function (state, id) {
const makeHistory = function (state) {
// TODO: Timeout offset.
if (true === !!timeoutReference) {
clearTimeout(timeoutReference);
}
timeoutReference = setTimeout(function () {
const snapshot = new TextSnapshot(
new TextareaTransaction()
);
snapshot.attach(state.getTextarea());
snapshot.rollback();
state.history.add(snapshot);
state.history.moveForward();
snapshot.detach();
}, 1000);
};
if (self.state.history.hasCurrent()) {
const current = self.state.history.getCurrent();
if (state.getValue() !== current.getValue()) {
makeHistory(self.state);
}
} else {
makeHistory(self.state);
}
});
parent.initialize(component);
this.initializeState();
return this;
};
this.initializeState = function () {
this.fresh = self.state.isFresh();
self.state.setFresh(false);
const configuration = this.getConfiguration();
configuration.tab.each(function (value, index) {
// Convert index 0 to false to true.
const active = false === !!index;
self.initializeTab(value, active);
});
configuration.button.each(function (value, key) {
self.initializeButton(key, value);
});
configuration.key.each(function (value, key) {
self.initializeAction(key, value, true);
});
this.initializeTextarea();
this.initializePreview();
};
this.initializeTab = function (name, active) {
const tabName = `tab-${name}`;
let tab;
if (self.state.tab.has(tabName)) {
tab = self.state.tab.get(tabName);
} else {
const tabPane = (function (name, active) {
const tabPaneName = `tab-pane-${name}`;
const tabPane = new TabPaneState();
tabPane.setName(tabPaneName);
tabPane.setActive(active);
return tabPane;
}(name, active));
tab = new TabState();
tab.setName(tabName);
tab.setActive(active);
tab.setPane(tabPane);
self.state.tab.add(tabName, tab);
tab.attachCallback(callback);
}
self.component.initializeTab(tab);
return this;
};
this.initializeTextarea = function () {
let textarea;
if (self.state.hasTextarea()) {
textarea = self.state.getTextarea();
} else {
textarea = new TextareaState();
textarea.setValue('');
// This is set in the textarea presenter.
textarea.setSelection(null);
textarea.attachCallback(callbackMirror);
textarea.attachCallback(callbackHistory);
self.state.setTextarea(textarea);
}
self.component.initializeTextarea(textarea);
return this;
};
this.initializePreview = function () {
let preview;
if (self.state.hasPreview()) {
preview = self.state.getPreview();
} else {
preview = new PreviewState();
preview.setValue('');
self.state.setPreview(preview);
}
self.component.initializePreview(preview);
return this;
};
this.initializeButton = function (name, actionName) {
const buttonName = `button-${name}`;
let button;
if (self.state.button.has(buttonName)) {
button = self.state.button.get(buttonName);
} else {
button = new ButtonState();
button.setName(buttonName);
self.state.button.add(buttonName, button);
}
this.initializeAction(buttonName, actionName, true);
self.component.initializeButton(button);
return this;
};
this.initializeAction = function (name, actionName, strict) {
// Special case, where strict is not set, but it should be true.
if ('undefined' === typeof strict) {
strict = true;
}
if (Action.registry.has(actionName)) {
const action = Action.registry.get(actionName);
this.action.add(name, action);
} else {
if (strict) {
throw 'ERR_ACTION';
}
}
return this;
};
this.triggerAction = function (name, strict) {
// Special case, where strict is not set, but it should be true.
if ('undefined' === typeof strict) {
strict = true;
}
if (this.action.has(name)) {
const action = this.action.get(name);
action.handle(self.state);
} else {
if (strict) {
throw 'ERR_ACTION';
}
}
return this;
};
this.destroy = function () {
parent.destroy();
if (this.fresh) {
self.state.tab.each(function (value, index) {
value.detachCallback(callback);
});
self.state.tab.clear();
}
timeoutReference = null;
callback = null;
if (this.fresh) {
if (self.state.hasTextarea()) {
let textarea = self.state.getTextarea();
textarea.detachCallback(callbackMirror);
textarea.detachCallback(callbackHistory);
self.state.setTextarea(null);
}
}
callbackMirror = null;
callbackHistory = null;
if (this.fresh) {
if (self.state.hasPreview()) {
self.state.setPreview(null);
}
}
this.action.clear();
if (this.fresh) {
//self.state.button.each(function (value, index) {
//});
self.state.button.clear();
}
this.engine = null;
this.configuration = null;
this.fresh = false;
};
return Elementary.Base.Merge(this, self);
}
export function MarkdownNotepadState() {
const self = new Elementary.Component.ComponentState();
this.fresh = true;
this.isFresh = function () {
return this.fresh;
};
this.setFresh = function (fresh) {
this.fresh = fresh;
return this;
};
this.tab = new Elementary.Collection.CollectionMap();
this.textarea = null;
this.getTextarea = function () {
return this.textarea;
};
this.setTextarea = function (textarea) {
this.textarea = textarea;
return this;
};
this.hasTextarea = function () {
return !!this.textarea;
};
this.preview = null;
this.getPreview = function () {
return this.preview;
};
this.setPreview = function (preview) {
this.preview = preview;
return this;
};
this.hasPreview = function () {
return !!this.preview;
};
this.button = new Elementary.Collection.CollectionMap();
this.history = new History();
return Elementary.Base.Merge(this, self);
}