added nested navmenus
This commit is contained in:
1 parent
a73c5ea002
commit
18734acaad
4 files changed
+510
-70
No files matched your search
+125
-33
@@ -54,6 +54,11 @@ interface SourceBookRecord {
|
||||
published?: string;
|
||||
}
|
||||
|
||||
interface GeneratedNavSourceRecord extends SourceBookRecord {
|
||||
parentSource?: string;
|
||||
parentName?: string;
|
||||
}
|
||||
|
||||
interface ClassRecord {
|
||||
name?: string;
|
||||
source?: string;
|
||||
@@ -130,6 +135,15 @@ interface CacheValue {
|
||||
rootDir: string;
|
||||
options: CharacterSelectionOptionSets;
|
||||
detailsByRef: Map<string, CharacterSelectionDetail>;
|
||||
rawLocators: Map<string, RawEntityLocator>;
|
||||
}
|
||||
|
||||
type RawEntityArrayKey = "class" | "background" | "race";
|
||||
|
||||
interface RawEntityLocator {
|
||||
filePath: string;
|
||||
arrayKey: RawEntityArrayKey;
|
||||
index: number;
|
||||
}
|
||||
|
||||
let cache: CacheValue | null = null;
|
||||
@@ -436,24 +450,25 @@ function classifySourceMeta(input: {
|
||||
|
||||
function buildSourceMetaMap(rootDir: string): Map<string, SourceMeta> {
|
||||
const rawMap = new Map<string, { sourceName?: string; sourceGroup?: string | null; sourcePublished?: string | null }>();
|
||||
const addRawSourceRecord = (record: SourceBookRecord): void => {
|
||||
const source = normalizeString(record.source).toUpperCase();
|
||||
if (!source || rawMap.has(source)) {
|
||||
return;
|
||||
}
|
||||
|
||||
rawMap.set(source, {
|
||||
sourceName: normalizeString(record.name) || source,
|
||||
sourceGroup: normalizeString(record.group) || null,
|
||||
sourcePublished: normalizeString(record.published) || null
|
||||
});
|
||||
};
|
||||
|
||||
const booksPath = path.join(rootDir, "data", "books.json");
|
||||
if (fs.existsSync(booksPath)) {
|
||||
const parsed = readJsonFile(booksPath);
|
||||
const books = isRecord(parsed) && Array.isArray(parsed.book) ? (parsed.book as SourceBookRecord[]) : [];
|
||||
for (const book of books) {
|
||||
const source = normalizeString(book.source).toUpperCase();
|
||||
if (!source) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!rawMap.has(source)) {
|
||||
rawMap.set(source, {
|
||||
sourceName: normalizeString(book.name) || source,
|
||||
sourceGroup: normalizeString(book.group) || null,
|
||||
sourcePublished: normalizeString(book.published) || null
|
||||
});
|
||||
}
|
||||
addRawSourceRecord(book);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,16 +480,22 @@ function buildSourceMetaMap(rootDir: string): Map<string, SourceMeta> {
|
||||
: [];
|
||||
|
||||
for (const adventure of adventures) {
|
||||
const source = normalizeString(adventure.source).toUpperCase();
|
||||
if (!source || rawMap.has(source)) {
|
||||
continue;
|
||||
addRawSourceRecord(adventure);
|
||||
}
|
||||
}
|
||||
|
||||
rawMap.set(source, {
|
||||
sourceName: normalizeString(adventure.name) || source,
|
||||
sourceGroup: normalizeString(adventure.group) || null,
|
||||
sourcePublished: normalizeString(adventure.published) || null
|
||||
});
|
||||
const generatedNavIndexPath = path.join(rootDir, "data", "generated", "gendata-nav-adventure-book-index.json");
|
||||
if (fs.existsSync(generatedNavIndexPath)) {
|
||||
const parsed = readJsonFile(generatedNavIndexPath);
|
||||
const books = isRecord(parsed) && Array.isArray(parsed.book)
|
||||
? (parsed.book as GeneratedNavSourceRecord[])
|
||||
: [];
|
||||
const adventures = isRecord(parsed) && Array.isArray(parsed.adventure)
|
||||
? (parsed.adventure as GeneratedNavSourceRecord[])
|
||||
: [];
|
||||
|
||||
for (const record of [...books, ...adventures]) {
|
||||
addRawSourceRecord(record);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -814,8 +835,32 @@ function createOption(
|
||||
};
|
||||
}
|
||||
|
||||
function readRawJsonFromLocator(locator: RawEntityLocator): string {
|
||||
try {
|
||||
if (!fs.existsSync(locator.filePath)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const parsed = readJsonFile(locator.filePath);
|
||||
const arr = isRecord(parsed) ? parsed[locator.arrayKey] : null;
|
||||
if (!Array.isArray(arr)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const entry = arr[locator.index];
|
||||
if (entry == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return JSON.stringify(entry, null, 2);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function buildCache(rootDir: string): CacheValue {
|
||||
const detailsByRef = new Map<string, CharacterSelectionDetail>();
|
||||
const rawLocators = new Map<string, RawEntityLocator>();
|
||||
const sourceMetaMap = buildSourceMetaMap(rootDir);
|
||||
|
||||
const classFluffMap = loadClassFluff(rootDir);
|
||||
@@ -827,7 +872,7 @@ function buildCache(rootDir: string): CacheValue {
|
||||
const classIndexJson = readJsonFile(classIndexPath);
|
||||
const classFileNames = isRecord(classIndexJson) ? Object.values(classIndexJson).filter((it): it is string => typeof it === "string") : [];
|
||||
|
||||
const allClasses: ClassRecord[] = [];
|
||||
const allClasses: Array<{ record: ClassRecord; filePath: string; index: number }> = [];
|
||||
const allClassFeatures: ClassFeatureRecord[] = [];
|
||||
const allSubclasses: SubclassRecord[] = [];
|
||||
|
||||
@@ -843,7 +888,10 @@ function buildCache(rootDir: string): CacheValue {
|
||||
}
|
||||
|
||||
if (Array.isArray(parsed.class)) {
|
||||
allClasses.push(...(parsed.class as ClassRecord[]));
|
||||
const classRecords = parsed.class as ClassRecord[];
|
||||
classRecords.forEach((record, classIndex) => {
|
||||
allClasses.push({ record, filePath, index: classIndex });
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(parsed.classFeature)) {
|
||||
@@ -871,7 +919,8 @@ function buildCache(rootDir: string): CacheValue {
|
||||
}
|
||||
|
||||
const classOptions: CharacterSelectionOption[] = [];
|
||||
for (const cls of allClasses) {
|
||||
for (const clsInfo of allClasses) {
|
||||
const cls = clsInfo.record;
|
||||
const name = normalizeString(cls.name);
|
||||
const source = normalizeString(cls.source).toUpperCase();
|
||||
if (!name || !source) {
|
||||
@@ -994,11 +1043,16 @@ function buildCache(rootDir: string): CacheValue {
|
||||
const detail: CharacterSelectionDetail = {
|
||||
...option,
|
||||
featureCards,
|
||||
rawJson: JSON.stringify(cls, null, 2)
|
||||
rawJson: ""
|
||||
};
|
||||
|
||||
classOptions.push(option);
|
||||
detailsByRef.set(option.entityRef, detail);
|
||||
rawLocators.set(option.entityRef, {
|
||||
filePath: clsInfo.filePath,
|
||||
arrayKey: "class",
|
||||
index: clsInfo.index
|
||||
});
|
||||
}
|
||||
|
||||
const backgroundPath = path.join(rootDir, "data", "backgrounds.json");
|
||||
@@ -1008,7 +1062,8 @@ function buildCache(rootDir: string): CacheValue {
|
||||
: [];
|
||||
|
||||
const backgroundOptions: CharacterSelectionOption[] = [];
|
||||
for (const bg of backgroundRecords) {
|
||||
for (let bgIndex = 0; bgIndex < backgroundRecords.length; bgIndex += 1) {
|
||||
const bg = backgroundRecords[bgIndex];
|
||||
const name = normalizeString(bg.name);
|
||||
const source = normalizeString(bg.source).toUpperCase();
|
||||
if (!name || !source) {
|
||||
@@ -1035,11 +1090,16 @@ function buildCache(rootDir: string): CacheValue {
|
||||
const detail: CharacterSelectionDetail = {
|
||||
...option,
|
||||
featureCards,
|
||||
rawJson: JSON.stringify(bg, null, 2)
|
||||
rawJson: ""
|
||||
};
|
||||
|
||||
backgroundOptions.push(option);
|
||||
detailsByRef.set(option.entityRef, detail);
|
||||
rawLocators.set(option.entityRef, {
|
||||
filePath: backgroundPath,
|
||||
arrayKey: "background",
|
||||
index: bgIndex
|
||||
});
|
||||
}
|
||||
|
||||
const speciesPath = path.join(rootDir, "data", "races.json");
|
||||
@@ -1049,7 +1109,8 @@ function buildCache(rootDir: string): CacheValue {
|
||||
: [];
|
||||
|
||||
const speciesOptions: CharacterSelectionOption[] = [];
|
||||
for (const race of speciesRecords) {
|
||||
for (let raceIndex = 0; raceIndex < speciesRecords.length; raceIndex += 1) {
|
||||
const race = speciesRecords[raceIndex];
|
||||
const name = normalizeString(race.name);
|
||||
const source = normalizeString(race.source).toUpperCase();
|
||||
if (!name || !source) {
|
||||
@@ -1078,11 +1139,16 @@ function buildCache(rootDir: string): CacheValue {
|
||||
const detail: CharacterSelectionDetail = {
|
||||
...option,
|
||||
featureCards,
|
||||
rawJson: JSON.stringify(race, null, 2)
|
||||
rawJson: ""
|
||||
};
|
||||
|
||||
speciesOptions.push(option);
|
||||
detailsByRef.set(option.entityRef, detail);
|
||||
rawLocators.set(option.entityRef, {
|
||||
filePath: speciesPath,
|
||||
arrayKey: "race",
|
||||
index: raceIndex
|
||||
});
|
||||
}
|
||||
|
||||
const sortOptions = (arr: CharacterSelectionOption[]): CharacterSelectionOption[] =>
|
||||
@@ -1097,16 +1163,18 @@ function buildCache(rootDir: string): CacheValue {
|
||||
backgrounds: sortOptions(backgroundOptions),
|
||||
species: sortOptions(speciesOptions)
|
||||
},
|
||||
detailsByRef
|
||||
detailsByRef,
|
||||
rawLocators
|
||||
};
|
||||
}
|
||||
|
||||
function getCache(rootDir: string): CacheValue {
|
||||
if (cache && cache.rootDir === rootDir) {
|
||||
const resolvedRootDir = path.resolve(rootDir);
|
||||
if (cache && cache.rootDir === resolvedRootDir) {
|
||||
return cache;
|
||||
}
|
||||
|
||||
const value = buildCache(rootDir);
|
||||
const value = buildCache(resolvedRootDir);
|
||||
cache = value;
|
||||
return value;
|
||||
}
|
||||
@@ -1121,8 +1189,32 @@ export function getCharacterSelectionDetail(rootDir: string, entityRefRaw: strin
|
||||
return null;
|
||||
}
|
||||
|
||||
const detail = getCache(rootDir).detailsByRef.get(entityRef);
|
||||
return detail || null;
|
||||
const cacheValue = getCache(rootDir);
|
||||
const detail = cacheValue.detailsByRef.get(entityRef);
|
||||
if (!detail) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (detail.rawJson) {
|
||||
return detail;
|
||||
}
|
||||
|
||||
const locator = cacheValue.rawLocators.get(entityRef);
|
||||
if (!locator) {
|
||||
return detail;
|
||||
}
|
||||
|
||||
const nextRawJson = readRawJsonFromLocator(locator);
|
||||
if (!nextRawJson) {
|
||||
return detail;
|
||||
}
|
||||
|
||||
const nextDetail: CharacterSelectionDetail = {
|
||||
...detail,
|
||||
rawJson: nextRawJson
|
||||
};
|
||||
cacheValue.detailsByRef.set(entityRef, nextDetail);
|
||||
return nextDetail;
|
||||
}
|
||||
|
||||
export function clearCharacterSelectionCache(): void {
|
||||
|
||||
+97
-3
@@ -367,6 +367,32 @@ function getDesktopReskinBootstrap(externalImageRoot: string | null): string {
|
||||
return cleanNavLabel(ele.textContent || "");
|
||||
};
|
||||
|
||||
const getNavEntryMeta = (ele) => {
|
||||
if (!ele) return {};
|
||||
|
||||
const out = {};
|
||||
const dateEle = ele.querySelector(":scope > .page__nav-date");
|
||||
if (dateEle) {
|
||||
const datePrefix = cleanNavLabel(dateEle.textContent || "");
|
||||
if (datePrefix) out.datePrefix = datePrefix;
|
||||
}
|
||||
|
||||
const sourceEle = ele.querySelector(":scope > .nav2-list__disp-source");
|
||||
if (sourceEle instanceof HTMLElement) {
|
||||
const computed = getComputedStyle(sourceEle);
|
||||
const sourceColor = String(computed.backgroundColor || computed.borderLeftColor || "").trim();
|
||||
if (
|
||||
sourceColor &&
|
||||
sourceColor !== "transparent" &&
|
||||
!/^rgba?\\(\\s*0\\s*,\\s*0\\s*,\\s*0(?:\\s*,\\s*0)?\\s*\\)$/i.test(sourceColor)
|
||||
) {
|
||||
out.sourceColor = sourceColor;
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
};
|
||||
|
||||
const isActionLikeHref = (hrefRaw) => {
|
||||
const href = String(hrefRaw || "").trim().toLowerCase();
|
||||
return !href || href === "#" || href.startsWith("javascript:");
|
||||
@@ -378,6 +404,9 @@ function getDesktopReskinBootstrap(externalImageRoot: string | null): string {
|
||||
|
||||
liElements.forEach((li, index) => {
|
||||
const entryId = \`\${idPrefix}-\${index}\`;
|
||||
const submenuList = li.querySelector(":scope > ul")
|
||||
|| (li.classList.contains("nav2-accord__wrp") ? li.querySelector(":scope > .nav2-accord__body") : null);
|
||||
const submenuChildren = submenuList ? parseNavbarMenuList(submenuList, \`\${entryId}-submenu\`) : [];
|
||||
|
||||
if (li.classList.contains("ve-dropdown-divider") || li.classList.contains("divider")) {
|
||||
entries.push({ id: \`\${entryId}-divider\`, type: "divider" });
|
||||
@@ -392,7 +421,6 @@ function getDesktopReskinBootstrap(externalImageRoot: string | null): string {
|
||||
|
||||
if (li.classList.contains("dropdown--navbar")) {
|
||||
const toggle = li.querySelector(":scope > a");
|
||||
const submenuList = li.querySelector(":scope > ul");
|
||||
const label = toggle ? getNavPrimaryText(toggle) : "";
|
||||
if (!label) return;
|
||||
|
||||
@@ -400,7 +428,23 @@ function getDesktopReskinBootstrap(externalImageRoot: string | null): string {
|
||||
id: \`\${entryId}-submenu\`,
|
||||
type: "submenu",
|
||||
label,
|
||||
children: submenuList ? parseNavbarMenuList(submenuList, \`\${entryId}-submenu\`) : []
|
||||
...getNavEntryMeta(toggle),
|
||||
children: submenuChildren
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (li.classList.contains("nav2-accord__wrp")) {
|
||||
const headPrimary = li.querySelector(":scope > .nav2-accord__head > :first-child");
|
||||
const label = headPrimary ? getNavPrimaryText(headPrimary) : "";
|
||||
if (!label || !submenuChildren.length) return;
|
||||
|
||||
entries.push({
|
||||
id: \`\${entryId}-accordion\`,
|
||||
type: "submenu",
|
||||
label,
|
||||
...getNavEntryMeta(headPrimary),
|
||||
children: submenuChildren
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -409,6 +453,33 @@ function getDesktopReskinBootstrap(externalImageRoot: string | null): string {
|
||||
if (link) {
|
||||
const label = getNavPrimaryText(link);
|
||||
const href = String(link.getAttribute("href") || "").trim();
|
||||
if (submenuChildren.length) {
|
||||
if (isActionLikeHref(href)) {
|
||||
const actionId = \`\${entryId}-submenu-action\`;
|
||||
link.setAttribute(NAVBAR_ACTION_ATTR, actionId);
|
||||
entries.push({
|
||||
id: \`\${entryId}-submenu-link\`,
|
||||
type: "submenu",
|
||||
label: label || "Section",
|
||||
...getNavEntryMeta(link),
|
||||
actionId,
|
||||
children: submenuChildren
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
entries.push({
|
||||
id: \`\${entryId}-submenu-link\`,
|
||||
type: "submenu",
|
||||
label: label || href,
|
||||
...getNavEntryMeta(link),
|
||||
href,
|
||||
isExternal: link.target === "_blank" || /^https?:\\/\\//i.test(href),
|
||||
children: submenuChildren
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isActionLikeHref(href)) {
|
||||
const actionId = \`\${entryId}-action\`;
|
||||
link.setAttribute(NAVBAR_ACTION_ATTR, actionId);
|
||||
@@ -420,6 +491,7 @@ function getDesktopReskinBootstrap(externalImageRoot: string | null): string {
|
||||
id: \`\${entryId}-link\`,
|
||||
type: "link",
|
||||
label: label || href,
|
||||
...getNavEntryMeta(link),
|
||||
href,
|
||||
isExternal: link.target === "_blank" || /^https?:\\/\\//i.test(href)
|
||||
});
|
||||
@@ -427,11 +499,33 @@ function getDesktopReskinBootstrap(externalImageRoot: string | null): string {
|
||||
}
|
||||
|
||||
const spans = Array.from(li.querySelectorAll(":scope > span")).filter((it) => it instanceof HTMLSpanElement);
|
||||
if (submenuChildren.length && spans.length) {
|
||||
const first = spans[0];
|
||||
const label = cleanNavLabel(first.textContent || first.title || "");
|
||||
const actionId = \`\${entryId}-submenu-span\`;
|
||||
first.setAttribute(NAVBAR_ACTION_ATTR, actionId);
|
||||
entries.push({
|
||||
id: \`\${entryId}-submenu-span\`,
|
||||
type: "submenu",
|
||||
label: label || "Section",
|
||||
...getNavEntryMeta(first),
|
||||
actionId,
|
||||
children: submenuChildren
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
spans.forEach((span, spanIndex) => {
|
||||
const label = cleanNavLabel(span.textContent || span.title || "");
|
||||
const actionId = \`\${entryId}-span-\${spanIndex}\`;
|
||||
span.setAttribute(NAVBAR_ACTION_ATTR, actionId);
|
||||
entries.push({ id: \`\${entryId}-span-\${spanIndex}\`, type: "action", label: label || "Action", actionId });
|
||||
entries.push({
|
||||
id: \`\${entryId}-span-\${spanIndex}\`,
|
||||
type: "action",
|
||||
label: label || "Action",
|
||||
...getNavEntryMeta(span),
|
||||
actionId
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+192
-23
@@ -107,6 +107,8 @@ type TopbarMenuEntry = {
|
||||
id: string;
|
||||
type: "link" | "action" | "divider" | "label" | "submenu";
|
||||
label?: string;
|
||||
datePrefix?: string;
|
||||
sourceColor?: string;
|
||||
href?: string;
|
||||
isExternal?: boolean;
|
||||
actionId?: string;
|
||||
@@ -161,6 +163,37 @@ function getElementPrimaryText(ele: Element): string {
|
||||
return cleanTopbarLabel(ele.textContent || "");
|
||||
}
|
||||
|
||||
function getElementMenuMeta(ele: Element | null): Pick<TopbarMenuEntry, "datePrefix" | "sourceColor"> {
|
||||
if (!ele) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const out: Pick<TopbarMenuEntry, "datePrefix" | "sourceColor"> = {};
|
||||
|
||||
const dateEle = ele.querySelector(":scope > .page__nav-date");
|
||||
if (dateEle) {
|
||||
const datePrefix = cleanTopbarLabel(dateEle.textContent || "");
|
||||
if (datePrefix) {
|
||||
out.datePrefix = datePrefix;
|
||||
}
|
||||
}
|
||||
|
||||
const sourceEle = ele.querySelector(":scope > .nav2-list__disp-source");
|
||||
if (sourceEle instanceof HTMLElement) {
|
||||
const computed = getComputedStyle(sourceEle);
|
||||
const sourceColor = (computed.backgroundColor || computed.borderLeftColor || "").trim();
|
||||
if (
|
||||
sourceColor
|
||||
&& sourceColor !== "transparent"
|
||||
&& !/^rgba?\(\s*0\s*,\s*0\s*,\s*0(?:\s*,\s*0)?\s*\)$/i.test(sourceColor)
|
||||
) {
|
||||
out.sourceColor = sourceColor;
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function getPathnameFromHrefLoose(hrefRaw: string): string {
|
||||
const href = hrefRaw.trim();
|
||||
if (!href) {
|
||||
@@ -178,7 +211,7 @@ function areTopbarGroupsEquivalent(a: TopbarMenuGroup[], b: TopbarMenuGroup[]):
|
||||
}
|
||||
|
||||
function parseNativeTopbarMenuList(
|
||||
list: HTMLUListElement,
|
||||
list: Element,
|
||||
registerAction: (ele: HTMLElement) => string,
|
||||
idPrefix: string
|
||||
): TopbarMenuEntry[] {
|
||||
@@ -187,6 +220,11 @@ function parseNativeTopbarMenuList(
|
||||
|
||||
liElements.forEach((li, index) => {
|
||||
const entryId = `${idPrefix}-${index}`;
|
||||
const submenuList = (
|
||||
li.querySelector(":scope > ul")
|
||||
|| (li.classList.contains("nav2-accord__wrp") ? li.querySelector(":scope > .nav2-accord__body") : null)
|
||||
) as Element | null;
|
||||
const submenuChildren = submenuList ? parseNativeTopbarMenuList(submenuList, registerAction, `${entryId}-submenu`) : [];
|
||||
if (li.classList.contains("ve-dropdown-divider") || li.classList.contains("divider")) {
|
||||
entries.push({ id: `${entryId}-divider`, type: "divider" });
|
||||
return;
|
||||
@@ -202,7 +240,6 @@ function parseNativeTopbarMenuList(
|
||||
|
||||
if (li.classList.contains("dropdown--navbar")) {
|
||||
const toggle = li.querySelector(":scope > a") as HTMLAnchorElement | null;
|
||||
const submenuList = li.querySelector(":scope > ul") as HTMLUListElement | null;
|
||||
const label = toggle ? getElementPrimaryText(toggle) : "";
|
||||
if (!label) {
|
||||
return;
|
||||
@@ -211,7 +248,24 @@ function parseNativeTopbarMenuList(
|
||||
id: `${entryId}-submenu`,
|
||||
type: "submenu",
|
||||
label,
|
||||
children: submenuList ? parseNativeTopbarMenuList(submenuList, registerAction, `${entryId}-submenu`) : []
|
||||
...getElementMenuMeta(toggle),
|
||||
children: submenuChildren
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (li.classList.contains("nav2-accord__wrp")) {
|
||||
const headPrimary = li.querySelector(":scope > .nav2-accord__head > :first-child");
|
||||
const label = headPrimary ? getElementPrimaryText(headPrimary) : "";
|
||||
if (!label || !submenuChildren.length) {
|
||||
return;
|
||||
}
|
||||
entries.push({
|
||||
id: `${entryId}-accordion`,
|
||||
type: "submenu",
|
||||
label,
|
||||
...getElementMenuMeta(headPrimary),
|
||||
children: submenuChildren
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -221,11 +275,25 @@ function parseNativeTopbarMenuList(
|
||||
const label = getElementPrimaryText(link);
|
||||
const href = (link.getAttribute("href") || "").trim();
|
||||
const isActionLike = href === "#" || href.toLowerCase().startsWith("javascript:");
|
||||
if (submenuChildren.length) {
|
||||
entries.push({
|
||||
id: `${entryId}-submenu-link`,
|
||||
type: "submenu",
|
||||
label: label || "Section",
|
||||
...getElementMenuMeta(link),
|
||||
href: !isActionLike ? href : undefined,
|
||||
actionId: isActionLike ? registerAction(link) : undefined,
|
||||
isExternal: !isActionLike ? link.target === "_blank" || /^https?:\/\//i.test(href) : false,
|
||||
children: submenuChildren
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (isActionLike) {
|
||||
entries.push({
|
||||
id: `${entryId}-action`,
|
||||
type: "action",
|
||||
label: label || "Action",
|
||||
...getElementMenuMeta(link),
|
||||
actionId: registerAction(link)
|
||||
});
|
||||
return;
|
||||
@@ -235,6 +303,7 @@ function parseNativeTopbarMenuList(
|
||||
id: `${entryId}-link`,
|
||||
type: "link",
|
||||
label: label || href,
|
||||
...getElementMenuMeta(link),
|
||||
href,
|
||||
isExternal: link.target === "_blank" || /^https?:\/\//i.test(href)
|
||||
});
|
||||
@@ -242,12 +311,26 @@ function parseNativeTopbarMenuList(
|
||||
}
|
||||
|
||||
const spans = Array.from(li.querySelectorAll(":scope > span")).filter((it): it is HTMLSpanElement => it instanceof HTMLSpanElement);
|
||||
if (submenuChildren.length && spans.length) {
|
||||
const first = spans[0];
|
||||
const label = cleanTopbarLabel(first.textContent || first.title || "");
|
||||
entries.push({
|
||||
id: `${entryId}-submenu-span`,
|
||||
type: "submenu",
|
||||
label: label || "Section",
|
||||
...getElementMenuMeta(first),
|
||||
actionId: registerAction(first),
|
||||
children: submenuChildren
|
||||
});
|
||||
return;
|
||||
}
|
||||
spans.forEach((span, spanIndex) => {
|
||||
const label = cleanTopbarLabel(span.textContent || span.title || "");
|
||||
entries.push({
|
||||
id: `${entryId}-span-${spanIndex}`,
|
||||
type: "action",
|
||||
label: label || "Action",
|
||||
...getElementMenuMeta(span),
|
||||
actionId: registerAction(span)
|
||||
});
|
||||
});
|
||||
@@ -1913,6 +1996,7 @@ function WikiView() {
|
||||
const [currentPath, setCurrentPath] = useState<string>(DEFAULT_PAGE);
|
||||
const [isRenderRuntimeReady, setIsRenderRuntimeReady] = useState(false);
|
||||
const [openTopMenuId, setOpenTopMenuId] = useState<string | null>(null);
|
||||
const [openTopMenuPath, setOpenTopMenuPath] = useState<string[]>([]);
|
||||
const [topbarSearch, setTopbarSearch] = useState("");
|
||||
const [topbarGroups, setTopbarGroups] = useState<TopbarMenuGroup[]>([]);
|
||||
const frameRef = useRef<HTMLIFrameElement | null>(null);
|
||||
@@ -2493,6 +2577,7 @@ function WikiView() {
|
||||
setCurrentPath(path);
|
||||
setCharacterModalMode("closed");
|
||||
setOpenTopMenuId(null);
|
||||
setOpenTopMenuPath([]);
|
||||
}, []);
|
||||
|
||||
const onSubmitTopbarSearch = useCallback((event: FormEvent<HTMLFormElement>) => {
|
||||
@@ -2555,6 +2640,7 @@ function WikiView() {
|
||||
return;
|
||||
}
|
||||
frameRef.current?.contentWindow?.postMessage({ type: "5etd:native-navbar-run-action", actionId }, "*");
|
||||
setOpenTopMenuPath([]);
|
||||
}, []);
|
||||
|
||||
const navigateTopbarHref = useCallback((hrefRaw: string, isExternal?: boolean) => {
|
||||
@@ -2583,13 +2669,13 @@ function WikiView() {
|
||||
}, [navigateWikiPath, resolveWikiPathFromHref]);
|
||||
|
||||
const activateTopbarEntry = useCallback((entry: TopbarMenuEntry) => {
|
||||
if (entry.type === "action") {
|
||||
if (entry.actionId) {
|
||||
runTopbarAction(entry.actionId);
|
||||
setOpenTopMenuId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.type === "link" && entry.href) {
|
||||
if (entry.href) {
|
||||
navigateTopbarHref(entry.href, entry.isExternal);
|
||||
return;
|
||||
}
|
||||
@@ -2598,6 +2684,7 @@ function WikiView() {
|
||||
const activateTopbarGroup = useCallback((group: TopbarMenuGroup) => {
|
||||
if (group.kind === "dropdown") {
|
||||
setOpenTopMenuId((current) => (current === group.id ? null : group.id));
|
||||
setOpenTopMenuPath([]);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2612,39 +2699,118 @@ function WikiView() {
|
||||
}
|
||||
}, [navigateTopbarHref, runTopbarAction]);
|
||||
|
||||
const renderTopbarEntries = useCallback((entries: TopbarMenuEntry[], depth = 0): JSX.Element[] => {
|
||||
return entries.flatMap((entry) => {
|
||||
useEffect(() => {
|
||||
if (!openTopMenuId) {
|
||||
setOpenTopMenuPath([]);
|
||||
}
|
||||
}, [openTopMenuId]);
|
||||
|
||||
const openTopbarSubmenuAtDepth = useCallback((depth: number, entryId: string) => {
|
||||
setOpenTopMenuPath((current) => {
|
||||
if (current[depth] === entryId && current.length === depth + 1) {
|
||||
return current;
|
||||
}
|
||||
return [...current.slice(0, depth), entryId];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const renderTopbarDropdown = useCallback((entries: TopbarMenuEntry[]): JSX.Element => {
|
||||
const renderEntryLabel = (entry: TopbarMenuEntry): JSX.Element => (
|
||||
<span className="topbar-item-content">
|
||||
{entry.datePrefix ? <span className="topbar-item-date">{entry.datePrefix}</span> : null}
|
||||
{entry.sourceColor ? <span className="topbar-item-source" style={{ backgroundColor: entry.sourceColor }} aria-hidden="true" /> : null}
|
||||
<span className="topbar-item-text">{entry.label}</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
const columns: TopbarMenuEntry[][] = [entries];
|
||||
let currentEntries = entries;
|
||||
|
||||
for (let depth = 0; depth < openTopMenuPath.length; depth += 1) {
|
||||
const activeSubmenuId = openTopMenuPath[depth];
|
||||
if (!activeSubmenuId) {
|
||||
break;
|
||||
}
|
||||
const submenuEntry = currentEntries.find((entry) => entry.type === "submenu" && entry.id === activeSubmenuId);
|
||||
if (!submenuEntry?.children?.length) {
|
||||
break;
|
||||
}
|
||||
columns.push(submenuEntry.children);
|
||||
currentEntries = submenuEntry.children;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="topbar-dropdown-columns">
|
||||
{columns.map((columnEntries, depth) => (
|
||||
<div key={`col-${depth}`} className="topbar-dropdown-column">
|
||||
{columnEntries.map((entry) => {
|
||||
if (entry.type === "divider") {
|
||||
return [<div key={entry.id} className="topbar-dropdown-divider" />];
|
||||
return <div key={entry.id} className="topbar-dropdown-divider" />;
|
||||
}
|
||||
|
||||
if (entry.type === "label") {
|
||||
return [<div key={entry.id} className="topbar-dropdown-label">{entry.label}</div>];
|
||||
return <div key={entry.id} className="topbar-dropdown-label">{entry.label}</div>;
|
||||
}
|
||||
|
||||
if (entry.type === "submenu") {
|
||||
const children = entry.children?.length ? renderTopbarEntries(entry.children, depth + 1) : [];
|
||||
return [
|
||||
<div key={entry.id} className={depth > 0 ? "topbar-submenu topbar-submenu--nested" : "topbar-submenu"}>
|
||||
<div className="topbar-submenu-title">{entry.label}</div>
|
||||
<div className="topbar-submenu-items">{children}</div>
|
||||
</div>
|
||||
];
|
||||
const isSubmenuActive = openTopMenuPath[depth] === entry.id;
|
||||
const hasChildren = Boolean(entry.children?.length);
|
||||
const className = [
|
||||
"topbar-dropdown-item",
|
||||
"topbar-dropdown-item--submenu",
|
||||
isSubmenuActive ? "is-active" : ""
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={entry.id}
|
||||
className={className}
|
||||
onMouseEnter={() => {
|
||||
if (!hasChildren) {
|
||||
return;
|
||||
}
|
||||
openTopbarSubmenuAtDepth(depth, entry.id);
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (!hasChildren) {
|
||||
return;
|
||||
}
|
||||
openTopbarSubmenuAtDepth(depth, entry.id);
|
||||
}}
|
||||
onClick={() => {
|
||||
if (hasChildren) {
|
||||
openTopbarSubmenuAtDepth(depth, entry.id);
|
||||
return;
|
||||
}
|
||||
activateTopbarEntry(entry);
|
||||
}}
|
||||
>
|
||||
{renderEntryLabel(entry)}
|
||||
{hasChildren ? <span className="topbar-submenu-caret" aria-hidden="true">▸</span> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const isActive = entry.type === "link" && entry.href ? isSameAppPath(entry.href, currentPathname) : false;
|
||||
return [
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={entry.id}
|
||||
className={isActive ? "topbar-dropdown-item is-active" : "topbar-dropdown-item"}
|
||||
onClick={() => activateTopbarEntry(entry)}
|
||||
>
|
||||
{entry.label}
|
||||
{renderEntryLabel(entry)}
|
||||
</button>
|
||||
];
|
||||
});
|
||||
}, [activateTopbarEntry, currentPathname, isSameAppPath]);
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}, [activateTopbarEntry, currentPathname, isSameAppPath, openTopMenuPath, openTopbarSubmenuAtDepth]);
|
||||
|
||||
const sourceBuckets = characterDraft?.steps.preferences.sourceBuckets ?? [];
|
||||
|
||||
@@ -3649,7 +3815,7 @@ function WikiView() {
|
||||
|
||||
{group.kind === "dropdown" && isOpen ? (
|
||||
<div className="topbar-dropdown-menu" role="menu">
|
||||
{renderTopbarEntries(group.items ?? [])}
|
||||
{renderTopbarDropdown(group.items ?? [])}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -3660,7 +3826,10 @@ function WikiView() {
|
||||
<button
|
||||
type="button"
|
||||
className={characterModalMode !== "closed" ? "topbar-menu-toggle is-active" : "topbar-menu-toggle"}
|
||||
onClick={() => setOpenTopMenuId((current) => (current === APP_CHARACTERS_MENU_ID ? null : APP_CHARACTERS_MENU_ID))}
|
||||
onClick={() => {
|
||||
setOpenTopMenuId((current) => (current === APP_CHARACTERS_MENU_ID ? null : APP_CHARACTERS_MENU_ID));
|
||||
setOpenTopMenuPath([]);
|
||||
}}
|
||||
aria-expanded={openTopMenuId === APP_CHARACTERS_MENU_ID}
|
||||
>
|
||||
<span>Characters</span>
|
||||
|
||||
+96
-11
@@ -152,14 +152,12 @@ body {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: calc(100% + 8px);
|
||||
min-width: 260px;
|
||||
max-width: min(520px, calc(100vw - 32px));
|
||||
min-width: 280px;
|
||||
max-width: min(1100px, calc(100vw - 32px));
|
||||
max-height: min(70vh, 680px);
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 8px;
|
||||
display: block;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(126, 181, 234, 0.38);
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(180deg, rgba(11, 20, 33, 0.985), rgba(7, 13, 24, 0.992));
|
||||
@@ -167,23 +165,55 @@ body {
|
||||
z-index: 60;
|
||||
}
|
||||
|
||||
.topbar-dropdown-columns {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: minmax(280px, 340px);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.topbar-dropdown-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
padding: 8px;
|
||||
border-right: 1px solid rgba(111, 167, 224, 0.32);
|
||||
}
|
||||
|
||||
.topbar-dropdown-column:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.topbar-dropdown-item {
|
||||
width: 100%;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 9px;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #d3e4f8;
|
||||
font-size: 0.88rem;
|
||||
font-size: 0.86rem;
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
padding: 7px 8px;
|
||||
cursor: pointer;
|
||||
white-space: normal;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.topbar-dropdown-item--submenu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.topbar-submenu-caret {
|
||||
color: #a7c3e3;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.topbar-dropdown-item:hover {
|
||||
border-color: rgba(113, 171, 230, 0.45);
|
||||
background: rgba(40, 71, 107, 0.42);
|
||||
background: rgba(36, 67, 103, 0.36);
|
||||
color: #f4f8ff;
|
||||
}
|
||||
|
||||
@@ -201,13 +231,39 @@ body {
|
||||
}
|
||||
|
||||
.topbar-dropdown-label {
|
||||
padding: 4px 8px;
|
||||
padding: 4px 8px 2px;
|
||||
color: #9db5d3;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.topbar-item-content {
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.topbar-item-date {
|
||||
color: #8ea8c8;
|
||||
min-width: 2.15rem;
|
||||
text-align: right;
|
||||
font-size: 0.74rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.topbar-item-source {
|
||||
width: 3px;
|
||||
height: 0.98rem;
|
||||
border-radius: 999px;
|
||||
flex: 0 0 3px;
|
||||
}
|
||||
|
||||
.topbar-item-text {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topbar-submenu {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
@@ -227,6 +283,22 @@ body {
|
||||
font-weight: 620;
|
||||
}
|
||||
|
||||
.topbar-submenu-title--interactive {
|
||||
width: 100%;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #c9dbf1;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.topbar-submenu-title--interactive:hover {
|
||||
border-color: rgba(113, 171, 230, 0.45);
|
||||
background: rgba(40, 71, 107, 0.42);
|
||||
color: #f4f8ff;
|
||||
}
|
||||
|
||||
.topbar-submenu-items {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
@@ -1232,6 +1304,19 @@ body {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.topbar-dropdown-columns {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.topbar-dropdown-column {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid rgba(111, 167, 224, 0.2);
|
||||
}
|
||||
|
||||
.topbar-dropdown-column:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.character-modal-backdrop {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user