src/og-components/og-input-calculator/directives/og-input-calculator.ts
import "../css/og-input-calculator.css";
import type {
OgInputCalculatorOperation,
OgInputCalculatorOperator,
OgInputCalculatorScope,
} from "~/og-components/og-input-calculator/types";
import OgInputCalculatorView from "~/og-components/og-input-calculator/views/calculator.html";
import type OgInputCurrencyController from "~/og-components/og-input-currency/controllers/currency";
import type OgInputNumberController from "~/og-components/og-input-number/controllers/number";
import type OgModalErrorService from "~/og-components/og-modal-error/services/og-modal-error";
import angular from "angular";
export default class OgInputCalculatorDirective {
public constructor(
$window: angular.IWindowService,
$timeout: angular.ITimeoutService,
ogModalErrorService: OgModalErrorService,
) {
const showError: (message?: unknown) => void =
ogModalErrorService.showError.bind(ogModalErrorService),
directive: angular.IDirective = {
restrict: "A",
require: ["ngModel", "?ogInputCurrency", "?ogInputNumber"],
replace: true,
templateUrl: OgInputCalculatorView,
link(
scope: OgInputCalculatorScope,
iElement: JQuery<Element>,
iAttrs: angular.IAttributes,
controllers: angular.IController[],
): void {
// Get the position of the popover, or default to left if unspecified
scope.position =
undefined === iAttrs.ogInputCalculator ||
"" === iAttrs.ogInputCalculator
? "left"
: String(iAttrs.ogInputCalculator);
const [ngModel] = controllers,
ogInputCurrency = 1,
ogInputNumber = 2,
ACTION_KEYS: Record<string, () => void> = {
Enter(): void {
scope.update();
},
Escape(): void {
scope.cancel();
},
"="(): void {
scope.update();
},
};
scope.ogInput =
(controllers[
ogInputCurrency
] as OgInputCurrencyController | null) ??
(controllers[ogInputNumber] as OgInputNumberController);
// Push an operation onto the stack
scope.push = (
operand: number,
operator: OgInputCalculatorOperator,
): void => {
// Push the operand on the stack
if (scope.stack.length) {
scope.stack[scope.stack.length - 1].operand = operand;
} else {
scope.stack.push({ operand });
// Show the popover
$timeout((): boolean =>
angular
.element(iElement)[0]
.dispatchEvent(new Event("showCalculator")),
).catch(showError);
}
// Push the operator onto the stack
scope.stack.push({ operator });
// Update the display expression
$timeout(
(): string =>
(scope.expression = `\n${operator} ${
scope.ogInput.rawToFormatted(
scope.ogInput.formattedToRaw(String(operand)),
) + (" " === scope.expression ? "" : scope.expression)
}`),
).catch(showError);
};
// Perform the calculation
scope.calculate = (value: string): void => {
// Default the result to the current view value
scope.result = Number(scope.ogInput.formattedToRaw(value));
// Make the current view value available on the scope
scope.current = scope.ogInput.rawToFormatted(Number(scope.result));
if (scope.stack.length > 1) {
scope.result = Number(
scope.stack.reduce(
(
memo: OgInputCalculatorOperation,
operation: OgInputCalculatorOperation,
index: number,
): OgInputCalculatorOperation => {
const result: OgInputCalculatorOperation & {
operand: number;
} = { operand: 0, ...memo };
// Last time through, use the view value for the operand
if (index === scope.stack.length - 1) {
operation.operand = scope.ogInput.formattedToRaw(value);
}
switch (operation.operator) {
case "+":
result.operand += Number(operation.operand);
break;
case "-":
result.operand -= Number(operation.operand);
break;
case "*":
result.operand *= Number(operation.operand);
break;
case "/":
result.operand /= Number(operation.operand);
break;
default:
}
return result;
},
).operand,
);
}
scope.formattedResult = scope.ogInput.rawToFormatted(
scope.ogInput.formattedToRaw(String(scope.result)),
);
};
// Handle input value changes
scope.inputChanged = (value: string): string => {
// Matches any number of digits, periods or commas, followed by +, -, * or /
const matches: RegExpExecArray | null =
/(?<operand>[-\d.,]+)(?<operator>[+\-*/])(?<residual>.*)/giu.exec(
value,
);
if (undefined === matches?.groups) {
// Recalculate
scope.calculate(value);
} else {
const { operand, operator, residual } = matches.groups;
// Push the first (operand) and second (operator) matches onto the stack
scope.push(
Number(operand),
operator as OgInputCalculatorOperator,
);
// Update the view value to the third match (anything after the operator), which is typically an empty string
iElement.val(residual);
}
// Return the current result
return String(scope.result);
};
// View to model
(ngModel as angular.INgModelController).$parsers.push(
scope.inputChanged,
);
// Update the input value and hide the calculator
scope.update = (): void => {
// Reset the stack
scope.clear();
// Set the value
iElement.val(scope.result);
// Hide the popover
scope.close();
};
// Cancel the calculation and hide the calculator
scope.cancel = (): void => {
scope.clear();
scope.close();
};
// Clear the stack
scope.clear = (): void => {
scope.stack = [];
scope.expression = " ";
};
// Close the popover
scope.close = (): void => {
$timeout((): boolean =>
angular
.element(iElement)[0]
.dispatchEvent(new Event("hideCalculator")),
).catch(showError);
};
// Start with a cleared calculator
scope.clear();
function keyHandler(event: JQuery.KeyDownEvent): void {
scope.keyHandler(event);
}
function update(): void {
if (scope.stack.length) {
scope.update();
}
}
// Declare key handler to detect operators and actions
scope.keyHandler = (event: JQuery.KeyDownEvent): void => {
// Check if the key pressed was an action key, and there is a pending calculation (otherwise, let the event propagate)
if (
!event.shiftKey &&
undefined !==
Object.getOwnPropertyDescriptor(ACTION_KEYS, event.key) &&
scope.stack.length
) {
// Invoke the action
ACTION_KEYS[event.key]();
// Select the new input value
$timeout(
(): unknown => $window.$(iElement).select() as unknown,
).catch(showError);
// Swallow the event
event.preventDefault();
event.stopPropagation();
}
};
iElement.on("keydown", keyHandler);
iElement.on("blur", update);
// When the element is destroyed, remove all event handlers
iElement.on("$destroy", (): void => {
iElement.off("keydown", keyHandler);
iElement.off("blur", update);
});
},
};
return directive;
}
public static factory(
$window: angular.IWindowService,
$timeout: angular.ITimeoutService,
ogModalErrorService: OgModalErrorService,
): OgInputCalculatorDirective {
return new OgInputCalculatorDirective(
$window,
$timeout,
ogModalErrorService,
);
}
}
OgInputCalculatorDirective.factory.$inject = [
"$window",
"$timeout",
"ogModalErrorService",
];