mirror of
https://github.com/nossimonov/obsidian-chartdown.git
synced 2026-07-22 08:38:09 +00:00
Release 0.1.1
This commit is contained in:
parent
23e51a867f
commit
1b2a69b437
10 changed files with 457 additions and 3 deletions
1
chartdown
Submodule
1
chartdown
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit cc691858d0788634000aba838ee6ec7e3373f32c
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
{
|
||||
"id": "chartdown",
|
||||
"name": "Chartdown",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"minAppVersion": "1.5.0",
|
||||
"description": "Render Chartdown map blocks (battlemaps, hex charts, region maps) as SVG, right in your notes.",
|
||||
"author": "Nossimonov",
|
||||
"authorUrl": "https://github.com/Nossimonov/Chartdown",
|
||||
"authorUrl": "https://github.com/Nossimonov",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
|
|
|
|||
116
src/block.test.ts
Normal file
116
src/block.test.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mountChartdownBlock, type BlockIO } from "./block";
|
||||
|
||||
const SOURCE = [
|
||||
"# Test Map",
|
||||
"map: battlemap",
|
||||
"grid: square 6x6",
|
||||
"[structures]",
|
||||
'building shed "Shed" : B2..D4',
|
||||
" door : C4.s",
|
||||
"[tokens]",
|
||||
'ogre "Gruk" : E5 hidden',
|
||||
].join("\n");
|
||||
|
||||
interface Written {
|
||||
name: string;
|
||||
contents: string;
|
||||
}
|
||||
|
||||
function makeIO(withRaster = false): { io: BlockIO; written: Written[]; notices: string[]; revealed: string[] } {
|
||||
const written: Written[] = [];
|
||||
const notices: string[] = [];
|
||||
const revealed: string[] = [];
|
||||
const io: BlockIO = {
|
||||
writeFile: async (name, contents) => {
|
||||
written.push({ name, contents });
|
||||
},
|
||||
notify: (m) => notices.push(m),
|
||||
reveal: (name) => revealed.push(name),
|
||||
...(withRaster
|
||||
? {
|
||||
rasterize: async () => "FAKEPNGBASE64",
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
return { io, written, notices, revealed };
|
||||
}
|
||||
|
||||
const flush = (): Promise<void> => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
describe("the per-map toolbar", () => {
|
||||
it("toggles GM view in place, re-rendering the map", () => {
|
||||
const el = document.createElement("div");
|
||||
mountChartdownBlock(SOURCE, el, { initialMode: "player", baseName: "test-map", folderLabel: "", io: makeIO().io });
|
||||
const toggle = el.querySelector<HTMLButtonElement>(".chartdown-mode-toggle")!;
|
||||
expect(toggle.textContent).toBe("Player view");
|
||||
expect(el.innerHTML).not.toContain("Gruk");
|
||||
toggle.click();
|
||||
expect(toggle.textContent).toBe("GM view");
|
||||
expect(el.innerHTML).toContain("Gruk");
|
||||
toggle.click();
|
||||
expect(el.innerHTML).not.toContain("Gruk");
|
||||
});
|
||||
|
||||
it("exports SVG next to the note, says where, and reveals the file", async () => {
|
||||
const el = document.createElement("div");
|
||||
const { io, written, notices, revealed } = makeIO();
|
||||
mountChartdownBlock(SOURCE, el, { initialMode: "gm", baseName: "test-map", folderLabel: "Maps/", io });
|
||||
const buttons = [...el.querySelectorAll("button")];
|
||||
buttons.find((b) => b.textContent === "Export SVG")!.click();
|
||||
await flush();
|
||||
expect(written).toHaveLength(1);
|
||||
expect(written[0]!.name).toBe("test-map-gm.svg");
|
||||
expect(written[0]!.contents).toContain("<svg");
|
||||
expect(notices[0]).toContain("Maps/test-map-gm.svg"); // the notice names the vault path
|
||||
expect(revealed).toEqual(["test-map-gm.svg"]); // and the file opens in the system explorer
|
||||
});
|
||||
|
||||
it("exports UVTT with the rasterized image plugged in", async () => {
|
||||
const el = document.createElement("div");
|
||||
const { io, written, revealed, notices } = makeIO(true);
|
||||
mountChartdownBlock(SOURCE, el, { initialMode: "player", baseName: "test-map", folderLabel: "", io });
|
||||
[...el.querySelectorAll("button")].find((b) => b.textContent === "Export UVTT")!.click();
|
||||
await flush();
|
||||
expect(written).toHaveLength(1);
|
||||
expect(written[0]!.name).toBe("test-map.dd2vtt");
|
||||
const uvtt = JSON.parse(written[0]!.contents) as Record<string, unknown>;
|
||||
expect((uvtt["resolution"] as { map_size: { x: number } }).map_size.x).toBe(6);
|
||||
expect(uvtt["image"]).toBe("FAKEPNGBASE64");
|
||||
expect((uvtt["portals"] as unknown[]).length).toBe(1);
|
||||
expect(revealed).toEqual(["test-map.dd2vtt"]);
|
||||
expect(notices[0]).toContain("hidden in Obsidian's explorer"); // .dd2vtt is an unknown extension in-app
|
||||
});
|
||||
|
||||
it("exports one UVTT file per level for multi-level maps", async () => {
|
||||
const multi = [
|
||||
"# Tower",
|
||||
"map: battlemap",
|
||||
"grid: square 6x6",
|
||||
"levels: top base",
|
||||
"level: base",
|
||||
"[structures]",
|
||||
'building tower "Tower" : B2..D4',
|
||||
"[terrain top]",
|
||||
"air : area A1..F6",
|
||||
].join("\n");
|
||||
const el = document.createElement("div");
|
||||
const { io, written } = makeIO(true);
|
||||
mountChartdownBlock(multi, el, { initialMode: "player", baseName: "tower", folderLabel: "", io });
|
||||
[...el.querySelectorAll("button")].find((b) => b.textContent === "Export UVTT")!.click();
|
||||
await flush();
|
||||
expect(written.map((w) => w.name).sort()).toEqual(["tower-base.dd2vtt", "tower-top.dd2vtt"]);
|
||||
});
|
||||
|
||||
it("refuses UVTT for non-battlemaps with a notice, writing nothing", async () => {
|
||||
const hex = ["map: hexcrawl", "grid: hex 3x3 pointy odd-row", "[hexes]", "A1 sea"].join("\n");
|
||||
const el = document.createElement("div");
|
||||
const { io, written, notices } = makeIO();
|
||||
mountChartdownBlock(hex, el, { initialMode: "player", baseName: "hexy", folderLabel: "", io });
|
||||
[...el.querySelectorAll("button")].find((b) => b.textContent === "Export UVTT")!.click();
|
||||
await flush();
|
||||
expect(written).toHaveLength(0);
|
||||
expect(notices.some((n) => n.includes("battlemap-only"))).toBe(true);
|
||||
});
|
||||
});
|
||||
111
src/block.ts
Normal file
111
src/block.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
/**
|
||||
* The per-map block UI (issue #41): a toolbar — GM/player toggle that
|
||||
* re-renders in place, SVG export, UVTT export (one .dd2vtt per level) —
|
||||
* above the rendered map. All side effects (writing files, notices, raster)
|
||||
* arrive injected through BlockIO, so this whole module tests against a
|
||||
* plain DOM.
|
||||
*/
|
||||
|
||||
import { parse } from "@chartdown/core";
|
||||
import { exportUvttSource, renderSource } from "@chartdown/render-svg";
|
||||
import { renderChartdownBlock, type RenderMode } from "./render";
|
||||
|
||||
export interface BlockIO {
|
||||
/** Write a text file next to the note; overwrite silently. */
|
||||
writeFile(name: string, contents: string): Promise<void>;
|
||||
notify(message: string): void;
|
||||
/** Reveal an exported file in the system file explorer (desktop only). */
|
||||
reveal?(name: string): void;
|
||||
/** Rasterize `region` of an SVG to a base64 PNG (no data-URI prefix). */
|
||||
rasterize?(svg: string, region: { x: number; y: number; w: number; h: number }, outW: number, outH: number): Promise<string>;
|
||||
}
|
||||
|
||||
export interface BlockOptions {
|
||||
initialMode: RenderMode;
|
||||
/** File base for exports, normally the map's doc id. */
|
||||
baseName: string;
|
||||
/** Vault-relative folder exports land in ("" = vault root) — notices say where. */
|
||||
folderLabel: string;
|
||||
io: BlockIO;
|
||||
}
|
||||
|
||||
const PIXELS_PER_GRID = 70;
|
||||
|
||||
export function mountChartdownBlock(source: string, el: HTMLElement, opts: BlockOptions): void {
|
||||
const doc = el.ownerDocument;
|
||||
let mode: RenderMode = opts.initialMode;
|
||||
|
||||
const wrapper = doc.createElement("div");
|
||||
wrapper.className = "chartdown-block";
|
||||
const toolbar = doc.createElement("div");
|
||||
toolbar.className = "chartdown-toolbar";
|
||||
const modeBtn = doc.createElement("button");
|
||||
modeBtn.className = "chartdown-mode-toggle";
|
||||
const svgBtn = doc.createElement("button");
|
||||
svgBtn.textContent = "Export SVG";
|
||||
const uvttBtn = doc.createElement("button");
|
||||
uvttBtn.textContent = "Export UVTT";
|
||||
toolbar.append(modeBtn, svgBtn, uvttBtn);
|
||||
const mapHost = doc.createElement("div");
|
||||
mapHost.className = "chartdown-map-host";
|
||||
wrapper.append(toolbar, mapHost);
|
||||
el.appendChild(wrapper);
|
||||
|
||||
const rerender = (): void => {
|
||||
mapHost.replaceChildren();
|
||||
renderChartdownBlock(source, mapHost, mode);
|
||||
modeBtn.textContent = mode === "gm" ? "GM view" : "Player view";
|
||||
modeBtn.setAttribute("aria-pressed", String(mode === "gm"));
|
||||
};
|
||||
|
||||
modeBtn.addEventListener("click", () => {
|
||||
mode = mode === "gm" ? "player" : "gm";
|
||||
rerender();
|
||||
});
|
||||
|
||||
svgBtn.addEventListener("click", () => {
|
||||
void (async () => {
|
||||
const { svg } = renderSource(source, { mode });
|
||||
const name = `${opts.baseName}${mode === "gm" ? "-gm" : ""}.svg`;
|
||||
await opts.io.writeFile(name, svg);
|
||||
opts.io.notify(`Chartdown: exported ${opts.folderLabel}${name} (a real file in your vault folder)`);
|
||||
opts.io.reveal?.(name);
|
||||
})();
|
||||
});
|
||||
|
||||
uvttBtn.addEventListener("click", () => {
|
||||
void (async () => {
|
||||
const parsed = parse(source).document;
|
||||
const levels = parsed.levels.length > 0 ? parsed.levels : [""];
|
||||
for (const level of levels) {
|
||||
const result = exportUvttSource(source, {
|
||||
mode,
|
||||
...(level ? { level } : {}),
|
||||
pixelsPerGrid: PIXELS_PER_GRID,
|
||||
});
|
||||
if (!result.uvtt) {
|
||||
const error = result.diagnostics.find((d) => d.severity === "error");
|
||||
opts.io.notify(`Chartdown: ${error?.message ?? "UVTT export failed"}`);
|
||||
return;
|
||||
}
|
||||
if (opts.io.rasterize && result.svg && result.imageRegion) {
|
||||
const size = (result.uvtt["resolution"] as { map_size: { x: number; y: number } }).map_size;
|
||||
result.uvtt["image"] = await opts.io.rasterize(
|
||||
result.svg,
|
||||
result.imageRegion,
|
||||
size.x * PIXELS_PER_GRID,
|
||||
size.y * PIXELS_PER_GRID,
|
||||
);
|
||||
}
|
||||
const name = `${opts.baseName}${level ? `-${level}` : ""}.dd2vtt`;
|
||||
await opts.io.writeFile(name, JSON.stringify(result.uvtt));
|
||||
// Obsidian's file explorer hides unknown extensions — the file is
|
||||
// real but invisible in-app, so say where it went and reveal it.
|
||||
opts.io.notify(`Chartdown: exported ${opts.folderLabel}${name} (hidden in Obsidian's explorer; it's in your vault folder)`);
|
||||
opts.io.reveal?.(name);
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
rerender();
|
||||
}
|
||||
106
src/main.ts
Normal file
106
src/main.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
/**
|
||||
* Obsidian plugin entry (issues #38/#41): registers the `chartdown`
|
||||
* code-block processor — each block mounts with a toolbar (GM/player toggle,
|
||||
* SVG and UVTT export) — and one setting: the DEFAULT view for newly
|
||||
* rendered blocks. Default player, fail-closed per spec 01 §6.
|
||||
*/
|
||||
|
||||
import { Notice, Plugin, PluginSettingTab, Setting, type App } from "obsidian";
|
||||
import { parse } from "@chartdown/core";
|
||||
import { mountChartdownBlock } from "./block";
|
||||
import type { RenderMode } from "./render";
|
||||
|
||||
interface ChartdownSettings {
|
||||
mode: RenderMode;
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: ChartdownSettings = { mode: "player" };
|
||||
|
||||
/** Rasterize a region of an SVG to base64 PNG via an offscreen canvas. */
|
||||
async function rasterize(
|
||||
svg: string,
|
||||
region: { x: number; y: number; w: number; h: number },
|
||||
outW: number,
|
||||
outH: number,
|
||||
): Promise<string> {
|
||||
const url = URL.createObjectURL(new Blob([svg], { type: "image/svg+xml" }));
|
||||
try {
|
||||
const img = new Image();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
img.onload = () => resolve();
|
||||
img.onerror = () => reject(new Error("could not rasterize the map SVG"));
|
||||
img.src = url;
|
||||
});
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = outW;
|
||||
canvas.height = outH;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("no 2d canvas available");
|
||||
ctx.drawImage(img, region.x, region.y, region.w, region.h, 0, 0, outW, outH);
|
||||
return canvas.toDataURL("image/png").split(",")[1] ?? "";
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
export default class ChartdownPlugin extends Plugin {
|
||||
settings: ChartdownSettings = DEFAULT_SETTINGS;
|
||||
|
||||
override async onload(): Promise<void> {
|
||||
this.settings = { ...DEFAULT_SETTINGS, ...((await this.loadData()) as Partial<ChartdownSettings> | null) };
|
||||
this.registerMarkdownCodeBlockProcessor("chartdown", (source, el, ctx) => {
|
||||
const slash = ctx.sourcePath.lastIndexOf("/");
|
||||
const folder = slash >= 0 ? ctx.sourcePath.slice(0, slash + 1) : "";
|
||||
mountChartdownBlock(source, el, {
|
||||
initialMode: this.settings.mode,
|
||||
baseName: parse(source).document.docId,
|
||||
folderLabel: folder,
|
||||
io: {
|
||||
writeFile: async (name, contents) => {
|
||||
await this.app.vault.adapter.write(folder + name, contents);
|
||||
},
|
||||
notify: (message) => {
|
||||
new Notice(message, 8000);
|
||||
},
|
||||
reveal: (name) => {
|
||||
// Desktop API; opens the system file explorer with the file
|
||||
// selected — the "get it out as a file" affordance.
|
||||
(this.app as unknown as { showInFolder?: (path: string) => void }).showInFolder?.(folder + name);
|
||||
},
|
||||
rasterize,
|
||||
},
|
||||
});
|
||||
});
|
||||
this.addSettingTab(new ChartdownSettingTab(this.app, this));
|
||||
}
|
||||
|
||||
async saveSettings(): Promise<void> {
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
}
|
||||
|
||||
class ChartdownSettingTab extends PluginSettingTab {
|
||||
private readonly plugin: ChartdownPlugin;
|
||||
|
||||
constructor(app: App, plugin: ChartdownPlugin) {
|
||||
super(app, plugin);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
override display(): void {
|
||||
this.containerEl.empty();
|
||||
new Setting(this.containerEl)
|
||||
.setName("Default to GM view")
|
||||
.setDesc(
|
||||
"New map blocks start in GM view (hidden tokens, [gm] notes, triggers). " +
|
||||
"Each map also has its own toolbar toggle. Off, maps start as the " +
|
||||
"player view — secrets stripped fail-closed.",
|
||||
)
|
||||
.addToggle((toggle) =>
|
||||
toggle.setValue(this.plugin.settings.mode === "gm").onChange(async (value) => {
|
||||
this.plugin.settings.mode = value ? "gm" : "player";
|
||||
await this.plugin.saveSettings();
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
40
src/render.test.ts
Normal file
40
src/render.test.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderChartdownBlock } from "./render";
|
||||
|
||||
const SOURCE = [
|
||||
"map: battlemap",
|
||||
"grid: square 6x6",
|
||||
"[tokens]",
|
||||
'ogre "Gruk" : C3 size=2 hidden',
|
||||
"hero : B2",
|
||||
].join("\n");
|
||||
|
||||
describe("obsidian block rendering", () => {
|
||||
it("renders an SVG that scales to the note (no fixed width/height)", () => {
|
||||
const el = document.createElement("div");
|
||||
renderChartdownBlock(SOURCE, el, "player");
|
||||
const svg = el.querySelector("svg.chartdown-map");
|
||||
expect(svg).toBeTruthy();
|
||||
expect(svg!.hasAttribute("viewBox")).toBe(true);
|
||||
expect(svg!.hasAttribute("width")).toBe(false);
|
||||
expect(svg!.hasAttribute("height")).toBe(false);
|
||||
});
|
||||
|
||||
it("player mode strips secrets; gm mode shows them (fail-closed default)", () => {
|
||||
const player = document.createElement("div");
|
||||
renderChartdownBlock(SOURCE, player, "player");
|
||||
expect(player.innerHTML).not.toContain("Gruk");
|
||||
const gm = document.createElement("div");
|
||||
renderChartdownBlock(SOURCE, gm, "gm");
|
||||
expect(gm.innerHTML).toContain("Gruk");
|
||||
});
|
||||
|
||||
it("errors surface beneath the map instead of vanishing", () => {
|
||||
const el = document.createElement("div");
|
||||
renderChartdownBlock("map: battlemap\ngrid: square 4x4\n[features]\ntable : on ghost at B2", el, "player");
|
||||
const box = el.querySelector(".chartdown-diagnostics");
|
||||
expect(box).toBeTruthy();
|
||||
expect(box!.textContent).toContain("line");
|
||||
});
|
||||
});
|
||||
35
src/render.ts
Normal file
35
src/render.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
/**
|
||||
* The pure half of the plugin: turn a chartdown fence's text into DOM inside
|
||||
* the element Obsidian hands us. Kept free of the `obsidian` module so it
|
||||
* tests against a plain DOM (the Obsidian-specific surface stays one thin
|
||||
* adapter in main.ts).
|
||||
*/
|
||||
|
||||
import { renderSource } from "@chartdown/render-svg";
|
||||
|
||||
export type RenderMode = "player" | "gm";
|
||||
|
||||
export function renderChartdownBlock(source: string, el: HTMLElement, mode: RenderMode): void {
|
||||
const doc = el.ownerDocument;
|
||||
const { svg, diagnostics } = renderSource(source, { mode });
|
||||
|
||||
const parsed = new DOMParser().parseFromString(svg, "image/svg+xml");
|
||||
const node = doc.importNode(parsed.documentElement, true) as unknown as SVGSVGElement;
|
||||
// Scale to the note's width; the viewBox keeps the aspect ratio.
|
||||
node.removeAttribute("width");
|
||||
node.removeAttribute("height");
|
||||
node.classList.add("chartdown-map");
|
||||
el.appendChild(node);
|
||||
|
||||
const errors = diagnostics.filter((d) => d.severity === "error");
|
||||
if (errors.length > 0) {
|
||||
const box = doc.createElement("div");
|
||||
box.className = "chartdown-diagnostics";
|
||||
for (const d of errors) {
|
||||
const line = doc.createElement("div");
|
||||
line.textContent = `line ${d.line}: ${d.message}`;
|
||||
box.appendChild(line);
|
||||
}
|
||||
el.appendChild(box);
|
||||
}
|
||||
}
|
||||
32
styles.css
Normal file
32
styles.css
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
.chartdown-toolbar {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.chartdown-toolbar button {
|
||||
font-size: var(--font-ui-smaller);
|
||||
padding: 2px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chartdown-mode-toggle[aria-pressed="true"] {
|
||||
background: var(--interactive-accent);
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
|
||||
.chartdown-map {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.chartdown-diagnostics {
|
||||
color: var(--text-error);
|
||||
font-size: 0.85em;
|
||||
font-family: var(--font-monospace);
|
||||
margin-top: 0.5em;
|
||||
padding: 0.5em 0.75em;
|
||||
border-left: 2px solid var(--text-error);
|
||||
background: var(--background-secondary);
|
||||
}
|
||||
12
tsconfig.json
Normal file
12
tsconfig.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@chartdown/core": ["../core/src/index"],
|
||||
"@chartdown/render-svg": ["../render-svg/src/index"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
{
|
||||
"0.1.0": "1.5.0"
|
||||
"0.1.0": "1.5.0",
|
||||
"0.1.1": "1.5.0"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue