Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions dnd5e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -43,6 +44,7 @@ globalThis.dnd5e = {
documents,
enrichers,
Filter,
inserts,
migrations,
registry,
ui: {},
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -670,6 +675,7 @@ export {
documents,
enrichers,
Filter,
inserts,
migrations,
registry,
utils,
Expand Down
24 changes: 24 additions & 0 deletions lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions less/v2/apps.less
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
197 changes: 197 additions & 0 deletions module/inserts.mjs
Original file line number Diff line number Diff line change
@@ -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<string, { label: string }>} 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<string>}
*/
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<object[]>} 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: `&amp;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<void>}
*/
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: '<aside class="notable"><h3>Title</h3><selection><p>Notable content.</p></selection></aside>'
},
{
action: "dnd5e-block-narrative",
title: "EDITOR.DND5E.Inserts.Narrative",
html: '<aside class="narrative"><selection><p>Narrative text.</p></selection></aside>'
},
{
action: "dnd5e-block-quest",
title: "EDITOR.DND5E.Inserts.Quest",
html: '<section class="quest"><figure class="icon"><img class="round" src="icons/svg/book.svg"></figure>'
+ "<article><h4>Quest</h4><selection><p>Quest description.</p></selection></article></section>"
},
{
action: "dnd5e-block-advice",
title: "EDITOR.DND5E.Inserts.Advice",
html: '<section class="advice"><figure class="icon"><img class="round" src="icons/svg/book.svg"></figure>'
+ "<article><h4>Advice</h4><selection><p>Advice content.</p></selection></article></section>"
},
{
action: "dnd5e-block-quote",
title: "EDITOR.DND5E.Inserts.Quote",
children: [
{
action: "dnd5e-block-quote-left",
title: "EDITOR.DND5E.Inserts.FloatLeft",
html: '<aside class="quote-lg float-left"><selection><p><q>Quote text.</q></p></selection>'
+ '<p class="quote-author">Author</p></aside>'
},
{
action: "dnd5e-block-quote-right",
title: "EDITOR.DND5E.Inserts.FloatRight",
html: '<aside class="quote-lg float-right"><selection><p><q>Quote text.</q></p></selection>'
+ '<p class="quote-author">Author</p></aside>'
}
]
},
{
action: "dnd5e-block-habitat-treasure",
title: "EDITOR.DND5E.Inserts.HabitatTreasure",
html: '<p class="habitat-treasure"><strong>Habitat:</strong> Any; <strong>Treasure:</strong> None</p>'
}
]
});

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 ?? []));
}