commit fa69a8281b0995959dd862aa6f0304711db60339 Author: Dave Mc Nicoll Date: Mon Oct 9 19:52:43 2023 -0400 - Added to assets-package diff --git a/asset/webcomponent/app.js b/asset/webcomponent/app.js new file mode 100644 index 0000000..347f67a --- /dev/null +++ b/asset/webcomponent/app.js @@ -0,0 +1,14 @@ + +import { ElementObserver } from "./element-observer.js"; + +import { UiTabs } from "./module/ui-tabs.js"; +window.customElements.define("ui-tabs", UiTabs); + +import { UiPopup } from "./module/ui-popup.js"; +window.customElements.define("ui-popup", UiPopup); + +import { UiTextarea } from "./module/ui-textarea.js"; +window.customElements.define("ui-textarea", UiTextarea); + +import { UiSelect } from "./module/ui-select.js"; +window.customElements.define("ui-select", UiSelect); \ No newline at end of file diff --git a/asset/webcomponent/element-observer.js b/asset/webcomponent/element-observer.js new file mode 100644 index 0000000..12379e4 --- /dev/null +++ b/asset/webcomponent/element-observer.js @@ -0,0 +1,159 @@ +/* + * @author Dave Mc Nicoll + * @version 1.0.0 + * + * Analysis elements entering or quitting the DOM, triggering events whenever + * a matching element is found. + * + **/ + +import { Hourglass } from "./hourglass.js"; + +const config = { + resize: { + timer: 200 + } +}; + +const ACTION_ADDED = 0, + ACTION_REMOVED = 1, + ACTION_RESIZED = 2, + MutationObserver = window.MutationObserver || window.WebKitMutationObserver; + +class ElementObserver { + + constructor(options) { + this.observer = null; + this.listeners = [ [] , [] ]; + } + + added(selector, callback) { + this._observe(selector, callback, ACTION_ADDED); + } + + removed(selector, callback) { + this._observe(selector, callback, ACTION_REMOVED); + } + + /* a little hacky function, awaiting the ResizeObserver specs to be ready */ + resize(element, callback) { + var width = 0; + + if ( ! this.hourglass ) { + element.dataset.element_width = element.getBoundingClientRect().width; + + this.hourglass = new Hourglass(function() { + this.resizing.forEach(function(item) { + width = item.element.getBoundingClientRect().width; + + if ( item.element.dataset !== width ) { + item.element.dataset.element_width = width; + item.callback.call(item.element, width); + } + }); + }.bind(this), { + start : false, + repeat : false, + timer : config.resize.timer + }); + + window.addEventListener('resize', function() { + this.hourglass.restart(); + }.bind(this)); + } + + this.resizing = this.resizing || []; + + this.resizing.push({ + element: element, + callback: callback + }); + } + + _observe(selector, callback, action) { + this.listeners[action].push({ + selector : selector, + callback : callback + }); + + this._register_observer(); + } + + _analyze_element(element, action) { + let split_selector = []; + + for (let j = 0, l1 = this.listeners[action].length; j < l1; j++) { + split_selector = this.listeners[action][j].selector.split(' '); + + if ( split_selector.length > 1 ) { + console.log("@todo!", split_selector); + } + else { + if ( $element.is( this.listeners[action][j].selector ) ) { + this.listeners[action][j].callback.call(element, element); + } + } + } + } + + _analyze_added_element() { + return this._analyze_element(this, ACTION_ADDED); + } + + _analyze_removed_element() { + return this._analyze_element(this, ACTION_REMOVED); + } + + _observe_element(e) { + for(var i = 0; i < e.length; i++) { + if (e[i].type === 'childList') { + console.error("THIS PART IS NOT TRANSLATED INTO PURE JS AS OF NOW ... SOME THINKING NEED TO BE DONE FIRST !"); +// $(e[i].addedNodes).each(this.analyze_added_element); +// $(e[i].removedNodes).each(this.analyze_removed_element); + } + } + } + + _register_observer() { + if ( this.observer || ( this.observer = new MutationObserver(this._observe_element) ) ) { + this.observer.observe(document.documentElement, { + subtree: true, + childList: true + }); + } + } + + set observer(value) { + return this._observer = value; + } + + get observer() { + return this._observer; + } + + set listeners(value) { + return this._listeners = value; + } + + get listeners() { + return this._listeners; + } + + get hourglass() { + return this._hourglass; + } + + set hourglass(value) { + return this._hourglass = value; + } + + get resizing() { + return this._resizing; + } + + set resizing(value) { + return this._resizing = value; + } +} + +export { ElementObserver } \ No newline at end of file diff --git a/asset/webcomponent/hourglass.js b/asset/webcomponent/hourglass.js new file mode 100644 index 0000000..2e6f910 --- /dev/null +++ b/asset/webcomponent/hourglass.js @@ -0,0 +1,107 @@ +/* + * Really simple timer / timeout object + * + * @author Dave Mc Nicoll + * @version 2.0.0 + * + */ + +const config = { + timer: 1000, + start: false, + loop: true, + run: false, // If you need a defined number of code execution, set a number to run and loop to false. + complete: function(){} // This complete callback function is called if the run exec number is reached. +}; + +class Hourglass { + + constructor(callback, options) { + this.object_id = null; + this.counter = null; + this.callback = callback !== undefined ? callback : function() {}; + + if ( config.start ) { + this.start(); + } + } + + start(timeout) { + let time = ( timeout !== undefined ? timeout : config.timer ); + + if ( config.loop ) { + if ( this.counter === null ) { + this.counter = config.loop ? 0 : null + } + + this.object_id = window.setInterval( this.call_interval.bind(this), time); + } + else { + this.object_id = window.setTimeout( this.call_timeout.bind(this), time); + } + } + + restart(timeout) { + this.clear(); + this.start(timeout); + } + + stop() { + if ( config.loop ) { + this.object_id && window.clearInterval(this.object_id); + } + else { + this.object_id && window.clearTimeout(this.object_id); + } + } + + clear() { + this.stop(); + this.counter = null; + this.object_id = null; + } + + call_timeout() { + this.callback(); + this.clear(); + } + + call_interval() { + this.callback(); + + if ( this.counter !== null && ++this.counter === config.run ) { + this.clear(); + config.complete !== undefined && config.complete(); + } + } + + running() { + return !! this.object_id; + } + + get callback() { + return this._callback; + } + + set callback(value) { + return this._callback = value; + } + + get counter() { + return this._counter; + } + + set counter(value) { + return this._counter = value; + } + + get object_id() { + return this._object_id; + } + + set object_id(value) { + return this._object_id = value; + } +} + +export { Hourglass } \ No newline at end of file diff --git a/asset/webcomponent/module/ui-popup.js b/asset/webcomponent/module/ui-popup.js new file mode 100644 index 0000000..d681924 --- /dev/null +++ b/asset/webcomponent/module/ui-popup.js @@ -0,0 +1,324 @@ +import { Webcomponent } from "./webcomponent.js"; + +const config = { + item: ".tab-item", + content: ".tab-content" +}; + +class UiPopup extends Webcomponent { + constructor() { + super(); + + this.fetch_template(); + this.message = this.querySelector('.message'); + this.title = this.querySelector('.title'); + + this.replaceVarsPlaceholder().attach().render(); + + this.init(); + this.handleOptions(); + } + handleOptions() { + if ( this.options ) { + if ( ! this.options['skip-keys'] ) { + document.addEventListener('keyup', function(e) { + if ( this.visible() ) { + let trigger, ev; + + e.preventDefault(); + + // There's no real value in checking if the popup is shown or not. + switch ( e.keyCode ) { + // RETURN + case 13: + trigger = this.querySelector('[action="confirm"]'); + ev = new CustomEvent('click', { trigger: trigger } ); + + trigger.dispatchEvent(ev); + break; + + // ESC + case 27: + trigger = this.querySelector('[action="close"]'); + ev = new CustomEvent('click', { trigger: trigger } ); + + trigger.dispatchEvent(ev); + break; + } + } + }.bind(this)); + } + } + } + + init() { + document.addEventListener('keyup', function(e) { + if ( this.visible() ) { + let trigger, ev; + + e.preventDefault(); + + // There's no real value in checking if the popup is shown or not. + switch ( e.keyCode ) { + case 13: // RETURN + trigger = this.querySelector('[action="confirm"]'); + ev = new CustomEvent('click', { trigger: trigger } ); + + trigger.dispatchEvent(ev); + break; + + + case 8: // BACKSPACE + case 27: // ESC + trigger = this.querySelector('[action="close"]'); + ev = new CustomEvent('click', { trigger: trigger } ); + + trigger.dispatchEvent(ev); + break; + } + } + }.bind(this)); + } + + attach() { + let triggers = document.querySelectorAll('[ui-popup]'); + + if ( triggers.length ) { + triggers.forEach(function(item) { + let trigger_args = this._parseTriggerArguments(item); + + if ( trigger_args.name === this.getAttribute('name') ) { + item.addEventListener('click', this.triggerAction.bind(this, item, trigger_args)); + } + + this.options = trigger_args.options; + }.bind(this)); + } + + return this; + } + + render() { + this.querySelectorAll('[action]').forEach(function(element) { + element.addEventListener("click", this.action.bind(this, element), false); + }.bind(this)); + + this.hide(); + } + + action(element, e) { + if ( ! element.attributes.action ) { + throw "A button was clicked onto which no action was bound."; + } + + switch(element.attributes.action.value) { + case "confirm": + case "yes": + case "ok": + this.actionFollowLink(element) || + this.actionClickButton(element) || + this.actionSendForm(this.trigger) || + this.actionSendForm(element); + + case "cancel": + case "no": + case "close": + this.hide(); + break; + } + + this.dispatchEvent(new CustomEvent('action:' + element.attributes.action.value)); + this.trigger.dispatchEvent(new CustomEvent('action:' + element.attributes.action.value)); + + if ( element.getAttribute("action") ) { + e.preventDefault(); + } + + return true; + } + + hide() { + this.classList.remove('visible'); + } + + show() { + this.classList.add('visible'); + } + + visible() { + return this.classList.contains('visible'); + } + + actionFollowLink(element) { + + //if ( element.getAttribute('follow-link') !== null ) { + // Automatically redirect to given location if it exists. + if ( this.trigger.tagName === "A" ) { + let href = this.trigger.getAttribute('href'); + + if ( href ) { + window.location = href; + + return true; + } + } + //} + + return false; + } + + actionClickButton(element) { + + //if ( element.getAttribute('button-click') !== null ) { + let tag = this.trigger.tagName.toLowerCase(); + + // Automatically redirect to given location if it exists. + if ( tag === "button" || ( ( tag === "input" ) && ( this.trigger.getAttribute("type").toLowerCase() === "submit") ) ) { + this.pause = true; + + this.trigger.click(); + + this.pause = false; + + return true; + } + //} + + return false; + } + + actionSendForm(element) { + //if ( element.getAttribute('send-form') !== null ) { + //let form = this.trigger.tagName === "FORM" ? this.trigger : this.trigger.closest('FORM'); + let form = element.tagName === "FORM" ? element : element.closest('FORM'); + + if ( form ) { + form.submit(); + + return true; + } + else { + throw "A send-form action was set on a popup where the trigger as no parent form."; + } + //} + } + + triggerAction(item, args, e) { + if (this.pause) { + return false; + } + + e.preventDefault(); + + this.trigger = item; + + args.vars && Object.keys(args.vars).map(function(objectKey, index) { +// let element = this.message.querySelectorAll(`.${objectKey}`); + let element = Array.prototype.slice.call( this.message.querySelectorAll(`.${objectKey}`) ).concat(Array.prototype.slice.call(this.title.querySelectorAll(`.${objectKey}`))); + + element.length ? element.forEach(function(item) { + item.innerHTML = args.vars[objectKey]; + }) : ( function() { + console.warn(`Variable '${objectKey}' could not be found within popup's message`); + } )(); + }.bind(this)); + + if ( args.options ) { + args.options['input'] && Object.keys(args.options['input']).forEach( + key => this.querySelectorAll(`[name="${key}"]`).forEach(function(f) { + if (f.tagName === "TEXTAREA") { + f.value = args.options['input'][key]; + } + else { + f.setAttribute("value", args.options['input'][key]); + } + + f.dispatchEvent(new Event("change")); + }) + ); + } + + this.show(); + + return true; + } + + replaceVarsPlaceholder() { + let setVar = function(match, varname) { + let element = document.createElement('span'); + element.classList.add('variable', varname); + + return element.outerHTML; + }; + + this.message.innerHTML = this.message.innerHTML.replace(/{\$(.*?)}/g, setVar); + this.title.innerHTML = this.title.innerHTML.replace(/{\$(.*?)}/g, setVar); + + return this; + } + + get trigger() { + return this._trigger; + } + + set trigger(value) { + return this._trigger = value; + } + + get pause() { + return this._pause; + } + + set pause(value) { + return this._pause = value; + } + + get message() { + return this._message; + } + + set message(value) { + return this._message = value; + } + + get title() { + return this._title; + } + + set title(value) { + return this._title = value; + } + + get action_func() { + return this._action_func; + } + + set action_func(value) { + return this._action_func = value; + } + + _parseTriggerArguments(item) { + let attr = item.getAttribute('ui-popup').trim(); + + if ( attr.charAt(0) === '{' ) { + try { + return JSON.parse(attr); + } + catch(error) { + console.error("Popup trigger failed on JSON parsing of " + attr + " from object:", item); + } + } + else if ( typeof attr === "string" && attr.length ) { + return { + name: attr + }; + } + else { + throw "A popup name must be given to attach this object."; + } + } + + +} + +export { UiPopup } diff --git a/asset/webcomponent/module/ui-select.js b/asset/webcomponent/module/ui-select.js new file mode 100644 index 0000000..8299888 --- /dev/null +++ b/asset/webcomponent/module/ui-select.js @@ -0,0 +1,283 @@ + +import { Webcomponent } from './webcomponent.js'; + +const config = { + content: { + wrapper: ".wrapper", + list: ".item-list" + }, + lang: { + create: "Create «{$name}»" + } +}; + +class UiSelect extends Webcomponent { + constructor() { + super(); + + this.fetch_template(); + + this.wrapper = this.shadowRoot.querySelector(config.content.wrapper); + this.list = this.shadowRoot.querySelector(config.content.list); + this.input = this.querySelector('input:not([type="hidden"])'); + this.select = this.querySelector('select'); + this.active = false; + + this.init(); + } + + init() { + this.input.setAttribute('readonly', 'readonly'); + + if ( this.getAttribute("create") !== null ) { + this.item_create = this.add_item("__create__", config.lang.create, false, true); + } + + Array.from(this.select.options).forEach(function(item) { + this.add_item(item.value, item.text, item.value === this.select.value); + }.bind(this)); + + [ "mouseover" , "mouseleave" ].forEach(function(ev) { + this.list.addEventListener(ev, function(e) { + this.active = ( ev === 'mouseover' ) && ( document.activeElement === this.input ); + }.bind(this)); + }.bind(this)); + + [ "focus" , "blur", "keyup", "click" ].forEach(function(ev) { + this.input.addEventListener(ev, function(e) { + switch(ev) { + case "focus": + case "blur": + setTimeout(function() { + this.has_focus = ev === "focus"; + this[ (this.has_focus ? "show" : "hide" ) + "_list" ].call(this, false); + }.bind(this), 100); + break; + + case "click": + if ( ! this.editing && this.has_focus ) { + this.editing = true; + this.input.removeAttribute('readonly'); + this.show_list(); + } + break; + + case "keyup": + e.target.value && this.editing ? this.filter(e.target.value) : this.show_list(true); + break; + } + + }.bind(this)); + }.bind(this)); + } + + add_item(key, value, selselfected, hide) { + let elem = document.createElement('li'); + + if (this.list.querySelector(`[data-key="${key}"]`) !== null) { + console.error(`Item with key '${key}' is already existing.`); + return false; + } + + elem.setAttribute('data-key', key); + + if (! (elem.innerText = value) ) { + elem.innerHTML = " "; + } + + let self = this; + + elem.addEventListener("click", function(e) { + self.set_value(this.getAttribute('data-key'), this.innerText); + + let item = self.list.querySelector(".selected"); + + if ( item ) { + item.classList.remove("selected"); + } + + this.classList.add("selected"); + + self.hide_list(true); + }); + + if (selected) { + elem.click(); + // this.input.key_up(); + } + + if (hide) { + elem.classList.add('hidden'); + } + + this.list.appendChild( + elem + ); + + return elem; + } + + set_value(key, value) { + this.input.value = value.trim(); + this.select.value = key; + } + + hide_list(force) { + if ( ! this.active || force ) { + this.has_focus = this.editing = false; + this.list.classList.add('hidden'); + this.input.setAttribute('readonly', 'readonly'); + this.show_all_items(); + this.item_create.classList.add('hidden'); + } + } + + show_list(show_all_items) { + this.list.classList.remove('hidden'); + + if ( show_all_items ) { + this.show_all_items(); + } + else { + this.editing && this.input.value && this.filter(this.input.value); + } + + if ((! this.querySelectorAll(":not(.hidden)").length || show_all_items ) && this.item_create ) { + this.item_create.classList.add('hidden'); + } + } + + flush() { + + } + + filter(org_query) { + let empty = true, + has_exact_result = false, + query = org_query.trim().normalize().toLowerCase(); + + Array.from( this.list.querySelectorAll('li:not([data-key="__create__"])') ).forEach(function(item) { + let text = item.innerText.normalize().toLowerCase(), + keep = false; + + if ( text === query ) { + keep = true; + empty = false; + has_exact_result = true; + } + else { + query.split(this._advanced_search ? " " : "\n").filter(item => item.length > 1).forEach(function(query_item) { + if ( text.indexOf(query_item) !== -1 ) { + keep = true; + empty = false; + } + }); + } + + if ( keep ) {; + item.classList.remove('hidden'); + } + else { + item.classList.add('hidden'); + } + }.bind(this)); + + if ( this.item_create ) { + if ( empty && ! has_exact_result ) { + this.item_create.classList.remove('hidden'); + + this.item_create.innerText = this.replace_vars(config.lang.create, { + name: org_query + }); + } + else { + this.item_create.classList.add('hidden'); + } + } + } + + replace_vars(content, vars) { + return content.replace(/{\$(.*?)}/g, function(match, varname) { + return vars[varname]; + }); + } + + show_all_items() { + Array.from( this.list.querySelectorAll('li') ).forEach(item => item.classList.remove('hidden')); + } + + get content() { + return this._content; + } + + set content(value) { + return this._content = value; + } + + get list() { + return this._list; + } + + set list(value) { + return this._list = value; + } + + get wrapper() { + return this._wrapper; + } + + set wrapper(value) { + return this._wrapper = value; + } + + get input() { + return this._input; + } + + set input(value) { + return this._input = value; + } + + get active() { + return this._active; + } + + set active(value) { + return this._active = value; + } + + get item_create() { + return this._item_create; + } + + set item_create(value) { + return this._item_create = value; + } + + get has_focus() { + return this._has_focus; + } + + set has_focus(value) { + return this._has_focus = value; + } + + get editing() { + return this._editing; + } + + set editing(value) { + this.wrapper.classList[value ? "add" : "remove"]('is-editing'); + return this._editing = value; + } + + get advanced_search() { + return this._advanced_search; + } + + set advanced_search(value) { + return this._advanced_search = value; + } +} + +export { UiSelect } diff --git a/asset/webcomponent/module/ui-tabs.js b/asset/webcomponent/module/ui-tabs.js new file mode 100644 index 0000000..8f65a1c --- /dev/null +++ b/asset/webcomponent/module/ui-tabs.js @@ -0,0 +1,50 @@ + +import { Webcomponent } from "./webcomponent.js"; + +const config = { + item: ".tab-item", + content: ".tab-content", + activeClass: "ui-tab-active", +}; + +class UiTabs extends Webcomponent { + constructor(parameters) { + super(); + + this.init(); + } + + init() { + window.addEventListener("hashchange", this.hashChange.bind(this), false); + + //this.querySelectorAll(config.item).forEach(ele => ele.addEventListener("click", this.clickTab.bind(this))); + + if (window.location.hash) { + let eve = new Event("click"); + eve.newURL = window.location.hash; + this.hashChange(eve); + } + } + + hashChange(e) { + let anchor = e.newURL.split('#')[1]; + + document.querySelectorAll("#" + anchor + config.content).forEach(function(content) { + content.parentNode.querySelectorAll(config.content + "." + config.activeClass).forEach(ele => ele.classList.remove(config.activeClass)); + content.classList.add(config.activeClass); + }); + + let element = this.querySelector("." + config.activeClass); + element ? element.classList.remove(config.activeClass) : null; + + element = this.querySelector(config.item + `[href="#${anchor}"]`); + element ? element.closest('div').classList.add(config.activeClass) : null; + } + + clickTab(e) { + e.preventDefault(); + history.pushState(null, null, this.getAttribute('href')); + } +} + +export { UiTabs } diff --git a/asset/webcomponent/module/ui-textarea.js b/asset/webcomponent/module/ui-textarea.js new file mode 100644 index 0000000..d4cd68e --- /dev/null +++ b/asset/webcomponent/module/ui-textarea.js @@ -0,0 +1,246 @@ + +import { Webcomponent } from "./webcomponent.js"; + +const baseRows = 1.6; + +const domElements = { + input: ".input", + toolbar: ".toolbar", + wrapper: ".wrapper", + boilerplate: ".content" +}; + +const domReplaceTag = { + i: "em", + b: "strong", + pre: "div" +}; + +const copyCss = [ + "font", "font-size", "color", "background", "outline" +]; + +class UiTextarea extends Webcomponent { + + constructor() { + super(); + + this.fetch_template(); + this.wrapper = this.shadowRoot.querySelector(domElements.wrapper); + this.content = this.shadowRoot.querySelector(domElements.boilerplate); + this.toolbar = this.shadowRoot.querySelector(domElements.toolbar); + + if ( ! this.textareaInit() ) { + return; + } + + this.toolbarInit(); + this.render(); + } + + render() { + document.execCommand("DefaultParagraphSeparator", false, "p"); + + this.content.innerHTML = this.textarea.value.trim(); + + this.content.addEventListener("input", function(e, d) { + this.content.querySelectorAll('[style]').forEach(function(element) { + element.removeAttribute('style'); + }); + + this.content.querySelectorAll('[class]').forEach(function(element) { + element.removeAttribute('class'); + }); + + this.textarea.value = this.content.innerHTML; + + this.content.dispatchEvent(new Event("change")); + }.bind(this)); + } + + actionAlign(value) { + switch(value) { + case "left" : + return document.execCommand('justifyLeft',false,null); + + case "center" : + return document.execCommand('justifyCenter',false,null); + + case "right" : + return document.execCommand('justifyRight',false,null); + + case "justify" : + return document.execCommand('justifyFull',false,null); + } + } + + actionBold(value, content) { + return document.execCommand('bold', false, null); + } + + actionItalic(value, content) { + return document.execCommand('italic', false, null); + } + + actionUnderline() { + return document.execCommand('underline',false, null); + } + + actionStrike() { + return document.execCommand('strikeThrough',false, null); + } + + actionUl() { + return document.execCommand('insertUnorderedList',false, null); + } + + actionOl() { + return document.execCommand('insertOrderedList',false, null); + } + + actionUndo() { + return document.execCommand('undo', false, null); + } + + actionRedo() { + return document.execCommand('redo', false, null); + } + + actionForecolor(color) { + return document.execCommand('forecolor', false, color); + } + + actionLink() { + + } + + actionImage() { + + } + + actionDocument() { + + } + + actionTag(tag) { + return document.execCommand('formatBlock', false, '<'+tag+'>'); + } + + toolbarInit() { + let action = this.getAttribute('toolbar'); + + if ( action !== null ) { + this.toolbar.setAttribute('action', action); + } + + this.toolbar.querySelectorAll('[data-action]:not(.font-color)').forEach(function(item){ + item.addEventListener('click', function(e) { + e.preventDefault(); + + let action = item.getAttribute('data-action'); + + return this["action" + action[0].toUpperCase() + action.slice(1)].call(this, item.getAttribute('data-value'), this.content); + }.bind(this)); + }.bind(this)); + + this.toolbar.querySelectorAll('.font-color').forEach(function(item) { + item.addEventListener('click', function(e) { + e.preventDefault(); + + this.toolbar.querySelector('label[for="colorpicker"]').dispatchEvent(new Event('click')); + }.bind(this)); + }.bind(this)); + + this.toolbar.querySelector('input[type="color"]').addEventListener('change', function(e) { + return this.actionForecolor.call(this, e.target.value); + }.bind(this)); + } + + textareaInit() { + if ( ! (this.textarea = this.querySelector("textarea") ) ) { + // throw "You should have a