Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: support React exercise picker #3

Merged
merged 2 commits into from
Aug 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading