diff --git a/src/main/wikiServer.ts b/src/main/wikiServer.ts index d4bda41..8588b80 100644 --- a/src/main/wikiServer.ts +++ b/src/main/wikiServer.ts @@ -350,7 +350,7 @@ function getDesktopReskinBootstrap(externalImageRoot: string | null): string { addCharactersMenu(); addResetOnboardingMenu(); bindNativeNavbarSync(); - postNativeNavbarModel(); + queueNativeNavbarModelPost({ isForce: true }); }; const NAVBAR_ACTION_ATTR = "data-5etd-nav-action-id"; @@ -593,17 +593,50 @@ function getDesktopReskinBootstrap(externalImageRoot: string | null): string { return groups; }; - const postNativeNavbarModel = () => { + let navbarModelLastSignature = ""; + let isNavbarModelPostQueued = false; + + const postNativeNavbarModel = ({ isForce = false } = {}) => { if (!window.parent || window.parent === window) return; + const groups = buildNativeNavbarGroups(); + let nextSignature = ""; + try { + nextSignature = JSON.stringify(groups); + } catch { + nextSignature = \`groups:\${groups.length}:\${Date.now()}\`; + } + + if (!isForce && nextSignature === navbarModelLastSignature) { + return; + } + navbarModelLastSignature = nextSignature; + window.parent.postMessage( { type: "5etd:native-navbar-data", - groups: buildNativeNavbarGroups() + groups }, "*" ); }; + const queueNativeNavbarModelPost = ({ isForce = false } = {}) => { + if (isForce) { + isNavbarModelPostQueued = false; + postNativeNavbarModel({ isForce: true }); + return; + } + + if (isNavbarModelPostQueued) { + return; + } + isNavbarModelPostQueued = true; + window.setTimeout(() => { + isNavbarModelPostQueued = false; + postNativeNavbarModel(); + }, 80); + }; + let navbarSyncObserver = null; const bindNativeNavbarSync = () => { const navbar = document.getElementById("navbar"); @@ -614,9 +647,9 @@ function getDesktopReskinBootstrap(externalImageRoot: string | null): string { navbar.dataset.desktopNavSyncBound = "1"; navbarSyncObserver?.disconnect?.(); navbarSyncObserver = new MutationObserver(() => { - window.requestAnimationFrame(postNativeNavbarModel); + queueNativeNavbarModelPost(); }); - navbarSyncObserver.observe(navbar, { childList: true, subtree: true, attributes: true, characterData: true }); + navbarSyncObserver.observe(navbar, { childList: true, subtree: true }); }; const runNativeNavbarAction = (actionId) => { @@ -649,7 +682,7 @@ function getDesktopReskinBootstrap(externalImageRoot: string | null): string { if (data.type === "5etd:native-navbar-request") { bindNativeNavbarSync(); - postNativeNavbarModel(); + queueNativeNavbarModelPost({ isForce: true }); return; } @@ -693,7 +726,7 @@ html.desktop-reskin .page__header { border: 1px solid rgba(109, 164, 223, 0.32) !important; border-radius: 18px; background: linear-gradient(115deg, rgba(17, 29, 47, 0.98), rgba(24, 38, 58, 0.96) 64%, rgba(87, 58, 32, 0.88)); - backdrop-filter: blur(14px); + // backdrop-filter: blur(14px); box-shadow: 0 10px 24px rgba(0, 0, 0, 0.24); } diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 73ba51d..6c46dc9 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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([]); const frameRef = useRef(null); const topbarRef = useRef(null); + const topbarGroupsSignatureRef = useRef(""); const [characterModalMode, setCharacterModalMode] = useState("closed"); const [characterStep, setCharacterStep] = useState(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(null); const [featCatalog, setFeatCatalog] = useState(null); const [spellCatalog, setSpellCatalog] = useState(null); const [itemCatalog, setItemCatalog] = useState([]); @@ -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() {
- {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 ?

No classes match the selected source buckets.

: null} -
- - - Page {classPageSafe}/{classTotalPages} ({filteredClassOptions.length} total) - - -
-
- - Page {speciesPageSafe}/{speciesTotalPages} ({filteredSpeciesOptions.length} total) - - -
-
- - Page {backgroundPageSafe}/{backgroundTotalPages} ({filteredBackgroundOptions.length} total) - - -
-
- {renderEntityDetailContent(entityPopoverDetail, entityPopover.kind)} +
{renderEntityDetailContent(entityPopoverDetail, entityPopover.kind)}
+
) : null} diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 7fe1a7b..eeb627e 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -69,7 +69,7 @@ body { radial-gradient(circle at 16% 0%, rgba(71, 131, 206, 0.34), transparent 34%), radial-gradient(circle at 88% -8%, rgba(234, 163, 78, 0.28), transparent 40%), linear-gradient(180deg, rgba(14, 24, 40, 0.96), rgba(9, 16, 28, 0.97)); - backdrop-filter: blur(12px); + /* backdrop-filter: blur(12px); */ position: relative; overflow: visible; isolation: isolate; @@ -516,9 +516,9 @@ body { border-radius: 16px; background: linear-gradient(180deg, rgba(14, 22, 35, 0.98), rgba(8, 14, 24, 0.99)); box-shadow: var(--shadow); - display: grid; - grid-template-rows: auto auto 1fr; - overflow: hidden; + /* display: grid; */ + /* grid-template-rows: auto auto 1fr; */ + overflow: scroll; } .character-modal button, @@ -1529,6 +1529,7 @@ body { .entity-card-grid { flex: 1; min-height: 0; + max-height: clamp(260px, 44vh, 560px); overflow: auto; padding-right: 4px; gap: 10px; @@ -1737,7 +1738,7 @@ body { color: #edf4ff; overflow: hidden; text-align: left; - padding: 0; + padding: 0 !important; display: block; min-height: 0; aspect-ratio: 1 / 1; @@ -1837,17 +1838,18 @@ body { place-items: center; padding: 18px; background: rgba(2, 6, 12, 0.74); - backdrop-filter: blur(2px); + /* backdrop-filter: blur(2px); */ } .entity-popover { width: 70%; max-height: calc(70vh - 36px); - overflow: auto; + overflow: hidden; + display: flex; + flex-direction: column; border: 1px solid var(--line-strong); border-radius: 14px; background: rgba(8, 12, 20, 0.98); - padding: 12px; box-shadow: 0 24px 64px rgba(0, 0, 0, 0.45); } @@ -1856,9 +1858,8 @@ body { align-items: center; justify-content: space-between; gap: 10px; - padding-bottom: 10px; + padding: 12px; border-bottom: 1px solid var(--line); - margin-bottom: 10px; } .entity-popover-header h4 { @@ -1867,6 +1868,22 @@ body { font-size: 1.05rem; } +.entity-popover-body { + flex: 1 1 auto; + min-height: 0; + overflow: auto; + padding: 12px; +} + +.entity-popover-footer { + display: flex; + justify-content: flex-end; + gap: 10px; + padding: 12px; + border-top: 1px solid var(--line); + background: rgba(5, 9, 16, 0.94); +} + .entity-detail--popover { min-height: 0; } @@ -1886,6 +1903,7 @@ body { .chooser-layout { min-height: 0; + max-height: 62vh; display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 12px; @@ -2249,10 +2267,10 @@ body { width: 100%; } - .ability-score-strip, + /* .ability-score-strip, .ability-assignment-grid--ability-step { grid-template-columns: 1fr; - } + } */ .chooser-layout { grid-template-columns: 1fr; @@ -2305,7 +2323,6 @@ body { width: calc(100vw - 20px); max-height: calc(100vh - 20px); border-radius: 12px; - padding: 10px; } .entity-popover-header {