From a5fa74574919a491986f197d41a5f8186a45562f Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 27 Jun 2020 23:56:01 +0100 Subject: [PATCH] Moved overlay component, migrated code-editor & added features - Moved Code-editor from vue to component. - Updated popup code so it background click only hides if the click originated on the same background. Clicks within the popup will no longer cause it to hide. - Added session-level history tracking to code editor. --- resources/js/components/code-editor.js | 117 ++++++++++++++++++ .../js/components/entity-selector-popup.js | 16 +-- resources/js/components/index.js | 13 +- resources/js/components/overlay.js | 43 ------- resources/js/components/popup.js | 61 +++++++++ resources/js/components/wysiwyg-editor.js | 4 +- resources/js/services/dom.js | 37 ++++-- resources/js/vues/code-editor.js | 46 ------- resources/js/vues/image-manager.js | 4 +- resources/js/vues/vues.js | 2 - resources/lang/en/components.php | 1 + resources/sass/_components.scss | 10 +- resources/sass/_layout.scss | 3 + resources/sass/_lists.scss | 2 + .../views/components/code-editor.blade.php | 78 +++++++----- .../entity-selector-popup.blade.php | 6 +- .../views/components/image-manager.blade.php | 2 +- 17 files changed, 289 insertions(+), 156 deletions(-) create mode 100644 resources/js/components/code-editor.js delete mode 100644 resources/js/components/overlay.js create mode 100644 resources/js/components/popup.js delete mode 100644 resources/js/vues/code-editor.js diff --git a/resources/js/components/code-editor.js b/resources/js/components/code-editor.js new file mode 100644 index 000000000..2e3506ec7 --- /dev/null +++ b/resources/js/components/code-editor.js @@ -0,0 +1,117 @@ +import Code from "../services/code"; +import {onChildEvent, onEnterPress, onSelect} from "../services/dom"; + +/** + * Code Editor + * @extends {Component} + */ +class CodeEditor { + + setup() { + this.container = this.$refs.container; + this.popup = this.$el; + this.editorInput = this.$refs.editor; + this.languageLinks = this.$manyRefs.languageLink; + this.saveButton = this.$refs.saveButton; + this.languageInput = this.$refs.languageInput; + this.historyDropDown = this.$refs.historyDropDown; + this.historyList = this.$refs.historyList; + + this.callback = null; + this.editor = null; + this.history = {}; + this.historyKey = 'code_history'; + this.setupListeners(); + } + + setupListeners() { + this.container.addEventListener('keydown', event => { + if (event.ctrlKey && event.key === 'Enter') { + this.save(); + } + }); + + onSelect(this.languageLinks, event => { + const language = event.target.dataset.lang; + this.languageInput.value = language; + this.updateEditorMode(language); + }); + + onEnterPress(this.languageInput, e => this.save()); + onSelect(this.saveButton, e => this.save()); + + onChildEvent(this.historyList, 'button', 'click', (event, elem) => { + event.preventDefault(); + const historyTime = elem.dataset.time; + if (this.editor) { + this.editor.setValue(this.history[historyTime]); + } + }); + } + + save() { + if (this.callback) { + this.callback(this.editor.getValue(), this.languageInput.value); + } + this.hide(); + } + + open(code, language, callback) { + this.languageInput.value = language; + this.callback = callback; + + this.show(); + this.updateEditorMode(language); + + Code.setContent(this.editor, code); + } + + show() { + if (!this.editor) { + this.editor = Code.popupEditor(this.editorInput, this.languageInput.value); + } + this.loadHistory(); + this.popup.components.popup.show(() => { + Code.updateLayout(this.editor); + this.editor.focus(); + }, () => { + this.addHistory() + }); + } + + hide() { + this.popup.components.popup.hide(); + this.addHistory(); + } + + updateEditorMode(language) { + Code.setMode(this.editor, language, this.editor.getValue()); + } + + loadHistory() { + this.history = JSON.parse(window.sessionStorage.getItem(this.historyKey) || '{}'); + const historyKeys = Object.keys(this.history).reverse(); + this.historyDropDown.classList.toggle('hidden', historyKeys.length === 0); + this.historyList.innerHTML = historyKeys.map(key => { + const localTime = (new Date(parseInt(key))).toLocaleTimeString(); + return `
  • `; + }).join(''); + } + + addHistory() { + if (!this.editor) return; + const code = this.editor.getValue(); + if (!code) return; + + // Stop if we'd be storing the same as the last item + const lastHistoryKey = Object.keys(this.history).pop(); + if (this.history[lastHistoryKey] === code) return; + + this.history[String(Date.now())] = code; + const historyString = JSON.stringify(this.history); + window.sessionStorage.setItem(this.historyKey, historyString); + } + +} + +export default CodeEditor; \ No newline at end of file diff --git a/resources/js/components/entity-selector-popup.js b/resources/js/components/entity-selector-popup.js index 147f7b583..0104eace7 100644 --- a/resources/js/components/entity-selector-popup.js +++ b/resources/js/components/entity-selector-popup.js @@ -1,27 +1,29 @@ - +/** + * Entity Selector Popup + * @extends {Component} + */ class EntitySelectorPopup { - constructor(elem) { - this.elem = elem; + setup() { + this.elem = this.$el; + this.selectButton = this.$refs.select; window.EntitySelectorPopup = this; this.callback = null; this.selection = null; - this.selectButton = elem.querySelector('.entity-link-selector-confirm'); this.selectButton.addEventListener('click', this.onSelectButtonClick.bind(this)); - window.$events.listen('entity-select-change', this.onSelectionChange.bind(this)); window.$events.listen('entity-select-confirm', this.onSelectionConfirm.bind(this)); } show(callback) { this.callback = callback; - this.elem.components.overlay.show(); + this.elem.components.popup.show(); } hide() { - this.elem.components.overlay.hide(); + this.elem.components.popup.hide(); } onSelectButtonClick() { diff --git a/resources/js/components/index.js b/resources/js/components/index.js index da194e438..1cea8949e 100644 --- a/resources/js/components/index.js +++ b/resources/js/components/index.js @@ -36,7 +36,9 @@ function initComponent(name, element) { try { instance = new componentModel(element); instance.$el = element; - instance.$refs = parseRefs(name, element); + const allRefs = parseRefs(name, element); + instance.$refs = allRefs.refs; + instance.$manyRefs = allRefs.manyRefs; instance.$opts = parseOpts(name, element); if (typeof instance.setup === 'function') { instance.setup(); @@ -67,6 +69,7 @@ function initComponent(name, element) { */ function parseRefs(name, element) { const refs = {}; + const manyRefs = {}; const prefix = `${name}@` const refElems = element.querySelectorAll(`[refs*="${prefix}"]`); for (const el of refElems) { @@ -76,9 +79,13 @@ function parseRefs(name, element) { .map(str => str.replace(prefix, '')); for (const ref of refNames) { refs[ref] = el; + if (typeof manyRefs[ref] === 'undefined') { + manyRefs[ref] = []; + } + manyRefs[ref].push(el); } } - return refs; + return {refs, manyRefs}; } /** @@ -134,6 +141,7 @@ function initAll(parentElement) { } window.components.init = initAll; +window.components.first = (name) => (window.components[name] || [null])[0]; export default initAll; @@ -141,5 +149,6 @@ export default initAll; * @typedef Component * @property {HTMLElement} $el * @property {Object} $refs + * @property {Object} $manyRefs * @property {Object} $opts */ \ No newline at end of file diff --git a/resources/js/components/overlay.js b/resources/js/components/overlay.js deleted file mode 100644 index 6963ba9d1..000000000 --- a/resources/js/components/overlay.js +++ /dev/null @@ -1,43 +0,0 @@ -import {fadeIn, fadeOut} from "../services/animations"; - -class Overlay { - - constructor(elem) { - this.container = elem; - elem.addEventListener('click', event => { - if (event.target === elem) return this.hide(); - }); - - window.addEventListener('keyup', event => { - if (event.key === 'Escape') { - this.hide(); - } - }); - - let closeButtons = elem.querySelectorAll('.popup-header-close'); - for (let i=0; i < closeButtons.length; i++) { - closeButtons[i].addEventListener('click', this.hide.bind(this)); - } - } - - hide(onComplete = null) { this.toggle(false, onComplete); } - show(onComplete = null) { this.toggle(true, onComplete); } - - toggle(show = true, onComplete) { - if (show) { - fadeIn(this.container, 240, onComplete); - } else { - fadeOut(this.container, 240, onComplete); - } - } - - focusOnBody() { - const body = this.container.querySelector('.popup-body'); - if (body) { - body.focus(); - } - } - -} - -export default Overlay; \ No newline at end of file diff --git a/resources/js/components/popup.js b/resources/js/components/popup.js new file mode 100644 index 000000000..13cf69d21 --- /dev/null +++ b/resources/js/components/popup.js @@ -0,0 +1,61 @@ +import {fadeIn, fadeOut} from "../services/animations"; +import {onSelect} from "../services/dom"; + +/** + * Popup window that will contain other content. + * This component provides the show/hide functionality + * with the ability for popup@hide child references to close this. + * @extends {Component} + */ +class Popup { + + setup() { + this.container = this.$el; + this.hideButtons = this.$manyRefs.hide || []; + + this.onkeyup = null; + this.onHide = null; + this.setupListeners(); + } + + setupListeners() { + let lastMouseDownTarget = null; + this.container.addEventListener('mousedown', event => { + lastMouseDownTarget = event.target; + }); + + this.container.addEventListener('click', event => { + if (event.target === this.container && lastMouseDownTarget === this.container) { + return this.hide(); + } + }); + + onSelect(this.hideButtons, e => this.hide()); + } + + hide(onComplete = null) { + fadeOut(this.container, 240, onComplete); + if (this.onkeyup) { + window.removeEventListener('keyup', this.onkeyup); + this.onkeyup = null; + } + if (this.onHide) { + this.onHide(); + } + } + + show(onComplete = null, onHide = null) { + fadeIn(this.container, 240, onComplete); + + this.onkeyup = (event) => { + if (event.key === 'Escape') { + this.hide(); + } + }; + window.addEventListener('keyup', this.onkeyup); + this.onHide = onHide; + } + +} + +export default Popup; \ No newline at end of file diff --git a/resources/js/components/wysiwyg-editor.js b/resources/js/components/wysiwyg-editor.js index 1c8c71099..5956b5e7a 100644 --- a/resources/js/components/wysiwyg-editor.js +++ b/resources/js/components/wysiwyg-editor.js @@ -137,7 +137,7 @@ function codePlugin() { if (!elemIsCodeBlock(selectedNode)) { const providedCode = editor.selection.getNode().textContent; - window.vues['code-editor'].open(providedCode, '', (code, lang) => { + window.components.first('code-editor').open(providedCode, '', (code, lang) => { const wrap = document.createElement('div'); wrap.innerHTML = `
    `; wrap.querySelector('code').innerText = code; @@ -155,7 +155,7 @@ function codePlugin() { let lang = selectedNode.hasAttribute('data-lang') ? selectedNode.getAttribute('data-lang') : ''; let currentCode = selectedNode.querySelector('textarea').textContent; - window.vues['code-editor'].open(currentCode, lang, (code, lang) => { + window.components.first('code-editor').open(currentCode, lang, (code, lang) => { const editorElem = selectedNode.querySelector('.CodeMirror'); const cmInstance = editorElem.CodeMirror; if (cmInstance) { diff --git a/resources/js/services/dom.js b/resources/js/services/dom.js index 966a4540e..2a9fad8b3 100644 --- a/resources/js/services/dom.js +++ b/resources/js/services/dom.js @@ -25,17 +25,34 @@ export function onEvents(listenerElement, events, callback) { /** * Helper to run an action when an element is selected. * A "select" is made to be accessible, So can be a click, space-press or enter-press. - * @param listenerElement - * @param callback + * @param {HTMLElement|Array} elements + * @param {function} callback */ -export function onSelect(listenerElement, callback) { - listenerElement.addEventListener('click', callback); - listenerElement.addEventListener('keydown', (event) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - callback(event); - } - }); +export function onSelect(elements, callback) { + if (!Array.isArray(elements)) { + elements = [elements]; + } + + for (const listenerElement of elements) { + listenerElement.addEventListener('click', callback); + listenerElement.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + callback(event); + } + }); + } +} + +/** + * Listen to enter press on the given element(s). + * @param {HTMLElement|Array} elements + * @param {function} callback + */ +export function onEnterPress(elements, callback) { + if (!Array.isArray(elements)) { + elements = [elements]; + } } /** diff --git a/resources/js/vues/code-editor.js b/resources/js/vues/code-editor.js deleted file mode 100644 index f888e6227..000000000 --- a/resources/js/vues/code-editor.js +++ /dev/null @@ -1,46 +0,0 @@ -import codeLib from "../services/code"; - -const methods = { - show() { - if (!this.editor) this.editor = codeLib.popupEditor(this.$refs.editor, this.language); - this.$refs.overlay.components.overlay.show(() => { - codeLib.updateLayout(this.editor); - this.editor.focus(); - }); - }, - hide() { - this.$refs.overlay.components.overlay.hide(); - }, - updateEditorMode(language) { - codeLib.setMode(this.editor, language, this.editor.getValue()); - }, - updateLanguage(lang) { - this.language = lang; - this.updateEditorMode(lang); - }, - open(code, language, callback) { - this.show(); - this.updateEditorMode(language); - this.language = language; - codeLib.setContent(this.editor, code); - this.code = code; - this.callback = callback; - }, - save() { - if (!this.callback) return; - this.callback(this.editor.getValue(), this.language); - this.hide(); - } -}; - -const data = { - editor: null, - language: '', - code: '', - callback: null -}; - -export default { - methods, - data -}; \ No newline at end of file diff --git a/resources/js/vues/image-manager.js b/resources/js/vues/image-manager.js index 6df12d16d..b87734556 100644 --- a/resources/js/vues/image-manager.js +++ b/resources/js/vues/image-manager.js @@ -35,7 +35,7 @@ const methods = { show(providedCallback, imageType = null) { callback = providedCallback; this.showing = true; - this.$el.children[0].components.overlay.show(); + this.$el.children[0].components.popup.show(); // Get initial images if they have not yet been loaded in. if (dataLoaded && imageType === this.imageType) return; @@ -53,7 +53,7 @@ const methods = { } this.showing = false; this.selectedImage = false; - this.$el.children[0].components.overlay.hide(); + this.$el.children[0].components.popup.hide(); }, async fetchData() { diff --git a/resources/js/vues/vues.js b/resources/js/vues/vues.js index 125d541ce..a53adf53f 100644 --- a/resources/js/vues/vues.js +++ b/resources/js/vues/vues.js @@ -5,7 +5,6 @@ function exists(id) { } import entityDashboard from "./entity-dashboard"; -import codeEditor from "./code-editor"; import imageManager from "./image-manager"; import tagManager from "./tag-manager"; import attachmentManager from "./attachment-manager"; @@ -13,7 +12,6 @@ import pageEditor from "./page-editor"; let vueMapping = { 'entity-dashboard': entityDashboard, - 'code-editor': codeEditor, 'image-manager': imageManager, 'tag-manager': tagManager, 'attachment-manager': attachmentManager, diff --git a/resources/lang/en/components.php b/resources/lang/en/components.php index d8e8981fb..32667eb4e 100644 --- a/resources/lang/en/components.php +++ b/resources/lang/en/components.php @@ -29,5 +29,6 @@ return [ 'code_editor' => 'Edit Code', 'code_language' => 'Code Language', 'code_content' => 'Code Content', + 'code_session_history' => 'Session History', 'code_save' => 'Save Code', ]; diff --git a/resources/sass/_components.scss b/resources/sass/_components.scss index 13e16e42f..c73c503b4 100644 --- a/resources/sass/_components.scss +++ b/resources/sass/_components.scss @@ -81,7 +81,7 @@ } } -[overlay] { +[overlay], .popup-background { @include lightDark(background-color, rgba(0, 0, 0, 0.333), rgba(0, 0, 0, 0.6)); position: fixed; z-index: 95536; @@ -611,11 +611,11 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { display: none; } -#code-editor .CodeMirror { +.code-editor .CodeMirror { height: 400px; } -#code-editor .lang-options { +.code-editor .lang-options { max-width: 480px; margin-bottom: $-s; a { @@ -625,10 +625,10 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { } @include smaller-than($m) { - #code-editor .lang-options { + .code-editor .lang-options { max-width: 100%; } - #code-editor .CodeMirror { + .code-editor .CodeMirror { height: 200px; } } diff --git a/resources/sass/_layout.scss b/resources/sass/_layout.scss index 4d044245a..226f5ccdb 100644 --- a/resources/sass/_layout.scss +++ b/resources/sass/_layout.scss @@ -49,6 +49,9 @@ &.v-center { align-items: center; } + &.v-end { + align-items: end; + } &.no-gap { grid-row-gap: 0; grid-column-gap: 0; diff --git a/resources/sass/_lists.scss b/resources/sass/_lists.scss index 77727060e..856cfed89 100644 --- a/resources/sass/_lists.scss +++ b/resources/sass/_lists.scss @@ -569,6 +569,8 @@ ul.pagination { @include lightDark(color, #555, #eee); fill: currentColor; text-align: start !important; + max-height: 500px; + overflow-y: auto; &.wide { min-width: 220px; } diff --git a/resources/views/components/code-editor.blade.php b/resources/views/components/code-editor.blade.php index 15f6ae252..6822bb28d 100644 --- a/resources/views/components/code-editor.blade.php +++ b/resources/views/components/code-editor.blade.php @@ -1,10 +1,10 @@ -
    -
    - +
    \ No newline at end of file diff --git a/resources/views/components/entity-selector-popup.blade.php b/resources/views/components/entity-selector-popup.blade.php index 0beee658d..ec8712b6a 100644 --- a/resources/views/components/entity-selector-popup.blade.php +++ b/resources/views/components/entity-selector-popup.blade.php @@ -1,13 +1,13 @@
    -
    + diff --git a/resources/views/components/image-manager.blade.php b/resources/views/components/image-manager.blade.php index 3eed2fdb7..5e2de7bb8 100644 --- a/resources/views/components/image-manager.blade.php +++ b/resources/views/components/image-manager.blade.php @@ -8,7 +8,7 @@ 'components.file_upload_timeout', ]) -
    +