website-builder
Version:
edit data for a templates
730 lines (725 loc) • 29 kB
JavaScript
// @flow
// #!/usr/bin/env node
// -*- coding: utf-8 -*-
/** @module websiteBuilder */
/* !
region header
[Project page](https://torben.website/websiteBuilder)
Copyright Torben Sickert (info["~at~"]torben.website) 16.12.2012
License
-------
This library written by Torben Sickert stand under a creative commons
naming 3.0 unported license.
See https://creativecommons.org/licenses/by/3.0/deed.de
endregion
*/
// TODO Button to restore template default values.
// region imports
import {$ as binding} from 'clientnode'
import {DomNode, PlainObject} from 'clientnode'
import ejs from 'ejs'
import JSONEditor from 'exports?JSONEditor!json-editor'
import tinymce from 'tinymce'
import 'tinymce/plugins/anchor'
import 'tinymce/plugins/advlist'
import 'tinymce/plugins/anchor'
import 'tinymce/plugins/autoresize'
import 'tinymce/plugins/autolink'
import 'tinymce/plugins/charmap'
import 'tinymce/plugins/code'
import 'tinymce/plugins/contextmenu'
import 'tinymce/plugins/fullscreen'
import 'tinymce/plugins/help'
import 'tinymce/plugins/hr'
import 'tinymce/plugins/image'
import 'tinymce/plugins/imagetools'
import 'tinymce/plugins/insertdatetime'
import 'tinymce/plugins/lists'
import 'tinymce/plugins/link'
import 'tinymce/plugins/media'
import 'tinymce/plugins/nonbreaking'
import 'tinymce/plugins/noneditable'
import 'tinymce/plugins/pagebreak'
import 'tinymce/plugins/paste'
import 'tinymce/plugins/preview'
import 'tinymce/plugins/print'
import 'tinymce/plugins/preview'
import 'tinymce/plugins/searchreplace'
import 'tinymce/plugins/spellchecker'
import 'tinymce/plugins/table'
import 'tinymce/plugins/textcolor'
import 'tinymce/plugins/toc'
import 'tinymce/plugins/visualblocks'
import 'tinymce/plugins/visualchars'
import 'tinymce/plugins/wordcount'
import 'tinymce/themes/modern/theme'
require.context(
'file?name=editorAssets.compiled/[path][name].[ext]&' +
'context=node_modules/tinymce!tinymce/skins',
true,
/.*/)
import './index.css'
// endregion
export const $:any = binding
// region plugins/classes
/**
* @property static:attributeNames - Attribute names to take into account.
* @property static:escapedMarkupSymbolMapping - Symbol sequence mapping to
* convert escaped markup.
* @property static:schema - Schema to specify scope.
* @property static:scope - Initial scope values.
*
* @property currentMode - Current editing mode.
* @property domNodes - Reference to all needed control dom nodes.
* @property domNode - Determined entry dom node.
* @property inPlaceEditorInstances - Mapping from scope name to its mapped
* list of dom nodes and in place editor instances.
* @property jsonEditor - JSON editor instance.
* @property onChangeListener - A list of callbacks to call on parameter
* changes.
* @property template - Determined template to render parameter into.
*/
export class WebsiteBuilder extends $.Tools.class {
// region properties
static attributeNames:Array<string> = [
'rawEditable', 'rawInitializedEditable',
'simpleEditable', 'simpleInitializedEditable',
'editable', 'initializedEditable',
'advancedEditable', 'advancedInitializedEditable'
]
static escapedMarkupSymbolMapping:{[key:string]:string} = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
''': `'`
}
static schema:?PlainObject
static scope:?PlainObject
currentMode:string = 'hybrid'
domNodes:{[key:string]:DomNode} = {}
domNode:DomNode
inPlaceEditorInstances:{[key:string]:Array<Array<Object>>}
initialized:boolean = false
initialScope:PlainObject = {}
scope:PlainObject = {}
jsonEditor:JSONEditor
onChangeListener:Array<Function> = []
template:string = ''
scope:PlainObject = {}
// endregion
// region static methods
/**
* Converts given escaped markup string into its plain representation.
* @param text - Input to convert.
* @returns Converted input.
*/
static unescapeHTML(text:string):string {
return text.replace(new RegExp(Object.keys(
WebsiteBuilder.escapedMarkupSymbolMapping
).join('|'), 'g'), (symbols:string):string =>
WebsiteBuilder.escapedMarkupSymbolMapping[symbols])
}
// endregion
// region public methods
/**
* @param options - Options for customizing editing behavior.
* @returns A promise which resolves with current instance when
* bootstrapped.
*/
initialize(options:Object = {}):Promise<WebsiteBuilder> {
this._options = {
className: {
selectedEditorIndicator: 'selected',
synchronizedEditorTemplateValue: 'synchronized'
},
defaultParameterScope: {},
entryPointAttributeName: 'root',
inPlaceEditor: {
default: {
/* eslint-disable camelcase */
allow_conditional_comments: false,
allow_script_urls: false,
cache_suffix: `?version=${UTC_BUILD_TIMESTAMP}`,
convert_fonts_to_spans: true,
document_base_url: '/',
element_format: 'xhtml',
entity_encoding: 'raw',
fix_list_elements: true,
forced_root_block: null,
hidden_input: false,
inline: true,
invalid_elements: 'em',
invalid_styles: 'color font-size line-height',
keep_styles: false,
menubar: false,
/* eslint-disable max-len */
plugins: 'fullscreen link code hr nonbreaking searchreplace visualblocks',
/* eslint-enable max-len */
relative_urls: false,
remove_script_host: false,
remove_trailing_brs: true,
schema: 'html5',
skin_url: 'editorAssets.compiled/skins/lightgray',
/* eslint-disable max-len */
toolbar1: 'cut copy paste | undo redo removeformat | styleselect formatselect fontselect fontsizeselect | searchreplace visualblocks fullscreen code',
toolbar2: 'alignleft aligncenter alignright alignjustify outdent indent | link hr nonbreaking bullist numlist bold italic underline strikethrough',
/* eslint-enable max-len */
trim: true
/* eslint-enable camelcase */
},
raw: {
/* eslint-disable max-len */
toolbar1: 'cut copy paste | undo redo removeformat | code | fullscreen',
/* eslint-enable max-len */
toolbar2: false
},
simple: {
/* eslint-disable max-len */
toolbar1: 'cut copy paste | undo redo removeformat | bold italic underline strikethrough subscript superscript | fullscreen',
toolbar2: false
/* eslint-enable max-len */
},
normal: {
/* eslint-disable max-len */
toolbar1: 'cut copy paste | undo redo removeformat | styleselect formatselect | searchreplace visualblocks fullscreen code'
/* eslint-enable max-len */
},
advanced: {}
},
jsonEditor: {
/* eslint-disable camelcase */
disable_array_delete_all_rows: true,
disable_array_delete_last_row: true,
disable_collapse: true,
disable_edit_json: true,
disable_properties: true,
format: 'grid'
/* eslint-enable camelcase */
},
neededDomNodeSpecifications: {
buttons: [
{
action: (state:string):void => {
this.domNodes.jsonEditor.style.display =
state === 'active' ? 'block' : 'none'
},
label: {
active: 'Hide options',
inactive: 'Show options'
},
name: 'options',
states: ['inactive', 'active']
},
{
action: this.updateMode.bind(this),
label: {
hybrid: 'Show preview',
preview: 'Mark editables',
helper: 'Unmark editables'
},
name: 'preview',
states: ['hybrid', 'preview', 'helper']
},
{
action: (state:string):void => {
console.log('TODO synchronize all')
},
label: {
dirty: 'Synchronize all',
synchronized: ''
},
name: 'synchronizeAll',
states: ['synchronized', 'dirty']
},
{
action: (state:string):void => {
console.log('TODO synchronize current')
},
label: {
dirty: 'Synchronize',
synchronized: ''
},
name: 'synchronizeSelected',
states: ['synchronized', 'dirty']
},
{
action: (state:string):void => {
console.log('TODO clear all')
},
label: {
cleared: '',
filled: 'clear'
},
name: 'clear',
states: ['filled', 'cleared']
},
{
action: (state:string):void => {
console.log('TODO reset all')
},
label: {
changed: 'reset',
unchanged: ''
},
name: 'reset',
states: ['unchanged', 'change']
},
{
action: (state:string):void => {
for (
const buttonDomNode:DomNode of
this.domNodes.toolbar.querySelectorAll(
'button')
)
if (
buttonDomNode !==
this.domNodes.toggleToolbar
)
buttonDomNode.style.display =
state === 'show' ? 'none' : 'initial'
},
label: {
hide: 'Show toolbar',
show: 'Hide toolbar'
},
name: 'toggleToolbar',
states: ['hide', 'show']
}
],
divs: [
{
name: 'jsonEditor',
style: {
backgroundColor: 'white',
display: 'none',
position: 'absolute',
letf: 0,
top: 0,
width: '100%',
zIndex: 9 ** 9
}
},
{
name: 'toolbar',
style: {
backgroundColor: 'white',
position: 'absolute',
right: 0,
top: 0,
zIndex: 9 ** 9 + 1
}
}
]
},
retrieveContent: {},
schema: null,
schemaName: 'schema',
scope: null,
scopeName: 'scope',
waitForDocumentReady: true
}
super.initialize(options)
if (!this._options.schema)
this._options.schema = this.constructor.schema
if (!this._options.schema)
this._options.schema = $.global[this._options.schemaName] || {}
if (!this._options.scope)
this._options.scope = this.constructor.scope
if (!this._options.scope)
this._options.scope = $.global[this._options.scopeName] || {}
this.scope = this._options.scope
return new Promise((resolve:Function):void => {
if (this._options.waitForDocumentReady)
$.global.document.addEventListener(
'DOMContentLoaded', ():void => resolve(this.bootstrap()))
else
resolve(this.bootstrap())
})
}
/**
* Initializes website editor.
* @returns This instance.
*/
bootstrap():WebsiteBuilder {
// region determine root element
this.domNode = $.global.document.querySelector(
`[${this._options.entryPointAttributeName}]`)
if (!this.domNode) {
this.domNode = $.global.document.createElement('div')
this.domNode.setAttribute(
this._options.entryPointAttributeName, '')
this.domNode.innerHTML = $.global.document.body.innerHTML
$.global.document.body.innerHTML = ''
$.global.document.body.appendChild(this.domNode)
}
this.template = this.domNode.innerHTML
// endregion
// region create toolbar
for (
const div:PlainObject of
this._options.neededDomNodeSpecifications.divs
) {
const domNode:DomNode = $.global.document.createElement('div')
for (const key:string in div.style)
if (div.style.hasOwnProperty(key))
domNode.style[key] = div.style[key]
this.domNodes[div.name] = domNode
$.global.document.body.appendChild(domNode)
}
for (
const button:PlainObject of
this._options.neededDomNodeSpecifications.buttons
) {
button.state = button.states[0]
const domNode:DomNode = $.global.document.createElement('button')
domNode.addEventListener('click', ():void => {
button.state = button.states[
(button.states.indexOf(button.state) + 1) %
button.states.length
]
domNode.textContent = button.label[button.state]
button.action(button.state)
})
domNode.textContent = button.label[button.state]
this.domNodes[button.name] = domNode
this.domNodes.toolbar.appendChild(domNode)
}
// endregion
// region initialize editor instances
this.jsonEditor = new JSONEditor(
this.domNodes.jsonEditor, this.constructor.extend(
this._options.jsonEditor, {schema: this._options.schema}))
this.jsonEditor.setValue(this.scope.parameter)
this.jsonEditor.on('change', ():void => {
this.initialized = true
const errors = this.jsonEditor.validate()
if (errors.length)
$.global.alert(errors[0])
else {
this.constructor.extend(
true, this.scope.parameter, this.jsonEditor.getValue())
// Initializes in place editors.
this.updateMode()
}
})
// endregion
return this
}
/**
* Binds given scope name and dom node to an in place editor instance on
* click.
* @param attributeName - Attribute name indicating editability.
* @param name - Scope name to bind to.
* @param domNode - Dom node to bind to.
* @returns Nothing.
*/
initializeDomNode(
attributeName:string, name:string, domNode:DomNode
):void {
if (this.currentMode === 'preview')
this.renderDomNode(
attributeName.toLowerCase().includes('initialized'), name,
domNode)
else
this.initializeInPlaceEditor(attributeName, name, domNode)
}
/**
* Initializes all in place editors.
* @returns Nothing.
*/
initializeDomNodes():void {
this.inPlaceEditorInstances = {}
for (const attributeName:string of WebsiteBuilder.attributeNames)
for (const domNode:DomNode of this.domNode.querySelectorAll(
`[${attributeName}]`
)) {
const name:string = domNode.getAttribute(attributeName)
if (!name)
continue
this.initializeDomNode(attributeName, name, domNode)
}
}
/**
* Binds given scope name and dom node to an in place editor instance on
* click.
* @param attributeName - Attribute name to determine editor type.
* @param name - Scope name to bind to.
* @param domNode - Dom node to bind to.
* @returns Nothing.
*/
initializeInPlaceEditor(
attributeName:boolean, name:string, domNode:DomNode
):void {
const tuple:Array<Object> = [domNode]
this.registerInPlaceEditor(name, tuple)
if (!attributeName.toLowerCase().includes('initialized'))
domNode.innerHTML = ''
let type:string = 'normal'
if (attributeName.toLowerCase().includes('advanced'))
type = 'advanced'
else if (attributeName.toLowerCase().includes('simple'))
type = 'simple'
else if (attributeName.toLowerCase().includes('raw'))
type = 'raw'
domNode.addEventListener('click', ():void =>
tinymce.init(this.constructor.extend(
true,
{},
this._options.inPlaceEditor.default,
this._options.inPlaceEditor[type],
{
setup: (instance:Object):void => {
tuple.push(instance)
instance.on('init', ():void => {
instance.focus()
this._inPlaceEditorBackgroundColorFix()
})
instance.on('focus', ():void => {
const className:string = this._options.className
.selectedEditorIndicator
const lastSelectedDomNode:DomNode =
this.domNode.querySelector(`.${className}`)
if (lastSelectedDomNode)
lastSelectedDomNode.classList.remove(className)
domNode.classList.add(className)
})
instance.on('blur', domNode.blur.bind(domNode))
// Update model on changes
instance.on('ExecCommand', ():void => this.updateModel(
name, instance))
for (const eventName:string of [
'change', 'ObjectResized'
])
instance.on(eventName, ():void => {
instance.save()
this.updateModel(name, instance)
})
},
target: domNode
})))
this.updateModel(name, domNode, true)
}
/**
* Provides current data via post message to parent context.
* @param parameterHasChanged - Indicates whether in place content has been
* updated or template parameter.
* @returns Nothing.
*/
populateData(parameterHasChanged:boolean = true):void {
for (const callback:Function of this.onChangeListener)
callback(
parameterHasChanged, this.currentMode, this.options,
this.domNode)
console.log('TODO Send data: ', this.scope)
}
/**
* Registers given in place editor to synchronize scope values with.
* @param name - Scope name given editor is bound to.
* @param tuple - Of dom node and in-place editor instance.
* @returns Nothing.
*/
registerInPlaceEditor(name:string, tuple:Array<Object>):void {
if (this.inPlaceEditorInstances.hasOwnProperty(name))
this.inPlaceEditorInstances[name].push(tuple)
else
this.inPlaceEditorInstances[name] = [tuple]
}
/**
* Registers a callback to be triggered after a parameter change has been
* performed.
* @param callback - Function to trigger in change.
* @returns An unregister function.
*/
registerOnChange(callback:Function):void {
this.onChangeListener.push(callback)
if (this.initialized)
callback(true, this.currentMode, this.options, this.domNode)
return ():void => {
const index:number = this.onChangeListener.indexOf(callback)
if (index !== -1)
this.onChangeListener.splice(index, 1)
}
}
/**
* Renders given content again current scope.
* @param content - String to render.
* @returns Rendered string.
*/
render(content:string):string {
if (content) {
const validNames:Array<string> = Object.keys(this.scope).filter((
name:string
):boolean => {
try {
new Function(`var ${name}`)()
} catch (error) {
return false
}
return true
})
// IgnoreTypeCheck
return new Function(
'scope', ...validNames, `return \`${content}\``
)(this.scope, ...validNames.map((name:string):any =>
this.scope[name]))
}
return ''
}
/**
* Renders current scope value for given name into given dom node.
* @param useContent - Indicates whether current content should be loaded.
* @param name - Scope name to retrieve value from.
* @param domNode - Node to render.
* @returns Nothing.
*/
renderDomNode(useContent:boolean, name:string, domNode:DomNode):void {
let content = ''
if (this.scope.hasOwnProperty(name))
content = this.scope[name]
else if (useContent)
content = domNode.innerHTML.trim()
domNode.innerHTML = this.render(content)
}
/**
* Renders currently defined template parameter into the entry dom node.
* @returns Nothing.
*/
renderParameter():void {
return this.domNode.innerHTML = ejs.render(
WebsiteBuilder.unescapeHTML(this.template),
this.constructor.extend(
true,
{Tools: this.constructor},
this._options.defaultParameterScope,
this.scope.parameter
)
).trim()
}
/**
* Transforms content before exporting from an in place editor.
* @param content - String to transform.
* @returns Transformed given content.
*/
transformContent(content:string):string {
return content.trim()
}
/**
* Updates current editor mode into given one or currently set.
* @param mode - New mode to switch to.
* @returns Nothing.
*/
updateMode(mode:?string):void {
if (mode)
this.currentMode = mode
for (const name:string of ['preview', 'hybrid', 'helper'])
this.domNode.classList.remove(name)
this.domNode.classList.add(this.currentMode)
this.renderParameter()
this.initializeDomNodes()
this.populateData()
}
/**
* Synchronizes in place editor content with each other.
* @param name - Scope bounded name to synchronize from given editor
* instance.
* @param givenInstance - In pace editor instance to use as data source.
* @param initialize - Indicated whether its the first update for given
* instance.
* @returns Nothing.
*/
updateModel(
name:string, givenInstance:Object, initialize:boolean = false
):void {
let content = this.transformContent(
'getContent' in givenInstance ?
givenInstance.getContent() :
givenInstance.innerHTML)
if (initialize) {
this.initialScope[name] = content
if (name in this.scope)
content = this.scope[name]
}
let inSyncWithTemplate:boolean = false
if (this.constructor.isEquivalentDOM(
this.initialScope[name], content
)) {
if (name in this.scope)
delete this.scope[name]
inSyncWithTemplate = true
} else
this.scope[name] = content
for (
const instance:Array<Object> of this.inPlaceEditorInstances[name]
) {
/*
NOTE: An instance tuple consists of a dom node and optionally
a running editor instance as second array value. So update
editor instance or dom node directly as fallback.
*/
if (instance.length === 2) {
if (
instance[1].getDoc() &&
instance[1].getContent() !== content
)
instance[1].setContent(content)
} else if (instance[0].innerHTML !== content)
instance[0].innerHTML = content
instance[0].setAttribute('title', name)
if (inSyncWithTemplate && instance.includes(givenInstance))
instance[0].classList.add(
this._options.className.synchronizedEditorTemplateValue)
else
instance[0].classList.remove(
this._options.className.synchronizedEditorTemplateValue)
}
this.populateData(false)
}
// endregion
// region protected methods
/**
* Tinymce uses color of target node for inline editing. For font color
* this doesn't make any sense if corresponding background color will be
* omitted and text color is the sames as background color so resetting it
* here to the same color as the first found button.
* @returns Nothing.
*/
_inPlaceEditorBackgroundColorFix():void {
for (const name:string of ['click', 'mouseover'])
$.global.document.body.addEventListener(name, (
event:Object
):void => {
if (event.target && (
event.target.classList.contains('mce-tinymce-inline') ||
event.target.classList.contains('mce-container')
)) {
const inlineEditorDomNode:?DomNode =
this.domNode.querySelector('.mce-tinymce-inline')
const buttonDomNodes:Array<DomNode> =
inlineEditorDomNode ?
inlineEditorDomNode.querySelectorAll('button') :
[]
const editorDomNode:?DomNode = this.domNode.querySelector(
'.mce-container')
const inlineButtonDomNodes:Array<DomNode> =
editorDomNode ?
editorDomNode.querySelectorAll(
'span[style]:not([contenteditable])') :
[]
if (buttonDomNodes.length && inlineButtonDomNodes.length)
for (const domNode:DomNode of inlineButtonDomNodes)
domNode.style.color = buttonDomNodes[0].style.color
}
})
}
// endregion
}
export default WebsiteBuilder
// endregion
$.WebsiteBuilder = (...parameter:Array<any>):Promise<DomNode> =>
$.Tools().controller(WebsiteBuilder, parameter)
// region vim modline
// vim: set tabstop=4 shiftwidth=4 expandtab:
// vim: foldmethod=marker foldmarker=region,endregion:
// endregion