
Mira
- 3 installs
- Updated May 3, 2026
- oil-oil/mira
Helps with ai & agent building tasks.
About
mira is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- mira
- AI & Agent Building
- AI-coding skill
Mira by the numbers
- 3 all-time installs (skills.sh)
- Ranked #13,677 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/oil-oil/mira --skill miraAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3 |
|---|---|
| Last updated | May 3, 2026 |
| Repository | oil-oil/mira ↗ |
What it does
Helps with ai & agent building tasks.
Files
Mira
Mira turns local files into visible Agent context. It has one global home:
~/.mira/Use boards to separate tasks and topics. Do not create separate Mira homes or rely on project-derived sessions.
When To Use Mira
Use Mira when seeing files together helps the task:
- Preview Markdown, HTML, images, or videos.
- Compare documents, screenshots, generated HTML, or media assets.
- Keep visual context across a multi-turn task.
- Draft prompts that reference local files.
- Review Markdown comments and connect them to source text.
- Preserve useful media descriptions for future Agents.
First Step
Call Mira first. Do not pre-check installation separately:
mira status --jsonIf the command fails because Mira is missing, install it and retry:
npm install -g https://github.com/oil-oil/mira/archive/refs/heads/main.tar.gz
mira status --jsonRun mira migrate --json after installing or after major upgrades. It migrates older stores into the global ~/.mira home and reports backup paths.
Start Or Reuse The Canvas
Use port 3020 by default:
mira init
mira serve --port 3020If the service is already running, reuse it. The UI is:
http://localhost:3020Board Discipline
Create one board per distinct task:
mira board create "<short task name>" --jsonFor multi-step or batch work, capture the returned board.id and pass it explicitly:
mira add --board <board-id> <file...> --json
mira import --board <board-id> <file...> --json
mira markdown --board <board-id> "prompt draft" --json
mira list --board <board-id> --json
mira context --board <board-id> allThis prevents another Agent from changing your CLI target board underneath you.
Use mira board use <board-id> only when you intentionally want to change the CLI default target. The browser UI keeps its own visible board, so CLI operations should not depend on what the user is currently viewing.
Delete a board only when the user clearly asks for it:
mira board delete <board-id> --confirm --jsonMira keeps at least one board. Deleting a board removes its board file and comments for that board.
Remove nodes with the CLI instead of editing board JSON by hand:
mira remove --board <board-id> <node-id...> --jsonThis updates board metadata and lets the browser refresh the visible board reliably.
Supported Preview Files
Markdown: .md .mdx .markdown
HTML: .html .htm
Images: .png .jpg .jpeg .gif .webp .svg .avif
Videos: .mp4 .webm .mov .m4vFor PDF, spreadsheets, archives, or unknown files, explain that Mira currently previews Markdown, HTML, images, and videos. Convert or extract content first only when the user wants that.
Put Files On The Canvas
Copy one-off files into Mira:
mira import --board <board-id> ~/Downloads/brief.md ~/Downloads/mockup.png --jsonMap a folder through a symlink:
mira link --board <board-id> ~/Downloads downloads --json
mira files --jsonAfter linking, add specific supported files without copying originals:
mira add --board <board-id> ~/.mira/files/downloads/example.png --jsonCreate a blank Markdown prompt note:
mira markdown --board <board-id> "prompt draft" --jsonThe UI also supports dragging supported files into the canvas. Treat CLI import as the same behavior: copy into the Mira files directory, then add nodes.
Batch add and import operations are arranged by file type automatically, using the same spacing rules as the UI smart layout.
Get Context For AI
Use JSON output for automation:
mira board current --json
mira list --board <board-id> --json
mira comments list --board <board-id> --json
mira timeline --board <board-id> --json --limit 20
mira context --board <board-id> <node-id>
mira read <path>Use context all only when the current board is small. For large boards, list nodes first, choose relevant node ids, then fetch context one by one.
Expected context shape:
# Canvas Context
board_id:
board_title:
node_id:
type:
title:
path:
summary:
asset:
## Content
Exact text when useful. For images and videos, include the file path and relevant visible details requested by the user.
## Comments
Open Markdown comments when present, including `comment_id`, `quote`, and `comment`.Edit Source Files
Markdown and HTML nodes can map to real files. Read before writing, make a focused edit, then write back:
mira read <path>
mira write <path> "<new content>"Mira reads and writes files inside the project, copied Mira files, and paths mapped through the Mira files directory. Preserve original images and videos unless the user explicitly asks to overwrite them.
Asset Metadata And Timeline
Mira stores lightweight asset metadata on nodes when files are imported or created: format, file size, image dimensions when easy to detect, Markdown word count, and HTML title.
If an Agent has inspected an image or video and learned something useful, store that reusable description:
mira describe --board <board-id> <node-id> "Short visual description useful for future Agents." --jsonUse Timeline for task memory, not full chat transcripts:
mira note --board <board-id> "User wants the preview drawer to stay visually minimal." --json
mira timeline --board <board-id> --json --limit 20Sync Model
Mira writes to disk first, then the running service watches ~/.mira and pushes browser updates through /api/events. If the UI looks stale:
mira status --json
mira migrate --json
curl -s http://localhost:3020/api/statusThe CLI and browser should both report canvasRoot as ~/.mira, with no sessionId.
Practical Defaults
- Use
miraas the command. - Use the single global home
~/.mira. - Start or reuse port
3020. - Create a board for each distinct task.
- Pass
--board <board-id>for batch or multi-step operations. - Use
importfor one-off files. - Use
linkplusaddfor folders the user wants to keep in place. - Use
mira notefor important user decisions and preferences. - Use
mira describeafter inspecting media when the description will save future visual analysis. - Read source files when exact text matters; use the visual canvas for orientation and comparison.
node_modules/
.next/
out/
dist/
build/
coverage/
.canvas/
.env
.env.*
*.log
*.tsbuildinfo
.DS_Store
!.env.example
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const appRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const sourceWorkspaceRoot = path.resolve(process.env.MIRA_SOURCE_WORKSPACE ?? process.env.CANVAS_WORKSPACE ?? process.cwd());
const workspaceRoot = sourceWorkspaceRoot;
const projectRoot = sourceWorkspaceRoot;
const miraHome = path.resolve(process.env.MIRA_HOME ?? path.join(os.homedir(), ".mira"));
const storageMode = process.env.MIRA_STORAGE ?? "home";
const localCanvasRoot = path.join(sourceWorkspaceRoot, ".canvas");
const canvasRoot = storageMode === "local" ? localCanvasRoot : miraHome;
const filesRoot = path.join(canvasRoot, "files");
const dropsRoot = path.join(filesRoot, "drops");
const notesRoot = path.join(filesRoot, "notes");
const boardsRoot = path.join(canvasRoot, "boards");
const metaFile = path.join(canvasRoot, "meta.json");
const migrationFile = path.join(canvasRoot, "migration.json");
const backupsRoot = path.join(os.homedir(), ".mira-backups");
const stateFile = path.join(canvasRoot, "state.json");
const commentsFile = path.join(canvasRoot, "comments.json");
const timelineFile = path.join(canvasRoot, "timeline.json");
const canvasFile = path.join(canvasRoot, "canvas.json");
const defaultBoardId = "main";
const migrationStartedAt = new Date().toISOString().replace(/[:.]/g, "-");
const renderableTypes = new Set(["markdown", "html", "image", "video"]);
const defaultNodeSizes = {
markdown: { width: 720, height: 820 },
html: { width: 960, height: 600 },
image: { width: 260, height: 220 },
video: { width: 300, height: 210 },
prompt: { width: 420, height: 320 },
file: { width: 300, height: 230 }
};
const arrangeGap = 48;
const arrangeRowGap = 64;
const layoutTypeOrder = new Map([
["image", 0],
["video", 1],
["markdown", 2],
["html", 3],
["prompt", 4],
["file", 5]
]);
function usage() {
const invokedName = path.basename(process.argv[1] ?? "mira");
const commandName = invokedName === "mira.mjs" ? "mira" : invokedName;
console.log(`${commandName} <command>
Commands:
init Initialize Mira Home
serve [--port 3020] Start the canvas service
open [--port 3020] Open the canvas service in the browser
status [--json] Print canvas paths and node counts
migrate [--json] Migrate older Mira stores into the global Mira Home
board list [--json] List boards
board current [--json] Print the current board
board create <title> [--json] Create and switch to a board
board use <id> [--json] Switch to a board
board delete <id> --confirm [--json] Delete a board
comments list [--json] List open comments on the current board
comments node <id> [--json] List comments for a node
comments file <path> [--json] List comments for a file
comments resolve <id> [--json] Resolve a comment
note <text> [--json] Add a short timeline note to the current board
timeline [--json] [--limit n] List recent timeline events
describe <node-id> <text> [--json] Store an AI-visible description on a node
list [--json] List canvas nodes
remove <node-id...> [--json] Remove nodes from the canvas
files [--json] List files under the Mira files directory
import <file...> [--json] Copy previewable files into the canvas and add nodes
add <file...> [--json] Add already-mapped files as nodes without copying them
markdown [title] [--json] Create an empty Markdown file and add it as a node
link <path> [name] [--json] Symlink a file or folder into the Mira files directory
context <node-id|all> Print node context
read <path> Read a text file already added to the canvas
write <path> <content> Write a text file already added to the canvas
Supported preview files:
markdown: .md .mdx .markdown
html: .html .htm
image: .png .jpg .jpeg .gif .webp .svg .avif
video: .mp4 .webm .mov .m4v
`);
}
function inferSourceType(filePath) {
const ext = path.extname(filePath).toLowerCase();
if ([".md", ".mdx", ".markdown"].includes(ext)) return "markdown";
if ([".html", ".htm"].includes(ext)) return "html";
if ([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".avif"].includes(ext)) return "image";
if ([".mp4", ".webm", ".mov", ".m4v"].includes(ext)) return "video";
return "file";
}
function safeName(input) {
return input.replace(/[^a-zA-Z0-9._-]/g, "-").replace(/-+/g, "-") || `file-${Date.now()}`;
}
function safeBoardId(input) {
return (
input
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 72) || `board-${Date.now()}`
);
}
function hasFlag(args, flag) {
return args.includes(flag);
}
function getOptionValue(args, flag) {
const index = args.indexOf(flag);
return index >= 0 ? args[index + 1] : undefined;
}
function withoutFlags(args) {
const values = [];
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg.startsWith("--")) {
if (["--board", "--port", "--status", "--limit"].includes(arg)) index += 1;
continue;
}
values.push(arg);
}
return values;
}
function printJson(value) {
console.log(JSON.stringify(value, null, 2));
}
function isRenderable(filePath) {
return renderableTypes.has(inferSourceType(filePath));
}
function imageSizeFromBuffer(buffer, filePath) {
const ext = path.extname(filePath).toLowerCase();
if (ext === ".png" && buffer.length >= 24 && buffer.toString("ascii", 1, 4) === "PNG") {
return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
}
if (ext === ".gif" && buffer.length >= 10 && buffer.toString("ascii", 0, 3) === "GIF") {
return { width: buffer.readUInt16LE(6), height: buffer.readUInt16LE(8) };
}
if ((ext === ".jpg" || ext === ".jpeg") && buffer.length > 4 && buffer[0] === 0xff && buffer[1] === 0xd8) {
let offset = 2;
while (offset < buffer.length) {
if (buffer[offset] !== 0xff) break;
const marker = buffer[offset + 1];
const length = buffer.readUInt16BE(offset + 2);
if ([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf].includes(marker)) {
return { height: buffer.readUInt16BE(offset + 5), width: buffer.readUInt16BE(offset + 7) };
}
offset += 2 + length;
}
}
if (ext === ".webp" && buffer.length >= 30 && buffer.toString("ascii", 0, 4) === "RIFF" && buffer.toString("ascii", 8, 12) === "WEBP") {
const chunk = buffer.toString("ascii", 12, 16);
if (chunk === "VP8X" && buffer.length >= 30) {
return { width: 1 + buffer.readUIntLE(24, 3), height: 1 + buffer.readUIntLE(27, 3) };
}
if (chunk === "VP8 " && buffer.length >= 30) {
return { width: buffer.readUInt16LE(26) & 0x3fff, height: buffer.readUInt16LE(28) & 0x3fff };
}
}
return {};
}
function svgSizeFromText(text) {
const width = text.match(/\bwidth=["']?([0-9.]+)/i)?.[1];
const height = text.match(/\bheight=["']?([0-9.]+)/i)?.[1];
if (width && height) return { width: Math.round(Number(width)), height: Math.round(Number(height)) };
const viewBox = text.match(/\bviewBox=["'][^"']*?([0-9.]+)\s+([0-9.]+)["']/i);
if (viewBox) return { width: Math.round(Number(viewBox[1])), height: Math.round(Number(viewBox[2])) };
return {};
}
async function getAssetMetadata(filePath) {
const stat = await fs.stat(filePath);
const sourceType = inferSourceType(filePath);
const ext = path.extname(filePath).toLowerCase().replace(".", "");
const metadata = {
format: ext || sourceType,
sizeBytes: stat.size,
updatedAt: stat.mtime.toISOString()
};
if (sourceType === "markdown") {
const text = await fs.readFile(filePath, "utf8").catch(() => "");
metadata.title = text.match(/^#\s+(.+)$/m)?.[1]?.trim();
metadata.words = text.trim() ? text.trim().split(/\s+/).length : 0;
} else if (sourceType === "html") {
const text = await fs.readFile(filePath, "utf8").catch(() => "");
metadata.title = text.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1]?.replace(/\s+/g, " ").trim();
} else if (sourceType === "image") {
if (ext === "svg") Object.assign(metadata, svgSizeFromText(await fs.readFile(filePath, "utf8").catch(() => "")));
else Object.assign(metadata, imageSizeFromBuffer(await fs.readFile(filePath), filePath));
}
return metadata;
}
async function countCanvasNodes(root) {
const state = await readJson(path.join(root, "state.json"), null);
if (state?.boards?.length) {
let count = 0;
for (const board of state.boards) {
const document = await readJson(path.join(root, "boards", `${safeBoardId(board.id)}.json`), { nodes: [] });
count += Array.isArray(document.nodes) ? document.nodes.length : 0;
}
return count;
}
const legacyDocument = await readJson(path.join(root, "canvas.json"), { nodes: [] });
return Array.isArray(legacyDocument.nodes) ? legacyDocument.nodes.length : 0;
}
function remapPathValue(value, fromRoot, toRoot) {
if (typeof value === "string") {
if (value === fromRoot || value.startsWith(`${fromRoot}${path.sep}`)) {
return path.join(toRoot, path.relative(fromRoot, value));
}
return value;
}
if (Array.isArray(value)) return value.map((item) => remapPathValue(item, fromRoot, toRoot));
if (value && typeof value === "object") {
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, remapPathValue(item, fromRoot, toRoot)]));
}
return value;
}
function remapBoardIdValue(value, boardIds) {
if (Array.isArray(value)) return value.map((item) => remapBoardIdValue(item, boardIds));
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([key, item]) => {
if (key === "boardId" && typeof item === "string" && boardIds.has(item)) return [key, boardIds.get(item)];
return [key, remapBoardIdValue(item, boardIds)];
})
);
}
return value;
}
function uniqueBoardId(baseInput, usedIds) {
const base = safeBoardId(baseInput);
let id = base;
let index = 2;
while (usedIds.has(id)) {
id = `${base}-${index}`;
index += 1;
}
usedIds.add(id);
return id;
}
async function mergeLegacyStore(root, label, state, comments, timeline) {
const legacyState = await readJson(path.join(root, "state.json"), null);
if (!legacyState?.boards?.length) return false;
const usedIds = new Set(state.boards.map((board) => board.id));
const boardIds = new Map();
const filesSourceRoot = path.join(root, "files");
const filesTargetRoot = path.join(filesRoot, "legacy", safeName(label));
if (await realpathIfExists(filesSourceRoot)) {
await copyLegacyFiles(filesSourceRoot, filesTargetRoot);
}
for (const board of legacyState.boards) {
const document = await readJson(path.join(root, "boards", `${safeBoardId(board.id)}.json`), { nodes: [], edges: [] });
const fallbackDocument = board.id === defaultBoardId ? await readJson(path.join(root, "canvas.json"), { nodes: [], edges: [] }) : { nodes: [], edges: [] };
const sourceDocument = document.nodes?.length ? document : fallbackDocument;
const hasContent = Boolean(sourceDocument.nodes?.length || sourceDocument.edges?.length);
if (!hasContent && board.id === defaultBoardId && usedIds.has(defaultBoardId)) continue;
const nextId = usedIds.has(board.id) ? uniqueBoardId(`${safeName(label)}-${board.id}`, usedIds) : uniqueBoardId(board.id, usedIds);
boardIds.set(board.id, nextId);
await writeJson(boardFile(nextId), remapPathValue(sourceDocument, filesSourceRoot, filesTargetRoot));
state.boards.push({
...board,
id: nextId,
title: nextId === board.id ? board.title : `${board.title} (${label})`
});
}
if (!boardIds.size) return false;
const legacyComments = await readJson(path.join(root, "comments.json"), { comments: [] });
const existingCommentIds = new Set(comments.comments.map((comment) => comment.id));
for (const legacyComment of legacyComments.comments ?? []) {
if (!boardIds.has(legacyComment.boardId)) continue;
const remapped = remapBoardIdValue(remapPathValue(legacyComment, filesSourceRoot, filesTargetRoot), boardIds);
if (existingCommentIds.has(remapped.id)) remapped.id = `${safeName(label)}-${remapped.id}`;
existingCommentIds.add(remapped.id);
comments.comments.push(remapped);
}
const legacyTimeline = await readJson(path.join(root, "timeline.json"), { events: [] });
const existingEventIds = new Set(timeline.events.map((event) => event.id));
for (const legacyEvent of legacyTimeline.events ?? []) {
if (legacyEvent.boardId && !boardIds.has(legacyEvent.boardId)) continue;
const remapped = remapBoardIdValue(remapPathValue(legacyEvent, filesSourceRoot, filesTargetRoot), boardIds);
if (existingEventIds.has(remapped.id)) remapped.id = `${safeName(label)}-${remapped.id}`;
existingEventIds.add(remapped.id);
timeline.events.push(remapped);
}
return true;
}
async function copyLegacyFiles(sourceRoot, targetRoot) {
if (sourceRoot === targetRoot || targetRoot.startsWith(`${sourceRoot}${path.sep}`)) return;
await fs.mkdir(targetRoot, { recursive: true });
try {
await fs.cp(sourceRoot, targetRoot, { recursive: true, force: true, errorOnExist: false });
return;
} catch {
const entries = await fs.readdir(sourceRoot, { withFileTypes: true }).catch(() => []);
for (const entry of entries) {
const source = path.join(sourceRoot, entry.name);
const target = path.join(targetRoot, entry.name);
await fs.cp(source, target, { recursive: true, force: true, errorOnExist: false }).catch(() => undefined);
}
}
}
async function backupMigrationSource(root, label) {
if (storageMode === "local") return undefined;
const backupRoot = path.join(backupsRoot, `global-migration-${migrationStartedAt}`, safeName(label));
await fs.mkdir(backupRoot, { recursive: true });
for (const name of ["state.json", "canvas.json", "comments.json", "timeline.json", "meta.json"]) {
await fs.copyFile(path.join(root, name), path.join(backupRoot, name)).catch(() => undefined);
}
await fs.cp(path.join(root, "boards"), path.join(backupRoot, "boards"), { recursive: true, force: true }).catch(() => undefined);
await fs.cp(path.join(root, "files"), path.join(backupRoot, "files"), { recursive: true, force: true }).catch(() => undefined);
return backupRoot;
}
async function migrateLegacyStores(state) {
if (storageMode === "local") return state;
const migration = await readJson(migrationFile, { migratedStores: [], backups: {} });
const migratedStores = new Set(migration.migratedStores ?? []);
const backups = migration.backups ?? {};
const comments = await readJson(commentsFile, { comments: [] });
const timeline = await readJson(timelineFile, { events: [] });
const candidates = [];
if (!migratedStores.has(localCanvasRoot) && (await realpathIfExists(path.join(localCanvasRoot, "state.json")))) {
candidates.push({ root: localCanvasRoot, label: `local-${safeName(path.basename(path.dirname(localCanvasRoot)))}` });
}
const sessionsRoot = path.join(miraHome, "sessions");
const sessions = await fs.readdir(sessionsRoot, { withFileTypes: true }).catch(() => []);
for (const entry of sessions) {
if (!entry.isDirectory()) continue;
const root = path.join(sessionsRoot, entry.name);
if (!migratedStores.has(root)) candidates.push({ root, label: entry.name });
}
let changed = false;
for (const candidate of candidates) {
backups[candidate.root] ??= (await backupMigrationSource(candidate.root, candidate.label)) ?? "";
if (await mergeLegacyStore(candidate.root, candidate.label, state, comments, timeline)) {
migratedStores.add(candidate.root);
changed = true;
}
}
if (changed) {
if (!state.boards.some((board) => board.id === state.currentBoardId)) {
state.currentBoardId = state.boards[0]?.id ?? defaultBoardId;
}
await writeJson(commentsFile, comments);
timeline.events = timeline.events.sort((a, b) => a.createdAt.localeCompare(b.createdAt)).slice(-1000);
await writeJson(timelineFile, timeline);
await writeJson(migrationFile, { migratedStores: [...migratedStores], backups, updatedAt: new Date().toISOString() });
}
return state;
}
async function ensureCanvas() {
await fs.mkdir(dropsRoot, { recursive: true });
await fs.mkdir(notesRoot, { recursive: true });
await fs.mkdir(boardsRoot, { recursive: true });
await writeJson(metaFile, {
storageModel: storageMode === "local" ? "local" : "global",
storageMode,
sourceWorkspaceRoot,
appRoot,
createdAt: (await readJson(metaFile, null))?.createdAt ?? new Date().toISOString(),
updatedAt: new Date().toISOString()
});
let state = await readJson(stateFile, null);
const timestamp = new Date().toISOString();
if (!state || !Array.isArray(state.boards) || !state.boards.length) {
state = {
currentBoardId: defaultBoardId,
boards: [
{
id: defaultBoardId,
title: "Main",
createdAt: timestamp,
updatedAt: timestamp
}
]
};
}
state = await migrateLegacyStores(state);
if (!state.currentBoardId || !state.boards.some((board) => board.id === state.currentBoardId)) {
state.currentBoardId = state.boards[0]?.id ?? defaultBoardId;
}
for (const board of state.boards) {
try {
await fs.access(boardFile(board.id));
} catch {
const document = board.id === defaultBoardId ? await readJson(canvasFile, { nodes: [], edges: [] }) : { nodes: [], edges: [] };
await writeJson(boardFile(board.id), document);
}
}
try {
await fs.access(commentsFile);
} catch {
await writeJson(commentsFile, { comments: [] });
}
try {
await fs.access(timelineFile);
} catch {
await writeJson(timelineFile, { events: [] });
}
await writeJson(stateFile, state);
}
async function readJson(filePath, fallback) {
try {
return JSON.parse(await fs.readFile(filePath, "utf8"));
} catch {
return fallback;
}
}
async function writeJson(filePath, value) {
await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
function boardFile(boardId) {
return path.join(boardsRoot, `${safeBoardId(boardId)}.json`);
}
async function readTimeline() {
await ensureCanvas();
return readJson(timelineFile, { events: [] });
}
async function recordTimelineEvent(input) {
const document = await readTimeline();
const event = {
id: `event-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
createdAt: new Date().toISOString(),
...input
};
document.events.push(event);
if (document.events.length > 1000) {
document.events = document.events.slice(-1000);
}
await writeJson(timelineFile, document);
return event;
}
function resolveUserPath(userPath) {
if (!userPath) throw new Error("Missing file path");
return path.isAbsolute(userPath) ? path.normalize(userPath) : path.normalize(path.join(process.cwd(), userPath));
}
async function realpathIfExists(targetPath) {
try {
return await fs.realpath(targetPath);
} catch {
return null;
}
}
async function isAllowedPath(targetPath) {
await ensureCanvas();
const resolved = resolveUserPath(targetPath);
const realTarget = await realpathIfExists(resolved);
if (!realTarget) return false;
const realProject = await fs.realpath(projectRoot);
if (realTarget === realProject || realTarget.startsWith(`${realProject}${path.sep}`)) {
return true;
}
const entries = await fs.readdir(filesRoot, { withFileTypes: true }).catch(() => []);
for (const entry of entries) {
const linkPath = path.join(filesRoot, entry.name);
const realLink = await realpathIfExists(linkPath);
if (realLink && (realTarget === realLink || realTarget.startsWith(`${realLink}${path.sep}`))) {
return true;
}
}
return false;
}
async function assertAllowedPath(targetPath) {
const resolved = resolveUserPath(targetPath);
if (!(await isAllowedPath(resolved))) {
throw new Error("This path has not been added to Mira. Import it with mira import or map it with mira link first.");
}
return resolved;
}
async function readState() {
await ensureCanvas();
return readJson(stateFile, { currentBoardId: defaultBoardId, boards: [] });
}
async function resolveBoardId(args) {
const requested = getOptionValue(args, "--board");
const state = await readState();
const boardId = safeBoardId(requested ?? state.currentBoardId);
if (!state.boards.some((board) => board.id === boardId)) {
throw new Error(`No board found for ${requested ?? boardId}`);
}
return boardId;
}
async function readCanvas(args = []) {
const boardId = await resolveBoardId(args);
return readJson(boardFile(boardId), { nodes: [], edges: [] });
}
async function writeCanvas(document, args = []) {
await ensureCanvas();
const state = await readState();
const boardId = await resolveBoardId(args);
const board = state.boards.find((item) => item.id === boardId);
if (board) board.updatedAt = new Date().toISOString();
await writeJson(boardFile(boardId), document);
await writeJson(stateFile, state);
}
async function currentBoard(args = []) {
const state = await readState();
const boardId = await resolveBoardId(args);
return state.boards.find((board) => board.id === boardId) ?? state.boards[0];
}
async function readComments() {
await ensureCanvas();
return readJson(commentsFile, { comments: [] });
}
async function writeComments(document) {
await ensureCanvas();
await writeJson(commentsFile, document);
}
async function listComments(args = [], filters = {}) {
const board = await currentBoard(args);
const status = getOptionValue(args, "--status") ?? "open";
const document = await readComments();
return {
board,
comments: document.comments.filter((comment) => {
if (comment.boardId !== board.id) return false;
if (status !== "all" && comment.status !== status) return false;
if (filters.nodeId && comment.nodeId !== filters.nodeId) return false;
if (filters.path && comment.path !== filters.path) return false;
return true;
})
};
}
async function uniquePath(directory, name) {
const parsed = path.parse(safeName(name));
let candidate = path.join(directory, `${parsed.name}${parsed.ext}`);
let index = 1;
while (true) {
try {
await fs.access(candidate);
candidate = path.join(directory, `${parsed.name}-${index}${parsed.ext}`);
index += 1;
} catch {
return candidate;
}
}
}
async function uniqueLinkPath(name) {
let candidate = path.join(filesRoot, safeName(name));
let index = 1;
while (true) {
try {
await fs.lstat(candidate);
const parsed = path.parse(safeName(name));
candidate = path.join(filesRoot, `${parsed.name}-${index}${parsed.ext}`);
index += 1;
} catch {
return candidate;
}
}
}
function initialNodePosition(index) {
return {
x: 96 + (index % 4) * 72,
y: 96 + Math.floor(index / 4) * 72
};
}
function estimateNodeSize(node) {
if (node?.data?.width && node?.data?.height) {
return { width: node.data.width, height: node.data.height + 32 };
}
if (node?.width && node?.height) {
return { width: node.width, height: node.height + 32 };
}
if (node?.measured?.width && node?.measured?.height) {
return { width: node.measured.width, height: node.measured.height + 32 };
}
const fallback = defaultNodeSizes[node?.data?.sourceType] ?? defaultNodeSizes.file;
return { width: fallback.width, height: fallback.height + 32 };
}
function sortByPosition(a, b) {
const rowDelta = a.position.y - b.position.y;
if (Math.abs(rowDelta) > 80) return rowDelta;
return a.position.x - b.position.x;
}
function orderedNodesForGroupedLayout(nodes) {
return [...nodes].sort((a, b) => {
const typeDelta = (layoutTypeOrder.get(a.data.sourceType) ?? 99) - (layoutTypeOrder.get(b.data.sourceType) ?? 99);
if (typeDelta) return typeDelta;
return sortByPosition(a, b);
});
}
function computeTypeGroups(nodes) {
const ordered = orderedNodesForGroupedLayout(nodes);
const groups = [];
for (const node of ordered) {
const last = groups.at(-1);
if (last?.[0]?.data?.sourceType === node.data.sourceType) {
last.push(node);
} else {
groups.push([node]);
}
}
return groups;
}
function measureLayoutRows(rows) {
return rows.map((row) => {
const sizes = row.map(estimateNodeSize);
return {
width: sizes.reduce((sum, size) => sum + size.width, 0) + Math.max(0, row.length - 1) * arrangeGap,
height: Math.max(...sizes.map((size) => size.height))
};
});
}
function chooseSmartGroupedColumns(nodes) {
const groups = computeTypeGroups(nodes);
const ordered = groups.flat();
if (ordered.length <= 1) return 1;
const targetRatio = 1.48;
const maxGroupSize = Math.max(...groups.map((group) => group.length));
const maxColumns = Math.min(maxGroupSize, Math.max(2, Math.ceil(Math.sqrt(ordered.length)) + 2));
let best = { columns: 1, score: Number.POSITIVE_INFINITY };
for (let columns = 1; columns <= maxColumns; columns += 1) {
const rows = groups.flatMap((group) => {
const groupRows = [];
for (let index = 0; index < group.length; index += columns) {
groupRows.push(group.slice(index, index + columns));
}
return groupRows;
});
const rowSizes = measureLayoutRows(rows);
const width = Math.max(...rowSizes.map((row) => row.width));
const height = rowSizes.reduce((sum, row) => sum + row.height, 0) + Math.max(0, rows.length - 1) * arrangeRowGap;
const ratio = width / Math.max(1, height);
const emptySlots = groups.reduce((sum, group) => sum + (Math.ceil(group.length / columns) * columns - group.length), 0);
const score = Math.abs(targetRatio - ratio) + emptySlots * 0.06 + height / 10000;
if (score < best.score) best = { columns, score };
}
return best.columns;
}
function groupedRowsForLayout(nodes) {
const groups = computeTypeGroups(nodes);
const columns = chooseSmartGroupedColumns(nodes);
return groups.flatMap((group) => {
const rows = [];
for (let index = 0; index < group.length; index += columns) {
rows.push(group.slice(index, index + columns));
}
return rows;
});
}
function computeSmartLayout(nodes, origin = { x: 96, y: 96 }) {
const rows = groupedRowsForLayout(nodes);
const positions = new Map();
let cursorY = origin.y;
const rowSizes = measureLayoutRows(rows);
for (const [rowIndex, row] of rows.entries()) {
const rowHeight = rowSizes[rowIndex].height;
let cursorX = origin.x;
for (const node of row) {
const size = estimateNodeSize(node);
positions.set(node.id, { x: cursorX, y: cursorY });
cursorX += size.width + arrangeGap;
}
cursorY += rowHeight + arrangeRowGap;
}
return positions;
}
function nextBatchOrigin(existingNodes) {
if (!existingNodes.length) return { x: 96, y: 96 };
const bottom = Math.max(...existingNodes.map((node) => node.position.y + estimateNodeSize(node).height));
return { x: 96, y: bottom + arrangeRowGap };
}
function applySmartLayout(nodes, origin) {
const positions = computeSmartLayout(nodes, origin);
for (const node of nodes) {
const position = positions.get(node.id);
if (position) node.position = position;
}
return nodes;
}
async function nodeForFile(filePath, index, prefix = "cli") {
const sourceType = inferSourceType(filePath);
return {
id: `${prefix}-${sourceType}-${Date.now()}-${index}`,
type: sourceType,
position: initialNodePosition(index),
data: {
title: path.basename(filePath),
path: filePath,
sourceType,
summary: filePath,
asset: await getAssetMetadata(filePath).catch(() => undefined),
preview: sourceType === "image" || sourceType === "video",
...(sourceType === "markdown" || sourceType === "html" ? defaultNodeSizes[sourceType] : {})
}
};
}
async function importFiles(args) {
const json = hasFlag(args, "--json");
const paths = withoutFlags(args);
if (!paths.length) throw new Error("Provide at least one file path to import.");
const document = await readCanvas(args);
const start = document.nodes.length;
const nodes = [];
for (const [index, input] of paths.entries()) {
const source = path.resolve(input);
const stat = await fs.stat(source);
if (!stat.isFile()) throw new Error(`${source} is not a file`);
if (!isRenderable(source)) throw new Error(`${source} cannot be previewed yet. Import Markdown, HTML, images, or videos.`);
const target = await uniquePath(dropsRoot, path.basename(source));
await fs.copyFile(source, target);
nodes.push(await nodeForFile(target, start + index));
}
applySmartLayout(nodes, nextBatchOrigin(document.nodes));
document.nodes.push(...nodes);
document.edges = document.edges ?? [];
await writeCanvas(document, args);
const board = await currentBoard(args);
await recordTimelineEvent({ type: "file.import", boardId: board.id, text: `Imported ${nodes.length} file${nodes.length === 1 ? "" : "s"}.`, details: { files: nodes.map((node) => node.data.path) } });
const result = { board, imported: nodes.length, nodes };
if (json) printJson(result);
else {
for (const node of nodes) console.log(`${node.id}\t${node.data.sourceType}\t${node.data.path}`);
}
}
async function addFiles(args) {
const json = hasFlag(args, "--json");
const paths = withoutFlags(args);
if (!paths.length) throw new Error("Provide at least one file path to add to the canvas.");
const document = await readCanvas(args);
const start = document.nodes.length;
const nodes = [];
for (const [index, input] of paths.entries()) {
const source = await assertAllowedPath(input);
const stat = await fs.stat(source);
if (!stat.isFile()) throw new Error(`${source} is not a file`);
if (!isRenderable(source)) throw new Error(`${source} cannot be previewed yet. Add Markdown, HTML, images, or videos.`);
nodes.push(await nodeForFile(source, start + index, "cli-add"));
}
applySmartLayout(nodes, nextBatchOrigin(document.nodes));
document.nodes.push(...nodes);
document.edges = document.edges ?? [];
await writeCanvas(document, args);
const board = await currentBoard(args);
await recordTimelineEvent({ type: "file.add", boardId: board.id, text: `Added ${nodes.length} mapped file${nodes.length === 1 ? "" : "s"}.`, details: { files: nodes.map((node) => node.data.path) } });
const result = { board, added: nodes.length, nodes };
if (json) printJson(result);
else {
for (const node of nodes) console.log(`${node.id}\t${node.data.sourceType}\t${node.data.path}`);
}
}
async function createMarkdown(args) {
const json = hasFlag(args, "--json");
const titleInput = withoutFlags(args).join(" ").trim();
const baseName = safeName(titleInput || `untitled-${Date.now()}`);
const fileName = baseName.endsWith(".md") ? baseName : `${baseName}.md`;
await ensureCanvas();
const target = await uniquePath(notesRoot, fileName);
await fs.writeFile(target, "", "utf8");
const document = await readCanvas(args);
const node = await nodeForFile(target, document.nodes.length, "cli-note");
applySmartLayout([node], nextBatchOrigin(document.nodes));
document.nodes.push(node);
document.edges = document.edges ?? [];
await writeCanvas(document, args);
const board = await currentBoard(args);
await recordTimelineEvent({ type: "markdown.create", boardId: board.id, path: target, nodeId: node.id, title: node.data.title });
const result = { board, created: true, node, path: target };
if (json) printJson(result);
else console.log(`${node.id}\tmarkdown\t${target}`);
}
async function linkPath(args) {
const json = hasFlag(args, "--json");
const [targetInput, nameInput] = withoutFlags(args);
if (!targetInput) throw new Error("Provide a path to link.");
await ensureCanvas();
const target = path.resolve(targetInput);
await fs.access(target);
const linkPath = await uniqueLinkPath(nameInput || path.basename(target));
await fs.symlink(target, linkPath);
const board = await currentBoard(args);
await recordTimelineEvent({ type: "file.link", boardId: board.id, path: linkPath, title: path.basename(linkPath), details: { target } });
const result = { ok: true, linkPath, target };
if (json) printJson(result);
else console.log(`${linkPath}\t->\t${target}`);
}
async function listNodes(args) {
const json = hasFlag(args, "--json");
const document = await readCanvas(args);
if (json) return printJson({ board: await currentBoard(args), nodes: document.nodes });
for (const node of document.nodes) {
console.log(`${node.id}\t${node.data.sourceType}\t${node.data.title}\t${node.data.path ?? ""}`);
}
}
async function removeNodes(args) {
const json = hasFlag(args, "--json");
const ids = withoutFlags(args);
if (!ids.length) throw new Error("Provide at least one node id to remove.");
const requested = new Set(ids);
const document = await readCanvas(args);
const removed = document.nodes.filter((node) => requested.has(node.id));
if (!removed.length) throw new Error(`No matching nodes found for ${ids.join(", ")}`);
document.nodes = document.nodes.filter((node) => !requested.has(node.id));
document.edges = (document.edges ?? []).filter((edge) => !requested.has(edge.source) && !requested.has(edge.target));
await writeCanvas(document, args);
const board = await currentBoard(args);
await recordTimelineEvent({
type: "node.remove",
boardId: board.id,
text: `Removed ${removed.length} node${removed.length === 1 ? "" : "s"}.`,
details: {
nodes: removed.map((node) => ({
id: node.id,
title: node.data.title,
path: node.data.path,
sourceType: node.data.sourceType
}))
}
});
if (json) return printJson({ board, removed: removed.length, nodes: removed });
for (const node of removed) console.log(`${node.id}\tremoved\t${node.data.title}`);
}
async function scanFiles() {
await ensureCanvas();
const files = [];
async function walk(currentPath, depth) {
if (depth > 3) return;
const entries = await fs.readdir(currentPath, { withFileTypes: true }).catch(() => []);
for (const entry of entries) {
const itemPath = path.join(currentPath, entry.name);
const stat = await fs.lstat(itemPath);
const linked = stat.isSymbolicLink();
if (entry.isDirectory() || linked) {
const real = await realpathIfExists(itemPath);
const targetStat = real ? await fs.stat(real).catch(() => null) : null;
if (entry.isDirectory() || targetStat?.isDirectory()) {
await walk(itemPath, depth + 1);
continue;
}
}
files.push({
name: entry.name,
path: itemPath,
sourceType: inferSourceType(itemPath),
linked
});
}
}
await walk(filesRoot, 0);
return files;
}
async function listFiles(args) {
const json = hasFlag(args, "--json");
const files = await scanFiles();
if (json) return printJson({ files });
for (const file of files) {
console.log(`${file.sourceType}\t${file.path}${file.linked ? "\tlinked" : ""}`);
}
}
async function boardCommand(args) {
const [action, ...rest] = args;
const json = hasFlag(args, "--json");
const state = await readState();
if (!action || action === "list") {
if (json) return printJson(state);
for (const board of state.boards) {
const marker = board.id === state.currentBoardId ? "*" : " ";
console.log(`${marker}\t${board.id}\t${board.title}`);
}
return;
}
if (action === "current") {
const board = state.boards.find((item) => item.id === state.currentBoardId) ?? state.boards[0];
if (json) return printJson({ board });
if (board) console.log(`${board.id}\t${board.title}`);
return;
}
if (action === "create") {
const title = withoutFlags(rest).join(" ").trim();
if (!title) throw new Error("Provide a board title.");
const baseId = safeBoardId(title);
let id = baseId;
let index = 2;
while (state.boards.some((board) => board.id === id)) {
id = `${baseId}-${index}`;
index += 1;
}
const timestamp = new Date().toISOString();
const board = { id, title, createdAt: timestamp, updatedAt: timestamp };
state.boards.push(board);
state.currentBoardId = id;
await writeJson(boardFile(id), { nodes: [], edges: [] });
await writeJson(stateFile, state);
await recordTimelineEvent({ type: "board.create", boardId: board.id, title: board.title });
if (json) return printJson({ board, boards: state.boards, currentBoardId: state.currentBoardId });
console.log(`${board.id}\t${board.title}`);
return;
}
if (action === "use") {
const [requested] = withoutFlags(rest);
if (!requested) throw new Error("Provide a board id.");
const boardId = safeBoardId(requested);
const board = state.boards.find((item) => item.id === boardId);
if (!board) throw new Error(`No board found for ${requested}`);
state.currentBoardId = board.id;
await writeJson(stateFile, state);
await recordTimelineEvent({ type: "board.use", boardId: board.id, title: board.title });
if (json) return printJson({ board, boards: state.boards, currentBoardId: state.currentBoardId });
console.log(`${board.id}\t${board.title}`);
return;
}
if (action === "delete") {
const [requested] = withoutFlags(rest);
if (!requested) throw new Error("Provide a board id.");
if (!hasFlag(rest, "--confirm")) throw new Error("Deleting a board requires --confirm.");
if (state.boards.length <= 1) throw new Error("Keep at least one board.");
const boardId = safeBoardId(requested);
const board = state.boards.find((item) => item.id === boardId);
if (!board) throw new Error(`No board found for ${requested}`);
state.boards = state.boards.filter((item) => item.id !== board.id);
if (state.currentBoardId === board.id) {
state.currentBoardId = state.boards[0]?.id ?? defaultBoardId;
}
await fs.rm(boardFile(board.id), { force: true });
const comments = await readComments();
comments.comments = comments.comments.filter((comment) => comment.boardId !== board.id);
await writeComments(comments);
await writeJson(stateFile, state);
await recordTimelineEvent({ type: "board.delete", boardId: board.id, title: board.title });
if (json) return printJson({ board, boards: state.boards, currentBoardId: state.currentBoardId });
console.log(`${board.id}\t${board.title}\tdeleted`);
return;
}
throw new Error(`Unknown board command: ${action}`);
}
async function commentsCommand(args) {
const [action, ...rest] = args;
const json = hasFlag(args, "--json");
if (!action || action === "list") {
const result = await listComments(args);
if (json) return printJson(result);
for (const comment of result.comments) {
console.log(`${comment.id}\t${comment.nodeId}\t${comment.title ?? ""}\t${comment.quote}\t${comment.comment}`);
}
return;
}
if (action === "node") {
const [nodeId] = withoutFlags(rest);
if (!nodeId) throw new Error("Provide a node id.");
const result = await listComments(args, { nodeId });
if (json) return printJson(result);
for (const comment of result.comments) {
console.log(`${comment.id}\t${comment.quote}\t${comment.comment}`);
}
return;
}
if (action === "file") {
const [filePath] = withoutFlags(rest);
if (!filePath) throw new Error("Provide a file path.");
const result = await listComments(args, { path: resolveUserPath(filePath) });
if (json) return printJson(result);
for (const comment of result.comments) {
console.log(`${comment.id}\t${comment.nodeId}\t${comment.quote}\t${comment.comment}`);
}
return;
}
if (action === "resolve") {
const [commentId] = withoutFlags(rest);
if (!commentId) throw new Error("Provide a comment id.");
const document = await readComments();
const comment = document.comments.find((item) => item.id === commentId);
if (!comment) throw new Error(`No comment found for ${commentId}`);
comment.status = "resolved";
comment.updatedAt = new Date().toISOString();
await writeComments(document);
await recordTimelineEvent({ type: "comment.resolve", boardId: comment.boardId, nodeId: comment.nodeId, path: comment.path, title: comment.title, text: comment.comment });
if (json) return printJson({ comment });
console.log(`${comment.id}\tresolved`);
return;
}
throw new Error(`Unknown comments command: ${action}`);
}
async function status(args) {
const json = hasFlag(args, "--json");
const document = await readCanvas(args);
const files = await scanFiles();
const state = await readState();
const board = await currentBoard(args);
const result = {
appRoot,
miraHome,
storageMode,
sourceWorkspaceRoot,
workspaceRoot,
projectRoot,
canvasRoot,
boardsRoot,
filesRoot,
canvasFile,
commentsFile,
timelineFile,
stateFile,
board,
boards: state.boards,
serviceUrl: "http://localhost:3020",
nodes: document.nodes.length,
files: files.length
};
if (json) printJson(result);
else {
console.log(`app\t${appRoot}`);
console.log(`home\t${miraHome}`);
console.log(`source\t${sourceWorkspaceRoot}`);
console.log(`storage\t${canvasRoot}`);
console.log(`board\t${board.id}\t${board.title}`);
console.log(`files\t${filesRoot}`);
console.log(`service\t${result.serviceUrl}`);
console.log(`nodes\t${result.nodes}`);
console.log(`files\t${result.files}`);
}
}
async function migrateCommand(args) {
const json = hasFlag(args, "--json");
await ensureCanvas();
const state = await readState();
const migration = await readJson(migrationFile, { migratedStores: [], backups: {} });
migration.backups ??= {};
migration.backups.__global_current ??= (await backupMigrationSource(canvasRoot, "current-global")) ?? "";
migration.updatedAt = new Date().toISOString();
await writeJson(migrationFile, migration);
const result = {
miraHome,
canvasRoot,
boards: state.boards,
migratedStores: migration.migratedStores ?? [],
backups: migration.backups ?? {}
};
if (json) return printJson(result);
console.log(`home\t${miraHome}`);
console.log(`storage\t${canvasRoot}`);
console.log(`boards\t${state.boards.length}`);
for (const store of result.migratedStores) console.log(`migrated\t${store}`);
}
async function contextFor(args) {
const [id] = withoutFlags(args);
if (!id) throw new Error("Provide a node id.");
const document = await readCanvas(args);
const board = await currentBoard(args);
const targets = id === "all" ? document.nodes : document.nodes.filter((item) => item.id === id);
if (!targets.length) throw new Error(`No node found for ${id}`);
const commentsDocument = await readComments();
for (const node of targets) {
let content = node.data.content ?? "";
if (!content && node.data.path && ["markdown", "html", "file"].includes(node.data.sourceType)) {
content = await fs.readFile(node.data.path, "utf8").catch(() => "");
}
const comments = commentsDocument.comments.filter(
(comment) => comment.boardId === board.id && comment.status === "open" && (comment.nodeId === node.id || (node.data.path && comment.path === node.data.path))
);
const asset = node.data.asset ?? (node.data.path ? await getAssetMetadata(node.data.path).catch(() => null) : null);
console.log(
[
"# Canvas Context",
"",
`board_id: ${board.id}`,
`board_title: ${board.title}`,
`node_id: ${node.id}`,
`type: ${node.data.sourceType}`,
`title: ${node.data.title}`,
node.data.path ? `path: ${node.data.path}` : "",
node.data.summary ? `summary: ${node.data.summary}` : "",
asset ? `asset: ${JSON.stringify(asset)}` : "",
"",
"## Content",
content || "(This node mainly provides a media path or canvas metadata.)",
comments.length ? "\n## Comments" : "",
...comments.map((comment) => [`comment_id: ${comment.id}`, `quote: ${comment.quote}`, `comment: ${comment.comment}`].join("\n"))
]
.filter(Boolean)
.join("\n")
);
if (targets.length > 1) console.log("\n---\n");
}
}
async function noteCommand(args) {
const json = hasFlag(args, "--json");
const text = withoutFlags(args).join(" ").trim();
if (!text) throw new Error("Provide a note.");
const board = await currentBoard(args);
const event = await recordTimelineEvent({ type: "note", boardId: board.id, text });
if (json) return printJson({ event });
console.log(`${event.id}\t${event.createdAt}\t${event.text}`);
}
async function timelineCommand(args) {
const json = hasFlag(args, "--json");
const limit = Number(getOptionValue(args, "--limit") ?? 50);
const board = await currentBoard(args);
const document = await readTimeline();
const events = document.events.filter((event) => event.boardId === board.id).slice(-limit);
if (json) return printJson({ board, events });
for (const event of events) {
console.log(`${event.createdAt}\t${event.type}\t${event.title ?? event.nodeId ?? event.path ?? ""}\t${event.text ?? ""}`);
}
}
async function describeCommand(args) {
const json = hasFlag(args, "--json");
const [nodeId, ...textParts] = withoutFlags(args);
const description = textParts.join(" ").trim();
if (!nodeId) throw new Error("Provide a node id.");
if (!description) throw new Error("Provide a description.");
const document = await readCanvas(args);
const node = document.nodes.find((item) => item.id === nodeId);
if (!node) throw new Error(`No node found for ${nodeId}`);
node.data.asset = {
...(node.data.path ? await getAssetMetadata(node.data.path).catch(() => ({})) : {}),
...node.data.asset,
description,
updatedAt: new Date().toISOString()
};
await writeCanvas(document, args);
const board = await currentBoard(args);
const event = await recordTimelineEvent({ type: "asset.describe", boardId: board.id, nodeId: node.id, path: node.data.path, title: node.data.title, text: description });
if (json) return printJson({ board, node, event });
console.log(`${node.id}\t${node.data.title}\t${description}`);
}
async function readCommand(args) {
const resolved = await assertAllowedPath(args[0]);
console.log(await fs.readFile(resolved, "utf8"));
}
async function writeCommand(args) {
const resolved = await assertAllowedPath(args[0]);
const content = args.slice(1).join(" ");
await fs.writeFile(resolved, content, "utf8");
const board = await currentBoard(args);
await recordTimelineEvent({ type: "file.write", boardId: board.id, path: resolved, title: path.basename(resolved), details: { bytes: Buffer.byteLength(content, "utf8") } });
}
async function serve(args) {
const portIndex = args.indexOf("--port");
const port = portIndex >= 0 ? args[portIndex + 1] : "3020";
const nextBin = path.join(appRoot, "node_modules", "next", "dist", "bin", "next");
const child = spawn(process.execPath, [nextBin, "dev", "--port", port], {
cwd: appRoot,
env: {
...process.env,
MIRA_HOME: miraHome,
MIRA_SOURCE_WORKSPACE: sourceWorkspaceRoot,
MIRA_STORAGE: storageMode,
CANVAS_WORKSPACE: sourceWorkspaceRoot,
PORT: port
},
stdio: "inherit"
});
child.on("exit", (code) => process.exit(code ?? 0));
}
async function openCanvas(args) {
const portIndex = args.indexOf("--port");
const port = portIndex >= 0 ? args[portIndex + 1] : "3020";
spawn("open", [`http://localhost:${port}`], {
cwd: workspaceRoot,
detached: true,
stdio: "ignore"
}).unref();
}
async function main() {
const [command, ...args] = process.argv.slice(2);
if (!command || command === "--help" || command === "-h") {
usage();
return;
}
if (command === "init") return ensureCanvas();
if (command === "serve") return serve(args);
if (command === "open") return openCanvas(args);
if (command === "status") return status(args);
if (command === "migrate") return migrateCommand(args);
if (command === "board") return boardCommand(args);
if (command === "comments") return commentsCommand(args);
if (command === "note") return noteCommand(args);
if (command === "timeline") return timelineCommand(args);
if (command === "describe") return describeCommand(args);
if (command === "list") return listNodes(args);
if (command === "remove" || command === "rm") return removeNodes(args);
if (command === "files") return listFiles(args);
if (command === "import") return importFiles(args);
if (command === "add") return addFiles(args);
if (command === "markdown") return createMarkdown(args);
if (command === "link") return linkPath(args);
if (command === "context") return contextFor(args);
if (command === "read") return readCommand(args);
if (command === "write") return writeCommand(args);
usage();
process.exitCode = 1;
}
main().catch((error) => {
console.error(error.message);
process.exit(1);
});
Contributing
Thanks for helping improve Mira.
Development Setup
pnpm install
pnpm devRun the production build before opening a pull request:
pnpm buildPull Request Guidelines
- Keep changes focused and easy to review.
- Preserve the local-first workspace model.
- Avoid destructive edits to user media files.
- Update
README.mdand the bundled skill when CLI behavior changes. - Include screenshots or short notes for UI changes.
Supported Files
The canvas currently previews Markdown, HTML, images, and videos. New preview types should include CLI support, UI rendering, and context behavior.
MIT License
Copyright (c) 2026 oil-oil
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
turbopack: {}
};
export default nextConfig;
{
"name": "mira",
"version": "0.1.0",
"description": "A local-first visual canvas for agents to preview Markdown, HTML, images, and videos.",
"license": "MIT",
"type": "module",
"homepage": "https://github.com/oil-oil/mira#readme",
"repository": {
"type": "git",
"url": "git+https://github.com/oil-oil/mira.git"
},
"bugs": {
"url": "https://github.com/oil-oil/mira/issues"
},
"bin": {
"mira": "./bin/mira.mjs"
},
"files": [
"SKILL.md",
"assets",
"bin",
"src",
"samples",
"skills",
"next.config.ts",
"next-env.d.ts",
"tsconfig.json",
"README.md",
"LICENSE",
"CONTRIBUTING.md",
"SECURITY.md"
],
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"keywords": [
"agent",
"canvas",
"cli",
"react-flow",
"markdown",
"html",
"preview"
],
"engines": {
"node": ">=20"
},
"packageManager": "pnpm@10.16.1",
"dependencies": {
"@radix-ui/react-context-menu": "^2.2.16",
"@tiptap/core": "3.22.5",
"@tiptap/extension-image": "3.22.5",
"@tiptap/react": "^3.22.5",
"@tiptap/starter-kit": "^3.22.5",
"@xyflow/react": "^12.9.2",
"chokidar": "^5.0.0",
"lucide-react": "^0.468.0",
"marked": "^18.0.3",
"next": "^16.0.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"turndown": "^7.2.4",
"typescript": "^5.7.2"
},
"devDependencies": {
"@types/node": "^22.10.2",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@types/turndown": "^5.0.6"
}
}
<p align="center"> <img src="./assets/mira-logo.png" alt="Mira" width="120" /> </p>
Mira
Mira is a local-first visual canvas for agents. It lets an agent place Markdown, HTML, images, and videos onto a clean board, while you preview the same context visually.
Install The Skill
npx skills add oil-oil/miraAfter installing the skill, ask your agent to use Mira when you want files previewed on a canvas.
What Mira Does
- Previews Markdown, HTML, images, and videos as movable nodes.
- Lets agents import files, link folders, create Markdown notes, and copy context.
- Tracks lightweight asset metadata such as file size, format, image dimensions, Markdown word count, and HTML title.
- Records a small task timeline through CLI actions and explicit notes.
- Keeps one global Mira home in
~/.mira; use boards to separate topics. - Updates the UI through local file events, so CLI changes appear without waiting for polling.
- Opens a local UI at
http://localhost:3020.
Agent Quick Start
Once the skill is installed, an agent can install and launch the CLI from GitHub:
npm install -g https://github.com/oil-oil/mira/archive/refs/heads/main.tar.gz
mira init
mira board create "Homepage Redesign" --json
mira import ~/Downloads/brief.md ~/Downloads/screenshot.png --json
mira serve --port 3020Commands
mira init
mira migrate [--json]
mira serve [--port 3020]
mira status [--json]
mira board list [--json]
mira board create <title> [--json]
mira board use <id> [--json]
mira board delete <id> --confirm [--json]
mira comments list [--json]
mira comments node <node-id> [--json]
mira note <text> [--json]
mira timeline [--json] [--limit n]
mira describe <node-id> <text> [--json]
mira list [--json]
mira remove <node-id...> [--json]
mira files [--json]
mira import <file...> [--json]
mira add <file...> [--json]
mira markdown [title] [--json]
mira link <path> [name] [--json]
mira context <node-id|all>
mira read <path>
mira write <path> <content>By default, Mira stores canvas data in:
~/.mira/Run mira status --json to see Mira Home and the CLI default board. The browser remembers its own visible board, so Agent commands do not pull the UI away from what you are viewing. Run mira migrate --json after upgrades to fold older ~/.mira/sessions/* or project .canvas/ data into the global home; migration records backups under ~/.mira-backups.
Batch imports are automatically grouped by file type and arranged with the same spacing as the UI smart layout. Board deletion always requires confirmation: the UI asks twice, and the CLI requires --confirm.
Supported Files
Markdown: .md .mdx .markdown
HTML: .html .htm
Images: .png .jpg .jpeg .gif .webp .svg .avif
Videos: .mp4 .webm .mov .m4vTech Stack
Mira uses Next.js, React, TypeScript, React Flow, Tiptap, Radix UI, and a Node.js CLI.
License
MIT
Mira
This canvas service turns files into movable visual nodes with copyable context.
First Release Capabilities
- Markdown can be edited in the right-side preview panel and written back to source files.
- HTML, images, and videos can be previewed directly in the canvas and opened in the right-side preview panel.
- Prompt nodes can reference nearby materials to organize input for AI.
- Files are managed inside the active Mira session under
~/.mira, with support for symlinks to external folders.
Draft Agent Workflow
When an Agent sees a canvas node, it should copy the node context first. The context includes paths, summaries, and only the necessary excerpts. If full content is needed, the Agent should read the source file through the local service.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<style>
body {
margin: 0;
font-family: ui-serif, Georgia, "Songti SC", serif;
color: #2b2925;
background: #fbf7ee;
}
.sheet {
min-height: 100vh;
box-sizing: border-box;
padding: 28px;
border: 1px solid #d8ccbb;
background:
linear-gradient(rgba(255, 255, 255, 0.52), rgba(255, 255, 255, 0.16)),
#f7efe0;
}
h1 {
margin: 0 0 12px;
font-size: 28px;
letter-spacing: 0;
}
p {
line-height: 1.8;
}
</style>
</head>
<body>
<main class="sheet">
<h1>HTML Node</h1>
<p>This node is rendered in an isolated iframe. You can switch to source editing and write changes back to the original HTML file.</p>
</main>
</body>
</html>
Security
Mira is intended for local development and local agent workflows.
Local Service
The preview service can read and write files inside the active project, copied Mira session files, and paths mapped through the session files directory under ~/.mira. Run it only on trusted machines and avoid exposing it directly to the public internet.
Reporting Issues
Please report security issues privately through GitHub once the repository is public, or contact the maintainer directly.
{
"skill_name": "mira",
"evals": [
{
"id": 1,
"prompt": "Put design-spec.md and a few screenshots from ~/Downloads onto the canvas for preview, then open the local service so I can view them.",
"expected_output": "Uses mira status/migrate, creates or reuses a dedicated board, imports supported Markdown/image files with an explicit --board id, starts or reuses port 3020, and reports the preview URL.",
"files": []
},
{
"id": 2,
"prompt": "Symlink my Downloads folder into Mira, then add only example.html and hero.png to the canvas without copying the original files.",
"expected_output": "Uses link plus add with an explicit --board id, avoids import for the specific files, and returns node ids/paths in JSON or a concise summary.",
"files": []
},
{
"id": 3,
"prompt": "Create a new Markdown prompt draft node so I can edit it in the right-side inspector. Also tell me how a future Agent can read this node's context.",
"expected_output": "Uses the markdown command with an explicit --board id, explains context/list/read commands, and does not create an unrelated prompt node.",
"files": []
},
{
"id": 4,
"prompt": "Another Agent may also be using Mira. Put these image candidates on a new board and make sure they do not accidentally go to Main.",
"expected_output": "Creates a dedicated board, captures its id, passes --board to every batch command, and avoids relying on current board state.",
"files": []
}
]
}
import { NextResponse } from "next/server";
import { createBoard, deleteBoard, listBoards, setCurrentBoard } from "@/lib/canvasStore";
export const runtime = "nodejs";
export async function GET() {
try {
return NextResponse.json(await listBoards());
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const payload = await request.json();
if (payload?.action === "create") {
const board = await createBoard(String(payload.title ?? ""));
return NextResponse.json({ board, ...(await listBoards()) });
}
if (payload?.action === "use") {
const board = await setCurrentBoard(String(payload.boardId ?? ""));
return NextResponse.json({ board, ...(await listBoards()) });
}
if (payload?.action === "delete") {
const board = await deleteBoard(String(payload.boardId ?? ""));
return NextResponse.json({ board, ...(await listBoards()) });
}
return NextResponse.json({ error: "Unsupported board action." }, { status: 400 });
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 400 });
}
}
import { NextResponse } from "next/server";
import { readCanvas, writeCanvas } from "@/lib/canvasStore";
export const runtime = "nodejs";
export async function GET(request: Request) {
try {
const url = new URL(request.url);
return NextResponse.json(await readCanvas(url.searchParams.get("board") ?? undefined));
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const url = new URL(request.url);
const document = await request.json();
const board = await writeCanvas(document, url.searchParams.get("board") ?? undefined);
return NextResponse.json({ ok: true, board });
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
}
}
import { NextResponse } from "next/server";
import { createComment, getCurrentBoard, listComments, resolveComment } from "@/lib/canvasStore";
export const runtime = "nodejs";
export async function GET(request: Request) {
try {
const url = new URL(request.url);
const boardId = url.searchParams.get("board") ?? (await getCurrentBoard())?.id;
const nodeId = url.searchParams.get("node") ?? undefined;
const path = url.searchParams.get("path") ?? undefined;
const status = (url.searchParams.get("status") as "open" | "resolved" | null) ?? "open";
const comments = await listComments({ boardId, nodeId, path, status });
return NextResponse.json({ comments });
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const payload = await request.json();
if (payload?.action === "resolve") {
const comment = await resolveComment(String(payload.id ?? ""));
return NextResponse.json({ comment });
}
const comment = await createComment({
boardId: String(payload.boardId ?? ""),
nodeId: String(payload.nodeId ?? ""),
path: payload.path ? String(payload.path) : undefined,
title: payload.title ? String(payload.title) : undefined,
quote: String(payload.quote ?? ""),
comment: String(payload.comment ?? "")
});
return NextResponse.json({ comment });
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 400 });
}
}
import { addMiraEventClient, notifyMiraChange, startMiraWatcher } from "@/lib/miraEvents";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET() {
startMiraWatcher();
let removeClient: (() => void) | undefined;
let heartbeat: ReturnType<typeof setInterval> | undefined;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
removeClient = addMiraEventClient(controller);
heartbeat = setInterval(() => {
controller.enqueue(new TextEncoder().encode(": heartbeat\n\n"));
}, 25000);
},
cancel() {
if (heartbeat) clearInterval(heartbeat);
removeClient?.();
}
});
return new Response(stream, {
headers: {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-store, no-transform",
connection: "keep-alive"
}
});
}
export async function POST(request: Request) {
const payload = (await request.json().catch(() => ({}))) as { boardId?: string; reason?: string; path?: string };
notifyMiraChange({
boardId: payload.boardId,
path: payload.path,
reason: payload.reason ?? "manual"
});
return Response.json({ ok: true });
}
import { promises as fs } from "fs";
import { NextRequest, NextResponse } from "next/server";
import { assertAllowedPath, getCurrentBoard, recordTimelineEvent } from "@/lib/canvasStore";
export const runtime = "nodejs";
export async function GET(request: NextRequest) {
try {
const filePath = request.nextUrl.searchParams.get("path") ?? "";
const resolved = await assertAllowedPath(filePath);
const content = await fs.readFile(resolved, "utf8");
return NextResponse.json({ path: resolved, content });
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 400 });
}
}
export async function POST(request: Request) {
try {
const body = (await request.json()) as { path?: string; content?: string };
const resolved = await assertAllowedPath(body.path ?? "");
await fs.writeFile(resolved, body.content ?? "", "utf8");
const board = await getCurrentBoard();
await recordTimelineEvent({ type: "file.write", boardId: board?.id, path: resolved, title: resolved.split("/").pop(), details: { bytes: Buffer.byteLength(body.content ?? "", "utf8") } });
return NextResponse.json({ ok: true, path: resolved });
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 400 });
}
}
import { promises as fs } from "fs";
import path from "path";
import { NextResponse } from "next/server";
import { filesRoot, getAssetMetadata, getCurrentBoard, inferSourceType, recordTimelineEvent } from "@/lib/canvasStore";
export const runtime = "nodejs";
async function uniquePath(directory: string, name: string) {
const parsed = path.parse(name);
let candidate = path.join(directory, `${parsed.name}${parsed.ext}`);
let index = 1;
while (true) {
try {
await fs.access(candidate);
candidate = path.join(directory, `${parsed.name}-${index}${parsed.ext}`);
index += 1;
} catch {
return candidate;
}
}
}
export async function POST() {
try {
const notesRoot = path.join(filesRoot, "notes");
await fs.mkdir(notesRoot, { recursive: true });
const targetPath = await uniquePath(notesRoot, `untitled-${Date.now()}.md`);
await fs.writeFile(targetPath, "", "utf8");
const asset = await getAssetMetadata(targetPath);
const board = await getCurrentBoard();
await recordTimelineEvent({ type: "markdown.create", boardId: board?.id, path: targetPath, title: path.basename(targetPath) });
return NextResponse.json({
ok: true,
title: path.basename(targetPath),
path: targetPath,
sourceType: inferSourceType(targetPath),
asset
});
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 400 });
}
}
import { promises as fs } from "fs";
import path from "path";
import { NextResponse } from "next/server";
import { filesRoot, getCurrentBoard, recordTimelineEvent, resolveUserPath } from "@/lib/canvasStore";
export const runtime = "nodejs";
function safeName(input: string) {
return input.replace(/[^a-zA-Z0-9._-]/g, "-").replace(/-+/g, "-");
}
export async function POST(request: Request) {
try {
const body = (await request.json()) as { target?: string; name?: string };
const target = resolveUserPath(body.target ?? "");
await fs.access(target);
const linkName = safeName(body.name || path.basename(target));
const linkPath = path.join(filesRoot, linkName);
await fs.symlink(target, linkPath);
const board = await getCurrentBoard();
await recordTimelineEvent({ type: "file.link", boardId: board?.id, path: linkPath, title: linkName, details: { target } });
return NextResponse.json({ ok: true, linkPath, target });
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 400 });
}
}
import { NextResponse } from "next/server";
import { scanCanvasFiles } from "@/lib/canvasStore";
export const runtime = "nodejs";
export async function GET() {
try {
return NextResponse.json({ files: await scanCanvasFiles() });
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
}
}
import { promises as fs } from "fs";
import { NextRequest, NextResponse } from "next/server";
import { assertAllowedPath, contentTypeFor } from "@/lib/canvasStore";
export const runtime = "nodejs";
export async function GET(request: NextRequest) {
try {
const filePath = request.nextUrl.searchParams.get("path") ?? "";
const resolved = await assertAllowedPath(filePath);
const buffer = await fs.readFile(resolved);
return new Response(buffer, {
headers: {
"content-type": contentTypeFor(resolved),
"cache-control": "no-store"
}
});
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 400 });
}
}
import { execFile } from "child_process";
import { promises as fs } from "fs";
import path from "path";
import { promisify } from "util";
import { NextResponse } from "next/server";
import { filesRoot, resolveUserPath } from "@/lib/canvasStore";
export const runtime = "nodejs";
const execFileAsync = promisify(execFile);
export async function POST(request: Request) {
try {
const body = (await request.json()) as { path?: string };
const target = body.path ? resolveUserPath(body.path) : filesRoot;
const stat = await fs.stat(target);
const folder = stat.isDirectory() ? target : path.dirname(target);
await execFileAsync("open", [folder]);
return NextResponse.json({ ok: true, path: folder });
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 400 });
}
}
import { promises as fs } from "fs";
import path from "path";
import { NextResponse } from "next/server";
import { filesRoot, getAssetMetadata, getCurrentBoard, inferSourceType, isRenderableSourceType, recordTimelineEvent } from "@/lib/canvasStore";
export const runtime = "nodejs";
function safeName(input: string) {
const cleaned = input.replace(/[^a-zA-Z0-9._-]/g, "-").replace(/-+/g, "-");
return cleaned || `file-${Date.now()}`;
}
async function uniquePath(directory: string, name: string) {
const parsed = path.parse(safeName(name));
let candidate = path.join(directory, `${parsed.name}${parsed.ext}`);
let index = 1;
while (true) {
try {
await fs.access(candidate);
candidate = path.join(directory, `${parsed.name}-${index}${parsed.ext}`);
index += 1;
} catch {
return candidate;
}
}
}
export async function POST(request: Request) {
try {
const formData = await request.formData();
const file = formData.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "Missing file" }, { status: 400 });
}
const sourceType = inferSourceType(file.name);
if (!isRenderableSourceType(sourceType)) {
return NextResponse.json({ error: "Only image, video, HTML, and Markdown files are supported." }, { status: 400 });
}
const dropsRoot = path.join(filesRoot, "drops");
await fs.mkdir(dropsRoot, { recursive: true });
const targetPath = await uniquePath(dropsRoot, file.name);
const buffer = Buffer.from(await file.arrayBuffer());
await fs.writeFile(targetPath, buffer);
const asset = await getAssetMetadata(targetPath);
const board = await getCurrentBoard();
await recordTimelineEvent({ type: "file.upload", boardId: board?.id, path: targetPath, title: path.basename(targetPath), details: { sourceType, sizeBytes: asset.sizeBytes } });
return NextResponse.json({
ok: true,
title: path.basename(targetPath),
path: targetPath,
sourceType,
asset
});
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 400 });
}
}
import { NextResponse } from "next/server";
import {
boardsRoot,
canvasFile,
canvasRoot,
commentsFile,
filesRoot,
getCurrentBoard,
metaFile,
miraHome,
sourceWorkspaceRoot,
stateFile,
storageMode,
timelineFile,
workspaceRoot
} from "@/lib/canvasStore";
export const runtime = "nodejs";
export async function GET() {
const currentBoard = await getCurrentBoard();
return NextResponse.json({
miraHome,
storageMode,
sourceWorkspaceRoot,
workspaceRoot,
canvasRoot,
boardsRoot,
filesRoot,
metaFile,
canvasFile,
commentsFile,
timelineFile,
stateFile,
currentBoard
});
}
import { NextResponse } from "next/server";
import { getCurrentBoard, listTimeline, recordTimelineEvent } from "@/lib/canvasStore";
export const runtime = "nodejs";
export async function GET(request: Request) {
try {
const url = new URL(request.url);
const boardId = url.searchParams.get("board") ?? (await getCurrentBoard())?.id;
const limit = Number(url.searchParams.get("limit") ?? 100);
const events = await listTimeline({ boardId, limit });
return NextResponse.json({ events });
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const payload = (await request.json()) as { text?: string; boardId?: string };
const board = await getCurrentBoard();
const event = await recordTimelineEvent({
type: "note",
boardId: payload.boardId || board?.id,
text: String(payload.text ?? "").trim()
});
return NextResponse.json({ event });
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 400 });
}
}
:root {
color-scheme: light;
--paper: #f8f1e8;
--paper-soft: #fffaf2;
--paper-warm: #f0e6d6;
--ink: #5a5046;
--muted: #978c7c;
--faint: #c0b6a6;
--line: #ded2c2;
--line-strong: #d0c0aa;
--accent: #b06b55;
--olive: #7f8a60;
--teal: #477780;
--shadow-soft: 0 8px 22px rgba(71, 52, 30, 0.052);
--shadow-hover: 0 12px 28px rgba(71, 52, 30, 0.078);
}
* {
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
margin: 0;
}
body {
color: var(--ink);
background:
linear-gradient(rgba(255, 255, 255, 0.2), rgba(255, 255, 255, 0.06)),
var(--paper);
font-family:
ui-serif,
Georgia,
"Songti SC",
"Noto Serif CJK SC",
serif;
}
button,
input,
textarea {
font: inherit;
}
button {
color: inherit;
}
.app-shell {
display: block;
width: 100vw;
height: 100vh;
overflow: hidden;
}
.sidebar {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 10px;
min-width: 0;
min-height: 0;
padding: 14px 10px;
border-right: 1px solid rgba(201, 184, 160, 0.72);
background: rgba(255, 250, 242, 0.7);
backdrop-filter: blur(10px);
}
.top-strip {
position: absolute;
top: 14px;
left: 14px;
z-index: 20;
display: flex;
align-items: center;
gap: 8px;
}
.canvas-toolbar,
.board-switcher {
position: relative;
display: flex;
align-items: center;
gap: 6px;
padding: 4px;
border: 1px solid var(--line);
border-radius: 999px;
background: rgba(255, 250, 242, 0.7);
box-shadow: 0 8px 20px rgba(71, 52, 30, 0.045);
backdrop-filter: blur(10px);
}
.canvas-toolbar button,
.board-switcher button,
.file-rail button,
.selection-toolbar button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px solid transparent;
border-radius: 999px;
background: transparent;
cursor: pointer;
transition:
background 140ms ease,
border-color 140ms ease,
color 140ms ease;
}
.canvas-toolbar button:hover,
.board-switcher button:hover,
.file-rail button:hover,
.selection-toolbar button:hover {
border-color: var(--line-strong);
background: rgba(255, 255, 255, 0.56);
color: var(--accent);
}
.canvas-toolbar svg,
.board-switcher svg,
.file-rail svg,
.selection-toolbar svg {
width: 18px;
height: 18px;
flex: 0 0 18px;
}
.canvas-toolbar button,
.board-switcher button {
width: auto;
gap: 5px;
padding: 0 10px;
color: var(--muted);
font-size: 12px;
}
.canvas-toolbar button span,
.board-switcher button span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.toolbar-trigger[aria-expanded="true"],
.board-trigger[aria-expanded="true"] {
border-color: var(--line-strong);
background: rgba(255, 255, 255, 0.62);
color: var(--accent);
}
.toolbar-menu,
.board-menu {
position: absolute;
top: calc(100% + 8px);
left: 0;
z-index: 80;
display: grid;
min-width: 154px;
gap: 4px;
padding: 6px;
border: 1px solid rgba(201, 184, 160, 0.78);
border-radius: 12px;
background: rgba(255, 250, 242, 0.94);
box-shadow: 0 12px 28px rgba(71, 52, 30, 0.1);
backdrop-filter: blur(12px);
}
.toolbar-menu button,
.board-menu button {
width: 100%;
justify-content: flex-start;
gap: 7px;
padding: 0 9px;
border-radius: 9px;
}
.board-trigger {
max-width: 220px;
}
.board-menu {
width: 280px;
overflow: hidden;
}
.board-list {
display: grid;
max-height: 260px;
gap: 3px;
overflow-x: hidden;
overflow-y: auto;
padding: 0;
}
.board-menu-row {
position: relative;
display: flex;
align-items: center;
min-width: 0;
min-height: 34px;
border-radius: 10px;
transition:
background 140ms ease,
color 140ms ease;
}
.board-menu-row.active {
background: rgba(173, 90, 61, 0.1);
color: var(--accent);
}
.board-menu-select {
flex: 1 1 auto;
width: 100% !important;
min-width: 0;
}
.board-menu-select span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.board-delete-control {
position: absolute;
top: 50%;
right: 4px;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid transparent;
border-radius: 9px;
background: transparent;
color: var(--muted);
cursor: pointer;
opacity: 0;
pointer-events: none;
transform: translateY(-50%) scale(0.94);
transition:
opacity 140ms ease,
transform 140ms ease,
background 140ms ease,
color 140ms ease;
}
.board-delete-control svg {
display: block;
width: 15px;
height: 15px;
flex: 0 0 auto;
}
.board-menu-row:hover .board-delete-control,
.board-menu-row:focus-within .board-delete-control {
opacity: 1;
pointer-events: auto;
transform: translateY(-50%) scale(1);
}
.board-delete-control:hover {
border-color: rgba(173, 90, 61, 0.28);
background: rgba(173, 90, 61, 0.08);
color: var(--accent);
}
.board-delete-control.disabled {
cursor: default;
opacity: 0.32;
pointer-events: none;
}
.board-delete-confirm {
position: absolute;
top: 50%;
right: 4px;
display: inline-flex;
flex: none;
gap: 3px;
padding: 2px;
border: 1px solid rgba(201, 184, 160, 0.72);
border-radius: 999px;
background: rgba(255, 250, 242, 0.98);
box-shadow: 0 8px 18px rgba(71, 52, 30, 0.08);
transform: translateY(-50%);
}
.board-delete-confirm span {
display: inline-flex;
align-items: center;
justify-content: center;
height: 24px;
padding: 0 8px;
border-radius: 999px;
color: var(--muted);
font-size: 10.5px;
cursor: pointer;
}
.board-delete-confirm span:first-child {
color: var(--accent);
}
.board-delete-confirm span:hover {
background: rgba(173, 90, 61, 0.08);
}
.hidden-file-input {
display: none;
}
.file-rail {
display: flex;
flex-direction: column;
flex: 1 1 auto;
gap: 7px;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
padding: 2px 0;
overscroll-behavior: contain;
}
.file-rail button {
justify-content: flex-start;
width: 100%;
height: 34px;
gap: 7px;
padding: 0 8px;
border-radius: 9px;
color: var(--muted);
}
.file-rail button.active {
background: rgba(173, 90, 61, 0.1);
color: var(--accent);
}
.file-rail span {
overflow: hidden;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.canvas-area {
position: relative;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
--bottom-bar-height: 26px;
}
.path-bar {
position: absolute;
right: 0;
bottom: 0;
left: 0;
z-index: 20;
display: flex;
align-items: center;
height: 26px;
min-width: 0;
padding: 0 12px;
border: 0;
border-top: 1px solid rgba(201, 184, 160, 0.58);
border-radius: 0;
background: rgba(255, 250, 242, 0.86);
color: var(--muted);
font-size: 12px;
line-height: 1;
cursor: copy;
backdrop-filter: blur(10px);
}
.path-bar:hover {
background: rgba(255, 250, 242, 0.94);
color: var(--accent);
}
.path-bar span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.drop-hint {
position: absolute;
inset: 18px;
z-index: 30;
display: grid;
place-items: center;
border: 1px dashed rgba(173, 90, 61, 0.34);
border-radius: 16px;
background: rgba(255, 250, 242, 0.68);
color: var(--accent);
font-size: 14px;
opacity: 0;
pointer-events: none;
transition: opacity 140ms ease;
backdrop-filter: blur(8px);
}
.canvas-area.is-dragging-file .drop-hint {
opacity: 1;
}
.react-flow {
width: 100%;
height: 100%;
background: transparent;
}
.react-flow__node {
border-radius: 12px;
will-change: transform;
}
.react-flow__node.selected .canvas-node {
outline: 0;
box-shadow: 0 13px 32px rgba(71, 52, 30, 0.13);
}
.react-flow__node.selected .media-node {
background: transparent;
box-shadow: none;
filter: drop-shadow(0 13px 28px rgba(71, 52, 30, 0.14));
}
.react-flow__node.selected .media-node::after {
content: none;
}
.react-flow__node.selected .node-caption {
color: var(--accent);
}
.react-flow__selection {
border: 1px solid rgba(173, 90, 61, 0.55);
background: rgba(173, 90, 61, 0.08);
}
.react-flow__controls {
bottom: calc(var(--bottom-bar-height) + 12px);
left: 28px;
overflow: hidden;
border: 1px solid rgba(201, 184, 160, 0.76);
border-radius: 10px;
box-shadow: var(--shadow-soft);
}
.react-flow__controls-button {
border-bottom: 1px solid var(--line);
background: rgba(255, 250, 242, 0.86);
color: var(--muted);
}
.selection-toolbar {
position: absolute;
top: 18px;
left: 50%;
z-index: 20;
display: flex;
align-items: center;
gap: 4px;
padding: 6px;
border: 1px solid rgba(201, 184, 160, 0.78);
border-radius: 999px;
background: rgba(255, 250, 242, 0.86);
box-shadow: var(--shadow-soft);
transform: translate(-50%, calc(-100% - 10px));
backdrop-filter: blur(10px);
}
.selection-toolbar button {
width: 36px;
height: 34px;
}
.toast-message {
position: absolute;
top: 18px;
left: 50%;
z-index: 28;
min-width: 128px;
padding: 8px 14px;
border: 1px solid rgba(201, 184, 160, 0.7);
border-radius: 999px;
background: rgba(255, 250, 242, 0.92);
color: var(--muted);
font-size: 12px;
text-align: center;
box-shadow: var(--shadow-soft);
transform: translateX(-50%);
animation: toast-in 180ms ease both;
backdrop-filter: blur(12px);
pointer-events: none;
}
.selection-divider {
width: 1px;
height: 20px;
margin: 0 3px;
background: rgba(201, 184, 160, 0.62);
}
.canvas-node {
position: relative;
contain: layout paint style;
color: var(--ink);
transition:
background-color 150ms ease,
box-shadow 150ms ease,
border-color 150ms ease;
}
.canvas-node:hover {
box-shadow: var(--shadow-hover);
}
.node-frame {
position: relative;
cursor: grab;
}
.node-frame:active {
cursor: grabbing;
}
.canvas-area.is-node-dragging .canvas-node {
transition: none;
}
.canvas-area.is-node-dragging .html-frame,
.canvas-area.is-node-dragging .media-video {
pointer-events: none;
}
.canvas-area.is-canvas-gesture .html-frame,
.canvas-area.is-canvas-gesture .media-video,
.canvas-area.is-canvas-gesture .markdown-preview {
pointer-events: none !important;
}
.media-node:hover {
box-shadow: none;
filter: drop-shadow(0 12px 24px rgba(71, 52, 30, 0.1));
}
.node-resize-line {
border-color: rgba(173, 90, 61, 0.42) !important;
}
.node-resize-handle {
width: 9px !important;
height: 9px !important;
border: 1px solid rgba(173, 90, 61, 0.58) !important;
background: rgba(255, 250, 242, 0.95) !important;
box-shadow: 0 4px 10px rgba(71, 52, 30, 0.13);
}
.paper-node {
width: 360px;
min-height: 280px;
padding: 12px;
border: 1px solid rgba(205, 188, 164, 0.82);
border-radius: 12px;
background:
linear-gradient(rgba(255, 255, 255, 0.62), rgba(255, 255, 255, 0.34)),
var(--paper-soft);
box-shadow: var(--shadow-soft);
}
.paper-node.is-sized {
display: flex;
min-height: unset;
flex-direction: column;
overflow: hidden;
}
.markdown {
display: flex;
flex-direction: column;
width: 720px;
height: 820px;
min-height: 520px;
max-height: 820px;
overflow: hidden;
}
.markdown.is-sized {
min-height: unset;
max-height: none;
}
.html-node {
width: 960px;
height: 600px;
min-height: unset;
padding: 10px;
}
.prompt-node {
width: 360px;
min-height: 280px;
background:
linear-gradient(rgba(255, 255, 255, 0.56), rgba(255, 255, 255, 0.25)),
#f3e5d5;
}
.file-node {
width: 300px;
min-height: 190px;
}
.file-node.is-sized {
min-height: unset;
}
.node-caption {
position: absolute;
top: -32px;
left: 0;
z-index: 2;
display: inline-flex;
align-items: center;
max-width: min(100%, 320px);
gap: 5px;
padding: 4px 7px;
border-radius: 9px;
color: var(--muted);
font-size: 12px;
line-height: 1.2;
pointer-events: auto;
transform: scale(var(--caption-scale, 1));
transform-origin: left bottom;
}
.node-caption svg {
width: 15px;
height: 15px;
flex: 0 0 15px;
}
.node-caption strong {
overflow: hidden;
font-weight: 540;
text-overflow: ellipsis;
white-space: nowrap;
}
.node-header {
display: grid;
grid-template-columns: 20px 1fr;
gap: 6px;
align-items: center;
color: var(--muted);
}
.node-header.compact {
margin-bottom: 10px;
}
.node-header strong {
overflow: hidden;
color: var(--muted);
font-size: 14px;
font-weight: 560;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.markdown-editor {
min-height: 292px;
max-height: 456px;
overflow-x: hidden;
overflow-y: auto;
}
.paper-node.is-sized .markdown-editor {
min-height: 0;
max-height: none;
flex: 1 1 auto;
overflow-x: hidden;
overflow-y: auto;
}
.markdown-preview {
flex: 1 1 auto;
min-height: 0;
max-height: none;
overflow-x: hidden;
overflow-y: auto;
padding: 10px 12px;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.markdown-body,
.tiptap-body {
max-width: 100%;
overflow-wrap: anywhere;
color: var(--ink);
font-size: 15px;
line-height: 1.72;
word-break: break-word;
}
.markdown-body img,
.tiptap-body img {
display: block;
width: 100%;
max-width: 100%;
height: auto;
object-fit: contain;
}
.markdown-body > *:first-child,
.tiptap-body > *:first-child {
margin-top: 0;
}
.markdown-body > *:last-child,
.tiptap-body > *:last-child {
margin-bottom: 0;
}
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.tiptap-body h1,
.tiptap-body h2,
.tiptap-body h3 {
margin: 0.72em 0 0.38em;
letter-spacing: 0;
}
.markdown-body h1,
.tiptap-body h1 {
font-size: 24px;
}
.markdown-body h2,
.tiptap-body h2 {
font-size: 19px;
}
.markdown-body p,
.markdown-body ul,
.markdown-body ol,
.tiptap-body p,
.tiptap-body ul,
.tiptap-body ol {
margin: 0.58em 0;
}
.markdown-body blockquote,
.tiptap-body blockquote {
margin: 0.8em 0;
padding-left: 12px;
border-left: 3px solid rgba(173, 90, 61, 0.24);
color: var(--muted);
}
.markdown-body code,
.tiptap-body code {
font-family:
ui-monospace,
SFMono-Regular,
Menlo,
monospace;
white-space: pre-wrap;
word-break: break-word;
}
.markdown-body pre,
.tiptap-body pre {
overflow-x: hidden;
overflow-y: auto;
padding: 10px;
border: 1px solid rgba(205, 188, 164, 0.75);
border-radius: 8px;
background: rgba(245, 237, 224, 0.78);
white-space: pre-wrap;
word-break: break-word;
}
.lazy-cover {
display: grid;
place-items: center;
gap: 10px;
min-height: 210px;
padding: 18px;
border: 1px dashed rgba(205, 188, 164, 0.8);
border-radius: 10px;
background: rgba(255, 252, 246, 0.58);
color: var(--faint);
text-align: center;
}
.paper-node.is-sized .lazy-cover {
min-height: 0;
flex: 1 1 auto;
}
.lazy-cover span {
max-width: 100%;
overflow: hidden;
font-size: 12px;
line-height: 1.5;
text-overflow: ellipsis;
}
.tiptap-body {
min-height: 292px;
max-width: 100%;
overflow-x: hidden;
padding: 10px 12px;
border-radius: 8px;
outline: 0;
cursor: text;
}
.paper-node.is-sized .tiptap-body {
min-height: 100%;
}
.tiptap-body.ProseMirror-focused {
outline: 0;
box-shadow: none;
}
.tiptap-body code,
.source-editor,
.file-card {
font-family:
ui-monospace,
SFMono-Regular,
Menlo,
monospace;
}
.source-editor,
.prompt-editor {
width: 100%;
min-height: 290px;
resize: vertical;
padding: 12px;
border: 1px solid rgba(205, 188, 164, 0.78);
border-radius: 8px;
outline: 0;
background: rgba(255, 252, 246, 0.82);
color: var(--ink);
font-size: 13px;
line-height: 1.6;
}
.paper-node.is-sized .source-editor,
.paper-node.is-sized .prompt-editor {
min-height: 0;
flex: 1 1 auto;
}
.source-editor.code {
min-height: 250px;
}
.prompt-editor {
min-height: 270px;
font-size: 15px;
}
.html-frame {
width: 100%;
height: auto;
aspect-ratio: 16 / 10;
border: 1px solid rgba(205, 188, 164, 0.78);
border-radius: 8px;
background: var(--paper-soft);
pointer-events: none;
}
.react-flow__node.selected .html-frame {
pointer-events: auto;
}
.html-node.is-sized .html-frame {
height: 100%;
aspect-ratio: auto;
min-height: 0;
flex: 1 1 auto;
}
.content-inspector {
position: absolute;
top: 0;
right: 0;
bottom: 0;
z-index: 35;
display: flex;
width: min(50vw, 760px);
min-width: 420px;
flex-direction: column;
border-left: 1px solid rgba(201, 184, 160, 0.72);
background: rgba(255, 250, 242, 0.92);
box-shadow: -18px 0 42px rgba(71, 52, 30, 0.1);
animation: inspector-enter 260ms cubic-bezier(0.2, 0.82, 0.2, 1) both;
backdrop-filter: blur(14px);
}
.content-inspector.is-closing {
pointer-events: none;
animation: inspector-exit 220ms cubic-bezier(0.4, 0, 0.2, 1) both;
}
.inspector-header {
display: grid;
grid-template-columns: 20px 1fr auto;
gap: 8px;
align-items: center;
min-height: 48px;
padding: 10px 14px;
border-bottom: 1px solid rgba(201, 184, 160, 0.58);
color: var(--muted);
}
.inspector-icon {
display: inline-flex;
align-items: center;
justify-content: center;
}
.inspector-title-button {
display: flex;
min-width: 0;
height: 32px;
align-items: center;
justify-content: flex-start;
padding: 0;
border: 0;
background: transparent;
color: var(--ink);
cursor: copy;
}
.inspector-title-button strong {
overflow: hidden;
font-size: 14px;
font-weight: 560;
text-overflow: ellipsis;
white-space: nowrap;
}
.inspector-title-button:hover strong {
color: var(--accent);
}
.inspector-close {
display: inline-flex;
width: 32px;
height: 32px;
align-items: center;
justify-content: center;
padding: 0;
border: 1px solid rgba(201, 184, 160, 0.72);
border-radius: 999px;
background: rgba(255, 255, 255, 0.42);
color: var(--muted);
cursor: pointer;
}
.inspector-close:hover {
color: var(--accent);
}
.inspector-body {
min-height: 0;
flex: 1 1 auto;
overflow: auto;
padding: 14px;
}
.asset-details {
display: grid;
gap: 4px;
padding: 9px 14px;
border-top: 1px solid rgba(201, 184, 160, 0.52);
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}
.asset-details p {
margin: 0;
}
.inspector-frame {
width: 100%;
height: 100%;
min-height: 0;
border: 1px solid rgba(205, 188, 164, 0.7);
border-radius: 10px;
background: var(--paper-soft);
}
.inspector-markdown {
max-width: 760px;
margin: 0 auto;
padding: 14px;
}
.inspector-editor {
width: 100%;
max-width: 760px;
min-height: 100%;
margin: 0 auto;
overflow-x: hidden;
}
.inspector-editor .markdown-editor,
.inspector-editor .tiptap-body {
min-height: calc(100vh - 196px);
max-height: none;
overflow-x: hidden;
}
.markdown-editor-wrap {
position: relative;
}
.selection-comment-popover {
position: absolute;
z-index: 12;
transform: translate(-50%, -100%);
animation: comment-pop 150ms ease-out;
}
.selection-comment-trigger,
.comment-card button,
.comment-composer button {
display: inline-flex;
align-items: center;
gap: 5px;
height: 30px;
padding: 0 10px;
border: 1px solid rgba(201, 184, 160, 0.74);
border-radius: 999px;
background: rgba(255, 253, 248, 0.92);
color: var(--muted);
cursor: pointer;
box-shadow: 0 8px 24px rgba(96, 71, 42, 0.08);
}
.selection-comment-trigger:hover,
.comment-card button:hover,
.comment-composer button:hover {
color: var(--accent);
}
.comment-composer {
display: grid;
gap: 8px;
width: 260px;
padding: 10px;
border: 1px solid rgba(201, 184, 160, 0.72);
background: rgba(255, 253, 248, 0.96);
box-shadow: 0 18px 46px rgba(96, 71, 42, 0.11);
}
.comment-composer textarea {
width: 100%;
min-height: 72px;
resize: vertical;
border: 1px solid rgba(201, 184, 160, 0.64);
background: rgba(255, 252, 246, 0.7);
color: var(--ink);
font: inherit;
font-size: 13px;
line-height: 1.5;
padding: 8px;
outline: none;
}
.comment-composer textarea:focus {
border-color: rgba(173, 90, 61, 0.42);
}
.comment-composer div {
display: flex;
justify-content: flex-end;
gap: 6px;
}
.comment-highlight {
border-radius: 3px;
background: rgba(246, 199, 104, 0.34);
box-shadow: inset 0 -2px rgba(196, 119, 61, 0.18);
}
@keyframes comment-pop {
from {
opacity: 0;
transform: translate(-50%, calc(-100% + 5px)) scale(0.98);
}
to {
opacity: 1;
transform: translate(-50%, -100%) scale(1);
}
}
.comments-panel {
display: grid;
gap: 8px;
margin-top: 10px;
}
.comment-card {
display: grid;
gap: 7px;
padding: 10px;
border-left: 2px solid rgba(173, 90, 61, 0.32);
background: rgba(255, 252, 246, 0.62);
}
.comment-card p {
margin: 0;
}
.comment-card p {
color: var(--ink);
font-size: 13px;
line-height: 1.55;
}
.comment-card button {
width: max-content;
height: 26px;
padding: 0 8px;
font-size: 12px;
}
.image-preview-pane {
display: grid;
min-height: 100%;
place-items: start center;
overflow: auto;
padding: 10px 0 34px;
}
.inspector-image {
display: block;
max-width: 100%;
max-height: calc(100vh - 122px);
object-fit: contain;
user-select: none;
}
.inspector-media {
display: block;
width: 100%;
height: auto;
max-width: 100%;
max-height: calc(100vh - 122px);
margin: 0;
border-radius: 0;
object-fit: contain;
background: rgba(74, 64, 53, 0.08);
}
.inspector-text {
min-height: 100%;
margin: 0;
overflow-wrap: anywhere;
color: var(--ink);
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
}
@keyframes inspector-enter {
from {
opacity: 0;
transform: translateX(28px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes inspector-exit {
from {
opacity: 1;
transform: translateX(0);
}
to {
opacity: 0;
transform: translateX(28px);
}
}
@keyframes toast-in {
from {
opacity: 0;
transform: translate(-50%, -6px);
}
to {
opacity: 1;
transform: translate(-50%, 0);
}
}
.media-node {
width: 260px;
overflow: visible;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.media-node.is-sized {
height: 100%;
min-height: unset;
}
.media-image,
.media-video {
display: block;
width: 100%;
height: auto;
max-height: none;
object-fit: contain;
border-radius: 0;
background: transparent;
}
.media-node.is-sized .media-image,
.media-node.is-sized .media-video {
height: 100%;
max-height: none;
object-fit: contain;
}
.media-placeholder {
display: grid;
place-items: center;
gap: 10px;
width: 100%;
min-height: 190px;
padding: 18px;
border: 1px solid rgba(205, 188, 164, 0.72);
border-radius: 12px;
background: rgba(255, 250, 242, 0.68);
color: var(--faint);
cursor: pointer;
}
.media-placeholder span {
max-width: 100%;
overflow: hidden;
font-size: 12px;
line-height: 1.5;
text-align: center;
text-overflow: ellipsis;
}
.video-placeholder {
background: rgba(74, 64, 53, 0.08);
}
.media-video {
background: rgba(74, 64, 53, 0.08);
opacity: 0.72;
transition: opacity 160ms ease;
}
.media-video.is-frame-ready,
.inspector-media.is-frame-ready {
opacity: 1;
}
.media-overlay {
position: absolute;
right: 10px;
bottom: 10px;
left: 10px;
display: flex;
align-items: center;
gap: 7px;
min-height: 34px;
padding: 7px 10px;
border: 1px solid rgba(205, 188, 164, 0.68);
border-radius: 10px;
background: rgba(255, 250, 242, 0.82);
color: var(--muted);
font-size: 13px;
opacity: 0;
transform: translateY(6px);
transition:
opacity 150ms ease,
transform 150ms ease;
backdrop-filter: blur(8px);
}
.media-node:hover .media-overlay {
opacity: 1;
transform: translateY(0);
}
.file-card {
overflow-wrap: anywhere;
padding: 12px;
border: 1px solid rgba(205, 188, 164, 0.78);
border-radius: 8px;
background: rgba(255, 252, 246, 0.78);
color: var(--muted);
font-size: 12px;
line-height: 1.6;
}
.node-status {
display: block;
min-height: 18px;
margin-top: 8px;
color: var(--olive);
font-size: 12px;
}
.context-menu {
z-index: 50;
min-width: 210px;
padding: 6px;
border: 1px solid rgba(201, 184, 160, 0.82);
border-radius: 10px;
background: rgba(255, 250, 242, 0.96);
color: var(--ink);
box-shadow: 0 18px 38px rgba(71, 52, 30, 0.14);
backdrop-filter: blur(12px);
}
.context-menu-item {
display: grid;
grid-template-columns: 18px minmax(96px, 1fr) auto;
gap: 8px;
align-items: center;
min-height: 32px;
padding: 6px 8px;
border-radius: 7px;
color: var(--muted);
font-size: 13px;
outline: 0;
cursor: pointer;
}
.context-menu-item > span:nth-child(2) {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.context-shortcut {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 3px;
margin-left: 10px;
}
.context-shortcut kbd {
display: inline-flex;
min-width: 18px;
height: 18px;
align-items: center;
justify-content: center;
padding: 0 5px;
border: 1px solid rgba(201, 184, 160, 0.76);
border-radius: 5px;
background: rgba(255, 255, 255, 0.46);
box-shadow: inset 0 -1px 0 rgba(71, 52, 30, 0.08);
color: var(--faint);
font-family:
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
sans-serif;
font-size: 10px;
font-weight: 520;
line-height: 1;
}
.context-menu-item[data-highlighted] {
background: rgba(173, 90, 61, 0.1);
color: var(--accent);
}
.context-menu-item.danger[data-highlighted] {
background: rgba(173, 62, 52, 0.12);
color: #a84236;
}
@media (max-width: 820px) {
.app-shell {
grid-template-columns: 172px 1fr;
}
.sidebar {
padding: 10px 6px;
}
.paper-node {
width: 330px;
min-height: 260px;
}
.markdown {
width: 340px;
min-height: 300px;
}
.markdown-editor,
.tiptap-body {
min-height: 240px;
}
.media-node {
width: 300px;
}
}
import type { Metadata } from "next";
import "./globals.css";
import "@xyflow/react/dist/style.css";
export const metadata: Metadata = {
title: "Mira",
description: "A local visual context canvas for files and prompts.",
icons: {
icon: "/icon.png",
apple: "/apple-icon.png"
}
};
export default function RootLayout({
children
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
export async function writeClipboardPayload(payload: { plain: string; html?: string }) {
if (payload.html && "ClipboardItem" in window && navigator.clipboard.write) {
await navigator.clipboard.write([
new ClipboardItem({
"text/plain": new Blob([payload.plain], { type: "text/plain" }),
"text/html": new Blob([payload.html], { type: "text/html" })
})
]);
return;
}
await navigator.clipboard.writeText(payload.plain);
}
import type { CanvasComment } from "./types";
export async function fetchComments(boardId: string, nodeId: string) {
const response = await fetch(`/api/comments?board=${encodeURIComponent(boardId)}&node=${encodeURIComponent(nodeId)}`);
const payload: { comments?: CanvasComment[]; error?: string } = await response.json();
if (!response.ok) throw new Error(payload.error ?? "Could not load comments.");
return payload.comments ?? [];
}
export async function createNodeComment(input: {
boardId: string;
nodeId: string;
path?: string;
title?: string;
quote: string;
comment: string;
}) {
const response = await fetch("/api/comments", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(input)
});
const payload: { comment?: CanvasComment; error?: string } = await response.json();
if (!response.ok || !payload.comment) throw new Error(payload.error ?? "Could not add comment.");
return payload.comment;
}
export async function resolveNodeComment(id: string) {
const response = await fetch("/api/comments", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ action: "resolve", id })
});
const payload: { comment?: CanvasComment; error?: string } = await response.json();
if (!response.ok) throw new Error(payload.error ?? "Could not resolve comment.");
return payload.comment;
}
"use client";
import { FilePlus2, FileText, Plus } from "lucide-react";
import { memo, useCallback, useEffect, useRef, useState } from "react";
import { supportedFileAccept } from "../constants";
export const CanvasToolbar = memo(function CanvasToolbar({
onAddFiles,
onAddMarkdown
}: {
onAddFiles: (files: FileList) => void;
onAddMarkdown: () => void;
}) {
const [open, setOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const toolbarRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!open) return;
const closeOnOutsideClick = (event: PointerEvent) => {
if (!toolbarRef.current?.contains(event.target as Node)) {
setOpen(false);
}
};
window.addEventListener("pointerdown", closeOnOutsideClick);
return () => window.removeEventListener("pointerdown", closeOnOutsideClick);
}, [open]);
const chooseFiles = useCallback(() => {
setOpen(false);
fileInputRef.current?.click();
}, []);
const handleFileChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
if (event.currentTarget.files?.length) {
onAddFiles(event.currentTarget.files);
}
event.currentTarget.value = "";
},
[onAddFiles]
);
const addMarkdown = useCallback(() => {
setOpen(false);
onAddMarkdown();
}, [onAddMarkdown]);
return (
<section className="canvas-toolbar" ref={toolbarRef}>
<button className="toolbar-trigger" title="Add Content" onClick={() => setOpen((value) => !value)} aria-expanded={open}>
<Plus size={17} />
<span>Add Content</span>
</button>
{open ? (
<div className="toolbar-menu">
<button onClick={chooseFiles}>
<FilePlus2 size={15} />
<span>Add Files</span>
</button>
<button onClick={addMarkdown}>
<FileText size={16} />
<span>New Markdown</span>
</button>
</div>
) : null}
<input ref={fileInputRef} className="hidden-file-input" type="file" multiple accept={supportedFileAccept} onChange={handleFileChange} />
</section>
);
});
"use client";
import { X } from "lucide-react";
import { memo, useCallback, useEffect, useState } from "react";
import { rawUrl, readText } from "../fileApi";
import type { CanvasNode } from "../types";
import { MarkdownEditor } from "./MarkdownEditor";
import { sourceIcons } from "./sourceIcons";
import { VideoPreview } from "./VideoPreview";
function ImagePreviewInspector({ node }: { node: CanvasNode }) {
return (
<div className="image-preview-pane">
<img className="inspector-image" src={rawUrl(node.data.path)} alt={node.data.title} draggable={false} />
</div>
);
}
function formatBytes(bytes?: number) {
if (!bytes) return "";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
function AssetDetails({ node }: { node: CanvasNode }) {
const asset = node.data.asset;
if (!asset) return null;
const items = [
asset.format ? `.${asset.format}` : "",
asset.width && asset.height ? `${asset.width} x ${asset.height}` : "",
asset.durationSeconds ? `${asset.durationSeconds.toFixed(1)}s` : "",
asset.words !== undefined ? `${asset.words} words` : "",
formatBytes(asset.sizeBytes)
].filter(Boolean);
return (
<section className="asset-details">
{items.length ? <p>{items.join(" · ")}</p> : null}
{asset.title ? <p>{asset.title}</p> : null}
{asset.description ? <p>{asset.description}</p> : null}
</section>
);
}
function InspectorContent({ node, boardId }: { node: CanvasNode; boardId: string }) {
const [content, setContent] = useState(node.data.content ?? "");
const [status, setStatus] = useState("");
useEffect(() => {
let cancelled = false;
setContent(node.data.content ?? "");
setStatus("");
if (node.data.path && ["markdown", "file"].includes(node.data.sourceType)) {
readText(node.data.path)
.then((text) => {
if (!cancelled) setContent(text);
})
.catch((error) => {
if (!cancelled) setStatus(error.message);
});
}
return () => {
cancelled = true;
};
}, [node.data.content, node.data.path, node.data.sourceType]);
if (node.data.sourceType === "html") {
return <iframe className="inspector-frame" src={rawUrl(node.data.path)} sandbox="allow-same-origin" title={node.data.title} />;
}
if (node.data.sourceType === "markdown") {
return (
<div className="inspector-editor">
<MarkdownEditor id={node.id} data={{ ...node.data, content }} boardId={boardId} onBindSave={() => undefined} />
</div>
);
}
if (node.data.sourceType === "image") {
return <ImagePreviewInspector node={node} />;
}
if (node.data.sourceType === "video") {
return <VideoPreview className="inspector-media" src={rawUrl(node.data.path)} title={node.data.title} />;
}
if (node.data.sourceType === "prompt") {
return <pre className="inspector-text">{node.data.content ?? ""}</pre>;
}
return <pre className="inspector-text">{content || status || node.data.path}</pre>;
}
const InspectorDrawer = memo(function InspectorDrawer({
node,
boardId,
closing,
onClose,
onExitComplete
}: {
node: CanvasNode;
boardId: string;
closing?: boolean;
onClose: () => void;
onExitComplete?: () => void;
}) {
const [copied, setCopied] = useState(false);
const pathText = node.data.path ?? node.id;
const copyPath = useCallback(async () => {
await navigator.clipboard.writeText(pathText);
setCopied(true);
window.setTimeout(() => setCopied(false), 1100);
}, [pathText]);
return (
<aside className={`content-inspector${closing ? " is-closing" : ""}`} onAnimationEnd={() => closing && onExitComplete?.()}>
<header className="inspector-header">
<span className="inspector-icon">{sourceIcons[node.data.sourceType]}</span>
<button className="inspector-title-button" onClick={copyPath} title={`${copied ? "Copied" : "Click to copy path"}\n${pathText}`}>
<strong>{node.data.title}</strong>
</button>
<button className="inspector-close" onClick={onClose} title="Close Preview" aria-label="Close Preview">
<X size={16} />
</button>
</header>
<section className="inspector-body">
<InspectorContent node={node} boardId={boardId} />
</section>
<AssetDetails node={node} />
</aside>
);
});
export const InspectorHost = memo(function InspectorHost({ node, boardId, onClose }: { node: CanvasNode | null; boardId: string; onClose: () => void }) {
const [renderedNode, setRenderedNode] = useState<CanvasNode | null>(node);
const [closing, setClosing] = useState(false);
useEffect(() => {
if (node) {
setRenderedNode(node);
setClosing(false);
return;
}
if (renderedNode) {
setClosing(true);
}
}, [node, renderedNode]);
const completeExit = useCallback(() => {
if (!closing) return;
setRenderedNode(null);
setClosing(false);
}, [closing]);
if (!renderedNode) return null;
return <InspectorDrawer node={renderedNode} boardId={boardId} closing={closing} onClose={onClose} onExitComplete={completeExit} />;
});
"use client";
import { Mark, Node as TiptapNode, mergeAttributes } from "@tiptap/core";
import Image from "@tiptap/extension-image";
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { Check, MessageCircle } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import TurndownService from "turndown";
import { createNodeComment, fetchComments, resolveNodeComment } from "../commentsApi";
import { isSupportedFile, readText, uploadCanvasFiles, writeText } from "../fileApi";
import { htmlForMarkdownDrop, markdownToHtml } from "../html";
import type { CanvasComment, CanvasNodeData } from "../types";
const TiptapVideo = TiptapNode.create({
name: "mediaVideo",
group: "block",
atom: true,
addAttributes() {
return {
src: { default: null },
title: { default: null }
};
},
parseHTML() {
return [{ tag: "video[src]" }];
},
renderHTML({ HTMLAttributes }) {
return ["video", mergeAttributes(HTMLAttributes, { controls: "true" })];
}
});
const TiptapIframe = TiptapNode.create({
name: "htmlEmbed",
group: "block",
atom: true,
addAttributes() {
return {
src: { default: null },
title: { default: null }
};
},
parseHTML() {
return [{ tag: "iframe[src]" }];
},
renderHTML({ HTMLAttributes }) {
return ["iframe", mergeAttributes(HTMLAttributes, { loading: "lazy" })];
}
});
const CommentHighlight = Mark.create({
name: "commentHighlight",
addAttributes() {
return {
commentId: {
default: null,
parseHTML: (element) => element.getAttribute("data-comment-id"),
renderHTML: (attributes) => (attributes.commentId ? { "data-comment-id": attributes.commentId } : {})
}
};
},
parseHTML() {
return [{ tag: "span[data-comment-id]" }];
},
renderHTML({ HTMLAttributes }) {
return ["span", mergeAttributes(HTMLAttributes, { class: "comment-highlight" }), 0];
}
});
function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value));
}
export function MarkdownEditor({
id,
data,
boardId,
onBindSave
}: {
id: string;
data: CanvasNodeData;
boardId: string;
onBindSave: (save?: () => Promise<void>) => void;
}) {
const [status, setStatus] = useState("");
const [comments, setComments] = useState<CanvasComment[]>([]);
const [commentDraft, setCommentDraft] = useState("");
const [commentOpen, setCommentOpen] = useState(false);
const [selectionBubble, setSelectionBubble] = useState<{
from: number;
to: number;
quote: string;
left: number;
top: number;
} | null>(null);
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const editorWrapRef = useRef<HTMLDivElement | null>(null);
const turndown = useMemo(() => {
const service = new TurndownService({
headingStyle: "atx",
codeBlockStyle: "fenced"
});
service.addRule("commentHighlight", {
filter: (node) => node.nodeName === "SPAN" && (node as HTMLElement).hasAttribute("data-comment-id"),
replacement: (content) => content
});
service.addRule("video", {
filter: "video",
replacement: (_content, node) => `\n\n${(node as HTMLElement).outerHTML}\n\n`
});
service.addRule("iframe", {
filter: "iframe",
replacement: (_content, node) => `\n\n${(node as HTMLElement).outerHTML}\n\n`
});
return service;
}, []);
const editor = useEditor({
extensions: [StarterKit, Image, TiptapVideo, TiptapIframe, CommentHighlight],
content: "",
immediatelyRender: false,
onUpdate: ({ editor }) => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
setStatus("Unsaved changes");
saveTimerRef.current = setTimeout(async () => {
const markdown = turndown.turndown(editor.getHTML());
setStatus("Saving");
await writeText(data.path, markdown);
data.onUpdate?.(id, { content: markdown });
setStatus("Saved");
}, 900);
},
editorProps: {
attributes: {
class: "tiptap-body nowheel nodrag nopan"
}
}
});
const refreshComments = useCallback(() => {
fetchComments(boardId, id)
.then(setComments)
.catch((error) => setStatus(error.message));
}, [boardId, id]);
useEffect(() => {
refreshComments();
}, [refreshComments]);
const updateSelectionBubble = useCallback(() => {
if (!editor) return null;
const { from, to } = editor.state.selection;
const quote = from === to ? "" : editor.state.doc.textBetween(from, to, "\n").trim();
const host = editorWrapRef.current?.getBoundingClientRect();
if (!quote || !host) {
setSelectionBubble(null);
setCommentOpen(false);
setCommentDraft("");
return null;
}
const start = editor.view.coordsAtPos(from);
const end = editor.view.coordsAtPos(to);
const left = clamp((start.left + end.right) / 2 - host.left, 92, Math.max(92, host.width - 92));
const top = Math.max(8, Math.min(start.top, end.top) - host.top - 10);
const bubble = { from, to, quote, left, top };
setSelectionBubble(bubble);
setCommentOpen(false);
setCommentDraft("");
return bubble;
}, [editor]);
useEffect(() => {
let cancelled = false;
setStatus("");
readText(data.path)
.then((markdown) => {
if (cancelled) return;
editor?.commands.setContent(markdownToHtml(markdown), { emitUpdate: false });
data.onUpdate?.(id, { content: markdown });
})
.catch((error) => setStatus(error.message));
return () => {
cancelled = true;
};
}, [data.path, editor, id]);
const saveSource = useCallback(async () => {
if (!editor) return;
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
const markdown = turndown.turndown(editor.getHTML());
setStatus("Saving");
await writeText(data.path, markdown);
data.onUpdate?.(id, { content: markdown });
setStatus("Saved");
}, [data, editor, id, turndown]);
useEffect(() => {
onBindSave(saveSource);
return () => onBindSave(undefined);
}, [onBindSave, saveSource]);
useEffect(() => {
return () => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
};
}, []);
const handleAssetDragOver = useCallback((event: React.DragEvent) => {
const files = Array.from(event.dataTransfer.files).filter((file) => ["image", "video", "text/html", "text/markdown"].some((type) => file.type.startsWith(type)) || isSupportedFile(file));
if (!files.length) return;
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = "copy";
}, []);
const handleAssetDrop = useCallback(
async (event: React.DragEvent) => {
const files = Array.from(event.dataTransfer.files).filter(isSupportedFile);
if (!editor || !files.length) return;
event.preventDefault();
event.stopPropagation();
setStatus("Inserting files");
try {
const uploaded = await uploadCanvasFiles(files);
const embeddable = uploaded.filter((file) => file.sourceType === "image" || file.sourceType === "video" || file.sourceType === "html");
if (!embeddable.length) {
setStatus("This file can be added to the canvas, but not embedded in the editor yet.");
return;
}
editor.chain().focus().insertContent(embeddable.map(htmlForMarkdownDrop).join("")).run();
setStatus("Inserted");
} catch (error) {
setStatus((error as Error).message);
}
},
[editor]
);
const openCommentComposer = useCallback(() => {
const bubble = selectionBubble ?? updateSelectionBubble();
if (!bubble) {
setStatus("Select text before adding a comment");
return;
}
setSelectionBubble(bubble);
setCommentOpen(true);
}, [selectionBubble, updateSelectionBubble]);
const submitComment = useCallback(async () => {
const bubble = selectionBubble ?? updateSelectionBubble();
if (!bubble) {
setStatus("Select text before adding a comment");
return;
}
const text = commentDraft.trim();
if (!text) {
setStatus("Write a comment first");
return;
}
setStatus("Adding comment");
try {
const comment = await createNodeComment({
boardId,
nodeId: id,
path: data.path,
title: data.title,
quote: bubble.quote,
comment: text
});
editor
?.chain()
.focus()
.setTextSelection({ from: bubble.from, to: bubble.to })
.setMark("commentHighlight", { commentId: comment.id })
.setTextSelection(bubble.to)
.run();
setComments((items) => items.concat(comment));
setCommentDraft("");
setSelectionBubble(null);
setCommentOpen(false);
setStatus("Comment added");
} catch (error) {
setStatus((error as Error).message);
}
}, [boardId, commentDraft, data.path, data.title, editor, id, selectionBubble, updateSelectionBubble]);
const resolveComment = useCallback(
async (commentId: string) => {
setStatus("Resolving comment");
try {
await resolveNodeComment(commentId);
const markType = editor?.schema.marks.commentHighlight;
if (editor && markType) {
const transaction = editor.state.tr;
editor.state.doc.descendants((node, position) => {
if (!node.isText) return;
const hasCommentMark = node.marks.some((mark) => mark.type === markType && mark.attrs.commentId === commentId);
if (hasCommentMark) transaction.removeMark(position, position + node.nodeSize, markType);
});
editor.view.dispatch(transaction);
}
setComments((items) => items.filter((comment) => comment.id !== commentId));
setStatus("Comment resolved");
} catch (error) {
setStatus((error as Error).message);
}
},
[editor]
);
return (
<>
<div className="markdown-editor-wrap" ref={editorWrapRef}>
{selectionBubble ? (
<div className={commentOpen ? "selection-comment-popover is-open" : "selection-comment-popover"} style={{ left: selectionBubble.left, top: selectionBubble.top }}>
{commentOpen ? (
<form
className="comment-composer"
onSubmit={(event) => {
event.preventDefault();
void submitComment();
}}
>
<textarea autoFocus value={commentDraft} onChange={(event) => setCommentDraft(event.target.value)} placeholder="Add a comment" />
<div>
<button
type="button"
onClick={() => {
setCommentOpen(false);
setCommentDraft("");
}}
>
Cancel
</button>
<button type="submit">Add</button>
</div>
</form>
) : (
<button type="button" className="selection-comment-trigger" onMouseDown={(event) => event.preventDefault()} onClick={openCommentComposer}>
<MessageCircle size={14} />
<span>Add Comment</span>
</button>
)}
</div>
) : null}
<EditorContent
editor={editor}
className="markdown-editor"
onDragOver={handleAssetDragOver}
onDrop={handleAssetDrop}
onMouseUp={updateSelectionBubble}
onKeyUp={updateSelectionBubble}
/>
</div>
{comments.length ? (
<section className="comments-panel">
{comments.map((comment) => (
<article key={comment.id} className="comment-card">
<p>{comment.comment}</p>
<button onClick={() => void resolveComment(comment.id)} title="Resolve comment">
<Check size={13} />
<span>Resolve</span>
</button>
</article>
))}
</section>
) : null}
{status ? <span className="node-status">{status}</span> : null}
</>
);
}
"use client";
import { useEffect, useState } from "react";
import { readText } from "../fileApi";
import { markdownToHtml } from "../html";
import type { CanvasNodeData } from "../types";
export function MarkdownPreview({ id, data }: { id: string; data: CanvasNodeData }) {
const [markdown, setMarkdown] = useState(data.content ?? "");
const [status, setStatus] = useState("");
useEffect(() => {
let cancelled = false;
if (data.content) {
setMarkdown(data.content);
return () => {
cancelled = true;
};
}
readText(data.path)
.then((content) => {
if (cancelled) return;
setMarkdown(content);
data.onUpdate?.(id, { content });
})
.catch((error) => setStatus(error.message));
return () => {
cancelled = true;
};
}, [data.content, data.onUpdate, data.path, id]);
return (
<div className="markdown-preview nowheel">
{markdown ? <div className="markdown-body" dangerouslySetInnerHTML={{ __html: markdownToHtml(markdown) }} /> : <span>{status || "Reading"}</span>}
</div>
);
}
"use client";
import React from "react";
import type { SelectionActions } from "../types";
export const SelectionActionsContext = React.createContext<SelectionActions | null>(null);