%PDF- %PDF-
Direktori : /home/opti/workflow/src/public/app-assets/vendors/formBuilder-master/src/js/ |
Current File : /home/opti/workflow/src/public/app-assets/vendors/formBuilder-master/src/js/helpers.js |
import mi18n from 'mi18n' import { instanceDom, empty, remove, optionFields } from './dom' import { instanceData } from './data' import { mobileClass, markup as m, forEach, camelCase, escapeHtml, trimObj, subtract, parseXML, capitalize, unique, xmlAttrString, flattenArray, } from './utils' import events from './events' import { config } from './config' import control from './control' import controlCustom from './control/custom' /** * Utilities specific to form-builder.js */ export default class Helpers { /** * Setup defaults, get instance data and dom * @param {String} formId * @param {Object} layout object instance used by various helpers * @param {Object} formBuilder instance */ constructor(formId, layout, formBuilder) { this.data = instanceData[formId] this.d = instanceDom[formId] this.doCancel = false this.layout = layout this.handleKeyDown = this.handleKeyDown.bind(this) this.formBuilder = formBuilder } /** * Callback for when a drag begins * * @param {Object} event * @param {Object} ui */ startMoving(event, ui) { ui.item.show().addClass('moving') this.doCancel = true this.from = ui.item.parent() } /** * Callback for when a drag ends * * @param {Object} event * @param {Object} ui */ stopMoving(event, ui) { const _this = this ui.item.removeClass('moving') if (_this.doCancel) { if (ui.sender) { $(ui.sender).sortable('cancel') } this.from.sortable('cancel') } _this.save() _this.doCancel = false } /** * jQuery UI sortable beforeStop callback used for both lists. * Logic for canceling the sort or drop. * @param {Object} event * @param {Object} ui * @return {void} */ beforeStop(event, ui) { const _this = this const opts = config.opts const form = _this.d.stage const lastIndex = form.childNodes.length - 1 const cancelArray = [] _this.stopIndex = ui.placeholder.index() - 1 if (!opts.sortableControls && ui.item.parent().hasClass('frmb-control')) { cancelArray.push(true) } if (opts.prepend) { cancelArray.push(_this.stopIndex === 0) } if (opts.append) { cancelArray.push(_this.stopIndex + 1 === lastIndex) } _this.doCancel = cancelArray.some(elem => elem === true) } /** * Attempts to get element type and subtype * * @param {Object} $field * @return {Object} {type: 'fieldType', subtype: 'fieldSubType'} */ getTypes($field) { const types = { type: $field.attr('type'), } const subtype = $('.fld-subtype', $field).val() if (subtype !== types.type) { types.subtype = subtype } return types } /** * Get option data for a field * @param {Object} field jQuery field object * @return {Array} Array of option values */ fieldOptionData(field) { const options = [] const $options = $('.sortable-options li', field) $options.each(i => { const $option = $($options[i]) const selected = $('.option-selected', $option).is(':checked') const attrs = { label: $('.option-label', $option).val(), value: $('.option-value', $option).val(), } if (selected) { attrs.selected = selected } options.push(attrs) }) return options } /** * XML save * @param {Object} form sortableFields node * @return {String} xml in string */ xmlSave(form) { const formData = this.prepData(form) const xmlSerializer = new XMLSerializer() const fields = ['<fields>'] formData.forEach(field => { const { values, ...fieldData } = field let fieldHTML = [`<field ${xmlAttrString(fieldData)}>`] // Handle options if (optionFields.includes(field.type)) { const options = values.map(option => m('option', option.label, option).outerHTML) fieldHTML = fieldHTML.concat(options) } fieldHTML.push('</field>') fields.push(fieldHTML) }) fields.push('</fields>') const formTemplate = m('form-template', flattenArray(fields).join('')) return xmlSerializer.serializeToString(formTemplate) } /** * Get formData from editor in JS Object format * @param {Object} form aka stage, DOM element * @return {Object} formData */ prepData(form) { const formData = [] const d = this.d const _this = this if (form.childNodes.length !== 0) { // build data object forEach(form.childNodes, function(index, field) { const $field = $(field) if (!$field.hasClass('disabled-field')) { let fieldData = _this.getTypes($field) const $roleInputs = $('.roles-field:checked', field) const roleVals = $roleInputs.map(index => $roleInputs[index].value).get() fieldData = Object.assign({}, fieldData, _this.getAttrVals(field)) if (fieldData.subtype) { if (fieldData.subtype === 'quill') { const id = `${fieldData.name}-preview` if (window.fbEditors.quill[id]) { const instance = window.fbEditors.quill[id].instance const data = instance.getContents() fieldData.value = window.JSON.stringify(data.ops) } } else if (fieldData.subtype === 'tinymce' && window.tinymce) { const id = `${fieldData.name}-preview` if (window.tinymce.editors[id]) { const editor = window.tinymce.editors[id] fieldData.value = editor.getContent() } } } if (roleVals.length) { fieldData.role = roleVals.join(',') } fieldData.className = fieldData.className || fieldData.class if (fieldData.className) { const match = /(?:^|\s)btn-(.*?)(?:\s|$)/g.exec(fieldData.className) if (match) { fieldData.style = match[1] } } fieldData = trimObj(fieldData) const multipleField = fieldData.type && fieldData.type.match(d.optionFieldsRegEx) if (multipleField) { fieldData.values = _this.fieldOptionData($field) } formData.push(fieldData) } }) } return formData } /** * Get and set the data for an editor. Mainly * a wrapper for handling dataType option * @param {Object} formData * @return {Object} formData */ getData(formData) { const data = this.data if (!formData) { formData = config.opts.formData } if (!formData) { return false } const setData = { xml: formData => (Array.isArray(formData) ? formData : parseXML(formData)), json: formData => { if (typeof formData === 'string') { return window.JSON.parse(formData) } return formData }, } data.formData = setData[config.opts.dataType](formData) || [] return data.formData } /** * Saves and returns formData * @param {Boolean} minify whether to return formatted or minified data * @return {XML|JSON} formData */ save(minify) { const _this = this const data = this.data const stage = this.d.stage const doSave = { xml: minify => _this.xmlSave(stage, minify), json: minify => window.JSON.stringify(_this.prepData(stage), null, minify && ' '), } // save action for current `dataType` data.formData = doSave[config.opts.dataType](minify) // trigger formSaved event document.dispatchEvent(events.formSaved) return data.formData } /** * increments the field ids with support for multiple editors * @param {String} id field ID * @return {String} incremented field ID */ incrementId(id) { const split = id.lastIndexOf('-') const newFieldNumber = parseInt(id.substring(split + 1)) + 1 const baseString = id.substring(0, split) return `${baseString}-${newFieldNumber}` } /** * Set the values for field attributes in the editor * @param {Object} field * @return {Object} fieldData */ getAttrVals(field) { const fieldData = Object.create(null) const attrs = field.querySelectorAll('[class*="fld-"]') forEach(attrs, index => { const attr = attrs[index] const name = camelCase(attr.getAttribute('name')) const value = [ [ attr.attributes.contenteditable, () => (config.opts.dataType === 'xml' ? escapeHtml(attr.innerHTML) : attr.innerHTML), ], [attr.type === 'checkbox', () => attr.checked], [attr.attributes.multiple, () => $(attr).val()], [true, () => attr.value], ].find(([condition]) => !!condition)[1]() fieldData[name] = value }) return fieldData } /** * Collect field attribute values and call fieldPreview to generate preview * @param {Object} $field jQuery DOM element */ updatePreview($field) { const _this = this const d = this.d const fieldClass = $field.attr('class') const field = $field[0] if (fieldClass.includes('input-control')) { return } const fieldType = $field.attr('type') const $prevHolder = $('.prev-holder', field) let previewData = Object.assign({}, _this.getAttrVals(field, previewData), { type: fieldType }) const style = $('.btn-style', field).val() if (style) { previewData.style = style } if (fieldType.match(d.optionFieldsRegEx)) { previewData.values = [] previewData.multiple = $('[name="multiple"]', field).is(':checked') $('.sortable-options li', field).each(function(i, $option) { const option = { selected: $('.option-selected', $option).is(':checked'), value: $('.option-value', $option).val(), label: $('.option-label', $option).val(), } previewData.values.push(option) }) } previewData = trimObj(previewData) previewData.className = _this.classNames(field, previewData) $field.data('fieldData', previewData) // determine the control class for this type, and then process it through the layout engine const custom = controlCustom.lookup(previewData.type) const controlClass = custom ? custom.class : control.getClass(previewData.type, previewData.subtype) const preview = this.layout.build(controlClass, previewData) empty($prevHolder[0]) $prevHolder[0].appendChild(preview) preview.dispatchEvent(events.fieldRendered) } /** * Display a custom tooltip for disabled fields. * * @param {Object} stage */ disabledTT(stage) { const move = (e, elem) => { const fieldOffset = elem.field.getBoundingClientRect() const x = e.clientX - fieldOffset.left - 21 const y = e.clientY - fieldOffset.top - elem.tt.offsetHeight - 12 elem.tt.style.transform = `translate(${x}px, ${y}px)` } const disabledFields = stage.querySelectorAll('.disabled-field') forEach(disabledFields, index => { const field = disabledFields[index] const title = mi18n.get('fieldNonEditable') if (title) { const tt = m('p', title, { className: 'frmb-tt' }) field.appendChild(tt) field.addEventListener('mousemove', e => move(e, { tt, field })) } }) } /** * Process classNames for field * @param {Object} field * @param {Object} previewData * @return {String} classNames */ classNames(field, previewData) { const className = field.querySelector('.fld-className') if (!className) { return } const { type, style } = previewData const classes = className.multiple ? $(className).val() : className.value.split(' ') const types = { button: 'btn', submit: 'btn', } const primaryType = types[type] if (primaryType) { if (style) { for (let i = 0; i < classes.length; i++) { const re = new RegExp(`(?:^|\\s)${primaryType}-(.*?)(?:\\s|$)+`, 'g') const match = classes[i].match(re) if (match) { classes.splice(i, 1) } } classes.push(primaryType + '-' + style) } classes.push(primaryType) } // reverse the array to put custom classes at end, // remove any duplicates, convert to string, remove whitespace return unique(classes) .join(' ') .trim() } /** * Closes and open dialog * * @param {Object} overlay Existing overlay if there is one * @param {Object} dialog Existing dialog */ closeConfirm(overlay, dialog) { if (!overlay) { overlay = document.getElementsByClassName('form-builder-overlay')[0] } overlay && remove(overlay) if (!dialog) { dialog = document.getElementsByClassName('form-builder-dialog')[0] } dialog && remove(dialog) document.removeEventListener('keydown', this.handleKeyDown, false) document.dispatchEvent(events.modalClosed) } /** * * @param {Object} e keydown event object * @param {Function} cb callback */ handleKeyDown(e) { const keyCode = e.keyCode || e.which if (keyCode === 27) { e.preventDefault() this.closeConfirm.call(this) } } /** * Returns the layout data based on controlPosition option * @param {String} controlPosition 'left' or 'right' * @return {Object} layout object */ editorLayout(controlPosition) { const layoutMap = { left: { stage: 'pull-right', controls: 'pull-left', }, right: { stage: 'pull-left', controls: 'pull-right', }, } return layoutMap[controlPosition] || '' } /** * Adds overlay to the page. Used for modals. * @return {Object} DOM Object */ showOverlay() { const overlay = m('div', null, { className: 'form-builder-overlay', }) document.body.appendChild(overlay) overlay.classList.add('visible') overlay.addEventListener('click', ({ target }) => this.closeConfirm(target), false) document.addEventListener('keydown', this.handleKeyDown, false) return overlay } /** * Custom confirmation dialog * * @param {Object} message Content to be displayed in the dialog * @param {Func} yesAction callback to fire if they confirm * @param {Boolean} coords location to put the dialog * @param {String} className Custom class to be added to the dialog * @return {Object} Reference to the modal */ confirm(message, yesAction, coords = false, className = '') { const _this = this const i18n = mi18n.current const overlay = _this.showOverlay() const yes = m('button', i18n.yes, { className: 'yes btn btn-success btn-sm', }) const no = m('button', i18n.no, { className: 'no btn btn-danger btn-sm', }) no.onclick = function() { _this.closeConfirm(overlay) } yes.onclick = function() { yesAction() _this.closeConfirm(overlay) } const btnWrap = m('div', [no, yes], { className: 'button-wrap' }) className = 'form-builder-dialog ' + className const miniModal = m('div', [message, btnWrap], { className }) if (!coords) { const dE = document.documentElement coords = { pageX: Math.max(dE.clientWidth, window.innerWidth || 0) / 2, pageY: Math.max(dE.clientHeight, window.innerHeight || 0) / 2, } miniModal.style.position = 'fixed' } else { miniModal.classList.add('positioned') } miniModal.style.left = coords.pageX + 'px' miniModal.style.top = coords.pageY + 'px' document.body.appendChild(miniModal) yes.focus() return miniModal } /** * Popup dialog the does not require confirmation. * @param {String|DOM|Array} content * @param {Boolean} coords screen coordinates to position dialog * @param {String} className classname to be added to the dialog * @return {Object} dom */ dialog(content, coords = false, className = '') { const _this = this const clientWidth = document.documentElement.clientWidth const clientHeight = document.documentElement.clientHeight _this.showOverlay() className = 'form-builder-dialog ' + className const miniModal = m('div', content, { className: className }) if (!coords) { coords = { pageX: Math.max(clientWidth, window.innerWidth || 0) / 2, pageY: Math.max(clientHeight, window.innerHeight || 0) / 2, } miniModal.style.position = 'fixed' } else { miniModal.classList.add('positioned') } miniModal.style.left = coords.pageX + 'px' miniModal.style.top = coords.pageY + 'px' document.body.appendChild(miniModal) document.dispatchEvent(events.modalOpened) if (className.indexOf('data-dialog') !== -1) { document.dispatchEvent(events.viewData) } return miniModal } /** * Confirm all fields will be removed then remove them * @param {Object} e click event object */ confirmRemoveAll(e) { const _this = this const formID = e.target.id.match(/frmb-\d{13}/)[0] const stage = document.getElementById(formID) const i18n = mi18n.current const fields = $('li.form-field', stage) const buttonPosition = e.target.getBoundingClientRect() const bodyRect = document.body.getBoundingClientRect() const coords = { pageX: buttonPosition.left + buttonPosition.width / 2, pageY: buttonPosition.top - bodyRect.top - 12, } if (fields.length) { _this.confirm( i18n.clearAllMessage, () => { _this.removeAllFields.call(_this, stage) config.opts.notify.success(i18n.allFieldsRemoved) config.opts.onClearAll() }, coords ) } else { _this.dialog(i18n.noFieldsToClear, coords) } } /** * Removes all fields from the form * @param {Object} stage to remove fields form * @param {Boolean} animate whether to animate or not * @return {void} */ removeAllFields(stage, animate = true) { const i18n = mi18n.current const opts = config.opts const fields = stage.querySelectorAll('li.form-field') const markEmptyArray = [] if (!fields.length) { return false } if (opts.prepend) { markEmptyArray.push(true) } if (opts.append) { markEmptyArray.push(true) } if (!markEmptyArray.some(elem => elem === true)) { stage.classList.add('empty') stage.dataset.content = i18n.getStarted } if (animate) { stage.classList.add('removing') let outerHeight = 0 forEach(fields, index => (outerHeight += fields[index].offsetHeight + 3)) fields[0].style.marginTop = `${-outerHeight}px` setTimeout(() => { empty(stage).classList.remove('removing') this.save() }, 400) } else { empty(stage) this.save() } } /** * If user re-orders the elements their order should be saved. * @param {Object} $cbUL our list of elements * @return {Array} fieldOrder */ setFieldOrder($cbUL) { if (!config.opts.sortableControls) { return false } const { sessionStorage, JSON } = window const fieldOrder = [] $cbUL.children().each((index, element) => { const type = $(element).data('type') if (type) { fieldOrder.push(type) } }) if (sessionStorage) { sessionStorage.setItem('fieldOrder', JSON.stringify(fieldOrder)) } return fieldOrder } /** * Close fields being editing * @param {Object} stage */ closeAllEdit() { const _this = this const fields = $('> li.editing', _this.d.stage) const toggleBtns = $('.toggle-form', _this.d.stage) const editPanels = $('.frm-holder', fields) toggleBtns.removeClass('open') fields.removeClass('editing') $('.prev-holder', fields).show() editPanels.hide() } /** * Toggles the edit mode for the given field * @param {String} fieldId * @param {Boolean} animate */ toggleEdit(fieldId, animate = true) { const field = document.getElementById(fieldId) const $editPanel = $('.frm-holder', field) const $preview = $('.prev-holder', field) field.classList.toggle('editing') $('.toggle-form', field).toggleClass('open') if (animate) { $preview.slideToggle(250) $editPanel.slideToggle(250) } else { $preview.toggle() $editPanel.toggle() } this.updatePreview($(field)) if (field.classList.contains('editing')) { this.formBuilder.currentEditPanel = $editPanel[0] config.opts.onOpenFieldEdit($editPanel[0]) document.dispatchEvent(events.fieldEditOpened) } else { config.opts.onCloseFieldEdit($editPanel[0]) document.dispatchEvent(events.fieldEditClosed) } } /** * Get the computed style for DOM element * @param {Object} elem dom element * @param {Boolean} property style eg. width, height, opacity * @return {String} computed style */ getStyle(elem, property = false) { let style if (window.getComputedStyle) { style = window.getComputedStyle(elem, null) } else if (elem.currentStyle) { style = elem.currentStyle } return property ? style[property] : style } /** * Controls follow scroll to the bottom of the editor */ stickyControls() { const { controls, stage } = this.d const $cbWrap = $(controls).parent() const cbPosition = controls.getBoundingClientRect() const { top: stageTop } = stage.getBoundingClientRect() $(window).scroll(function(evt) { const scrollTop = $(evt.target).scrollTop() const offsetDefaults = { top: 5, bottom: 'auto', right: 'auto', left: cbPosition.left, } const offset = Object.assign({}, offsetDefaults, config.opts.stickyControls.offset) if (scrollTop > stageTop) { const style = { position: 'sticky', } const cbStyle = Object.assign(style, offset) const cbPosition = controls.getBoundingClientRect() const stagePosition = stage.getBoundingClientRect() const cbBottom = cbPosition.top + cbPosition.height const stageBottom = stagePosition.top + stagePosition.height const atBottom = cbBottom === stageBottom && cbPosition.top > scrollTop if (cbBottom > stageBottom && cbPosition.top !== stagePosition.top) { $cbWrap.css({ position: 'absolute', top: 'auto', bottom: 0, right: 0, left: 'auto', }) } if (cbBottom < stageBottom || atBottom) { $cbWrap.css(cbStyle) } } else { controls.parentElement.removeAttribute('style') } }) } /** * Open a dialog with the form's data */ showData() { let formData = this.getFormData(config.opts.dataType, true) if (config.opts.dataType === 'xml') { formData = escapeHtml(formData) } const code = m('code', formData, { className: `formData-${config.opts.dataType}`, }) this.dialog(m('pre', code), null, 'data-dialog') } /** * Remove a field from the stage * @param {String} fieldID ID of the field to be removed * @param {Number} animationSpeed * @return {Boolean} fieldRemoved returns true if field is removed */ removeField(fieldID, animationSpeed = 250) { let fieldRemoved = false const _this = this const form = this.d.stage const fields = form.getElementsByClassName('form-field') if (!fields.length) { config.opts.notify.warning('No fields to remove') return false } if (!fieldID) { const availableIds = [].slice.call(fields).map(field => { return field.id }) config.opts.notify.warning('fieldID required to remove specific fields.') config.opts.notify.warning('Removing last field since no ID was supplied.') config.opts.notify.warning('Available IDs: ' + availableIds.join(', ')) fieldID = form.lastChild.id } const field = document.getElementById(fieldID) const $field = $(field) if (!field) { config.opts.notify.warning('Field not found') return false } $field.slideUp(animationSpeed, function() { $field.removeClass('deleting') $field.remove() fieldRemoved = true _this.save() if (!form.childNodes.length) { form.classList.add('empty') form.dataset.content = mi18n.current.getStarted } }) const userEvents = config.opts.typeUserEvents[field.type] if (userEvents && userEvents.onremove) { userEvents.onremove(field) } document.dispatchEvent(events.fieldRemoved) return fieldRemoved } /** * Generate markup for form action buttons * @param {Object} buttonData * @return {Object} DOM element for action button */ processActionButtons(buttonData) { const { label, events, ...attrs } = buttonData let labelText = label const data = this.data if (!labelText) { if (attrs.id) { labelText = mi18n.current[attrs.id] || capitalize(attrs.id) } else { labelText = '' } } else { labelText = mi18n.current[labelText] || labelText } if (!attrs.id) { attrs.id = `${data.formID}-action-${Math.round(Math.random() * 1000)}` } else { attrs.id = `${data.formID}-${attrs.id}-action` } const button = m('button', labelText, attrs) if (events) { for (const event in events) { if (events.hasOwnProperty(event)) { button.addEventListener(event, evt => events[event](evt)) } } } return button } /** * Register any subtype controls specified in the 'subtypes' option, retrieve * all defined subtypes & build the export subtype format * @param {Array} subtypeOpts * @return {Array} subtypes */ processSubtypes(subtypeOpts) { const disabledSubtypes = config.opts.disabledSubtypes // first register any passed subtype options against the appropriate type control class for (const fieldType in subtypeOpts) { if (subtypeOpts.hasOwnProperty(fieldType)) { control.register(subtypeOpts[fieldType], control.getClass(fieldType), fieldType) } } // retrieve a list of all subtypes const registeredSubtypes = control.getRegisteredSubtypes() // remove disabled subtypes const subtypeDef = Object.entries(registeredSubtypes).reduce((acc, [key, val]) => { acc[key] = (disabledSubtypes[key] && subtract(disabledSubtypes[key], val)) || val return acc }, {}) // reformat the subtypes for each fieldType const subtypes = {} for (const fieldType in subtypeDef) { if (subtypeDef.hasOwnProperty(fieldType)) { // loop through each defined subtype & build the formatted data structure const formatted = [] for (const subtype of subtypeDef[fieldType]) { const controlClass = control.getClass(fieldType, subtype) const label = controlClass.mi18n(`subtype.${subtype}`) || controlClass.mi18n(subtype) || subtype formatted.push({ label, value: subtype, }) } subtypes[fieldType] = formatted } } return subtypes } /** * Generate stage and controls dom elements * @param {String} formID [description] */ editorUI(formID) { const d = this.d const data = this.data const id = formID || data.formID d.editorWrap = m('div', null, { id: `${data.formID}-form-wrap`, className: `form-wrap form-builder ${mobileClass()}`, }) d.stage = m('ul', null, { id, className: `frmb stage-wrap ${data.layout.stage}`, }) // Create container for controls d.controls = m('ul', null, { id: `${id}-control-box`, className: 'frmb-control', }) const buttons = this.formActionButtons() d.formActions = m('div', buttons, { className: 'form-actions btn-group', }) } /** * Generates form action buttons * @return {Object} formActions btn-group */ formActionButtons() { const opts = config.opts return opts.actionButtons .map(btnData => { if (btnData.id && opts.disabledActionButtons.indexOf(btnData.id) === -1) { return this.processActionButtons(btnData) } }) .filter(Boolean) } /** * Process user options for actionButtons * @param {Object} options * @return {Object} processedOptions */ processOptions(options) { const _this = this const { actionButtons, replaceFields, ...opts } = options let fieldEditContainer = opts.fieldEditContainer if (typeof opts.fieldEditContainer === 'string') { fieldEditContainer = document.querySelector(opts.fieldEditContainer) } const mergedActionButtons = [ { type: 'button', id: 'clear', className: 'clear-all btn btn-danger', events: { click: _this.confirmRemoveAll.bind(_this), }, }, { type: 'button', label: 'viewJSON', id: 'data', className: 'btn btn-default get-data', events: { click: _this.showData.bind(_this), }, }, { type: 'button', id: 'save', className: 'btn btn-primary save-template', events: { click: evt => { _this.save() config.opts.onSave(evt, _this.data.formData) }, }, }, ].concat(actionButtons) opts.fields = opts.fields.concat(replaceFields) opts.disableFields = opts.disableFields.concat(replaceFields.map(({ type }) => type && type)) if (opts.dataType === 'xml') { // html labels are not available using xml dataType opts.disableHTMLLabels = true } config.opts = Object.assign({}, { actionButtons: mergedActionButtons }, { fieldEditContainer }, opts) return config.opts } /** * Small wrapper for input markup * @param {Object} attrs * @return {Object} DOM element */ input(attrs = {}) { return m('input', null, attrs) } /** * Gets the data for current instance of formBuilder * @param {String} type * @param {Boolean} formatted * @return {Array|String} formData */ getFormData(type = 'js', formatted = false) { const h = this const data = { js: () => h.prepData(h.d.stage), xml: () => h.xmlSave(h.d.stage), json: formatted => window.JSON.stringify(h.prepData(h.d.stage), null, formatted && ' '), } return data[type](formatted) } }