drafted 1.14.25 → 1.14.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/mcp/server.mjs +63 -24
- package/package.json +1 -1
- package/src/shared/excalidraw.mjs +25 -0
- package/src/shared/test-excalidraw-merge.mjs +40 -0
package/mcp/server.mjs
CHANGED
|
@@ -140,7 +140,7 @@ const LOCAL_ONLY_PARAMS = ['file_path'];
|
|
|
140
140
|
// tool wrapper before the handler runs — so the stdio contract, the Zod runtime
|
|
141
141
|
// validation, and every handler stay byte-for-byte unchanged.
|
|
142
142
|
const REMOTE_JSON_STRING_PARAMS = {
|
|
143
|
-
frame: ['excalidraw_data', 'requests', 'state'],
|
|
143
|
+
frame: ['excalidraw_data', 'elements', 'requests', 'state'],
|
|
144
144
|
wiki: ['frontmatter', 'pages'],
|
|
145
145
|
project: ['layers'],
|
|
146
146
|
template: ['layers'],
|
|
@@ -1131,6 +1131,39 @@ function shapeSkillCatalog(result, { limit, offset = 0, compact = false } = {})
|
|
|
1131
1131
|
return result;
|
|
1132
1132
|
}
|
|
1133
1133
|
|
|
1134
|
+
// Shape the template `list` array (a bare array from /api/templates) so it stays
|
|
1135
|
+
// within an agent's token budget: full layer bodies (prose descriptions across
|
|
1136
|
+
// every built-in + org + public template) blow past 90KB. compact (default)
|
|
1137
|
+
// returns identity + a layerCount; compact=false returns full bodies, paged.
|
|
1138
|
+
function compactTemplateEntry(t) {
|
|
1139
|
+
if (!t || typeof t !== 'object') return t;
|
|
1140
|
+
return {
|
|
1141
|
+
id: t.id,
|
|
1142
|
+
slug: t.slug,
|
|
1143
|
+
name: t.name,
|
|
1144
|
+
description: t.description,
|
|
1145
|
+
layerCount: Array.isArray(t.layers) ? t.layers.length : undefined,
|
|
1146
|
+
skillSlugs: t.skillSlugs,
|
|
1147
|
+
};
|
|
1148
|
+
}
|
|
1149
|
+
function shapeTemplateCatalog(tpls, { limit, offset = 0, compact = true } = {}) {
|
|
1150
|
+
if (!Array.isArray(tpls)) return tpls;
|
|
1151
|
+
const total = tpls.length;
|
|
1152
|
+
const start = Math.max(0, Math.floor(Number(offset) || 0));
|
|
1153
|
+
const cap = Math.min(Math.max(1, Math.floor(Number(limit) || 25)), 100);
|
|
1154
|
+
const page = tpls.slice(start, start + cap);
|
|
1155
|
+
return {
|
|
1156
|
+
templates: page.map(compact ? compactTemplateEntry : (t) => t),
|
|
1157
|
+
totalAvailable: total,
|
|
1158
|
+
offset: start,
|
|
1159
|
+
returned: page.length,
|
|
1160
|
+
truncated: start + page.length < total,
|
|
1161
|
+
note: compact
|
|
1162
|
+
? 'Compact catalog: {id,slug,name,description,layerCount,skillSlugs}. Pass compact=false for full layer definitions; use limit/offset to page.'
|
|
1163
|
+
: 'Full template bodies. Use limit/offset to page; pass compact=true for a leaner catalog.',
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1134
1167
|
// Semantic frame URL (/o/<org>/<project>/<layer>/<lane>/<label>) from a row that
|
|
1135
1168
|
// carries slugs (e.g. /api/search hits). Returns null when a slug is missing so the
|
|
1136
1169
|
// caller can fall back to the /f/<uuid> resolver.
|
|
@@ -2202,6 +2235,9 @@ tool('template', 'Manage project templates in an org. Dispatch by `action`: list
|
|
|
2202
2235
|
layers: z.array(z.object({}).passthrough()).optional().describe('[create|update] array of layer definitions'),
|
|
2203
2236
|
skillSlugs: z.array(z.string()).optional().describe('[create|update] skill slugs to auto-attach to projects created from this template. Slugs are resolved against the org\'s skills (org-local first, built-in fallback) at project-create time; missing slugs are silently skipped. Pass an empty array on update to clear.'),
|
|
2204
2237
|
visibility: z.string().optional().describe('[create|update] "org" or "public"'),
|
|
2238
|
+
compact: z.boolean().optional().describe('[list] default true — return {id,slug,name,description,layerCount,skillSlugs} only. Pass false for full layer definitions.'),
|
|
2239
|
+
limit: z.number().optional().describe('[list] max templates to return (default 25, max 100).'),
|
|
2240
|
+
offset: z.number().optional().describe('[list] pagination offset.'),
|
|
2205
2241
|
org: z.string().optional().describe('Org (id or name) to scope this call to — defaults to the open project\'s org. Per-request only.'),
|
|
2206
2242
|
}, async (args) => {
|
|
2207
2243
|
try {
|
|
@@ -2211,7 +2247,7 @@ tool('template', 'Manage project templates in an org. Dispatch by `action`: list
|
|
|
2211
2247
|
case 'list': {
|
|
2212
2248
|
const tpls = await api('GET', '/api/templates', undefined, orgHeader);
|
|
2213
2249
|
markSearched(getSessionState().gates, 'template');
|
|
2214
|
-
return ok(tpls);
|
|
2250
|
+
return ok(shapeTemplateCatalog(tpls, { limit: args.limit, offset: args.offset, compact: args.compact !== false }));
|
|
2215
2251
|
}
|
|
2216
2252
|
case 'create': {
|
|
2217
2253
|
const { name, description, layers, skillSlugs, visibility } = args;
|
|
@@ -2766,13 +2802,16 @@ tool('get_org', {
|
|
|
2766
2802
|
|
|
2767
2803
|
// ── Filesystem tools (direct HTTP to /api/fs) ─────────────────────
|
|
2768
2804
|
|
|
2769
|
-
tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by path, frame URL, or UUID), write (new frame or overwrite), set_state / get_state (persist or read a deployed windowType:"app" frame\'s hydration state — push e.g. {specText} to drive a generic app frame with data after deploy; the canvas hydrates the app from it on load), Google Sheet actions (`get_sheet`, `read_sheet_values`, `write_sheet_values`, `append_sheet_rows`, `clear_sheet_range`, `update_sheet`), Google Doc actions (`get_doc`, `read_doc_content`, `write_doc_content`, `append_doc_content`, `clear_doc_content`, `update_doc`), Google Slide actions (`get_slide`, `read_slide_content`, `write_slide_content`, `append_slides`, `clear_slides`, `update_slide`), write_excalidraw (native editable Excalidraw diagram), edit (hashline ops), mv (rename/move), anchor (mark as required-read for the layer), search (match frame names). Use project(action="open") first. For listing use `ls`, for deletion use `rm`.\n\n**Google Workspace native content:** Create or attach Google Docs/Sheets/Slides with `frame(action="write", googleType=...)`. After creating, immediately populate the native file using the matching write action in the same tool — do NOT leave it empty and do NOT tell the user you cannot write to it. For Sheets: `write_sheet_values` or `append_sheet_rows` (pass `path` or `googleId` from the create response). For Docs: `write_doc_content`/`append_doc_content`. For Slides: `write_slide_content`/`append_slides`. Read with `read_sheet_values`/`read_doc_content`/`read_slide_content`. Do NOT use inline `frame.write(content)` or hashline `frame.edit` to populate Google Workspace frames.\n\n**Write — content, binary, or Google Workspace frame:** ' + (isRemote ? 'Provide exactly one of `content` (HTML/markdown/text), `base64` (base64-encoded binary with optional `content_type`), or `googleType` (`google-doc`, `google-sheet`, `google-slide`).' : 'Provide exactly one of `content` (HTML/markdown/text), `file_path` (absolute local file), `base64` (base64-encoded binary with optional `content_type`), or `googleType` (`google-doc`, `google-sheet`, `google-slide`).') + ' Call get_org first; when `googleDrive.connected` is true, strongly prefer Google Workspace frames for docs, sheets, and slides in that org. For inline content, filename extension matters: use `.html` for complete HTML documents and `.md` for Markdown. Never place a full HTML document in a `.md` or extensionless frame. For a new Google file, pass `googleType` and optional `title`; for an existing Google file, pass `googleType` plus `url` or `googleId`. ' + (isRemote ? 'For binary frames (images, PDFs, videos), pass `base64` with the binary bytes.' : 'For binary frames (images, PDFs, videos), use `file_path` when the file is local to the MCP host, or `base64` when the caller already has binary bytes.') + '\n\n**Write — dimensions:** By default, frames use the layer\'s default size (e.g. 1440×900 for designs, 1440×3000 for wireframes). Often too large for small content. Use `autoSize: true` to measure HTML content and size to fit, or pass explicit `width`/`height`.', {
|
|
2805
|
+
tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by path, frame URL, or UUID), write (new frame or overwrite), set_state / get_state (persist or read a deployed windowType:"app" frame\'s hydration state — push e.g. {specText} to drive a generic app frame with data after deploy; the canvas hydrates the app from it on load), Google Sheet actions (`get_sheet`, `read_sheet_values`, `write_sheet_values`, `append_sheet_rows`, `clear_sheet_range`, `update_sheet`), Google Doc actions (`get_doc`, `read_doc_content`, `write_doc_content`, `append_doc_content`, `clear_doc_content`, `update_doc`), Google Slide actions (`get_slide`, `read_slide_content`, `write_slide_content`, `append_slides`, `clear_slides`, `update_slide`), write_excalidraw (native editable Excalidraw diagram — full-scene replace), edit_excalidraw (element-level upsert/remove by id, for growing or surgically editing a large scene without resending it), edit (hashline ops), mv (rename/move), anchor (mark as required-read for the layer), search (match frame names). Use project(action="open") first. For listing use `ls`, for deletion use `rm`.\n\n**Google Workspace native content:** Create or attach Google Docs/Sheets/Slides with `frame(action="write", googleType=...)`. After creating, immediately populate the native file using the matching write action in the same tool — do NOT leave it empty and do NOT tell the user you cannot write to it. For Sheets: `write_sheet_values` or `append_sheet_rows` (pass `path` or `googleId` from the create response). For Docs: `write_doc_content`/`append_doc_content`. For Slides: `write_slide_content`/`append_slides`. Read with `read_sheet_values`/`read_doc_content`/`read_slide_content`. Do NOT use inline `frame.write(content)` or hashline `frame.edit` to populate Google Workspace frames.\n\n**Write — content, binary, or Google Workspace frame:** ' + (isRemote ? 'Provide exactly one of `content` (HTML/markdown/text), `base64` (base64-encoded binary with optional `content_type`), or `googleType` (`google-doc`, `google-sheet`, `google-slide`).' : 'Provide exactly one of `content` (HTML/markdown/text), `file_path` (absolute local file), `base64` (base64-encoded binary with optional `content_type`), or `googleType` (`google-doc`, `google-sheet`, `google-slide`).') + ' Call get_org first; when `googleDrive.connected` is true, strongly prefer Google Workspace frames for docs, sheets, and slides in that org. For inline content, filename extension matters: use `.html` for complete HTML documents and `.md` for Markdown. Never place a full HTML document in a `.md` or extensionless frame. For a new Google file, pass `googleType` and optional `title`; for an existing Google file, pass `googleType` plus `url` or `googleId`. ' + (isRemote ? 'For binary frames (images, PDFs, videos), pass `base64` with the binary bytes.' : 'For binary frames (images, PDFs, videos), use `file_path` when the file is local to the MCP host, or `base64` when the caller already has binary bytes.') + '\n\n**Write — dimensions:** By default, frames use the layer\'s default size (e.g. 1440×900 for designs, 1440×3000 for wireframes). Often too large for small content. Use `autoSize: true` to measure HTML content and size to fit, or pass explicit `width`/`height`.', {
|
|
2770
2806
|
projectId: PROJECT_OVERRIDE_PARAM,
|
|
2771
|
-
action: z.enum(['read', 'write', 'set_state', 'get_state', 'write_sheet_values', 'read_sheet_values', 'append_sheet_rows', 'clear_sheet_range', 'get_sheet', 'update_sheet', 'get_doc', 'read_doc_content', 'write_doc_content', 'append_doc_content', 'clear_doc_content', 'update_doc', 'get_slide', 'read_slide_content', 'write_slide_content', 'append_slides', 'clear_slides', 'update_slide', 'create_office', 'read_office', 'edit_office', 'write_excalidraw', 'edit', 'mv', 'anchor', 'search', 'versions', 'read_version', 'restore_version']).describe('Operation to perform. Use native Doc/Slide actions for Google Docs/Slides; do not use inline write/edit for native Workspace content.'),
|
|
2807
|
+
action: z.enum(['read', 'write', 'set_state', 'get_state', 'write_sheet_values', 'read_sheet_values', 'append_sheet_rows', 'clear_sheet_range', 'get_sheet', 'update_sheet', 'get_doc', 'read_doc_content', 'write_doc_content', 'append_doc_content', 'clear_doc_content', 'update_doc', 'get_slide', 'read_slide_content', 'write_slide_content', 'append_slides', 'clear_slides', 'update_slide', 'create_office', 'read_office', 'edit_office', 'write_excalidraw', 'edit_excalidraw', 'edit', 'mv', 'anchor', 'search', 'versions', 'read_version', 'restore_version']).describe('Operation to perform. Use native Doc/Slide actions for Google Docs/Slides; do not use inline write/edit for native Workspace content.'),
|
|
2772
2808
|
path: z.string().optional().describe('[read] /{layer}/{lane}/{filename}, frame URL, or UUID. [write|edit|anchor] /{layer}/{lane}/{filename}.'),
|
|
2773
2809
|
lines: z.string().optional().describe('[read] line range (e.g. "1-50"). Omit to read all.'),
|
|
2774
2810
|
content: z.string().optional().describe('[write] HTML/markdown/text for Drafted inline frames. [write_doc_content|append_doc_content] native Google Doc body text. Do not use action=write content to populate Google Doc/Slide frames.'),
|
|
2775
|
-
excalidraw_data: z.any().optional().describe('[write_excalidraw] Excalidraw scene JSON object or JSON string. Defaults to an empty scene. WRITE LEAN SCENES: your MCP client caps tool-call arguments (a big scene is rejected before Drafted ever sees it), so emit only the fields that carry meaning — id, type, x, y, width, height, angle, text/label, strokeColor, backgroundColor, fillStyle, strokeWidth, and the binding ids for arrows. Omit every field the editor can default (version, versionNonce, seed, updated, groupIds, boundElements when empty, roundness, opacity at 100, frameId when null). The editor fills them on open; shipping them can triple the payload for no gain.
|
|
2811
|
+
excalidraw_data: z.any().optional().describe('[write_excalidraw] Excalidraw scene JSON object or JSON string. Defaults to an empty scene. EVERY write_excalidraw REPLACES THE WHOLE SCENE — it does not merge; to grow a diagram, either resend all prior elements plus the new ones, or use action="edit_excalidraw" (element-level upsert/remove by id) which needs only the changed elements. WRITE LEAN SCENES: your MCP client caps tool-call arguments (a big scene is rejected before Drafted ever sees it — once a scene exceeds that cap, edit it with edit_excalidraw, not write_excalidraw), so emit only the fields that carry meaning — id, type, x, y, width, height, angle, text/label, strokeColor, backgroundColor, fillStyle, strokeWidth, and the binding ids for arrows. Omit every field the editor can default (version, versionNonce, seed, updated, groupIds, boundElements when empty, roundness, opacity at 100, frameId when null). The editor fills them on open; shipping them can triple the payload for no gain.'),
|
|
2812
|
+
elements: z.array(z.any()).optional().describe('[edit_excalidraw] Excalidraw elements to upsert by id: an element whose id already exists is shallow-merged (a partial {id,x,y} moves it, keeping its other props); a new id is appended. Send only the changed elements — the rest of the scene is preserved. Same lean-fields guidance as excalidraw_data.'),
|
|
2813
|
+
remove: z.array(z.string()).optional().describe('[edit_excalidraw] Element ids to delete from the scene.'),
|
|
2814
|
+
raw: z.boolean().optional().describe('[read] return the frame content as stored bytes (no NNNhash| line-number prefixes) — use when you need to parse the content as JSON (e.g. an Excalidraw scene). Note: edit ops require the hashline anchors, so omit raw when you intend to edit.'),
|
|
2776
2815
|
state: z.any().optional().describe('[set_state] App-frame state object (JSON) to persist for a deployed windowType:"app" frame — e.g. {specText:"..."} for the AS/NZS electrical app. The canvas hydrates the app from this on load (the host posts a "hydrate" message with it when the frame mounts), so you can deploy a generic app frame once and drive it with data afterwards. Max 64KB. Frame must be an app frame.'),
|
|
2777
2816
|
file_path: z.string().optional().describe('[write] absolute path to a local file to upload. Mutually exclusive with content/base64/googleType.'),
|
|
2778
2817
|
base64: z.string().optional().describe('[write] base64-encoded binary content. Mutually exclusive with content/file_path/googleType. Use with content_type when known.'),
|
|
@@ -2909,9 +2948,12 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
|
|
|
2909
2948
|
};
|
|
2910
2949
|
switch (action) {
|
|
2911
2950
|
case 'read': {
|
|
2912
|
-
const { path, lines } = args;
|
|
2951
|
+
const { path, lines, raw } = args;
|
|
2913
2952
|
if (!path) throw new Error('path required for action=read');
|
|
2914
|
-
const
|
|
2953
|
+
const qs = [];
|
|
2954
|
+
if (lines) qs.push(`lines=${encodeURIComponent(lines)}`);
|
|
2955
|
+
if (raw) qs.push('raw=1');
|
|
2956
|
+
const query = qs.length ? `?${qs.join('&')}` : '';
|
|
2915
2957
|
const frameUrlMatch = path.match(/\/f\/([a-f0-9-]{36})/);
|
|
2916
2958
|
const uuidMatch = path.match(/^[a-f0-9-]{36}$/);
|
|
2917
2959
|
const frameId = frameUrlMatch?.[1] || (uuidMatch ? path : null);
|
|
@@ -3180,6 +3222,20 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
|
|
|
3180
3222
|
_meta: { frameHtml: body.content },
|
|
3181
3223
|
});
|
|
3182
3224
|
}
|
|
3225
|
+
case 'edit_excalidraw': {
|
|
3226
|
+
const { path, elements, remove } = args;
|
|
3227
|
+
if (!path) throw new Error('path required for action=edit_excalidraw');
|
|
3228
|
+
const parts = path.replace(/^\/+/, '').split('/');
|
|
3229
|
+
if (parts.length !== 3) throw new Error('Path must be /{layer}/{lane}/{filename}');
|
|
3230
|
+
const filename = parts[2].toLowerCase().endsWith('.excalidraw') ? parts[2] : parts[2] + '.excalidraw';
|
|
3231
|
+
if (!Array.isArray(elements) && !Array.isArray(remove)) {
|
|
3232
|
+
throw new Error('Provide elements[] to add/update and/or remove[] element ids');
|
|
3233
|
+
}
|
|
3234
|
+
const result = await api('POST', '/api/fs/edit-excalidraw', { path: `/${parts[0]}/${parts[1]}/${filename}`, elements, remove });
|
|
3235
|
+
return ok(withProject(withFrameBreadcrumb(result, { hint: true })), {
|
|
3236
|
+
structuredContent: frameStructuredContent(result, projectCtx),
|
|
3237
|
+
});
|
|
3238
|
+
}
|
|
3183
3239
|
case 'edit': {
|
|
3184
3240
|
const { path, operations } = args;
|
|
3185
3241
|
if (!path) throw new Error('path required for action=edit');
|
|
@@ -4382,23 +4438,6 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
|
|
|
4382
4438
|
// Hashlines come from `read` (which now formats content as
|
|
4383
4439
|
// `LINE+ID|content`). The server applies the ops via the same
|
|
4384
4440
|
// hashline algorithm — no client-side re-hashing, no algorithm drift.
|
|
4385
|
-
case 'write_excalidraw': {
|
|
4386
|
-
const { path, excalidraw_data, width, height, color } = args;
|
|
4387
|
-
if (!path) throw new Error('path required for action=write_excalidraw');
|
|
4388
|
-
const parts = path.replace(/^\/+/, '').split('/');
|
|
4389
|
-
if (parts.length !== 3) throw new Error('Path must be /{layer}/{lane}/{filename}');
|
|
4390
|
-
const filename = parts[2].toLowerCase().endsWith('.excalidraw') ? parts[2] : parts[2] + '.excalidraw';
|
|
4391
|
-
const scene = excalidraw_data ?? emptyExcalidrawScene();
|
|
4392
|
-
const body = { content: stringifyExcalidrawScene(scene) };
|
|
4393
|
-
if (width) body.width = width;
|
|
4394
|
-
if (height) body.height = height;
|
|
4395
|
-
if (color) body.color = color;
|
|
4396
|
-
const result = await api('PUT', `/api/fs/${parts[0]}/${parts[1]}/${filename}`, body);
|
|
4397
|
-
return ok(withProject(withFrameBreadcrumb(result, { hint: true })), {
|
|
4398
|
-
structuredContent: frameStructuredContent(result, projectCtx),
|
|
4399
|
-
_meta: { frameHtml: body.content },
|
|
4400
|
-
});
|
|
4401
|
-
}
|
|
4402
4441
|
case 'edit': {
|
|
4403
4442
|
const { path: editPath, pageId: editPageId, operations: editOps } = args;
|
|
4404
4443
|
if (!Array.isArray(editOps) || editOps.length === 0) throw new Error('operations (array) required for action=edit');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "drafted",
|
|
3
|
-
"version": "1.14.
|
|
3
|
+
"version": "1.14.26",
|
|
4
4
|
"description": "Drafted — visual thinking surface for humans and AI agents. Renders HTML, markdown, images, and code as frames on a zoomable canvas, with MCP tools for AI agents and real-time sync for humans.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -38,3 +38,28 @@ export function normalizeExcalidrawScene(input) {
|
|
|
38
38
|
export function stringifyExcalidrawScene(input) {
|
|
39
39
|
return JSON.stringify(normalizeExcalidrawScene(input), null, 2);
|
|
40
40
|
}
|
|
41
|
+
|
|
42
|
+
// Element-level merge for surgical edits: upsert `upserts` into the stored scene
|
|
43
|
+
// by element id (shallow-merge onto an existing element so a partial {id,x,y}
|
|
44
|
+
// moves it without dropping its other props), and drop every id in `removeIds`.
|
|
45
|
+
// Existing element order is preserved (z-order = array order); updated elements
|
|
46
|
+
// keep their position, new ones append. This is the accumulate/merge path that
|
|
47
|
+
// write_excalidraw (full replace) is not — it lets a large scene be edited with
|
|
48
|
+
// a small payload instead of resending the whole thing.
|
|
49
|
+
export function mergeExcalidrawElements(existing, upserts = [], removeIds = []) {
|
|
50
|
+
const scene = normalizeExcalidrawScene(existing);
|
|
51
|
+
const removeSet = new Set(Array.isArray(removeIds) ? removeIds : []);
|
|
52
|
+
const byId = new Map();
|
|
53
|
+
for (const el of scene.elements) {
|
|
54
|
+
if (!el || removeSet.has(el.id)) continue;
|
|
55
|
+
byId.set(el.id, el);
|
|
56
|
+
}
|
|
57
|
+
for (const el of (Array.isArray(upserts) ? upserts : [])) {
|
|
58
|
+
if (!el || typeof el !== 'object' || Array.isArray(el) || !el.id) {
|
|
59
|
+
throw new Error('Each element to upsert must be an object with an id');
|
|
60
|
+
}
|
|
61
|
+
if (removeSet.has(el.id)) continue;
|
|
62
|
+
byId.set(el.id, byId.has(el.id) ? { ...byId.get(el.id), ...el } : el);
|
|
63
|
+
}
|
|
64
|
+
return { ...scene, elements: Array.from(byId.values()) };
|
|
65
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Runnable self-check for mergeExcalidrawElements. No framework: `node src/shared/test-excalidraw-merge.mjs`.
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import { mergeExcalidrawElements } from './excalidraw.mjs';
|
|
4
|
+
|
|
5
|
+
const base = {
|
|
6
|
+
type: 'excalidraw', version: 2, source: 'x',
|
|
7
|
+
elements: [
|
|
8
|
+
{ id: 'a', type: 'rectangle', x: 0, y: 0, strokeColor: '#000' },
|
|
9
|
+
{ id: 'b', type: 'text', x: 10, y: 10, text: 'hi' },
|
|
10
|
+
{ id: 'c', type: 'ellipse', x: 20, y: 20 },
|
|
11
|
+
],
|
|
12
|
+
appState: {}, files: {},
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// update (partial, shallow-merge keeps other props) + keeps position
|
|
16
|
+
let r = mergeExcalidrawElements(base, [{ id: 'b', x: 99 }]);
|
|
17
|
+
assert.deepStrictEqual(r.elements.map(e => e.id), ['a', 'b', 'c'], 'order preserved on update');
|
|
18
|
+
assert.strictEqual(r.elements[1].x, 99, 'x updated');
|
|
19
|
+
assert.strictEqual(r.elements[1].text, 'hi', 'other props preserved on partial update');
|
|
20
|
+
|
|
21
|
+
// add appends
|
|
22
|
+
r = mergeExcalidrawElements(base, [{ id: 'd', type: 'diamond', x: 5 }]);
|
|
23
|
+
assert.deepStrictEqual(r.elements.map(e => e.id), ['a', 'b', 'c', 'd'], 'new element appended');
|
|
24
|
+
|
|
25
|
+
// remove drops by id
|
|
26
|
+
r = mergeExcalidrawElements(base, [], ['a', 'c']);
|
|
27
|
+
assert.deepStrictEqual(r.elements.map(e => e.id), ['b'], 'ids removed');
|
|
28
|
+
|
|
29
|
+
// remove wins over upsert of the same id
|
|
30
|
+
r = mergeExcalidrawElements(base, [{ id: 'a', x: 1 }], ['a']);
|
|
31
|
+
assert.deepStrictEqual(r.elements.map(e => e.id), ['b', 'c'], 'remove beats upsert');
|
|
32
|
+
|
|
33
|
+
// accepts a JSON string as stored content (frame.content is a string)
|
|
34
|
+
r = mergeExcalidrawElements(JSON.stringify(base), [{ id: 'e', type: 'text', x: 0 }]);
|
|
35
|
+
assert.strictEqual(r.elements.length, 4, 'parses stored string content');
|
|
36
|
+
|
|
37
|
+
// upsert without id is rejected
|
|
38
|
+
assert.throws(() => mergeExcalidrawElements(base, [{ x: 1 }]), /must be an object with an id/);
|
|
39
|
+
|
|
40
|
+
console.log('ok: mergeExcalidrawElements');
|