affine-mcp-server 1.13.0 → 2.0.0

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.
@@ -1,9 +1,11 @@
1
1
  import { z } from "zod";
2
+ import { generateKeyBetween } from "fractional-indexing";
2
3
  import { receipt, text } from "../util/mcp.js";
3
4
  import { wsUrlFromGraphQLEndpoint, connectWorkspaceSocket, joinWorkspace, loadDoc, pushDocUpdate, deleteDoc as wsDeleteDoc } from "../ws.js";
4
5
  import * as Y from "yjs";
5
6
  import { parseMarkdownToOperations } from "../markdown/parse.js";
6
7
  import { renderBlocksToMarkdown } from "../markdown/render.js";
8
+ import { DEFAULT_NOTE_XYWH, DEFAULT_STACK_GAP_HORIZONTAL, DEFAULT_STACK_GAP_VERTICAL, SIDE_TO_NORMALIZED_POSITION, encloseBounds, estimateConnectorLabelXYWH, estimateNoteHeightForMarkdown, formatXywhString, parseXywhString, pickConnectorSides, pickFurthestInDirection, sortByFractionalIndex, stackRelativeTo, } from "../edgeless/layout.js";
7
9
  const WorkspaceId = z.string().min(1, "workspaceId required");
8
10
  const DocId = z.string().min(1, "docId required");
9
11
  const MarkdownContent = z.string().min(1, "markdown required");
@@ -519,6 +521,24 @@ export function registerDocTools(server, gql, defaults) {
519
521
  }
520
522
  return null;
521
523
  }
