Skip to content

Commit

Permalink
fix: support React exercise picker (#3)
Browse files Browse the repository at this point in the history
* refactor: separate exercise selector responsibilities to delegates

* fix: support new React exercise picker

Fixes #2
  • Loading branch information
Wassup789 authored Aug 4, 2024
1 parent 2c14c94 commit d47bd1a
Show file tree
Hide file tree
Showing 13 changed files with 443 additions and 130 deletions.
6 changes: 6 additions & 0 deletions src/Observers.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { takeoverExerciseContainer } from "./enhancements/ExerciseSelectorEnhancement";
import { monitorWeightContainer } from "./enhancements/ExerciseSetWeightEnhancement";
import { OnObserverDestroyFunct } from "./models/OnObserverDestroyFunct";
import { takeoverWorkoutExerciseEditor } from "./enhancements/WorkoutExerciseEditorEnhancement";

const CHOSEN_PARENT_CONTAINER_SELECTOR = ".workout-name, .workout-step-exercises",
WEIGHT_CONTAINER_SELECTOR = ".input-append.weight-entry",
WORKOUT_EXERCISE_CONTAINER_SELECTOR = "[class^='ExercisePicker_dropdown_']",
CONTAINER_MAPPINGS: ReadonlyArray<[string, (parent: HTMLElement) => void]> = [
[CHOSEN_PARENT_CONTAINER_SELECTOR, addExerciseContainersFromParent],
[WEIGHT_CONTAINER_SELECTOR, addWeightContainersFromParent],
[WORKOUT_EXERCISE_CONTAINER_SELECTOR, addExerciseContainersForWorkoutsFromParent],
],
knownContainers: Map<HTMLElement, Exclude<OnObserverDestroyFunct, false>> = new Map();

Expand Down Expand Up @@ -43,6 +46,9 @@ function addExerciseContainersFromParent(parent: HTMLElement) {
function addWeightContainersFromParent(parent: HTMLElement) {
addGenericContainersFromParent(parent, WEIGHT_CONTAINER_SELECTOR, (container) => monitorWeightContainer(container));
}
function addExerciseContainersForWorkoutsFromParent(parent: HTMLElement) {
addGenericContainersFromParent(parent, WORKOUT_EXERCISE_CONTAINER_SELECTOR, (container) => takeoverWorkoutExerciseEditor(container));
}

function addGenericContainersFromParent(parent: HTMLElement, containerSelector: string, callback: (container: HTMLElement) => OnObserverDestroyFunct) {
const containers = Array.from(parent.querySelectorAll(containerSelector)) as HTMLElement[];
Expand Down
108 changes: 29 additions & 79 deletions src/components/ExerciseSelector.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { customElement, state } from "lit/decorators.js";
import { customElement, property } from "lit/decorators.js";
import { css, html, LitElement } from "lit";
import ExerciseOption from "../models/ExerciseOption";
import ExerciseSelectorPopup from "./ExerciseSelectorPopup";
import ExerciseGroup from "../models/ExerciseGroup";
import { TypedLitElement } from "../models/TypedEventTarget";
import ExerciseSelectorDelegate from "../models/ExerciseSelectorDelegate";

@customElement(ExerciseSelector.NAME)
export default class ExerciseSelector extends (LitElement as TypedLitElement<ExerciseSelector, ExerciseSelectorEventMap>) {
Expand Down Expand Up @@ -61,77 +62,32 @@ export default class ExerciseSelector extends (LitElement as TypedLitElement<Exe
}
`;

readonly parentSelectElem: HTMLSelectElement;
readonly suggestedGroup: ExerciseGroup;
get suggestedGroup(): ExerciseGroup {
return this.delegate.suggestedGroup;
}

readonly fromWorkoutEditor: boolean;
readonly canApplyToMultipleSets: boolean;
readonly type: string;
private readonly delegate: ExerciseSelectorDelegate;

readonly popupInstance: ExerciseSelectorPopup;

private _savedOption: ExerciseOption | null = null;
@state()
private set savedOption(value: ExerciseOption | null) {
this._savedOption = value;
}
get savedOption(): ExerciseOption | null {
return this._savedOption;
}

connectErrorListener!: () => void;
disconnectErrorListener!: () => void;
@property()
savedOption: ExerciseOption | null = null;

constructor(readonly parentElem: HTMLElement) {
constructor(
delegate: ExerciseSelectorDelegate,
type: string,
canApplyToMultipleSets: boolean,
readonly parentElem: HTMLElement
) {
super();

this.fromWorkoutEditor = parentElem.matches(".workout-step-exercises");
this.parentSelectElem = parentElem.querySelector("select.chosen-select")!;
this.suggestedGroup = this.generateSuggestedGroup();
this.type = ExerciseSelector.getType(this);
this.popupInstance = ExerciseSelectorPopup.getInstanceForSelector(this);

this.findSavedOption();
this.setupErrorListener();
}

private static getType(selector: ExerciseSelector) {
const option = selector.parentSelectElem.querySelector("optgroup:last-child > option:last-child") as HTMLOptionElement;

return option.innerText;
}

private generateSuggestedGroup(): ExerciseGroup {
const options = (Array.from(this.parentSelectElem.querySelectorAll("optgroup[label=\"Suggested\"] option")) as HTMLOptionElement[])
.map((e) => new ExerciseOption(e))
.sort((a, b) => a.textCleaned.localeCompare(b.textCleaned));

return new ExerciseGroup("Suggested", options);
}

findSavedOption() {
const option = this.parentSelectElem.selectedOptions[0] ?? null;
if (!option) {
return;
}
this.canApplyToMultipleSets = canApplyToMultipleSets;
this.type = type;
this.delegate = delegate;

const exerciseOption = ExerciseOption.findExerciseOptionFromOptionElement(this.popupInstance.allOptions, option);

if (exerciseOption) {
this.savedOption = exerciseOption;
} else {
console.warn("Failed to find the option instance for", option);
}
}

setupErrorListener() {
const errorElem = this.parentElem.querySelector(".chosen-single")!;

const observer = new MutationObserver(() => {
this.onError(errorElem.classList.contains("error-tooltip-active"));
});

this.connectErrorListener = () => observer.observe(errorElem, { attributes: true, attributeFilter: ["class"] });
this.disconnectErrorListener = () => observer.disconnect();
this.popupInstance = ExerciseSelectorPopup.getInstanceForSelector(this);
}

protected render(): unknown {
Expand All @@ -145,21 +101,15 @@ export default class ExerciseSelector extends (LitElement as TypedLitElement<Exe
connectedCallback() {
super.connectedCallback();

this.connectErrorListener?.();

this.hideGarminElements();
this.delegate.onConnected();

ExerciseSelector.INSTANCES.add(this);
}

hideGarminElements() {
this.parentElem.querySelector(".chosen-container.chosen-container-single")!.setAttribute("style", "opacity: 0;pointer-events: none;height: 0px;position: relative;max-width: none;width: 100%;display: block;");
}

disconnectedCallback() {
super.disconnectedCallback();

this.disconnectErrorListener?.();
this.delegate.onDisconnected();
this.dispatchEvent(new CustomEvent(ExerciseSelector.EVENT_ON_DISCONNECT));

ExerciseSelector.INSTANCES.delete(this);
Expand All @@ -178,17 +128,17 @@ export default class ExerciseSelector extends (LitElement as TypedLitElement<Exe
}

onSelectOption(option: ExerciseOption | null) {
const optionElemIndex = !option ? -1 : option.findOptionFromSelectElement(this.parentSelectElem)?.index;

if (optionElemIndex !== undefined) {
this.parentSelectElem.selectedIndex = optionElemIndex;
this.parentSelectElem.dispatchEvent(new Event("change"));

if (this.delegate.onSelectOption(option)) {
this.savedOption = option;
} else {
console.warn("Failed to find the option element for", option);
}
}

generateOptions(): ExerciseOption[] {
return this.delegate.generateOptions()
.sort((a, b) => {
return (Number(b.suggested) - Number(a.suggested)) || a.textCleaned.localeCompare(b.textCleaned);
});
}
}

interface ExerciseSelectorEventMap {
Expand Down
26 changes: 4 additions & 22 deletions src/components/ExerciseSelectorPopup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export default class ExerciseSelectorPopup extends LitElement {
let instance = this.INSTANCES.get(selector.type);

if (!instance) {
instance = new ExerciseSelectorPopup(selector);
instance = new ExerciseSelectorPopup(selector.generateOptions());
this.INSTANCES.set(selector.type, instance);
}

Expand Down Expand Up @@ -158,11 +158,10 @@ export default class ExerciseSelectorPopup extends LitElement {
@query("input") inputElem!: HTMLInputElement;
@query(".options-container") optionsElem!: HTMLInputElement;

private constructor(initialSelector: ExerciseSelector) {
private constructor(options: ExerciseOption[]) {
super();

const selectElem = initialSelector.parentSelectElem;
this.options = this.generateOptions(selectElem);
this.options = options;
this.allOptions = this.options;
this.groups = this.generateOptionGroups();
this.populateOptionElements();
Expand Down Expand Up @@ -193,7 +192,7 @@ export default class ExerciseSelectorPopup extends LitElement {
</div>
<div class="filters-container">
<exercise-selector-filter-applies
style=${styleMap({ display: this.host && !this.host.fromWorkoutEditor ? "" : "none" })}
style=${styleMap({ display: this.host && this.host.canApplyToMultipleSets ? "" : "none" })}
@on-input=${(evt: CustomEvent<ApplyMode>) => this.applyMode = evt.detail}></exercise-selector-filter-applies>
<exercise-selector-filter-preview
.overlayTitle=${this.selectedOption?.text || ""}
Expand Down Expand Up @@ -558,23 +557,6 @@ export default class ExerciseSelectorPopup extends LitElement {
this.deactivate();
};

private generateOptions(selectElem: HTMLSelectElement): ExerciseOption[] {
const usedKeys: Record<string, true> = {};

return Array.from(selectElem.querySelectorAll("option"))
.filter((e) => !e.parentElement!.matches("[label='Suggested']"))
.map((e) => {
return new ExerciseOption(e);
})
.filter((item) => {
const key = item.categoryValue + "~~~" + item.value;
return (item.value === ExerciseSelectorPopup.EMPTY_EXERCISE_VALUE || Object.prototype.hasOwnProperty.call(usedKeys, key)) ? false : (usedKeys[key] = true);
})
.sort((a, b) => {
return (Number(b.suggested) - Number(a.suggested)) || a.textCleaned.localeCompare(b.textCleaned);
});
}

private updateFilterVisibilityForOption(option: ExerciseOption) {
option.updateFilterVisibility(this.activeMuscleGroupFilters, this.bodyweightFilter, this.favoritesFilter);
}
Expand Down
6 changes: 3 additions & 3 deletions src/enhancements/ExerciseSelectorEnhancement.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { OnObserverDestroyFunct } from "../models/OnObserverDestroyFunct";
import ExerciseSelector from "../components/ExerciseSelector";
import ExerciseSelectorBasicDelegate from "../models/ExerciseSelectorBasicDelegate";

export function takeoverExerciseContainer(container: HTMLElement): OnObserverDestroyFunct {
try {
const exerciseSelector = new ExerciseSelector(container);
const basicExerciseSelector = new ExerciseSelectorBasicDelegate(container);

container.append(exerciseSelector);
container.append(basicExerciseSelector.exerciseSelector);
} catch (e) {
// do nothing, invalid element
return false;
Expand Down
19 changes: 19 additions & 0 deletions src/enhancements/WorkoutExerciseEditorEnhancement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { OnObserverDestroyFunct } from "../models/OnObserverDestroyFunct";
import ReactHelper from "../helpers/ReactHelper";
import ExerciseSelectorReactDelegate from "../models/ExerciseSelectorReactDelegate";

export function takeoverWorkoutExerciseEditor(container: HTMLElement): OnObserverDestroyFunct {
try {
const props = ReactHelper.closestProps(container, ["flattenedExerciseTypes", "onChange"], 5);

if (props) {
const reactExerciseSelector = new ExerciseSelectorReactDelegate(props, container);
container.parentElement!.append(reactExerciseSelector.exerciseSelector);
}
} catch (e) {
// do nothing, invalid element
return false;
}

return () => {};
}
71 changes: 71 additions & 0 deletions src/helpers/ReactHelper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
export default class ReactHelper {
static closestProps(
elem: HTMLElement,
propKeys: string[],
maxDepth: number
): Record<string, unknown> | null {
let currentParent: HTMLElement | null = elem;
do {
const reactFiberKey = this.getReactFiberKey(currentParent);

if (reactFiberKey && this.isReactFiber(currentParent[reactFiberKey])) {
const memoizedProps = (currentParent[reactFiberKey] as unknown as ReactFiber).memoizedProps,
matchingProps = this.closestPropsRecursive(memoizedProps, propKeys);

if (matchingProps) {
return matchingProps;
}
}

currentParent = currentParent.parentElement;
maxDepth--;
} while (currentParent && maxDepth > 0);

return null;
}

private static closestPropsRecursive(props: ReactProps, propKeys: string[]): Record<string, unknown> | null {
if (
"props" in props &&
propKeys.filter((e) => e in props.props).length === propKeys.length
) {
return props.props;
}

if ("children" in props && props.children) {
const childProps = Array.isArray(props.children) ? props.children : [props.children];

for (const props of childProps) {
if (typeof props === "object" && props && !Array.isArray(props)) {
const value = this.closestPropsRecursive(props, propKeys);

if (value) {
return value;
}
}
}
}

return null;
}

private static getReactFiberKey<T extends object>(elem: T): keyof T | null {
const objKeys = Object.keys(elem) as (keyof T)[];

return objKeys.find((e) => (e as string).startsWith("__reactFiber")) || null;
}

private static isReactFiber(obj: unknown): obj is ReactFiber {
return Boolean(typeof obj === "object" && obj && !Array.isArray(obj) &&
"memoizedProps" in obj && typeof obj.memoizedProps === "object" && !Array.isArray(obj.memoizedProps) && obj.memoizedProps &&
"children" in obj.memoizedProps && typeof obj.memoizedProps.children === "object");
}
}

type ReactFiber = {
memoizedProps: ReactProps;
};
type ReactProps = {
children?: ReactProps | ReactProps[];
props: Record<string, unknown> & ReactProps;
};
31 changes: 31 additions & 0 deletions src/models/BasicExerciseOption.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import ExerciseOption from "./ExerciseOption";

export default class BasicExerciseOption extends ExerciseOption {
constructor(optionElem: HTMLOptionElement) {
super(
BasicExerciseOption.findValue(optionElem) || "",
BasicExerciseOption.findCategory(optionElem) || "",
optionElem.innerText,
optionElem.parentElement!.matches("[label='Suggested']")
);
}

static findExerciseOption(exerciseOptions: readonly ExerciseOption[], option: HTMLOptionElement): ExerciseOption | null {
const value = this.findValue(option),
category = this.findCategory(option);

if (!value || !category) {
return null;
}

return exerciseOptions.find((e) => e.value === value && e.categoryValue === category) ?? null;
}

private static findValue(option: HTMLOptionElement): string {
return option.value;
}

private static findCategory(option: HTMLOptionElement): string | null {
return option.dataset["exerciseCategory"] ?? null;
}
}
Loading

0 comments on commit d47bd1a

Please sign in to comment.