From 3726aa75e32d4e37f9351eb585303cc553ae44e0 Mon Sep 17 00:00:00 2001 From: jjaffeux Date: Mon, 20 Apr 2020 16:07:10 +0200 Subject: [PATCH] REFACTOR: various code/UI/UX changes and refactorings - ability to clear placeholders - builder UI - link to placeholder - improve styles --- common/common.scss | 85 ++++- common/header.html | 233 ------------- .../discourse-placeholder-builder.js.es6 | 47 +++ .../discourse/initializers/setup.js.es6 | 318 ++++++++++++++++++ .../modal/discourse-placeholder-builder.hbs | 60 ++++ locales/en.yml | 17 + mobile/mobile.scss | 23 ++ 7 files changed, 546 insertions(+), 237 deletions(-) delete mode 100644 common/header.html create mode 100644 javascripts/discourse/controllers/discourse-placeholder-builder.js.es6 create mode 100644 javascripts/discourse/initializers/setup.js.es6 create mode 100644 javascripts/discourse/templates/modal/discourse-placeholder-builder.hbs create mode 100644 locales/en.yml create mode 100644 mobile/mobile.scss diff --git a/common/common.scss b/common/common.scss index 6cd324e..3bdf489 100644 --- a/common/common.scss +++ b/common/common.scss @@ -1,9 +1,53 @@ -.d-wrap[data-wrap=placeholder] { +.placeholder-ui { + display: flex; + justify-content: space-between; + padding: 1em; + background-color: blend-primary-secondary(5%); + border: 1px solid $primary-low; + align-items: center; + margin-bottom: 0.5em; + + .clear-placeholder { + svg { + pointer-events: none; + } + } + + .placeholders-container { + max-width: 90%; + margin: -1em 1em 0 0; + display: flex; + flex-wrap: wrap; + flex: 1; + align-items: center; + justify-content: space-between; + + a { + font-size: $font-down-1; + padding: 0.25em; + background: $primary-low; + border-radius: 3px; + color: $primary-medium; + margin-top: 1em; + + &:hover { + background: $primary-low-mid; + } + } + } +} + +div.d-wrap[data-wrap="placeholder"] { + margin: 1em 0; +} + +.d-wrap[data-wrap="placeholder"] { padding: 0.5em; - border: 1px solid $primary-medium; display: flex; align-items: center; justify-content: space-between; + border-left: 5px solid $primary-low; + background-color: blend-primary-secondary(5%); .discourse-placeholder-name { width: 200px; @@ -12,15 +56,29 @@ overflow: hidden; white-space: nowrap; margin-right: 0.5em; + min-width: 250px; + } + + p, + .discourse-placeholder-name { + font-size: $font-down-1; + color: dark-light-choose($primary-high, $secondary-low); + } + + p { + display: flex; + flex-direction: column; + margin: 0; } .discourse-placeholder-value, .discourse-placeholder-select { box-sizing: border-box; + margin-left: 1em; } .discourse-placeholder-value { - width: 100%; + width: 350px; padding: 0.5em; line-height: $line-height-small; color: $primary; @@ -29,7 +87,7 @@ } .discourse-placeholder-select { - width: 100%; + width: 350px; margin: 0.5em 0; line-height: $line-height-small; color: $primary; @@ -37,3 +95,22 @@ border: 1px solid $primary-medium; } } + +.discourse-placeholder-builder-modal { + .input { + input { + margin: 0; + width: 100%; + } + } + + .multi-select { + width: 100%; + } + + .description { + font-size: $font-down-1; + color: $primary-medium; + margin: 0.25em 0 1em 0; + } +} diff --git a/common/header.html b/common/header.html deleted file mode 100644 index fcf6436..0000000 --- a/common/header.html +++ /dev/null @@ -1,233 +0,0 @@ - diff --git a/javascripts/discourse/controllers/discourse-placeholder-builder.js.es6 b/javascripts/discourse/controllers/discourse-placeholder-builder.js.es6 new file mode 100644 index 0000000..610edb1 --- /dev/null +++ b/javascripts/discourse/controllers/discourse-placeholder-builder.js.es6 @@ -0,0 +1,47 @@ +import Controller from "@ember/controller"; +import ModalFunctionality from "discourse/mixins/modal-functionality"; +import EmberObject, { action } from "@ember/object"; +import { isBlank } from "@ember/utils"; + +export default Controller.extend(ModalFunctionality, { + form: null, + + onShow() { + this.set( + "form", + EmberObject.create({ + key: null, + description: null, + values: [] + }) + ); + }, + + onClose() {}, + + @action + insertPlaceholder() { + if (isBlank(this.form.key)) { + bootbox.alert(I18n.t(themePrefix("builder.errors.no_key"))); + return; + } + + let output = `[wrap=placeholder key="${this.form.key}"`; + + if (this.form.description) { + output = `${output} description="${this.form.description}"`; + } + + if (this.form.values.length) { + if (this.form.values.length === 1) { + output = `${output} default="${this.form.values.firstObject}"`; + } else { + output = `${output} defaults="${this.form.values.join(",")}"`; + } + } + + this.model.toolbarEvent.addText(`${output}][/wrap]`); + + this.send("closeModal"); + } +}); diff --git a/javascripts/discourse/initializers/setup.js.es6 b/javascripts/discourse/initializers/setup.js.es6 new file mode 100644 index 0000000..18b9b5b --- /dev/null +++ b/javascripts/discourse/initializers/setup.js.es6 @@ -0,0 +1,318 @@ +import { iconHTML } from "discourse-common/lib/icon-library"; +import showModal from "discourse/lib/show-modal"; +import { withPluginApi } from "discourse/lib/plugin-api"; +import { later, debounce } from "@ember/runloop"; + +const VALID_TAGS = "h1, h2, h3, h4, h5, h6, p, code, blockquote, .md-table, li"; +const DELIMITER = "="; + +function buildPlaceholderUI(element, clearButton, placeholderNodes) { + const ui = document.createElement("div"); + ui.classList.add("placeholder-ui"); + + const placeholdersContainer = document.createElement("div"); + placeholdersContainer.classList.add("placeholders-container"); + + placeholderNodes.forEach(placeholderNode => { + const link = document.createElement("a"); + link.href = `#placeholder-key-${placeholderNode.dataset.key}`; + link.innerText = placeholderNode.dataset.key; + placeholdersContainer.append(link); + }); + + ui.appendChild(placeholdersContainer); + ui.appendChild(clearButton); + + return ui; +} + +function buildInput(key, placeholder) { + const input = document.createElement("input"); + input.classList.add("discourse-placeholder-value"); + input.dataset.key = key; + input.dataset.delimiter = placeholder.delimiter; + + if (placeholder.description) { + input.setAttribute("placeholder", placeholder.description); + } + + if (placeholder.default) { + input.value = placeholder.default; + } + + return input; +} + +function addSelectOption(select, options = {}) { + const option = document.createElement("option"); + option.classList.add("discourse-placeholder-option"); + option.value = options.value; + option.text = options.description || options.value; + + if (options.selected) { + option.setAttribute("selected", true); + } + + select.appendChild(option); +} + +function buildSelect(key, placeholder) { + const select = document.createElement("select"); + select.classList.add("discourse-placeholder-select"); + select.dataset.key = key; + select.dataset.delimiter = placeholder.delimiter; + + if (placeholder.description) { + addSelectOption(select, { + value: "none", + description: placeholder.description + }); + } + + placeholder.defaults.forEach(value => + addSelectOption(select, { + value, + selected: placeholder.default === value + }) + ); + + return select; +} + +function buildClearButton() { + const clearButton = document.createElement("button"); + clearButton.innerHTML = iconHTML("trash-alt"); + clearButton.classList.add( + "clear-placeholder", + "btn", + "no-text", + "btn-default", + "btn-primary" + ); + clearButton.disabled = true; + return clearButton; +} + +export default { + name: "discourse-placeholder-theme-component", + + initialize() { + withPluginApi("0.8.7", api => { + api.decorateCooked( + ($cooked, postWidget) => { + if (!postWidget) return; + + const postIdentifier = `d-placeholder-${postWidget.widget.attrs.topicId}-${postWidget.widget.attrs.id}-`; + const clearButton = buildClearButton(); + clearButton.addEventListener("click", _clearPlaceholders); + const mappings = []; + const placeholders = {}; + + function processChange(inputEvent) { + const value = inputEvent.target.value; + const key = inputEvent.target.dataset.key; + const delimiter = inputEvent.target.dataset.delimiter; + const placeholderIdentifier = `${postIdentifier}${key}`; + + if (value) { + $.cookie(placeholderIdentifier, value); + } else { + $.removeCookie(placeholderIdentifier); + } + + let newValue; + if (value && value.length && value !== "none") { + newValue = value; + clearButton.disabled = false; + } else { + newValue = `${delimiter}${key}${delimiter}`; + } + + $cooked.find(VALID_TAGS).each((index, elem) => { + const mapping = mappings[index]; + + if (!mapping) return; + + let diff = 0; + let replaced = false; + let newInnnerHTML = elem.innerHTML; + + mapping.forEach(m => { + if (m.pattern !== `${delimiter}${key}${delimiter}`) { + m.position = m.position + diff; + return; + } + + replaced = true; + + const previousLength = m.length; + const prefix = newInnnerHTML.slice(0, m.position + diff); + const suffix = newInnnerHTML.slice( + m.position + diff + m.length, + newInnnerHTML.length + ); + newInnnerHTML = `${prefix}${newValue}${suffix}`; + + m.length = newValue.length; + m.position = m.position + diff; + diff = diff + newValue.length - previousLength; + }); + + if (replaced) elem.innerHTML = newInnnerHTML; + }); + } + + function processPlaceholders() { + mappings.length = 0; + + const keys = Object.keys(placeholders); + const pattern = keys + .map(key => { + const placeholder = placeholders[key]; + return `(${placeholder.delimiter}${key}${placeholder.delimiter})`; + }) + .join("|"); + const regex = new RegExp(pattern, "g"); + + $cooked.find(VALID_TAGS).each((index, elem) => { + let match; + + mappings[index] = mappings[index] || []; + + while ((match = regex.exec(elem.innerHTML)) != null) { + mappings[index].push({ + pattern: match[0], + position: match.index, + length: match[0].length + }); + } + }); + } + + function _fillPlaceholders() { + if (Object.keys(placeholders).length > 0) { + processPlaceholders(placeholders, $cooked, mappings); + + // trigger fake event to setup initial state + Object.keys(placeholders).forEach(placeholderKey => { + const placeholder = placeholders[placeholderKey]; + const placeholderIdentifier = `${postIdentifier}${placeholderKey}`; + const value = $.cookie(placeholderIdentifier); + + if (value) { + clearButton.disabled = false; + } + + processChange({ + target: { + value, + dataset: { + key: placeholderKey, + delimiter: placeholder.delimiter + } + } + }); + }); + } + } + + function _clearPlaceholders(event) { + $cooked[0] + .querySelectorAll( + ".discourse-placeholder-value, .discourse-placeholder-select" + ) + .forEach(node => { + $.removeCookie(`${postIdentifier}${node.dataset.key}`); + node.value = + node.parentNode.dataset.default || + (node.tagName === "SELECT" ? "none" : ""); + }); + + event.target.disabled = true; + } + + const placeholderNodes = $cooked[0].querySelectorAll( + ".d-wrap[data-wrap=placeholder]:not(.placeholdered)" + ); + + if (placeholderNodes.length) { + $cooked[0].prepend( + buildPlaceholderUI($cooked[0], clearButton, placeholderNodes) + ); + } + + placeholderNodes.forEach(elem => { + const dataKey = elem.dataset.key; + + if (!dataKey) return; + + elem.id = `placeholder-key-${dataKey}`; + + const placeholderIdentifier = `${postIdentifier}${dataKey}`; + const valueFromCookie = $.cookie(placeholderIdentifier); + const defaultValues = (elem.dataset.defaults || "") + .split(",") + .filter(Boolean); + + placeholders[dataKey] = { + default: valueFromCookie || elem.dataset.default, + defaults: defaultValues, + delimiter: elem.dataset.delimiter || DELIMITER, + description: elem.dataset.description + }; + + const span = document.createElement("span"); + span.classList.add("discourse-placeholder-name", "placeholdered"); + span.innerText = dataKey; + + // content has been set inside the [wrap][/wrap] block + if (elem.querySelector("p")) { + elem.querySelector("p").prepend(span); + } else { + elem.prepend(span); + } + + if (defaultValues && defaultValues.length) { + const select = buildSelect(dataKey, placeholders[dataKey]); + elem.appendChild(select); + } else { + const input = buildInput(dataKey, placeholders[dataKey]); + elem.appendChild(input); + } + }); + + $cooked + .on("input", ".discourse-placeholder-value", inputEvent => + debounce(this, processChange, inputEvent, 250) + ) + .on("change", ".discourse-placeholder-select", inputEvent => + debounce(this, processChange, inputEvent, 250) + ); + + later(_fillPlaceholders, 500); + }, + { onlyStream: true, id: "discourse-placeholder-theme-component" } + ); + + api.addToolbarPopupMenuOptionsCallback(() => { + return { + action: "insertPlaceholder", + icon: "file", + label: themePrefix("toolbar.builder") + }; + }); + + api.modifyClass("controller:composer", { + actions: { + insertPlaceholder() { + showModal("discourse-placeholder-builder", { + model: { + toolbarEvent: this.toolbarEvent + } + }); + } + } + }); + }); + } +}; diff --git a/javascripts/discourse/templates/modal/discourse-placeholder-builder.hbs b/javascripts/discourse/templates/modal/discourse-placeholder-builder.hbs new file mode 100644 index 0000000..c2127dd --- /dev/null +++ b/javascripts/discourse/templates/modal/discourse-placeholder-builder.hbs @@ -0,0 +1,60 @@ +{{#d-modal-body + title=(theme-prefix "builder.title") + class="discourse-placeholder-builder" + style="overflow: auto"}} +
+
+ + {{theme-i18n "builder.key.label"}} + +
+ {{input + value=(readonly form.key) + input=(action (mut form.key) value="target.value") + }} +
+

{{theme-i18n "builder.key.description"}}

+
+ +
+ + {{theme-i18n "builder.description.label"}} + +
+ {{input + value=(readonly form.description) + input=(action (mut form.description) value="target.value") + }} +
+

{{theme-i18n "builder.description.description"}}

+
+ +
+ + {{theme-i18n "builder.values.label"}} + +
+ {{multi-select + valueProperty=null + nameProperty=null + value=form.values + content=form.values + options=(hash + allowAny=true + placementStrategy="absolute" + ) + onChange=(action (mut form.values)) + }} +
+

{{theme-i18n "builder.values.description"}}

+
+
+{{/d-modal-body}} + + diff --git a/locales/en.yml b/locales/en.yml new file mode 100644 index 0000000..61f465e --- /dev/null +++ b/locales/en.yml @@ -0,0 +1,17 @@ +en: + toolbar: + builder: "Add Placeholder" + builder: + errors: + no_key: "A key is required." + title: "Add Placeholder" + insert: "Insert" + key: + label: "Key" + description: "The =Key= to be replaced in the post." + description: + label: "Description" + description: "Description displayed on input with no value set." + values: + label: "Default value(s)" + description: "Optional value(s) for your placeholder, if multiple values are defined, a select will be used." diff --git a/mobile/mobile.scss b/mobile/mobile.scss new file mode 100644 index 0000000..12f83ee --- /dev/null +++ b/mobile/mobile.scss @@ -0,0 +1,23 @@ +.placeholder-ui { + flex-direction: column; + + .placeholders-container { + max-width: 100%; + } +} + +.d-wrap[data-wrap="placeholder"] { + .discourse-placeholder-name { + width: 50%; + min-width: auto; + } + + p { + max-width: 40%; + } + + .discourse-placeholder-value, + .discourse-placeholder-select { + width: 100%; + } +}