Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25767838fa | ||
|
|
493a20054b | ||
|
|
aa29ddbf13 | ||
|
|
9114e81f02 |
@@ -15,6 +15,9 @@ public/dist/
|
||||
.cursor/
|
||||
.claude/
|
||||
|
||||
# User preferences (only the example ships)
|
||||
skills/excalidraw-skill/preferences.json
|
||||
|
||||
# Development artifacts
|
||||
*.excalidraw
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@sanjibdevnath/mcp-excalidraw-local",
|
||||
"version": "1.3.0",
|
||||
"version": "1.5.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@sanjibdevnath/mcp-excalidraw-local",
|
||||
"version": "1.3.0",
|
||||
"version": "1.5.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@sanjibdevnath/mcp-excalidraw-local",
|
||||
"version": "1.3.0",
|
||||
"version": "1.5.0",
|
||||
"description": "Fully local MCP server for Excalidraw with SQLite persistence, multi-tenancy, auto-sync, real-time canvas, and 32 tools",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
|
||||
@@ -15,6 +15,79 @@ Run these checks **in order**:
|
||||
|
||||
See `references/cheatsheet.md` for the full MCP-vs-REST mapping and REST API gotchas.
|
||||
|
||||
## Step 1: Load User Preferences
|
||||
|
||||
Before creating any elements, load the user's diagram preferences. These control default font, roughness, stroke width, etc.
|
||||
|
||||
### Preference Resolution Order (most specific wins)
|
||||
|
||||
| Priority | Scope | Location | Persists |
|
||||
|----------|-------|----------|----------|
|
||||
| 1 (highest) | Session | In-memory (set via prompt during this conversation) | No — current session only |
|
||||
| 2 | Folder | `.claude/excalidraw-preferences.json` in the current project root | Yes — per-project |
|
||||
| 3 | Global | `~/.claude/skills/excalidraw-skill/preferences.json` | Yes — all projects |
|
||||
| 4 (lowest) | Hardcoded | Server defaults (fontFamily: 1, roughness: 0, fontSize: 20, strokeWidth: 2) | — |
|
||||
|
||||
### How to Load
|
||||
|
||||
1. **Check folder-level first**: Read `.claude/excalidraw-preferences.json` from the current working directory (or project root). If it exists and has `defaults`, use those values.
|
||||
2. **Fall back to global**: Read `~/.claude/skills/excalidraw-skill/preferences.json`. If it exists and has `defaults`, use those values.
|
||||
3. **If neither exists** → run the **First-Time Setup** prompt below.
|
||||
4. **Merge**: Folder preferences override global; global overrides hardcoded. Only override fields that are explicitly set.
|
||||
|
||||
### First-Time Setup (Interactive)
|
||||
|
||||
If no preferences file exists at either location, **prompt the user before drawing anything**:
|
||||
|
||||
> **Excalidraw Preferences Setup**
|
||||
>
|
||||
> I don't have any saved diagram preferences yet. Let me set up your defaults so every diagram looks the way you want.
|
||||
|
||||
Ask these questions (use `AskUserQuestion` tool if available, otherwise ask inline):
|
||||
|
||||
1. **Font family** — Which font for all text?
|
||||
- Excalifont (hand-drawn) = 1
|
||||
- Helvetica (sans-serif) = 2
|
||||
- Cascadia (monospace) = 3
|
||||
- Comic Shanns = 4
|
||||
- Nunito = 6
|
||||
- Lilita One = 7
|
||||
|
||||
2. **Roughness** — Diagram style?
|
||||
- Clean/professional (roughness: 0) — recommended
|
||||
- Hand-drawn sketch (roughness: 1)
|
||||
- Very rough (roughness: 2)
|
||||
|
||||
3. **Scope** — Where to save?
|
||||
- **This session only** — don't save to disk, just use for this conversation
|
||||
- **This project** — save to `.claude/excalidraw-preferences.json` in project root
|
||||
- **Global (all projects)** — save to `~/.claude/skills/excalidraw-skill/preferences.json`
|
||||
|
||||
Then save the preferences JSON to the chosen location:
|
||||
|
||||
```json
|
||||
{
|
||||
"defaults": {
|
||||
"fontFamily": <user_choice>,
|
||||
"fontSize": 20,
|
||||
"roughness": <user_choice>,
|
||||
"strokeWidth": 2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For session-only scope, just hold the values in memory and apply them to every element in this conversation.
|
||||
|
||||
### Applying Preferences
|
||||
|
||||
Once loaded, apply `defaults` to **every element** that supports the property:
|
||||
- `fontFamily` → all text-containing elements (text, rectangles with labels, diamonds, ellipses, arrows with labels)
|
||||
- `fontSize` → text elements and labels (unless the element explicitly overrides it)
|
||||
- `roughness` → all elements
|
||||
- `strokeWidth` → arrows and lines
|
||||
|
||||
User-specified values in individual element calls always override preferences.
|
||||
|
||||
## Core Principles (Read Before Any Diagram)
|
||||
|
||||
These principles were learned through extensive iterative use. Violating them produces bad diagrams.
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"_comment": "Excalidraw MCP user preferences. Copy to preferences.json to activate.",
|
||||
"_fontReference": {
|
||||
"1": "Excalifont (hand-drawn)",
|
||||
"2": "Helvetica (sans-serif)",
|
||||
"3": "Cascadia (monospace)",
|
||||
"4": "Comic Shanns",
|
||||
"5": "Liberation Sans",
|
||||
"6": "Nunito",
|
||||
"7": "Lilita One"
|
||||
},
|
||||
"defaults": {
|
||||
"fontFamily": 1,
|
||||
"fontSize": 20,
|
||||
"roughness": 0,
|
||||
"strokeWidth": 2
|
||||
}
|
||||
}
|
||||
@@ -94,16 +94,15 @@
|
||||
|---------|----------------|-----------------|
|
||||
| Shape labels | `"text": "My Label"` (auto-converts) | `"label": {"text": "My Label"}` |
|
||||
| Arrow binding | `"startElementId": "id"` / `"endElementId": "id"` | `"start": {"id": "id"}` / `"end": {"id": "id"}` |
|
||||
| `fontFamily` | String `"1"` or omit | String `"1"` or omit (never a number) |
|
||||
| `fontFamily` | Number or string — use value from user preferences (see Step 1 in SKILL.md) | String — use value from user preferences |
|
||||
| Tenant scoping | Auto (uses active tenant) | Include `X-Tenant-Id` header on every request |
|
||||
|
||||
### Element Creation Best Practices
|
||||
|
||||
- **Always set `roughness: 0`** for clean, professional diagrams (default is hand-drawn).
|
||||
- **Always set `strokeWidth: 2`** on arrows for visibility.
|
||||
- **Always apply user preferences** — load from Step 1 in SKILL.md and apply `fontFamily`, `roughness`, `fontSize`, `strokeWidth` to every element.
|
||||
- **Create shapes first, arrows second** (two separate `batch_create_elements` calls).
|
||||
- **Assign custom `id`** to every shape so arrows can reference it.
|
||||
- **Size shapes for their text** — Virgil font is ~30% wider than standard. Use sizing formulas from SKILL.md.
|
||||
- **Size shapes for their text** — use sizing formulas from SKILL.md.
|
||||
- `points` accepts both `[[x,y]]` tuples and `[{x,y}]` objects — normalized automatically.
|
||||
- **Curved arrows**: Use `"roundness": {"type": 2}` with 3+ points. **Elbowed arrows**: Use `"elbowed": true`.
|
||||
|
||||
|
||||
+45
-4
@@ -65,6 +65,47 @@ const CANVAS_PORT = process.env.CANVAS_PORT || process.env.PORT || '3000';
|
||||
const EXPRESS_SERVER_URL = process.env.EXPRESS_SERVER_URL || `http://localhost:${CANVAS_PORT}`;
|
||||
const ENABLE_CANVAS_SYNC = true;
|
||||
|
||||
// User preferences for element defaults (font, roughness, etc.)
|
||||
// Resolution: folder-level .claude/excalidraw-preferences.json > global ~/.claude/skills/excalidraw-skill/preferences.json > hardcoded
|
||||
interface ExcalidrawPreferences {
|
||||
fontFamily: number;
|
||||
fontSize: number;
|
||||
roughness: number;
|
||||
strokeWidth: number;
|
||||
}
|
||||
|
||||
const HARDCODED_DEFAULTS: ExcalidrawPreferences = {
|
||||
fontFamily: 1,
|
||||
fontSize: 20,
|
||||
roughness: 0,
|
||||
strokeWidth: 2,
|
||||
};
|
||||
|
||||
function loadPreferences(): ExcalidrawPreferences {
|
||||
const locations = [
|
||||
path.join(process.cwd(), '.claude', 'excalidraw-preferences.json'),
|
||||
path.join(process.env.HOME || '~', '.claude', 'skills', 'excalidraw-skill', 'preferences.json'),
|
||||
];
|
||||
|
||||
for (const loc of locations) {
|
||||
try {
|
||||
if (fs.existsSync(loc)) {
|
||||
const raw = JSON.parse(fs.readFileSync(loc, 'utf-8'));
|
||||
if (raw?.defaults) {
|
||||
logger.info(`Loaded user preferences from ${loc}`);
|
||||
return { ...HARDCODED_DEFAULTS, ...raw.defaults };
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn(`Failed to read preferences from ${loc}: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
return HARDCODED_DEFAULTS;
|
||||
}
|
||||
|
||||
const USER_PREFS = loadPreferences();
|
||||
|
||||
// One-time tokens for clear_canvas confirmation (token → expiry timestamp)
|
||||
const pendingClearTokens = new Map<string, { expiresAt: number; elementCount: number }>();
|
||||
const CLEAR_TOKEN_TTL_MS = 120_000; // 2 minutes
|
||||
@@ -2199,8 +2240,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
if (el.type === 'text') {
|
||||
base.text = text ?? '';
|
||||
base.originalText = text ?? '';
|
||||
base.fontSize = rest.fontSize ?? 20;
|
||||
base.fontFamily = rest.fontFamily ?? 1;
|
||||
base.fontSize = rest.fontSize ?? USER_PREFS.fontSize;
|
||||
base.fontFamily = rest.fontFamily ?? USER_PREFS.fontFamily;
|
||||
base.textAlign = rest.textAlign ?? 'center';
|
||||
base.verticalAlign = rest.verticalAlign ?? 'middle';
|
||||
base.autoResize = rest.autoResize ?? true;
|
||||
@@ -2294,8 +2335,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
|
||||
locked: false,
|
||||
text: labelText,
|
||||
originalText: labelText,
|
||||
fontSize: isArrow ? 14 : (rest.fontSize ?? 16),
|
||||
fontFamily: rest.fontFamily ?? 1,
|
||||
fontSize: isArrow ? 14 : (rest.fontSize ?? USER_PREFS.fontSize),
|
||||
fontFamily: rest.fontFamily ?? USER_PREFS.fontFamily,
|
||||
textAlign: 'center',
|
||||
verticalAlign: 'middle',
|
||||
autoResize: true,
|
||||
|
||||
+103
-6
@@ -118,7 +118,7 @@ function detectInstalledAgents(): AgentDef[] {
|
||||
// ── Phase 1: Environment Check ──────────────────────────────
|
||||
|
||||
async function phaseEnvironment(rl: readline.Interface): Promise<boolean> {
|
||||
heading('1/3', 'Environment');
|
||||
heading('1/4', 'Environment');
|
||||
let allOk = true;
|
||||
|
||||
// Node.js version
|
||||
@@ -182,10 +182,103 @@ async function phaseEnvironment(rl: readline.Interface): Promise<boolean> {
|
||||
return allOk;
|
||||
}
|
||||
|
||||
// ── Preference Setup ─────────────────────────────────────────
|
||||
|
||||
const FONT_OPTIONS: { value: number; label: string }[] = [
|
||||
{ value: 1, label: 'Excalifont (hand-drawn)' },
|
||||
{ value: 2, label: 'Helvetica (sans-serif)' },
|
||||
{ value: 3, label: 'Cascadia (monospace)' },
|
||||
{ value: 4, label: 'Comic Shanns' },
|
||||
{ value: 6, label: 'Nunito' },
|
||||
{ value: 7, label: 'Lilita One' },
|
||||
];
|
||||
|
||||
const ROUGHNESS_OPTIONS: { value: number; label: string }[] = [
|
||||
{ value: 0, label: 'Clean / professional' },
|
||||
{ value: 1, label: 'Hand-drawn sketch' },
|
||||
{ value: 2, label: 'Very rough' },
|
||||
];
|
||||
|
||||
function getGlobalPreferencesPath(): string {
|
||||
return path.join(os.homedir(), '.claude', 'skills', 'excalidraw-skill', 'preferences.json');
|
||||
}
|
||||
|
||||
function globalPreferencesExist(): boolean {
|
||||
return fs.existsSync(getGlobalPreferencesPath());
|
||||
}
|
||||
|
||||
function writePreferencesFile(filePath: string, prefs: { fontFamily: number; fontSize: number; roughness: number; strokeWidth: number }): void {
|
||||
const dir = path.dirname(filePath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
const content = {
|
||||
defaults: prefs,
|
||||
};
|
||||
fs.writeFileSync(filePath, JSON.stringify(content, null, 2) + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
async function phasePreferences(rl: readline.Interface, phaseLabel: string): Promise<void> {
|
||||
heading(phaseLabel, 'Diagram Preferences');
|
||||
|
||||
const prefsPath = getGlobalPreferencesPath();
|
||||
|
||||
if (globalPreferencesExist()) {
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(prefsPath, 'utf-8'));
|
||||
const d = raw?.defaults;
|
||||
if (d) {
|
||||
const fontLabel = FONT_OPTIONS.find(f => f.value === d.fontFamily)?.label ?? `font ${d.fontFamily}`;
|
||||
const roughLabel = ROUGHNESS_OPTIONS.find(r => r.value === d.roughness)?.label ?? `roughness ${d.roughness}`;
|
||||
ok(`Current: ${fontLabel}, ${roughLabel}, fontSize ${d.fontSize}, strokeWidth ${d.strokeWidth}`);
|
||||
const change = await confirm(rl, 'Change preferences?', false);
|
||||
if (!change) return;
|
||||
}
|
||||
} catch {
|
||||
warn(`Could not read ${prefsPath}, will reconfigure.`);
|
||||
}
|
||||
}
|
||||
|
||||
info('These defaults apply to every diagram (font, style, etc.).');
|
||||
info('');
|
||||
|
||||
// Font
|
||||
process.stdout.write('\n Font family:\n');
|
||||
FONT_OPTIONS.forEach((f, i) => {
|
||||
const marker = f.value === 1 ? ' (default)' : '';
|
||||
process.stdout.write(` ${CYAN}[${i + 1}]${RESET} ${f.label}${marker}\n`);
|
||||
});
|
||||
const fontAnswer = (await ask(rl, 'Choose [1]: ')).trim();
|
||||
const fontIdx = fontAnswer === '' ? 0 : parseInt(fontAnswer, 10) - 1;
|
||||
const fontFamily = (fontIdx >= 0 && fontIdx < FONT_OPTIONS.length) ? FONT_OPTIONS[fontIdx]!.value : 1;
|
||||
|
||||
// Roughness
|
||||
process.stdout.write('\n Diagram style:\n');
|
||||
ROUGHNESS_OPTIONS.forEach((r, i) => {
|
||||
const marker = r.value === 0 ? ' (default)' : '';
|
||||
process.stdout.write(` ${CYAN}[${i + 1}]${RESET} ${r.label}${marker}\n`);
|
||||
});
|
||||
const roughAnswer = (await ask(rl, 'Choose [1]: ')).trim();
|
||||
const roughIdx = roughAnswer === '' ? 0 : parseInt(roughAnswer, 10) - 1;
|
||||
const roughness = (roughIdx >= 0 && roughIdx < ROUGHNESS_OPTIONS.length) ? ROUGHNESS_OPTIONS[roughIdx]!.value : 0;
|
||||
|
||||
const prefs = { fontFamily, fontSize: 20, roughness, strokeWidth: 2 };
|
||||
|
||||
try {
|
||||
writePreferencesFile(prefsPath, prefs);
|
||||
const fontLabel = FONT_OPTIONS.find(f => f.value === fontFamily)?.label ?? `${fontFamily}`;
|
||||
const roughLabel = ROUGHNESS_OPTIONS.find(r => r.value === roughness)?.label ?? `${roughness}`;
|
||||
ok(`Saved: ${fontLabel}, ${roughLabel}`);
|
||||
ok(`File: ${prefsPath}`);
|
||||
} catch (err) {
|
||||
fail(`Failed to save preferences: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 2: Skill Installation ─────────────────────────────
|
||||
|
||||
async function phaseSkillInstall(rl: readline.Interface): Promise<void> {
|
||||
heading('2/3', 'Agent Skill');
|
||||
heading('2/4', 'Agent Skill');
|
||||
|
||||
const wantSkill = await confirm(rl, 'Install the Excalidraw agent skill?');
|
||||
if (!wantSkill) {
|
||||
@@ -347,7 +440,7 @@ function writeInstructionDirective(filePath: string, format: 'claude-md' | 'curs
|
||||
// ── Phase 3: MCP Configuration ──────────────────────────────
|
||||
|
||||
async function phaseMcpConfig(rl: readline.Interface): Promise<void> {
|
||||
heading('3/3', 'MCP Configuration');
|
||||
heading('4/4', 'MCP Configuration');
|
||||
|
||||
const wantConfig = await confirm(rl, 'Add MCP server to agent configs automatically?');
|
||||
if (!wantConfig) {
|
||||
@@ -519,7 +612,7 @@ export async function runUpdate(): Promise<void> {
|
||||
|
||||
try {
|
||||
// ── Phase 1: Detect existing skill installations ──────────
|
||||
heading('1/2', 'Skill Update');
|
||||
heading('1/3', 'Skill Update');
|
||||
|
||||
const allInstalls = findExistingSkillInstalls();
|
||||
const existing = allInstalls.filter(i => i.exists);
|
||||
@@ -612,8 +705,11 @@ export async function runUpdate(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 2: MCP config check ────────────────────────────
|
||||
heading('2/2', 'MCP Configuration');
|
||||
// ── Phase 2: Preferences ─────────────────────────────────
|
||||
await phasePreferences(rl, '2/3');
|
||||
|
||||
// ── Phase 3: MCP config check ────────────────────────────
|
||||
heading('3/3', 'MCP Configuration');
|
||||
|
||||
for (const agent of detectedAgents) {
|
||||
if (agent.mcpConfigType === 'json-file' && agent.mcpConfigPath) {
|
||||
@@ -685,6 +781,7 @@ export async function runSetup(): Promise<void> {
|
||||
try {
|
||||
await phaseEnvironment(rl);
|
||||
await phaseSkillInstall(rl);
|
||||
await phasePreferences(rl, '3/4');
|
||||
await phaseMcpConfig(rl);
|
||||
|
||||
process.stdout.write(`\n ${GREEN}${BOLD}Done!${RESET} Open ${CYAN}http://localhost:3000${RESET} to verify the canvas.\n\n`);
|
||||
|
||||
Reference in New Issue
Block a user