diff --git a/dnd5e.mjs b/dnd5e.mjs index 561f8e1618..7440b65761 100644 --- a/dnd5e.mjs +++ b/dnd5e.mjs @@ -23,6 +23,7 @@ import * as dice from "./module/dice/_module.mjs"; import * as documents from "./module/documents/_module.mjs"; import * as enrichers from "./module/enrichers.mjs"; import * as Filter from "./module/filter.mjs"; +import * as inserts from "./module/inserts.mjs"; import * as migrations from "./module/migration.mjs"; import { registerModuleData, registerModuleRedirects, setupModulePacks } from "./module/module-registration.mjs"; import { default as registry } from "./module/registry.mjs"; @@ -43,6 +44,7 @@ globalThis.dnd5e = { documents, enrichers, Filter, + inserts, migrations, registry, ui: {}, @@ -548,6 +550,9 @@ Hooks.once("ready", function() { // Adjust sourced items on actors now that compendium UUID redirects have been initialized game.actors.forEach(a => a.sourcedItems._redirectKeys()); + // ProseMirror inserts (after compendia are available so reference rule titles can resolve) + inserts.registerProseMirrorInserts(); + // Register items by type dnd5e.registry.classes.initialize(); dnd5e.registry.subclasses.initialize(); @@ -670,6 +675,7 @@ export { documents, enrichers, Filter, + inserts, migrations, registry, utils, diff --git a/lang/en.json b/lang/en.json index fe302b7c2f..95f5579f60 100644 --- a/lang/en.json +++ b/lang/en.json @@ -7,6 +7,30 @@ } }, +"EDITOR.DND5E": { + "Inserts": { + "Group": "D&D Blocks", + "Notable": "Notable", + "Narrative": "Narrative", + "Quest": "Quest", + "Advice": "Advice", + "Quote": "Pull Quote", + "FloatLeft": "Float Left", + "FloatRight": "Float Right", + "HabitatTreasure": "Habitat & Treasure", + "EnrichersGroup": "D&D Enrichers", + "Check": "Ability Check", + "Save": "Saving Throw", + "Skill": "Skill Check", + "Damage": "Damage", + "Attack": "Attack Roll", + "Award": "Award", + "Item": "Item", + "Reference": "Reference", + "Lookup": "Lookup" + } +}, + "TYPES.Actor.character": "Player Character", "TYPES.Actor.characterPl": "Player Characters", "TYPES.Actor.encounter": "Encounter", diff --git a/less/v2/apps.less b/less/v2/apps.less index 99ab0337a7..b1fd9ecc34 100644 --- a/less/v2/apps.less +++ b/less/v2/apps.less @@ -2100,6 +2100,13 @@ dialog.dnd5e2.application { ul { box-shadow: 0 3px 6px var(--dnd5e-shadow-45); } li.divider { border-color: #ddd; } + li[data-action^="dnd5e-"] > span { text-transform: capitalize; } + + ul:has(> li[data-action^="dnd5e-"]):not(:has(> li > ul)) { + max-height: 50vh; + overflow: hidden auto; + } + &.theme-dark { --dropdown-background: var(--dnd5e-color-blue-gray-2); --dropdown-border: transparent; diff --git a/module/inserts.mjs b/module/inserts.mjs new file mode 100644 index 0000000000..ce0c678392 --- /dev/null +++ b/module/inserts.mjs @@ -0,0 +1,197 @@ +/** + * Build inline enricher inserts for each entry in a config record, titled by the entry's label. + * @param {string} prefix Action id prefix for each generated child. + * @param {Record} record Config record keyed by entry id. + * @param {(key: string) => string} html Builds the enricher markup for a given entry id. + * @returns {object[]} + */ +function buildEnricherInserts(prefix, record, html) { + return Object.entries(record).map(([key, { label }]) => ({ + action: `${prefix}-${key}`, + title: label, + inline: true, + html: html(key) + })); +} + +/* -------------------------------------------- */ + +/** + * Inserts whose children are abilities and should stay in stat order rather than be alphabetized. + * @type {Set} + */ +const STAT_ORDERED = new Set(["dnd5e-enricher-check", "dnd5e-enricher-save", "dnd5e-reference-ability"]); + +/** + * Recursively order insert entries and their submenus alphabetically by localized title. + * @param {object[]} inserts The insert entries to sort in place. + */ +function sortInserts(inserts) { + inserts.sort((a, b) => game.i18n.localize(a.title).localeCompare(game.i18n.localize(b.title))); + for ( const insert of inserts ) { + if ( insert.children && !STAT_ORDERED.has(insert.action) ) sortInserts(insert.children); + } +} + +/* -------------------------------------------- */ + +/** + * Build reference enricher inserts grouped into a submenu per rule type, one leaf per referenceable entry. + * Entries without a resolvable reference are skipped and duplicate references (e.g. `str`/`strength`) are collapsed. + * Rule entries have no configured label, so their referenced document name is resolved for the title. + * @returns {Promise} One insert group per non-empty rule type. + */ +async function buildReferenceInserts() { + const groups = []; + for ( const [type, { label, references }] of Object.entries(CONFIG.DND5E.ruleTypes) ) { + const record = foundry.utils.getProperty(CONFIG.DND5E, references) ?? {}; + const seen = new Set(); + const children = []; + for ( const [key, source] of Object.entries(record) ) { + const uuid = foundry.utils.getType(source) === "Object" ? source.reference : source; + if ( !uuid || seen.has(uuid) ) continue; + seen.add(uuid); + children.push({ + action: `dnd5e-reference-${type}-${key}`, + title: source?.label ?? source?.name ?? (await fromUuid(uuid))?.name ?? key, + inline: true, + html: `&Reference[${type}=${key}]` + }); + } + if ( children.length ) groups.push({ action: `dnd5e-reference-${type}`, title: label, children }); + } + return groups; +} + +/* -------------------------------------------- */ + +/** + * Register the system's special HTML blocks and enrichers as ProseMirror inserts. + * @returns {Promise} + */ +export async function registerProseMirrorInserts() { + CONFIG.TextEditor.inserts.push({ + action: "dnd5e-blocks", + title: "EDITOR.DND5E.Inserts.Group", + children: [ + { + action: "dnd5e-block-notable", + title: "EDITOR.DND5E.Inserts.Notable", + html: '' + }, + { + action: "dnd5e-block-narrative", + title: "EDITOR.DND5E.Inserts.Narrative", + html: '' + }, + { + action: "dnd5e-block-quest", + title: "EDITOR.DND5E.Inserts.Quest", + html: '
' + + "

Quest

Quest description.

" + }, + { + action: "dnd5e-block-advice", + title: "EDITOR.DND5E.Inserts.Advice", + html: '
' + + "

Advice

Advice content.

" + }, + { + action: "dnd5e-block-quote", + title: "EDITOR.DND5E.Inserts.Quote", + children: [ + { + action: "dnd5e-block-quote-left", + title: "EDITOR.DND5E.Inserts.FloatLeft", + html: '' + }, + { + action: "dnd5e-block-quote-right", + title: "EDITOR.DND5E.Inserts.FloatRight", + html: '' + } + ] + }, + { + action: "dnd5e-block-habitat-treasure", + title: "EDITOR.DND5E.Inserts.HabitatTreasure", + html: '

Habitat: Any; Treasure: None

' + } + ] + }); + + CONFIG.TextEditor.inserts.push({ + action: "dnd5e-enrichers", + title: "EDITOR.DND5E.Inserts.EnrichersGroup", + children: [ + { + action: "dnd5e-enricher-check", + title: "EDITOR.DND5E.Inserts.Check", + children: buildEnricherInserts("dnd5e-enricher-check", CONFIG.DND5E.abilities, key => `[[/check ability=${key}]]`) + }, + { + action: "dnd5e-enricher-save", + title: "EDITOR.DND5E.Inserts.Save", + children: buildEnricherInserts("dnd5e-enricher-save", CONFIG.DND5E.abilities, key => `[[/save ability=${key}]]`) + }, + { + action: "dnd5e-enricher-skill", + title: "EDITOR.DND5E.Inserts.Skill", + children: buildEnricherInserts("dnd5e-enricher-skill", CONFIG.DND5E.skills, key => `[[/check skill=${key}]]`) + }, + { + action: "dnd5e-enricher-damage", + title: "EDITOR.DND5E.Inserts.Damage", + children: buildEnricherInserts("dnd5e-enricher-damage", { + ...CONFIG.DND5E.damageTypes, + ...Object.fromEntries(Object.entries(CONFIG.DND5E.healingTypes) + .map(([key, type]) => [key, { ...type, label: type.labelShort ?? type.label }])) + }, key => `[[/damage type=${key}]]`) + }, + { + action: "dnd5e-enricher-attack", + title: "EDITOR.DND5E.Inserts.Attack", + inline: true, + html: "[[/attack +5]]" + }, + { + action: "dnd5e-enricher-award", + title: "EDITOR.DND5E.Inserts.Award", + children: [ + ...buildEnricherInserts("dnd5e-enricher-award", CONFIG.DND5E.currencies, + key => `[[/award 50${key}]]`), + { + action: "dnd5e-enricher-award-xp", + title: "DND5E.ExperiencePoints.Label", + inline: true, + html: "[[/award 50xp]]" + } + ] + }, + { + action: "dnd5e-enricher-item", + title: "EDITOR.DND5E.Inserts.Item", + inline: true, + html: "[[/item Longsword]]" + }, + { + action: "dnd5e-enricher-reference", + title: "EDITOR.DND5E.Inserts.Reference", + children: await buildReferenceInserts() + }, + { + action: "dnd5e-enricher-lookup", + title: "EDITOR.DND5E.Inserts.Lookup", + inline: true, + html: "[[lookup @name]]" + } + ] + }); + + // Alphabetize every submenu by localized title. + CONFIG.TextEditor.inserts + .filter(insert => insert.action?.startsWith("dnd5e-")) + .forEach(insert => sortInserts(insert.children ?? [])); +}