Adjusted functionality and style of class/background/species screens

This commit is contained in:
boris committed 2026-02-26 20:12:45 +00:00
1 parent 18734acaad
commit 4491e22573
3 files changed
+186 -172

No files matched your search

+116 -152
View File
@@ -1,7 +1,6 @@
import { FormEvent, JSX, useCallback, useEffect, useMemo, useRef, useState } from "react";
const DEFAULT_PAGE = "/5etools.html";
const CARD_PAGE_SIZE = 8;
const APP_CHARACTERS_MENU_ID = "__characters";
const CHARACTER_STEPS = [
@@ -82,6 +81,10 @@ const FIVEETOOLS_RENDER_STYLESHEET_PATHS = ["css/bootstrap.css", "css/main.css"]
type CharacterModalMode = "closed" | "list" | "editor";
type EntityPopoverKind = "class" | "species" | "background";
type PendingEntitySelection = {
kind: EntityPopoverKind;
option: CharacterSelectionOption;
};
type RendererHandle = {
setBaseUrl: (value: string) => RendererHandle;
@@ -206,10 +209,6 @@ function getPathnameFromHrefLoose(hrefRaw: string): string {
}
}
function areTopbarGroupsEquivalent(a: TopbarMenuGroup[], b: TopbarMenuGroup[]): boolean {
return JSON.stringify(a) === JSON.stringify(b);
}
function parseNativeTopbarMenuList(
list: Element,
registerAction: (ele: HTMLElement) => string,
@@ -755,17 +754,6 @@ function filterBySourceBuckets(options: CharacterSelectionOption[], sourceBucket
return options.filter((option) => selected.has(option.sourceBucket));
}
function clampPage(page: number, totalItems: number): number {
const totalPages = Math.max(1, Math.ceil(totalItems / CARD_PAGE_SIZE));
return Math.max(1, Math.min(totalPages, page));
}
function paginateOptions(options: CharacterSelectionOption[], page: number): CharacterSelectionOption[] {
const safePage = clampPage(page, options.length);
const start = (safePage - 1) * CARD_PAGE_SIZE;
return options.slice(start, start + CARD_PAGE_SIZE);
}
function toEntityRef(option: CharacterSelectionOption): CharacterEntityRef {
return {
entityType: option.entityType,
@@ -2001,6 +1989,7 @@ function WikiView() {
const [topbarGroups, setTopbarGroups] = useState<TopbarMenuGroup[]>([]);
const frameRef = useRef<HTMLIFrameElement | null>(null);
const topbarRef = useRef<HTMLElement | null>(null);
const topbarGroupsSignatureRef = useRef<string>("");
const [characterModalMode, setCharacterModalMode] = useState<CharacterModalMode>("closed");
const [characterStep, setCharacterStep] = useState<number>(0);
@@ -2019,10 +2008,8 @@ function WikiView() {
const [spellSearch, setSpellSearch] = useState("");
const [equipmentItemInput, setEquipmentItemInput] = useState("");
const [classPage, setClassPage] = useState(1);
const [speciesPage, setSpeciesPage] = useState(1);
const [backgroundPage, setBackgroundPage] = useState(1);
const [entityPopover, setEntityPopover] = useState<{ kind: EntityPopoverKind; entityRef: string } | null>(null);
const [pendingEntitySelection, setPendingEntitySelection] = useState<PendingEntitySelection | null>(null);
const [featCatalog, setFeatCatalog] = useState<FeatCatalogItem[] | null>(null);
const [spellCatalog, setSpellCatalog] = useState<SpellCatalogItem[] | null>(null);
const [itemCatalog, setItemCatalog] = useState<string[]>([]);
@@ -2081,6 +2068,60 @@ function WikiView() {
[ensureOptionDetail]
);
const closeEntityPopover = useCallback(() => {
setEntityPopover(null);
setPendingEntitySelection(null);
}, []);
const confirmPendingEntitySelection = useCallback(() => {
if (!pendingEntitySelection) {
closeEntityPopover();
return;
}
const { kind, option } = pendingEntitySelection;
updateDraft((draft) => {
if (kind === "class") {
return {
...draft,
selections: {
...draft.selections,
class: toEntityRef(option),
subclass: null
}
};
}
if (kind === "species") {
return {
...draft,
selections: {
...draft.selections,
species: toEntityRef(option)
}
};
}
return {
...draft,
selections: {
...draft.selections,
background: toEntityRef(option)
},
steps: {
...draft.steps,
abilityScores: {
...draft.steps.abilityScores,
backgroundBonusModeIndex: 0,
backgroundBonusSelections: []
}
}
};
});
closeEntityPopover();
}, [closeEntityPopover, pendingEntitySelection, updateDraft]);
const refreshCharacterSummaries = useCallback(async () => {
const list = await window.desktopApi.listCharacters();
setCharacterSummaries(list);
@@ -2093,9 +2134,6 @@ function WikiView() {
setFeatSearch("");
setSpellSearch("");
setEquipmentItemInput("");
setClassPage(1);
setSpeciesPage(1);
setBackgroundPage(1);
setCharacterStep(0);
}, []);
@@ -2348,9 +2386,9 @@ function WikiView() {
};
};
const pLoadAll = runtime.DataUtil?.spell?.pLoadAll;
const spellDataUtil = runtime.DataUtil?.spell;
const getCombinedClasses = runtime.Renderer?.spell?.getCombinedClasses;
if (!pLoadAll) {
if (!spellDataUtil?.pLoadAll) {
if (!isCancelled) {
setSpellCatalog([]);
}
@@ -2367,7 +2405,7 @@ function WikiView() {
sourceLookup = null;
}
const spellsRaw = await pLoadAll();
const spellsRaw = await spellDataUtil.pLoadAll();
const next = (Array.isArray(spellsRaw) ? spellsRaw : [])
.map((spellRaw): SpellCatalogItem | null => {
if (!isRecord(spellRaw)) {
@@ -2471,7 +2509,20 @@ function WikiView() {
return;
}
setTopbarGroups((current) => (areTopbarGroupsEquivalent(current, data.groups as TopbarMenuGroup[]) ? current : (data.groups as TopbarMenuGroup[])));
const nextGroups = data.groups as TopbarMenuGroup[];
let nextSignature = "";
try {
nextSignature = JSON.stringify(nextGroups);
} catch {
nextSignature = `groups:${nextGroups.length}:${Date.now()}`;
}
if (topbarGroupsSignatureRef.current === nextSignature) {
return;
}
topbarGroupsSignatureRef.current = nextSignature;
setTopbarGroups(nextGroups);
return;
}
@@ -2829,39 +2880,6 @@ function WikiView() {
[characterOptions?.backgrounds, sourceBuckets, backgroundSearch]
);
const classTotalPages = Math.max(1, Math.ceil(filteredClassOptions.length / CARD_PAGE_SIZE));
const speciesTotalPages = Math.max(1, Math.ceil(filteredSpeciesOptions.length / CARD_PAGE_SIZE));
const backgroundTotalPages = Math.max(1, Math.ceil(filteredBackgroundOptions.length / CARD_PAGE_SIZE));
const classPageSafe = clampPage(classPage, filteredClassOptions.length);
const speciesPageSafe = clampPage(speciesPage, filteredSpeciesOptions.length);
const backgroundPageSafe = clampPage(backgroundPage, filteredBackgroundOptions.length);
const classPageOptions = useMemo(
() => paginateOptions(filteredClassOptions, classPageSafe),
[filteredClassOptions, classPageSafe]
);
const speciesPageOptions = useMemo(
() => paginateOptions(filteredSpeciesOptions, speciesPageSafe),
[filteredSpeciesOptions, speciesPageSafe]
);
const backgroundPageOptions = useMemo(
() => paginateOptions(filteredBackgroundOptions, backgroundPageSafe),
[filteredBackgroundOptions, backgroundPageSafe]
);
useEffect(() => {
setClassPage((current) => clampPage(current, filteredClassOptions.length));
}, [filteredClassOptions.length]);
useEffect(() => {
setSpeciesPage((current) => clampPage(current, filteredSpeciesOptions.length));
}, [filteredSpeciesOptions.length]);
useEffect(() => {
setBackgroundPage((current) => clampPage(current, filteredBackgroundOptions.length));
}, [filteredBackgroundOptions.length]);
useEffect(() => {
const selectedRefs = [
characterDraft?.selections.class?.entityRef,
@@ -2883,6 +2901,12 @@ function WikiView() {
: null;
const entityPopoverDetail = entityPopover ? detailCache[entityPopover.entityRef] ?? null : null;
useEffect(() => {
if (characterModalMode !== "editor") {
closeEntityPopover();
}
}, [characterModalMode, closeEntityPopover]);
useEffect(() => {
if (characterModalMode !== "editor" || !entityPopover) {
return;
@@ -2890,22 +2914,22 @@ function WikiView() {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setEntityPopover(null);
closeEntityPopover();
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [characterModalMode, entityPopover]);
}, [characterModalMode, closeEntityPopover, entityPopover]);
useEffect(() => {
if (characterModalMode !== "editor" || !entityPopover) {
return;
}
if (characterStep < 0 || characterStep > 2) {
setEntityPopover(null);
closeEntityPopover();
}
}, [characterModalMode, characterStep, entityPopover]);
}, [characterModalMode, characterStep, closeEntityPopover, entityPopover]);
const classSkillData = useMemo(
() => parseClassSkills(selectedClassDetail),
@@ -4168,7 +4192,7 @@ function WikiView() {
<div className="entity-layout">
<div className="entity-list-panel entity-list-panel--solo">
<div className="entity-card-grid">
{classPageOptions.map((option) => {
{filteredClassOptions.map((option) => {
const isSelected = characterDraft.selections.class?.entityRef === option.entityRef;
const imageUrl = buildAssetUrl(wikiUrl, option.imagePath);
const sourceBadge = getSourceBadgeText(option.source, option.sourceEdition);
@@ -4179,14 +4203,7 @@ function WikiView() {
key={option.entityRef}
className={isSelected ? "entity-tile is-selected" : "entity-tile"}
onClick={() => {
updateDraft((draft) => ({
...draft,
selections: {
...draft.selections,
class: toEntityRef(option),
subclass: null
}
}));
setPendingEntitySelection({ kind: "class", option });
openEntityPopover("class", option.entityRef);
}}
>
@@ -4205,22 +4222,6 @@ function WikiView() {
{filteredClassOptions.length === 0 ? <p className="character-empty">No classes match the selected source buckets.</p> : null}
<div className="entity-pagination">
<button type="button" onClick={() => setClassPage((it) => Math.max(1, it - 1))} disabled={classPageSafe <= 1}>
Previous
</button>
<span>
Page {classPageSafe}/{classTotalPages} ({filteredClassOptions.length} total)
</span>
<button
type="button"
onClick={() => setClassPage((it) => Math.min(classTotalPages, it + 1))}
disabled={classPageSafe >= classTotalPages}
>
Next
</button>
</div>
<div className="entity-list-actions">
<button
type="button"
@@ -4229,6 +4230,7 @@ function WikiView() {
if (!characterDraft.selections.class) {
return;
}
setPendingEntitySelection(null);
openEntityPopover("class", characterDraft.selections.class.entityRef);
}}
disabled={!characterDraft.selections.class}
@@ -4252,7 +4254,7 @@ function WikiView() {
<div className="entity-layout">
<div className="entity-list-panel entity-list-panel--solo">
<div className="entity-card-grid">
{speciesPageOptions.map((option) => {
{filteredSpeciesOptions.map((option) => {
const isSelected = characterDraft.selections.species?.entityRef === option.entityRef;
const imageUrl = buildAssetUrl(wikiUrl, option.imagePath);
const sourceBadge = getSourceBadgeText(option.source, option.sourceEdition);
@@ -4263,13 +4265,7 @@ function WikiView() {
key={option.entityRef}
className={isSelected ? "entity-tile is-selected" : "entity-tile"}
onClick={() => {
updateDraft((draft) => ({
...draft,
selections: {
...draft.selections,
species: toEntityRef(option)
}
}));
setPendingEntitySelection({ kind: "species", option });
openEntityPopover("species", option.entityRef);
}}
>
@@ -4288,22 +4284,6 @@ function WikiView() {
{filteredSpeciesOptions.length === 0 ? <p className="character-empty">No species match the selected source buckets.</p> : null}
<div className="entity-pagination">
<button type="button" onClick={() => setSpeciesPage((it) => Math.max(1, it - 1))} disabled={speciesPageSafe <= 1}>
Previous
</button>
<span>
Page {speciesPageSafe}/{speciesTotalPages} ({filteredSpeciesOptions.length} total)
</span>
<button
type="button"
onClick={() => setSpeciesPage((it) => Math.min(speciesTotalPages, it + 1))}
disabled={speciesPageSafe >= speciesTotalPages}
>
Next
</button>
</div>
<div className="entity-list-actions">
<button
type="button"
@@ -4312,6 +4292,7 @@ function WikiView() {
if (!characterDraft.selections.species) {
return;
}
setPendingEntitySelection(null);
openEntityPopover("species", characterDraft.selections.species.entityRef);
}}
disabled={!characterDraft.selections.species}
@@ -4337,7 +4318,7 @@ function WikiView() {
<div className="entity-layout">
<div className="entity-list-panel entity-list-panel--solo">
<div className="entity-card-grid">
{backgroundPageOptions.map((option) => {
{filteredBackgroundOptions.map((option) => {
const isSelected = characterDraft.selections.background?.entityRef === option.entityRef;
const imageUrl = buildAssetUrl(wikiUrl, option.imagePath);
const sourceBadge = getSourceBadgeText(option.source, option.sourceEdition);
@@ -4348,21 +4329,7 @@ function WikiView() {
key={option.entityRef}
className={isSelected ? "entity-tile is-selected" : "entity-tile"}
onClick={() => {
updateDraft((draft) => ({
...draft,
selections: {
...draft.selections,
background: toEntityRef(option)
},
steps: {
...draft.steps,
abilityScores: {
...draft.steps.abilityScores,
backgroundBonusModeIndex: 0,
backgroundBonusSelections: []
}
}
}));
setPendingEntitySelection({ kind: "background", option });
openEntityPopover("background", option.entityRef);
}}
>
@@ -4383,26 +4350,6 @@ function WikiView() {
<p className="character-empty">No backgrounds match the selected source buckets.</p>
) : null}
<div className="entity-pagination">
<button
type="button"
onClick={() => setBackgroundPage((it) => Math.max(1, it - 1))}
disabled={backgroundPageSafe <= 1}
>
Previous
</button>
<span>
Page {backgroundPageSafe}/{backgroundTotalPages} ({filteredBackgroundOptions.length} total)
</span>
<button
type="button"
onClick={() => setBackgroundPage((it) => Math.min(backgroundTotalPages, it + 1))}
disabled={backgroundPageSafe >= backgroundTotalPages}
>
Next
</button>
</div>
<div className="entity-list-actions">
<button
type="button"
@@ -4411,6 +4358,7 @@ function WikiView() {
if (!characterDraft.selections.background) {
return;
}
setPendingEntitySelection(null);
openEntityPopover("background", characterDraft.selections.background.entityRef);
}}
disabled={!characterDraft.selections.background}
@@ -5973,7 +5921,7 @@ function WikiView() {
<div
className="entity-popover-backdrop"
role="presentation"
onClick={() => setEntityPopover(null)}
onClick={closeEntityPopover}
>
<section
className="entity-popover"
@@ -5990,11 +5938,27 @@ function WikiView() {
? "Species details"
: "Background details"}
</h4>
<button type="button" className="is-subtle" onClick={() => setEntityPopover(null)}>
Close
<button type="button" className="is-subtle" onClick={closeEntityPopover}>
{pendingEntitySelection ? "Cancel" : "Close"}
</button>
</header>
{renderEntityDetailContent(entityPopoverDetail, entityPopover.kind)}
<div className="entity-popover-body">{renderEntityDetailContent(entityPopoverDetail, entityPopover.kind)}</div>
<footer className="entity-popover-footer">
{pendingEntitySelection ? (
<>
<button type="button" className="is-subtle" onClick={closeEntityPopover}>
Cancel
</button>
<button type="button" onClick={confirmPendingEntitySelection}>
Add {pendingEntitySelection.kind}
</button>
</>
) : (
<button type="button" className="is-subtle" onClick={closeEntityPopover}>
Close
</button>
)}
</footer>
</section>
</div>
) : null}