diff --git a/.claude/commands/dev.md b/.claude/commands/dev.md new file mode 100644 index 0000000..327dbd7 --- /dev/null +++ b/.claude/commands/dev.md @@ -0,0 +1,16 @@ +--- +description: Start the duckmage plugin dev watcher (esbuild watch mode) +allowed-tools: Bash(npm:*) +--- + +Start esbuild in watch mode for the duckmage plugin. This rebuilds `main.js` automatically on every TypeScript file save. + +``` +cd /mnt/c/Users/markr/Documents/KB/journal/.obsidian/plugins/duckmage-plugin && npm run dev +``` + +Run this in the background — it is a long-running process. Tell the user the watcher has started, then remind them: +- `main.js` rebuilds automatically on every save to `main.ts` +- To reload the plugin in Obsidian after a rebuild: open the developer console (Ctrl+Shift+I) and run: + `app.plugins.disablePlugin('duckmage-plugin'); app.plugins.enablePlugin('duckmage-plugin');` +- Or use `/rebuild` instead for a one-off production build (includes type-checking). diff --git a/.claude/commands/rebuild.md b/.claude/commands/rebuild.md new file mode 100644 index 0000000..c0d0e8e --- /dev/null +++ b/.claude/commands/rebuild.md @@ -0,0 +1,16 @@ +--- +description: Build the duckmage plugin (type-check + bundle) +allowed-tools: Bash(npm:*) +--- + +Current directory: !`pwd` + +Run the production build for the duckmage Obsidian plugin: + +``` +cd /mnt/c/Users/markr/Documents/KB/journal/.obsidian/plugins/duckmage-plugin && npm run build +``` + +Report the result clearly: +- If it succeeded, confirm that `main.js` was updated. +- If it failed, show the full error output and identify the likely cause (TypeScript type error, missing import, syntax error, etc.). diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a6a5b45 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,64 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +# Development (watch mode with inline sourcemaps) — use with Hot Reload (see below) +npm run dev + +# Production build (type-checks then bundles) +npm run build + +# Bump version (updates manifest.json and versions.json, stages both) +npm run version +``` + +Slash commands (invoke inside Claude Code): +- `/dev` — starts esbuild in watch mode (long-running; pairs with Hot Reload) +- `/rebuild` — one-off production build; reports errors if the build fails + +There are no tests. The built output is `main.js` in the repo root, which Obsidian loads directly from this plugin folder. + +## Dev loop + +Each coding session: +1. Run `npm run dev` in a terminal (or `/dev` from Claude Code) — esbuild watches for changes and rebuilds `main.js` on every save +2. After a rebuild, reload the plugin in Obsidian via the developer console (Ctrl+Shift+I): + ```js + app.plugins.disablePlugin('duckmage-plugin'); app.plugins.enablePlugin('duckmage-plugin'); + ``` +3. Use `/rebuild` for a final production build before committing (runs the TypeScript type-check too) + +## Architecture + +This is a single-file Obsidian plugin (`main.ts`) compiled by esbuild into `main.js`. All source lives in `main.ts`; there are no other source files. + +### Plugin purpose + +Renders an interactive hex-grid map for tabletop RPG world-building inside Obsidian. Each hex cell corresponds to a Markdown note on disk. + +### Key classes + +- **`DuckmagePlugin`** — Main plugin entry point. Registers the view, ribbon icon, command, and settings tab. Exposes `hexPath(x, y)`, `createHexNote(x, y)`, and `loadAvailableIcons()`. Stores `availableIcons: string[]` (PNGs from the `icons/` folder, loaded at startup). +- **`HexMapView`** (extends `ItemView`) — Renders the hex grid. `renderGrid(terrainOverrides?)` does a full DOM re-render; the optional `terrainOverrides` map allows immediate visual updates before the metadata cache catches up. Left-click opens/creates a note; right-click opens `HexEditorModal`. +- **`HexEditorModal`** (extends `Modal`) — The right-click editor. Has a terrain picker (color swatch + icon grid) and link sections for Towns, Dungeons, and Features. Uses `ensureHexNote()` to create the note on demand before writing to it. +- **`FileLinkSuggestModal`** (extends `SuggestModal`) — Reusable file-search modal scoped to `worldFolder`. Takes an `onChoose` callback. +- **`DuckmageSettingTab`** — Settings UI for folder paths, grid dimensions, hex gap, and terrain palette (name + color + icon dropdown). + +### Data model + +- **Hex notes**: stored at `{hexFolder}/{x}_{y}.md` (e.g. `RPG/duckmage/hexes/3_7.md`). Created via `DuckmagePlugin.createHexNote(x, y)` using the configured template or the built-in `DEFAULT_HEX_TEMPLATE`. +- **Template placeholders**: `{{x}}`, `{{y}}`, `{{title}}`. The default template and any custom template should include `## Towns`, `## Dungeons`, and `## Features` headings so that links added via the editor land in the right section. +- **Terrain**: stored in a hex note's YAML frontmatter as `terrain: `. Read via `getTerrainFromFile` (uses metadata cache); written via `setTerrainInFile` (parses/patches raw file content). +- **Terrain icons**: PNG files in the plugin's `icons/` folder. Loaded at startup into `plugin.availableIcons`. Each `TerrainColor` entry has an optional `icon` filename. URLs are resolved via `getIconUrl(plugin, filename)` → `app.vault.adapter.getResourcePath(manifest.dir + '/icons/' + filename)`. +- **Section links**: `addLinkToSection(app, filePath, section, linkText)` inserts a wiki-link under the named `##` heading (appending the section if absent). `getLinksInSection` reads them back for display. +- **Settings** (`data.json`): `worldFolder`, `hexFolder`, `templatePath`, `hexGap`, `terrainPalette` (array of `{name, color, icon?}`), `gridSize` ({cols, rows}), `zoomLevel`. + +### Key conventions + +- Folder paths are normalized (no leading/trailing slashes) via `normalizeFolder()`. +- Links use vault-relative paths via `metadataCache.fileToLinktext(file, sourcePath)`. +- `obsidian` is an esbuild external — never bundled; provided by Obsidian at runtime. +- CSS classes use the `duckmage-` prefix (styles in `styles.css`). diff --git a/data.json b/data.json index 3f8f73f..b50da01 100644 --- a/data.json +++ b/data.json @@ -1,13 +1,14 @@ { "mySetting": "default", "worldFolder": "RPG/duckmage/world", - "hexFolder": "RPG/duckmage/world/hexes", + "hexFolder": "RPG/duckmage/hexes", "templatePath": "RPG/duckmage/world/hextemplate.md", "hexGap": "0.15", "terrainPalette": [ { "name": "mountain", - "color": "#6e7b91" + "color": "#6e7b91", + "icon": "bw-mountain.png" }, { "name": "water", @@ -15,19 +16,23 @@ }, { "name": "grass", - "color": "#229b66" + "color": "#638c7b", + "icon": "bw-grassland.png" }, { "name": "forest", - "color": "#0a431f" + "color": "#0a431f", + "icon": "bw-forest-heavy.png" }, { "name": "cliffs", - "color": "#a86f1f" + "color": "#a86f1f", + "icon": "bw-brokenlands.png" }, { "name": "desert", - "color": "#c6b47b" + "color": "#c6b47b", + "icon": "bw-cactus.png" }, { "name": "snow", @@ -35,12 +40,177 @@ }, { "name": "aaaaa", - "color": "#600aff" + "color": "#432e6b", + "icon": "bw-swamp.png" + }, + { + "name": "hills", + "color": "#a8a29e", + "icon": "bw-hills.png" + }, + { + "name": "desert rocky", + "color": "#d97706", + "icon": "bw-desert-rocky.png" + }, + { + "name": "dunes", + "color": "#fbbf24", + "icon": "bw-dunes.png" + }, + { + "name": "cactus", + "color": "#ca8a04", + "icon": "bw-cactus.png" + }, + { + "name": "cactus heavy", + "color": "#b45309", + "icon": "bw-cactus-heavy.png" + }, + { + "name": "badlands", + "color": "#c2410c", + "icon": "bw-badlands.png" + }, + { + "name": "brokenlands", + "color": "#92400e", + "icon": "bw-brokenlands.png" + }, + { + "name": "forest heavy", + "color": "#15803d", + "icon": "bw-forest-heavy.png" + }, + { + "name": "forested hills", + "color": "#22c55e", + "icon": "bw-forested-hills.png" + }, + { + "name": "mixed forest", + "color": "#16a34a", + "icon": "bw-forest-mixed.png" + }, + { + "name": "mixed forest heavy", + "color": "#15803d", + "icon": "bw-forest-mixed-heavy.png" + }, + { + "name": "mixed forest hills", + "color": "#22c55e", + "icon": "bw-forest-mixed-hills.png" + }, + { + "name": "evergreen", + "color": "#166534", + "icon": "bw-evergreen.png" + }, + { + "name": "evergreen heavy", + "color": "#14532d", + "icon": "bw-evergreen-heavy.png" + }, + { + "name": "evergreen hills", + "color": "#4ade80", + "icon": "bw-evergreen-hills.png" + }, + { + "name": "jungle", + "color": "#15803d", + "icon": "bw-jungle.png" + }, + { + "name": "jungle heavy", + "color": "#14532d", + "icon": "bw-jungle-heavy.png" + }, + { + "name": "jungle hills", + "color": "#4ade80", + "icon": "bw-jungle-hills.png" + }, + { + "name": "mountain peak", + "color": "#78716c", + "icon": "bw-mountain.png" + }, + { + "name": "mountains snow", + "color": "#bfdbfe", + "icon": "bw-mountains-snow.png" + }, + { + "name": "forested mountain", + "color": "#6b9e7c", + "icon": "bw-forested-mountain.png" + }, + { + "name": "forested mountains", + "color": "#5e8c6a", + "icon": "bw-forested-mountains.png" + }, + { + "name": "mixed forest mountain", + "color": "#6b9e7c", + "icon": "bw-forest-mixed-mountain.png" + }, + { + "name": "mixed forest mountains", + "color": "#5e8c6a", + "icon": "bw-forest-mixed-mountains.png" + }, + { + "name": "evergreen mountain", + "color": "#6b7280", + "icon": "bw-evergreen-mountain.png" + }, + { + "name": "evergreen mountains", + "color": "#4b5563", + "icon": "bw-evergreen-mountains.png" + }, + { + "name": "jungle mountain", + "color": "#4d7c0f", + "icon": "bw-jungle-mountain.png" + }, + { + "name": "jungle mountains", + "color": "#3f6212", + "icon": "bw-jungle-mountains.png" + }, + { + "name": "volcano", + "color": "#b91c1c", + "icon": "bw-volcano.png" + }, + { + "name": "volcano dormant", + "color": "#78350f", + "icon": "bw-volcano-dormant.png" + }, + { + "name": "marsh", + "color": "#4d7c0f", + "icon": "bw-marsh.png" + }, + { + "name": "swamp", + "color": "#365314", + "icon": "bw-swamp.png" } ], "gridSize": { - "cols": 25, - "rows": 16 + "cols": 26, + "rows": 17 + }, + "gridOffset": { + "x": -1, + "y": -1 }, "zoomLevel": 1 } \ No newline at end of file diff --git a/icons/black-and-white-worldographer.wxx b/icons/black-and-white-worldographer.wxx new file mode 100644 index 0000000..2c2acd6 Binary files /dev/null and b/icons/black-and-white-worldographer.wxx differ diff --git a/icons/black_white.properties b/icons/black_white.properties new file mode 100644 index 0000000..6d18989 --- /dev/null +++ b/icons/black_white.properties @@ -0,0 +1,252 @@ +symbolnames=Heavy Forest Forested Hills Mountains Marsh Farmland Swamp Light Forest Grassland Cactus Evergreen Mountains Forested Mountains Heavy Cactus Jungle Heavy Evergreen Sandy Desert Sand Dunes Rocky Desert Evergreen Hills Ocean Light Evergreen Badlands Sea Jungle Hills Volcano Hills +Heavy_Forest.category=Forests +Heavy_Forest.elevation=0 +Heavy_Forest.iconfilename=bw-forest-heavy.png +Heavy_Forest.scalefactor=0.82 +Heavy_Forest.iscustom=false +Heavy_Forest.isdrawborder=false +Heavy_Forest.isfeature=false +Heavy_Forest.isfill=true +Heavy_Forest.isuseicon=true +Heavy_Forest.backgroundrgb=-1 +Forested_Hills.category=Forests +Forested_Hills.elevation=2 +Forested_Hills.iconfilename=bw-forested-hills.png +Forested_Hills.scalefactor=0.82 +Forested_Hills.iscustom=false +Forested_Hills.isdrawborder=false +Forested_Hills.isfeature=false +Forested_Hills.isfill=true +Forested_Hills.isuseicon=true +Forested_Hills.backgroundrgb=-1 +Mountains.category=Other Land +Mountains.elevation=4 +Mountains.iconfilename=bw-mountains.png +Mountains.scalefactor=0.82 +Mountains.iscustom=false +Mountains.isdrawborder=false +Mountains.isfeature=false +Mountains.isfill=true +Mountains.isuseicon=true +Mountains.backgroundrgb=-1 +Marsh.category=Other Land +Marsh.elevation=-1 +Marsh.iconfilename=bw-marsh.png +Marsh.scalefactor=0.82 +Marsh.iscustom=false +Marsh.isdrawborder=false +Marsh.isfeature=false +Marsh.isfill=true +Marsh.isuseicon=true +Marsh.backgroundrgb=-1 +Farmland.category=Other Land +Farmland.elevation=0 +Farmland.iconfilename= +Farmland.scalefactor=0.82 +Farmland.iscustom=false +Farmland.isdrawborder=false +Farmland.isfeature=false +Farmland.isfill=true +Farmland.isuseicon=true +Farmland.backgroundrgb=-1 +Swamp.category=Other Land +Swamp.elevation=-1 +Swamp.iconfilename=bw-swamp.png +Swamp.scalefactor=0.82 +Swamp.iscustom=false +Swamp.isdrawborder=false +Swamp.isfeature=false +Swamp.isfill=true +Swamp.isuseicon=true +Swamp.backgroundrgb=-1 +Light_Forest.category=Forests +Light_Forest.elevation=0 +Light_Forest.iconfilename=bw-forest.png +Light_Forest.scalefactor=0.82 +Light_Forest.iscustom=false +Light_Forest.isdrawborder=false +Light_Forest.isfeature=false +Light_Forest.isfill=true +Light_Forest.isuseicon=true +Light_Forest.backgroundrgb=-1 +Grassland.category=Other Land +Grassland.elevation=0 +Grassland.iconfilename=bw-grassland.png +Grassland.scalefactor=0.82 +Grassland.iscustom=false +Grassland.isdrawborder=false +Grassland.isfeature=false +Grassland.isfill=true +Grassland.isuseicon=true +Grassland.backgroundrgb=-1 +Cactus.category=Rough Land +Cactus.elevation=0 +Cactus.iconfilename=bw-cactus.png +Cactus.scalefactor=0.82 +Cactus.iscustom=false +Cactus.isdrawborder=false +Cactus.isfeature=false +Cactus.isfill=true +Cactus.isuseicon=true +Cactus.backgroundrgb=-1 +Evergreen_Mountains.category=Forests +Evergreen_Mountains.elevation=4 +Evergreen_Mountains.iconfilename=bw-evergreen-mountains.png +Evergreen_Mountains.scalefactor=0.82 +Evergreen_Mountains.iscustom=false +Evergreen_Mountains.isdrawborder=false +Evergreen_Mountains.isfeature=false +Evergreen_Mountains.isfill=true +Evergreen_Mountains.isuseicon=true +Evergreen_Mountains.backgroundrgb=-1 +Forested_Mountains.category=Forests +Forested_Mountains.elevation=4 +Forested_Mountains.iconfilename=bw-forested-mountains.png +Forested_Mountains.scalefactor=0.82 +Forested_Mountains.iscustom=false +Forested_Mountains.isdrawborder=false +Forested_Mountains.isfeature=false +Forested_Mountains.isfill=true +Forested_Mountains.isuseicon=true +Forested_Mountains.backgroundrgb=-1 +Heavy_Cactus.category=Rough Land +Heavy_Cactus.elevation=0 +Heavy_Cactus.iconfilename=bw-cactus-heavy.png +Heavy_Cactus.scalefactor=0.82 +Heavy_Cactus.iscustom=false +Heavy_Cactus.isdrawborder=false +Heavy_Cactus.isfeature=false +Heavy_Cactus.isfill=true +Heavy_Cactus.isuseicon=true +Heavy_Cactus.backgroundrgb=-1 +Jungle.category=Forests +Jungle.elevation=0 +Jungle.iconfilename=bw-jungle.png +Jungle.scalefactor=0.82 +Jungle.iscustom=false +Jungle.isdrawborder=false +Jungle.isfeature=false +Jungle.isfill=true +Jungle.isuseicon=true +Jungle.backgroundrgb=-1 +Heavy_Evergreen.category=Forests +Heavy_Evergreen.elevation=0 +Heavy_Evergreen.iconfilename=bw-evergreen-heavy.png +Heavy_Evergreen.scalefactor=0.82 +Heavy_Evergreen.iscustom=false +Heavy_Evergreen.isdrawborder=false +Heavy_Evergreen.isfeature=false +Heavy_Evergreen.isfill=true +Heavy_Evergreen.isuseicon=true +Heavy_Evergreen.backgroundrgb=-1 +Sandy_Desert.category=Rough Land +Sandy_Desert.elevation=0 +Sandy_Desert.iconfilename=bw-desert.png +Sandy_Desert.scalefactor=0.82 +Sandy_Desert.iscustom=false +Sandy_Desert.isdrawborder=false +Sandy_Desert.isfeature=false +Sandy_Desert.isfill=true +Sandy_Desert.isuseicon=true +Sandy_Desert.backgroundrgb=-1 +Sand_Dunes.category=Rough Land +Sand_Dunes.elevation=2 +Sand_Dunes.iconfilename=bw-dunes.png +Sand_Dunes.scalefactor=0.82 +Sand_Dunes.iscustom=false +Sand_Dunes.isdrawborder=false +Sand_Dunes.isfeature=false +Sand_Dunes.isfill=true +Sand_Dunes.isuseicon=true +Sand_Dunes.backgroundrgb=-1 +Rocky_Desert.category=Rough Land +Rocky_Desert.elevation=0 +Rocky_Desert.iconfilename=bw-desert-rocky.png +Rocky_Desert.scalefactor=0.82 +Rocky_Desert.iscustom=false +Rocky_Desert.isdrawborder=false +Rocky_Desert.isfeature=false +Rocky_Desert.isfill=true +Rocky_Desert.isuseicon=true +Rocky_Desert.backgroundrgb=-1 +Evergreen_Hills.category=Forests +Evergreen_Hills.elevation=2 +Evergreen_Hills.iconfilename=bw-evergreen-hills.png +Evergreen_Hills.scalefactor=0.82 +Evergreen_Hills.iscustom=false +Evergreen_Hills.isdrawborder=false +Evergreen_Hills.isfeature=false +Evergreen_Hills.isfill=true +Evergreen_Hills.isuseicon=true +Evergreen_Hills.backgroundrgb=-1 +Ocean.category=Water +Ocean.elevation=-3 +Ocean.iconfilename= +Ocean.scalefactor=0.82 +Ocean.iscustom=false +Ocean.isdrawborder=false +Ocean.isfeature=false +Ocean.isfill=true +Ocean.isuseicon=true +Ocean.backgroundrgb=-6710887 +Light_Evergreen.category=Forests +Light_Evergreen.elevation=0 +Light_Evergreen.iconfilename=bw-evergreen.png +Light_Evergreen.scalefactor=0.82 +Light_Evergreen.iscustom=false +Light_Evergreen.isdrawborder=false +Light_Evergreen.isfeature=false +Light_Evergreen.isfill=true +Light_Evergreen.isuseicon=true +Light_Evergreen.backgroundrgb=-1 +Badlands.category=Rough Land +Badlands.elevation=0 +Badlands.iconfilename=bw-brokenlands.png +Badlands.scalefactor=0.82 +Badlands.iscustom=false +Badlands.isdrawborder=false +Badlands.isfeature=false +Badlands.isfill=true +Badlands.isuseicon=true +Badlands.backgroundrgb=-1 +Sea.category=Water +Sea.elevation=-1 +Sea.iconfilename= +Sea.scalefactor=0.82 +Sea.iscustom=false +Sea.isdrawborder=false +Sea.isfeature=false +Sea.isfill=true +Sea.isuseicon=true +Sea.backgroundrgb=-3355444 +Jungle_Hills.category=Forests +Jungle_Hills.elevation=2 +Jungle_Hills.iconfilename=bw-jungle-hills.png +Jungle_Hills.scalefactor=0.82 +Jungle_Hills.iscustom=false +Jungle_Hills.isdrawborder=false +Jungle_Hills.isfeature=false +Jungle_Hills.isfill=true +Jungle_Hills.isuseicon=true +Jungle_Hills.backgroundrgb=-1 +Volcano.category=Other Land +Volcano.elevation=4 +Volcano.iconfilename=bw-volcano.png +Volcano.scalefactor=0.82 +Volcano.iscustom=false +Volcano.isdrawborder=false +Volcano.isfeature=false +Volcano.isfill=true +Volcano.isuseicon=true +Volcano.backgroundrgb=-1 +Hills.category=Other Land +Hills.elevation=2 +Hills.iconfilename=bw-hills.png +Hills.scalefactor=0.82 +Hills.iscustom=false +Hills.isdrawborder=false +Hills.isfeature=false +Hills.isfill=true +Hills.isuseicon=true +Hills.backgroundrgb=-1 + diff --git a/icons/bw-badlands.png b/icons/bw-badlands.png new file mode 100644 index 0000000..996aee1 Binary files /dev/null and b/icons/bw-badlands.png differ diff --git a/icons/bw-brokenlands.png b/icons/bw-brokenlands.png new file mode 100644 index 0000000..ed3da04 Binary files /dev/null and b/icons/bw-brokenlands.png differ diff --git a/icons/bw-cactus-heavy.png b/icons/bw-cactus-heavy.png new file mode 100644 index 0000000..9577f10 Binary files /dev/null and b/icons/bw-cactus-heavy.png differ diff --git a/icons/bw-cactus.png b/icons/bw-cactus.png new file mode 100644 index 0000000..1104bb2 Binary files /dev/null and b/icons/bw-cactus.png differ diff --git a/icons/bw-desert-rocky.png b/icons/bw-desert-rocky.png new file mode 100644 index 0000000..0a9b082 Binary files /dev/null and b/icons/bw-desert-rocky.png differ diff --git a/icons/bw-desert.png b/icons/bw-desert.png new file mode 100644 index 0000000..b994838 Binary files /dev/null and b/icons/bw-desert.png differ diff --git a/icons/bw-dunes.png b/icons/bw-dunes.png new file mode 100644 index 0000000..a35fcc9 Binary files /dev/null and b/icons/bw-dunes.png differ diff --git a/icons/bw-evergreen-heavy.png b/icons/bw-evergreen-heavy.png new file mode 100644 index 0000000..87089d0 Binary files /dev/null and b/icons/bw-evergreen-heavy.png differ diff --git a/icons/bw-evergreen-hills.png b/icons/bw-evergreen-hills.png new file mode 100644 index 0000000..b9b5d2b Binary files /dev/null and b/icons/bw-evergreen-hills.png differ diff --git a/icons/bw-evergreen-mountain.png b/icons/bw-evergreen-mountain.png new file mode 100644 index 0000000..58b572a Binary files /dev/null and b/icons/bw-evergreen-mountain.png differ diff --git a/icons/bw-evergreen-mountains.png b/icons/bw-evergreen-mountains.png new file mode 100644 index 0000000..cb24652 Binary files /dev/null and b/icons/bw-evergreen-mountains.png differ diff --git a/icons/bw-evergreen.png b/icons/bw-evergreen.png new file mode 100644 index 0000000..3c00db4 Binary files /dev/null and b/icons/bw-evergreen.png differ diff --git a/icons/bw-forest-heavy.png b/icons/bw-forest-heavy.png new file mode 100644 index 0000000..1b47bff Binary files /dev/null and b/icons/bw-forest-heavy.png differ diff --git a/icons/bw-forest-mixed-heavy.png b/icons/bw-forest-mixed-heavy.png new file mode 100644 index 0000000..0c0568f Binary files /dev/null and b/icons/bw-forest-mixed-heavy.png differ diff --git a/icons/bw-forest-mixed-hills.png b/icons/bw-forest-mixed-hills.png new file mode 100644 index 0000000..752612b Binary files /dev/null and b/icons/bw-forest-mixed-hills.png differ diff --git a/icons/bw-forest-mixed-mountain.png b/icons/bw-forest-mixed-mountain.png new file mode 100644 index 0000000..994062f Binary files /dev/null and b/icons/bw-forest-mixed-mountain.png differ diff --git a/icons/bw-forest-mixed-mountains.png b/icons/bw-forest-mixed-mountains.png new file mode 100644 index 0000000..0bb5836 Binary files /dev/null and b/icons/bw-forest-mixed-mountains.png differ diff --git a/icons/bw-forest-mixed.png b/icons/bw-forest-mixed.png new file mode 100644 index 0000000..4e92449 Binary files /dev/null and b/icons/bw-forest-mixed.png differ diff --git a/icons/bw-forest.png b/icons/bw-forest.png new file mode 100644 index 0000000..3d480b3 Binary files /dev/null and b/icons/bw-forest.png differ diff --git a/icons/bw-forested-hills.png b/icons/bw-forested-hills.png new file mode 100644 index 0000000..da3d9e1 Binary files /dev/null and b/icons/bw-forested-hills.png differ diff --git a/icons/bw-forested-mountain.png b/icons/bw-forested-mountain.png new file mode 100644 index 0000000..77e0264 Binary files /dev/null and b/icons/bw-forested-mountain.png differ diff --git a/icons/bw-forested-mountains.png b/icons/bw-forested-mountains.png new file mode 100644 index 0000000..ba6cf11 Binary files /dev/null and b/icons/bw-forested-mountains.png differ diff --git a/icons/bw-grassland.png b/icons/bw-grassland.png new file mode 100644 index 0000000..c0c120d Binary files /dev/null and b/icons/bw-grassland.png differ diff --git a/icons/bw-hills.png b/icons/bw-hills.png new file mode 100644 index 0000000..78b30da Binary files /dev/null and b/icons/bw-hills.png differ diff --git a/icons/bw-jungle-heavy.png b/icons/bw-jungle-heavy.png new file mode 100644 index 0000000..4bf713c Binary files /dev/null and b/icons/bw-jungle-heavy.png differ diff --git a/icons/bw-jungle-hills.png b/icons/bw-jungle-hills.png new file mode 100644 index 0000000..3b9c715 Binary files /dev/null and b/icons/bw-jungle-hills.png differ diff --git a/icons/bw-jungle-mountain.png b/icons/bw-jungle-mountain.png new file mode 100644 index 0000000..41487cf Binary files /dev/null and b/icons/bw-jungle-mountain.png differ diff --git a/icons/bw-jungle-mountains.png b/icons/bw-jungle-mountains.png new file mode 100644 index 0000000..55a5130 Binary files /dev/null and b/icons/bw-jungle-mountains.png differ diff --git a/icons/bw-jungle.png b/icons/bw-jungle.png new file mode 100644 index 0000000..8fe56b7 Binary files /dev/null and b/icons/bw-jungle.png differ diff --git a/icons/bw-marsh.png b/icons/bw-marsh.png new file mode 100644 index 0000000..915be60 Binary files /dev/null and b/icons/bw-marsh.png differ diff --git a/icons/bw-mountain-snow.png b/icons/bw-mountain-snow.png new file mode 100644 index 0000000..817a6ab Binary files /dev/null and b/icons/bw-mountain-snow.png differ diff --git a/icons/bw-mountain.png b/icons/bw-mountain.png new file mode 100644 index 0000000..d1c7ce1 Binary files /dev/null and b/icons/bw-mountain.png differ diff --git a/icons/bw-mountains-snow.png b/icons/bw-mountains-snow.png new file mode 100644 index 0000000..9992354 Binary files /dev/null and b/icons/bw-mountains-snow.png differ diff --git a/icons/bw-mountains.png b/icons/bw-mountains.png new file mode 100644 index 0000000..b5b36ab Binary files /dev/null and b/icons/bw-mountains.png differ diff --git a/icons/bw-swamp.png b/icons/bw-swamp.png new file mode 100644 index 0000000..19369ca Binary files /dev/null and b/icons/bw-swamp.png differ diff --git a/icons/bw-volcano-dormant.png b/icons/bw-volcano-dormant.png new file mode 100644 index 0000000..a5f62f1 Binary files /dev/null and b/icons/bw-volcano-dormant.png differ diff --git a/icons/bw-volcano.png b/icons/bw-volcano.png new file mode 100644 index 0000000..1a62313 Binary files /dev/null and b/icons/bw-volcano.png differ diff --git a/main.ts b/main.ts index 0bd7fb6..a64e1c4 100644 --- a/main.ts +++ b/main.ts @@ -1,19 +1,25 @@ -import { App, ItemView, Menu, Modal, Notice, Plugin, PluginSettingTab, Setting, SuggestModal, TFile, WorkspaceLeaf } from "obsidian"; -import MiniSearch from 'minisearch' -//https://github.com/lucaong/minisearch - +import { App, ItemView, Modal, Notice, Plugin, PluginSettingTab, Setting, SuggestModal, TFile, WorkspaceLeaf } from "obsidian"; const VIEW_TYPE_HEX_MAP = "duckmage-hex-map"; +const LINK_SECTIONS = ["Towns", "Dungeons", "Features"] as const; +type LinkSection = typeof LINK_SECTIONS[number]; +const TEXT_SECTIONS = [ + { key: "description", label: "Description" }, + { key: "landmark", label: "Landmark" }, + { key: "hidden", label: "Hidden" }, + { key: "secret", label: "Secret" }, + { key: "encounters", label: "Encounters" }, + { key: "weather", label: "Weather" }, + { key: "hooks & rumors", label: "Hooks & Rumors" }, +] as const; -//let minisearch = new MiniSearch() +// ─── Helpers ────────────────────────────────────────────────────────────────── -/** Normalize folder path: no leading/trailing slashes. */ function normalizeFolder(path: string): string { return path.replace(/^\/+|\/+$/g, "") || ""; } -/** Get terrain key from a note's frontmatter (from cache or by reading). */ function getTerrainFromFile(app: App, path: string): string | null { const file = app.vault.getAbstractFileByPath(path); if (!(file instanceof TFile)) return null; @@ -22,7 +28,6 @@ function getTerrainFromFile(app: App, path: string): string | null { return typeof terrain === "string" ? terrain : null; } -/** Set terrain in a note's frontmatter. File must already exist. */ async function setTerrainInFile(app: App, path: string, terrainKey: string | null): Promise { const file = app.vault.getAbstractFileByPath(path); if (!(file instanceof TFile)) return false; @@ -34,7 +39,6 @@ async function setTerrainInFile(app: App, path: string, terrainKey: string | nul const rest = content.slice(fmMatch[0].length); let newFm: string; if (terrainKey === null) { - // Remove any terrain line (including optional newline); support quoted values newFm = fmBlock.replace(/^\s*terrain:\s*[^\r\n]*(?:\r?\n)?/gm, "").trimEnd(); } else { const terrainLine = /^\s*terrain:\s*.*$/m; @@ -51,9 +55,102 @@ async function setTerrainInFile(app: App, path: string, terrainKey: string | nul return true; } +function getIconUrl(plugin: DuckmagePlugin, iconFilename: string): string { + return plugin.app.vault.adapter.getResourcePath(`${plugin.manifest.dir}/icons/${iconFilename}`); +} + +/** Insert a wiki-link under the named ### section, creating the section if absent. */ +async function addLinkToSection(app: App, filePath: string, section: string, linkText: string): Promise { + const file = app.vault.getAbstractFileByPath(filePath); + if (!(file instanceof TFile)) return; + let content = await app.vault.read(file); + + const headingRegex = new RegExp(`^###\\s+${section}\\s*$`, "mi"); + const match = headingRegex.exec(content); + + if (!match) { + content = content.trimEnd() + `\n\n### ${section}\n\n${linkText}\n`; + await app.vault.modify(file, content); + return; + } + + const afterHeading = match.index + match[0].length; + const nextHeadingMatch = /\n###? /m.exec(content.slice(afterHeading)); + const sectionEnd = nextHeadingMatch ? afterHeading + nextHeadingMatch.index : content.length; + const sectionContent = content.slice(afterHeading, sectionEnd); + + if (sectionContent.includes(linkText)) return; // already present + + const trimmedSection = sectionContent.trimEnd(); + const insertAt = afterHeading + trimmedSection.length; + content = content.slice(0, insertAt) + "\n\n" + linkText + content.slice(insertAt); + await app.vault.modify(file, content); +} + +/** Return all wiki-link targets found under a named ### section. */ +async function getLinksInSection(app: App, filePath: string, section: string): Promise { + const file = app.vault.getAbstractFileByPath(filePath); + if (!(file instanceof TFile)) return []; + const content = await app.vault.read(file); + + const headingRegex = new RegExp(`^###\\s+${section}\\s*$`, "mi"); + const match = headingRegex.exec(content); + if (!match) return []; + + const afterHeading = match.index + match[0].length; + const nextHeadingMatch = /\n###? /m.exec(content.slice(afterHeading)); + const sectionEnd = nextHeadingMatch ? afterHeading + nextHeadingMatch.index : content.length; + const sectionContent = content.slice(afterHeading, sectionEnd); + + const links: string[] = []; + const linkRegex = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g; + let m; + while ((m = linkRegex.exec(sectionContent)) !== null) { + links.push(m[1]); + } + return links; +} + +/** Return the plain text body of a named ### section (stops at next heading or ---). */ +async function getSectionContent(app: App, filePath: string, section: string): Promise { + const file = app.vault.getAbstractFileByPath(filePath); + if (!(file instanceof TFile)) return ""; + const content = await app.vault.read(file); + + const headingRegex = new RegExp(`^###\\s+${section}\\s*$`, "mi"); + const match = headingRegex.exec(content); + if (!match) return ""; + + const afterHeading = match.index + match[0].length; + const nextBoundary = /\n(?:#{1,6} |-{3,})/m.exec(content.slice(afterHeading)); + const sectionEnd = nextBoundary ? afterHeading + nextBoundary.index : content.length; + return content.slice(afterHeading, sectionEnd).trim(); +} + +/** Replace the body of a named ### section in-place. */ +async function setSectionContent(app: App, filePath: string, section: string, newText: string): Promise { + const file = app.vault.getAbstractFileByPath(filePath); + if (!(file instanceof TFile)) return; + let content = await app.vault.read(file); + + const headingRegex = new RegExp(`^###\\s+${section}\\s*$`, "mi"); + const match = headingRegex.exec(content); + if (!match) return; + + const afterHeading = match.index + match[0].length; + const nextBoundary = /\n(?:#{1,6} |-{3,})/m.exec(content.slice(afterHeading)); + const sectionEnd = nextBoundary ? afterHeading + nextBoundary.index : content.length; + + const replacement = newText.trim() ? `\n\n${newText.trim()}\n` : "\n"; + await app.vault.modify(file, content.slice(0, afterHeading) + replacement + content.slice(sectionEnd)); +} + +// ─── Types ──────────────────────────────────────────────────────────────────── + export interface TerrainColor { name: string; color: string; + icon?: string; } interface DuckmagePluginSettings { @@ -63,21 +160,60 @@ interface DuckmagePluginSettings { templatePath: string; hexGap: string; terrainPalette: TerrainColor[]; - gridSize: { - cols: number; - rows: number; - }; + gridSize: { cols: number; rows: number }; + gridOffset: { x: number; y: number }; zoomLevel: number; } const DEFAULT_TERRAIN_PALETTE: TerrainColor[] = [ - { name: "mountain", color: "#9ca3af" }, - { name: "water", color: "#60a5fa" }, - { name: "grass", color: "#84cc16" }, - { name: "forest", color: "#15803d" }, - { name: "cliffs", color: "#a16207" }, - { name: "desert", color: "#eab308" }, - { name: "snow", color: "#e0f2fe" }, + // Open + { name: "grass", color: "#84cc16", icon: "bw-grassland.png" }, + { name: "hills", color: "#a8a29e", icon: "bw-hills.png" }, + // Desert + { name: "desert", color: "#eab308", icon: "bw-desert.png" }, + { name: "desert rocky", color: "#d97706", icon: "bw-desert-rocky.png" }, + { name: "dunes", color: "#fbbf24", icon: "bw-dunes.png" }, + { name: "cactus", color: "#ca8a04", icon: "bw-cactus.png" }, + { name: "cactus heavy", color: "#b45309", icon: "bw-cactus-heavy.png" }, + { name: "badlands", color: "#c2410c", icon: "bw-badlands.png" }, + { name: "brokenlands", color: "#92400e", icon: "bw-brokenlands.png" }, + // Forest + { name: "forest", color: "#16a34a", icon: "bw-forest.png" }, + { name: "forest heavy", color: "#15803d", icon: "bw-forest-heavy.png" }, + { name: "forested hills", color: "#22c55e", icon: "bw-forested-hills.png" }, + { name: "mixed forest", color: "#16a34a", icon: "bw-forest-mixed.png" }, + { name: "mixed forest heavy", color: "#15803d", icon: "bw-forest-mixed-heavy.png" }, + { name: "mixed forest hills", color: "#22c55e", icon: "bw-forest-mixed-hills.png" }, + // Evergreen + { name: "evergreen", color: "#166534", icon: "bw-evergreen.png" }, + { name: "evergreen heavy", color: "#14532d", icon: "bw-evergreen-heavy.png" }, + { name: "evergreen hills", color: "#4ade80", icon: "bw-evergreen-hills.png" }, + // Jungle + { name: "jungle", color: "#15803d", icon: "bw-jungle.png" }, + { name: "jungle heavy", color: "#14532d", icon: "bw-jungle-heavy.png" }, + { name: "jungle hills", color: "#4ade80", icon: "bw-jungle-hills.png" }, + // Mountains + { name: "mountain", color: "#9ca3af", icon: "bw-mountains.png" }, + { name: "mountain peak", color: "#78716c", icon: "bw-mountain.png" }, + { name: "mountains snow", color: "#bfdbfe", icon: "bw-mountains-snow.png" }, + { name: "snow", color: "#e0f2fe", icon: "bw-mountain-snow.png" }, + { name: "forested mountain", color: "#6b9e7c", icon: "bw-forested-mountain.png" }, + { name: "forested mountains", color: "#5e8c6a", icon: "bw-forested-mountains.png" }, + { name: "mixed forest mountain", color: "#6b9e7c", icon: "bw-forest-mixed-mountain.png" }, + { name: "mixed forest mountains", color: "#5e8c6a", icon: "bw-forest-mixed-mountains.png" }, + { name: "evergreen mountain", color: "#6b7280", icon: "bw-evergreen-mountain.png" }, + { name: "evergreen mountains", color: "#4b5563", icon: "bw-evergreen-mountains.png" }, + { name: "jungle mountain", color: "#4d7c0f", icon: "bw-jungle-mountain.png" }, + { name: "jungle mountains", color: "#3f6212", icon: "bw-jungle-mountains.png" }, + // Volcanic + { name: "volcano", color: "#b91c1c", icon: "bw-volcano.png" }, + { name: "volcano dormant", color: "#78350f", icon: "bw-volcano-dormant.png" }, + // Wetlands + { name: "marsh", color: "#4d7c0f", icon: "bw-marsh.png" }, + { name: "swamp", color: "#365314", icon: "bw-swamp.png" }, + // Water / cliffs (no icons) + { name: "water", color: "#60a5fa" }, + { name: "cliffs", color: "#a16207" }, ]; const DEFAULT_SETTINGS: DuckmagePluginSettings = { @@ -87,45 +223,116 @@ const DEFAULT_SETTINGS: DuckmagePluginSettings = { templatePath: "", hexGap: "0.15", terrainPalette: DEFAULT_TERRAIN_PALETTE, - gridSize: { - cols: 20, - rows: 16 - }, + gridSize: { cols: 20, rows: 16 }, + gridOffset: { x: 0, y: 0 }, zoomLevel: 1, }; +const DEFAULT_HEX_TEMPLATE = `--- +terrain: +--- + +# Hex {{x}}, {{y}} + +**Region:** +**Terrain:** + +--- + +### description + +What the party sees and feels. Terrain, atmosphere, any obvious features. + +--- + +### landmark + +The visible standout feature — spire, ruin, lighthouse, statue, village — that can be spotted or used for navigation. + +--- + +### Towns + +--- + +### Dungeons + +--- + +### Features + +--- + +### hidden + +Discoverable with exploration, tracking, or clues. Hidden lairs, ruins, tombs, camps, shortcuts. + +--- + +### secret + +Revealed only through specific actions, NPCs, or investigation. + +--- + +### encounters + +- **Table:** *(terrain from world/encounters)* +- **Custom / notable:** + +--- + +### weather + +Normal for region, or special (e.g. always pleasant, sandstorms, magic zone effect). + +--- + +### hooks & rumors + +Seeds for adventures, things locals might mention, or what finding this hex could lead to. +`; + +// ─── Plugin ─────────────────────────────────────────────────────────────────── + export default class DuckmagePlugin extends Plugin { settings: DuckmagePluginSettings; + availableIcons: string[] = []; async onload() { await this.loadSettings(); + await this.loadAvailableIcons(); this.registerView(VIEW_TYPE_HEX_MAP, (leaf) => new HexMapView(leaf, this)); - this.addRibbonIcon("map", "Duckmage: Open hex map", () => this.openHexMap()); - this.addCommand({ id: "open-hex-map", name: "Open Duckmage hex map", callback: () => this.openHexMap(), }); - - // Settings tab this.addSettingTab(new DuckmageSettingTab(this.app, this)); } - private openHexMap(): void { - const leaf = this.app.workspace.getLeaf(false); - leaf.setViewState({ type: VIEW_TYPE_HEX_MAP }); - } - onunload() {} + private openHexMap(): void { + this.app.workspace.getLeaf(false).setViewState({ type: VIEW_TYPE_HEX_MAP }); + } + async loadSettings() { const data = await this.loadData(); this.settings = Object.assign({}, DEFAULT_SETTINGS, data); + if (!this.settings.gridOffset) this.settings.gridOffset = { x: 0, y: 0 }; if (!Array.isArray(this.settings.terrainPalette) || this.settings.terrainPalette.length === 0) { this.settings.terrainPalette = [...DEFAULT_TERRAIN_PALETTE]; + } else { + // Merge in any new default entries not already present by name + const existing = new Set(this.settings.terrainPalette.map(e => e.name)); + for (const entry of DEFAULT_TERRAIN_PALETTE) { + if (!existing.has(entry.name)) { + this.settings.terrainPalette.push({ ...entry }); + } + } } } @@ -133,413 +340,29 @@ export default class DuckmagePlugin extends Plugin { await this.saveData(this.settings); } - /** Path for hex note at (x, y). */ + async loadAvailableIcons() { + try { + const result = await this.app.vault.adapter.list(`${this.manifest.dir}/icons`); + this.availableIcons = result.files + .filter(f => f.toLowerCase().endsWith(".png")) + .map(f => f.split("/").pop() as string) + .sort(); + } catch { + this.availableIcons = []; + } + } + hexPath(x: number, y: number): string { const folder = normalizeFolder(this.settings.hexFolder); return folder ? `${folder}/${x}_${y}.md` : `${x}_${y}.md`; } -} - -class HexMapView extends ItemView { - plugin: DuckmagePlugin; - gridCols: number; - gridRows: number; - - constructor(leaf: WorkspaceLeaf, plugin: DuckmagePlugin) { - super(leaf); - this.plugin = plugin; - this.gridCols = plugin.settings.gridSize.cols; - this.gridRows = plugin.settings.gridSize.rows; - } - - getViewType(): string { - return VIEW_TYPE_HEX_MAP; - } - - getDisplayText(): string { - return "Hex map"; - } - - async onOpen(): Promise { - this.renderGrid(); - } - - /** - * @param terrainOverrides - If set, use these terrain keys for the given paths instead of cache (for immediate UI update after set/clear). - */ - private renderGrid(terrainOverrides?: Map): void { - const { contentEl } = this; - contentEl.empty(); - contentEl.addClasses(["duckmage-hex-map-container"]); - const gap = this.plugin.settings.hexGap?.trim() || "0.15"; - contentEl.style.setProperty("--duckmage-hex-gap", /^\d*\.?\d+$/.test(gap) ? `${gap}em` : gap); - - const folder = normalizeFolder(this.plugin.settings.hexFolder); - const palette = this.plugin.settings.terrainPalette ?? []; - - // Create grid container - const gridContainer = contentEl.createDiv({ cls: "duckmage-hex-map-grid" }); - - for (let y = 0; y < this.gridRows; y++) { - const rowEl = gridContainer.createDiv({ cls: "duckmage-hex-row" }); - for (let x = 0; x < this.gridCols; x++) { - const path = folder ? `${folder}/${x}_${y}.md` : `${x}_${y}.md`; - const file = this.app.vault.getAbstractFileByPath(path); - const exists = file instanceof TFile; - const terrainKey = - terrainOverrides?.has(path) ? terrainOverrides.get(path)! : getTerrainFromFile(this.app, path); - const terrainColor = - terrainKey != null ? palette.find((p) => p.name === terrainKey)?.color : undefined; - - const hexEl = rowEl.createDiv({ - cls: `duckmage-hex ${exists ? "duckmage-hex-exists" : ""}`, - attr: { "data-x": String(x), "data-y": String(y) }, - }); - hexEl.tabIndex = -1; - if (terrainColor) hexEl.style.backgroundColor = terrainColor; - hexEl.createSpan({ cls: "duckmage-hex-label", text: `${x},${y}` }); - if (exists) hexEl.createSpan({ cls: "duckmage-hex-dot" }); - - hexEl.addEventListener("click", () => this.onHexClick(x, y)); - hexEl.addEventListener("dragstart", (e) => this.onHexDragStart(e, x, y)); - hexEl.addEventListener("dragover", (e) => this.onHexDragOver(e, x, y)); - hexEl.addEventListener("drop", (e) => this.onHexDrop(e, x, y)); - hexEl.addEventListener("contextmenu", (e) => this.onHexContextMenu(e, x, y)); - } - } - - /** - * Displays the terrain selection modal. - * Moving this into its own public function allows for future re-use or customization. - * Each terrain is rendered as a button with color preview. - */ - - } - private openTerrainModal ( - x: number, - y: number, - palette: Array<{ name: string; color: string }>, - hexEl: HTMLElement, - currentTerrain?: string - ) { - class TerrainModal extends Modal { - constructor(public app: App, public onChoose: (terrainName: string) => void, public palette: Array<{ name: string; color: string }>, public selected: string | undefined) { - super(app); - } - - onOpen() { - const { contentEl } = this; - contentEl.empty(); - contentEl.createEl("h3", { text: "Choose terrain" }); - - const list = contentEl.createDiv({ cls: "duckmage-terrain-list" }); - this.palette.forEach(entry => { - const btn = list.createEl("button", { - cls: "duckmage-terrain-btn" + (entry.name === this.selected ? " is-selected" : "") - }); - btn.style.display = "flex"; - btn.style.alignItems = "center"; - btn.style.gap = "0.5em"; - const swatch = btn.createSpan({ cls: "duckmage-terrain-color-preview" }); - swatch.style.backgroundColor = entry.color; - swatch.style.display = "inline-block"; - swatch.style.width = "18px"; - swatch.style.height = "18px"; - swatch.style.borderRadius = "3px"; - btn.createSpan({ text: entry.name, cls: "duckmage-terrain-name" }); - btn.onclick = async () => { - this.onChoose(entry.name); - this.close(); - }; - }); - // Optionally, add custom buttons here in the modal as needed - } - - onClose() { - this.contentEl.empty(); - } - } - - const modal = new TerrainModal(this.app, - async (terrainName: string) => { - hexEl.style.backgroundColor = palette.find(p => p.name === terrainName)?.color ?? ""; - let file = this.app.vault.getAbstractFileByPath(this.plugin.hexPath(x, y)); - if (!(file instanceof TFile)) { - file = await (this as any).createHexNote(x, y, this.plugin.hexPath(x, y)); - if (!file) { - await new Promise(resolve => setTimeout(resolve, 2000)); - file = this.app.vault.getAbstractFileByPath(this.plugin.hexPath(x, y)); - } - } - await setTerrainInFile(this.app, this.plugin.hexPath(x, y), terrainName); - this.renderGrid(new Map([[this.plugin.hexPath(x, y), terrainName]])); - }, - palette, - currentTerrain - ); - modal.open(); - }; - - private onHexContextMenu(evt: MouseEvent, x: number, y: number): void { - evt.preventDefault(); - const path = this.plugin.hexPath(x, y); - const palette = this.plugin.settings.terrainPalette ?? []; - const hexEl = evt.currentTarget as HTMLElement; - - const clearHighlight = () => { - hexEl.blur(); - window.getSelection()?.removeAllRanges(); - }; - - const menu = new Menu(); - - menu.addItem((item) => - item - .setTitle("Link file to hex") - .setIcon("link") - .onClick(async () => { - // Open file suggestion modal - // Use Obsidian's FileSuggest modal (if available) - let files: TFile[]; - const rootFolder = this.plugin.settings.worldFolder?.trim(); - if (rootFolder) { - const allFiles = this.app.vault.getFiles(); - const normalizedRoot = rootFolder.replace(/^\/+|\/+$/g, ""); - files = allFiles.filter(f => f.path.startsWith(normalizedRoot + "/") || f.path === normalizedRoot); - } else { - files = this.app.vault.getFiles(); - } - return new Promise((resolve) => { - const modal = new (class extends SuggestModal { - constructor(app: App) { - super(app); - this.setPlaceholder("Search for a file to link..."); - } - getSuggestions(query: string): TFile[] { - return files - .filter(f => f.basename.toLowerCase().contains(query.toLowerCase())) - .sort((a, b) => a.basename.localeCompare(b.basename)); - } - renderSuggestion(file: TFile, el: HTMLElement) { - el.createSpan({ text: file.basename }); - el.createEl("small", { text: ` (${file.path})`, cls: "duckmage-suggestion-path" }); - } - onChooseSuggestion(file: TFile, evt: MouseEvent | KeyboardEvent) { - // Find or create the hex note file representing this hex - const hexPath = this.app.vault.getAbstractFileByPath(path); - const targetFile = file; - const filesToEdit: { file: TFile; linkTo: TFile }[] = []; - - // Only proceed if both files are valid TFile objects - if (hexPath instanceof TFile && targetFile instanceof TFile) { - // Add files for mutual linking - filesToEdit.push({ file: hexPath, linkTo: targetFile }); - filesToEdit.push({ file: targetFile, linkTo: hexPath }); - } - - const getLinkText = (from: TFile, to: TFile) => { - // Create relative link path as per Obsidian linking (no .md) - const fromDir = from.path.substring(0, from.path.lastIndexOf('/')); - let rel = this.app.metadataCache.fileToLinktext(to, fromDir); - return `[[${rel}]]`; - }; - - const addLinkIfNotPresent = async (file: TFile, linkTo: TFile) => { - const linkText = getLinkText(file, linkTo); - let content = await this.app.vault.read(file); - - // Only add link if not already present (as link) - if (!content.includes(linkText)) { - // Add the link at the end - content = `${content.trim()}\n\n${linkText}\n`; - await this.app.vault.modify(file, content); - } - }; - - Promise.all(filesToEdit.map(({file, linkTo}) => addLinkIfNotPresent(file, linkTo))) - .then(() => { - resolve(); - // Optionally re-render grid - if (this && typeof (this as any).owner?.renderGrid === 'function') { - (this as any).owner.renderGrid(); - } else if ((this as any).app && typeof (this as any).app.workspace?.trigger === "function") { - (this as any).app.workspace.trigger('duckmage:refresh'); - } - }) - .catch((err) => { - console.error('Failed to link files:', err); - resolve(); - }); - } - })(this.app); - modal.open(); - }); - }) - ); - - - - // Get the current terrain key for this hex - const currentTerrainKey = getTerrainFromFile(this.app, path); - - menu.addItem((item) => - item - .setTitle("Set Terrain") - .setIcon("palette") - .onClick(() => { - this.openTerrainModal(x, y, palette, hexEl, currentTerrainKey as string); - }) - ); - - //const terrainMenu = new Menu(); - // for (const entry of palette) { - // terrainMenu.addItem((item) => - // item - // .setTitle(entry.name) - // .setIcon("palette") - // .onClick(async () => { - // clearHighlight(); - // let file = this.app.vault.getAbstractFileByPath(path); - // hexEl.style.backgroundColor = entry.color; - // if (!(file instanceof TFile)) { - // file = await this.createHexNote(x, y, path); - // if (!file) { - // await new Promise(resolve => setTimeout(resolve, 2000)); - // file = this.app.vault.getAbstractFileByPath(path); - // } - // } - // await setTerrainInFile(this.app, path, entry.name); - // this.renderGrid(new Map([[path, entry.name]])); - // }) - // ); - // } - - // menu.addItem((item) => - // item - // .setTitle("Terrain") - // .setIcon("palette") - // .onClick((evt) => { - // terrainMenu.showAtMouseEvent(evt as MouseEvent); - // }) - // ); - - // for (const entry of palette) { - // menu.addItem((item) => - // item - // .setTitle(entry.name) - // .setIcon("palette") - // .onClick(async () => { - // clearHighlight(); - // let file = this.app.vault.getAbstractFileByPath(path); - // hexEl.style.backgroundColor = entry.color; - // if (!(file instanceof TFile)) { - // file = await this.createHexNote(x, y, path); - // if (!file) { - // //temporarily assign - // //wait and get the file - // await new Promise(resolve => setTimeout(resolve, 2000)); - // file = this.app.vault.getAbstractFileByPath(path); - // } - // } - // await setTerrainInFile(this.app, path, entry.name); - // this.renderGrid(new Map([[path, entry.name]])); - // }) - // ); - // } - - - - - - - // //allow adding links to the hex for landmarks/towns/dungeons etc - // //todo instead of prompt have user search for a file to link - // menu.addItem((item) => - // item - // .setTitle("Add landmark link") - // .setIcon("link") - // .onClick(async () => { - // //todo search for a link - // const landmarkLink = - // if (landmarkLink) { - // const landmarkLinks = JSON.parse(hexEl.getAttribute("data-landmark-links") || "[]"); - // landmarkLinks.push(landmarkLink); - // hexEl.setAttribute("data-landmark-links", JSON.stringify(landmarkLinks)); - // this.renderGrid(); - // } - // }) - // ); - // //allow showing list of landmark links - // menu.addItem((item) => - // item - // .setTitle("Landmark links") - // .setIcon("link") - // .onClick(async () => { - // const landmarkLinks = JSON.parse(hexEl.getAttribute("data-landmark-links") || "[]"); - // const menu = new Menu(); - // for (const link of landmarkLinks) { - // menu.addItem((item) => - // item - // .setTitle(link) - // .setIcon("link") - // .onClick(async () => { - // const leaf = this.app.workspace.getLeaf(false); - // await leaf.openFile(link); - // }) - // ); - // } - // menu.showAtMouseEvent(evt); - // }) - // ); - menu.showAtMouseEvent(evt); - } - - private async onHexClick(x: number, y: number): Promise { - const path = this.plugin.hexPath(x, y); - const abstract = this.app.vault.getAbstractFileByPath(path); - let fileToOpen: TFile | null = abstract instanceof TFile ? abstract : null; - - if (!fileToOpen) { - const created = await this.createHexNote(x, y, path); - if (created) { - fileToOpen = created; - this.renderGrid(); - } else return; - } - - const leaf = this.app.workspace.getLeaf(false); - await leaf.openFile(fileToOpen); - } - - private async onHexDragStart(evt: DragEvent, x: number, y: number): Promise { - const path = this.plugin.hexPath(x, y); - const file = this.app.vault.getAbstractFileByPath(path); - if (!(file instanceof TFile)) return; - //check if dataTransfer is null - if (evt.dataTransfer) { - evt.dataTransfer.setData("text/plain", path); - } - } - - private async onHexDragOver(evt: DragEvent, x: number, y: number): Promise { - const path = this.plugin.hexPath(x, y); - const file = this.app.vault.getAbstractFileByPath(path); - if (!(file instanceof TFile)) return; - evt.preventDefault(); - } - - private async onHexDrop(evt: DragEvent, x: number, y: number): Promise { - const path = this.plugin.hexPath(x, y); - const file = this.app.vault.getAbstractFileByPath(path); - if (!(file instanceof TFile)) return; - evt.preventDefault(); - } - - private async createHexNote(x: number, y: number, path: string): Promise { - const templatePath = (this.plugin.settings.templatePath ?? "").replace(/^\/+|\/+$/g, ""); - const DEFAULT_TEMPLATE = `# Hex {{x}}, {{y}}\n\n`; + /** Create a hex note from the configured template (or the built-in default). */ + async createHexNote(x: number, y: number): Promise { + const path = this.hexPath(x, y); + const templatePath = (this.settings.templatePath ?? "").replace(/^\/+|\/+$/g, ""); let content: string; + if (templatePath) { try { content = await this.app.vault.adapter.read(templatePath); @@ -548,7 +371,7 @@ class HexMapView extends ItemView { return null; } } else { - content = DEFAULT_TEMPLATE; + content = DEFAULT_HEX_TEMPLATE; } content = content @@ -556,15 +379,13 @@ class HexMapView extends ItemView { .replace(/\{\{y\}\}/g, String(y)) .replace(/\{\{title\}\}/g, `Hex ${x}, ${y}`); - const folder = normalizeFolder(this.plugin.settings.hexFolder); - if (folder) { - const folderObj = this.app.vault.getAbstractFileByPath(folder); - if (!folderObj) await this.app.vault.createFolder(folder); + const folder = normalizeFolder(this.settings.hexFolder); + if (folder && !this.app.vault.getAbstractFileByPath(folder)) { + await this.app.vault.createFolder(folder); } try { - const created = await this.app.vault.create(path, content); - return created; + return await this.app.vault.create(path, content); } catch (e) { new Notice("Could not create note: " + (e instanceof Error ? e.message : String(e))); return null; @@ -572,22 +393,407 @@ class HexMapView extends ItemView { } } -class SampleModal extends Modal { - constructor(app: App) { +// ─── Hex Map View ───────────────────────────────────────────────────────────── + +class HexMapView extends ItemView { + plugin: DuckmagePlugin; + private zoom = 1; + private panX = 0; + private panY = 0; + private viewportEl: HTMLElement | null = null; + + constructor(leaf: WorkspaceLeaf, plugin: DuckmagePlugin) { + super(leaf); + this.plugin = plugin; + } + + getViewType(): string { return VIEW_TYPE_HEX_MAP; } + getDisplayText(): string { return "Hex map"; } + + async onOpen(): Promise { + const { contentEl } = this; + contentEl.addClass("duckmage-hex-map-container"); + + this.viewportEl = contentEl.createDiv({ cls: "duckmage-hex-map-viewport" }); + this.applyTransform(); + + // ── Zoom (scroll wheel, no modifier required) ────────────────────────── + this.registerDomEvent(contentEl, "wheel", (e: WheelEvent) => { + e.preventDefault(); + const rect = contentEl.getBoundingClientRect(); + const cx = e.clientX - rect.left; + const cy = e.clientY - rect.top; + const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12; + const newZoom = Math.min(5, Math.max(0.2, this.zoom * factor)); + this.panX = cx - (cx - this.panX) * (newZoom / this.zoom); + this.panY = cy - (cy - this.panY) * (newZoom / this.zoom); + this.zoom = newZoom; + this.applyTransform(); + }, { passive: false }); + + // ── Pan (click-drag) ─────────────────────────────────────────────────── + let isDragging = false; + let hasDragged = false; + let dragStartX = 0, dragStartY = 0, panStartX = 0, panStartY = 0; + + this.registerDomEvent(contentEl, "mousedown", (e: MouseEvent) => { + if (e.button !== 0) return; + isDragging = true; + hasDragged = false; + dragStartX = e.clientX; + dragStartY = e.clientY; + panStartX = this.panX; + panStartY = this.panY; + this.viewportEl?.addClass("is-dragging"); + }); + + this.registerDomEvent(document, "mousemove", (e: MouseEvent) => { + if (!isDragging) return; + const dx = e.clientX - dragStartX; + const dy = e.clientY - dragStartY; + if (!hasDragged && (Math.abs(dx) > 4 || Math.abs(dy) > 4)) hasDragged = true; + if (hasDragged) { + this.panX = panStartX + dx; + this.panY = panStartY + dy; + this.applyTransform(); + } + }); + + this.registerDomEvent(document, "mouseup", () => { + isDragging = false; + this.viewportEl?.removeClass("is-dragging"); + }); + + // Swallow clicks that ended a drag so hex click-handlers don't fire + this.registerDomEvent(contentEl, "click", (e: MouseEvent) => { + if (hasDragged) { e.stopPropagation(); hasDragged = false; } + }, { capture: true } as AddEventListenerOptions); + + this.createExpandButtons(contentEl); + this.renderGrid(); + } + + private createExpandButtons(container: HTMLElement): void { + const dirs = [ + { + cls: "duckmage-expand-top", + action: async () => { + this.plugin.settings.gridOffset.y--; + this.plugin.settings.gridSize.rows++; + await this.plugin.saveSettings(); + this.renderGrid(); + }, + }, + { + cls: "duckmage-expand-bottom", + action: async () => { + this.plugin.settings.gridSize.rows++; + await this.plugin.saveSettings(); + this.renderGrid(); + }, + }, + { + cls: "duckmage-expand-left", + action: async () => { + this.plugin.settings.gridOffset.x--; + this.plugin.settings.gridSize.cols++; + await this.plugin.saveSettings(); + this.renderGrid(); + }, + }, + { + cls: "duckmage-expand-right", + action: async () => { + this.plugin.settings.gridSize.cols++; + await this.plugin.saveSettings(); + this.renderGrid(); + }, + }, + ]; + for (const { cls, action } of dirs) { + const btn = container.createEl("button", { cls: `duckmage-expand-btn ${cls}`, text: "+" }); + btn.addEventListener("click", action); + } + } + + private applyTransform(): void { + if (this.viewportEl) { + this.viewportEl.style.transform = + `translate(${this.panX}px, ${this.panY}px) scale(${this.zoom})`; + } + } + + renderGrid(terrainOverrides?: Map): void { + if (!this.viewportEl) return; + this.viewportEl.empty(); + + const gap = this.plugin.settings.hexGap?.trim() || "0.15"; + this.viewportEl.style.setProperty("--duckmage-hex-gap", /^\d*\.?\d+$/.test(gap) ? `${gap}em` : gap); + + const { cols, rows } = this.plugin.settings.gridSize; + const { x: ox, y: oy } = this.plugin.settings.gridOffset; + const folder = normalizeFolder(this.plugin.settings.hexFolder); + const palette = this.plugin.settings.terrainPalette ?? []; + const gridContainer = this.viewportEl.createDiv({ cls: "duckmage-hex-map-grid" }); + + for (let j = 0; j < rows; j++) { + const y = oy + j; + // Stagger based on the actual y coordinate so adding rows above never shifts the pattern + const rowEl = gridContainer.createDiv({ + cls: `duckmage-hex-row${y % 2 !== 0 ? " duckmage-hex-row-offset" : ""}`, + }); + for (let i = 0; i < cols; i++) { + const x = ox + i; + const path = folder ? `${folder}/${x}_${y}.md` : `${x}_${y}.md`; + const exists = this.app.vault.getAbstractFileByPath(path) instanceof TFile; + const terrainKey = terrainOverrides?.has(path) + ? terrainOverrides.get(path)! + : getTerrainFromFile(this.app, path); + const terrainEntry = terrainKey != null ? palette.find(p => p.name === terrainKey) : undefined; + + const hexEl = rowEl.createDiv({ + cls: `duckmage-hex${exists ? " duckmage-hex-exists" : ""}`, + attr: { "data-x": String(x), "data-y": String(y) }, + }); + hexEl.tabIndex = -1; + + if (terrainEntry?.color) hexEl.style.backgroundColor = terrainEntry.color; + + if (terrainEntry?.icon) { + const img = hexEl.createEl("img", { cls: "duckmage-hex-icon" }); + img.src = getIconUrl(this.plugin, terrainEntry.icon); + img.alt = terrainEntry.name; + } + + hexEl.createSpan({ cls: "duckmage-hex-label", text: `${x},${y}` }); + if (exists) hexEl.createSpan({ cls: "duckmage-hex-dot" }); + + hexEl.addEventListener("click", () => this.onHexClick(x, y)); + hexEl.addEventListener("contextmenu", (evt) => this.onHexContextMenu(evt, x, y)); + } + } + } + + private onHexContextMenu(evt: MouseEvent, x: number, y: number): void { + evt.preventDefault(); + new HexEditorModal(this.app, this.plugin, x, y, (overrides) => this.renderGrid(overrides)).open(); + } + + private async onHexClick(x: number, y: number): Promise { + const path = this.plugin.hexPath(x, y); + const abstract = this.app.vault.getAbstractFileByPath(path); + let fileToOpen: TFile | null = abstract instanceof TFile ? abstract : null; + + if (!fileToOpen) { + fileToOpen = await this.plugin.createHexNote(x, y); + if (fileToOpen) this.renderGrid(); + else return; + } + + await this.app.workspace.getLeaf(false).openFile(fileToOpen); + } +} + +// ─── Hex Editor Modal ───────────────────────────────────────────────────────── + +class HexEditorModal extends Modal { + constructor( + app: App, + private plugin: DuckmagePlugin, + private x: number, + private y: number, + private onChanged: (overrides?: Map) => void, + ) { super(app); } - onOpen() { - const { contentEl } = this; - contentEl.setText("Sample modal — edit main.ts to customize."); - } - - onClose() { + async onOpen() { const { contentEl } = this; contentEl.empty(); + contentEl.addClass("duckmage-hex-editor"); + contentEl.createEl("h2", { text: `Hex ${this.x}, ${this.y}` }); + + const path = this.plugin.hexPath(this.x, this.y); + const hexExists = this.app.vault.getAbstractFileByPath(path) instanceof TFile; + + this.renderTerrainSection(contentEl, path); + + contentEl.createEl("hr", { cls: "duckmage-editor-divider" }); + contentEl.createEl("h3", { text: "World features" }); + + for (const section of LINK_SECTIONS) { + await this.renderLinkSection(contentEl, path, section, hexExists); + } + + contentEl.createEl("hr", { cls: "duckmage-editor-divider" }); + contentEl.createEl("h3", { text: "Notes" }); + + for (const { key, label } of TEXT_SECTIONS) { + await this.renderTextSection(contentEl, path, key, label); + } + } + + onClose() { this.contentEl.empty(); } + + private renderTerrainSection(container: HTMLElement, path: string): void { + const currentTerrain = getTerrainFromFile(this.app, path); + const palette = this.plugin.settings.terrainPalette; + + const section = container.createDiv({ cls: "duckmage-editor-section" }); + section.createEl("h3", { text: "Terrain" }); + + const grid = section.createDiv({ cls: "duckmage-terrain-picker" }); + + for (const entry of palette) { + const btn = grid.createDiv({ + cls: `duckmage-terrain-option${entry.name === currentTerrain ? " is-selected" : ""}`, + }); + + const preview = btn.createDiv({ cls: "duckmage-terrain-preview" }); + preview.style.backgroundColor = entry.color; + + if (entry.icon) { + const img = preview.createEl("img", { cls: "duckmage-terrain-preview-icon" }); + img.src = getIconUrl(this.plugin, entry.icon); + img.alt = entry.name; + } + + btn.createSpan({ text: entry.name, cls: "duckmage-terrain-option-name" }); + + btn.addEventListener("click", async () => { + await this.ensureHexNote(); + await setTerrainInFile(this.app, path, entry.name); + this.onChanged(new Map([[path, entry.name]])); + this.close(); + }); + } + + if (currentTerrain) { + const clearBtn = section.createEl("button", { text: "Clear terrain", cls: "duckmage-clear-btn mod-warning" }); + clearBtn.addEventListener("click", async () => { + await setTerrainInFile(this.app, path, null); + this.onChanged(new Map([[path, null]])); + this.close(); + }); + } + } + + private async renderLinkSection( + container: HTMLElement, + path: string, + section: LinkSection, + hexExists: boolean, + ): Promise { + const sectionEl = container.createDiv({ cls: "duckmage-editor-link-section" }); + const header = sectionEl.createDiv({ cls: "duckmage-link-section-header" }); + header.createEl("h4", { text: section }); + const addBtn = header.createEl("button", { text: "+ Add", cls: "duckmage-add-btn" }); + const linksEl = sectionEl.createDiv({ cls: "duckmage-link-list" }); + + if (hexExists) { + const links = await getLinksInSection(this.app, path, section); + this.renderLinkList(linksEl, links); + } else { + linksEl.createSpan({ text: "—", cls: "duckmage-link-empty" }); + } + + addBtn.addEventListener("click", () => { + new FileLinkSuggestModal(this.app, this.plugin, async (file) => { + const hexFile = await this.ensureHexNote(); + if (!hexFile) { new Notice("Could not create hex note."); return; } + const linkText = `[[${this.app.metadataCache.fileToLinktext(file, path)}]]`; + await addLinkToSection(this.app, path, section, linkText); + this.onChanged(); + const links = await getLinksInSection(this.app, path, section); + linksEl.empty(); + this.renderLinkList(linksEl, links); + }).open(); + }); + } + + private renderLinkList(container: HTMLElement, links: string[]): void { + if (links.length === 0) { + container.createSpan({ text: "None", cls: "duckmage-link-empty" }); + } else { + for (const link of links) { + container.createDiv({ text: `[[${link}]]`, cls: "duckmage-link-item" }); + } + } + } + + private async renderTextSection( + container: HTMLElement, + path: string, + section: string, + label: string, + ): Promise { + const hexFile = this.app.vault.getAbstractFileByPath(path); + const currentContent = hexFile instanceof TFile + ? await getSectionContent(this.app, path, section) + : ""; + + const sectionEl = container.createDiv({ cls: "duckmage-editor-text-section" }); + sectionEl.createEl("label", { text: label, cls: "duckmage-text-section-label" }); + const textarea = sectionEl.createEl("textarea", { cls: "duckmage-text-section-textarea" }); + textarea.rows = 3; + textarea.placeholder = `${label}…`; + textarea.value = currentContent; + + textarea.addEventListener("blur", async () => { + const file = await this.ensureHexNote(); + if (!file) return; + await setSectionContent(this.app, path, section, textarea.value); + this.onChanged(); + }); + } + + private async ensureHexNote(): Promise { + const path = this.plugin.hexPath(this.x, this.y); + const existing = this.app.vault.getAbstractFileByPath(path); + if (existing instanceof TFile) return existing; + return this.plugin.createHexNote(this.x, this.y); } } +// ─── File Suggest Modal ─────────────────────────────────────────────────────── + +class FileLinkSuggestModal extends SuggestModal { + constructor( + app: App, + private plugin: DuckmagePlugin, + private onChoose: (file: TFile) => void, + ) { + super(app); + this.setPlaceholder("Search for a file to link..."); + } + + getSuggestions(query: string): TFile[] { + const rootFolder = normalizeFolder(this.plugin.settings.worldFolder?.trim() ?? ""); + let files: TFile[]; + if (rootFolder) { + files = this.app.vault.getFiles().filter( + f => f.path.startsWith(rootFolder + "/") || f.path === rootFolder, + ); + } else { + files = this.app.vault.getFiles(); + } + return files + .filter(f => f.basename.toLowerCase().contains(query.toLowerCase())) + .sort((a, b) => a.basename.localeCompare(b.basename)); + } + + renderSuggestion(file: TFile, el: HTMLElement): void { + el.createSpan({ text: file.basename }); + el.createEl("small", { text: ` — ${file.path}`, cls: "duckmage-suggestion-path" }); + } + + onChooseSuggestion(file: TFile, _evt: MouseEvent | KeyboardEvent): void { + this.onChoose(file); + } +} + +// ─── Settings ───────────────────────────────────────────────────────────────── + class DuckmageSettingTab extends PluginSettingTab { plugin: DuckmagePlugin; @@ -598,125 +804,142 @@ class DuckmageSettingTab extends PluginSettingTab { display(): void { const { containerEl } = this; - containerEl.empty(); new Setting(containerEl) - .setName("World notes folder") - .setDesc("Vault-relative path where world files are stored.") - .addText((text) => - text - .setPlaceholder("world") - .setValue(this.plugin.settings.worldFolder) - .onChange(async (value) => { - this.plugin.settings.worldFolder = normalizeFolder(value ?? ""); - await this.plugin.saveSettings(); - }) - ); + .setName("World notes folder") + .setDesc("Vault-relative path. Scopes the file search when adding links to hexes.") + .addText(text => + text + .setPlaceholder("world") + .setValue(this.plugin.settings.worldFolder) + .onChange(async value => { + this.plugin.settings.worldFolder = normalizeFolder(value ?? ""); + await this.plugin.saveSettings(); + }), + ); new Setting(containerEl) .setName("Hex notes folder") .setDesc("Vault-relative path where hex notes (x_y.md) are stored.") - .addText((text) => + .addText(text => text .setPlaceholder("world/hexes") .setValue(this.plugin.settings.hexFolder) - .onChange(async (value) => { + .onChange(async value => { this.plugin.settings.hexFolder = normalizeFolder(value ?? ""); await this.plugin.saveSettings(); - }) + }), ); new Setting(containerEl) .setName("Template path") - .setDesc("Vault-relative path to the template file for new hex notes. Use {{x}}, {{y}}, {{title}} as placeholders.") - .addText((text) => + .setDesc("Vault-relative path to a hex note template. Supports {{x}}, {{y}}, {{title}}. Include ## Towns, ## Dungeons, and ## Features headings for the link sections.") + .addText(text => text .setPlaceholder("templates/hex.md") .setValue(this.plugin.settings.templatePath) - .onChange(async (value) => { + .onChange(async value => { this.plugin.settings.templatePath = (value ?? "").replace(/^\/+|\/+$/g, ""); await this.plugin.saveSettings(); - }) + }), ); new Setting(containerEl) - .setName("Grid Width") - .setDesc("Width of Grid") - .addText((text)=> - text.setPlaceholder("16") - .setValue(String(this.plugin.settings.gridSize.cols) ?? "16") - .onChange(async (value) => { - this.plugin.settings.gridSize.cols = (Number(value.trim()) ?? 16) || 16; + .setName("Grid width") + .setDesc("Number of hex columns.") + .addText(text => + text + .setPlaceholder("20") + .setValue(String(this.plugin.settings.gridSize.cols)) + .onChange(async value => { + this.plugin.settings.gridSize.cols = Number(value.trim()) || 20; await this.plugin.saveSettings(); - }) - ) + }), + ); + new Setting(containerEl) - .setName("Grid Height") - .setDesc("Height of Grid") - .addText((text)=> - text.setPlaceholder("20") - .setValue(String(this.plugin.settings.gridSize.rows) ?? "20") - .onChange(async (value) => { - this.plugin.settings.gridSize.rows = (Number(value.trim()) ?? 20) || 20; + .setName("Grid height") + .setDesc("Number of hex rows.") + .addText(text => + text + .setPlaceholder("16") + .setValue(String(this.plugin.settings.gridSize.rows)) + .onChange(async value => { + this.plugin.settings.gridSize.rows = Number(value.trim()) || 16; await this.plugin.saveSettings(); - }) - ) + }), + ); new Setting(containerEl) .setName("Hex cell spacing") - .setDesc("Gap between hex cells (e.g. 0.15 for a small gap, 0 for none). Use a number for em units, or add 'em'/'px' (e.g. 0.2em).") - .addText((text) => + .setDesc("Gap between hex cells. A bare number is treated as em units (e.g. 0.15), or append em/px.") + .addText(text => text .setPlaceholder("0.15") .setValue(this.plugin.settings.hexGap ?? "0.15") - .onChange(async (value) => { + .onChange(async value => { this.plugin.settings.hexGap = (value ?? "0.15").trim() || "0"; await this.plugin.saveSettings(); - }) + }), ); containerEl.createEl("h3", { text: "Terrain palette" }); - const paletteDesc = containerEl.createDiv({ cls: "setting-item-description" }); - paletteDesc.setText("Right-click a hex to set its color. Add or edit terrain types and colors below."); - paletteDesc.style.marginBottom = "0.75em"; + containerEl.createEl("p", { + text: "Right-click a hex to set terrain. Each type can have a fill color and an icon from the plugin's icons folder.", + cls: "setting-item-description", + }); const listEl = containerEl.createDiv({ cls: "duckmage-palette-list" }); const palette = this.plugin.settings.terrainPalette ?? []; + for (let i = 0; i < palette.length; i++) { const entry = palette[i]; const itemEl = listEl.createDiv({ cls: "duckmage-palette-item" }); + new Setting(itemEl) - .addText((text) => + .addText(text => text - .setPlaceholder("Name (e.g. mountain)") + .setPlaceholder("Name") .setValue(entry.name) - .onChange(async (value) => { + .onChange(async value => { entry.name = (value ?? "").trim() || entry.name; await this.plugin.saveSettings(); - }) + }), ) - .addColorPicker((color) => { - color.setValue(entry.color).onChange(async (value) => { + .addColorPicker(color => + color.setValue(entry.color).onChange(async value => { entry.color = value; await this.plugin.saveSettings(); + }), + ) + .addDropdown(dropdown => { + dropdown.addOption("", "— no icon —"); + for (const icon of this.plugin.availableIcons) { + const label = icon.replace(/^bw-/, "").replace(/\.png$/, "").replace(/-/g, " "); + dropdown.addOption(icon, label); + } + dropdown.setValue(entry.icon ?? ""); + dropdown.onChange(async value => { + entry.icon = value || undefined; + await this.plugin.saveSettings(); }); }) - .addExtraButton((btn) => + .addExtraButton(btn => btn.setIcon("trash-2").onClick(async () => { this.plugin.settings.terrainPalette.splice(i, 1); await this.plugin.saveSettings(); this.display(); - }) + }), ); } - new Setting(containerEl).addButton((btn) => - btn.setButtonText("Add terrain color").onClick(async () => { + new Setting(containerEl).addButton(btn => + btn.setButtonText("Add terrain type").onClick(async () => { this.plugin.settings.terrainPalette.push({ name: "New", color: "#888888" }); await this.plugin.saveSettings(); this.display(); - }) + }), ); } } diff --git a/styles.css b/styles.css index c7f51a8..8069ebc 100644 --- a/styles.css +++ b/styles.css @@ -1,31 +1,41 @@ /* Duckmage plugin styles */ -/* Hex map view */ +/* ── Hex map view ─────────────────────────────────────────────────────────── */ + .duckmage-hex-map-container { + overflow: hidden; + position: relative; + width: 100%; + height: 100%; +} + +.duckmage-hex-map-viewport { + position: absolute; + top: 0; + left: 0; + transform-origin: 0 0; + will-change: transform; + cursor: grab; padding: 1em; - overflow: auto; +} + +.duckmage-hex-map-viewport.is-dragging { + cursor: grabbing; } .duckmage-hex-row { display: flex; flex-wrap: nowrap; justify-content: flex-start; - /* Flat-top hex: vertical step is 3/4 of hex height, so pull next row up by 1/4 of full height = half of --hex-size */ margin-bottom: calc(var(--hex-size, 2.2em) * -0.5); } -.duckmage-hex-row:nth-child(even) { - /* Offset even rows by exactly half hex width so they tessellate */ +.duckmage-hex-row-offset { margin-left: calc(var(--hex-size, 2.2em) * 1.732 / 2); } -.duckmage-hex:focus { - outline: none; -} - .duckmage-hex { --hex-size: 2.2em; - /* Flat-top regular hex: width = sqrt(3)*size, height = 2*size */ width: calc(var(--hex-size) * 1.732); height: calc(var(--hex-size) * 2); clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%); @@ -41,14 +51,15 @@ transition: background-color 0.15s ease, border-color 0.15s ease; } +.duckmage-hex:focus { outline: none; } + .duckmage-hex:hover { background: var(--background-modifier-hover); border-color: var(--interactive-accent); } -/* Has-note state: accent border and dot only (no purple fill so terrain/clear stays clear) */ .duckmage-hex-exists { - border-color: var(--interactive-accent); + border-color: var(--background-modifier-border-hover); } .duckmage-hex-exists:hover { @@ -56,12 +67,29 @@ background: var(--background-modifier-hover); } +/* Terrain icon fills the hex, sits behind the label */ +.duckmage-hex-icon { + position: absolute; + width: 78%; + height: 78%; + object-fit: contain; + pointer-events: none; + opacity: 0.75; +} + .duckmage-hex-label { - font-size: 0.65em; + position: relative; /* above icon */ + font-size: 0.6em; font-weight: 600; color: var(--text-muted); pointer-events: none; user-select: none; + z-index: 1; +} + +/* Keep label readable over terrain color */ +.duckmage-hex[style*="background-color"] .duckmage-hex-label { + text-shadow: 0 0 3px var(--background-primary), 0 0 6px var(--background-primary); } .duckmage-hex-dot { @@ -72,14 +100,205 @@ border-radius: 50%; background: var(--interactive-accent); pointer-events: none; + z-index: 1; } -/* Terrain-colored hex: keep label readable (text shadow if needed) */ -.duckmage-hex[style*="background-color"] .duckmage-hex-label { - text-shadow: 0 0 2px var(--background-primary), 0 0 4px var(--background-primary); +/* ── Hex editor modal ─────────────────────────────────────────────────────── */ + +.duckmage-hex-editor h2 { + margin-bottom: 0.75em; } -/* Settings: terrain palette list */ +.duckmage-hex-editor h3 { + margin: 0.25em 0 0.5em; +} + +.duckmage-hex-editor h4 { + margin: 0; + font-size: 0.9em; + font-weight: 600; +} + +.duckmage-editor-divider { + margin: 1em 0; + border: none; + border-top: 1px solid var(--background-modifier-border); +} + +.duckmage-editor-section { + margin-bottom: 1em; +} + +/* Terrain picker grid */ +.duckmage-terrain-picker { + display: flex; + flex-wrap: wrap; + gap: 0.4em; + margin-bottom: 0.75em; +} + +.duckmage-terrain-option { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.25em; + padding: 0.35em; + border-radius: 6px; + cursor: pointer; + border: 2px solid transparent; + background: var(--background-secondary); + transition: border-color 0.1s ease, background 0.1s ease; + min-width: 52px; +} + +.duckmage-terrain-option:hover { + background: var(--background-modifier-hover); + border-color: var(--interactive-accent); +} + +.duckmage-terrain-option.is-selected { + border-color: var(--interactive-accent); + background: var(--background-modifier-hover); +} + +.duckmage-terrain-preview { + width: 36px; + height: 36px; + border-radius: 4px; + position: relative; + overflow: hidden; + flex-shrink: 0; +} + +.duckmage-terrain-preview-icon { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: contain; +} + +.duckmage-terrain-option-name { + font-size: 0.7em; + text-align: center; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 54px; +} + +.duckmage-clear-btn { + margin-top: 0.5em; +} + +/* Link sections */ +.duckmage-editor-link-section { + margin-bottom: 0.85em; +} + +.duckmage-link-section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.3em; +} + +.duckmage-add-btn { + font-size: 0.8em; + padding: 0.15em 0.5em; +} + +.duckmage-link-list { + display: flex; + flex-direction: column; + gap: 0.2em; + padding-left: 0.5em; +} + +.duckmage-link-item { + font-size: 0.85em; + color: var(--text-accent); +} + +.duckmage-link-empty { + font-size: 0.85em; + color: var(--text-faint); +} + +.duckmage-suggestion-path { + color: var(--text-muted); +} + +/* ── Expand buttons ───────────────────────────────────────────────────────── */ + +.duckmage-expand-btn { + position: absolute; + z-index: 10; + width: 26px; + height: 26px; + padding: 0; + border-radius: 50%; + background: var(--background-secondary); + border: 1px solid var(--background-modifier-border); + color: var(--text-muted); + font-size: 1.1em; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + opacity: 0.5; + transition: opacity 0.15s ease, color 0.15s ease; +} + +.duckmage-expand-btn:hover { + opacity: 1; + color: var(--text-normal); +} + +.duckmage-expand-top { top: 4px; left: 50%; transform: translateX(-50%); } +.duckmage-expand-bottom { bottom: 4px; left: 50%; transform: translateX(-50%); } +.duckmage-expand-left { left: 4px; top: 50%; transform: translateY(-50%); } +.duckmage-expand-right { right: 4px; top: 50%; transform: translateY(-50%); } + +/* Text note sections */ +.duckmage-editor-text-section { + display: flex; + flex-direction: column; + gap: 0.25em; + margin-bottom: 0.85em; +} + +.duckmage-text-section-label { + font-size: 0.85em; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.duckmage-text-section-textarea { + width: 100%; + resize: vertical; + font-size: 0.9em; + font-family: var(--font-text); + background: var(--background-secondary); + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + padding: 0.4em 0.5em; + color: var(--text-normal); + line-height: 1.5; + box-sizing: border-box; +} + +.duckmage-text-section-textarea:focus { + outline: none; + border-color: var(--interactive-accent); +} + +/* ── Settings: terrain palette ────────────────────────────────────────────── */ + .duckmage-palette-list .duckmage-palette-item { margin-bottom: 0.5em; }