drafted 1.14.25 → 1.14.27

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 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. For a big diagram, write it in passes: create the scene, then frame(action="write_excalidraw") again with the next batch of elements.'),
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 query = lines ? `?lines=${encodeURIComponent(lines)}` : '';
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);
@@ -2920,8 +2962,10 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
2920
2962
  result = await api('GET', `/api/fs/by-id/${frameId}${query}`);
2921
2963
  } else {
2922
2964
  const parts = path.replace(/^\/+/, '').split('/');
2923
- if (parts.length !== 3) throw new Error('Path must be /{layer}/{lane}/{filename}, a frame URL, or a frame ID');
2924
- result = await api('GET', `/api/fs/${parts[0]}/${parts[1]}/${parts[2]}${query}`);
2965
+ // 2 segments = a layer-root frame (empty lane, e.g. /designs/AGENTS.md);
2966
+ // 3 = /{layer}/{lane}/{filename}. Both resolve server-side.
2967
+ if (parts.length !== 2 && parts.length !== 3) throw new Error('Path must be /{layer}/{filename}, /{layer}/{lane}/{filename}, a frame URL, or a frame ID');
2968
+ result = await api('GET', `/api/fs/${parts.map(encodeURIComponent).join('/')}${query}`);
2925
2969
  }
2926
2970
  // Surface content as the visible text. Some Claude clients prefer
2927
2971
  // structuredContent over text when both are present and structured looks
@@ -3180,6 +3224,20 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
3180
3224
  _meta: { frameHtml: body.content },
3181
3225
  });
3182
3226
  }
3227
+ case 'edit_excalidraw': {
3228
+ const { path, elements, remove } = args;
3229
+ if (!path) throw new Error('path required for action=edit_excalidraw');
3230
+ const parts = path.replace(/^\/+/, '').split('/');
3231
+ if (parts.length !== 3) throw new Error('Path must be /{layer}/{lane}/{filename}');
3232
+ const filename = parts[2].toLowerCase().endsWith('.excalidraw') ? parts[2] : parts[2] + '.excalidraw';
3233
+ if (!Array.isArray(elements) && !Array.isArray(remove)) {
3234
+ throw new Error('Provide elements[] to add/update and/or remove[] element ids');
3235
+ }
3236
+ const result = await api('POST', '/api/fs/edit-excalidraw', { path: `/${parts[0]}/${parts[1]}/${filename}`, elements, remove });
3237
+ return ok(withProject(withFrameBreadcrumb(result, { hint: true })), {
3238
+ structuredContent: frameStructuredContent(result, projectCtx),
3239
+ });
3240
+ }
3183
3241
  case 'edit': {
3184
3242
  const { path, operations } = args;
3185
3243
  if (!path) throw new Error('path required for action=edit');
@@ -4382,23 +4440,6 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
4382
4440
  // Hashlines come from `read` (which now formats content as
4383
4441
  // `LINE+ID|content`). The server applies the ops via the same
4384
4442
  // 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
4443
  case 'edit': {
4403
4444
  const { path: editPath, pageId: editPageId, operations: editOps } = args;
4404
4445
  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.25",
3
+ "version": "1.14.27",
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');