mirror of
https://github.com/sbuffkin/hexmaker.git
synced 2026-07-22 14:30:24 +00:00
fixes and adding overlays and icon upload
This commit is contained in:
parent
31feff8c59
commit
4b141857b3
16 changed files with 1050 additions and 121 deletions
85
.claude/commands/lint.md
Normal file
85
.claude/commands/lint.md
Normal 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.
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -324,4 +324,5 @@ export const DEFAULT_SETTINGS: HexmakerPluginSettings = {
|
|||
encounterTableExcludedFolders: ["terrain"],
|
||||
defaultRegion: "default",
|
||||
workflowsFolder: "",
|
||||
hiddenIcons: [],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
163
src/hex-map/HexSidePanel.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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, 64–128 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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
131
src/random-tables/RollerBlock.ts
Normal file
131
src/random-tables/RollerBlock.ts
Normal 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();
|
||||
})();
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
289
styles.css
289
styles.css
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue