fixes and adding overlays and icon upload

This commit is contained in:
isaprettycoolguy@protonmail.com 2026-04-11 12:07:34 -04:00
parent 31feff8c59
commit 4b141857b3
16 changed files with 1050 additions and 121 deletions

85
.claude/commands/lint.md Normal file
View file

@ -0,0 +1,85 @@
---
description: Run ESLint on the duckmage plugin and fix all issues
allowed-tools: Bash(npm:*), Read, Edit, Grep
---
Current directory: !`pwd`
Run the linter for the duckmage Obsidian plugin:
```
cd /mnt/c/Users/markr/Documents/KB/journal/.obsidian/plugins/duckmage-plugin && npm run lint 2>&1
```
Report the results clearly:
- If there are **no issues**, confirm the plugin is lint-clean.
- If there are **errors or warnings**, list each one with file, line, rule name, and a brief description.
Then fix every issue using the guidance below, edit the affected files directly, and re-run lint to confirm zero issues remain.
---
## Fix guidance (update this section as new patterns are encountered)
### `@typescript-eslint/no-unsafe-argument` / `no-unsafe-call` / `no-unsafe-member-access`
Caused by casting to `any` and then using the result. Typically appears when accessing undocumented Obsidian API events (e.g., `"editor-menu"`).
**Fix**: Replace the `any` cast with a typed local interface that precisely describes the call, then cast through `unknown`:
```typescript
interface WorkspaceWithEditorMenu {
on(name: "editor-menu", callback: (menu: Menu, editor: Editor) => void): EventRef;
}
this.registerEvent(
(this.app.workspace as unknown as WorkspaceWithEditorMenu).on("editor-menu", (menu, editor) => { ... })
);
```
This removes `any` entirely; `unknown as SomeInterface` is fully type-safe.
### `obsidianmd/ui/sentence-case`
UI text must use sentence case: only the first word and proper nouns are capitalised.
**Fix A — change the text** (preferred for ordinary strings):
- Leading symbol counts as first token, so the following word is lowercase:
- `"← Pick icons"``"← pick icons"`
- `"↩ Map mode"``"↩ map mode"`
- Parenthetical range notation: `"(A → Z)"``"(a → z)"`
**Fix B — eslint-disable** for intentional special-case UI text that would look wrong if sentence-cased:
- Short abbreviation labels: `"wt ↑"`, `"a→z"`, `"z→a"` — these are symbols/abbreviations, not sentences
- Settings path references with proper nouns: `'Set … in Settings → "Icons folder" …'`
- The linter flags the **line containing the string literal**, not the `createEl(` call line. For multi-line object args, place the disable comment on the line directly before the `text:` or `attr:` property, inside the object literal:
```typescript
entriesHeadingRow.createEl("button", {
// eslint-disable-next-line obsidianmd/ui/sentence-case
text: "wt ↑",
...
});
```
- For single-line `setText` calls or `.title =` assignments, the disable goes on the line before as usual
### `@typescript-eslint/no-floating-promises`
An async call whose returned Promise is not awaited or handled.
**Fix**: Prefix with `void` to explicitly discard, or `await` inside an async context:
```typescript
void this.plugin.saveSettings();
// or
await this.plugin.saveSettings();
```
### `@typescript-eslint/no-misused-promises`
Passing an async function where a sync one is expected (e.g., event listeners).
**Fix**: Wrap in a void IIFE:
```typescript
el.addEventListener("click", () => { void (async () => { ... })(); });
```
### `@typescript-eslint/restrict-template-expressions`
Using a non-string value inside a template literal without explicit conversion.
**Fix**: Convert explicitly: `${String(value)}` or `${value ?? ""}`.
---
After fixing all issues, run `/rebuild` to confirm the build and tests still pass.

View file

