affine-mcp-server 1.12.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.
@@ -0,0 +1,222 @@
1
+ /** Pure-function edgeless-canvas layout helpers. No Y.Doc, no MCP wiring. */
2
+ /** Only these four positions carry tangent vectors in BlockSuite's connector model. */
3
+ export const SIDE_TO_NORMALIZED_POSITION = {
4
+ top: [0.5, 0],
5
+ bottom: [0.5, 1],
6
+ left: [0, 0.5],
7
+ right: [1, 0.5],
8
+ };
9
+ /** Pick connector sides for a src/tgt pair. Single-axis → that axis; diagonal →
10
+ * dominant by center displacement; overlap → 4×4 midpoint minimization. Ports
11
+ * BlockSuite's `getNearestConnectableAnchor` (`connector-manager.ts:174-190`). */
12
+ export function pickConnectorSides(src, tgt) {
13
+ const srcBottom = src.y + src.h;
14
+ const srcRight = src.x + src.w;
15
+ const tgtBottom = tgt.y + tgt.h;
16
+ const tgtRight = tgt.x + tgt.w;
17
+ const above = srcBottom <= tgt.y;
18
+ const below = tgtBottom <= src.y;
19
+ const leftOf = srcRight <= tgt.x;
20
+ const rightOf = tgtRight <= src.x;
21
+ const vSeparated = above || below;
22
+ const hSeparated = leftOf || rightOf;
23
+ if (vSeparated && !hSeparated) {
24
+ return above ? { from: "bottom", to: "top" } : { from: "top", to: "bottom" };
25
+ }
26
+ if (hSeparated && !vSeparated) {
27
+ return leftOf ? { from: "right", to: "left" } : { from: "left", to: "right" };
28
+ }
29
+ if (vSeparated && hSeparated) {
30
+ const dx = (tgt.x + tgt.w / 2) - (src.x + src.w / 2);
31
+ const dy = (tgt.y + tgt.h / 2) - (src.y + src.h / 2);
32
+ if (Math.abs(dx) >= Math.abs(dy)) {
33
+ return dx >= 0 ? { from: "right", to: "left" } : { from: "left", to: "right" };
34
+ }
35
+ return dy >= 0 ? { from: "bottom", to: "top" } : { from: "top", to: "bottom" };
36
+ }
37
+ const anchors = (b) => [
38
+ { side: "top", x: b.x + b.w / 2, y: b.y },
39
+ { side: "bottom", x: b.x + b.w / 2, y: b.y + b.h },
40
+ { side: "left", x: b.x, y: b.y + b.h / 2 },
41
+ { side: "right", x: b.x + b.w, y: b.y + b.h / 2 },
42
+ ];
43
+ const srcA = anchors(src);
44
+ const tgtA = anchors(tgt);
45
+ let best = {
46
+ from: "bottom",
47
+ to: "top",
48
+ dist: Infinity,
49
+ };
50
+ for (const a of srcA) {
51
+ for (const b of tgtA) {
52
+ const dx = b.x - a.x;
53
+ const dy = b.y - a.y;
54
+ const dist = dx * dx + dy * dy;
55
+ if (dist < best.dist)
56
+ best = { from: a.side, to: b.side, dist };
57
+ }
58
+ }
59
+ return { from: best.from, to: best.to };
60
+ }
61
+ /** Enclosing bound of `children`, expanded by `padding` and `titleBand` (extra on top for a frame title). */
62
+ export function encloseBounds(children, opts = {}) {
63
+ if (children.length === 0)
64
+ return null;
65
+ const padding = opts.padding ?? 40;
66
+ const titleBand = opts.titleBand ?? 60;
67
+ let minX = Infinity;
68
+ let minY = Infinity;
69
+ let maxX = -Infinity;
70
+ let maxY = -Infinity;
71
+ for (const c of children) {
72
+ minX = Math.min(minX, c.x);
73
+ minY = Math.min(minY, c.y);
74
+ maxX = Math.max(maxX, c.x + c.w);
75
+ maxY = Math.max(maxY, c.y + c.h);
76
+ }
77
+ return {
78
+ x: Math.floor(minX - padding),
79
+ y: Math.floor(minY - padding - titleBand),
80
+ w: Math.max(1, Math.ceil(maxX - minX + padding * 2)),
81
+ h: Math.max(1, Math.ceil(maxY - minY + padding * 2 + titleBand)),
82
+ };
83
+ }
84
+ /** Pick the bound furthest along `direction` (bottommost for `"down"`, etc). */
85
+ export function pickFurthestInDirection(candidates, direction) {
86
+ if (candidates.length === 0)
87
+ return null;
88
+ let chosen = candidates[0];
89
+ for (let i = 1; i < candidates.length; i++) {
90
+ const c = candidates[i];
91
+ if (direction === "down" && c.y + c.h > chosen.y + chosen.h)
92
+ chosen = c;
93
+ else if (direction === "up" && c.y < chosen.y)
94
+ chosen = c;
95
+ else if (direction === "right" && c.x + c.w > chosen.x + chosen.w)
96
+ chosen = c;
97
+ else if (direction === "left" && c.x < chosen.x)
98
+ chosen = c;
99
+ }
100
+ return chosen;
101
+ }
102
+ /** Parse BlockSuite's `[x,y,w,h]` string. Returns null if the input isn't well-formed. */
103
+ export function parseXywhString(value) {
104
+ if (typeof value !== "string")
105
+ return null;
106
+ const m = value.match(/^\s*\[\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\]\s*$/);
107
+ if (!m)
108
+ return null;
109
+ return { x: Number(m[1]), y: Number(m[2]), width: Number(m[3]), height: Number(m[4]) };
110
+ }
111
+ /** Inverse of `parseXywhString`. */
112
+ export function formatXywhString(x, y, width, height) {
113
+ return `[${x},${y},${width},${height}]`;
114
+ }
115
+ /** Asymmetric defaults: notes default to wide/short, so equal gaps feel tight horizontally. */
116
+ export const DEFAULT_STACK_GAP_VERTICAL = 40;
117
+ export const DEFAULT_STACK_GAP_HORIZONTAL = 80;
118
+ /** BlockSuite's createDefaultDoc constants (packages/affine/model/src/consts/note.ts). */
119
+ export const DEFAULT_PAGE_BLOCK_WIDTH = 800;
120
+ export const DEFAULT_NOTE_HEIGHT = 92;
121
+ export const DEFAULT_NOTE_XYWH = `[0,0,${DEFAULT_PAGE_BLOCK_WIDTH},${DEFAULT_NOTE_HEIGHT}]`;
122
+ /** Position a new block relative to `ref` along `direction`. The orthogonal axis
123
+ * inherits from `ref` unless `preserveX` / `preserveY` is supplied. */
124
+ export function stackRelativeTo(ref, newSize, opts = {}) {
125
+ const direction = opts.direction ?? "down";
126
+ const isHorizontal = direction === "left" || direction === "right";
127
+ const gap = opts.gap ?? (isHorizontal ? DEFAULT_STACK_GAP_HORIZONTAL : DEFAULT_STACK_GAP_VERTICAL);
128
+ if (direction === "down") {
129
+ return { x: opts.preserveX ?? ref.x, y: ref.y + ref.h + gap };
130
+ }
131
+ if (direction === "up") {
132
+ return { x: opts.preserveX ?? ref.x, y: ref.y - gap - newSize.h };
133
+ }
134
+ if (direction === "right") {
135
+ return { x: ref.x + ref.w + gap, y: opts.preserveY ?? ref.y };
136
+ }
137
+ return { x: ref.x - gap - newSize.w, y: opts.preserveY ?? ref.y };
138
+ }
139
+ /** Over-estimate note height from markdown. BlockSuite's `EdgelessNoteMask`
140
+ * `ResizeObserver` corrects `prop:xywh.h` to the DOM-measured height on first render. */
141
+ export function estimateNoteHeightForMarkdown(markdown, widthPx) {
142
+ const NOTE_V_PADDING = 64;
143
+ const BODY_LINE_H = 34;
144
+ const CHAR_WIDTH = 8;
145
+ const H_PADDING = 52;
146
+ const H1_LINE_H = 58;
147
+ const H2_LINE_H = 48;
148
+ const H3_LINE_H = 40;
149
+ const CODE_LINE_H = 32;
150
+ const CODE_FENCE_PAD = 30;
151
+ const CODE_BLOCK_EXTRA = 20;
152
+ const BLANK_LINE_H = 14;
153
+ const usableWidth = Math.max(80, widthPx - H_PADDING);
154
+ const charsPerLine = Math.max(16, Math.floor(usableWidth / CHAR_WIDTH));
155
+ let total = NOTE_V_PADDING;
156
+ let inCode = false;
157
+ const lines = markdown.split("\n");
158
+ for (const raw of lines) {
159
+ const line = raw.trim();
160
+ if (line.startsWith("```")) {
161
+ inCode = !inCode;
162
+ total += CODE_FENCE_PAD;
163
+ if (inCode)
164
+ total += CODE_BLOCK_EXTRA;
165
+ continue;
166
+ }
167
+ if (inCode) {
168
+ total += CODE_LINE_H;
169
+ continue;
170
+ }
171
+ if (line === "") {
172
+ total += BLANK_LINE_H;
173
+ continue;
174
+ }
175
+ let lineHeight = BODY_LINE_H;
176
+ let prefixChars = 0;
177
+ if (/^#\s/.test(line)) {
178
+ lineHeight = H1_LINE_H;
179
+ prefixChars = 2;
180
+ }
181
+ else if (/^##\s/.test(line)) {
182
+ lineHeight = H2_LINE_H;
183
+ prefixChars = 3;
184
+ }
185
+ else if (/^###\s/.test(line)) {
186
+ lineHeight = H3_LINE_H;
187
+ prefixChars = 4;
188
+ }
189
+ else if (/^[-*]\s/.test(line)) {
190
+ prefixChars = 2;
191
+ }
192
+ else if (/^\d+\.\s/.test(line)) {
193
+ prefixChars = 3;
194
+ }
195
+ const contentChars = Math.max(1, line.length - prefixChars);
196
+ const wraps = Math.max(1, Math.ceil(contentChars / charsPerLine));
197
+ total += lineHeight * wraps;
198
+ }
199
+ return Math.max(120, Math.ceil(total));
200
+ }
201
+ /** Initial `labelXYWH` at source→target midpoint so BlockSuite's `hasLabel()` gate passes on first render. */
202
+ export function estimateConnectorLabelXYWH(labelText, fontSize, midpoint, maxWidth) {
203
+ const charWidth = fontSize * 0.55;
204
+ const estimatedW = Math.max(16, Math.ceil(labelText.length * charWidth));
205
+ const w = Math.min(estimatedW, maxWidth);
206
+ const h = Math.ceil(fontSize + 4);
207
+ if (!midpoint)
208
+ return [0, 0, Math.max(w, 16), Math.max(h, 16)];
209
+ return [Math.round(midpoint.x - w / 2), Math.round(midpoint.y - h / 2), w, h];
210
+ }
211
+ /** Sort by fractional `index` string ascending. Y.Map iteration order is not stable across reloads. */
212
+ export function sortByFractionalIndex(entries) {
213
+ return entries.slice().sort((a, b) => {
214
+ const ai = typeof a.index === "string" ? a.index : "";
215
+ const bi = typeof b.index === "string" ? b.index : "";
216
+ if (ai < bi)
217
+ return -1;
218
+ if (ai > bi)
219
+ return 1;
220
+ return 0;
221
+ });
222
+ }
package/dist/index.js CHANGED
@@ -18,6 +18,7 @@ import { runCli } from "./cli.js";
18
18
  import { startHttpMcpServer } from "./sse.js";
