diff --git a/.claude/commands/lint.md b/.claude/commands/lint.md new file mode 100644 index 0000000..8a64925 --- /dev/null +++ b/.claude/commands/lint.md @@ -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. diff --git a/manifest.json b/manifest.json index 1da65d4..db5e066 100644 --- a/manifest.json +++ b/manifest.json @@ -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", diff --git a/package.json b/package.json index 0b5822d..f59b40a 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/HexmakerPlugin.ts b/src/HexmakerPlugin.ts index 272ee99..f0493a6 100644 --- a/src/HexmakerPlugin.ts +++ b/src/HexmakerPlugin.ts @@ -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 { 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 { 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 { diff --git a/src/constants.ts b/src/constants.ts index cdee32c..49b29d8 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -324,4 +324,5 @@ export const DEFAULT_SETTINGS: HexmakerPluginSettings = { encounterTableExcludedFolders: ["terrain"], defaultRegion: "default", workflowsFolder: "", + hiddenIcons: [], }; diff --git a/src/hex-map/FileLinkSuggestModal.ts b/src/hex-map/FileLinkSuggestModal.ts index b95b021..fd75075 100644 --- a/src/hex-map/FileLinkSuggestModal.ts +++ b/src/hex-map/FileLinkSuggestModal.ts @@ -7,13 +7,15 @@ export class FileLinkSuggestModal extends SuggestModal { 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 { 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)); } diff --git a/src/hex-map/HexMapView.ts b/src/hex-map/HexMapView.ts index 8d20e65..560a4c4 100644 --- a/src/hex-map/HexMapView.ts +++ b/src/hex-map/HexMapView.ts @@ -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); }); diff --git a/src/hex-map/HexSidePanel.ts b/src/hex-map/HexSidePanel.ts new file mode 100644 index 0000000..0d0c601 --- /dev/null +++ b/src/hex-map/HexSidePanel.ts @@ -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(); + + 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); + } + } +} diff --git a/src/hex-map/IconPickerModal.ts b/src/hex-map/IconPickerModal.ts index be3cc7a..6b65fc0 100644 --- a/src/hex-map/IconPickerModal.ts +++ b/src/hex-map/IconPickerModal.ts @@ -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 => { + 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()); + } +} diff --git a/src/random-tables/RandomTableEditorModal.ts b/src/random-tables/RandomTableEditorModal.ts index f572805..880b17c 100644 --- a/src/random-tables/RandomTableEditorModal.ts +++ b/src/random-tables/RandomTableEditorModal.ts @@ -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) diff --git a/src/random-tables/RandomTableView.ts b/src/random-tables/RandomTableView.ts index b3bccb9..54d05c2 100644 --- a/src/random-tables/RandomTableView.ts +++ b/src/random-tables/RandomTableView.ts @@ -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, diff --git a/src/random-tables/RollerBlock.ts b/src/random-tables/RollerBlock.ts new file mode 100644 index 0000000..18a6580 --- /dev/null +++ b/src/random-tables/RollerBlock.ts @@ -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(); + })(); + }); + }, + ); +} diff --git a/src/random-tables/randomTable.ts b/src/random-tables/randomTable.ts index c8e8414..b51e993 100644 --- a/src/random-tables/randomTable.ts +++ b/src/random-tables/randomTable.ts @@ -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; diff --git a/src/types.ts b/src/types.ts index d577c4e..49988e4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; diff --git a/styles.css b/styles.css index 8fbf504..7cacfba 100644 --- a/styles.css +++ b/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; diff --git a/versions.json b/versions.json index 047e599..43e252e 100644 --- a/versions.json +++ b/versions.json @@ -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" }