@ -1,7 +1,7 @@
{
"id": "hexmaker",
"name": "Hexmap World Creator",
"version": "1.0.18",
"version": "1.0.19",
"minAppVersion": "1.12.0",
"description": "Create a hex map and a guidebook level wiki for your setting with minimal fuss. Hex crawl worldbuilding toolbox. System agnostic, ready for any TRPG you care to run in it.",
"author": "morkdev",

View file

@ -1,6 +1,6 @@
{
"name": "hexmaker-plugin",
"version": "1.0.18",
"version": "1.0.19",
"description": "A plugin to power hex crawl worldbuilding and running.",
"main": "main.js",
"scripts": {

View file

@ -1,8 +1,10 @@
import { Notice, Plugin, TAbstractFile, TFile, TFolder } from "obsidian";
import { Editor, EventRef, Menu, Notice, Plugin, TAbstractFile, TFile, TFolder } from "obsidian";
import { HexMapView } from "./hex-map/HexMapView";
import { HexTableView } from "./hex-table/HexTableView";
import { RandomTableView } from "./random-tables/RandomTableView";
import { HexmakerSettingTab } from "./HexmakerSettingTab";
import { registerRollerBlock } from "./random-tables/RollerBlock";
import { FileLinkSuggestModal } from "./hex-map/FileLinkSuggestModal";
import {
DEFAULT_PALETTE_NAME,
DEFAULT_SETTINGS,
@ -72,6 +74,39 @@ export default class HexmakerPlugin extends Plugin {
});
this.addSettingTab(new HexmakerSettingTab(this.app, this));
// Register the embedded roller code block processor
registerRollerBlock(this);
// Right-click "Insert table roller" in any editor.
// "editor-menu" is a valid Obsidian workspace event not yet in the official types.
interface WorkspaceWithEditorMenu {
on(name: "editor-menu", callback: (menu: Menu, editor: Editor) => void): EventRef;
}
this.registerEvent(
(this.app.workspace as unknown as WorkspaceWithEditorMenu).on(
"editor-menu",
(menu: Menu, editor: Editor) => {
menu.addItem((item) =>
item
.setTitle("Insert table roller")
.setIcon("dice")
.setSection("insert")
.onClick(() => {
new FileLinkSuggestModal(
this.app,
this,
(file) => {
const block = `\`\`\`duckmage-roller\n${file.path}\n\`\`\``;
editor.replaceSelection(block);
},
normalizeFolder(this.settings.tablesFolder),
).open();
}),
);
},
),
);
interface WithOpenTable {
openTable?(path: string): void;
}
@ -316,20 +351,18 @@ export default class HexmakerPlugin extends Plugin {
: `${regionName}/${x}_${y}.md`;
}
/** Build the Obsidian URI roller link for a table file path. */
buildRollerLink(filePath: string): string {
const vault = encodeURIComponent(this.app.vault.getName());
const file = encodeURIComponent(filePath);
return `[🎲 Open in Hexmaker Roller](obsidian://duckmage-roll?vault=${vault}&file=${file})`;
/** Build an embedded roller code block (path-less — reads the current file). */
buildRollerLink(): string {
return "```duckmage-roller\n```";
}
/** Add a roller link to a table file if it doesn't already have one. */
async ensureRollerLink(filePath: string): Promise<void> {
const file = this.app.vault.getAbstractFileByPath(filePath);
if (!(file instanceof TFile)) return;
const link = this.buildRollerLink(filePath);
const link = this.buildRollerLink();
await this.app.vault.process(file, (content) => {
if (content.includes("obsidian://duckmage-roll")) return content;
if (content.includes("obsidian://duckmage-roll") || content.includes("```duckmage-roller")) return content;
const fmMatch = content.match(/^---\n[\s\S]*?\n---\n/);
const insertAt = fmMatch ? fmMatch[0].length : 0;
return (
@ -342,7 +375,7 @@ export default class HexmakerPlugin extends Plugin {
});
}
/** Add roller links to all existing table files in the tables folder that don't have one. */
/** Add roller blocks to all existing table files in the tables folder that don't have one. */
async ensureAllRollerLinks(): Promise<void> {
const folder = normalizeFolder(this.settings.tablesFolder);
const prefix = folder ? folder + "/" : "";
@ -353,9 +386,9 @@ export default class HexmakerPlugin extends Plugin {
let count = 0;
for (const file of files) {
let added = false;
const link = this.buildRollerLink(file.path);
const link = this.buildRollerLink();
await this.app.vault.process(file, (content) => {
if (content.includes("obsidian://duckmage-roll")) return content;
if (content.includes("obsidian://duckmage-roll") || content.includes("```duckmage-roller")) return content;
added = true;
const fmMatch = content.match(/^---\n[\s\S]*?\n---\n/);
const insertAt = fmMatch ? fmMatch[0].length : 0;
@ -399,7 +432,7 @@ export default class HexmakerPlugin extends Plugin {
"roll-filter": false,
"encounter-filter": false,
},
this.buildRollerLink(path),
this.buildRollerLink(),
),
);
} catch {
@ -465,7 +498,7 @@ export default class HexmakerPlugin extends Plugin {
"roll-filter": false,
"encounter-filter": false,
},
this.buildRollerLink(path),
this.buildRollerLink(),
),
);
} catch {

View file

@ -324,4 +324,5 @@ export const DEFAULT_SETTINGS: HexmakerPluginSettings = {
encounterTableExcludedFolders: ["terrain"],
defaultRegion: "default",
workflowsFolder: "",
hiddenIcons: [],
};

View file

@ -7,13 +7,15 @@ export class FileLinkSuggestModal extends SuggestModal<TFile> {
app: App,
private plugin: HexmakerPlugin,
private onChoose: (file: TFile) => void,
private folderOverride?: string,
) {
super(app);
this.setPlaceholder("Search for a file to link...");
}
getSuggestions(query: string): TFile[] {
const rootFolder = normalizeFolder(this.plugin.settings.worldFolder?.trim() ?? "");
const rootFolder = this.folderOverride
?? normalizeFolder(this.plugin.settings.worldFolder?.trim() ?? "");
let files: TFile[];
if (rootFolder) {
files = this.app.vault.getFiles().filter(
@ -23,7 +25,7 @@ export class FileLinkSuggestModal extends SuggestModal<TFile> {
files = this.app.vault.getFiles();
}
return files
.filter(f => f.basename.toLowerCase().contains(query.toLowerCase()))
.filter(f => !f.basename.startsWith("_") && f.basename.toLowerCase().contains(query.toLowerCase()))
.sort((a, b) => a.basename.localeCompare(b.basename));
}

View file

@ -30,6 +30,7 @@ import {
import { GotoHexModal } from "./GotoHexModal";
import { HexHelpModal } from "./HexHelpModal";
import { FolderTreePickerModal } from "./FolderTreePickerModal";
import { DrawingToolPanel, OverlayPanel } from "./HexSidePanel";
type TerrainUndoEntry = {
x: number;
@ -75,6 +76,8 @@ export class HexMapView extends ItemView {
private factionLinkBtnLabel: HTMLSpanElement | null = null;
private paintFactionPath: string | null = null;
private swapBtn: HTMLButtonElement | null = null;
private deselToolBtn: HTMLButtonElement | null = null;
private overlayPanel: OverlayPanel | null = null;
private swapSource: { x: number; y: number } | null = null;
private swapDest: { x: number; y: number } | null = null;
// The last-clicked hex key and the specific chain being extended
@ -180,6 +183,8 @@ export class HexMapView extends ItemView {
panStartY = 0;
let isTerrainPainting = false;
let lastPaintedKey: string | null = null;
let isRightDragging = false;
let rightDragMoved = false;
this.registerDomEvent(contentEl, "mousedown", (e: MouseEvent) => {
// Middle click: always pan
@ -194,6 +199,19 @@ export class HexMapView extends ItemView {
this.viewportEl?.addClass("is-dragging");
return;
}
// Right click with no active tool: pan (contextmenu suppressed if drag occurs)
if (e.button === 2 && this.drawingMode === null) {
isRightDragging = true;
rightDragMoved = false;
isDragging = true;
hasDragged = false;
dragStartX = e.clientX;
dragStartY = e.clientY;
panStartX = this.panX;
panStartY = this.panY;
this.viewportEl?.addClass("is-dragging");
return;
}
if (e.button !== 0) return;
if (this.drawingMode === "terrain" || this.drawingMode === "icon") {
isTerrainPainting = true;
@ -267,8 +285,10 @@ export class HexMapView extends ItemView {
if (!isDragging) return;
const dx = e.clientX - dragStartX;
const dy = e.clientY - dragStartY;
if (!hasDragged && (Math.abs(dx) > 4 || Math.abs(dy) > 4))
if (!hasDragged && (Math.abs(dx) > 4 || Math.abs(dy) > 4)) {
hasDragged = true;
if (isRightDragging) rightDragMoved = true;
}
if (hasDragged) {
this.panX = panStartX + dx;
this.panY = panStartY + dy;
@ -282,6 +302,7 @@ export class HexMapView extends ItemView {
isTerrainPainting = false;
lastPaintedKey = null;
isDragging = false;
isRightDragging = false;
this.viewportEl?.removeClass("is-dragging");
});
@ -305,6 +326,12 @@ export class HexMapView extends ItemView {
contentEl,
"contextmenu",
(e: MouseEvent) => {
if (rightDragMoved) {
e.preventDefault();
e.stopPropagation();
rightDragMoved = false;
return;
}
if (this.drawingMode === null) return;
const onHex = (e.target as HTMLElement).closest(".duckmage-hex");
if (onHex && this.drawingMode === "path") return;
@ -446,20 +473,18 @@ export class HexMapView extends ItemView {
});
helpBtn.addEventListener("click", () => new HexHelpModal(this.app).open());
// Toggle button — always visible, collapses/restores only the drawing tools
const toggleBtn = controlsEl.createEl("button", {
cls: "duckmage-toolbar-toggle-btn",
title: "Hide tools",
});
toggleBtn.setText("≡");
toggleBtn.addEventListener("click", () => {
const collapsed = controlsEl.hasClass("duckmage-toolbar-collapsed");
controlsEl.toggleClass("duckmage-toolbar-collapsed", !collapsed);
toggleBtn.title = collapsed ? "Hide tools" : "Show tools";
});
// Drawing toolbar column — right side, collapses when toggled
this.createDrawingToolbar(controlsEl);
// Side panels — drawing tools (pencil) + overlays (layers), mutually exclusive
const toolsPanel = new DrawingToolPanel(controlsEl, (panel) =>
this.buildDrawingToolbarContent(panel),
);
this.overlayPanel = new OverlayPanel(
controlsEl,
this.plugin,
() => this.viewportEl,
() => this.getActiveRegion(),
);
toolsPanel.onBeforeOpen = () => this.overlayPanel?.close();
this.overlayPanel.onBeforeOpen = () => toolsPanel.close();
// Saving indicator — appears while background writes are in flight
this.savingIndicatorEl = controlsEl.createEl("span", {
@ -551,8 +576,26 @@ export class HexMapView extends ItemView {
}
}
private createDrawingToolbar(container: HTMLElement): void {
const toolbar = container.createDiv({ cls: "duckmage-draw-toolbar" });
private buildDrawingToolbarContent(toolbar: HTMLElement): void {
// "Deselect tool" button — visible only when a tool is active
this.deselToolBtn = toolbar.createEl("button", {
cls: "duckmage-desel-tool-btn",
text: "↩ map mode",
});
this.deselToolBtn.hide();
this.deselToolBtn.addEventListener("click", () => {
if (this.drawingMode === "terrain") this.exitTerrainMode();
else if (this.drawingMode === "icon") this.exitIconMode();
else if (this.drawingMode === "tableLink") this.exitTableLinkMode();
else if (this.drawingMode === "factionLink") this.exitFactionLinkMode();
else if (this.drawingMode === "swap") this.exitSwapMode();
else if (this.drawingMode === "path") {
this.exitPathMode();
this.drawingMode = null;
this.updateToolbarButtonStates();
this.updatePathOverlay();
}
});
const centerHexBtn = toolbar.createEl("button", {
cls: "duckmage-draw-btn duckmage-center-hex-btn",
@ -622,6 +665,8 @@ export class HexMapView extends ItemView {
}
private handleTerrainButton(): void {
if (this.drawingMode === "terrain") { this.exitTerrainMode(); return; }
// Show crosshair on the viewport while the picker is open
this.viewportEl?.addClass("duckmage-terrain-picking");
@ -668,6 +713,7 @@ export class HexMapView extends ItemView {
}
private handleIconButton(): void {
if (this.drawingMode === "icon") { this.exitIconMode(); return; }
new IconPickerModal(this.app, this.plugin, (iconName: string | null) => {
this.drawingMode = "icon";
this.paintIconName = iconName;
@ -685,6 +731,7 @@ export class HexMapView extends ItemView {
}
private handleTableLinkButton(): void {
if (this.drawingMode === "tableLink") { this.exitTableLinkMode(); return; }
new FolderTreePickerModal(
this.app,
this.plugin,
@ -713,6 +760,7 @@ export class HexMapView extends ItemView {
}
private handleFactionLinkButton(): void {
if (this.drawingMode === "factionLink") { this.exitFactionLinkMode(); return; }
new FolderTreePickerModal(
this.app,
this.plugin,
@ -926,6 +974,8 @@ export class HexMapView extends ItemView {
}
private updateToolbarButtonStates(): void {
if (this.drawingMode !== null) this.deselToolBtn?.show();
else this.deselToolBtn?.hide();
this.pathToolbarBtn?.toggleClass("is-active", this.drawingMode === "path");
// Update path button swatch color to show active type
if (this.pathBtnSwatch) {
@ -1112,6 +1162,10 @@ export class HexMapView extends ItemView {
);
const region = this.getActiveRegion();
// Sync overlay checkboxes and CSS classes to the active region's saved state
this.overlayPanel?.syncToRegion();
const { cols, rows } = region.gridSize;
const { x: ox, y: oy } = region.gridOffset;
const hexBase = normalizeFolder(this.plugin.settings.hexFolder);
@ -1147,19 +1201,36 @@ export class HexMapView extends ItemView {
const iconOverride = iconOverrides?.has(path)
? iconOverrides.get(path)!
: getIconOverrideFromFile(this.app, path);
const iconToShow = iconOverride ?? terrainEntry?.icon;
if (iconToShow) {
const iconColor = iconOverride ? undefined : terrainEntry?.iconColor;
if (iconOverride) {
// Render terrain icon as hidden fallback — shown by CSS when overrides are off
if (terrainEntry?.icon) {
createIconEl(
hexEl,
getIconUrl(this.plugin, terrainEntry.icon),
terrainEntry.name,
terrainEntry.iconColor,
"duckmage-hex-terrain-icon",
);
}
// Render the override icon (primary, visible by default)
createIconEl(
hexEl,
getIconUrl(this.plugin, iconToShow),
getIconUrl(this.plugin, iconOverride),
terrainEntry?.name ?? "",
iconColor,
undefined,
"duckmage-hex-icon duckmage-hex-override-icon",
);
// Tag the hex so the SVG overlay can elevate this icon above roads/rivers
hexEl.dataset.iconOverride = iconOverride;
} else if (terrainEntry?.icon) {
createIconEl(
hexEl,
getIconUrl(this.plugin, terrainEntry.icon),
terrainEntry.name,
terrainEntry.iconColor,
"duckmage-hex-icon",
);
}
// Tag override icons so the SVG overlay can elevate them above roads/rivers
if (iconOverride) hexEl.dataset.iconOverride = iconOverride;
if (this.selectedHex?.x === x && this.selectedHex?.y === y)
hexEl.addClass("is-selected");
@ -1789,6 +1860,13 @@ export class HexMapView extends ItemView {
}
private handlePathButton(): void {
if (this.drawingMode === "path") {
this.exitPathMode();
this.drawingMode = null;
this.updateToolbarButtonStates();
this.updatePathOverlay();
return;
}
new PathPickerModal(
this.app,
this.plugin,
@ -2080,6 +2158,7 @@ export class HexMapView extends ItemView {
imgEl.setAttribute("height", String(iconH));
imgEl.setAttribute("href", getIconUrl(this.plugin, iconName));
imgEl.setAttribute("opacity", "0.75");
imgEl.setAttribute("class", "duckmage-svg-icon-override");
svg.appendChild(imgEl);
});
@ -2111,6 +2190,7 @@ export class HexMapView extends ItemView {
textEl.setAttribute("fill", "var(--text-muted)");
}
textEl.setAttribute("pointer-events", "none");
textEl.setAttribute("class", "duckmage-svg-coord-label");
textEl.textContent = `${x},${y}`;
svg.appendChild(textEl);
});

163
src/hex-map/HexSidePanel.ts Normal file
View file

@ -0,0 +1,163 @@
import { setIcon } from "obsidian";
import type HexmakerPlugin from "../HexmakerPlugin";
import type { RegionData } from "../types";
// ── Abstract base ────────────────────────────────────────────────────────────
export abstract class HexSidePanel {
protected panelEl: HTMLDivElement;
private toggleBtn: HTMLButtonElement;
private _isOpen = false;
/** Called just before this panel opens — used for mutual exclusion. */
public onBeforeOpen?: () => void;
constructor(
container: HTMLElement,
iconName: string,
rightOffset: number,
title: string,
) {
this.toggleBtn = container.createEl("button", {
cls: "duckmage-panel-toggle-btn",
attr: { title },
});
this.toggleBtn.style.right = `${rightOffset}px`;
setIcon(this.toggleBtn, iconName);
this.toggleBtn.addEventListener("click", () => this.toggle());
this.panelEl = container.createDiv({ cls: "duckmage-side-panel" });
this.panelEl.style.right = `${rightOffset - 4}px`;
this.panelEl.hide();
// Subclasses must call this.buildPanel(this.panelEl) after super() returns,
// once their own fields are initialised.
}
protected abstract buildPanel(panel: HTMLDivElement): void;
toggle(): void {
if (this._isOpen) this.close();
else this.open();
}
open(): void {
this.onBeforeOpen?.();
this._isOpen = true;
this.panelEl.show();
this.toggleBtn.addClass("is-active");
}
close(): void {
this._isOpen = false;
this.panelEl.hide();
this.toggleBtn.removeClass("is-active");
}
get isOpen(): boolean {
return this._isOpen;
}
}
// ── Drawing tool panel ───────────────────────────────────────────────────────
/** Callback signature the view passes in to build the drawing toolbar content. */
export type DrawingToolbarBuilder = (panel: HTMLDivElement) => void;
export class DrawingToolPanel extends HexSidePanel {
private builder: DrawingToolbarBuilder;
constructor(container: HTMLElement, builder: DrawingToolbarBuilder) {
super(container, "pencil", 8, "Drawing tools");
this.builder = builder;
this.buildPanel(this.panelEl);
}
protected buildPanel(panel: HTMLDivElement): void {
this.builder(panel);
}
}
// ── Overlay panel ────────────────────────────────────────────────────────────
export type OverlayKey = "showCoords" | "showTerrainIcons" | "showIconOverrides";
interface OverlayOption {
key: OverlayKey;
label: string;
cssClass: string;
}
const OVERLAY_OPTIONS: OverlayOption[] = [
{ key: "showCoords", label: "Show coordinates", cssClass: "duckmage-hide-coords" },
{ key: "showTerrainIcons", label: "Show terrain icons", cssClass: "duckmage-hide-terrain-icons" },
{ key: "showIconOverrides", label: "Show icon overrides",cssClass: "duckmage-hide-icon-overrides" },
];
export class OverlayPanel extends HexSidePanel {
private plugin: HexmakerPlugin;
private getViewportEl: () => HTMLElement | null;
private getActiveRegion: () => RegionData;
private checkboxes = new Map<OverlayKey, HTMLInputElement>();
constructor(
container: HTMLElement,
plugin: HexmakerPlugin,
getViewportEl: () => HTMLElement | null,
getActiveRegion: () => RegionData,
) {
super(container, "layers", 44, "Map overlays");
this.plugin = plugin;
this.getViewportEl = getViewportEl;
this.getActiveRegion = getActiveRegion;
this.buildPanel(this.panelEl);
}
protected buildPanel(panel: HTMLDivElement): void {
for (const opt of OVERLAY_OPTIONS) {
const row = panel.createDiv({ cls: "duckmage-overlay-row" });
const cb = document.createElement("input");
cb.type = "checkbox";
cb.checked = true; // default — refreshed in syncToRegion()
row.appendChild(cb);
this.checkboxes.set(opt.key, cb);
const label = row.createSpan({ text: opt.label, cls: "duckmage-overlay-label" });
const apply = () => {
const region = this.getActiveRegion();
region[opt.key] = cb.checked;
void this.plugin.saveSettings();
this.applyClass(opt, cb.checked);
};
cb.addEventListener("change", apply);
label.addEventListener("click", () => {
cb.checked = !cb.checked;
apply();
});
}
}
/** Read the current region's saved state and apply it to the viewport + checkboxes. */
syncToRegion(): void {
const region = this.getActiveRegion();
for (const opt of OVERLAY_OPTIONS) {
// undefined → true (backwards compat)
const value = region[opt.key];
const show = value === undefined ? true : Boolean(value);
const cb = this.checkboxes.get(opt.key);
if (cb) cb.checked = show;
this.applyClass(opt, show);
}
}
private applyClass(opt: OverlayOption, show: boolean): void {
const vp = this.getViewportEl();
if (!vp) return;
if (show) {
vp.removeClass(opt.cssClass);
} else {
vp.addClass(opt.cssClass);
}
}
}

View file

@ -1,52 +1,181 @@
import { App } from "obsidian";
import { HexmakerModal } from "../HexmakerModal";
import type HexmakerPlugin from "../HexmakerPlugin";
import { getIconUrl } from "../utils";
export class IconPickerModal extends HexmakerModal {
constructor(
app: App,
private plugin: HexmakerPlugin,
private onSelect: (iconName: string | null) => void,
) {
super(app);
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("duckmage-hex-editor");
this.makeDraggable();
contentEl.createEl("h2", { text: "Paint icon" });
const section = contentEl.createDiv({ cls: "duckmage-editor-section" });
const grid = section.createDiv({ cls: "duckmage-icon-picker" });
// Remove icon option
const clearBtn = grid.createDiv({ cls: "duckmage-icon-option" });
clearBtn.createDiv({ cls: "duckmage-icon-preview duckmage-icon-preview-clear" });
clearBtn.createSpan({ text: "Remove", cls: "duckmage-icon-option-name" });
clearBtn.addEventListener("click", () => {
this.onSelect(null);
this.close();
});
for (const icon of this.plugin.availableIcons) {
const label = icon.replace(/^bw-/, "").replace(/\.(png|jpg|jpeg|gif|svg|webp)$/i, "").replace(/-/g, " ");
const btn = grid.createDiv({ cls: "duckmage-icon-option" });
const preview = btn.createDiv({ cls: "duckmage-icon-preview" });
const img = preview.createEl("img", { cls: "duckmage-icon-preview-img" });
img.src = getIconUrl(this.plugin, icon);
img.alt = label;
btn.createSpan({ text: label, cls: "duckmage-icon-option-name" });
btn.addEventListener("click", () => {
this.onSelect(icon);
this.close();
});
}
}
onClose(): void {
this.contentEl.empty();
}
}
import { App, TFile, setIcon } from "obsidian";
import { HexmakerModal } from "../HexmakerModal";
import type HexmakerPlugin from "../HexmakerPlugin";
import { getIconUrl, normalizeFolder } from "../utils";
export class IconPickerModal extends HexmakerModal {
private manageMode = false;
constructor(
app: App,
private plugin: HexmakerPlugin,
private onSelect: (iconName: string | null) => void,
) {
super(app);
}
onOpen(): void {
this.contentEl.addClass("duckmage-hex-editor");
this.makeDraggable();
this.render();
}
onClose(): void {
this.contentEl.empty();
}
private render(): void {
const { contentEl } = this;
contentEl.empty();
// ── Header ────────────────────────────────────────────────────────────
const header = contentEl.createDiv({ cls: "duckmage-icon-picker-header" });
header.createEl("h2", { text: "Paint icon" });
const manageBtn = header.createEl("button", {
cls: "duckmage-icon-manage-btn",
text: this.manageMode ? "← pick icons" : "Manage icons",
});
manageBtn.addEventListener("click", () => {
this.manageMode = !this.manageMode;
this.render();
});
// ── Add section (manage mode only) ────────────────────────────────────
if (this.manageMode) {
this.renderAddSection(contentEl);
}
// ── Icon grid ─────────────────────────────────────────────────────────
const section = contentEl.createDiv({ cls: "duckmage-editor-section" });
const grid = section.createDiv({ cls: "duckmage-icon-picker" });
const hidden = new Set(this.plugin.settings.hiddenIcons ?? []);
if (!this.manageMode) {
// Remove icon option
const clearBtn = grid.createDiv({ cls: "duckmage-icon-option" });
clearBtn.createDiv({ cls: "duckmage-icon-preview duckmage-icon-preview-clear" });
clearBtn.createSpan({ text: "Remove", cls: "duckmage-icon-option-name" });
clearBtn.addEventListener("click", () => {
this.onSelect(null);
this.close();
});
}
const icons = this.manageMode
? this.plugin.availableIcons
: this.plugin.availableIcons.filter((i) => !hidden.has(i));
for (const icon of icons) {
const isHidden = hidden.has(icon);
const label = icon
.replace(/^bw-/, "")
.replace(/\.(png|jpg|jpeg|gif|svg|webp)$/i, "")
.replace(/-/g, " ");
const btn = grid.createDiv({
cls: `duckmage-icon-option${isHidden ? " duckmage-icon-hidden" : ""}`,
});
const preview = btn.createDiv({ cls: "duckmage-icon-preview" });
const img = preview.createEl("img", { cls: "duckmage-icon-preview-img" });
img.src = getIconUrl(this.plugin, icon);
img.alt = label;
btn.createSpan({ text: label, cls: "duckmage-icon-option-name" });
if (this.manageMode) {
const hideBtn = btn.createEl("button", {
cls: "duckmage-icon-hide-btn",
attr: { title: isHidden ? "Show icon" : "Hide icon" },
});
setIcon(hideBtn, isHidden ? "eye" : "eye-off");
hideBtn.addEventListener("click", (e) => {
e.stopPropagation();
const list = this.plugin.settings.hiddenIcons ?? [];
this.plugin.settings.hiddenIcons = isHidden
? list.filter((h) => h !== icon)
: [...list, icon];
void this.plugin.saveSettings().then(() => this.render());
});
} else {
btn.addEventListener("click", () => {
this.onSelect(icon);
this.close();
});
}
}
}
private renderAddSection(container: HTMLElement): void {
const section = container.createDiv({ cls: "duckmage-icon-add-section" });
const iconsFolder = normalizeFolder(this.plugin.settings.iconsFolder ?? "");
if (!iconsFolder) {
section.createEl("p", {
// eslint-disable-next-line obsidianmd/ui/sentence-case
text: 'Set an icons folder in Settings → "Icons folder" to upload custom icons.',
cls: "duckmage-icon-add-warning",
});
return;
}
const dropZone = section.createDiv({ cls: "duckmage-icon-drop-zone" });
dropZone.createSpan({ text: "Drag & drop images here, or click to browse" });
dropZone.createEl("br");
dropZone.createEl("small", {
text: "Recommended: PNG, 64128 px square · PNG / JPG / GIF / SVG / WebP",
});
const statusEl = section.createEl("p", { cls: "duckmage-icon-add-status" });
const handleFiles = async (fileList: FileList | File[]): Promise<void> => {
const files = Array.from(fileList).filter((f) =>
/\.(png|jpg|jpeg|gif|svg|webp)$/i.test(f.name),
);
if (!files.length) {
statusEl.setText("No supported image files found.");
return;
}
let added = 0;
for (const file of files) {
const targetPath = `${iconsFolder}/${file.name}`;
const buffer = await file.arrayBuffer();
const existing = this.plugin.app.vault.getAbstractFileByPath(targetPath);
if (existing instanceof TFile) {
await this.plugin.app.vault.modifyBinary(existing, buffer);
} else {
await this.plugin.app.vault.createBinary(targetPath, buffer);
}
added++;
}
this.plugin.loadAvailableIcons();
statusEl.setText(`Added ${added} icon${added === 1 ? "" : "s"}.`);
setTimeout(() => this.render(), 800);
};
// Hidden file input
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept = ".png,.jpg,.jpeg,.gif,.svg,.webp";
fileInput.multiple = true;
fileInput.addEventListener("change", () => {
void handleFiles(fileInput.files ?? []);
});
// Drag-and-drop
dropZone.addEventListener("dragover", (e) => {
e.preventDefault();
dropZone.addClass("is-dragover");
});
dropZone.addEventListener("dragleave", () => {
dropZone.removeClass("is-dragover");
});
dropZone.addEventListener("drop", (e) => {
e.preventDefault();
dropZone.removeClass("is-dragover");
void handleFiles(e.dataTransfer?.files ?? []);
});
// Click anywhere on drop zone to browse
dropZone.addEventListener("click", () => fileInput.click());
}
}

View file

@ -113,6 +113,51 @@ export class RandomTableEditorModal extends HexmakerModal {
text: "Entries",
cls: "duckmage-table-editor-heading",
});
// ── Sort buttons ──────────────────────────────────────────────────
let weightSortAsc = false;
let alphaSortAsc = false;
const stripLeadingSpecial = (s: string) =>
s.replace(/^[^a-zA-Z0-9]+/, "").toLowerCase();
const weightSortBtn = entriesHeadingRow.createEl("button", {
// eslint-disable-next-line obsidianmd/ui/sentence-case
text: "wt ↑",
cls: "duckmage-table-editor-add-one-btn",
attr: { title: "Sort entries by weight (low → high)" },
});
weightSortBtn.addEventListener("click", () => {
weightSortAsc = !weightSortAsc;
entries.sort((a, b) =>
weightSortAsc ? a.weight - b.weight : b.weight - a.weight,
);
weightSortBtn.setText(weightSortAsc ? "wt ↓" : "wt ↑");
weightSortBtn.title = weightSortAsc
? "Sort entries by weight (high → low)"
: "Sort entries by weight (low → high)";
renderRows();
});
const alphaSortBtn = entriesHeadingRow.createEl("button", {
// eslint-disable-next-line obsidianmd/ui/sentence-case
text: "a→z",
cls: "duckmage-table-editor-add-one-btn",
attr: { title: "Sort entries alphabetically (a → z)" },
});
alphaSortBtn.addEventListener("click", () => {
alphaSortAsc = !alphaSortAsc;
entries.sort((a, b) => {
const ka = stripLeadingSpecial(a.result);
const kb = stripLeadingSpecial(b.result);
return alphaSortAsc ? ka.localeCompare(kb) : kb.localeCompare(ka);
});
alphaSortBtn.setText(alphaSortAsc ? "z→a" : "a→z");
alphaSortBtn.title = alphaSortAsc
? "Sort entries alphabetically (z → a)"
: "Sort entries alphabetically (a → z)";
renderRows();
});
const subOneToAllBtn = entriesHeadingRow.createEl("button", {
text: "-1",
cls: "duckmage-table-editor-add-one-btn",
@ -460,11 +505,14 @@ export class RandomTableEditorModal extends HexmakerModal {
await this.retireDeletedEntries(originalResults, entries, linkedFolder);
await this.syncLinkedFolder(entries, linkedFolder);
}
// Rebuild preamble: preserve roller link, replace user description
const rollerLinkMatch = preamble.match(
/\[.*?\]\(obsidian:\/\/duckmage-roll[^)]*\)/,
);
const rollerLink = rollerLinkMatch ? rollerLinkMatch[0] : "";
// Rebuild preamble: preserve or insert roller block, replace user description
const rollerLinkMatch =
preamble.match(/```duckmage-roller[\s\S]*?```/) ??
preamble.match(/\[.*?\]\(obsidian:\/\/duckmage-roll[^)]*\)/);
// Always include a roller — upgrade old URI links to the code block form
const rollerLink = rollerLinkMatch?.index !== undefined && /```duckmage-roller/.test(rollerLinkMatch[0])
? rollerLinkMatch[0]
: "```duckmage-roller\n```";
const newDescription = descInput.value.trim();
const newPreamble = [rollerLink, newDescription]
.filter(Boolean)

View file

@ -27,7 +27,7 @@ import { Frontmatter } from "../frontmatter";
import { buildTree, type TreeNode } from "./FolderTree";
import { ConfirmDeleteModal } from "./ConfirmDeleteModal";
const DIE_OPTIONS = [
export const DIE_OPTIONS = [
{ label: "— no die —", value: 0 },
{ label: "d4", value: 4 },
{ label: "d6", value: 6 },
@ -320,13 +320,13 @@ export class RandomTableView extends ItemView {
f.parent?.path === srcFolder && !f.basename.startsWith("_"),
)
.sort((a, b) => a.basename.localeCompare(b.basename));
const rollerLink = this.plugin.buildRollerLink(newPath);
const rollerLink = this.plugin.buildRollerLink();
const entryRows = folderFiles
.map((f) => `| [[${f.basename}]] | 1 |`)
.join("\n");
content = `---\ndice: ${this.plugin.settings.defaultTableDice}\nlinkedFolder: "[[${srcFolder}]]"\n---\n\n${rollerLink}\n\n| Result | Weight |\n|--------|--------|\n${entryRows || "| | 1 |"}\n`;
} else {
const rollerLink = this.plugin.buildRollerLink(newPath);
const rollerLink = this.plugin.buildRollerLink();
content = makeTableTemplate(
this.plugin.settings.defaultTableDice,
undefined,

View file

@ -0,0 +1,131 @@
import { TFile } from "obsidian";
import type HexmakerPlugin from "../HexmakerPlugin";
import { parseRandomTable, rollOnTable } from "./randomTable";
import { DIE_OPTIONS } from "./RandomTableView";
/**
* Registers the `duckmage-roller` fenced code block processor.
*
* Block format:
* ```duckmage-roller
* path/to/table.md optional; omit to roll the current note itself
* ```
*
* Renders a compact interactive roller widget in reading / preview mode.
*/
export function registerRollerBlock(plugin: HexmakerPlugin): void {
plugin.registerMarkdownCodeBlockProcessor(
"duckmage-roller",
async (source, el, ctx) => {
const targetPath = source.trim() || ctx.sourcePath;
const file = plugin.app.vault.getAbstractFileByPath(targetPath);
if (!(file instanceof TFile)) {
el.createDiv({
cls: "duckmage-rt-empty",
text: `Table not found: "${targetPath}"`,
});
return;
}
// Read initial table to seed the die selector
const initialContent = await plugin.app.vault.read(file);
const initialTable = parseRandomTable(initialContent);
// Local die state — cosmetic, not saved to file
let currentDice = initialTable.dice;
// ── Widget container ─────────────────────────────────────────────
const block = el.createDiv({ cls: "duckmage-roller-block" });
// Header: title + die selector
const header = block.createDiv({ cls: "duckmage-roller-header" });
header.createSpan({
cls: "duckmage-roller-title",
text: `🎲 ${file.basename}`,
});
const dieSelect = header.createEl("select", {
cls: "duckmage-roller-die",
});
for (const opt of DIE_OPTIONS) {
const o = dieSelect.createEl("option", {
value: String(opt.value),
text: opt.label,
});
if (opt.value === currentDice) o.selected = true;
}
dieSelect.addEventListener("change", () => {
currentDice = parseInt(dieSelect.value, 10);
});
// Roll button
const rollBtn = block.createEl("button", {
text: "Roll",
cls: "duckmage-roller-btn mod-cta",
});
// Result area — hidden until first roll (reuses modal CSS classes)
const resultBox = block.createDiv({ cls: "duckmage-roll-result" });
resultBox.hide();
const resultTextarea = resultBox.createEl("textarea", {
cls: "duckmage-roll-result-textarea",
});
resultTextarea.readOnly = true;
const resultBtns = resultBox.createDiv({ cls: "duckmage-roll-result-btns" });
const copyBtn = resultBtns.createEl("button", {
text: "Copy",
cls: "mod-cta",
});
copyBtn.addEventListener("click", () => {
void navigator.clipboard.writeText(resultTextarea.value);
copyBtn.setText("Copied!");
setTimeout(() => copyBtn.setText("Copy"), 1200);
});
// History: scrollable list, each item individually copyable
const historyEl = block.createDiv({ cls: "duckmage-roller-history" });
const history: string[] = [];
const renderHistory = () => {
historyEl.empty();
for (const item of history) {
const row = historyEl.createDiv({ cls: "duckmage-roller-history-item" });
row.createSpan({ cls: "duckmage-roller-history-text", text: item });
const hCopyBtn = row.createEl("button", {
text: "⎘",
cls: "duckmage-roller-history-copy",
attr: { title: "Copy" },
});
hCopyBtn.addEventListener("click", () => {
void navigator.clipboard.writeText(item);
hCopyBtn.setText("✓");
setTimeout(() => hCopyBtn.setText("⎘"), 1000);
});
}
};
// Roll: re-reads the file each time so edits to the table are reflected live
rollBtn.addEventListener("click", () => {
void (async () => {
const content = await plugin.app.vault.read(file);
const table = parseRandomTable(content);
const rolled = rollOnTable(table);
if (!rolled) return;
// For linked-folder / isLink entries strip the path prefix
let display = rolled.result;
if (rolled.isLink || table.linkedFolder) {
display = display.split("/").pop() ?? display;
}
resultTextarea.value = display;
resultBox.show();
history.unshift(display);
if (history.length > 10) history.pop();
renderHistory();
})();
});
},
);
}

View file

@ -38,6 +38,7 @@ export function parseRandomTable(content: string): RandomTable {
: "";
const descriptionRaw = preambleText
.replace(/\[.*?\]\(obsidian:\/\/duckmage-roll[^)]*\)/g, "")
.replace(/```duckmage-roller[\s\S]*?```/g, "")
.trim();
const description = descriptionRaw || undefined;

View file

@ -20,6 +20,9 @@ export interface RegionData {
gridSize: { cols: number; rows: number };
gridOffset: { x: number; y: number };
pathChains: PathChain[];
showCoords?: boolean; // undefined = true (backwards-compatible)
showTerrainIcons?: boolean; // undefined = true
showIconOverrides?: boolean; // undefined = true
}
export interface TerrainPalette {
@ -61,6 +64,7 @@ export interface HexmakerPluginSettings {
encounterTableExcludedFolders: string[];
defaultRegion: string;
workflowsFolder: string;
hiddenIcons: string[];
}
export const LINK_SECTIONS = ["Towns", "Dungeons", "Features", "Quests", "Factions", "Encounters Table"] as const;

View file

@ -27,20 +27,13 @@
pointer-events: auto;
}
/* Collapse drawing tools when toggled */
.duckmage-toolbar-collapsed .duckmage-draw-toolbar {
display: none;
}
/* ── Side panel toggle buttons ────────────────────────────────────────────── */
/* Toggle button — always visible, top-right corner */
.duckmage-toolbar-toggle-btn {
.duckmage-panel-toggle-btn {
position: absolute;
top: 8px;
right: 8px;
width: 28px;
height: 28px;
font-size: 1.1em;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
@ -53,12 +46,80 @@
pointer-events: auto;
}
.duckmage-toolbar-toggle-btn:hover {
.duckmage-panel-toggle-btn svg {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.duckmage-panel-toggle-btn:hover {
opacity: 1;
color: var(--text-normal);
border-color: var(--interactive-accent);
}
.duckmage-panel-toggle-btn.is-active {
opacity: 1;
border-color: var(--interactive-accent);
color: var(--interactive-accent);
}
/* ── Side panel container ─────────────────────────────────────────────────── */
.duckmage-side-panel {
position: absolute;
top: 44px;
background: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
border-radius: 6px;
padding: 8px;
display: flex;
flex-direction: column;
gap: 6px;
z-index: 10;
min-width: 160px;
pointer-events: auto;
}
/* ── Overlay panel rows ───────────────────────────────────────────────────── */
.duckmage-overlay-row {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.9em;
white-space: nowrap;
}
.duckmage-overlay-row input[type="checkbox"] {
flex-shrink: 0;
cursor: pointer;
}
.duckmage-overlay-label {
cursor: pointer;
color: var(--text-normal);
user-select: none;
}
/* ── Overlay hide rules ───────────────────────────────────────────────────── */
/* Terrain fallback icon — hidden by default; shown by CSS when override icons are off */
.duckmage-hex-terrain-icon { display: none; }
/* Coordinates */
.duckmage-hide-coords .duckmage-hex-label { visibility: hidden; }
.duckmage-hide-coords .duckmage-svg-coord-label { display: none; }
/* Terrain icons off: hide plain terrain icons and suppress the fallback */
.duckmage-hide-terrain-icons .duckmage-hex:not([data-icon-override]) .duckmage-hex-icon { display: none; }
.duckmage-hide-terrain-icons .duckmage-hex-terrain-icon { display: none !important; }
/* Icon overrides off: hide override icon, reveal terrain fallback */
.duckmage-hide-icon-overrides .duckmage-hex-override-icon { display: none; }
.duckmage-hide-icon-overrides .duckmage-hex[data-icon-override] .duckmage-hex-terrain-icon { display: block; }
.duckmage-hide-icon-overrides .duckmage-svg-icon-override { display: none; }
.duckmage-hex-map-viewport {
position: absolute;
top: 0;
@ -858,15 +919,23 @@ div.duckmage-terrain-preview-icon {
/* ── Roads & Rivers toolbar ───────────────────────────────────────────────── */
.duckmage-draw-toolbar {
position: absolute;
top: 44px;
right: 8px;
z-index: 10;
display: flex;
flex-direction: column;
gap: 4px;
align-items: stretch;
.duckmage-desel-tool-btn {
height: 26px;
padding: 0 10px;
border-radius: 4px;
background: var(--background-secondary);
border: 1px solid var(--interactive-accent);
color: var(--interactive-accent);
font-size: 0.75em;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
width: 100%;
}
.duckmage-desel-tool-btn:hover {
background: var(--interactive-accent);
color: var(--text-on-accent);
}
.duckmage-draw-btn {
@ -1222,6 +1291,116 @@ div.duckmage-terrain-preview-icon {
max-width: 54px;
}
/* ── Icon picker: header + manage mode ───────────────────────────────────── */
.duckmage-icon-picker-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.duckmage-icon-picker-header h2 {
margin: 0;
}
.duckmage-icon-manage-btn {
flex-shrink: 0;
padding: 3px 10px;
font-size: 0.8em;
border-radius: 4px;
border: 1px solid var(--background-modifier-border);
background: var(--background-secondary);
color: var(--text-muted);
cursor: pointer;
}
.duckmage-icon-manage-btn:hover {
border-color: var(--interactive-accent);
color: var(--text-normal);
}
/* Hide button — appears on hover over each icon tile in manage mode */
.duckmage-icon-option {
position: relative;
}
.duckmage-icon-hide-btn {
position: absolute;
top: 2px;
right: 2px;
width: 20px;
height: 20px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
background: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
cursor: pointer;
opacity: 0;
transition: opacity 0.1s;
z-index: 2;
}
.duckmage-icon-hide-btn svg {
width: 12px;
height: 12px;
}
.duckmage-icon-option:hover .duckmage-icon-hide-btn {
opacity: 1;
}
.duckmage-icon-hidden .duckmage-icon-hide-btn {
opacity: 0.7;
}
/* Dim hidden icons in manage mode */
.duckmage-icon-hidden {
opacity: 0.35;
}
/* ── Icon picker: add section ────────────────────────────────────────────── */
.duckmage-icon-add-section {
margin-bottom: 12px;
}
.duckmage-icon-drop-zone {
border: 2px dashed var(--background-modifier-border);
border-radius: 6px;
padding: 16px 12px;
text-align: center;
cursor: pointer;
color: var(--text-muted);
font-size: 0.88em;
transition: border-color 0.15s ease, background 0.15s ease;
user-select: none;
}
.duckmage-icon-drop-zone:hover,
.duckmage-icon-drop-zone.is-dragover {
border-color: var(--interactive-accent);
background: var(--background-modifier-hover);
color: var(--text-normal);
}
.duckmage-icon-add-status {
font-size: 0.8em;
color: var(--text-muted);
margin: 4px 0 0;
min-height: 1.2em;
}
.duckmage-icon-add-warning {
font-size: 0.85em;
color: var(--color-orange);
margin: 0;
}
.duckmage-icon-btn-preview {
display: none;
width: 14px;
@ -2144,6 +2323,78 @@ div.duckmage-terrain-preview-icon {
opacity: 1;
}
/* ── Embedded roller code block widget ─────────────────────────────────── */
.duckmage-roller-block {
border: 1px solid var(--background-modifier-border);
border-radius: 6px;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 8px;
max-width: 420px;
background: var(--background-secondary);
}
.duckmage-roller-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.duckmage-roller-title {
font-weight: 600;
font-size: 0.9em;
color: var(--text-normal);
}
.duckmage-roller-die {
font-size: 0.8em;
}
.duckmage-roller-btn {
align-self: flex-start;
}
.duckmage-roller-history {
max-height: 130px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 2px;
}
.duckmage-roller-history-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
font-size: 0.8em;
color: var(--text-muted);
padding: 1px 2px;
}
.duckmage-roller-history-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.duckmage-roller-history-copy {
background: none;
border: none;
color: var(--text-faint);
cursor: pointer;
font-size: 0.9em;
padding: 0 2px;
flex-shrink: 0;
}
.duckmage-roller-history-copy:hover {
color: var(--text-normal);
}
/* Per-entry copy button in the table odds grid */
.duckmage-rt-copy-col-header {
width: 24px;

View file

@ -7,5 +7,6 @@
"1.0.4": "1.12.0",
"1.0.5": "1.12.0",
"1.0.6": "1.12.0",
"1.0.18": "1.12.0"
"1.0.18": "1.12.0",
"1.0.19": "1.12.0"
}