19
19
  import { existsSync } from "fs";
20
20
  import { CONFIG_FILE } from "./config.js";
21
+ import { createToolFilter, toolFilterRequiresRegisterTool } from "./toolSurface.js";
21
22
  // CLI commands: affine-mcp login|status|logout|version
22
23
  const rawArgs = process.argv.slice(2);
23
24
  const cliArgs = rawArgs[0] === "--" ? rawArgs.slice(1) : rawArgs;
@@ -43,21 +44,8 @@ if (subcommand) {
43
44
  const config = loadConfig();
44
45
  const transportMode = (process.env.MCP_TRANSPORT || "stdio").toLowerCase();
45
46
  const useHttpTransport = transportMode === "sse" || transportMode === "http" || transportMode === "streamable";
46
- // ---------------------------------------------------------------------------
47
- // Tool filtering — parsed once at module load (not per-session in HTTP mode)
48
- // ---------------------------------------------------------------------------
49
- const KNOWN_GROUPS = new Set([
50
- "workspaces", "docs", "comments", "history", "organize",
51
- "users", "access_tokens", "blobs", "notifications",
52
- ]);
53
- const DISABLED_GROUPS = new Set((process.env.AFFINE_DISABLED_GROUPS || "")
54
- .split(",")
55
- .map((s) => s.trim().toLowerCase())
56
- .filter(Boolean));
57
- const DISABLED_TOOLS = new Set((process.env.AFFINE_DISABLED_TOOLS || "")
58
- .split(",")
59
- .map((s) => s.trim())
60
- .filter(Boolean));
47
+ // Tool filtering is parsed once at module load (not per-session in HTTP mode).
48
+ const toolFilter = createToolFilter(process.env);
61
49
  // Startup diagnostics (visible in Claude Code MCP server logs via stderr)
62
50
  console.error(`[affine-mcp] Config: ${CONFIG_FILE} (${existsSync(CONFIG_FILE) ? 'found' : 'missing'})`);
63
51
  console.error(`[affine-mcp] Endpoint: ${config.baseUrl}${config.graphqlPath}`);
@@ -70,12 +58,8 @@ if (hasAuth && config.baseUrl.startsWith("http://")
70
58
  console.error("WARNING: Credentials configured over plain HTTP. Use HTTPS for remote servers.");
71
59
  }
72
60
  console.error(`[affine-mcp] Workspace: ${config.defaultWorkspaceId ? 'set' : '(none)'}`);
73
- // Warn about unknown group names (likely typos) before they silently do nothing
74
- for (const g of DISABLED_GROUPS) {
75
- if (!KNOWN_GROUPS.has(g)) {
76
- console.error(`[affine-mcp] WARNING: Unknown group "${g}" in AFFINE_DISABLED_GROUPS — ` +
77
- `valid groups: ${[...KNOWN_GROUPS].join(", ")}`);
78
- }
61
+ for (const warning of toolFilter.warnings) {
62
+ console.error(`[affine-mcp] WARNING: ${warning}`);
79
63
  }
80
64
  if (config.authMode === "oauth" && !useHttpTransport) {
81
65
  throw new Error("AFFINE_MCP_AUTH_MODE=oauth requires MCP_TRANSPORT=http (or streamable/sse).");
@@ -155,52 +139,40 @@ async function buildServer() {
155
139
  console.error("WARNING: No authentication configured. Some operations may fail.");
156
140
  console.error("Set AFFINE_API_TOKEN or run: affine-mcp login");
157
141
  }
158
- // ---------------------------------------------------------------------------
159
- // Per-tool blacklist: patch registerTool on this server instance so individual
160
- // tools in AFFINE_DISABLED_TOOLS are silently skipped during registration.
161
- // All tool files use server.registerTool exclusively — no need to patch server.tool.
162
- // ---------------------------------------------------------------------------
163
- if (DISABLED_TOOLS.size > 0) {
164
- const originalRegisterTool = server.registerTool?.bind(server);
165
- if (typeof originalRegisterTool !== "function") {
166
- console.error("[affine-mcp] WARNING: server.registerTool not found — " +
167
- "AFFINE_DISABLED_TOOLS will have no effect. " +
168
- "The MCP SDK API may have changed.");
169
- }
170
- else {
171
- server.registerTool = (name, options, handler) => {
172
- if (DISABLED_TOOLS.has(name))
173
- return;
174
- return originalRegisterTool(name, options, handler);
175
- };
142
+ const originalRegisterTool = server.registerTool?.bind(server);
143
+ if (typeof originalRegisterTool !== "function") {
144
+ const message = "[affine-mcp] server.registerTool not found - tool filtering cannot be enforced. " +
145
+ "The MCP SDK API may have changed.";
146
+ if (toolFilterRequiresRegisterTool(toolFilter)) {
147
+ throw new Error(`${message} Refusing to start because AFFINE_TOOL_PROFILE is not "full" ` +
148
+ "or AFFINE_DISABLED_GROUPS/AFFINE_DISABLED_TOOLS is configured.");
176
149
  }
150
+ console.error(`[affine-mcp] WARNING: ${message} Continuing with the full tool surface.`);
177
151
  }
178
- // Log filters directly from environment variables
152
+ else {
153
+ server.registerTool = (name, options, handler) => {
154
+ if (!toolFilter.isEnabled(name))
155
+ return;
156
+ return originalRegisterTool(name, options, handler);
157
+ };
158
+ }
159
+ console.error(`[affine-mcp] Tool profile: ${toolFilter.profile}`);
179
160
  console.error(`[affine-mcp] Disabled groups: ${process.env.AFFINE_DISABLED_GROUPS || "(none)"}`);
180
161
  console.error(`[affine-mcp] Disabled tools: ${process.env.AFFINE_DISABLED_TOOLS || "(none)"}`);
181
- if (!DISABLED_GROUPS.has("workspaces"))
182
- registerWorkspaceTools(server, gql);
183
- if (!DISABLED_GROUPS.has("docs"))
184
- registerDocTools(server, gql, { workspaceId: config.defaultWorkspaceId });
185
- if (!DISABLED_GROUPS.has("comments"))
186
- registerCommentTools(server, gql, { workspaceId: config.defaultWorkspaceId });
187
- if (!DISABLED_GROUPS.has("history"))
188
- registerHistoryTools(server, gql, { workspaceId: config.defaultWorkspaceId });
189
- if (!DISABLED_GROUPS.has("organize"))
190
- registerOrganizeTools(server, gql, { workspaceId: config.defaultWorkspaceId });
191
- if (!DISABLED_GROUPS.has("users")) {
192
- registerUserTools(server, gql);
193
- registerUserCRUDTools(server, gql);
194
- if (config.authMode !== "oauth") {
195
- registerAuthTools(server, gql, config.baseUrl);
196
- }
162
+ console.error(`[affine-mcp] Enabled tools: ${toolFilter.enabledTools.length}/${toolFilter.totalToolCount}`);
163
+ registerWorkspaceTools(server, gql);
164
+ registerDocTools(server, gql, { workspaceId: config.defaultWorkspaceId });
165
+ registerCommentTools(server, gql, { workspaceId: config.defaultWorkspaceId });
166
+ registerHistoryTools(server, gql, { workspaceId: config.defaultWorkspaceId });
167
+ registerOrganizeTools(server, gql, { workspaceId: config.defaultWorkspaceId });
168
+ registerUserTools(server, gql);
169
+ registerUserCRUDTools(server, gql);
170
+ if (config.authMode !== "oauth") {
171
+ registerAuthTools(server, gql, config.baseUrl);
197
172
  }
198
- if (!DISABLED_GROUPS.has("access_tokens"))
199
- registerAccessTokenTools(server, gql);
200
- if (!DISABLED_GROUPS.has("blobs"))
201
- registerBlobTools(server, gql);
202
- if (!DISABLED_GROUPS.has("notifications"))
203
- registerNotificationTools(server, gql);
173
+ registerAccessTokenTools(server, gql);
174
+ registerBlobTools(server, gql);
175
+ registerNotificationTools(server, gql);
204
176
  return server;
205
177
  }
206
178
  async function start() {
@@ -59,6 +59,30 @@ function findMatchingInline(tokens, start, openType, closeType) {
59
59
  function deltaToString(deltas) {
60
60
  return deltas.map(delta => delta.insert).join("");
61
61
  }
62
+ /**
63
+ * Strip deltas corresponding to the first line (up to and including the first
64
+ * "\n" separator). Used by callout parsing to remove the `[!NOTE]` marker
65
+ * line from the collected blockquote deltas.
66
+ */
67
+ function stripFirstDeltaLine(deltas) {
68
+ const result = [];
69
+ let pastNewline = false;
70
+ for (const delta of deltas) {
71
+ if (pastNewline) {
72
+ result.push(delta);
73
+ continue;
74
+ }
75
+ const nlIndex = delta.insert.indexOf("\n");
76
+ if (nlIndex >= 0) {
77
+ pastNewline = true;
78
+ const remainder = delta.insert.slice(nlIndex + 1);
79
+ if (remainder.length > 0) {
80
+ result.push({ ...delta, insert: remainder });
81
+ }
82
+ }
83
+ }
84
+ return result;
85
+ }
62
86
  function renderInline(children) {
63
87
  function applyAttrs(deltas, attrs) {
64
88
  return deltas.map(delta => ({
@@ -234,23 +258,37 @@ function parseTable(tokens, start, end) {
234
258
  }
235
259
  function collectQuoteText(tokens, start, end) {
236
260
  const lines = [];
261
+ const allDeltas = [];
262
+ let firstLine = true;
237
263
  for (let i = start; i < end; i += 1) {
238
264
  const token = tokens[i];
239
265
  if (token.type === "inline") {
240
- const line = deltaToString(renderInline(token.children ?? [])).trim();
266
+ const lineDeltas = renderInline(token.children ?? []);
267
+ const line = deltaToString(lineDeltas).trim();
241
268
  if (line) {
269
+ if (!firstLine) {
270
+ allDeltas.push({ insert: "\n" });
271
+ }
272
+ allDeltas.push(...lineDeltas);
242
273
  lines.push(line);
274
+ firstLine = false;
243
275
  }
244
276
  continue;
245
277
  }
246
278
  if (token.type === "fence" || token.type === "code_block") {
247
279
  const language = (token.info ?? "").trim();
248
280
  const codeBody = token.content.replace(/\n$/, "");
249
- lines.push(`\`\`\`${language}\n${codeBody}\n\`\`\``);
281
+ const fenceText = `\`\`\`${language}\n${codeBody}\n\`\`\``;
282
+ if (!firstLine) {
283
+ allDeltas.push({ insert: "\n" });
284
+ }
285
+ allDeltas.push({ insert: fenceText });
286
+ lines.push(fenceText);
287
+ firstLine = false;
250
288
  continue;
251
289
  }
252
290
  }
253
- return lines.join("\n");
291
+ return { text: lines.join("\n"), deltas: allDeltas };
254
292
  }
255
293
  function parseCalloutAdmonition(text) {
256
294
  const lines = text.split("\n");
@@ -361,8 +399,9 @@ function parseTokens(tokens, start, end, state) {
361
399
  const levelNum = Number((token.tag ?? "h1").replace("h", ""));
362
400
  const level = Math.max(1, Math.min(6, levelNum));
363
401
  const inline = tokens.slice(i + 1, close).find(inner => inner.type === "inline");
364
- const text = inline ? deltaToString(renderInline(inline.children ?? [])).trim() : "";
365
- state.operations.push({ type: "heading", level, text });
402
+ const headingDeltas = inline ? renderInline(inline.children ?? []) : [];
403
+ const text = deltaToString(headingDeltas).trim();
404
+ state.operations.push({ type: "heading", level, text, deltas: headingDeltas });
366
405
  i = close + 1;
367
406
  break;
368
407
  }
@@ -402,9 +441,10 @@ function parseTokens(tokens, start, end, state) {
402
441
  i = close + 1;
403
442
  break;
404
443
  }
405
- const text = deltaToString(renderInline(children)).trim();
444
+ const paragraphDeltas = renderInline(children);
445
+ const text = deltaToString(paragraphDeltas).trim();
406
446
  if (text.length > 0) {
407
- state.operations.push({ type: "paragraph", text });
447
+ state.operations.push({ type: "paragraph", text, deltas: paragraphDeltas });
408
448
  }
409
449
  i = close + 1;
410
450
  break;
@@ -429,13 +469,14 @@ function parseTokens(tokens, start, end, state) {
429
469
  i += 1;
430
470
  break;
431
471
  }
432
- const quoteText = collectQuoteText(tokens, i + 1, close).trim();
472
+ const quoteResult = collectQuoteText(tokens, i + 1, close);
473
+ const quoteText = quoteResult.text.trim();
433
474
  const calloutText = parseCalloutAdmonition(quoteText);
434
475
  if (calloutText !== null) {
435
- state.operations.push({ type: "callout", text: calloutText });
476
+ state.operations.push({ type: "callout", text: calloutText, deltas: stripFirstDeltaLine(quoteResult.deltas) });
436
477
  }
437
478
  else if (quoteText.length > 0) {
438
- state.operations.push({ type: "quote", text: quoteText });
479
+ state.operations.push({ type: "quote", text: quoteText, deltas: quoteResult.deltas });
439
480
  }
440
481
  i = close + 1;
441
482
  break;