mirror of
https://github.com/pandorafms/pandorafms.git
synced 2025-07-07 05:54:53 +02:00
1023 lines
32 KiB
TypeScript
1023 lines
32 KiB
TypeScript
import {
|
|
AnyObject,
|
|
Position,
|
|
Size,
|
|
WithAgentProps,
|
|
WithModuleProps,
|
|
LinkedVisualConsoleProps,
|
|
LinkedVisualConsolePropsStatus,
|
|
UnknownObject,
|
|
ItemMeta
|
|
} from "./types";
|
|
|
|
import helpTipIcon from "./help-tip.png";
|
|
import fontAwesomeIcon from "./FontAwesomeIcon";
|
|
import { faPencilAlt, faListAlt } from "@fortawesome/free-solid-svg-icons";
|
|
import "./autocomplete.css";
|
|
|
|
/**
|
|
* Return a number or a default value from a raw value.
|
|
* @param value Raw value from which we will try to extract a valid number.
|
|
* @param defaultValue Default value to use if we cannot extract a valid number.
|
|
* @return A valid number or the default value.
|
|
*/
|
|
export function parseIntOr<T>(value: unknown, defaultValue: T): number | T {
|
|
if (typeof value === "number") return value;
|
|
if (typeof value === "string" && value.length > 0 && !isNaN(parseInt(value)))
|
|
return parseInt(value);
|
|
else return defaultValue;
|
|
}
|
|
|
|
/**
|
|
* Return a number or a default value from a raw value.
|
|
* @param value Raw value from which we will try to extract a valid number.
|
|
* @param defaultValue Default value to use if we cannot extract a valid number.
|
|
* @return A valid number or the default value.
|
|
*/
|
|
export function parseFloatOr<T>(value: unknown, defaultValue: T): number | T {
|
|
if (typeof value === "number") return value;
|
|
if (
|
|
typeof value === "string" &&
|
|
value.length > 0 &&
|
|
!isNaN(parseFloat(value))
|
|
)
|
|
return parseFloat(value);
|
|
else return defaultValue;
|
|
}
|
|
|
|
/**
|
|
* Check if a string exists and it's not empty.
|
|
* @param value Value to check.
|
|
* @return The check result.
|
|
*/
|
|
export function stringIsEmpty(value?: string | null): boolean {
|
|
return value == null || value.length === 0;
|
|
}
|
|
|
|
/**
|
|
* Return a not empty string or a default value from a raw value.
|
|
* @param value Raw value from which we will try to extract a non empty string.
|
|
* @param defaultValue Default value to use if we cannot extract a non empty string.
|
|
* @return A non empty string or the default value.
|
|
*/
|
|
export function notEmptyStringOr<T>(
|
|
value: unknown,
|
|
defaultValue: T
|
|
): string | T {
|
|
return typeof value === "string" && value.length > 0 ? value : defaultValue;
|
|
}
|
|
|
|
/**
|
|
* Return a boolean from a raw value.
|
|
* @param value Raw value from which we will try to extract the boolean.
|
|
* @return A valid boolean value. false by default.
|
|
*/
|
|
export function parseBoolean(value: unknown): boolean {
|
|
if (typeof value === "boolean") return value;
|
|
else if (typeof value === "number") return value > 0;
|
|
else if (typeof value === "string") return value === "1" || value === "true";
|
|
else return false;
|
|
}
|
|
|
|
/**
|
|
* Return a valid date or a default value from a raw value.
|
|
* @param value Raw value from which we will try to extract a valid date.
|
|
* @param defaultValue Default value to use if we cannot extract a valid date.
|
|
* @return A valid date or the default value.
|
|
*/
|
|
export function parseDateOr<T>(value: unknown, defaultValue: T): Date | T {
|
|
if (value instanceof Date) return value;
|
|
else if (typeof value === "number") return new Date(value * 1000);
|
|
else if (
|
|
typeof value === "string" &&
|
|
!Number.isNaN(new Date(value).getTime())
|
|
)
|
|
return new Date(value);
|
|
else return defaultValue;
|
|
}
|
|
|
|
/**
|
|
* Pad the current string with another string (multiple times, if needed)
|
|
* until the resulting string reaches the given length.
|
|
* The padding is applied from the start (left) of the current string.
|
|
* @param value Text that needs to be padded.
|
|
* @param length Length of the returned text.
|
|
* @param pad Text to add.
|
|
* @return Padded text.
|
|
*/
|
|
export function leftPad(
|
|
value: string | number,
|
|
length: number,
|
|
pad: string | number = " "
|
|
): string {
|
|
if (typeof value === "number") value = `${value}`;
|
|
if (typeof pad === "number") pad = `${pad}`;
|
|
|
|
const diffLength = length - value.length;
|
|
if (diffLength === 0) return value;
|
|
if (diffLength < 0) return value.substr(Math.abs(diffLength));
|
|
|
|
if (diffLength === pad.length) return `${pad}${value}`;
|
|
if (diffLength < pad.length) return `${pad.substring(0, diffLength)}${value}`;
|
|
|
|
const repeatTimes = Math.floor(diffLength / pad.length);
|
|
const restLength = diffLength - pad.length * repeatTimes;
|
|
|
|
let newPad = "";
|
|
for (let i = 0; i < repeatTimes; i++) newPad += pad;
|
|
|
|
if (restLength === 0) return `${newPad}${value}`;
|
|
return `${newPad}${pad.substring(0, restLength)}${value}`;
|
|
}
|
|
|
|
/* Decoders */
|
|
|
|
/**
|
|
* Build a valid typed object from a raw object.
|
|
* @param data Raw object.
|
|
* @return An object representing the position.
|
|
*/
|
|
export function positionPropsDecoder(data: AnyObject): Position {
|
|
return {
|
|
x: parseIntOr(data.x, 0),
|
|
y: parseIntOr(data.y, 0)
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Build a valid typed object from a raw object.
|
|
* @param data Raw object.
|
|
* @return An object representing the size.
|
|
* @throws Will throw a TypeError if the width and height are not valid numbers.
|
|
*/
|
|
export function sizePropsDecoder(data: AnyObject): Size | never {
|
|
if (
|
|
data.width == null ||
|
|
isNaN(parseInt(data.width)) ||
|
|
data.height == null ||
|
|
isNaN(parseInt(data.height))
|
|
) {
|
|
throw new TypeError("invalid size.");
|
|
}
|
|
|
|
return {
|
|
width: parseInt(data.width),
|
|
height: parseInt(data.height)
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Build a valid typed object from a raw object.
|
|
* @param data Raw object.
|
|
* @return An object representing the agent properties.
|
|
*/
|
|
export function agentPropsDecoder(data: AnyObject): WithAgentProps {
|
|
const agentProps: WithAgentProps = {
|
|
agentId: parseIntOr(data.agentId, null),
|
|
agentName: notEmptyStringOr(data.agentName, null),
|
|
agentAlias: notEmptyStringOr(data.agentAlias, null),
|
|
agentDescription: notEmptyStringOr(data.agentDescription, null),
|
|
agentAddress: notEmptyStringOr(data.agentAddress, null)
|
|
};
|
|
|
|
return data.metaconsoleId != null
|
|
? {
|
|
metaconsoleId: data.metaconsoleId,
|
|
...agentProps // Object spread: http://es6-features.org/#SpreadOperator
|
|
}
|
|
: agentProps;
|
|
}
|
|
|
|
/**
|
|
* Build a valid typed object from a raw object.
|
|
* @param data Raw object.
|
|
* @return An object representing the module and agent properties.
|
|
*/
|
|
export function modulePropsDecoder(data: AnyObject): WithModuleProps {
|
|
return {
|
|
moduleId: parseIntOr(data.moduleId, null),
|
|
moduleName: notEmptyStringOr(data.moduleName, null),
|
|
moduleDescription: notEmptyStringOr(data.moduleDescription, null),
|
|
...agentPropsDecoder(data) // Object spread: http://es6-features.org/#SpreadOperator
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Build a valid typed object from a raw object.
|
|
* @param data Raw object.
|
|
* @return An object representing the linked visual console properties.
|
|
* @throws Will throw a TypeError if the status calculation properties are invalid.
|
|
*/
|
|
export function linkedVCPropsDecoder(
|
|
data: AnyObject
|
|
): LinkedVisualConsoleProps | never {
|
|
let linkedLayoutStatusProps: LinkedVisualConsolePropsStatus = {
|
|
linkedLayoutStatusType: "default"
|
|
};
|
|
switch (data.linkedLayoutStatusType) {
|
|
case "weight": {
|
|
const weight = parseIntOr(data.linkedLayoutStatusTypeWeight, null);
|
|
if (weight == null)
|
|
throw new TypeError("invalid status calculation properties.");
|
|
|
|
if (data.linkedLayoutStatusTypeWeight)
|
|
linkedLayoutStatusProps = {
|
|
linkedLayoutStatusType: "weight",
|
|
linkedLayoutStatusTypeWeight: weight
|
|
};
|
|
break;
|
|
}
|
|
case "service": {
|
|
const warningThreshold = parseIntOr(
|
|
data.linkedLayoutStatusTypeWarningThreshold,
|
|
null
|
|
);
|
|
const criticalThreshold = parseIntOr(
|
|
data.linkedLayoutStatusTypeCriticalThreshold,
|
|
null
|
|
);
|
|
if (warningThreshold == null || criticalThreshold == null) {
|
|
throw new TypeError("invalid status calculation properties.");
|
|
}
|
|
|
|
linkedLayoutStatusProps = {
|
|
linkedLayoutStatusType: "service",
|
|
linkedLayoutStatusTypeWarningThreshold: warningThreshold,
|
|
linkedLayoutStatusTypeCriticalThreshold: criticalThreshold
|
|
};
|
|
break;
|
|
}
|
|
}
|
|
|
|
return {
|
|
linkedLayoutId: parseIntOr(data.linkedLayoutId, null),
|
|
linkedLayoutNodeId: parseIntOr(data.linkedLayoutNodeId, null),
|
|
...linkedLayoutStatusProps // Object spread: http://es6-features.org/#SpreadOperator
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Build a valid typed object from a raw object.
|
|
* @param data Raw object.
|
|
* @return An object representing the item's meta properties.
|
|
*/
|
|
export function itemMetaDecoder(data: UnknownObject): ItemMeta | never {
|
|
const receivedAt = parseDateOr(data.receivedAt, null);
|
|
if (receivedAt === null) throw new TypeError("invalid meta structure");
|
|
|
|
let error = null;
|
|
if (data.error instanceof Error) error = data.error;
|
|
else if (typeof data.error === "string") error = new Error(data.error);
|
|
|
|
return {
|
|
receivedAt,
|
|
error,
|
|
editMode: parseBoolean(data.editMode),
|
|
isFromCache: parseBoolean(data.isFromCache),
|
|
isFetching: false,
|
|
isUpdating: false,
|
|
isBeingMoved: false,
|
|
isBeingResized: false,
|
|
isSelected: false,
|
|
lineMode: false
|
|
};
|
|
}
|
|
|
|
/**
|
|
* To get a CSS rule with the most used prefixes.
|
|
* @param ruleName Name of the CSS rule.
|
|
* @param ruleValue Value of the CSS rule.
|
|
* @return An array of rules with the prefixes applied.
|
|
*/
|
|
export function prefixedCssRules(
|
|
ruleName: string,
|
|
ruleValue: string
|
|
): string[] {
|
|
const rule = `${ruleName}: ${ruleValue};`;
|
|
return [
|
|
`-webkit-${rule}`,
|
|
`-moz-${rule}`,
|
|
`-ms-${rule}`,
|
|
`-o-${rule}`,
|
|
`${rule}`
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Decode a base64 string.
|
|
* @param input Data encoded using base64.
|
|
* @return Decoded data.
|
|
*/
|
|
export function decodeBase64(input: string): string {
|
|
return decodeURIComponent(escape(window.atob(input)));
|
|
}
|
|
|
|
/**
|
|
* Generate a date representation with the format 'd/m/Y'.
|
|
* @param initialDate Date to be used instead of a generated one.
|
|
* @param locale Locale to use if localization is required and available.
|
|
* @example 24/02/2020.
|
|
* @return Date representation.
|
|
*/
|
|
export function humanDate(date: Date, locale: string | null = null): string {
|
|
if (locale && Intl && Intl.DateTimeFormat) {
|
|
// Format using the user locale.
|
|
const options: Intl.DateTimeFormatOptions = {
|
|
day: "2-digit",
|
|
month: "2-digit",
|
|
year: "numeric"
|
|
};
|
|
return Intl.DateTimeFormat(locale, options).format(date);
|
|
} else {
|
|
// Use getDate, getDay returns the week day.
|
|
const day = leftPad(date.getDate(), 2, 0);
|
|
// The getMonth function returns the month starting by 0.
|
|
const month = leftPad(date.getMonth() + 1, 2, 0);
|
|
const year = leftPad(date.getFullYear(), 4, 0);
|
|
|
|
// Format: 'd/m/Y'.
|
|
return `${day}/${month}/${year}`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate a time representation with the format 'hh:mm:ss'.
|
|
* @param initialDate Date to be used instead of a generated one.
|
|
* @example 01:34:09.
|
|
* @return Time representation.
|
|
*/
|
|
export function humanTime(date: Date): string {
|
|
const hours = leftPad(date.getHours(), 2, 0);
|
|
const minutes = leftPad(date.getMinutes(), 2, 0);
|
|
const seconds = leftPad(date.getSeconds(), 2, 0);
|
|
|
|
return `${hours}:${minutes}:${seconds}`;
|
|
}
|
|
|
|
interface Macro {
|
|
macro: string | RegExp;
|
|
value: string;
|
|
}
|
|
/**
|
|
* Replace the macros of a text.
|
|
* @param macros List of macros and their replacements.
|
|
* @param text Text in which we need to replace the macros.
|
|
*/
|
|
export function replaceMacros(macros: Macro[], text: string): string {
|
|
return macros.reduce(
|
|
(acc, { macro, value }) => acc.replace(macro, value),
|
|
text
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Create a function which will limit the rate of execution of
|
|
* the selected function to one time for the selected interval.
|
|
* @param delay Interval.
|
|
* @param fn Function to be executed at a limited rate.
|
|
*/
|
|
export function throttle<T, R>(delay: number, fn: (...args: T[]) => R) {
|
|
let last = 0;
|
|
return (...args: T[]) => {
|
|
const now = Date.now();
|
|
if (now - last < delay) return;
|
|
last = now;
|
|
return fn(...args);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create a function which will call the selected function only
|
|
* after the interval time has passed after its last execution.
|
|
* @param delay Interval.
|
|
* @param fn Function to be executed after the last call.
|
|
*/
|
|
export function debounce<T>(delay: number, fn: (...args: T[]) => void) {
|
|
let timerRef: number | null = null;
|
|
return (...args: T[]) => {
|
|
if (timerRef !== null) window.clearTimeout(timerRef);
|
|
timerRef = window.setTimeout(() => {
|
|
fn(...args);
|
|
timerRef = null;
|
|
}, delay);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Retrieve the offset of an element relative to the page.
|
|
* @param el Node used to calculate the offset.
|
|
*/
|
|
function getOffset(el: HTMLElement | null, parent?: HTMLElement) {
|
|
let x = 0;
|
|
let y = 0;
|
|
while (
|
|
el &&
|
|
!Number.isNaN(el.offsetLeft) &&
|
|
!Number.isNaN(el.offsetTop) &&
|
|
el !== parent
|
|
) {
|
|
x += el.offsetLeft - el.scrollLeft;
|
|
y += el.offsetTop - el.scrollTop;
|
|
el = el.offsetParent as HTMLElement | null;
|
|
}
|
|
return { top: y, left: x };
|
|
}
|
|
|
|
/**
|
|
* Add the grab & move functionality to a certain element inside it's container.
|
|
*
|
|
* @param element Element to move.
|
|
* @param onMoved Function to execute when the element moves.
|
|
* @param altContainer Alternative element to contain the moved element.
|
|
*
|
|
* @return A function which will clean the event handlers when executed.
|
|
*/
|
|
export function addMovementListener(
|
|
element: HTMLElement,
|
|
onMoved: (x: Position["x"], y: Position["y"]) => void,
|
|
altContainer?: HTMLElement
|
|
): Function {
|
|
const container = altContainer || (element.parentElement as HTMLElement);
|
|
|
|
// Store the initial draggable state.
|
|
const isDraggable = element.draggable;
|
|
// Init the coordinates.
|
|
let lastX: Position["x"] = 0;
|
|
let lastY: Position["y"] = 0;
|
|
let lastMouseX: Position["x"] = 0;
|
|
let lastMouseY: Position["y"] = 0;
|
|
let mouseElementOffsetX: Position["x"] = 0;
|
|
let mouseElementOffsetY: Position["y"] = 0;
|
|
// Bounds.
|
|
let containerBounds = container.getBoundingClientRect();
|
|
let containerOffset = getOffset(container);
|
|
let containerTop = containerOffset.top;
|
|
let containerBottom = containerTop + containerBounds.height;
|
|
let containerLeft = containerOffset.left;
|
|
let containerRight = containerLeft + containerBounds.width;
|
|
let elementBounds = element.getBoundingClientRect();
|
|
let borderWidth = window.getComputedStyle(element).borderWidth || "0";
|
|
let borderFix = Number.parseInt(borderWidth) * 2;
|
|
|
|
// Will run onMoved 32ms after its last execution.
|
|
const debouncedMovement = debounce(32, onMoved);
|
|
// Will run onMoved one time max every 16ms.
|
|
const throttledMovement = throttle(16, onMoved);
|
|
|
|
const handleMove = (e: MouseEvent) => {
|
|
// Calculate the new element coordinates.
|
|
let x = 0;
|
|
let y = 0;
|
|
|
|
const mouseX = e.pageX;
|
|
const mouseY = e.pageY;
|
|
const mouseDeltaX = mouseX - lastMouseX;
|
|
const mouseDeltaY = mouseY - lastMouseY;
|
|
|
|
const minX = 0;
|
|
const maxX = containerBounds.width - elementBounds.width + borderFix;
|
|
const minY = 0;
|
|
const maxY = containerBounds.height - elementBounds.height + borderFix;
|
|
|
|
const outOfBoundsLeft =
|
|
mouseX < containerLeft ||
|
|
(lastX === 0 &&
|
|
mouseDeltaX > 0 &&
|
|
mouseX < containerLeft + mouseElementOffsetX);
|
|
const outOfBoundsRight =
|
|
mouseX > containerRight ||
|
|
mouseDeltaX + lastX + elementBounds.width - borderFix >
|
|
containerBounds.width ||
|
|
(lastX === maxX &&
|
|
mouseDeltaX < 0 &&
|
|
mouseX > containerLeft + maxX + mouseElementOffsetX);
|
|
const outOfBoundsTop =
|
|
mouseY < containerTop ||
|
|
(lastY === 0 &&
|
|
mouseDeltaY > 0 &&
|
|
mouseY < containerTop + mouseElementOffsetY);
|
|
const outOfBoundsBottom =
|
|
mouseY > containerBottom ||
|
|
mouseDeltaY + lastY + elementBounds.height - borderFix >
|
|
containerBounds.height ||
|
|
(lastY === maxY &&
|
|
mouseDeltaY < 0 &&
|
|
mouseY > containerTop + maxY + mouseElementOffsetY);
|
|
|
|
if (outOfBoundsLeft) x = minX;
|
|
else if (outOfBoundsRight) x = maxX;
|
|
else x = mouseDeltaX + lastX;
|
|
|
|
if (outOfBoundsTop) y = minY;
|
|
else if (outOfBoundsBottom) y = maxY;
|
|
else y = mouseDeltaY + lastY;
|
|
|
|
if (x < 0) x = minX;
|
|
if (y < 0) y = minY;
|
|
|
|
// Store the last mouse coordinates.
|
|
lastMouseX = mouseX;
|
|
lastMouseY = mouseY;
|
|
|
|
if (x === lastX && y === lastY) return;
|
|
|
|
// Run the movement events.
|
|
throttledMovement(x, y);
|
|
debouncedMovement(x, y);
|
|
|
|
// Store the coordinates of the element.
|
|
lastX = x;
|
|
lastY = y;
|
|
};
|
|
const handleEnd = () => {
|
|
// Reset the positions.
|
|
lastX = 0;
|
|
lastY = 0;
|
|
lastMouseX = 0;
|
|
lastMouseY = 0;
|
|
// Remove the move event.
|
|
document.removeEventListener("mousemove", handleMove);
|
|
// Clean itself.
|
|
document.removeEventListener("mouseup", handleEnd);
|
|
// Reset the draggable property to its initial state.
|
|
element.draggable = isDraggable;
|
|
// Reset the body selection property to a default state.
|
|
document.body.style.userSelect = "auto";
|
|
};
|
|
const handleStart = (e: MouseEvent) => {
|
|
// Avoid starting the movement on right click.
|
|
if (e.button === 2) return;
|
|
|
|
e.stopPropagation();
|
|
|
|
// Disable the drag temporarily.
|
|
element.draggable = false;
|
|
|
|
// Fix for Firefox browser.
|
|
element.setAttribute("ondragstart", "return false;");
|
|
element.setAttribute("draggable", "false");
|
|
|
|
// Store the difference between the cursor and
|
|
// the initial coordinates of the element.
|
|
const elementOffset = getOffset(element, container);
|
|
lastX = elementOffset.left;
|
|
lastY = elementOffset.top;
|
|
|
|
// Store the mouse position.
|
|
lastMouseX = e.pageX;
|
|
lastMouseY = e.pageY;
|
|
// Store the relative position between the mouse and the element.
|
|
mouseElementOffsetX = e.offsetX;
|
|
mouseElementOffsetY = e.offsetY;
|
|
|
|
// Initialize the bounds.
|
|
containerBounds = container.getBoundingClientRect();
|
|
containerOffset = getOffset(container);
|
|
containerTop = containerOffset.top;
|
|
containerBottom = containerTop + containerBounds.height;
|
|
containerLeft = containerOffset.left;
|
|
containerRight = containerLeft + containerBounds.width;
|
|
elementBounds = element.getBoundingClientRect();
|
|
borderWidth = window.getComputedStyle(element).borderWidth || "0";
|
|
borderFix = Number.parseInt(borderWidth) * 2;
|
|
|
|
// Listen to the mouse movement.
|
|
document.addEventListener("mousemove", handleMove);
|
|
// Listen to the moment when the mouse click is not pressed anymore.
|
|
document.addEventListener("mouseup", handleEnd);
|
|
// Limit the mouse selection of the body.
|
|
document.body.style.userSelect = "none";
|
|
};
|
|
|
|
// Event to listen the init of the movement.
|
|
element.addEventListener("mousedown", handleStart);
|
|
|
|
// Returns a function to clean the event listeners.
|
|
return () => {
|
|
element.removeEventListener("mousedown", handleStart);
|
|
handleEnd();
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Add the grab & resize functionality to a certain element.
|
|
*
|
|
* @param element Element to move.
|
|
* @param onResized Function to execute when the element is resized.
|
|
*
|
|
* @return A function which will clean the event handlers when executed.
|
|
*/
|
|
export function addResizementListener(
|
|
element: HTMLElement,
|
|
onResized: (x: Position["x"], y: Position["y"]) => void
|
|
): Function {
|
|
const minWidth = 15;
|
|
const minHeight = 15;
|
|
|
|
const resizeDraggable = document.createElement("div");
|
|
resizeDraggable.className = "resize-draggable";
|
|
element.appendChild(resizeDraggable);
|
|
|
|
// Container of the resizable element.
|
|
const container = element.parentElement as HTMLElement;
|
|
// Store the initial draggable state.
|
|
const isDraggable = element.draggable;
|
|
// Init the coordinates.
|
|
let lastWidth: Size["width"] = 0;
|
|
let lastHeight: Size["height"] = 0;
|
|
let lastMouseX: Position["x"] = 0;
|
|
let lastMouseY: Position["y"] = 0;
|
|
let mouseElementOffsetX: Position["x"] = 0;
|
|
let mouseElementOffsetY: Position["y"] = 0;
|
|
// Init the bounds.
|
|
let containerBounds = container.getBoundingClientRect();
|
|
let containerOffset = getOffset(container);
|
|
let containerTop = containerOffset.top;
|
|
let containerBottom = containerTop + containerBounds.height;
|
|
let containerLeft = containerOffset.left;
|
|
let containerRight = containerLeft + containerBounds.width;
|
|
let elementOffset = getOffset(element);
|
|
let elementTop = elementOffset.top;
|
|
let elementLeft = elementOffset.left;
|
|
let borderWidth = window.getComputedStyle(element).borderWidth || "0";
|
|
let borderFix = Number.parseInt(borderWidth);
|
|
|
|
// Will run onResized 32ms after its last execution.
|
|
const debouncedResizement = debounce(32, onResized);
|
|
// Will run onResized one time max every 16ms.
|
|
const throttledResizement = throttle(16, onResized);
|
|
|
|
const handleResize = (e: MouseEvent) => {
|
|
// Calculate the new element coordinates.
|
|
let width = lastWidth + (e.pageX - lastMouseX);
|
|
let height = lastHeight + (e.pageY - lastMouseY);
|
|
|
|
if (width === lastWidth && height === lastHeight) return;
|
|
|
|
if (
|
|
width < lastWidth &&
|
|
e.pageX > elementLeft + (lastWidth - mouseElementOffsetX)
|
|
)
|
|
return;
|
|
|
|
if (width < minWidth) {
|
|
// Minimum value.
|
|
width = minWidth;
|
|
} else if (width + elementLeft - borderFix / 2 >= containerRight) {
|
|
// Limit the size to the container.
|
|
width = containerRight - elementLeft;
|
|
}
|
|
if (height < minHeight) {
|
|
// Minimum value.
|
|
height = minHeight;
|
|
} else if (height + elementTop - borderFix / 2 >= containerBottom) {
|
|
// Limit the size to the container.
|
|
height = containerBottom - elementTop;
|
|
}
|
|
|
|
// Run the movement events.
|
|
throttledResizement(width, height);
|
|
debouncedResizement(width, height);
|
|
|
|
// Store the coordinates of the element.
|
|
lastWidth = width;
|
|
lastHeight = height;
|
|
// Store the last mouse coordinates.
|
|
lastMouseX = e.pageX;
|
|
lastMouseY = e.pageY;
|
|
};
|
|
const handleEnd = () => {
|
|
// Reset the positions.
|
|
lastWidth = 0;
|
|
lastHeight = 0;
|
|
lastMouseX = 0;
|
|
lastMouseY = 0;
|
|
mouseElementOffsetX = 0;
|
|
mouseElementOffsetY = 0;
|
|
// Remove the move event.
|
|
document.removeEventListener("mousemove", handleResize);
|
|
// Clean itself.
|
|
document.removeEventListener("mouseup", handleEnd);
|
|
// Reset the draggable property to its initial state.
|
|
element.draggable = isDraggable;
|
|
// Reset the body selection property to a default state.
|
|
document.body.style.userSelect = "auto";
|
|
};
|
|
const handleStart = (e: MouseEvent) => {
|
|
e.stopPropagation();
|
|
|
|
// Disable the drag temporarily.
|
|
element.draggable = false;
|
|
|
|
// Store the difference between the cursor and
|
|
// the initial coordinates of the element.
|
|
const { width, height } = element.getBoundingClientRect();
|
|
lastWidth = width;
|
|
lastHeight = height;
|
|
// Store the mouse position.
|
|
lastMouseX = e.pageX;
|
|
lastMouseY = e.pageY;
|
|
// Store the relative position between the mouse and the element.
|
|
mouseElementOffsetX = e.offsetX;
|
|
mouseElementOffsetY = e.offsetY;
|
|
|
|
// Initialize the bounds.
|
|
containerBounds = container.getBoundingClientRect();
|
|
containerOffset = getOffset(container);
|
|
containerTop = containerOffset.top;
|
|
containerBottom = containerTop + containerBounds.height;
|
|
containerLeft = containerOffset.left;
|
|
containerRight = containerLeft + containerBounds.width;
|
|
elementOffset = getOffset(element);
|
|
elementTop = elementOffset.top;
|
|
elementLeft = elementOffset.left;
|
|
|
|
// Listen to the mouse movement.
|
|
document.addEventListener("mousemove", handleResize);
|
|
// Listen to the moment when the mouse click is not pressed anymore.
|
|
document.addEventListener("mouseup", handleEnd);
|
|
// Limit the mouse selection of the body.
|
|
document.body.style.userSelect = "none";
|
|
};
|
|
|
|
// Event to listen the init of the movement.
|
|
resizeDraggable.addEventListener("mousedown", handleStart);
|
|
|
|
// Returns a function to clean the event listeners.
|
|
return () => {
|
|
resizeDraggable.remove();
|
|
handleEnd();
|
|
};
|
|
}
|
|
|
|
// TODO: Document and code
|
|
export function t(text: string): string {
|
|
return text;
|
|
}
|
|
|
|
export function helpTip(text: string): HTMLElement {
|
|
const container = document.createElement("a");
|
|
container.className = "tip";
|
|
const icon = document.createElement("img");
|
|
icon.src = helpTipIcon;
|
|
icon.className = "forced_title";
|
|
icon.setAttribute("alt", text);
|
|
icon.setAttribute("data-title", text);
|
|
icon.setAttribute("data-use_title_for_force_title", "1");
|
|
|
|
container.appendChild(icon);
|
|
|
|
return container;
|
|
}
|
|
|
|
interface PeriodSelectorOption {
|
|
value: number;
|
|
text: string;
|
|
}
|
|
export function periodSelector(
|
|
selectedValue: PeriodSelectorOption["value"] | null,
|
|
emptyOption: PeriodSelectorOption | null,
|
|
options: PeriodSelectorOption[],
|
|
onChange: (value: PeriodSelectorOption["value"]) => void
|
|
): HTMLElement {
|
|
if (selectedValue === null) selectedValue = 0;
|
|
const initialValue = emptyOption ? emptyOption.value : 0;
|
|
let currentValue: number =
|
|
selectedValue != null ? selectedValue : initialValue;
|
|
// Main container.
|
|
const container = document.createElement("div");
|
|
// Container for the period selector.
|
|
const periodsContainer = document.createElement("div");
|
|
const selectPeriods = document.createElement("select");
|
|
const useManualPeriodsBtn = document.createElement("a");
|
|
// Container for the custom period input.
|
|
const manualPeriodsContainer = document.createElement("div");
|
|
const inputTimeValue = document.createElement("input");
|
|
const unitsSelect = document.createElement("select");
|
|
const usePeriodsBtn = document.createElement("a");
|
|
// Units to multiply the custom period input.
|
|
const unitOptions: { value: string; text: string }[] = [
|
|
{ value: "1", text: t("Seconds").toLowerCase() },
|
|
{ value: "60", text: t("Minutes").toLowerCase() },
|
|
{ value: "3600", text: t("Hours").toLowerCase() },
|
|
{ value: "86400", text: t("Days").toLowerCase() },
|
|
{ value: "604800", text: t("Weeks").toLowerCase() },
|
|
{ value: `${86400 * 30}`, text: t("Months").toLowerCase() },
|
|
{ value: `${86400 * 30 * 12}`, text: t("Years").toLowerCase() }
|
|
];
|
|
|
|
// Will be executed every time the value changes.
|
|
const handleChange = (value: number) => {
|
|
currentValue = value;
|
|
onChange(currentValue);
|
|
};
|
|
// Will return the first period option smaller than the value.
|
|
const findPeriodsOption = (value: number) =>
|
|
options
|
|
.sort((a, b) => (a.value < b.value ? 1 : -1))
|
|
.find(optionVal => value >= optionVal.value);
|
|
// Will return the first multiple of the value using the custom input multipliers.
|
|
const findManualPeriodsOptionValue = (value: number) =>
|
|
unitOptions
|
|
.map(unitOption => Number.parseInt(unitOption.value))
|
|
.sort((a, b) => (a < b ? 1 : -1))
|
|
.find(optionVal => value % optionVal === 0);
|
|
// Will find and set a valid option for the period selector.
|
|
const setPeriodsValue = (value: number) => {
|
|
let option = findPeriodsOption(value);
|
|
selectPeriods.value = `${option ? option.value : initialValue}`;
|
|
};
|
|
// Will transform the value to show the perfect fit for the custom input period.
|
|
const setManualPeriodsValue = (value: number) => {
|
|
const optionVal = findManualPeriodsOptionValue(value);
|
|
if (optionVal) {
|
|
inputTimeValue.value = `${value / optionVal}`;
|
|
unitsSelect.value = `${optionVal}`;
|
|
} else {
|
|
inputTimeValue.value = `${value}`;
|
|
unitsSelect.value = "1";
|
|
}
|
|
};
|
|
|
|
// Will modify the value to show the perfect fit for this element and show its container.
|
|
const showPeriods = () => {
|
|
let option = findPeriodsOption(currentValue);
|
|
const newValue = option ? option.value : initialValue;
|
|
selectPeriods.value = `${newValue}`;
|
|
|
|
if (newValue !== currentValue) handleChange(newValue);
|
|
|
|
container.replaceChild(periodsContainer, manualPeriodsContainer);
|
|
};
|
|
// Will modify the value to show the perfect fit for this element and show its container.
|
|
const showManualPeriods = () => {
|
|
const optionVal = findManualPeriodsOptionValue(currentValue);
|
|
|
|
if (optionVal) {
|
|
inputTimeValue.value = `${currentValue / optionVal}`;
|
|
unitsSelect.value = `${optionVal}`;
|
|
} else {
|
|
inputTimeValue.value = `${currentValue}`;
|
|
unitsSelect.value = "1";
|
|
}
|
|
|
|
container.replaceChild(manualPeriodsContainer, periodsContainer);
|
|
};
|
|
|
|
// Append the elements
|
|
|
|
periodsContainer.appendChild(selectPeriods);
|
|
periodsContainer.appendChild(useManualPeriodsBtn);
|
|
|
|
manualPeriodsContainer.appendChild(inputTimeValue);
|
|
manualPeriodsContainer.appendChild(unitsSelect);
|
|
manualPeriodsContainer.appendChild(usePeriodsBtn);
|
|
|
|
if (
|
|
options.find(option => option.value === selectedValue) ||
|
|
(emptyOption && emptyOption.value === selectedValue)
|
|
) {
|
|
// Start with the custom periods select.
|
|
container.appendChild(periodsContainer);
|
|
} else {
|
|
// Start with the manual time input
|
|
container.appendChild(manualPeriodsContainer);
|
|
}
|
|
|
|
// Set and fill the elements.
|
|
|
|
// Periods selector.
|
|
|
|
selectPeriods.addEventListener("change", (e: Event) =>
|
|
handleChange(
|
|
parseIntOr((e.target as HTMLSelectElement).value, initialValue)
|
|
)
|
|
);
|
|
if (emptyOption) {
|
|
const optionElem = document.createElement("option");
|
|
optionElem.value = `${emptyOption.value}`;
|
|
optionElem.text = emptyOption.text;
|
|
selectPeriods.appendChild(optionElem);
|
|
}
|
|
options.forEach(option => {
|
|
const optionElem = document.createElement("option");
|
|
optionElem.value = `${option.value}`;
|
|
optionElem.text = option.text;
|
|
selectPeriods.appendChild(optionElem);
|
|
});
|
|
|
|
setPeriodsValue(selectedValue);
|
|
|
|
useManualPeriodsBtn.appendChild(
|
|
fontAwesomeIcon(faPencilAlt, t("Show manual period input"), {
|
|
size: "small"
|
|
})
|
|
);
|
|
useManualPeriodsBtn.addEventListener("click", e => {
|
|
e.preventDefault();
|
|
showManualPeriods();
|
|
});
|
|
|
|
// Manual periods input.
|
|
|
|
inputTimeValue.type = "number";
|
|
inputTimeValue.min = "0";
|
|
inputTimeValue.required = true;
|
|
inputTimeValue.addEventListener("change", (e: Event) =>
|
|
handleChange(
|
|
parseIntOr((e.target as HTMLSelectElement).value, 0) *
|
|
parseIntOr(unitsSelect.value, 1)
|
|
)
|
|
);
|
|
// Select for time units.
|
|
unitsSelect.addEventListener("change", (e: Event) =>
|
|
handleChange(
|
|
parseIntOr(inputTimeValue.value, 0) *
|
|
parseIntOr((e.target as HTMLSelectElement).value, 1)
|
|
)
|
|
);
|
|
unitOptions.forEach(option => {
|
|
const optionElem = document.createElement("option");
|
|
optionElem.value = `${option.value}`;
|
|
optionElem.text = option.text;
|
|
unitsSelect.appendChild(optionElem);
|
|
});
|
|
|
|
setManualPeriodsValue(selectedValue);
|
|
|
|
usePeriodsBtn.appendChild(
|
|
fontAwesomeIcon(faListAlt, t("Show periods selector"), { size: "small" })
|
|
);
|
|
usePeriodsBtn.addEventListener("click", e => {
|
|
e.preventDefault();
|
|
showPeriods();
|
|
});
|
|
|
|
return container;
|
|
}
|
|
|
|
/**
|
|
* Cuts the text if their length is greater than the selected max length
|
|
* and applies the selected ellipse to the result text.
|
|
* @param str Text to cut
|
|
* @param max Maximum length after cutting the text
|
|
* @param ellipse String to be added to the cutted text
|
|
* @returns Full text or text cutted with the ellipse
|
|
*/
|
|
export function ellipsize(
|
|
str: string,
|
|
max: number = 140,
|
|
ellipse: string = "…"
|
|
): string {
|
|
return str.trim().length > max ? str.substr(0, max).trim() + ellipse : str;
|
|
}
|
|
|
|
// TODO: Document
|
|
export function autocompleteInput<T>(
|
|
initialValue: string | null,
|
|
onDataRequested: (value: string, done: (data: T[]) => void) => void,
|
|
renderListElement: (data: T) => HTMLElement,
|
|
onSelected: (data: T) => string
|
|
): HTMLElement {
|
|
const container = document.createElement("div");
|
|
container.classList.add("autocomplete");
|
|
|
|
const input = document.createElement("input");
|
|
input.type = "text";
|
|
input.required = true;
|
|
if (initialValue !== null) input.value = initialValue;
|
|
|
|
const list = document.createElement("div");
|
|
list.classList.add("autocomplete-items");
|
|
|
|
const cleanList = () => {
|
|
list.innerHTML = "";
|
|
};
|
|
|
|
input.addEventListener("keyup", e => {
|
|
const value = (e.target as HTMLInputElement).value;
|
|
if (value) {
|
|
onDataRequested(value, data => {
|
|
cleanList();
|
|
if (data instanceof Array) {
|
|
data.forEach(item => {
|
|
const listElement = renderListElement(item);
|
|
listElement.addEventListener("click", () => {
|
|
input.value = onSelected(item);
|
|
cleanList();
|
|
});
|
|
list.appendChild(listElement);
|
|
});
|
|
}
|
|
});
|
|
} else {
|
|
cleanList();
|
|
}
|
|
});
|
|
|
|
container.appendChild(input);
|
|
container.appendChild(list);
|
|
|
|
return container;
|
|
}
|