REFACTOR: various code/UI/UX changes and refactorings

- ability to clear placeholders
- builder UI
- link to placeholder
- improve styles
This commit is contained in:
jjaffeux 2020-04-20 16:07:10 +02:00
parent 6f46840fb1
commit 3726aa75e3
7 changed files with 546 additions and 237 deletions

View File

@ -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;
}
}

View File

@ -1,233 +0,0 @@
<script type="text/discourse-plugin" version="0.8.30">
api.decorateCooked(($cooked, postWidget) => {
const VALID_TAGS = "h1, h2, h3, h4, h5, h6, p, code, blockquote, .md-table, li";
const DELIMITER = "=";
const mappings = [];
const placeholders = {};
// http://davidwalsh.name/javascript-debounce-function
function debounce(func, wait, immediate) {
let timeout;
return function() {
const context = this,
args = arguments;
const later = function() {
timeout = null;
if (!immediate) func.apply(context, args);
};
const callNow = immediate && !timeout;
clearTimeout(timeout);
timeout = setTimeout(later, wait);
if (callNow) func.apply(context, args);
};
}
function processChange($cooked, inputEvent, mappings) {
const value = inputEvent.target.value;
const key = inputEvent.target.dataset.key;
const delimiter = inputEvent.target.dataset.delimiter;
if (postWidget) {
const placeholderIdentifier = `d-placeholder-${postWidget.widget.attrs.topicId}-${postWidget.widget.attrs.id}-${key}`;
if (value) {
$.cookie(placeholderIdentifier, value);
} else {
$.removeCookie(placeholderIdentifier);
}
}
let newValue;
if (value && value.length && value !== "none") {
newValue = value;
} else {
newValue = `${delimiter}${key}${delimiter}`;
}
$cooked.find(VALID_TAGS).each((index, elem) => {
let replaced = false;
const mapping = mappings[index];
let newInnnerHTML = elem.innerHTML;
let diff = 0;
if (!mapping) {
return;
}
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 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 processPlaceholders(placeholders, $cooked, mappings) {
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) => {
const innerHTML = elem.innerHTML;
let match;
mappings[index] = mappings[index] || [];
while ((match = regex.exec(innerHTML)) != null) {
mappings[index].push({
pattern: match[0],
position: match.index,
length: match[0].length
});
}
});
}
$cooked.find(".d-wrap[data-wrap=placeholder]:not(.placeholdered)").each((index, elem) => {
const dataKey = elem.dataset.key;
if (!dataKey) {
return;
}
let valueFromCookie;
if (postWidget) {
const placeholderIdentifier = `d-placeholder-${postWidget.widget.attrs.topicId}-${postWidget.widget.attrs.id}`;
valueFromCookie = $.cookie(`${placeholderIdentifier}-${dataKey}`);
}
const defaultValue = valueFromCookie || elem.dataset.default;
const defaultValues = (elem.dataset.defaults || "").split(",").filter(x => x);
const description = elem.dataset.description;
const delimiter = elem.dataset.delimiter || DELIMITER;
placeholders[dataKey] = {
default: defaultValue,
defaults: defaultValues,
delimiter,
description
}
elem.classList.add("placeholdered")
const span = document.createElement("span");
span.classList.add("discourse-placeholder-name")
span.innerText = dataKey;
elem.appendChild(span)
if (defaultValues && defaultValues.length) {
const select = document.createElement("select");
select.classList.add("discourse-placeholder-select")
select.dataset.key = dataKey;
select.dataset.delimiter = delimiter;
if (description) {
addSelectOption(select, { value: "none", description });
}
defaultValues.forEach(value =>
addSelectOption(select, {
value,
selected: defaultValue === value
})
);
elem.appendChild(select);
} else {
const input = document.createElement("input");
input.classList.add("discourse-placeholder-value")
input.dataset.key = dataKey;
input.dataset.delimiter = delimiter;
if (description) {
input.setAttribute("placeholder", description);
}
if (valueFromCookie || defaultValue) {
input.value = valueFromCookie || defaultValue;
}
elem.appendChild(input)
}
})
$cooked
.on(
"input",
".discourse-placeholder-value",
debounce(inputEvent => {
processChange($cooked, inputEvent, mappings);
}, 250)
)
.on(
"change",
".discourse-placeholder-select",
debounce(inputEvent => {
processChange($cooked, inputEvent, mappings);
}, 250)
);
Ember.run.later(() => {
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 value =
placeholder.default ||
(placeholder.defaults.length && !placeholder.description
? placeholder.defaults[0]
: null);
processChange(
$cooked,
{ target: { value, dataset: { key: placeholderKey, delimiter: placeholder.delimiter } } },
mappings
);
});
}, 500);
}, { id: "discourse-placeholder-theme-component" });
</script>

View File

@ -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");
}
});

View File

@ -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
}
});
}
}
});
});
}
};

View File

@ -0,0 +1,60 @@
{{#d-modal-body
title=(theme-prefix "builder.title")
class="discourse-placeholder-builder"
style="overflow: auto"}}
<form>
<div class="control">
<span class="label">
{{theme-i18n "builder.key.label"}}
</span>
<div class="input">
{{input
value=(readonly form.key)
input=(action (mut form.key) value="target.value")
}}
</div>
<p class="description">{{theme-i18n "builder.key.description"}}</p>
</div>
<div class="control">
<span class="label">
{{theme-i18n "builder.description.label"}}
</span>
<div class="input">
{{input
value=(readonly form.description)
input=(action (mut form.description) value="target.value")
}}
</div>
<p class="description">{{theme-i18n "builder.description.description"}}</p>
</div>
<div class="control">
<span class="label">
{{theme-i18n "builder.values.label"}}
</span>
<div class="input">
{{multi-select
valueProperty=null
nameProperty=null
value=form.values
content=form.values
options=(hash
allowAny=true
placementStrategy="absolute"
)
onChange=(action (mut form.values))
}}
</div>
<p class="description">{{theme-i18n "builder.values.description"}}</p>
</div>
</form>
{{/d-modal-body}}
<div class="modal-footer discourse-local-dates-create-modal-footer">
{{d-button
class="btn-primary"
action=(action "insertPlaceholder")
label=(theme-prefix "builder.insert")
}}
</div>

17
locales/en.yml Normal file
View File

@ -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."

23
mobile/mobile.scss Normal file
View File

@ -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%;
}
}