Skip to content

Commit

Permalink
Add an "ABC Picker" as an alternative to opening compendiums from PC …
Browse files Browse the repository at this point in the history
…sheets (foundryvtt#16896)
  • Loading branch information
stwlam authored Oct 20, 2024
1 parent 676e41c commit 3c2f1d0
Show file tree
Hide file tree
Showing 18 changed files with 899 additions and 165 deletions.
10 changes: 5 additions & 5 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,20 @@ import prettier from "eslint-plugin-prettier";
import globals from "globals";
import tseslint from "typescript-eslint";

export default [
{ files: ["**/*.ts"] },
{ ignores: ["**/dist/"] },
export default tseslint.config(
{ ignores: ["dist/", "packs/", "static/"] },
{ plugins: { jest, prettier } },
eslint.configs.recommended,
...tseslint.configs.recommended,
{
plugins: { jest, prettier },
languageOptions: {
globals: {
...globals.browser,
...jest.environments.globals.globals,
},
ecmaVersion: 2023,
sourceType: "module",
parser: tseslint.parser,
parserOptions: { project: "./tsconfig.json" },
},
rules: {
Expand Down Expand Up @@ -55,4 +55,4 @@ export default [
files: ["tests/**/*"],
rules: { "global-require": "off" },
},
];
);
486 changes: 382 additions & 104 deletions package-lock.json

Large diffs are not rendered by default.

21 changes: 12 additions & 9 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,27 +29,28 @@
"node": ">=20.15.0"
},
"devDependencies": {
"@eslint/js": "^9.12.0",
"@eslint/js": "^9.13.0",
"@pixi/graphics-smooth": "^1.1.0",
"@pixi/particle-emitter": "5.0.8",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"@types/eslint__js": "^8.42.3",
"@types/fs-extra": "^11.0.4",
"@types/jest": "^29.5.13",
"@types/jquery": "^3.5.31",
"@types/jsdom": "^21.1.7",
"@types/luxon": "^3.4.2",
"@types/node": "^20.14.6",
"@types/node": "^20.16.13",
"@types/prompts": "^2.4.9",
"@types/showdown": "^2.0.6",
"@types/sortablejs": "^1.15.8",
"@types/tooltipster": "^0.0.35",
"@types/uuid": "^10.0.0",
"@types/yaireo__tagify": "4.17.0",
"@typescript-eslint/eslint-plugin": "^8.8.1",
"@typescript-eslint/parser": "^8.8.1",
"@typescript-eslint/eslint-plugin": "^8.10.0",
"@typescript-eslint/parser": "^8.10.0",
"classic-level": "^1.3.0",
"es-jest": "^2.1.0",
"eslint": "^9.12.0",
"eslint": "^9.13.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-jest": "^28.8.3",
"eslint-plugin-json": "^4.0.1",
Expand All @@ -67,15 +68,16 @@
"prompts": "^2.4.2",
"prosemirror-commands": "^1.5.2",
"prosemirror-view": "^1.33.6",
"sass": "^1.79.5",
"sass": "^1.80.3",
"socket.io": "4.7.5",
"socket.io-client": "4.7.5",
"svelte-preprocess": "^6.0.3",
"tinymce": "6.8.4",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.19.1",
"typescript": "^5.3.3",
"typescript-eslint": "^8.8.1",
"vite": "^5.4.8",
"typescript-eslint": "^8.10.0",
"vite": "^5.4.9",
"vite-plugin-checker": "^0.8.0",
"vite-plugin-static-copy": "^2.0.0",
"vite-tsconfig-paths": "^5.0.1",
Expand All @@ -89,8 +91,9 @@
"luxon": "^3.5.0",
"minisearch": "^7.1.0",
"nouislider": "^15.8.1",
"remeda": "^2.15.0",
"remeda": "^2.15.2",
"sortablejs": "^1.15.3",
"svelte": "^5.0.2",
"uuid": "^10.0.0"
}
}
172 changes: 172 additions & 0 deletions src/module/actor/character/apps/abc-picker/app.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
<script lang="ts">
import type { ItemPF2e } from "@item";
import { ErrorPF2e, htmlQuery } from "@util";
import type { ABCPickerContext } from "./app.ts";
const { actor, foundryApp, state: data }: ABCPickerContext = $props();
const typePlural = game.i18n.localize(`PF2E.Item.${data.itemType.capitalize()}.Plural`);
const searchPlaceholder = game.i18n.format("PF2E.Actor.Character.ABCPicker.SearchPlaceholder", {
items: typePlural,
});
let selection: string | null = $state(null);
/** Show the confirmation button. */
function showConfirmation(button: HTMLButtonElement): void {
const row = button.closest("li");
selection = selection === row?.dataset.uuid ? null : (row?.dataset.uuid ?? null);
}
/** Open an item sheet to show additional details. */
async function viewItemSheet(uuid: ItemUUID): Promise<void> {
const item = await fromUuid<ItemPF2e>(uuid);
item?.sheet.render(true);
}
/** Create a new embedded ABC item on the character. */
async function saveSelection(uuid: ItemUUID): Promise<void> {
const item = await fromUuid<ItemPF2e>(uuid);
if (!item) throw ErrorPF2e(`Unexpected error retrieving ${data.itemType}`);
actor.createEmbeddedDocuments("Item", [item.clone().toObject()]);
foundryApp.close();
}
/** Search list and show or hide according to match result. */
const searchItems = fu.debounce((query: string) => {
const regexp = new RegExp(RegExp.escape(query.trim()), "i");
for (const row of foundryApp.element.getElementsByTagName("li")) {
row.hidden = !regexp.test(htmlQuery(row, "[data-name]")?.innerText ?? "");
}
}, 200);
</script>

<header class="search">
<i class="fa-solid fa-search"></i>
<input
type="search"
spellcheck="false"
placeholder={searchPlaceholder}
onkeyup={(event) => searchItems(event.currentTarget.value)}
/>
</header>

<ul>
{#each data.items as item}
<li data-uuid={item.uuid}>
<img src={item.img} loading="lazy" alt="Class icon" />
<button type="button" class="flat name-source" onclick={(e) => showConfirmation(e.currentTarget)}>
<div class="name" data-name>{item.name}</div>
<div class="source" class:publication={item.source.publication}>{item.source.name}</div>
</button>
<div class="buttons">
<button
type="button"
class="confirm"
class:selected={selection === item.uuid}
data-tooltip="PF2E.Actor.Character.ABCPicker.Tooltip.ConfirmSelection"
onclick={() => saveSelection(item.uuid)}><i class="fa-solid fa-check"></i></button
>
<button
type="button"
data-tooltip="PF2E.Actor.Character.ABCPicker.Tooltip.ViewSheet"
onclick={() => viewItemSheet(item.uuid)}><i class="fa-solid fa-info fa-fw"></i></button
>
</div>
</li>
{/each}
</ul>

<style>
header.search {
align-items: center;
border-bottom: 1px solid var(--color-border);
flex-flow: row nowrap;
gap: var(--space-8);
justify-content: start;
padding: var(--space-8) var(--space-8);
input::placeholder {
color: var(--color-light-5);
}
}
ul {
flex-flow: column nowrap;
height: 100%;
list-style: none;
margin: 0;
overflow: hidden scroll;
padding: var(--space-4) 0;
& > li {
align-items: center;
border-top: 1px solid var(--color-border);
display: flex;
gap: var(--space-8);
margin: 0;
padding: var(--space-3) var(--space-6);
img {
color: pointer;
border: none;
height: 3rem;
}
button.name-source {
display: flex;
flex-flow: column nowrap;
flex-grow: 1;
justify-content: center;
.name {
color: var(--color-text-primary);
}
.source {
color: var(--color-form-hint);
font-size: var(--font-size-12);
&.publication {
font-style: italic;
}
}
&:hover + .buttons button.confirm:not(.selected) {
opacity: 0.33;
visibility: visible;
}
}
.buttons {
align-items: center;
display: flex;
gap: var(--space-2);
flex-flow: row nowrap;
justify-content: end;
button {
font-size: var(--font-size-12);
height: 1.5rem;
width: 1.5rem;
i {
margin-right: 0;
}
&.confirm {
opacity: 0;
visibility: hidden;
&:not(:hover) {
color: darkgreen;
}
&.selected {
opacity: 1;
visibility: visible;
}
}
}
}
}
}
</style>
119 changes: 119 additions & 0 deletions src/module/actor/character/apps/abc-picker/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { CharacterPF2e } from "@actor";
import type { ItemPF2e } from "@item";
import type { ItemType } from "@item/base/data/index.ts";
import { SvelteApplicationMixin, SvelteApplicationRenderContext } from "@system/svelte/mixin.svelte.ts";
import { sluggify } from "@util";
import { UUIDUtils } from "@util/uuid.ts";
import * as R from "remeda";
import type { ApplicationConfiguration } from "types/foundry/client-esm/applications/_types.d.ts";
import type { ApplicationV2 } from "types/foundry/client-esm/applications/api/module.d.ts";
import Root from "./app.svelte";

type AhBCDType = Extract<ItemType, "ancestry" | "heritage" | "background" | "class" | "deity">;

interface ABCPickerConfiguration extends ApplicationConfiguration {
actor: CharacterPF2e;
itemType: AhBCDType;
}

interface ABCItemRef {
name: string;
img: ImageFilePath;
uuid: ItemUUID;
source: {
name: string;
/** Whether the source comes from an item's publication data or is simply the providing module */
publication: boolean;
};
hidden: boolean;
}

interface ABCPickerContext extends SvelteApplicationRenderContext {
actor: CharacterPF2e;
foundryApp: ABCPicker;
state: { prompt: string; itemType: AhBCDType; items: ABCItemRef[] };
}

/** A `Compendium`-like application for presenting A(H)BCD options for a character */
class ABCPicker extends SvelteApplicationMixin<
AbstractConstructorOf<ApplicationV2> & { DEFAULT_OPTIONS: DeepPartial<ABCPickerConfiguration> }
>(foundry.applications.api.ApplicationV2) {
static override DEFAULT_OPTIONS: DeepPartial<ABCPickerConfiguration> = {
id: "{id}",
classes: ["abc-picker"],
position: { width: 350, height: 650 },
window: { icon: "fa-solid fa-atlas", contentClasses: ["standard-form", "compact"] },
};

declare options: ABCPickerConfiguration;

protected root = Root;

override get title(): string {
const type = game.i18n.localize(`TYPES.Item.${this.options.itemType}`);
return game.i18n.format("PF2E.Actor.Character.ABCPicker.Title", { type });
}

protected override _initializeApplicationOptions(options: Partial<ABCPickerConfiguration>): ABCPickerConfiguration {
const initialized = super._initializeApplicationOptions(options) as ABCPickerConfiguration;
initialized.window.icon = `fa-solid ${CONFIG.Item.typeIcons[initialized.itemType]}`;
initialized.uniqueId = `abc-picker-${initialized.itemType}-${initialized.actor.uuid}`;
return initialized;
}

/** Gather all items of the request type from the world and across all item compendiums. */
async #gatherItems(): Promise<ABCItemRef[]> {
const { actor, itemType } = this.options;
const worldItems = game.items.filter((i) => i.type === itemType && i.testUserPermission(game.user, "LIMITED"));
const packItems = await UUIDUtils.fromUUIDs(
game.packs
.filter((p) => p.documentName === "Item" && p.testUserPermission(game.user, "LIMITED"))
.flatMap((p) => p.index.filter((e) => e.type === itemType).map((e) => e.uuid as CompendiumItemUUID)),
);

const items = [...worldItems, ...packItems]
.filter((item): item is ItemPF2e<null> => {
if (item.type !== itemType || item.parent) return false;
if (item.isOfType("heritage")) {
const ancestrySlug = actor.ancestry ? (actor.ancestry.slug ?? sluggify(actor.ancestry.name)) : null;
return item.system.ancestry?.slug === ancestrySlug || item.system.ancestry === null;
}
return true;
})
.sort((a, b) => a.name.localeCompare(b.name));

/** Resolve a "source", preferring publication title if set and resorting to fallbacks. */
const resolveSource = (item: ItemPF2e): { name: string; publication: boolean } => {
const publication = item.system.publication.title.trim();
if (publication) return { name: publication, publication: true };
if (item.uuid.startsWith("Item.")) return { name: game.world.title, publication: false };
const compendiumPack = game.packs.get(item.pack ?? "");
const module = game.modules.get(compendiumPack?.metadata.packageName ?? "");
const name = module?.title ?? compendiumPack?.title ?? "???";
return { name, publication: false };
};

return items.map(
(i): ABCItemRef => ({
...R.pick(i, ["name", "img", "uuid"]),
source: resolveSource(i),
hidden: false,
}),
);
}

protected override async _prepareContext(): Promise<ABCPickerContext> {
const itemType = this.options.itemType;
return {
actor: this.options.actor,
foundryApp: this,
state: {
prompt: game.i18n.localize(`PF2E.Actor.Character.ABCPicker.Prompt.${itemType}`),
itemType,
items: await this.#gatherItems(),
},
};
}
}

export { ABCPicker, type ABCPickerContext };
2 changes: 1 addition & 1 deletion src/module/actor/character/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1156,7 +1156,7 @@ class CharacterPF2e<TParent extends TokenDocumentPF2e | null = TokenDocumentPF2e
`item:proficiency:rank:${proficiencyRank}`,
// @todo migrate away:
PROFICIENCY_RANK_OPTION[proficiencyRank],
...weaponTraits, // always add weapon traits as options
...weaponTraits, // @todo same
meleeOrRanged,
]);

Expand Down
Loading

0 comments on commit 3c2f1d0

Please sign in to comment.