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
17 changes: 17 additions & 0 deletions docs/architecture/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,23 @@ Heraldry is the current example:
This keeps each half under the correct layer (generation vs view) while the shared
`emblems/` name signals they form one feature.

### Bulk Action Bar bridge (`window.bulkBars`)

The generic, type-agnostic Bulk Action Bar machinery lives in `src/controllers/bulk-action/`
(a pure selection core, the `BulkEntityAdapter` interface, the DOM bar, the delete
confirmation). The per-type adapter is supplied by each menu: a migrated TS menu's
controller assembles its adapter inline — with the delete cascade/summary in a co-located
`*-cascade.ts` — and passes it to `BulkActionBar`, so the menu-specific logic stays in the
menu's own module. The remaining **legacy menus**
(`public/modules/ui/*.js`) load as plain `<script>` tags and cannot import ES modules, so
`src/controllers/bulk-action/legacy-bridge.ts` registers `window.bulkBars` (eagerly, via
`src/controllers/index.ts`) — the same `window.X` cross-layer pattern used for
`window.COA`, `window.tip`, `window.ensureEl`, etc. A legacy menu calls
`window.bulkBars.mount(type, {redraw})` on open and `window.bulkBars.sync(type)` after each
row-render. This bridge is a **deliberate, temporary seam**: as each legacy menu is
migrated to TS it switches to the direct-import path, and the bridge is deleted once all
are migrated. See `docs/prd/bulk-action-bar.md`.

## Why no `core/`

Folders are named by **role**, never by vague importance. A generic `core/` becomes a
Expand Down
1 change: 1 addition & 0 deletions docs/domain/glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,4 @@ This glossary covers core terminology, data structures, and concepts used throug
- **Overview Tool**: A summary UI for a particular system (e.g., production-overview, market-overview).
- **Configurator**: A UI for setting up world generation parameters.
- **Submap**: A tool to generate a new, more detailed map strictly from a selected area of the current map.
- **Bulk Action Bar**: A reusable multi-select control attached to each list-style overview/editor dialog. A button in the menu's footer toggles bulk mode, which adds a per-row checkbox column plus inline footer controls — a filter-aware "select all", a selected-count, and bulk actions (Delete, Lock, Unlock, Set color) on the current selection. Selection is scoped to the open menu (per-type), not cross-type.
84 changes: 84 additions & 0 deletions docs/prd/bulk-action-bar.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# PRD — Bulk Action Bar

## Problem

Every list-style overview/editor acts on one entity at a time. Deleting twelve states
means twelve trash clicks and twelve confirmations. The same holds for burgs, provinces,
cultures, religions, regiments, markers, routes, zones, markets, and rivers.

## Solution

A single reusable multi-select control on each list menu. A bulk button in the menu's
footer button row toggles bulk mode, which shows per-row checkboxes plus inline footer
controls: Select all, a selected-count, and Delete / Lock / Unlock / Set color (only the
actions the entity type supports). Selection is scoped to the open menu and kept across
actions, so they can be chained.

Bulk delete shows one confirmation summarizing the cascade (entities removed, dependents
reassigned or removed) and reports any locked rows it will skip. Locked rows are protected
from bulk delete — the safety model, since there is no undo. For States and Provinces an
optional "also delete contained burgs" choice is offered.

## Non-goals

- Cross-type selection (selecting states and markers together).
- Bulk edits beyond Lock / Unlock / Set color.
- Undo/redo. Lock-protection plus an explicit confirmation are the safety model.

## Architecture

The generic, type-agnostic machinery lives in `src/controllers/bulk-action/`:

- `bulk-selection.ts` — `BulkSelection`, a pure selection set with a `canSelect` predicate.
- `bulk-entity-adapter.ts` — the `BulkEntityAdapter` interface, the per-type seam.
- `bulk-action-bar.ts` — `BulkActionBar`, the DOM glue: one instance per menu, adds the
footer button + inline controls, applies row checkboxes, re-syncs after each re-render.
- `bulk-delete-confirm.ts` — builds the confirmation from a cascade summary.