524
+ function pruneFromFrameChildElementIds(blocks, deletedIds) {
525
+ if (deletedIds.length === 0)
526
+ return;
527
+ const idSet = new Set(deletedIds);
528
+ for (const [, value] of blocks) {
529
+ if (!(value instanceof Y.Map))
530
+ continue;
531
+ if (value.get("sys:flavour") !== "affine:frame")
532
+ continue;
533
+ const owned = value.get("prop:childElementIds");
534
+ if (!(owned instanceof Y.Map))
535
+ continue;
536
+ for (const id of idSet) {
537
+ if (owned.has(id))
538
+ owned.delete(id);
539
+ }
540
+ }
541
+ }
522
542
  function ensureNoteBlock(blocks) {
523
543
  const existingNoteId = findBlockIdByFlavour(blocks, "affine:note");
524
544
  if (existingNoteId) {
@@ -528,20 +548,29 @@ export function registerDocTools(server, gql, defaults) {
528
548
  if (!pageId) {
529
549
  throw new Error("Document has no page block; unable to insert content.");
530
550
  }
551
+ // Mirror BlockSuite's createDefaultDoc shape so the editor doesn't re-seed
552
+ // its own default note alongside ours.
531
553
  const noteId = generateId();
532
554
  const note = new Y.Map();
533
555
  setSysFields(note, noteId, "affine:note");
534
556
  note.set("sys:parent", null);
535
- note.set("sys:children", new Y.Array());
536
- note.set("prop:xywh", "[0,0,800,95]");
557
+ const noteChildren = new Y.Array();
558
+ note.set("sys:children", noteChildren);
559
+ note.set("prop:xywh", DEFAULT_NOTE_XYWH);
537
560
  note.set("prop:index", "a0");
538
561
  note.set("prop:hidden", false);
539
562
  note.set("prop:displayMode", "both");
540
- const background = new Y.Map();
541
- background.set("light", "#ffffff");
542
- background.set("dark", "#252525");
543
- note.set("prop:background", background);
563
+ note.set("prop:background", buildDefaultNoteBackground());
544
564
  blocks.set(noteId, note);
565
+ const paragraphId = generateId();
566
+ const paragraph = new Y.Map();
567
+ setSysFields(paragraph, paragraphId, "affine:paragraph");
568
+ paragraph.set("sys:parent", null);
569
+ paragraph.set("sys:children", new Y.Array());
570
+ paragraph.set("prop:type", "text");
571
+ paragraph.set("prop:text", makeText(""));
572
+ blocks.set(paragraphId, paragraph);
573
+ noteChildren.push([paragraphId]);
545
574
  const page = blocks.get(pageId);
546
575
  let pageChildren = page.get("sys:children");
547
576
  if (!(pageChildren instanceof Y.Array)) {
@@ -551,6 +580,12 @@ export function registerDocTools(server, gql, defaults) {
551
580
  pageChildren.push([noteId]);
552
581
  return noteId;
553
582
  }
583
+ function buildDefaultNoteBackground() {
584
+ const map = new Y.Map();
585
+ map.set("light", "#ffffff");
586
+ map.set("dark", "#252525");
587
+ return map;
588
+ }
554
589
  function ensureSurfaceBlock(blocks) {
555
590
  const existingSurfaceId = findBlockIdByFlavour(blocks, "affine:surface");
556
591
  if (existingSurfaceId) {
@@ -834,9 +869,20 @@ export function registerDocTools(server, gql, defaults) {
834
869
  const design = parsed.design ?? "";
835
870
  const reference = (parsed.reference ?? "").trim();
836
871
  const refFlavour = (parsed.refFlavour ?? "").trim();
837
- const width = Number.isFinite(parsed.width) ? Math.max(1, Math.floor(parsed.width)) : 100;
838
- const height = Number.isFinite(parsed.height) ? Math.max(1, Math.floor(parsed.height)) : 100;
839
- const background = (parsed.background ?? "transparent").trim() || "transparent";
872
+ const x = Number.isFinite(parsed.x) ? Math.floor(parsed.x) : 0;
873
+ const y = Number.isFinite(parsed.y) ? Math.floor(parsed.y) : 0;
874
+ const widthProvided = Number.isFinite(parsed.width);
875
+ const heightProvided = Number.isFinite(parsed.height);
876
+ const width = widthProvided ? Math.max(1, Math.floor(parsed.width)) : 100;
877
+ let height = heightProvided ? Math.max(1, Math.floor(parsed.height)) : 100;
878
+ // Pre-inflate the stored height so stackAfter'd siblings don't overlap the
879
+ // note before the editor's ResizeObserver corrects it on first render.
880
+ if (typeInfo.type === "note" && !heightProvided && typeof parsed.markdown === "string" && parsed.markdown.length > 0) {
881
+ height = Math.max(height, estimateNoteHeightForMarkdown(parsed.markdown, widthProvided ? width : 400));
882
+ }
883
+ const background = typeof parsed.background === "string"
884
+ ? (parsed.background.trim() || "transparent")
885
+ : (parsed.background && typeof parsed.background === "object" ? parsed.background : "transparent");
840
886
  const sourceId = (parsed.sourceId ?? "").trim();
841
887
  const name = (parsed.name ?? "attachment").trim() || "attachment";
842
888
  const mimeType = (parsed.mimeType ?? "application/octet-stream").trim() || "application/octet-stream";
@@ -860,6 +906,8 @@ export function registerDocTools(server, gql, defaults) {
860
906
  design,
861
907
  reference,
862
908
  refFlavour,
909
+ x,
910
+ y,
863
911
  width,
864
912
  height,
865
913
  background,
@@ -882,6 +930,14 @@ export function registerDocTools(server, gql, defaults) {
882
930
  tableData,
883
931
  deltas: parsed.deltas,
884
932
  tableCellDeltas,
933
+ childElementIds: Array.isArray(parsed.childElementIds) ? parsed.childElementIds : undefined,
934
+ stackAfter: parsed.stackAfter,
935
+ padding: Number.isFinite(parsed.padding) ? Math.max(0, Math.floor(parsed.padding)) : undefined,
936
+ xProvided: Number.isFinite(parsed.x),
937
+ yProvided: Number.isFinite(parsed.y),
938
+ widthProvided,
939
+ heightProvided,
940
+ markdown: typeof parsed.markdown === "string" ? parsed.markdown : undefined,
885
941
  };
886
942
  validateNormalizedAppendBlockInput(normalized, parsed);
887
943
  return normalized;
@@ -1565,10 +1621,15 @@ export function registerDocTools(server, gql, defaults) {
1565
1621
  block.set("sys:parent", null);
1566
1622
  block.set("sys:children", new Y.Array());
1567
1623
  block.set("prop:title", makeText(content || "Frame"));
1568
- block.set("prop:background", normalized.background);
1569
- block.set("prop:xywh", `[0,0,${normalized.width},${normalized.height}]`);
1624
+ // 'transparent' matches FrameBlockSchema; any other value renders as a
1625
+ // solid fill (the border is separate).
1626
+ block.set("prop:background", normalized.background ?? "transparent");
1627
+ block.set("prop:xywh", `[${normalized.x},${normalized.y},${normalized.width},${normalized.height}]`);
1570
1628
  block.set("prop:index", "a0");
1571
- block.set("prop:childElementIds", new Y.Map());
1629
+ const childIds = new Y.Map();
1630
+ for (const id of normalized._frameOwnedIds ?? [])
1631
+ childIds.set(id, true);
1632
+ block.set("prop:childElementIds", childIds);
1572
1633
  block.set("prop:presentationIndex", "a0");
1573
1634
  block.set("prop:lockedBySelf", false);
1574
1635
  return { blockId, block, flavour: "affine:frame" };
@@ -1576,27 +1637,54 @@ export function registerDocTools(server, gql, defaults) {
1576
1637
  case "edgeless_text": {
1577
1638
  setSysFields(block, blockId, "affine:edgeless-text");
1578
1639
  block.set("sys:parent", null);
1579
- block.set("sys:children", new Y.Array());
1580
- block.set("prop:xywh", `[0,0,${normalized.width},${normalized.height}]`);
1640
+ const edgelessTextChildren = new Y.Array();
1641
+ block.set("prop:xywh", `[${normalized.x},${normalized.y},${normalized.width},${normalized.height}]`);
1581
1642
  block.set("prop:index", "a0");
1582
1643
  block.set("prop:lockedBySelf", false);
1583
1644
  block.set("prop:scale", 1);
1584
1645
  block.set("prop:rotate", 0);
1585
1646
  block.set("prop:hasMaxWidth", false);
1586
1647
  block.set("prop:comments", undefined);
1587
- block.set("prop:color", "black");
1648
+ // Theme-adaptive token so canvas text stays legible in dark mode.
1649
+ block.set("prop:color", "--affine-text-primary-color");
1588
1650
  block.set("prop:fontFamily", "Inter");
1589
1651
  block.set("prop:fontStyle", "normal");
1590
1652
  block.set("prop:fontWeight", "regular");
1591
1653
  block.set("prop:textAlign", "left");
1592
- return { blockId, block, flavour: "affine:edgeless-text" };
1654
+ const edgelessTextExtraBlocks = [];
1655
+ if (content) {
1656
+ const paraId = generateId();
1657
+ const para = new Y.Map();
1658
+ setSysFields(para, paraId, "affine:paragraph");
1659
+ para.set("sys:parent", null);
1660
+ para.set("sys:children", new Y.Array());
1661
+ para.set("prop:type", "text");
1662
+ para.set("prop:text", makeText(normalized.deltas ?? content));
1663
+ edgelessTextChildren.push([paraId]);
1664
+ edgelessTextExtraBlocks.push({ blockId: paraId, block: para });
1665
+ }
1666
+ block.set("sys:children", edgelessTextChildren);
1667
+ return { blockId, block, flavour: "affine:edgeless-text", extraBlocks: edgelessTextExtraBlocks };
1593
1668
  }
1594
1669
  case "note": {
1595
1670
  setSysFields(block, blockId, "affine:note");
1596
1671
  block.set("sys:parent", null);
1597
- block.set("sys:children", new Y.Array());
1598
- block.set("prop:xywh", `[0,0,${normalized.width},${normalized.height}]`);
1599
- block.set("prop:background", normalized.background);
1672
+ const noteChildren = new Y.Array();
1673
+ block.set("prop:xywh", `[${normalized.x},${normalized.y},${normalized.width},${normalized.height}]`);
1674
+ // BlockSuite reads the adaptive-bg case as a Y.Map; a plain JS object
1675
+ // would serialize to a JSON string and break theme switching.
1676
+ const bg = normalized.background;
1677
+ if (bg && typeof bg === "object" && !Array.isArray(bg) && ("light" in bg || "dark" in bg)) {
1678
+ const bgMap = new Y.Map();
1679
+ if (typeof bg.light === "string")
1680
+ bgMap.set("light", bg.light);
1681
+ if (typeof bg.dark === "string")
1682
+ bgMap.set("dark", bg.dark);
1683
+ block.set("prop:background", bgMap);
1684
+ }
1685
+ else {
1686
+ block.set("prop:background", bg);
1687
+ }
1600
1688
  block.set("prop:index", "a0");
1601
1689
  block.set("prop:lockedBySelf", false);
1602
1690
  block.set("prop:hidden", false);
@@ -1610,7 +1698,152 @@ export function registerDocTools(server, gql, defaults) {
1610
1698
  edgeless.set("style", style);
1611
1699
  block.set("prop:edgeless", edgeless);
1612
1700
  block.set("prop:comments", undefined);
1613
- return { blockId, block, flavour: "affine:note" };
1701
+ const noteExtraBlocks = [];
1702
+ if (content) {
1703
+ const paraId = generateId();
1704
+ const para = new Y.Map();
1705
+ setSysFields(para, paraId, "affine:paragraph");
1706
+ para.set("sys:parent", null);
1707
+ para.set("sys:children", new Y.Array());
1708
+ para.set("prop:type", "text");
1709
+ para.set("prop:text", makeText(normalized.deltas ?? content));
1710
+ noteChildren.push([paraId]);
1711
+ noteExtraBlocks.push({ blockId: paraId, block: para });
1712
+ }
1713
+ block.set("sys:children", noteChildren);
1714
+ return { blockId, block, flavour: "affine:note", extraBlocks: noteExtraBlocks };
1715
+ }
1716
+ }
1717
+ }
1718
+ function resolveBlockBoundAsBound(blocks, blockId) {
1719
+ const b = blocks.get(blockId);
1720
+ if (b instanceof Y.Map) {
1721
+ const xywh = parseXywhString(b.get("prop:xywh"));
1722
+ if (xywh)
1723
+ return { x: xywh.x, y: xywh.y, w: xywh.width, h: xywh.height };
1724
+ }
1725
+ return null;
1726
+ }
1727
+ function resolveEdgelessLayoutHints(blocks, normalized) {
1728
+ const defaultPadding = normalized.padding ?? 40;
1729
+ let placed = false;
1730
+ if (normalized.stackAfter) {
1731
+ const idList = Array.isArray(normalized.stackAfter.blockId)
1732
+ ? normalized.stackAfter.blockId
1733
+ : [normalized.stackAfter.blockId];
1734
+ const direction = normalized.stackAfter.direction ?? "down";
1735
+ const missing = [];
1736
+ const bounds = [];
1737
+ for (const id of idList) {
1738
+ const b = resolveBlockBoundAsBound(blocks, id);
1739
+ if (!b)
1740
+ missing.push(id);
1741
+ else
1742
+ bounds.push(b);
1743
+ }
1744
+ const ref = pickFurthestInDirection(bounds, direction);
1745
+ if (!ref) {
1746
+ throw new Error(`stackAfter: no blockIds resolved to xywh. Missing: ${JSON.stringify(missing)}`);
1747
+ }
1748
+ // Gap precedence: explicit `gap` > explicit `padding` > direction default
1749
+ // (horizontal larger because notes are wide-short and tight sideways).
1750
+ const isHorizontal = direction === "left" || direction === "right";
1751
+ const directionDefaultGap = isHorizontal ? DEFAULT_STACK_GAP_HORIZONTAL : DEFAULT_STACK_GAP_VERTICAL;
1752
+ const gap = normalized.stackAfter.gap
1753
+ ?? (normalized.padding !== undefined ? normalized.padding : directionDefaultGap);
1754
+ // Center on the anchor group's orthogonal-axis union; caller x/y wins.
1755
+ const isVertical = direction === "down" || direction === "up";
1756
+ let preserveX;
1757
+ let preserveY;
1758
+ if (normalized.xProvided === true) {
1759
+ preserveX = normalized.x;
1760
+ }
1761
+ else if (isVertical) {
1762
+ const minX = bounds.reduce((m, b) => Math.min(m, b.x), Infinity);
1763
+ const maxX = bounds.reduce((m, b) => Math.max(m, b.x + b.w), -Infinity);
1764
+ preserveX = Math.round((minX + maxX) / 2 - normalized.width / 2);
1765
+ }
1766
+ if (normalized.yProvided === true) {
1767
+ preserveY = normalized.y;
1768
+ }
1769
+ else if (!isVertical) {
1770
+ const minY = bounds.reduce((m, b) => Math.min(m, b.y), Infinity);
1771
+ const maxY = bounds.reduce((m, b) => Math.max(m, b.y + b.h), -Infinity);
1772
+ preserveY = Math.round((minY + maxY) / 2 - normalized.height / 2);
1773
+ }
1774
+ const { x, y } = stackRelativeTo(ref, { w: normalized.width, h: normalized.height }, { direction, gap, preserveX, preserveY });
1775
+ normalized.x = x;
1776
+ normalized.y = y;
1777
+ placed = true;
1778
+ }
1779
+ // prop:childElementIds accepts both surface-element ids and block ids —
1780
+ // that's what the editor writes when you drag a note into a frame.
1781
+ if (normalized.type === "frame" && normalized.childElementIds && normalized.childElementIds.length > 0) {
1782
+ const surfaceCtx = getSurfaceElementsValueMap(blocks, { create: false });
1783
+ const surfaceValueMap = surfaceCtx?.value ?? new Y.Map();
1784
+ const ownedIds = [];
1785
+ const missing = [];
1786
+ const kids = [];
1787
+ for (const id of normalized.childElementIds) {
1788
+ const resolved = resolveChildBound(surfaceValueMap, blocks, id);
1789
+ if (resolved.kind === "missing") {
1790
+ missing.push(id);
1791
+ }
1792
+ else {
1793
+ ownedIds.push(id);
1794
+ if (resolved.bound)
1795
+ kids.push(resolved.bound);
1796
+ }
1797
+ }
1798
+ if (ownedIds.length === 0) {
1799
+ throw new Error(`None of the ids in childElementIds were found: ${JSON.stringify(missing)}.`);
1800
+ }
1801
+ normalized._frameOwnedIds = ownedIds;
1802
+ normalized._frameMissing = missing;
1803
+ if (!normalized.widthProvided || !normalized.heightProvided || !normalized.xProvided || !normalized.yProvided) {
1804
+ const wrapped = encloseBounds(kids, { padding: defaultPadding, titleBand: 60 });
1805
+ if (wrapped) {
1806
+ if (!normalized.xProvided)
1807
+ normalized.x = wrapped.x;
1808
+ if (!normalized.yProvided)
1809
+ normalized.y = wrapped.y;
1810
+ if (!normalized.widthProvided)
1811
+ normalized.width = wrapped.w;
1812
+ if (!normalized.heightProvided)
1813
+ normalized.height = wrapped.h;
1814
+ placed = true;
1815
+ }
1816
+ }
1817
+ }
1818
+ // Auto-stack-below fallback: avoids dropping new edgeless blocks on top of
1819
+ // the seeded default note at [0,0,…] when the caller gave no placement.
1820
+ const isEdgelessBlock = normalized.type === "frame" ||
1821
+ normalized.type === "note" ||
1822
+ normalized.type === "edgeless_text";
1823
+ if (!placed && isEdgelessBlock && !normalized.yProvided) {
1824
+ const existing = [];
1825
+ for (const [, b] of blocks.entries()) {
1826
+ if (!(b instanceof Y.Map))
1827
+ continue;
1828
+ const flavour = b.get("sys:flavour");
1829
+ if (flavour !== "affine:note" &&
1830
+ flavour !== "affine:frame" &&
1831
+ flavour !== "affine:edgeless-text")
1832
+ continue;
1833
+ const xywh = parseXywhString(b.get("prop:xywh"));
1834
+ if (!xywh)
1835
+ continue;
1836
+ existing.push({ x: xywh.x, y: xywh.y, w: xywh.width, h: xywh.height });
1837
+ }
1838
+ const ref = pickFurthestInDirection(existing, "down");
1839
+ if (ref) {
1840
+ const { x, y } = stackRelativeTo(ref, { w: normalized.width, h: normalized.height }, {
1841
+ direction: "down",
1842
+ gap: defaultPadding,
1843
+ preserveX: normalized.xProvided === true ? normalized.x : undefined,
1844
+ });
1845
+ normalized.x = x;
1846
+ normalized.y = y;
1614
1847
  }
1615
1848
  }
1616
1849
  }
@@ -1631,6 +1864,7 @@ export function registerDocTools(server, gql, defaults) {
1631
1864
  }
1632
1865
  const prevSV = Y.encodeStateVector(doc);
1633
1866
  const blocks = doc.getMap("blocks");
1867
+ resolveEdgelessLayoutHints(blocks, normalized);
1634
1868
  const context = resolveInsertContext(blocks, normalized);
1635
1869
  const { blockId, block, flavour, blockType, extraBlocks } = createBlock(normalized);
1636
1870
  blocks.set(blockId, block);
@@ -1647,7 +1881,16 @@ export function registerDocTools(server, gql, defaults) {
1647
1881
  }
1648
1882
  const delta = Y.encodeStateAsUpdate(doc, prevSV);
1649
1883
  await pushDocUpdate(socket, workspaceId, normalized.docId, Buffer.from(delta).toString("base64"));
1650
- return { appended: true, blockId, flavour, blockType, normalizedType: normalized.type, legacyType: normalized.legacyType || null };
1884
+ return {
1885
+ appended: true,
1886
+ blockId,
1887
+ flavour,
1888
+ blockType,
1889
+ normalizedType: normalized.type,
1890
+ legacyType: normalized.legacyType || null,
1891
+ ownedIds: normalized._frameOwnedIds,
1892
+ missing: normalized._frameMissing,
1893
+ };
1651
1894
  }
1652
1895
  finally {
1653
1896
  socket.disconnect();
@@ -2420,30 +2663,26 @@ export function registerDocTools(server, gql, defaults) {
2420
2663
  setSysFields(note, noteId, "affine:note");
2421
2664
  note.set("sys:parent", null);
2422
2665
  note.set("prop:displayMode", "both");
2423
- note.set("prop:xywh", "[0,0,800,95]");
2666
+ note.set("prop:xywh", DEFAULT_NOTE_XYWH);
2424
2667
  note.set("prop:index", "a0");
2425
2668
  note.set("prop:hidden", false);
2426
- const background = new Y.Map();
2427
- background.set("light", "#ffffff");
2428
- background.set("dark", "#252525");
2429
- note.set("prop:background", background);
2669
+ note.set("prop:background", buildDefaultNoteBackground());
2430
2670
  const noteChildren = new Y.Array();
2431
2671
  note.set("sys:children", noteChildren);
2432
2672
  blocks.set(noteId, note);
2433
2673
  children.push([noteId]);
2434
- if (parsed.content) {
2435
- const paraId = generateId();
2436
- const para = new Y.Map();
2437
- setSysFields(para, paraId, "affine:paragraph");
2438
- para.set("sys:parent", null);
2439
- para.set("sys:children", new Y.Array());
2440
- para.set("prop:type", "text");
2441
- const paragraphText = new Y.Text();
2674
+ const paraId = generateId();
2675
+ const para = new Y.Map();
2676
+ setSysFields(para, paraId, "affine:paragraph");
2677
+ para.set("sys:parent", null);
2678
+ para.set("sys:children", new Y.Array());
2679
+ para.set("prop:type", "text");
2680
+ const paragraphText = new Y.Text();
2681
+ if (parsed.content)
2442
2682
  paragraphText.insert(0, parsed.content);
2443
- para.set("prop:text", paragraphText);
2444
- blocks.set(paraId, para);
2445
- noteChildren.push([paraId]);
2446
- }
2683
+ para.set("prop:text", paragraphText);
2684
+ blocks.set(paraId, para);
2685
+ noteChildren.push([paraId]);
2447
2686
  const meta = ydoc.getMap("meta");
2448
2687
  meta.set("id", docId);
2449
2688
  meta.set("title", title);
@@ -2562,16 +2801,23 @@ export function registerDocTools(server, gql, defaults) {
2562
2801
  setSysFields(note, noteId, "affine:note");
2563
2802
  note.set("sys:parent", null);
2564
2803
  note.set("prop:displayMode", "both");
2565
- note.set("prop:xywh", "[0,0,800,95]");
2804
+ note.set("prop:xywh", DEFAULT_NOTE_XYWH);
2566
2805
  note.set("prop:index", "a0");
2567
2806
  note.set("prop:hidden", false);
2568
- const background = new Y.Map();
2569
- background.set("light", "#ffffff");
2570
- background.set("dark", "#252525");
2571
- note.set("prop:background", background);
2572
- note.set("sys:children", new Y.Array());
2807
+ note.set("prop:background", buildDefaultNoteBackground());
2808
+ const skeletonNoteChildren = new Y.Array();
2809
+ note.set("sys:children", skeletonNoteChildren);
2573
2810
  blocks.set(noteId, note);
2574
2811
  page.get("sys:children").push([noteId]);
2812
+ const skeletonParaId = generateId();
2813
+ const skeletonPara = new Y.Map();
2814
+ setSysFields(skeletonPara, skeletonParaId, "affine:paragraph");
2815
+ skeletonPara.set("sys:parent", null);
2816
+ skeletonPara.set("sys:children", new Y.Array());
2817
+ skeletonPara.set("prop:type", "text");
2818
+ skeletonPara.set("prop:text", new Y.Text());
2819
+ blocks.set(skeletonParaId, skeletonPara);
2820
+ skeletonNoteChildren.push([skeletonParaId]);
2575
2821
  const meta = doc.getMap("meta");
2576
2822
  meta.set("id", docId);
2577
2823
  meta.set("title", title);
@@ -3553,24 +3799,30 @@ export function registerDocTools(server, gql, defaults) {
3553
3799
  knownUnsupportedFlavours: [...KNOWN_BLOCK_FLAVOURS].filter(flavour => !MARKDOWN_EXPORT_SUPPORTED_FLAVOURS.has(flavour)).sort(),
3554
3800
  },
3555
3801
  highLevelAuthoring: {
3556
- semanticPageComposer: false,
3557
- nativeTemplateInstantiation: false,
3558
- createDocWithPlacement: false,
3559
- semanticSectionEditing: false,
3802
+ semanticPageComposer: true,
3803
+ nativeTemplateInstantiation: true,
3804
+ createDocWithPlacement: true,
3805
+ semanticSectionEditing: true,
3806
+ },
3807
+ edgelessCanvas: {
3808
+ blockMutation: true,
3809
+ surfaceElementMutation: true,
3810
+ canvasReadback: true,
3811
+ frameOwnershipMutation: true,
3560
3812
  },
3561
3813
  },
3562
3814
  database: {
3563
3815
  supported: true,
3564
3816
  columnTypes: [...DATABASE_COLUMN_TYPE_VALUES],
3565
3817
  initialViewModes: [...APPEND_BLOCK_DATA_VIEW_MODE_VALUES],
3566
- advancedViewMutation: false,
3567
- intentDrivenComposition: false,
3818
+ advancedViewMutation: true,
3819
+ intentDrivenComposition: true,
3568
3820
  linkedDocRows: true,
3569
3821
  },
3570
3822
  workspace: {
3571
3823
  organizeToolsExperimental: true,
3572
- ruleBackedCollections: false,
3573
- workspaceBlueprints: false,
3824
+ ruleBackedCollections: true,
3825
+ workspaceBlueprints: true,
3574
3826
  },
3575
3827
  collaboration: {
3576
3828
  docComments: true,
@@ -3859,36 +4111,31 @@ export function registerDocTools(server, gql, defaults) {
3859
4111
  callouts: z.array(z.string().min(1)).optional().describe("Callout blocks to append under the new section."),
3860
4112
  },
3861
4113
  }, appendSemanticSectionHandler);
3862
- // APPEND PARAGRAPH
3863
- const appendParagraphHandler = async (parsed) => {
3864
- const result = await appendBlockInternal({
3865
- workspaceId: parsed.workspaceId,
3866
- docId: parsed.docId,
3867
- type: "paragraph",
3868
- text: parsed.text,
3869
- });
3870
- return receipt("doc.append_paragraph", {
3871
- workspaceId: parsed.workspaceId || defaults.workspaceId || null,
3872
- docId: parsed.docId,
3873
- appended: result.appended,
3874
- blockId: result.blockId,
3875
- paragraphId: result.blockId,
3876
- blockType: result.blockType || null,
3877
- normalizedType: result.normalizedType,
3878
- legacyType: result.legacyType,
3879
- });
3880
- };
3881
- server.registerTool('append_paragraph', {
3882
- title: 'Append Paragraph',
3883
- description: 'Append a text paragraph block to a document',
3884
- inputSchema: {
3885
- workspaceId: z.string().optional(),
3886
- docId: z.string(),
3887
- text: z.string(),
3888
- },
3889
- }, appendParagraphHandler);
3890
4114
  const appendBlockHandler = async (parsed) => {
3891
- const result = await appendBlockInternal(parsed);
4115
+ // Drop `text` when `markdown` is set so markdown-parsed children don't
4116
+ // sit next to a stale one-paragraph echo.
4117
+ const shouldApplyMarkdown = parsed.type === "note" && !!parsed.markdown;
4118
+ const coreParsed = shouldApplyMarkdown ? { ...parsed, text: undefined } : parsed;
4119
+ const result = await appendBlockInternal(coreParsed);
4120
+ let markdownApplied;
4121
+ if (shouldApplyMarkdown && result.appended && result.blockId) {
4122
+ const parsedMd = parseMarkdownToOperations(parsed.markdown);
4123
+ if (parsedMd.operations.length > 0) {
4124
+ const applied = await applyMarkdownOperationsInternal({
4125
+ workspaceId: parsed.workspaceId || defaults.workspaceId,
4126
+ docId: parsed.docId,
4127
+ operations: parsedMd.operations,
4128
+ strict: parsed.strict,
4129
+ placement: { parentId: result.blockId },
4130
+ });
4131
+ markdownApplied = {
4132
+ appendedCount: applied.appendedCount,
4133
+ skippedCount: applied.skippedCount,
4134
+ blockIds: applied.blockIds,
4135
+ warnings: parsedMd.warnings,
4136
+ };
4137
+ }
4138
+ }
3892
4139
  return receipt("doc.append_block", {
3893
4140
  workspaceId: parsed.workspaceId || defaults.workspaceId || null,
3894
4141
  docId: parsed.docId,
@@ -3899,6 +4146,9 @@ export function registerDocTools(server, gql, defaults) {
3899
4146
  blockType: result.blockType || null,
3900
4147
  normalizedType: result.normalizedType,
3901
4148
  legacyType: result.legacyType,
4149
+ ...(result.ownedIds ? { ownedIds: result.ownedIds } : {}),
4150
+ ...(result.missing ? { missing: result.missing } : {}),
4151
+ ...(markdownApplied ? { markdown: markdownApplied } : {}),
3902
4152
  });
3903
4153
  };
3904
4154
  server.registerTool("append_block", {
@@ -3916,9 +4166,19 @@ export function registerDocTools(server, gql, defaults) {
3916
4166
  design: z.string().optional().describe("Design payload for embed_html"),
3917
4167
  reference: z.string().optional().describe("Target id for surface_ref"),
3918
4168
  refFlavour: z.string().optional().describe("Target flavour for surface_ref (e.g. affine:frame)"),
3919
- width: z.number().int().min(1).max(10000).optional().describe("Width for frame/edgeless_text/note"),
3920
- height: z.number().int().min(1).max(10000).optional().describe("Height for frame/edgeless_text/note"),
3921
- background: z.string().optional().describe("Background for frame/note"),
4169
+ x: z.number().int().optional().describe("X position on the edgeless canvas for frame/edgeless_text/note (default 0). Prefer ≥40px between sibling bounds; BlockSuite does not auto-arrange."),
4170
+ y: z.number().int().optional().describe("Y position on the edgeless canvas for frame/edgeless_text/note (default 0)."),
4171
+ width: z.number().int().min(1).max(10000).optional().describe("Width for frame/edgeless_text/note."),
4172
+ height: z.number().int().min(1).max(10000).optional().describe("Height for frame/edgeless_text/note. When `markdown` is set and height is omitted, an over-estimate is computed from the content — AFFiNE's render-time ResizeObserver corrects `prop:xywh` to the true DOM-measured height on first browser open."),
4173
+ background: z.any().optional().describe("Background for frame/note. Frame default 'transparent'. For notes, prefer AFFiNE's adaptive `--affine-note-background-<color>` family — `blue` / `purple` / `yellow` / `green` / `teal` / `red` / `orange` / `magenta` / `grey` / `white` / `black`. For specific per-theme colors, pass a `{light, dark}` hex object like `{light:'#fff', dark:'#252525'}`."),
4174
+ markdown: z.string().optional().describe("When type='note', parse this markdown into heading/paragraph/list/code child blocks inside the note (BlockSuite-native: mirrors what happens when you paste markdown into an edgeless note). Takes precedence over 'text' for note children. Ignored for other block types."),
4175
+ childElementIds: z.array(z.string()).optional().describe("For type='frame' only. The frame's contents. Accepts ids of surface elements (shapes/connectors/groups) AND edgeless blocks (notes/frames/edgeless-text) — BlockSuite's prop:childElementIds holds both, matching what the editor writes when you drag a note into a frame. Dragging the frame drags every owned member. Ids that don't resolve come back under 'missing'. When width/height are omitted the frame is sized to the union of resolvable child bounds + padding + a 30px title band."),
4176
+ stackAfter: z.object({
4177
+ blockId: z.union([z.string(), z.array(z.string())]).describe("Block(s) to stack relative to. String = a single anchor; array = pick whichever is furthest in the stack direction (bottommost for 'down', rightmost for 'right', etc.)."),
4178
+ direction: z.enum(["down", "up", "right", "left"]).optional().describe("Direction (default 'down')"),
4179
+ gap: z.number().int().optional().describe("Gap in px between the anchor and the new block. Default is direction-aware: 80 for left/right, 40 for down/up — mirrors native-flowchart spacing where the flow axis gets more breathing room than the cross axis. Explicit `padding` on the block overrides this default; explicit `gap` wins over both."),
4180
+ }).optional().describe("Layout helper — position this block relative to one or more existing edgeless blocks. Picks the furthest anchor in `direction` for the stack axis, and centers the new block on the anchor group's union on the orthogonal axis (matches how BlockSuite aligns selection-derived blocks; reduces to inherit-anchor-x when widths match). Caller-provided x/y on the orthogonal axis still wins. Works for frame/note/edgeless_text. Example: `stackAfter: { blockId: [f1, f2, f3], gap: 80 }` stacks below whichever column frame ends lowest, centered across all three. Note heights shift at first render (page-root grows with the title, content notes shrink/grow with their children); give extra gap and fix up with `update_edgeless_block` if the down/right chain drifts."),
4181
+ padding: z.number().int().optional().describe("Default padding (px) for `childElementIds` auto-sizing on frames (each side, plus +30px title band) and fallback gap for `stackAfter` (default 40)."),
3922
4182
  sourceId: z.string().optional().describe("Blob source id for image/attachment"),
3923
4183
  name: z.string().optional().describe("Attachment file name"),
3924
4184
  mimeType: z.string().optional().describe("Attachment mime type"),
@@ -4103,7 +4363,7 @@ export function registerDocTools(server, gql, defaults) {
4103
4363
  },
4104
4364
  }, exportWithFidelityReportHandler);
4105
4365
  // Core logic for creating a doc from markdown — returns structured data, no MCP envelope.
4106
- // Used by both createDocFromMarkdownHandler and batchCreateDocsHandler.
4366
+ // Used by createDocFromMarkdownHandler and internal markdown-based flows.
4107
4367
  const createDocFromMarkdownCore = async (parsed) => {
4108
4368
  const parsedMarkdown = parseMarkdownToOperations(parsed.markdown);
4109
4369
  let operations = [...parsedMarkdown.operations];
@@ -4174,46 +4434,53 @@ export function registerDocTools(server, gql, defaults) {
4174
4434
  parentDocId: z.string().optional().describe("If provided, the new doc is automatically embedded into this parent doc as a linked child (visible in sidebar)."),
4175
4435
  },
4176
4436
  }, createDocFromMarkdownHandler);
4177
- // batch_create_docs: create up to 20 docs in one call
4178
- const batchCreateDocsHandler = async (parsed) => {
4437
+ const createDocFromTemplateCore = async (parsed) => {
4179
4438
  const workspaceId = parsed.workspaceId || defaults.workspaceId;
4180
4439
  if (!workspaceId)
4181
4440
  throw new Error("workspaceId is required.");
4182
- if (!Array.isArray(parsed.docs) || parsed.docs.length === 0)
4183
- throw new Error("docs array is required.");
4184
- if (parsed.docs.length > 20)
4185
- throw new Error("Maximum 20 docs per batch.");
4186
- const results = [];
4187
- for (const item of parsed.docs) {
4188
- try {
4189
- const d = await createDocFromMarkdownCore({ workspaceId, title: item.title, markdown: item.markdown, parentDocId: item.parentDocId });
4190
- results.push({
4191
- title: d.title,
4192
- docId: d.docId,
4193
- parentDocId: d.parentDocId,
4194
- linkedToParent: d.linkedToParent,
4195
- warnings: d.warnings ?? [],
4196
- });
4441
+ const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
4442
+ const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
4443
+ const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
4444
+ try {
4445
+ await joinWorkspace(socket, workspaceId);
4446
+ const snap = await loadDoc(socket, workspaceId, parsed.templateDocId);
4447
+ if (!snap.missing)
4448
+ throw new Error(`Template doc ${parsed.templateDocId} not found.`);
4449
+ const doc = new Y.Doc();
4450
+ Y.applyUpdate(doc, Buffer.from(snap.missing, "base64"));
4451
+ const collected = collectDocForMarkdown(doc, new Map());
4452
+ const rendered = renderBlocksToMarkdown({ rootBlockIds: collected.rootBlockIds, blocksById: collected.blocksById });
4453
+ let markdown = rendered.markdown;
4454
+ const vars = parsed.variables ?? {};
4455
+ for (const [key, value] of Object.entries(vars)) {
4456
+ const pattern = new RegExp(`\\{\\{\\s*${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\}}`, "g");
4457
+ markdown = markdown.replace(pattern, value);
4197
4458
  }
4198
- catch (err) {
4199
- results.push({ title: item.title, docId: "", parentDocId: item.parentDocId ?? null, linkedToParent: false, warnings: [`Failed: ${err?.message ?? String(err)}`] });
4459
+ const unfilled = [...markdown.matchAll(/\{\{\s*[\w.-]+\s*\}\}/g)].map(match => match[0]);
4460
+ socket.disconnect();
4461
+ const created = await createDocFromMarkdownCore({ workspaceId, title: parsed.title, markdown, parentDocId: parsed.parentDocId });
4462
+ return {
4463
+ workspaceId,
4464
+ sourceTemplateDocId: parsed.templateDocId,
4465
+ docId: created.docId,
4466
+ title: created.title,
4467
+ parentDocId: created.parentDocId,
4468
+ linkedToParent: created.linkedToParent,
4469
+ cloneMode: "markdown-roundtrip",
4470
+ lossy: Boolean(created.lossy ?? (created.warnings?.length ?? 0) > 0),
4471
+ warnings: created.warnings ?? [],
4472
+ stats: created.stats ?? null,
4473
+ unfilledVariables: unfilled,
4474
+ };
4475
+ }
4476
+ catch (err) {
4477
+ try {
4478
+ socket.disconnect();
4200
4479
  }
4480
+ catch { /* already disconnected */ }
4481
+ throw err;
4201
4482
  }
4202
- const failed = results.filter(r => !r.docId).length;
4203
- return text({ created: results.length - failed, failed, results });
4204
4483
  };
4205
- server.registerTool("batch_create_docs", {
4206
- title: "Batch Create Documents",
4207
- description: "Create multiple AFFiNE documents in a single call. Each doc can optionally be linked to a parent (parentDocId) to appear in the sidebar. Max 20 docs per batch.",
4208
- inputSchema: {
4209
- workspaceId: z.string().optional(),
4210
- docs: z.array(z.object({
4211
- title: z.string().describe("Document title."),
4212
- markdown: z.string().describe("Markdown content."),
4213
- parentDocId: z.string().optional().describe("Parent doc ID — if provided, the new doc is embedded under this parent in the sidebar."),
4214
- })).min(1).max(20).describe("Array of docs to create (max 20)."),
4215
- },
4216
- }, batchCreateDocsHandler);
4217
4484
  const appendMarkdownHandler = async (parsed) => {
4218
4485
  const workspaceId = parsed.workspaceId || defaults.workspaceId;
4219
4486
  if (!workspaceId) {
@@ -4352,194 +4619,6 @@ export function registerDocTools(server, gql, defaults) {
4352
4619
  description: 'Delete a document and remove from workspace list',
4353
4620
  inputSchema: { workspaceId: z.string().optional(), docId: z.string() },
4354
4621
  }, deleteDocHandler);
4355
- // ─── cleanup_orphan_embeds ──────────────────────────────────────────────────
4356
- const cleanupOrphanEmbedsHandler = async (parsed) => {
4357
- const workspaceId = parsed.workspaceId || defaults.workspaceId;
4358
- if (!workspaceId)
4359
- throw new Error("workspaceId is required.");
4360
- const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
4361
- const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
4362
- const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
4363
- try {
4364
- await joinWorkspace(socket, workspaceId);
4365
- const snap = await loadDoc(socket, workspaceId, parsed.docId);
4366
- if (!snap.missing)
4367
- throw new Error(`Doc ${parsed.docId} not found.`);
4368
- const doc = new Y.Doc();
4369
- Y.applyUpdate(doc, Buffer.from(snap.missing, "base64"));
4370
- const blocks = doc.getMap("blocks");
4371
- const orphans = [];
4372
- for (const [blockId, raw] of blocks) {
4373
- if (!(raw instanceof Y.Map))
4374
- continue;
4375
- if (raw.get("sys:flavour") !== "affine:embed-linked-doc")
4376
- continue;
4377
- const targetId = raw.get("prop:pageId");
4378
- if (typeof targetId !== "string" || !targetId) {
4379
- orphans.push({ blockId, targetDocId: targetId ?? "" });
4380
- continue;
4381
- }
4382
- const targetSnap = await loadDoc(socket, workspaceId, targetId);
4383
- if (!targetSnap.missing)
4384
- orphans.push({ blockId, targetDocId: targetId });
4385
- }
4386
- if (parsed.dryRun || orphans.length === 0) {
4387
- return text({ docId: parsed.docId, dryRun: parsed.dryRun ?? false, orphansFound: orphans.length, orphans });
4388
- }
4389
- const prevSV = Y.encodeStateVector(doc);
4390
- for (const { blockId } of orphans) {
4391
- for (const [, parentRaw] of blocks) {
4392
- if (!(parentRaw instanceof Y.Map))
4393
- continue;
4394
- const children = parentRaw.get("sys:children");
4395
- if (!(children instanceof Y.Array))
4396
- continue;
4397
- const ids = childIdsFrom(children);
4398
- const idx = ids.indexOf(blockId);
4399
- if (idx !== -1) {
4400
- children.delete(idx, 1);
4401
- break;
4402
- }
4403
- }
4404
- blocks.delete(blockId);
4405
- }
4406
- const delta = Y.encodeStateAsUpdate(doc, prevSV);
4407
- await pushDocUpdate(socket, workspaceId, parsed.docId, Buffer.from(delta).toString("base64"));
4408
- return text({ docId: parsed.docId, dryRun: false, orphansRemoved: orphans.length, orphans });
4409
- }
4410
- finally {
4411
- socket.disconnect();
4412
- }
4413
- };
4414
- server.registerTool("cleanup_orphan_embeds", {
4415
- title: "Cleanup Orphan Embed Links",
4416
- description: "Remove embed_linked_doc blocks that point to deleted/non-existent docs. Use dryRun=true to preview without making changes.",
4417
- inputSchema: {
4418
- workspaceId: z.string().optional(),
4419
- docId: z.string().describe("The doc to clean up orphan embeds from."),
4420
- dryRun: z.boolean().optional().describe("If true, only report orphans without deleting (default: false)."),
4421
- },
4422
- }, cleanupOrphanEmbedsHandler);
4423
- // ─── find_and_replace ───────────────────────────────────────────────────────
4424
- const findAndReplaceHandler = async (parsed) => {
4425
- const workspaceId = parsed.workspaceId || defaults.workspaceId;
4426
- if (!workspaceId)
4427
- throw new Error("workspaceId is required.");
4428
- const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
4429
- const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
4430
- const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
4431
- try {
4432
- await joinWorkspace(socket, workspaceId);
4433
- const snap = await loadDoc(socket, workspaceId, parsed.docId);
4434
- if (!snap.missing)
4435
- throw new Error(`Doc ${parsed.docId} not found.`);
4436
- const doc = new Y.Doc();
4437
- Y.applyUpdate(doc, Buffer.from(snap.missing, "base64"));
4438
- const blocks = doc.getMap("blocks");
4439
- let totalMatches = 0;
4440
- const matchLog = [];
4441
- const matchAll = parsed.matchAll !== false;
4442
- for (const [blockId, raw] of blocks) {
4443
- if (!(raw instanceof Y.Map))
4444
- continue;
4445
- const flavour = raw.get("sys:flavour");
4446
- for (const [, val] of raw) {
4447
- if (!(val instanceof Y.Text))
4448
- continue;
4449
- const original = val.toString();
4450
- if (!original.includes(parsed.search))
4451
- continue;
4452
- const replaced = matchAll
4453
- ? original.split(parsed.search).join(parsed.replace)
4454
- : original.replace(parsed.search, parsed.replace);
4455
- const count = matchAll ? original.split(parsed.search).length - 1 : 1;
4456
- totalMatches += count;
4457
- matchLog.push({ blockId, flavour: flavour ?? "unknown", original, replaced });
4458
- if (!parsed.dryRun) {
4459
- const prevSV = Y.encodeStateVector(doc);
4460
- val.delete(0, val.length);
4461
- val.insert(0, replaced);
4462
- const delta = Y.encodeStateAsUpdate(doc, prevSV);
4463
- await pushDocUpdate(socket, workspaceId, parsed.docId, Buffer.from(delta).toString("base64"));
4464
- }
4465
- }
4466
- }
4467
- return text({
4468
- docId: parsed.docId, search: parsed.search, replace: parsed.replace,
4469
- dryRun: parsed.dryRun ?? false, totalMatches, blocksAffected: matchLog.length, matches: matchLog,
4470
- });
4471
- }
4472
- finally {
4473
- socket.disconnect();
4474
- }
4475
- };
4476
- server.registerTool("find_and_replace", {
4477
- title: "Find and Replace in Document",
4478
- description: "Find and replace text across all Y.Text fields in a document (paragraphs, headings, titles). matchAll defaults to true. Use dryRun=true to preview before applying.",
4479
- inputSchema: {
4480
- workspaceId: z.string().optional(),
4481
- docId: z.string().describe("The doc to search in."),
4482
- search: z.string().min(1).describe("Text to find (must not be empty)."),
4483
- replace: z.string().describe("Replacement text."),
4484
- matchAll: z.boolean().optional().describe("Replace all occurrences (default: true)."),
4485
- dryRun: z.boolean().optional().describe("If true, only report matches without replacing (default: false)."),
4486
- },
4487
- }, findAndReplaceHandler);
4488
- // ─── get_docs_by_tag ────────────────────────────────────────────────────────
4489
- const getDocsByTagHandler = async (parsed) => {
4490
- const workspaceId = parsed.workspaceId || defaults.workspaceId;
4491
- if (!workspaceId)
4492
- throw new Error("workspaceId is required.");
4493
- const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
4494
- const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
4495
- const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
4496
- try {
4497
- await joinWorkspace(socket, workspaceId);
4498
- const wsSnap = await loadDoc(socket, workspaceId, workspaceId);
4499
- if (!wsSnap.missing)
4500
- return text({ tag: parsed.tag, count: 0, docs: [] });
4501
- const wsDoc = new Y.Doc();
4502
- Y.applyUpdate(wsDoc, Buffer.from(wsSnap.missing, "base64"));
4503
- const meta = wsDoc.getMap("meta");
4504
- const { byId, options } = getWorkspaceTagOptionMaps(meta);
4505
- const q = parsed.tag.toLowerCase();
4506
- const matchingTagIds = new Set(options.filter(o => o.value.toLowerCase().includes(q)).map(o => o.id));
4507
- if (matchingTagIds.size === 0) {
4508
- return text({
4509
- tag: parsed.tag,
4510
- count: 0,
4511
- docs: [],
4512
- availableTags: options.map(o => o.value),
4513
- });
4514
- }
4515
- const pages = getWorkspacePageEntries(meta);
4516
- const baseUrl = (process.env.AFFINE_BASE_URL || endpoint.replace(/\/graphql\/?$/, '')).replace(/\/$/, '');
4517
- const matched = pages
4518
- .map(p => {
4519
- const rawTagIds = getStringArray(p.tagsArray);
4520
- return { p, rawTagIds };
4521
- })
4522
- .filter(({ rawTagIds }) => rawTagIds.some(tid => matchingTagIds.has(tid)))
4523
- .map(({ p, rawTagIds }) => ({
4524
- docId: p.id,
4525
- title: p.title ?? "Untitled",
4526
- tags: resolveTagLabels(rawTagIds, byId),
4527
- url: `${baseUrl}/workspace/${workspaceId}/${p.id}`,
4528
- }));
4529
- return text({ tag: parsed.tag, count: matched.length, docs: matched });
4530
- }
4531
- finally {
4532
- socket.disconnect();
4533
- }
4534
- };
4535
- server.registerTool("get_docs_by_tag", {
4536
- title: "Get Documents by Tag",
4537
- description: "Filter documents by tag name (case-insensitive substring match). Returns matching docs with their full tag list. If no match, also returns availableTags for discoverability.",
4538
- inputSchema: {
4539
- workspaceId: z.string().optional(),
4540
- tag: z.string().describe("Tag name to filter by (substring match, case-insensitive)."),
4541
- },
4542
- }, getDocsByTagHandler);
4543
4622
  // ─── list_workspace_tree ────────────────────────────────────────────────────
4544
4623
  const listWorkspaceTreeHandler = async (parsed) => {
4545
4624
  const workspaceId = parsed.workspaceId || defaults.workspaceId;
@@ -4774,260 +4853,49 @@ export function registerDocTools(server, gql, defaults) {
4774
4853
  title: z.string().describe("New title."),
4775
4854
  },
4776
4855
  }, updateDocTitleHandler);
4777
- // ─── get_doc_by_title ────────────────────────────────────────────────────────
4778
- const getDocByTitleHandler = async (parsed) => {
4779
- const workspaceId = parsed.workspaceId || defaults.workspaceId;
4780
- if (!workspaceId)
4781
- throw new Error("workspaceId is required.");
4856
+ async function syncRawTagsToDoc(parsed) {
4857
+ if (parsed.tags.length === 0) {
4858
+ return;
4859
+ }
4782
4860
  const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
4783
4861
  const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
4784
4862
  const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
4785
4863
  try {
4786
- await joinWorkspace(socket, workspaceId);
4787
- const wsSnap = await loadDoc(socket, workspaceId, workspaceId);
4788
- if (!wsSnap.missing)
4789
- return text({ query: parsed.query, found: false, results: [] });
4864
+ await joinWorkspace(socket, parsed.workspaceId);
4865
+ const workspaceSnapshot = await loadDoc(socket, parsed.workspaceId, parsed.workspaceId);
4866
+ if (!workspaceSnapshot.missing) {
4867
+ throw new Error(`Workspace root document not found for workspace ${parsed.workspaceId}`);
4868
+ }
4790
4869
  const wsDoc = new Y.Doc();
4791
- Y.applyUpdate(wsDoc, Buffer.from(wsSnap.missing, "base64"));
4792
- const q = parsed.query.toLowerCase();
4793
- const limit = parsed.limit ?? 1;
4794
- const matches = getWorkspacePageEntries(wsDoc.getMap("meta"))
4795
- .filter(p => p.title && p.title.toLowerCase().includes(q)).slice(0, limit);
4796
- if (matches.length === 0)
4797
- return text({ query: parsed.query, found: false, results: [] });
4798
- const results = [];
4799
- for (const match of matches) {
4800
- const snap = await loadDoc(socket, workspaceId, match.id);
4801
- if (!snap.missing) {
4802
- results.push({ docId: match.id, title: match.title, found: false });
4803
- continue;
4804
- }
4805
- const doc = new Y.Doc();
4806
- Y.applyUpdate(doc, Buffer.from(snap.missing, "base64"));
4807
- const collected = collectDocForMarkdown(doc, new Map());
4808
- const rendered = renderBlocksToMarkdown({ rootBlockIds: collected.rootBlockIds, blocksById: collected.blocksById });
4809
- results.push({ docId: match.id, title: match.title, found: true, markdown: rendered.markdown,
4810
- url: `${(process.env.AFFINE_BASE_URL || endpoint.replace(/\/graphql\/?$/, '')).replace(/\/$/, '')}/workspace/${workspaceId}/${match.id}` });
4870
+ Y.applyUpdate(wsDoc, Buffer.from(workspaceSnapshot.missing, "base64"));
4871
+ const wsPrevSV = Y.encodeStateVector(wsDoc);
4872
+ const wsMeta = wsDoc.getMap("meta");
4873
+ const page = getWorkspacePageEntries(wsMeta).find(entry => entry.id === parsed.docId);
4874
+ if (!page) {
4875
+ throw new Error(`docId ${parsed.docId} is not present in workspace ${parsed.workspaceId}`);
4876
+ }
4877
+ const pageTags = ensureTagArray(page.entry);
4878
+ pageTags.delete(0, pageTags.length);
4879
+ for (const tag of parsed.tags) {
4880
+ pageTags.push([tag]);
4881
+ }
4882
+ const wsDelta = Y.encodeStateAsUpdate(wsDoc, wsPrevSV);
4883
+ await pushDocUpdate(socket, parsed.workspaceId, parsed.workspaceId, Buffer.from(wsDelta).toString("base64"));
4884
+ const docSnapshot = await loadDoc(socket, parsed.workspaceId, parsed.docId);
4885
+ if (!docSnapshot.missing) {
4886
+ throw new Error(`Document ${parsed.docId} not found in workspace ${parsed.workspaceId}`);
4811
4887
  }
4812
- return text({ query: parsed.query, found: results.some(r => r.found), results });
4813
- }
4814
- finally {
4815
- socket.disconnect();
4816
- }
4817
- };
4818
- server.registerTool("get_doc_by_title", {
4819
- title: "Get Document by Title",
4820
- description: "Find a document by title and return its content as markdown in a single call. Combines search_docs + export_doc_markdown. Returns the first match by default; use limit for multiple.",
4821
- inputSchema: {
4822
- workspaceId: z.string().optional(),
4823
- query: z.string().describe("Title search query (case-insensitive substring match)."),
4824
- limit: z.number().optional().describe("Max docs to return with content (default: 1)."),
4825
- },
4826
- }, getDocByTitleHandler);
4827
- // ─── list_backlinks ──────────────────────────────────────────────────────────
4828
- const listBacklinksHandler = async (parsed) => {
4829
- const workspaceId = parsed.workspaceId || defaults.workspaceId;
4830
- if (!workspaceId)
4831
- throw new Error("workspaceId is required.");
4832
- const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
4833
- const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
4834
- const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
4835
- try {
4836
- await joinWorkspace(socket, workspaceId);
4837
- const wsSnap = await loadDoc(socket, workspaceId, workspaceId);
4838
- if (!wsSnap.missing)
4839
- return text({ docId: parsed.docId, count: 0, backlinks: [] });
4840
- const wsDoc = new Y.Doc();
4841
- Y.applyUpdate(wsDoc, Buffer.from(wsSnap.missing, "base64"));
4842
- const pages = getWorkspacePageEntries(wsDoc.getMap("meta"));
4843
- const titleById = new Map(pages.map(p => [p.id, p.title]));
4844
- const backlinks = [];
4845
- for (const page of pages) {
4846
- if (page.id === parsed.docId)
4847
- continue;
4848
- const snap = await loadDoc(socket, workspaceId, page.id);
4849
- if (!snap.missing)
4850
- continue;
4851
- const doc = new Y.Doc();
4852
- Y.applyUpdate(doc, Buffer.from(snap.missing, "base64"));
4853
- const blocks = doc.getMap("blocks");
4854
- for (const [, raw] of blocks) {
4855
- if (!(raw instanceof Y.Map))
4856
- continue;
4857
- if (raw.get("sys:flavour") === "affine:embed-linked-doc" && raw.get("prop:pageId") === parsed.docId) {
4858
- backlinks.push({ docId: page.id, title: titleById.get(page.id) ?? null,
4859
- url: `${(process.env.AFFINE_BASE_URL || endpoint.replace(/\/graphql\/?$/, '')).replace(/\/$/, '')}/workspace/${workspaceId}/${page.id}` });
4860
- break;
4861
- }
4862
- }
4863
- }
4864
- return text({ docId: parsed.docId, count: backlinks.length, backlinks });
4865
- }
4866
- finally {
4867
- socket.disconnect();
4868
- }
4869
- };
4870
- server.registerTool("list_backlinks", {
4871
- title: "List Document Backlinks",
4872
- description: "Find all documents that embed-link to a given doc (its parents/references in the sidebar). Scans all docs — may be slow on large workspaces.",
4873
- inputSchema: {
4874
- workspaceId: z.string().optional(),
4875
- docId: z.string().describe("The doc to find backlinks for."),
4876
- },
4877
- }, listBacklinksHandler);
4878
- // ─── duplicate_doc ───────────────────────────────────────────────────────────
4879
- const duplicateDocHandler = async (parsed) => {
4880
- const workspaceId = parsed.workspaceId || defaults.workspaceId;
4881
- if (!workspaceId)
4882
- throw new Error("workspaceId is required.");
4883
- const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
4884
- const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
4885
- const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
4886
- try {
4887
- await joinWorkspace(socket, workspaceId);
4888
- const snap = await loadDoc(socket, workspaceId, parsed.docId);
4889
- if (!snap.missing)
4890
- throw new Error(`Doc ${parsed.docId} not found.`);
4891
- const doc = new Y.Doc();
4892
- Y.applyUpdate(doc, Buffer.from(snap.missing, "base64"));
4893
- const collected = collectDocForMarkdown(doc, new Map());
4894
- const rendered = renderBlocksToMarkdown({ rootBlockIds: collected.rootBlockIds, blocksById: collected.blocksById });
4895
- const newTitle = (parsed.title ?? `${collected.title || "Untitled"} (copy)`).trim();
4896
- socket.disconnect();
4897
- const created = await createDocFromMarkdownCore({ workspaceId, title: newTitle, markdown: rendered.markdown, parentDocId: parsed.parentDocId });
4898
- return receipt("doc.duplicate", {
4899
- workspaceId,
4900
- sourceDocId: parsed.docId,
4901
- docId: created.docId,
4902
- title: created.title,
4903
- parentDocId: created.parentDocId,
4904
- linkedToParent: created.linkedToParent,
4905
- cloneMode: "markdown-roundtrip",
4906
- lossy: Boolean(created.lossy ?? (created.warnings?.length ?? 0) > 0),
4907
- warnings: created.warnings ?? [],
4908
- stats: created.stats ?? null,
4909
- });
4910
- }
4911
- catch (err) {
4912
- try {
4913
- socket.disconnect();
4914
- }
4915
- catch { /* already disconnected */ }
4916
- throw err;
4917
- }
4918
- };
4919
- server.registerTool("duplicate_doc", {
4920
- title: "Duplicate Document",
4921
- description: "Clone a document by copying its markdown content into a new doc. Optionally set a new title and/or parentDocId to place it in the sidebar.",
4922
- inputSchema: {
4923
- workspaceId: z.string().optional(),
4924
- docId: z.string().describe("The source doc to duplicate."),
4925
- title: z.string().optional().describe("Title for the new doc. Defaults to '<original title> (copy)'."),
4926
- parentDocId: z.string().optional().describe("Parent doc to link the new doc under in the sidebar."),
4927
- },
4928
- }, duplicateDocHandler);
4929
- // ─── create_doc_from_template ───────────────────────────────────────────────
4930
- const createDocFromTemplateHandler = async (parsed) => {
4931
- const workspaceId = parsed.workspaceId || defaults.workspaceId;
4932
- if (!workspaceId)
4933
- throw new Error("workspaceId is required.");
4934
- const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
4935
- const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
4936
- const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
4937
- try {
4938
- await joinWorkspace(socket, workspaceId);
4939
- const snap = await loadDoc(socket, workspaceId, parsed.templateDocId);
4940
- if (!snap.missing)
4941
- throw new Error(`Template doc ${parsed.templateDocId} not found.`);
4942
- const doc = new Y.Doc();
4943
- Y.applyUpdate(doc, Buffer.from(snap.missing, "base64"));
4944
- const collected = collectDocForMarkdown(doc, new Map());
4945
- const rendered = renderBlocksToMarkdown({ rootBlockIds: collected.rootBlockIds, blocksById: collected.blocksById });
4946
- let markdown = rendered.markdown;
4947
- const vars = parsed.variables ?? {};
4948
- for (const [key, value] of Object.entries(vars)) {
4949
- const pattern = new RegExp(`\\{\\{\\s*${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\}}`, "g");
4950
- markdown = markdown.replace(pattern, value);
4951
- }
4952
- const unfilled = [...markdown.matchAll(/\{\{\s*[\w.-]+\s*\}\}/g)].map(match => match[0]);
4953
- socket.disconnect();
4954
- const created = await createDocFromMarkdownCore({ workspaceId, title: parsed.title, markdown, parentDocId: parsed.parentDocId });
4955
- return receipt("doc.create_from_template", {
4956
- workspaceId,
4957
- sourceTemplateDocId: parsed.templateDocId,
4958
- docId: created.docId,
4959
- title: created.title,
4960
- parentDocId: created.parentDocId,
4961
- linkedToParent: created.linkedToParent,
4962
- cloneMode: "markdown-roundtrip",
4963
- lossy: Boolean(created.lossy ?? (created.warnings?.length ?? 0) > 0),
4964
- warnings: created.warnings ?? [],
4965
- stats: created.stats ?? null,
4966
- unfilledVariables: unfilled,
4967
- });
4968
- }
4969
- catch (err) {
4970
- try {
4971
- socket.disconnect();
4972
- }
4973
- catch { /* already disconnected */ }
4974
- throw err;
4975
- }
4976
- };
4977
- server.registerTool("create_doc_from_template", {
4978
- title: "Create Document from Template",
4979
- description: "Clone a template doc and substitute {{variable}} placeholders. Returns a warning for any unfilled variables. Optionally link to a parent doc in the sidebar.",
4980
- inputSchema: {
4981
- workspaceId: z.string().optional(),
4982
- templateDocId: z.string().describe("The template doc to clone from."),
4983
- title: z.string().describe("Title for the new doc."),
4984
- variables: z.record(z.string(), z.string()).optional().describe("Key-value map of {{variable}} substitutions."),
4985
- parentDocId: z.string().optional().describe("Parent doc to link the new doc under in the sidebar."),
4986
- },
4987
- }, createDocFromTemplateHandler);
4988
- async function syncRawTagsToDoc(parsed) {
4989
- if (parsed.tags.length === 0) {
4990
- return;
4991
- }
4992
- const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
4993
- const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
4994
- const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
4995
- try {
4996
- await joinWorkspace(socket, parsed.workspaceId);
4997
- const workspaceSnapshot = await loadDoc(socket, parsed.workspaceId, parsed.workspaceId);
4998
- if (!workspaceSnapshot.missing) {
4999
- throw new Error(`Workspace root document not found for workspace ${parsed.workspaceId}`);
5000
- }
5001
- const wsDoc = new Y.Doc();
5002
- Y.applyUpdate(wsDoc, Buffer.from(workspaceSnapshot.missing, "base64"));
5003
- const wsPrevSV = Y.encodeStateVector(wsDoc);
5004
- const wsMeta = wsDoc.getMap("meta");
5005
- const page = getWorkspacePageEntries(wsMeta).find(entry => entry.id === parsed.docId);
5006
- if (!page) {
5007
- throw new Error(`docId ${parsed.docId} is not present in workspace ${parsed.workspaceId}`);
5008
- }
5009
- const pageTags = ensureTagArray(page.entry);
5010
- pageTags.delete(0, pageTags.length);
5011
- for (const tag of parsed.tags) {
5012
- pageTags.push([tag]);
5013
- }
5014
- const wsDelta = Y.encodeStateAsUpdate(wsDoc, wsPrevSV);
5015
- await pushDocUpdate(socket, parsed.workspaceId, parsed.workspaceId, Buffer.from(wsDelta).toString("base64"));
5016
- const docSnapshot = await loadDoc(socket, parsed.workspaceId, parsed.docId);
5017
- if (!docSnapshot.missing) {
5018
- throw new Error(`Document ${parsed.docId} not found in workspace ${parsed.workspaceId}`);
5019
- }
5020
- const doc = new Y.Doc();
5021
- Y.applyUpdate(doc, Buffer.from(docSnapshot.missing, "base64"));
5022
- const docPrevSV = Y.encodeStateVector(doc);
5023
- const docMeta = doc.getMap("meta");
5024
- const docTags = ensureTagArray(docMeta);
5025
- docTags.delete(0, docTags.length);
5026
- for (const tag of parsed.tags) {
5027
- docTags.push([tag]);
5028
- }
5029
- const docDelta = Y.encodeStateAsUpdate(doc, docPrevSV);
5030
- await pushDocUpdate(socket, parsed.workspaceId, parsed.docId, Buffer.from(docDelta).toString("base64"));
4888
+ const doc = new Y.Doc();
4889
+ Y.applyUpdate(doc, Buffer.from(docSnapshot.missing, "base64"));
4890
+ const docPrevSV = Y.encodeStateVector(doc);
4891
+ const docMeta = doc.getMap("meta");
4892
+ const docTags = ensureTagArray(docMeta);
4893
+ docTags.delete(0, docTags.length);
4894
+ for (const tag of parsed.tags) {
4895
+ docTags.push([tag]);
4896
+ }
4897
+ const docDelta = Y.encodeStateAsUpdate(doc, docPrevSV);
4898
+ await pushDocUpdate(socket, parsed.workspaceId, parsed.docId, Buffer.from(docDelta).toString("base64"));
5031
4899
  }
5032
4900
  finally {
5033
4901
  socket.disconnect();
@@ -5120,14 +4988,13 @@ export function registerDocTools(server, gql, defaults) {
5120
4988
  if (!allowFallback) {
5121
4989
  throw new Error(`Native template instantiation is not supported: ${nativeSummary.fallbackReasons.join(" | ")}`);
5122
4990
  }
5123
- const fallbackResult = await createDocFromTemplateHandler({
4991
+ const created = await createDocFromTemplateCore({
5124
4992
  workspaceId,
5125
4993
  templateDocId: parsed.templateDocId,
5126
4994
  title: targetTitle,
5127
4995
  variables: parsed.variables,
5128
4996
  parentDocId: parsed.parentDocId,
5129
4997
  });
5130
- const created = JSON.parse(fallbackResult.content[0].text);
5131
4998
  return text({
5132
4999
  ...created,
5133
5000
  mode: "markdown_fallback",
@@ -5930,56 +5797,6 @@ export function registerDocTools(server, gql, defaults) {
5930
5797
  databaseBlockId: z.string().min(1).describe("Block ID of the affine:database block"),
5931
5798
  },
5932
5799
  }, readDatabaseColumnsHandler);
5933
- const updateDatabaseCellHandler = async (parsed) => {
5934
- const workspaceId = parsed.workspaceId || defaults.workspaceId;
5935
- if (!workspaceId)
5936
- throw new Error("workspaceId is required");
5937
- const ctx = await loadDatabaseDocContext(workspaceId, parsed.docId, parsed.databaseBlockId);
5938
- try {
5939
- const rowBlock = getDatabaseRowBlock(ctx.blocks, ctx.dbBlock, parsed.databaseBlockId, parsed.rowBlockId);
5940
- const rowCells = ensureDatabaseRowCells(ctx.cellsMap, parsed.rowBlockId);
5941
- const col = findDatabaseColumn(parsed.column, ctx);
5942
- if (!col) {
5943
- if (!isTitleAliasKey(parsed.column)) {
5944
- throw new Error(`Column '${parsed.column}' not found. Available columns: ${availableDatabaseColumns(ctx)}`);
5945
- }
5946
- }
5947
- else {
5948
- writeDatabaseCellValue(rowCells, col, parsed.value, parsed.createOption ?? true);
5949
- }
5950
- if (parsed.linkedDocId) {
5951
- rowBlock.set("prop:text", makeLinkedDocText(parsed.linkedDocId));
5952
- }
5953
- else if (isTitleAliasKey(parsed.column) || (col && (col.type === "title" || isTitleAliasKey(col.name)))) {
5954
- rowBlock.set("prop:text", makeText(String(parsed.value ?? "")));
5955
- }
5956
- const delta = Y.encodeStateAsUpdate(ctx.doc, ctx.prevSV);
5957
- await pushDocUpdate(ctx.socket, workspaceId, parsed.docId, Buffer.from(delta).toString("base64"));
5958
- return text({
5959
- updated: true,
5960
- rowBlockId: parsed.rowBlockId,
5961
- column: parsed.column,
5962
- value: parsed.value ?? null,
5963
- });
5964
- }
5965
- finally {
5966
- ctx.socket.disconnect();
5967
- }
5968
- };
5969
- server.registerTool("update_database_cell", {
5970
- title: "Update Database Cell",
5971
- description: "Update a single cell on an existing AFFiNE database row. Use `title` to update the row title shown in Kanban card headers.",
5972
- inputSchema: {
5973
- workspaceId: z.string().optional().describe("Workspace ID (optional if default set)"),
5974
- docId: DocId.describe("Document ID containing the database"),
5975
- databaseBlockId: z.string().min(1).describe("Block ID of the affine:database block"),
5976
- rowBlockId: z.string().min(1).describe("Row paragraph block ID"),
5977
- column: z.string().min(1).describe("Column name or ID. Use `title` for the built-in row title."),
5978
- value: z.unknown().describe("New cell value"),
5979
- createOption: z.boolean().optional().describe("For select and multi-select columns, create the option label if it does not exist (default true)"),
5980
- linkedDocId: z.string().optional().describe("Link this row to an existing doc by ID. Replaces any existing title with a linked doc reference."),
5981
- },
5982
- }, updateDatabaseCellHandler);
5983
5800
  const updateDatabaseRowHandler = async (parsed) => {
5984
5801
  const workspaceId = parsed.workspaceId || defaults.workspaceId;
5985
5802
  if (!workspaceId)
@@ -6231,4 +6048,1256 @@ export function registerDocTools(server, gql, defaults) {
6231
6048
  width: z.number().optional().describe("Column width in pixels (default 200)"),
6232
6049
  },
6233
6050
  }, addDatabaseColumnHandler);
6051
+ function resolveSurfaceNewlines(s) {
6052
+ return s.replace(/\\n/g, "\n");
6053
+ }
6054
+ function getSurfaceElementsValueMap(blocks, options) {
6055
+ let surfaceId = findBlockIdByFlavour(blocks, "affine:surface");
6056
+ if (!surfaceId) {
6057
+ if (!options.create)
6058
+ return null;
6059
+ surfaceId = ensureSurfaceBlock(blocks);
6060
+ }
6061
+ const surface = blocks.get(surfaceId);
6062
+ let elements = surface.get("prop:elements");
6063
+ if (!(elements instanceof Y.Map)) {
6064
+ if (!options.create)
6065
+ return null;
6066
+ elements = new Y.Map();
6067
+ elements.set("type", "$blocksuite:internal:native$");
6068
+ elements.set("value", new Y.Map());
6069
+ surface.set("prop:elements", elements);
6070
+ }
6071
+ let value = elements.get("value");
6072
+ if (!(value instanceof Y.Map)) {
6073
+ if (!options.create)
6074
+ return null;
6075
+ value = new Y.Map();
6076
+ elements.set("value", value);
6077
+ }
6078
+ return { surfaceId, value };
6079
+ }
6080
+ function serializeSurfaceElement(elementId, el) {
6081
+ const out = { id: elementId };
6082
+ for (const [k, v] of el.entries()) {
6083
+ if (v instanceof Y.Text) {
6084
+ out[k] = v.toString();
6085
+ }
6086
+ else if (v instanceof Y.Map) {
6087
+ out[k] = v.toJSON();
6088
+ }
6089
+ else if (v instanceof Y.Array) {
6090
+ out[k] = v.toArray();
6091
+ }
6092
+ else {
6093
+ out[k] = v;
6094
+ }
6095
+ }
6096
+ const xywh = parseXywhString(out.xywh);
6097
+ if (xywh)
6098
+ out.bounds = xywh;
6099
+ return out;
6100
+ }
6101
+ function buildShapeElementData(elementId, seed, index, input) {
6102
+ const x = input.x ?? 0;
6103
+ const y = input.y ?? 0;
6104
+ const w = input.width ?? 100;
6105
+ const h = input.height ?? 100;
6106
+ const data = {
6107
+ type: "shape",
6108
+ id: elementId,
6109
+ index,
6110
+ seed,
6111
+ xywh: formatXywhString(x, y, w, h),
6112
+ rotate: 0,
6113
+ shapeType: input.shapeType ?? "rect",
6114
+ shapeStyle: "General",
6115
+ radius: input.radius ?? 0,
6116
+ filled: input.filled ?? true,
6117
+ fillColor: input.fillColor ?? "--affine-palette-shape-yellow",
6118
+ strokeWidth: input.strokeWidth ?? 2,
6119
+ strokeColor: input.strokeColor ?? "--affine-palette-line-yellow",
6120
+ strokeStyle: input.strokeStyle ?? "solid",
6121
+ roughness: 1.4,
6122
+ // Fixed #000000 matches BlockSuite's shape tool: shape fills don't
6123
+ // theme-adapt, so label color stays pinned too. Override for dark fills.
6124
+ color: input.color ?? "#000000",
6125
+ fontFamily: "blocksuite:surface:Inter",
6126
+ fontSize: input.fontSize ?? 20,
6127
+ fontStyle: "normal",
6128
+ fontWeight: input.fontWeight ?? "600",
6129
+ textAlign: "center",
6130
+ textHorizontalAlign: "center",
6131
+ textVerticalAlign: "center",
6132
+ textResizing: 1,
6133
+ maxWidth: false,
6134
+ padding: [10, 20],
6135
+ shadow: null,
6136
+ };
6137
+ if (input.text) {
6138
+ const yText = new Y.Text();
6139
+ yText.insert(0, resolveSurfaceNewlines(input.text));
6140
+ data.text = yText;
6141
+ }
6142
+ return data;
6143
+ }
6144
+ function resolveConnectorEndpointBoundAsBound(surfaceValueMap, blocks, endpointId) {
6145
+ if (!endpointId)
6146
+ return null;
6147
+ const surfaceEl = surfaceValueMap.get(endpointId);
6148
+ if (surfaceEl instanceof Y.Map) {
6149
+ const xywh = parseXywhString(surfaceEl.get("xywh"));
6150
+ if (xywh)
6151
+ return { x: xywh.x, y: xywh.y, w: xywh.width, h: xywh.height };
6152
+ }
6153
+ const block = blocks.get(endpointId);
6154
+ if (block instanceof Y.Map) {
6155
+ const xywh = parseXywhString(block.get("prop:xywh"));
6156
+ if (xywh)
6157
+ return { x: xywh.x, y: xywh.y, w: xywh.width, h: xywh.height };
6158
+ }
6159
+ return null;
6160
+ }
6161
+ function resolveChildBound(surfaceValueMap, blocks, id) {
6162
+ const surfaceEl = surfaceValueMap.get(id);
6163
+ if (surfaceEl instanceof Y.Map) {
6164
+ const xywh = parseXywhString(surfaceEl.get("xywh"));
6165
+ return {
6166
+ kind: "surface",
6167
+ bound: xywh ? { x: xywh.x, y: xywh.y, w: xywh.width, h: xywh.height } : null,
6168
+ };
6169
+ }
6170
+ const block = blocks.get(id);
6171
+ if (block instanceof Y.Map) {
6172
+ const xywh = parseXywhString(block.get("prop:xywh"));
6173
+ return {
6174
+ kind: "block",
6175
+ bound: xywh ? { x: xywh.x, y: xywh.y, w: xywh.width, h: xywh.height } : null,
6176
+ };
6177
+ }
6178
+ return { kind: "missing", bound: null };
6179
+ }
6180
+ function resolveConnectorEndpointCenter(surfaceValueMap, blocks, endpointId, endpointPosition) {
6181
+ if (endpointPosition && Array.isArray(endpointPosition) && endpointPosition.length === 2) {
6182
+ return { x: endpointPosition[0], y: endpointPosition[1] };
6183
+ }
6184
+ if (!endpointId)
6185
+ return null;
6186
+ const surfaceEl = surfaceValueMap.get(endpointId);
6187
+ if (surfaceEl instanceof Y.Map) {
6188
+ const xywh = parseXywhString(surfaceEl.get("xywh"));
6189
+ if (xywh)
6190
+ return { x: xywh.x + xywh.width / 2, y: xywh.y + xywh.height / 2 };
6191
+ }
6192
+ const block = blocks.get(endpointId);
6193
+ if (block instanceof Y.Map) {
6194
+ const xywh = parseXywhString(block.get("prop:xywh"));
6195
+ if (xywh)
6196
+ return { x: xywh.x + xywh.width / 2, y: xywh.y + xywh.height / 2 };
6197
+ }
6198
+ return null;
6199
+ }
6200
+ function buildConnectorElementData(elementId, seed, index, input) {
6201
+ const source = {};
6202
+ if (input.sourceId) {
6203
+ source.id = input.sourceId;
6204
+ if (input.sourcePosition)
6205
+ source.position = input.sourcePosition;
6206
+ }
6207
+ else if (input.sourcePosition) {
6208
+ source.position = input.sourcePosition;
6209
+ }
6210
+ const target = {};
6211
+ if (input.targetId) {
6212
+ target.id = input.targetId;
6213
+ if (input.targetPosition)
6214
+ target.position = input.targetPosition;
6215
+ }
6216
+ else if (input.targetPosition) {
6217
+ target.position = input.targetPosition;
6218
+ }
6219
+ const data = {
6220
+ type: "connector",
6221
+ id: elementId,
6222
+ index,
6223
+ seed,
6224
+ mode: input.mode ?? 2,
6225
+ // Theme-adaptive token so connectors stay legible in dark mode.
6226
+ stroke: input.stroke ?? "--affine-text-primary-color",
6227
+ strokeWidth: input.strokeWidth ?? 2,
6228
+ strokeStyle: input.strokeStyle ?? "solid",
6229
+ roughness: 1.4,
6230
+ frontEndpointStyle: input.frontEndpointStyle ?? "None",
6231
+ rearEndpointStyle: input.rearEndpointStyle ?? "Arrow",
6232
+ source,
6233
+ target,
6234
+ labelDisplay: true,
6235
+ labelOffset: { distance: 0.5, anchor: "center" },
6236
+ labelStyle: {
6237
+ color: "--affine-text-primary-color",
6238
+ fontFamily: "blocksuite:surface:Inter",
6239
+ fontSize: 16,
6240
+ fontStyle: "normal",
6241
+ fontWeight: "400",
6242
+ textAlign: "center",
6243
+ },
6244
+ labelConstraints: { hasMaxWidth: true, maxWidth: 280 },
6245
+ };
6246
+ if (input.label) {
6247
+ const yText = new Y.Text();
6248
+ yText.insert(0, resolveSurfaceNewlines(input.label));
6249
+ data.text = yText;
6250
+ }
6251
+ return data;
6252
+ }
6253
+ function buildTextElementData(elementId, seed, index, input) {
6254
+ const x = input.x ?? 0;
6255
+ const y = input.y ?? 0;
6256
+ const w = input.width ?? 200;
6257
+ const h = input.height ?? 30;
6258
+ const yText = new Y.Text();
6259
+ if (input.text)
6260
+ yText.insert(0, resolveSurfaceNewlines(input.text));
6261
+ return {
6262
+ type: "text",
6263
+ id: elementId,
6264
+ index,
6265
+ seed,
6266
+ xywh: formatXywhString(x, y, w, h),
6267
+ rotate: 0,
6268
+ text: yText,
6269
+ color: input.color ?? "--affine-text-primary-color",
6270
+ fontFamily: "blocksuite:surface:Inter",
6271
+ fontSize: input.fontSize ?? 16,
6272
+ fontStyle: "normal",
6273
+ fontWeight: input.fontWeight ?? "400",
6274
+ textAlign: "center",
6275
+ hasMaxWidth: false,
6276
+ };
6277
+ }
6278
+ function buildGroupElementData(elementId, seed, index, input) {
6279
+ const childMap = new Y.Map();
6280
+ for (const childId of input.children ?? []) {
6281
+ childMap.set(childId, true);
6282
+ }
6283
+ const yTitle = new Y.Text();
6284
+ if (input.title)
6285
+ yTitle.insert(0, input.title);
6286
+ return {
6287
+ type: "group",
6288
+ id: elementId,
6289
+ index,
6290
+ seed,
6291
+ children: childMap,
6292
+ title: yTitle,
6293
+ };
6294
+ }
6295
+ function nextSurfaceElementIndex(valueMap) {
6296
+ let maxIndex = null;
6297
+ for (const [, el] of valueMap.entries()) {
6298
+ if (!(el instanceof Y.Map))
6299
+ continue;
6300
+ const idx = el.get("index");
6301
+ if (typeof idx !== "string")
6302
+ continue;
6303
+ if (maxIndex === null || idx > maxIndex)
6304
+ maxIndex = idx;
6305
+ }
6306
+ return generateKeyBetween(maxIndex, null);
6307
+ }
6308
+ function buildSurfaceElementData(type, index, input) {
6309
+ const elementId = generateId();
6310
+ const seed = Math.floor(Math.random() * 2 ** 31);
6311
+ switch (type) {
6312
+ case "shape":
6313
+ return { elementId, data: buildShapeElementData(elementId, seed, index, input) };
6314
+ case "connector":
6315
+ return { elementId, data: buildConnectorElementData(elementId, seed, index, input) };
6316
+ case "text":
6317
+ return { elementId, data: buildTextElementData(elementId, seed, index, input) };
6318
+ case "group":
6319
+ return { elementId, data: buildGroupElementData(elementId, seed, index, input) };
6320
+ }
6321
+ }
6322
+ function writeSurfaceElement(valueMap, elementId, data) {
6323
+ const el = new Y.Map();
6324
+ for (const [k, v] of Object.entries(data)) {
6325
+ el.set(k, v);
6326
+ }
6327
+ valueMap.set(elementId, el);
6328
+ }
6329
+ const addSurfaceElementHandler = async (params) => {
6330
+ const workspaceId = params.workspaceId || defaults.workspaceId;
6331
+ if (!workspaceId) {
6332
+ throw new Error("workspaceId is required. Provide it as a parameter or set AFFINE_WORKSPACE_ID in environment.");
6333
+ }
6334
+ const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
6335
+ const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
6336
+ const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
6337
+ try {
6338
+ await joinWorkspace(socket, workspaceId);
6339
+ const doc = new Y.Doc();
6340
+ const snapshot = await loadDoc(socket, workspaceId, params.docId);
6341
+ if (snapshot.missing) {
6342
+ Y.applyUpdate(doc, Buffer.from(snapshot.missing, "base64"));
6343
+ }
6344
+ const prevSV = Y.encodeStateVector(doc);
6345
+ const blocks = doc.getMap("blocks");
6346
+ const ctx = getSurfaceElementsValueMap(blocks, { create: true });
6347
+ const index = params.index ?? nextSurfaceElementIndex(ctx.value);
6348
+ // Mirror updateSurfaceElementHandler: report inapplicable gated fields
6349
+ // in `ignored` instead of silently dropping them at the per-type builder.
6350
+ const ignored = [];
6351
+ for (const [field, applicable] of Object.entries(FIELD_APPLICABILITY)) {
6352
+ if (params[field] === undefined)
6353
+ continue;
6354
+ if (!applicable.includes(params.type))
6355
+ ignored.push(field);
6356
+ }
6357
+ const { elementId, data } = buildSurfaceElementData(params.type, index, params);
6358
+ if (params.type === "connector") {
6359
+ const srcBounds = resolveConnectorEndpointBoundAsBound(ctx.value, blocks, params.sourceId);
6360
+ const tgtBounds = resolveConnectorEndpointBoundAsBound(ctx.value, blocks, params.targetId);
6361
+ // Auto-snap to the four tangent-carrying sides when the caller only
6362
+ // supplied ids — the renderer needs tangents to draw arrow heads.
6363
+ if (srcBounds && tgtBounds && !params.sourcePosition && !params.targetPosition) {
6364
+ const natural = pickConnectorSides(srcBounds, tgtBounds);
6365
+ data.source = { ...data.source, position: SIDE_TO_NORMALIZED_POSITION[natural.from] };
6366
+ data.target = { ...data.target, position: SIDE_TO_NORMALIZED_POSITION[natural.to] };
6367
+ }
6368
+ if (params.label) {
6369
+ const srcCenter = srcBounds
6370
+ ? { x: srcBounds.x + srcBounds.w / 2, y: srcBounds.y + srcBounds.h / 2 }
6371
+ : resolveConnectorEndpointCenter(ctx.value, blocks, params.sourceId, params.sourcePosition);
6372
+ const tgtCenter = tgtBounds
6373
+ ? { x: tgtBounds.x + tgtBounds.w / 2, y: tgtBounds.y + tgtBounds.h / 2 }
6374
+ : resolveConnectorEndpointCenter(ctx.value, blocks, params.targetId, params.targetPosition);
6375
+ const midpoint = srcCenter && tgtCenter
6376
+ ? { x: (srcCenter.x + tgtCenter.x) / 2, y: (srcCenter.y + tgtCenter.y) / 2 }
6377
+ : (srcCenter ?? tgtCenter);
6378
+ data.labelXYWH = estimateConnectorLabelXYWH(params.label, 16, midpoint, 280);
6379
+ }
6380
+ }
6381
+ writeSurfaceElement(ctx.value, elementId, data);
6382
+ const delta = Y.encodeStateAsUpdate(doc, prevSV);
6383
+ await pushDocUpdate(socket, workspaceId, params.docId, Buffer.from(delta).toString("base64"));
6384
+ return text({
6385
+ added: true,
6386
+ elementId,
6387
+ type: params.type,
6388
+ surfaceBlockId: ctx.surfaceId,
6389
+ ignored,
6390
+ });
6391
+ }
6392
+ finally {
6393
+ socket.disconnect();
6394
+ }
6395
+ };
6396
+ const listSurfaceElementsHandler = async (params) => {
6397
+ const workspaceId = params.workspaceId || defaults.workspaceId;
6398
+ if (!workspaceId) {
6399
+ throw new Error("workspaceId is required. Provide it as a parameter or set AFFINE_WORKSPACE_ID in environment.");
6400
+ }
6401
+ const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
6402
+ const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
6403
+ const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
6404
+ try {
6405
+ await joinWorkspace(socket, workspaceId);
6406
+ const doc = new Y.Doc();
6407
+ const snapshot = await loadDoc(socket, workspaceId, params.docId);
6408
+ if (!snapshot.missing) {
6409
+ return text({
6410
+ docId: params.docId,
6411
+ exists: false,
6412
+ surfaceBlockId: null,
6413
+ count: 0,
6414
+ elements: [],
6415
+ });
6416
+ }
6417
+ Y.applyUpdate(doc, Buffer.from(snapshot.missing, "base64"));
6418
+ const blocks = doc.getMap("blocks");
6419
+ const ctx = getSurfaceElementsValueMap(blocks, { create: false });
6420
+ if (!ctx) {
6421
+ return text({
6422
+ docId: params.docId,
6423
+ exists: true,
6424
+ surfaceBlockId: null,
6425
+ count: 0,
6426
+ elements: [],
6427
+ });
6428
+ }
6429
+ const elements = [];
6430
+ for (const [id, value] of ctx.value.entries()) {
6431
+ const entryId = String(id);
6432
+ if (params.elementId && entryId !== params.elementId)
6433
+ continue;
6434
+ if (!(value instanceof Y.Map))
6435
+ continue;
6436
+ const serialized = serializeSurfaceElement(entryId, value);
6437
+ if (params.type && serialized.type !== params.type)
6438
+ continue;
6439
+ elements.push(serialized);
6440
+ }
6441
+ const sorted = sortByFractionalIndex(elements);
6442
+ return text({
6443
+ docId: params.docId,
6444
+ exists: true,
6445
+ surfaceBlockId: ctx.surfaceId,
6446
+ count: sorted.length,
6447
+ elements: sorted,
6448
+ });
6449
+ }
6450
+ finally {
6451
+ socket.disconnect();
6452
+ }
6453
+ };
6454
+ // Also parsed by scripts/verify-surface-element-gating.mjs — adding a styling
6455
+ // field to surfaceElementFieldSchemas without a row here fails that gate.
6456
+ // Per-type rows mirror blocksuite's @field decorators (toeverything/blocksuite@5cb5cb6
6457
+ // packages/affine/model/src/elements/{connector,shape}.ts).
6458
+ const FIELD_APPLICABILITY = {
6459
+ shapeType: ["shape"],
6460
+ radius: ["shape"],
6461
+ filled: ["shape"],
6462
+ fillColor: ["shape"],
6463
+ strokeColor: ["shape"], // connectors use top-level `stroke`
6464
+ strokeWidth: ["shape", "connector"],
6465
+ strokeStyle: ["shape", "connector"],
6466
+ color: ["shape", "text"], // connectors use labelStyle.*
6467
+ fontSize: ["shape", "text"],
6468
+ fontWeight: ["shape", "text"],
6469
+ stroke: ["connector"],
6470
+ mode: ["connector"],
6471
+ frontEndpointStyle: ["connector"],
6472
+ rearEndpointStyle: ["connector"],
6473
+ };
6474
+ const updateSurfaceElementHandler = async (params) => {
6475
+ const workspaceId = params.workspaceId || defaults.workspaceId;
6476
+ if (!workspaceId) {
6477
+ throw new Error("workspaceId is required. Provide it as a parameter or set AFFINE_WORKSPACE_ID in environment.");
6478
+ }
6479
+ const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
6480
+ const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
6481
+ const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
6482
+ try {
6483
+ await joinWorkspace(socket, workspaceId);
6484
+ const doc = new Y.Doc();
6485
+ const snapshot = await loadDoc(socket, workspaceId, params.docId);
6486
+ if (!snapshot.missing) {
6487
+ throw new Error(`Document '${params.docId}' not found or has no content.`);
6488
+ }
6489
+ Y.applyUpdate(doc, Buffer.from(snapshot.missing, "base64"));
6490
+ const prevSV = Y.encodeStateVector(doc);
6491
+ const blocks = doc.getMap("blocks");
6492
+ const ctx = getSurfaceElementsValueMap(blocks, { create: false });
6493
+ if (!ctx)
6494
+ throw new Error("Document has no surface elements to update.");
6495
+ const el = ctx.value.get(params.elementId);
6496
+ if (!(el instanceof Y.Map)) {
6497
+ throw new Error(`Surface element '${params.elementId}' not found.`);
6498
+ }
6499
+ const elementType = el.get("type");
6500
+ const changed = [];
6501
+ const ignored = [];
6502
+ const geomProvided = params.x !== undefined ||
6503
+ params.y !== undefined ||
6504
+ params.width !== undefined ||
6505
+ params.height !== undefined;
6506
+ if (geomProvided) {
6507
+ if (elementType === "shape" || elementType === "text") {
6508
+ const current = parseXywhString(el.get("xywh")) ?? {
6509
+ x: 0,
6510
+ y: 0,
6511
+ width: 100,
6512
+ height: 100,
6513
+ };
6514
+ const nx = params.x ?? current.x;
6515
+ const ny = params.y ?? current.y;
6516
+ const nw = params.width ?? current.width;
6517
+ const nh = params.height ?? current.height;
6518
+ el.set("xywh", formatXywhString(nx, ny, nw, nh));
6519
+ changed.push("xywh");
6520
+ }
6521
+ else {
6522
+ if (params.x !== undefined)
6523
+ ignored.push("x");
6524
+ if (params.y !== undefined)
6525
+ ignored.push("y");
6526
+ if (params.width !== undefined)
6527
+ ignored.push("width");
6528
+ if (params.height !== undefined)
6529
+ ignored.push("height");
6530
+ }
6531
+ }
6532
+ const setYText = (key, value) => {
6533
+ const yText = new Y.Text();
6534
+ yText.insert(0, resolveSurfaceNewlines(value));
6535
+ el.set(key, yText);
6536
+ changed.push(key);
6537
+ };
6538
+ if (params.text !== undefined) {
6539
+ if (elementType === "shape" || elementType === "text" || elementType === "connector") {
6540
+ setYText("text", params.text);
6541
+ }
6542
+ else {
6543
+ ignored.push("text");
6544
+ }
6545
+ }
6546
+ if (params.label !== undefined) {
6547
+ if (elementType === "connector") {
6548
+ setYText("text", params.label);
6549
+ }
6550
+ else {
6551
+ ignored.push("label");
6552
+ }
6553
+ }
6554
+ if (params.title !== undefined) {
6555
+ if (elementType === "group") {
6556
+ setYText("title", params.title);
6557
+ }
6558
+ else {
6559
+ ignored.push("title");
6560
+ }
6561
+ }
6562
+ for (const [field, applicable] of Object.entries(FIELD_APPLICABILITY)) {
6563
+ const key = field;
6564
+ if (params[key] === undefined)
6565
+ continue;
6566
+ if (applicable.includes(elementType)) {
6567
+ el.set(field, params[key]);
6568
+ changed.push(field);
6569
+ }
6570
+ else {
6571
+ ignored.push(field);
6572
+ }
6573
+ }
6574
+ if (params.index !== undefined) {
6575
+ el.set("index", params.index);
6576
+ changed.push("index");
6577
+ }
6578
+ if (params.sourceId !== undefined || params.sourcePosition !== undefined) {
6579
+ if (elementType === "connector") {
6580
+ const source = {};
6581
+ if (params.sourceId)
6582
+ source.id = params.sourceId;
6583
+ if (params.sourcePosition)
6584
+ source.position = params.sourcePosition;
6585
+ el.set("source", source);
6586
+ changed.push("source");
6587
+ }
6588
+ else {
6589
+ if (params.sourceId !== undefined)
6590
+ ignored.push("sourceId");
6591
+ if (params.sourcePosition !== undefined)
6592
+ ignored.push("sourcePosition");
6593
+ }
6594
+ }
6595
+ if (params.targetId !== undefined || params.targetPosition !== undefined) {
6596
+ if (elementType === "connector") {
6597
+ const target = {};
6598
+ if (params.targetId)
6599
+ target.id = params.targetId;
6600
+ if (params.targetPosition)
6601
+ target.position = params.targetPosition;
6602
+ el.set("target", target);
6603
+ changed.push("target");
6604
+ }
6605
+ else {
6606
+ if (params.targetId !== undefined)
6607
+ ignored.push("targetId");
6608
+ if (params.targetPosition !== undefined)
6609
+ ignored.push("targetPosition");
6610
+ }
6611
+ }
6612
+ if (params.children !== undefined) {
6613
+ if (elementType === "group") {
6614
+ const childMap = new Y.Map();
6615
+ for (const childId of params.children)
6616
+ childMap.set(childId, true);
6617
+ el.set("children", childMap);
6618
+ changed.push("children");
6619
+ }
6620
+ else {
6621
+ ignored.push("children");
6622
+ }
6623
+ }
6624
+ const delta = Y.encodeStateAsUpdate(doc, prevSV);
6625
+ await pushDocUpdate(socket, workspaceId, params.docId, Buffer.from(delta).toString("base64"));
6626
+ return text({
6627
+ updated: changed.length > 0,
6628
+ elementId: params.elementId,
6629
+ type: typeof elementType === "string" ? elementType : null,
6630
+ changed,
6631
+ ignored,
6632
+ });
6633
+ }
6634
+ finally {
6635
+ socket.disconnect();
6636
+ }
6637
+ };
6638
+ const deleteSurfaceElementHandler = async (params) => {
6639
+ const workspaceId = params.workspaceId || defaults.workspaceId;
6640
+ if (!workspaceId) {
6641
+ throw new Error("workspaceId is required. Provide it as a parameter or set AFFINE_WORKSPACE_ID in environment.");
6642
+ }
6643
+ const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
6644
+ const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
6645
+ const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
6646
+ try {
6647
+ await joinWorkspace(socket, workspaceId);
6648
+ const doc = new Y.Doc();
6649
+ const snapshot = await loadDoc(socket, workspaceId, params.docId);
6650
+ if (!snapshot.missing) {
6651
+ throw new Error(`Document '${params.docId}' not found or has no content.`);
6652
+ }
6653
+ Y.applyUpdate(doc, Buffer.from(snapshot.missing, "base64"));
6654
+ const prevSV = Y.encodeStateVector(doc);
6655
+ const blocks = doc.getMap("blocks");
6656
+ const ctx = getSurfaceElementsValueMap(blocks, { create: false });
6657
+ if (!ctx) {
6658
+ return text({
6659
+ deleted: false,
6660
+ elementId: params.elementId,
6661
+ reason: "no-surface",
6662
+ prunedConnectors: [],
6663
+ });
6664
+ }
6665
+ const existing = ctx.value.get(params.elementId);
6666
+ if (!(existing instanceof Y.Map)) {
6667
+ return text({
6668
+ deleted: false,
6669
+ elementId: params.elementId,
6670
+ reason: "not-found",
6671
+ prunedConnectors: [],
6672
+ });
6673
+ }
6674
+ ctx.value.delete(params.elementId);
6675
+ const prunedConnectors = [];
6676
+ if (params.pruneConnectors) {
6677
+ const toDelete = [];
6678
+ for (const [otherId, otherVal] of ctx.value.entries()) {
6679
+ if (!(otherVal instanceof Y.Map))
6680
+ continue;
6681
+ if (otherVal.get("type") !== "connector")
6682
+ continue;
6683
+ const source = otherVal.get("source");
6684
+ const target = otherVal.get("target");
6685
+ const srcId = source && typeof source === "object" ? source.id : undefined;
6686
+ const tgtId = target && typeof target === "object" ? target.id : undefined;
6687
+ if (srcId === params.elementId || tgtId === params.elementId) {
6688
+ toDelete.push(String(otherId));
6689
+ }
6690
+ }
6691
+ for (const id of toDelete) {
6692
+ ctx.value.delete(id);
6693
+ prunedConnectors.push(id);
6694
+ }
6695
+ }
6696
+ pruneFromFrameChildElementIds(blocks, [params.elementId, ...prunedConnectors]);
6697
+ const delta = Y.encodeStateAsUpdate(doc, prevSV);
6698
+ await pushDocUpdate(socket, workspaceId, params.docId, Buffer.from(delta).toString("base64"));
6699
+ return text({ deleted: true, elementId: params.elementId, prunedConnectors });
6700
+ }
6701
+ finally {
6702
+ socket.disconnect();
6703
+ }
6704
+ };
6705
+ const updateFrameChildrenHandler = async (params) => {
6706
+ const workspaceId = params.workspaceId || defaults.workspaceId;
6707
+ if (!workspaceId) {
6708
+ throw new Error("workspaceId is required. Provide it as a parameter or set AFFINE_WORKSPACE_ID in environment.");
6709
+ }
6710
+ const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
6711
+ const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
6712
+ const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
6713
+ try {
6714
+ await joinWorkspace(socket, workspaceId);
6715
+ const doc = new Y.Doc();
6716
+ const snapshot = await loadDoc(socket, workspaceId, params.docId);
6717
+ if (!snapshot.missing) {
6718
+ throw new Error(`Document '${params.docId}' not found or has no content.`);
6719
+ }
6720
+ Y.applyUpdate(doc, Buffer.from(snapshot.missing, "base64"));
6721
+ const prevSV = Y.encodeStateVector(doc);
6722
+ const blocks = doc.getMap("blocks");
6723
+ const frameBlock = blocks.get(params.blockId);
6724
+ if (!(frameBlock instanceof Y.Map) || frameBlock.get("sys:flavour") !== "affine:frame") {
6725
+ throw new Error(`Frame block '${params.blockId}' not found.`);
6726
+ }
6727
+ // All-missing is legit here: it's how callers clear ownership.
6728
+ const surfaceCtx = getSurfaceElementsValueMap(blocks, { create: false });
6729
+ const surfaceValueMap = surfaceCtx?.value ?? new Y.Map();
6730
+ const ownedIds = [];
6731
+ const missing = [];
6732
+ const kids = [];
6733
+ for (const id of params.childElementIds) {
6734
+ const resolved = resolveChildBound(surfaceValueMap, blocks, id);
6735
+ if (resolved.kind === "missing") {
6736
+ missing.push(id);
6737
+ }
6738
+ else {
6739
+ ownedIds.push(id);
6740
+ if (resolved.bound)
6741
+ kids.push(resolved.bound);
6742
+ }
6743
+ }
6744
+ const childMap = new Y.Map();
6745
+ for (const id of ownedIds)
6746
+ childMap.set(id, true);
6747
+ frameBlock.set("prop:childElementIds", childMap);
6748
+ // Resize-to-fit is default; skip on all-missing so clear-ownership
6749
+ // doesn't collapse the frame to zero.
6750
+ const resize = params.resizeToFit !== false;
6751
+ const padding = Number.isFinite(params.padding)
6752
+ ? Math.max(0, Math.floor(params.padding))
6753
+ : 40;
6754
+ let xywh = null;
6755
+ if (resize && kids.length > 0) {
6756
+ const wrapped = encloseBounds(kids, { padding, titleBand: 60 });
6757
+ if (wrapped) {
6758
+ xywh = { x: wrapped.x, y: wrapped.y, width: wrapped.w, height: wrapped.h };
6759
+ frameBlock.set("prop:xywh", formatXywhString(xywh.x, xywh.y, xywh.width, xywh.height));
6760
+ }
6761
+ }
6762
+ const delta = Y.encodeStateAsUpdate(doc, prevSV);
6763
+ await pushDocUpdate(socket, workspaceId, params.docId, Buffer.from(delta).toString("base64"));
6764
+ return text({
6765
+ updated: true,
6766
+ blockId: params.blockId,
6767
+ flavour: "affine:frame",
6768
+ ownedIds,
6769
+ missing,
6770
+ resized: xywh !== null,
6771
+ ...(xywh ? { xywh } : {}),
6772
+ });
6773
+ }
6774
+ finally {
6775
+ socket.disconnect();
6776
+ }
6777
+ };
6778
+ const updateEdgelessBlockHandler = async (params) => {
6779
+ const workspaceId = params.workspaceId || defaults.workspaceId;
6780
+ if (!workspaceId) {
6781
+ throw new Error("workspaceId is required. Provide it as a parameter or set AFFINE_WORKSPACE_ID in environment.");
6782
+ }
6783
+ const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
6784
+ const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
6785
+ const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
6786
+ try {
6787
+ await joinWorkspace(socket, workspaceId);
6788
+ const doc = new Y.Doc();
6789
+ const snapshot = await loadDoc(socket, workspaceId, params.docId);
6790
+ if (!snapshot.missing) {
6791
+ throw new Error(`Document '${params.docId}' not found or has no content.`);
6792
+ }
6793
+ Y.applyUpdate(doc, Buffer.from(snapshot.missing, "base64"));
6794
+ const prevSV = Y.encodeStateVector(doc);
6795
+ const blocks = doc.getMap("blocks");
6796
+ const block = blocks.get(params.blockId);
6797
+ if (!(block instanceof Y.Map)) {
6798
+ throw new Error(`Block '${params.blockId}' not found.`);
6799
+ }
6800
+ const flavour = block.get("sys:flavour");
6801
+ if (flavour !== "affine:note" && flavour !== "affine:frame" && flavour !== "affine:edgeless-text") {
6802
+ throw new Error(`Block '${params.blockId}' has flavour '${String(flavour)}' — update_edgeless_block only mutates note/frame/edgeless-text blocks.`);
6803
+ }
6804
+ const changed = [];
6805
+ const ignored = [];
6806
+ if (params.x !== undefined || params.y !== undefined || params.width !== undefined || params.height !== undefined) {
6807
+ const prev = parseXywhString(block.get("prop:xywh")) ?? { x: 0, y: 0, width: 0, height: 0 };
6808
+ block.set("prop:xywh", formatXywhString(params.x ?? prev.x, params.y ?? prev.y, params.width ?? prev.width, params.height ?? prev.height));
6809
+ changed.push("xywh");
6810
+ }
6811
+ if (params.background !== undefined) {
6812
+ if (flavour === "affine:edgeless-text") {
6813
+ ignored.push("background"); // edgeless-text has no prop:background
6814
+ }
6815
+ else {
6816
+ const bg = params.background;
6817
+ if (bg && typeof bg === "object" && !Array.isArray(bg) && ("light" in bg || "dark" in bg)) {
6818
+ const bgMap = new Y.Map();
6819
+ if (typeof bg.light === "string")
6820
+ bgMap.set("light", bg.light);
6821
+ if (typeof bg.dark === "string")
6822
+ bgMap.set("dark", bg.dark);
6823
+ block.set("prop:background", bgMap);
6824
+ }
6825
+ else {
6826
+ block.set("prop:background", bg);
6827
+ }
6828
+ changed.push("background");
6829
+ }
6830
+ }
6831
+ const delta = Y.encodeStateAsUpdate(doc, prevSV);
6832
+ await pushDocUpdate(socket, workspaceId, params.docId, Buffer.from(delta).toString("base64"));
6833
+ return text({ updated: changed.length > 0, blockId: params.blockId, flavour, changed, ignored });
6834
+ }
6835
+ finally {
6836
+ socket.disconnect();
6837
+ }
6838
+ };
6839
+ const deleteBlockHandler = async (params) => {
6840
+ const workspaceId = params.workspaceId || defaults.workspaceId;
6841
+ if (!workspaceId) {
6842
+ throw new Error("workspaceId is required. Provide it as a parameter or set AFFINE_WORKSPACE_ID in environment.");
6843
+ }
6844
+ const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
6845
+ const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
6846
+ const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
6847
+ try {
6848
+ await joinWorkspace(socket, workspaceId);
6849
+ const doc = new Y.Doc();
6850
+ const snapshot = await loadDoc(socket, workspaceId, params.docId);
6851
+ if (!snapshot.missing) {
6852
+ throw new Error(`Document '${params.docId}' not found or has no content.`);
6853
+ }
6854
+ Y.applyUpdate(doc, Buffer.from(snapshot.missing, "base64"));
6855
+ const prevSV = Y.encodeStateVector(doc);
6856
+ const blocks = doc.getMap("blocks");
6857
+ const block = blocks.get(params.blockId);
6858
+ if (!(block instanceof Y.Map)) {
6859
+ return text({ deleted: false, blockId: params.blockId, reason: "not-found" });
6860
+ }
6861
+ const flavour = block.get("sys:flavour");
6862
+ if (flavour === "affine:page") {
6863
+ throw new Error(`Refusing to delete page-root block '${params.blockId}' — use delete_doc for whole-doc removal.`);
6864
+ }
6865
+ const deletedIds = [];
6866
+ const deleteRecursive = params.deleteChildren !== false;
6867
+ const walk = (id) => {
6868
+ const b = blocks.get(id);
6869
+ if (!(b instanceof Y.Map))
6870
+ return;
6871
+ if (deleteRecursive) {
6872
+ const children = b.get("sys:children");
6873
+ if (children instanceof Y.Array) {
6874
+ for (const c of children.toArray()) {
6875
+ if (typeof c === "string")
6876
+ walk(c);
6877
+ }
6878
+ }
6879
+ }
6880
+ blocks.delete(id);
6881
+ deletedIds.push(id);
6882
+ };
6883
+ walk(params.blockId);
6884
+ for (const [, maybeParent] of blocks.entries()) {
6885
+ if (!(maybeParent instanceof Y.Map))
6886
+ continue;
6887
+ const kids = maybeParent.get("sys:children");
6888
+ if (!(kids instanceof Y.Array))
6889
+ continue;
6890
+ const arr = kids.toArray();
6891
+ for (let i = arr.length - 1; i >= 0; i--) {
6892
+ if (typeof arr[i] === "string" && deletedIds.includes(arr[i])) {
6893
+ kids.delete(i, 1);
6894
+ }
6895
+ }
6896
+ }
6897
+ // Mirror delete_surface_element's pruneConnectors semantics.
6898
+ const prunedConnectors = [];
6899
+ if (params.pruneConnectors) {
6900
+ const ctx = getSurfaceElementsValueMap(blocks, { create: false });
6901
+ if (ctx) {
6902
+ const toDelete = [];
6903
+ for (const [otherId, otherVal] of ctx.value.entries()) {
6904
+ if (!(otherVal instanceof Y.Map))
6905
+ continue;
6906
+ if (otherVal.get("type") !== "connector")
6907
+ continue;
6908
+ const source = otherVal.get("source");
6909
+ const target = otherVal.get("target");
6910
+ const srcId = source && typeof source === "object" ? source.id : undefined;
6911
+ const tgtId = target && typeof target === "object" ? target.id : undefined;
6912
+ if ((typeof srcId === "string" && deletedIds.includes(srcId)) ||
6913
+ (typeof tgtId === "string" && deletedIds.includes(tgtId))) {
6914
+ toDelete.push(String(otherId));
6915
+ }
6916
+ }
6917
+ for (const id of toDelete) {
6918
+ ctx.value.delete(id);
6919
+ prunedConnectors.push(id);
6920
+ }
6921
+ }
6922
+ }
6923
+ pruneFromFrameChildElementIds(blocks, [...deletedIds, ...prunedConnectors]);
6924
+ const delta = Y.encodeStateAsUpdate(doc, prevSV);
6925
+ await pushDocUpdate(socket, workspaceId, params.docId, Buffer.from(delta).toString("base64"));
6926
+ return text({ deleted: true, blockId: params.blockId, deletedIds, prunedConnectors });
6927
+ }
6928
+ finally {
6929
+ socket.disconnect();
6930
+ }
6931
+ };
6932
+ const getEdgelessCanvasHandler = async (params) => {
6933
+ const workspaceId = params.workspaceId || defaults.workspaceId;
6934
+ if (!workspaceId) {
6935
+ throw new Error("workspaceId is required. Provide it as a parameter or set AFFINE_WORKSPACE_ID in environment.");
6936
+ }
6937
+ const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
6938
+ const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
6939
+ const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
6940
+ try {
6941
+ await joinWorkspace(socket, workspaceId);
6942
+ const doc = new Y.Doc();
6943
+ const snapshot = await loadDoc(socket, workspaceId, params.docId);
6944
+ if (!snapshot.missing) {
6945
+ return text({
6946
+ docId: params.docId,
6947
+ exists: false,
6948
+ surfaceBlockId: null,
6949
+ edgelessBlocks: [],
6950
+ surfaceElements: [],
6951
+ bounds: null,
6952
+ elementCounts: { shape: 0, connector: 0, text: 0, group: 0 },
6953
+ });
6954
+ }
6955
+ Y.applyUpdate(doc, Buffer.from(snapshot.missing, "base64"));
6956
+ const blocks = doc.getMap("blocks");
6957
+ const edgelessFlavours = new Set([
6958
+ "affine:frame",
6959
+ "affine:edgeless-text",
6960
+ "affine:note",
6961
+ ]);
6962
+ const collectNoteText = (rootId) => {
6963
+ const out = [];
6964
+ const seen = new Set();
6965
+ const visit = (id) => {
6966
+ if (seen.has(id))
6967
+ return;
6968
+ seen.add(id);
6969
+ const b = blocks.get(id);
6970
+ if (!(b instanceof Y.Map))
6971
+ return;
6972
+ const t = asText(b.get("prop:text"));
6973
+ if (t)
6974
+ out.push(t);
6975
+ for (const childId of childIdsFrom(b.get("sys:children"))) {
6976
+ visit(childId);
6977
+ }
6978
+ };
6979
+ const root = blocks.get(rootId);
6980
+ if (root instanceof Y.Map) {
6981
+ for (const childId of childIdsFrom(root.get("sys:children"))) {
6982
+ visit(childId);
6983
+ }
6984
+ }
6985
+ return out;
6986
+ };
6987
+ // Structured tree rather than flat-joined text so markdown-seeded notes
6988
+ // round-trip with heading/paragraph/list structure intact.
6989
+ const collectNoteChildren = (rootId) => {
6990
+ const result = [];
6991
+ const seen = new Set();
6992
+ const visit = (id) => {
6993
+ if (seen.has(id))
6994
+ return null;
6995
+ seen.add(id);
6996
+ const b = blocks.get(id);
6997
+ if (!(b instanceof Y.Map))
6998
+ return null;
6999
+ const childFlavour = b.get("sys:flavour");
7000
+ if (typeof childFlavour !== "string")
7001
+ return null;
7002
+ const entry = { id, flavour: childFlavour };
7003
+ const t = asText(b.get("prop:text"));
7004
+ if (t)
7005
+ entry.text = t;
7006
+ const propType = b.get("prop:type");
7007
+ if (typeof propType === "string")
7008
+ entry.type = propType;
7009
+ const checked = b.get("prop:checked");
7010
+ if (typeof checked === "boolean")
7011
+ entry.checked = checked;
7012
+ const language = b.get("prop:language");
7013
+ if (typeof language === "string" && language.length > 0)
7014
+ entry.language = language;
7015
+ const subChildren = [];
7016
+ for (const childId of childIdsFrom(b.get("sys:children"))) {
7017
+ const c = visit(childId);
7018
+ if (c)
7019
+ subChildren.push(c);
7020
+ }
7021
+ if (subChildren.length)
7022
+ entry.children = subChildren;
7023
+ return entry;
7024
+ };
7025
+ const root = blocks.get(rootId);
7026
+ if (root instanceof Y.Map) {
7027
+ for (const childId of childIdsFrom(root.get("sys:children"))) {
7028
+ const c = visit(childId);
7029
+ if (c)
7030
+ result.push(c);
7031
+ }
7032
+ }
7033
+ return result;
7034
+ };
7035
+ const edgelessBlocks = [];
7036
+ for (const [id, raw] of blocks.entries()) {
7037
+ if (!(raw instanceof Y.Map))
7038
+ continue;
7039
+ const flavour = raw.get("sys:flavour");
7040
+ if (typeof flavour !== "string" || !edgelessFlavours.has(flavour))
7041
+ continue;
7042
+ const xywhRaw = raw.get("prop:xywh");
7043
+ const bounds = parseXywhString(xywhRaw);
7044
+ const propIndex = raw.get("prop:index");
7045
+ const entry = {
7046
+ id: String(id),
7047
+ flavour,
7048
+ xywh: typeof xywhRaw === "string" ? xywhRaw : null,
7049
+ bounds,
7050
+ index: typeof propIndex === "string" ? propIndex : null,
7051
+ };
7052
+ if (flavour === "affine:frame") {
7053
+ entry.title = asText(raw.get("prop:title")) || null;
7054
+ const bg = raw.get("prop:background");
7055
+ entry.background = bg instanceof Y.Map ? bg.toJSON() : bg ?? null;
7056
+ const owned = raw.get("prop:childElementIds");
7057
+ entry.childElementIds = owned instanceof Y.Map ? Object.keys(owned.toJSON()) : [];
7058
+ }
7059
+ else if (flavour === "affine:edgeless-text") {
7060
+ const lines = collectNoteText(String(id));
7061
+ entry.text = lines.length ? lines.join("\n") : null;
7062
+ entry.color = raw.get("prop:color") ?? null;
7063
+ }
7064
+ else if (flavour === "affine:note") {
7065
+ const lines = collectNoteText(String(id));
7066
+ entry.text = lines.length ? lines.join("\n") : null;
7067
+ // `text` is the flat-join legacy view; `children` carries structure.
7068
+ entry.children = collectNoteChildren(String(id));
7069
+ entry.displayMode = raw.get("prop:displayMode") ?? null;
7070
+ const bg = raw.get("prop:background");
7071
+ entry.background = bg instanceof Y.Map ? bg.toJSON() : bg ?? null;
7072
+ }
7073
+ edgelessBlocks.push(entry);
7074
+ }
7075
+ const ctx = getSurfaceElementsValueMap(blocks, { create: false });
7076
+ const surfaceElements = [];
7077
+ const counts = {
7078
+ shape: 0,
7079
+ connector: 0,
7080
+ text: 0,
7081
+ group: 0,
7082
+ };
7083
+ if (ctx) {
7084
+ for (const [elId, val] of ctx.value.entries()) {
7085
+ if (!(val instanceof Y.Map))
7086
+ continue;
7087
+ const serialized = serializeSurfaceElement(String(elId), val);
7088
+ surfaceElements.push(serialized);
7089
+ const t = serialized.type;
7090
+ if (t && t in counts)
7091
+ counts[t]++;
7092
+ }
7093
+ }
7094
+ const sortedSurfaceElements = sortByFractionalIndex(surfaceElements);
7095
+ let minX = Infinity;
7096
+ let minY = Infinity;
7097
+ let maxX = -Infinity;
7098
+ let maxY = -Infinity;
7099
+ let hasAny = false;
7100
+ const include = (b) => {
7101
+ if (!b)
7102
+ return;
7103
+ hasAny = true;
7104
+ minX = Math.min(minX, b.x);
7105
+ minY = Math.min(minY, b.y);
7106
+ maxX = Math.max(maxX, b.x + b.width);
7107
+ maxY = Math.max(maxY, b.y + b.height);
7108
+ };
7109
+ const sortedEdgelessBlocks = sortByFractionalIndex(edgelessBlocks);
7110
+ for (const eb of sortedEdgelessBlocks)
7111
+ include(eb.bounds);
7112
+ for (const se of sortedSurfaceElements)
7113
+ include(se.bounds);
7114
+ return text({
7115
+ docId: params.docId,
7116
+ exists: true,
7117
+ surfaceBlockId: ctx?.surfaceId ?? null,
7118
+ edgelessBlocks: sortedEdgelessBlocks,
7119
+ surfaceElements: sortedSurfaceElements,
7120
+ elementCounts: counts,
7121
+ bounds: hasAny
7122
+ ? { minX, minY, maxX, maxY, width: maxX - minX, height: maxY - minY }
7123
+ : null,
7124
+ });
7125
+ }
7126
+ finally {
7127
+ socket.disconnect();
7128
+ }
7129
+ };
7130
+ const surfaceElementFieldSchemas = {
7131
+ x: z.number().optional().describe("X position on canvas (shape/text; default 0)."),
7132
+ y: z.number().optional().describe("Y position on canvas (shape/text; default 0)."),
7133
+ width: z.number().optional().describe("Width (shape default 100, text default 200)."),
7134
+ height: z.number().optional().describe("Height (shape default 100, text default 30)."),
7135
+ shapeType: z
7136
+ .enum(["rect", "ellipse", "diamond", "triangle"])
7137
+ .optional()
7138
+ .describe("Shape type (default rect). Shape only."),
7139
+ radius: z.number().optional().describe("Corner radius for rect (0.1 = rounded). Shape only."),
7140
+ filled: z.boolean().optional().describe("Whether shape is filled (default true). Shape only."),
7141
+ fillColor: z
7142
+ .string()
7143
+ .optional()
7144
+ .describe("Fill color. Prefer the `--affine-palette-shape-<color>` family (yellow/orange/red/magenta/purple/navy/blue/green/teal/grey/white/black). These are fixed colors — AFFiNE shape colors are not theme-adaptive by design. Shape only."),
7145
+ strokeColor: z.string().optional().describe("Stroke color. Prefer the `--affine-palette-line-<color>` family (same color names as fillColor). Fixed colors, not theme-adaptive. Shape only."),
7146
+ strokeWidth: z.number().optional().describe("Stroke width (default 2). Shape/connector."),
7147
+ strokeStyle: z
7148
+ .enum(["solid", "dash", "none"])
7149
+ .optional()
7150
+ .describe("Stroke style. Shape/connector."),
7151
+ text: z
7152
+ .string()
7153
+ .optional()
7154
+ .describe("Text content (shape/text) or connector label. Replaces existing Y.Text on update."),
7155
+ color: z.string().optional().describe("Text color. Shape default `#000000` — keep unless the fill is dark, then pass a contrasting hex. Canvas text default `--affine-text-primary-color` (theme-adaptive). Shape/text."),
7156
+ fontSize: z
7157
+ .number()
7158
+ .optional()
7159
+ .describe("Font size (shape default 20, text default 16). Shape/text."),
7160
+ fontWeight: z
7161
+ .string()
7162
+ .optional()
7163
+ .describe("Font weight (shape default 600, text default 400). Shape/text."),
7164
+ sourceId: z.string().optional().describe("Connector source element id. Connector only."),
7165
+ targetId: z.string().optional().describe("Connector target element id. Connector only."),
7166
+ sourcePosition: z
7167
+ .array(z.number())
7168
+ .length(2)
7169
+ .optional()
7170
+ .describe("Source [x,y]: relative [0-1] if sourceId set, absolute otherwise. Connector only."),
7171
+ targetPosition: z
7172
+ .array(z.number())
7173
+ .length(2)
7174
+ .optional()
7175
+ .describe("Target [x,y]: relative [0-1] if targetId set, absolute otherwise. Connector only. When both source/target are bound by id and neither position is provided, endpoints snap to the BlockSuite side-midpoint facing the other endpoint so connectors flow in a clear direction. Pass [0.5,0] top, [0.5,1] bottom, [0,0.5] left, [1,0.5] right to force a specific side."),
7176
+ mode: z
7177
+ .number()
7178
+ .optional()
7179
+ .describe("Connector mode: 0=straight, 1=orthogonal (elbow), 2=curve (default 2). Connector only."),
7180
+ frontEndpointStyle: z
7181
+ .enum(["None", "Arrow", "Triangle", "Circle", "Diamond"])
7182
+ .optional()
7183
+ .describe("Front endpoint style (default None). Connector only."),
7184
+ rearEndpointStyle: z
7185
+ .enum(["None", "Arrow", "Triangle", "Circle", "Diamond"])
7186
+ .optional()
7187
+ .describe("Rear endpoint style (default Arrow). Connector only."),
7188
+ stroke: z.string().optional().describe("Connector stroke color (default '--affine-text-primary-color' — theme-adaptive, near-black in light / near-white in dark). Accepts any CSS color or AFFiNE palette token. Connector only."),
7189
+ label: z.string().optional().describe("Connector label (stored as text on the connector). Connector only."),
7190
+ children: z.array(z.string()).optional().describe("Child element ids. Group only."),
7191
+ title: z.string().optional().describe("Group title. Group only."),
7192
+ index: z
7193
+ .string()
7194
+ .optional()
7195
+ .describe("BlockSuite fractional-index string controlling z-order. On add, defaults to a key above every existing element's index (new elements render on top). On update, replaces the stored value — pass a key less than some existing index to send-to-back, or greater to bring-to-front. Use the value returned by list_surface_elements to pick a specific position."),
7196
+ };
7197
+ server.registerTool("add_surface_element", {
7198
+ title: "Add Surface Element",
7199
+ description: "Add a shape, connector, text, or group to the AFFiNE edgeless canvas surface. Shapes support rect/ellipse/diamond/triangle with fill, stroke, and text. Connectors draw arrows between shapes (by id) or between absolute points. Use for building diagrams programmatically. Style fields that don't apply to the chosen element type are reported in the response 'ignored' list (mirrors update_surface_element).",
7200
+ inputSchema: {
7201
+ workspaceId: z.string().optional().describe("Workspace ID (optional if default set)"),
7202
+ docId: DocId.describe("Document ID"),
7203
+ type: z.enum(["shape", "connector", "text", "group"]).describe("Element type"),
7204
+ ...surfaceElementFieldSchemas,
7205
+ },
7206
+ }, addSurfaceElementHandler);
7207
+ server.registerTool("list_surface_elements", {
7208
+ title: "List Surface Elements",
7209
+ description: "List all shape/connector/text/group elements on the AFFiNE edgeless canvas surface. Returns raw xywh strings plus parsed {x,y,width,height} bounds, with Y.Text fields serialized to plain strings. Optional filters by element type or id.",
7210
+ inputSchema: {
7211
+ workspaceId: z.string().optional().describe("Workspace ID (optional if default set)"),
7212
+ docId: DocId.describe("Document ID"),
7213
+ type: z
7214
+ .enum(["shape", "connector", "text", "group"])
7215
+ .optional()
7216
+ .describe("Filter by element type"),
7217
+ elementId: z.string().optional().describe("Filter to a single element id"),
7218
+ },
7219
+ }, listSurfaceElementsHandler);
7220
+ server.registerTool("update_surface_element", {
7221
+ title: "Update Surface Element",
7222
+ description: "Partially update a surface element by id. x/y/width/height merge with the element's current xywh (move without resizing, or vice versa). text/label/title replace their Y.Text wholesale. Fields that don't apply to the element's type are reported in the response 'ignored' list.",
7223
+ inputSchema: {
7224
+ workspaceId: z.string().optional().describe("Workspace ID (optional if default set)"),
7225
+ docId: DocId.describe("Document ID"),
7226
+ elementId: z.string().min(1).describe("Element ID to update"),
7227
+ ...surfaceElementFieldSchemas,
7228
+ },
7229
+ }, updateSurfaceElementHandler);
7230
+ server.registerTool("delete_surface_element", {
7231
+ title: "Delete Surface Element",
7232
+ description: "Delete a surface element by id. Set pruneConnectors=true to also delete any connectors whose source or target referenced the deleted element.",
7233
+ inputSchema: {
7234
+ workspaceId: z.string().optional().describe("Workspace ID (optional if default set)"),
7235
+ docId: DocId.describe("Document ID"),
7236
+ elementId: z.string().min(1).describe("Element ID to delete"),
7237
+ pruneConnectors: z
7238
+ .boolean()
7239
+ .optional()
7240
+ .describe("Also delete connectors referencing this element (default false)"),
7241
+ },
7242
+ }, deleteSurfaceElementHandler);
7243
+ server.registerTool("update_frame_children", {
7244
+ title: "Update Frame Children",
7245
+ description: "Replace a frame block's contents wholesale. Accepts ids of surface elements (shapes/connectors/groups) AND edgeless blocks (notes/frames/edgeless-text) — all go into BlockSuite's prop:childElementIds map, matching what the editor writes when you drag members into a frame. Dragging the frame drags every owned member. Ids that don't resolve come back under 'missing'. By default the frame is resized to fit its new contents (plus padding + title band); set resizeToFit=false to leave xywh untouched. Pass `[]` to clear ownership (resize is skipped in that case).",
7246
+ inputSchema: {
7247
+ workspaceId: z.string().optional().describe("Workspace ID (optional if default set)"),
7248
+ docId: DocId.describe("Document ID"),
7249
+ blockId: z.string().min(1).describe("Frame block id (flavour affine:frame)."),
7250
+ childElementIds: z
7251
+ .array(z.string())
7252
+ .describe("Full list of ids the frame should own/contain. Replaces any existing ownership."),
7253
+ resizeToFit: z
7254
+ .boolean()
7255
+ .optional()
7256
+ .describe("If true (default), recompute xywh from the union of resolvable child bounds + padding + title band. Set to false to preserve the frame's current box (useful when you want ownership-only edits or manual positioning)."),
7257
+ padding: z
7258
+ .number()
7259
+ .int()
7260
+ .optional()
7261
+ .describe("Padding (px) used when resizeToFit is true (default 40). Ignored when resizeToFit=false."),
7262
+ },
7263
+ }, updateFrameChildrenHandler);
7264
+ server.registerTool("update_edgeless_block", {
7265
+ title: "Update Edgeless Block",
7266
+ description: "Partially update a note/frame/edgeless-text block by id. x/y/width/height merge with current prop:xywh (move without resizing, or vice versa). background replaces prop:background (AFFiNE token or `{light, dark}` hex object). Fields that don't apply to the block's flavour come back under `ignored`.",
7267
+ inputSchema: {
7268
+ workspaceId: z.string().optional().describe("Workspace ID (optional if default set)"),
7269
+ docId: DocId.describe("Document ID"),
7270
+ blockId: z.string().min(1).describe("Block id (flavour affine:note/affine:frame/affine:edgeless-text)."),
7271
+ x: z.number().optional(),
7272
+ y: z.number().optional(),
7273
+ width: z.number().optional(),
7274
+ height: z.number().optional(),
7275
+ background: z
7276
+ .union([
7277
+ z.string(),
7278
+ z.object({ light: z.string().optional(), dark: z.string().optional() }),
7279
+ ])
7280
+ .optional()
7281
+ .describe("Note/frame only. Prefer `--affine-note-background-<color>` or `{light, dark}` hex."),
7282
+ },
7283
+ }, updateEdgelessBlockHandler);
7284
+ server.registerTool("delete_block", {
7285
+ title: "Delete Block",
7286
+ description: "Delete a block by id. Removes descendants and unlinks from the parent's sys:children by default; set deleteChildren=false to keep descendants orphaned (for re-parenting), or pruneConnectors=true to also drop surface connectors referencing any deleted id. Refuses affine:page — use delete_doc for whole docs.",
7287
+ inputSchema: {
7288
+ workspaceId: z.string().optional().describe("Workspace ID (optional if default set)"),
7289
+ docId: DocId.describe("Document ID"),
7290
+ blockId: z.string().min(1).describe("Block id to delete."),
7291
+ deleteChildren: z.boolean().optional().describe("Also delete descendants (default true)."),
7292
+ pruneConnectors: z.boolean().optional().describe("Also delete connectors bound to any deleted id (default false)."),
7293
+ },
7294
+ }, deleteBlockHandler);
7295
+ server.registerTool("get_edgeless_canvas", {
7296
+ title: "Get Edgeless Canvas",
7297
+ description: "Read the full edgeless canvas: all edgeless-positioned blocks (notes, frames, edgeless-text) with their xywh, plus all surface elements (shapes, connectors, text, groups). Includes aggregate bounding box and per-type element counts. Use this when you need to understand canvas layout end-to-end before placing new elements.",
7298
+ inputSchema: {
7299
+ workspaceId: z.string().optional().describe("Workspace ID (optional if default set)"),
7300
+ docId: DocId.describe("Document ID"),
7301
+ },
7302
+ }, getEdgelessCanvasHandler);
6234
7303
  }