Each menu supplies a `BulkEntityAdapter`. **The adapter's logic lives in that menu's own
controller**, not in the generic module: a migrated menu's controller assembles the adapter
inline and passes it to `BulkActionBar`, with its delete cascade and summary kept in a
co-located, unit-tested `*-cascade.ts` that single-delete reuses too (so the two delete
paths can't diverge).

Legacy plain-JS menus (`public/modules/ui/*.js`) load as `<script>` tags and can't import
ES modules, so they reach the bar through `legacy-bridge.ts`, which registers
`window.bulkBars` — the same `window.X` pattern already used for `window.tip`/`window.COA`.
A legacy menu calls `window.bulkBars.mount(type, {redraw})` on open and
`window.bulkBars.sync(type)` after each row-render. Their adapters still sit under
`bulk-action/adapters/`; that, and the bridge itself, are removed when each menu migrates
to TS.

## Cascade fidelity

For migrated menus (States, Cultures, Religions, Regiments) the single-delete data
mutations were extracted into the pure `*-cascade.ts` now shared by single and bulk delete.
Provinces' single delete was a legacy closure that couldn't be imported, so its mutations
are reimplemented in its adapter and matched against the original. Burgs, Markers, Routes,
Zones, Markets, and Rivers delegate to their existing remove functions.

A regiment's `i` is unique only within its state, so regiment rows encode a composite id
(`stateId * 100000 + regimentId`) the adapter decodes.

## Data model

- `Route` gains an optional `lock?: boolean` so routes can be bulk locked/unlocked.

## Removed

- Burgs "Remove All" button — replaced by "select all → Delete".
- Zones erase-mode toggle, its `dragZoneBrush` erase branch, and its orphaned Ctrl hotkey.

(The Rivers "Remove all" button is kept.)

## Acceptance criteria

- Each menu's footer has one bulk button; toggling it shows row checkboxes and the inline
Select-all / count / action controls, and hides them again.
- Bulk delete shows the cascade summary, skips locked rows, and redraws once.
- "Also delete contained burgs" appears in States and Provinces only; default delete
reassigns burgs to neutral.
- Lock/Unlock and Set color apply to the whole selection; Set color repaints the map.
- Non-deletable special rows (neutral state, "no religion"/"no market", capitals) are
excluded from selection and select-all.
- `BulkSelection` and every menu's cascade/predicates are unit-tested.
36 changes: 36 additions & 0 deletions public/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -1410,6 +1410,42 @@ div.slider .ui-slider-handle {
width: max-content;
}

/* The single footer entry button is styled like the menu's other icon buttons.
When bulk mode is on it is highlighted so it reads as a pressed toggle. */
.bulkToggle.active {
color: #a00;
}

/* The inline controls that appear in the footer while bulk mode is on. */
.bulkInline {
display: inline-flex;
align-items: center;
gap: 0.5em;
}

/* `!important` so an editor's footer show-all handler (`#xBottom > * { display:inline-block }`,
run when exiting manual-assignment) cannot force-reveal the bulk controls while bulk mode
is off — a non-important inline style loses to an important stylesheet rule. */
.bulkInline[hidden] {
display: none !important;
}

.bulkInline .bulkSelectAll {
cursor: pointer;
user-select: none;
}

.bulkInline .bulkCount {
font-style: italic;
color: #666;
}

.bulkRowCheckbox {
cursor: pointer;
margin-right: 0.3em;
vertical-align: middle;
}

div.header {
display: grid;
width: 0;
Expand Down
18 changes: 2 additions & 16 deletions public/modules/ui/burgs-overview.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ function overviewBurgs(settings = { stateId: null, cultureId: null }) {
updateFilter();
updateLockAllIcon();
burgsOverviewAddLines();
window.bulkBars?.mount("burgs", {redraw: burgsOverviewAddLines});
$("#burgsOverview").dialog();

if (modules.overviewBurgs) return;
Expand Down Expand Up @@ -37,7 +38,6 @@ function overviewBurgs(settings = { stateId: null, cultureId: null }) {
uploadFile(this, importBurgNames);
});
ensureEl("burgsLockAll").addEventListener("click", toggleLockAll);
ensureEl("burgsRemoveAll").addEventListener("click", triggerAllBurgsRemove);

function refreshBurgsEditor() {
updateFilter();
Expand Down Expand Up @@ -174,6 +174,7 @@ function overviewBurgs(settings = { stateId: null, cultureId: null }) {
body.querySelectorAll("div > span.icon-trash-empty").forEach(el => el.addEventListener("click", triggerBurgRemove));

applySorting(burgsHeader);
window.bulkBars?.sync("burgs");
}

function getCultureOptions(culture) {
Expand Down Expand Up @@ -576,21 +577,6 @@ function overviewBurgs(settings = { stateId: null, cultureId: null }) {
});
}

function triggerAllBurgsRemove() {
const number = pack.burgs.filter(b => b.i && !b.removed && !b.capital && !b.lock).length;
confirmationDialog({
title: `Remove ${number} burgs`,
message: `
Are you sure you want to remove all <i>unlocked</i> burgs except for capitals?
<br><i>To remove a capital you have to remove its state first</i>`,
confirm: "Remove",
onConfirm: () => {
pack.burgs.filter(b => b.i && !(b.capital || b.lock)).forEach(b => Burgs.remove(b.i));
burgsOverviewAddLines();
}
});
}

function toggleLockAll() {
const activeBurgs = pack.burgs.filter(b => b.i && !b.removed);
const allLocked = activeBurgs.every(burg => burg.lock);
Expand Down
9 changes: 0 additions & 9 deletions public/modules/ui/hotkeys.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,6 @@ function handleKeyup(event) {
else if (key === "7") zoom.scaleTo(svg, 7);
else if (key === "8") zoom.scaleTo(svg, 8);
else if (key === "9") zoom.scaleTo(svg, 9);
else if (ctrl) toggleMode();
}

function allowHotkeys() {
Expand Down Expand Up @@ -167,14 +166,6 @@ function handleBracketSizeChange(code) {
return true;
}

function toggleMode() {
if (zonesRemove?.offsetParent) {
zonesRemove.classList.contains("pressed")
? zonesRemove.classList.remove("pressed")
: zonesRemove.classList.add("pressed");
}
}

function removeElementOnKey() {
const fastDelete = Array.from(document.querySelectorAll("[role='dialog'] .fastDelete")).find(
dialog => dialog.style.display !== "none"
Expand Down
4 changes: 4 additions & 0 deletions public/modules/ui/markers-overview.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ function overviewMarkers() {

addLines();

window.bulkBars?.mount("markers", {redraw: addLines});

$("#markersOverview").dialog({
title: "Markers Overview",
resizable: false,
Expand Down Expand Up @@ -109,6 +111,8 @@ function overviewMarkers() {
markersFooterTotal.innerText = pack.markers.length;

applySorting(markersHeader);

window.bulkBars?.sync("markers");
}

function invertPin() {
Expand Down
11 changes: 11 additions & 0 deletions public/modules/ui/provinces-editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ function editProvinces() {
const body = ensureEl("provincesBodySection");
refreshProvincesEditor();

window.bulkBars?.mount("provinces", {
redraw: () => {
// drawProvinces repaints region fills so a bulk Set color shows on the map
if (layerIsOn("toggleProvinces")) drawProvinces();
if (layerIsOn("toggleBorders")) drawBorders();
refreshProvincesEditor();
}
});

if (modules.editProvinces) return;
modules.editProvinces = true;

Expand Down Expand Up @@ -209,6 +218,8 @@ function editProvinces() {
}
applySorting(provincesHeader);
$("#provincesEditor").dialog({ width: fitContent() });

window.bulkBars?.sync("provinces");
}

function getCapitalOptions(burgs, capital) {
Expand Down
2 changes: 2 additions & 0 deletions public/modules/ui/rivers-overview.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ function overviewRivers() {

const body = ensureEl("riversBody");
riversOverviewAddLines();
window.bulkBars?.mount("rivers", {redraw: riversOverviewAddLines});
$("#riversOverview").dialog();

if (modules.overviewRivers) return;
Expand Down Expand Up @@ -95,6 +96,7 @@ function overviewRivers() {
body.querySelectorAll("div > span.icon-trash-empty").forEach(el => el.on("click", triggerRiverRemove));

applySorting(riversHeader);
window.bulkBars?.sync("rivers");
}

function riverHighlightOn(event) {
Expand Down
3 changes: 3 additions & 0 deletions public/modules/ui/routes-overview.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ function overviewRoutes() {

const body = ensureEl("routesBody");
routesOverviewAddLines();
window.bulkBars?.mount("routes", {redraw: routesOverviewAddLines});
$("#routesOverview").dialog();

if (modules.overviewRoutes) return;
Expand Down Expand Up @@ -83,6 +84,8 @@ function overviewRoutes() {
body.querySelectorAll("div > span.icon-trash-empty").forEach(el => el.on("click", triggerRouteRemove));

applySorting(routesHeader);

window.bulkBars?.sync("routes");
}

function routeHighlightOn(event) {
Expand Down
44 changes: 20 additions & 24 deletions public/modules/ui/zones-editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ function editZones() {
updateFilters();
zonesEditorAddLines();

window.bulkBars?.mount("zones", {
redraw: () => {
// drawZones repaints zone polygons so a bulk Set color shows on the map
drawZones();
zonesEditorAddLines();
}
});

if (modules.editZones) return;
modules.editZones = true;

Expand All @@ -30,7 +38,6 @@ function editZones() {
ensureEl("zonesManuallyCancel").on("click", cancelZonesManualAssignent);
ensureEl("zonesAdd").on("click", addZonesLayer);
ensureEl("zonesExport").on("click", downloadZonesData);
ensureEl("zonesRemove").on("click", e => e.target.classList.toggle("pressed"));

body.on("click", function (ev) {
const line = ev.target.closest("div.states");
Expand Down Expand Up @@ -134,6 +141,8 @@ function editZones() {
togglePercentageMode();
}
$("#zonesEditor").dialog({width: fitContent()});

window.bulkBars?.sync("zones");
}

function zoneHighlightOn(event) {
Expand Down Expand Up @@ -219,7 +228,6 @@ function editZones() {

function dragZoneBrush() {
const radius = +ensureEl("zonesBrush").value;
const eraseMode = ensureEl("zonesRemove").classList.contains("pressed");
const landOnly = ensureEl("zonesBrushLandOnly").checked;

d3.event.on("drag", () => {
Expand All @@ -235,28 +243,16 @@ function editZones() {
const zone = pack.zones.find(z => z.i === zoneId);
if (!zone) return;

if (eraseMode) {
const data = zones
.selectAll("polygon")
.data()
.filter(d => !(d.zoneId === zoneId && selection.includes(d.cell)));
zones
.selectAll("polygon")
.data(data, d => `${d.zoneId}-${d.cell}`)
.exit()
.remove();
} else {
const data = selection.map(cell => ({cell, zoneId, fill: zone.color}));
zones
.selectAll("polygon")
.data(data, d => `${d.zoneId}-${d.cell}`)
.enter()
.append("polygon")
.attr("points", d => getPackPolygon(d.cell))
.attr("fill", d => d.fill)
.attr("data-zone", d => d.zoneId)
.attr("data-cell", d => d.cell);
}
const data = selection.map(cell => ({cell, zoneId, fill: zone.color}));
zones
.selectAll("polygon")
.data(data, d => `${d.zoneId}-${d.cell}`)
.enter()
.append("polygon")
.attr("points", d => getPackPolygon(d.cell))
.attr("fill", d => d.fill)
.attr("data-zone", d => d.zoneId)
.attr("data-cell", d => d.cell);
});
}

Expand Down
Loading