excalidrop 0.1.1

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.
Files changed (234) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +411 -0
  3. package/dist/cli.d.ts +24 -0
  4. package/dist/cli.d.ts.map +1 -0
  5. package/dist/cli.js +533 -0
  6. package/dist/cli.js.map +1 -0
  7. package/dist/frontend/assets/Assistant-Bold-gm-uSS1B.woff2 +0 -0
  8. package/dist/frontend/assets/Assistant-Medium-DrcxCXg3.woff2 +0 -0
  9. package/dist/frontend/assets/Assistant-Regular-DVxZuzxb.woff2 +0 -0
  10. package/dist/frontend/assets/Assistant-SemiBold-SCI4bEL9.woff2 +0 -0
  11. package/dist/frontend/assets/Tableau10-B-NsZVaP.js +1 -0
  12. package/dist/frontend/assets/_commonjs-dynamic-modules-TDtrdbi3.js +1 -0
  13. package/dist/frontend/assets/advancedFormat-bKUmsig-.js +1 -0
  14. package/dist/frontend/assets/ar-SA-G6X2FPQ2-C7K_UR3P.js +10 -0
  15. package/dist/frontend/assets/arc-BI_FJWE7.js +1 -0
  16. package/dist/frontend/assets/array-BKyUJesY.js +1 -0
  17. package/dist/frontend/assets/az-AZ-76LH7QW2-PkhuRuSr.js +1 -0
  18. package/dist/frontend/assets/band-dPffDWoQ.js +1 -0
  19. package/dist/frontend/assets/bg-BG-XCXSNQG7-CgLRSYTl.js +5 -0
  20. package/dist/frontend/assets/blockDiagram-38ab4fdb-BFdBRYSn.js +118 -0
  21. package/dist/frontend/assets/blockDiagram-68f4deed-Ynbqr7bQ.js +118 -0
  22. package/dist/frontend/assets/bn-BD-2XOGV67Q-CEvkU0mz.js +5 -0
  23. package/dist/frontend/assets/c4Diagram-15b5d702-Dm7F1Pn_.js +10 -0
  24. package/dist/frontend/assets/c4Diagram-3d4e48cf-COkK-e67.js +10 -0
  25. package/dist/frontend/assets/ca-ES-6MX7JW3Y-DzP6q9bE.js +8 -0
  26. package/dist/frontend/assets/channel-BqCiG5n3.js +1 -0
  27. package/dist/frontend/assets/classDiagram-70f12bd4-CRzulwvH.js +2 -0
  28. package/dist/frontend/assets/classDiagram-d40c83e7-DNbA-n-a.js +2 -0
  29. package/dist/frontend/assets/classDiagram-v2-d5a6b087-l-GglywH.js +2 -0
  30. package/dist/frontend/assets/classDiagram-v2-f2320105-B_TY1zoc.js +2 -0
  31. package/dist/frontend/assets/clone-B6Jqcrku.js +1 -0
  32. package/dist/frontend/assets/createText-2e5e7dd3-DB_k52Pn.js +5 -0
  33. package/dist/frontend/assets/createText-d213de94-D-G49giw.js +5 -0
  34. package/dist/frontend/assets/cs-CZ-2BRQDIVT-Bf4TOzpR.js +11 -0
  35. package/dist/frontend/assets/cytoscape-cose-bilkent-DQYypj0P.js +331 -0
  36. package/dist/frontend/assets/da-DK-5WZEPLOC-jO-5kyXb.js +5 -0
  37. package/dist/frontend/assets/de-DE-XR44H4JA-BkNXWOVh.js +8 -0
  38. package/dist/frontend/assets/directory-open-01563666-DWU9wJ6I.js +1 -0
  39. package/dist/frontend/assets/directory-open-4ed118d0-BzWybGaI.js +1 -0
  40. package/dist/frontend/assets/edges-332bd1c7-CVvMtkTa.js +4 -0
  41. package/dist/frontend/assets/edges-e0da2a9e-DQBAvC8-.js +4 -0
  42. package/dist/frontend/assets/el-GR-BZB4AONW-Dv7Y9hMk.js +10 -0
  43. package/dist/frontend/assets/elk.bundled-YVS3PsMH.js +26 -0
  44. package/dist/frontend/assets/erDiagram-880f2ed8-BjBeR38f.js +51 -0
  45. package/dist/frontend/assets/erDiagram-9861fffd-B2Bi3R45.js +51 -0
  46. package/dist/frontend/assets/es-ES-U4NZUMDT-CWhs0SQY.js +9 -0
  47. package/dist/frontend/assets/eu-ES-A7QVB2H4-BfkFwc3e.js +11 -0
  48. package/dist/frontend/assets/fa-IR-HGAKTJCU-BgWzSj46.js +8 -0
  49. package/dist/frontend/assets/fi-FI-Z5N7JZ37-CUyvkum-.js +6 -0
  50. package/dist/frontend/assets/file-open-002ab408-DIuFHtCF.js +1 -0
  51. package/dist/frontend/assets/file-open-7c801643-684qeFg4.js +1 -0
  52. package/dist/frontend/assets/file-save-3189631c-x92wctJd.js +1 -0
  53. package/dist/frontend/assets/file-save-745eba88-Bb9F9Kg7.js +1 -0
  54. package/dist/frontend/assets/flowDb-7c981674-BSZyyVBf.js +10 -0
  55. package/dist/frontend/assets/flowDb-956e92f1-BC3-CLkF.js +10 -0
  56. package/dist/frontend/assets/flowDiagram-66a62f08-H57DAvmM.js +4 -0
  57. package/dist/frontend/assets/flowDiagram-cbd28bf7-Bw4Z2nBD.js +4 -0
  58. package/dist/frontend/assets/flowDiagram-v2-96b9c2cf-CBjbm_oB.js +1 -0
  59. package/dist/frontend/assets/flowDiagram-v2-ffc7f31a-CoQufPp2.js +1 -0
  60. package/dist/frontend/assets/flowchart-elk-definition-36e2d292-1kZNdf3X.js +114 -0
  61. package/dist/frontend/assets/flowchart-elk-definition-4a651766-DDhG2Esi.js +114 -0
  62. package/dist/frontend/assets/fr-FR-RHASNOE6-7xzoRwbd.js +9 -0
  63. package/dist/frontend/assets/ganttDiagram-04f9e578-CtUSsXCz.js +257 -0
  64. package/dist/frontend/assets/ganttDiagram-c361ad54-K42AklFY.js +257 -0
  65. package/dist/frontend/assets/gitGraphDiagram-21fc4d3e-uW6XA6Y6.js +70 -0
  66. package/dist/frontend/assets/gitGraphDiagram-72cf32ee-CCNbhnBg.js +70 -0
  67. package/dist/frontend/assets/gl-ES-HMX3MZ6V-CXuDDwNP.js +10 -0
  68. package/dist/frontend/assets/graph-CYOmcSYN.js +1 -0
  69. package/dist/frontend/assets/graph-o42dOfI7.js +1 -0
  70. package/dist/frontend/assets/he-IL-6SHJWFNN-O9dzh9EM.js +10 -0
  71. package/dist/frontend/assets/hi-IN-IWLTKZ5I-DlrOXhWs.js +4 -0
  72. package/dist/frontend/assets/hu-HU-A5ZG7DT2-C3eNg-uI.js +7 -0
  73. package/dist/frontend/assets/id-ID-SAP4L64H-6iM4pI5c.js +10 -0
  74. package/dist/frontend/assets/image-blob-reduce.esm-B6b2_-a4.js +7 -0
  75. package/dist/frontend/assets/index-3862675e-DrvLhypC.js +1 -0
  76. package/dist/frontend/assets/index-6079d271-BgVTRqg2.js +1 -0
  77. package/dist/frontend/assets/index-BRA5OktX.css +1 -0
  78. package/dist/frontend/assets/index-D-cr7jpJ.js +349 -0
  79. package/dist/frontend/assets/index-DGmpr33w.js +3 -0
  80. package/dist/frontend/assets/index-HkUXslJy.js +87 -0
  81. package/dist/frontend/assets/infoDiagram-4a4f5b27-ZxmAcaW1.js +7 -0
  82. package/dist/frontend/assets/infoDiagram-f8f76790-Bn-PhiFG.js +7 -0
  83. package/dist/frontend/assets/init-Gi6I4Gst.js +1 -0
  84. package/dist/frontend/assets/it-IT-JPQ66NNP-B26NQzGp.js +11 -0
  85. package/dist/frontend/assets/ja-JP-DBVTYXUO-CB0fvnYJ.js +8 -0
  86. package/dist/frontend/assets/journeyDiagram-29694f62-ggDKs06f.js +139 -0
  87. package/dist/frontend/assets/journeyDiagram-49397b02-pdDS-n-z.js +139 -0
  88. package/dist/frontend/assets/kaa-6HZHGXH3-YNdBdcwJ.js +1 -0
  89. package/dist/frontend/assets/kab-KAB-ZGHBKWFO-D86pwZO0.js +8 -0
  90. package/dist/frontend/assets/katex-ChWnQ-fc.js +261 -0
  91. package/dist/frontend/assets/kk-KZ-P5N5QNE5-CpG5oheM.js +1 -0
  92. package/dist/frontend/assets/km-KH-HSX4SM5Z-DYOZ3Azw.js +11 -0
  93. package/dist/frontend/assets/ko-KR-MTYHY66A-BHorm6lC.js +9 -0
  94. package/dist/frontend/assets/ku-TR-6OUDTVRD-CM9RgEHv.js +9 -0
  95. package/dist/frontend/assets/layout-CCtkxrAf.js +1 -0
  96. package/dist/frontend/assets/layout-DW9Qf-At.js +1 -0
  97. package/dist/frontend/assets/line-DvZfgg9P.js +1 -0
  98. package/dist/frontend/assets/linear-FwFrGRfa.js +1 -0
  99. package/dist/frontend/assets/lt-LT-XHIRWOB4-DvriKXBj.js +3 -0
  100. package/dist/frontend/assets/lv-LV-5QDEKY6T-DzQpwk87.js +7 -0
  101. package/dist/frontend/assets/mindmap-definition-ac74a2e8-DsRo8f2w.js +95 -0
  102. package/dist/frontend/assets/mindmap-definition-fc14e90a-dQ9reluI.js +95 -0
  103. package/dist/frontend/assets/mr-IN-CRQNXWMA-C7RzxxP5.js +13 -0
  104. package/dist/frontend/assets/my-MM-5M5IBNSE-B-RRuy29.js +1 -0
  105. package/dist/frontend/assets/nb-NO-T6EIAALU-B47x16Ik.js +10 -0
  106. package/dist/frontend/assets/nl-NL-IS3SIHDZ-BxAKoQne.js +8 -0
  107. package/dist/frontend/assets/nn-NO-6E72VCQL-dfpbEDSZ.js +8 -0
  108. package/dist/frontend/assets/oc-FR-POXYY2M6-gv6geBjV.js +8 -0
  109. package/dist/frontend/assets/ordinal-Cboi1Yqb.js +1 -0
  110. package/dist/frontend/assets/pa-IN-N4M65BXN-CY30-qH7.js +4 -0
  111. package/dist/frontend/assets/path-CbwjOpE9.js +1 -0
  112. package/dist/frontend/assets/pica-DWYGHLQV.js +7 -0
  113. package/dist/frontend/assets/pie-CDiWh1rj.js +1 -0
  114. package/dist/frontend/assets/pieDiagram-421022e6-Bgy-duyf.js +35 -0
  115. package/dist/frontend/assets/pieDiagram-8a3498a8-DH7FsbPK.js +35 -0
  116. package/dist/frontend/assets/pl-PL-T2D74RX3-TrHugsIA.js +9 -0
  117. package/dist/frontend/assets/pt-BR-5N22H2LF-CJxdFp9c.js +9 -0
  118. package/dist/frontend/assets/pt-PT-UZXXM6DQ-CB-1q6YJ.js +9 -0
  119. package/dist/frontend/assets/quadrantDiagram-0957ecba-DcwYI1oi.js +7 -0
  120. package/dist/frontend/assets/quadrantDiagram-120e2f19-CvHU5a9v.js +7 -0
  121. package/dist/frontend/assets/requirementDiagram-23d650b8-tdagN8HP.js +52 -0
  122. package/dist/frontend/assets/requirementDiagram-deff3bca-BMGM-fSI.js +52 -0
  123. package/dist/frontend/assets/ro-RO-JPDTUUEW-DQq9DBOd.js +11 -0
  124. package/dist/frontend/assets/roundRect-0PYZxl1G.js +1 -0
  125. package/dist/frontend/assets/ru-RU-B4JR7IUQ-CWX32rfh.js +9 -0
  126. package/dist/frontend/assets/sankeyDiagram-04a897e0-3i6zWpY2.js +8 -0
  127. package/dist/frontend/assets/sankeyDiagram-23345273-ncj1Ol7S.js +8 -0
  128. package/dist/frontend/assets/sankeyLinkHorizontal-DgqkLiUE.js +1 -0
  129. package/dist/frontend/assets/selectAll-DJwesbwT.js +1 -0
  130. package/dist/frontend/assets/sequenceDiagram-17ac3bff-ChyKKRZA.js +122 -0
  131. package/dist/frontend/assets/sequenceDiagram-704730f1-D57_wP9k.js +122 -0
  132. package/dist/frontend/assets/si-LK-N5RQ5JYF-Lkc5ZxyG.js +1 -0
  133. package/dist/frontend/assets/sk-SK-C5VTKIMK-DyvYXjqZ.js +6 -0
  134. package/dist/frontend/assets/sl-SI-NN7IZMDC-D_MvP9Rh.js +6 -0
  135. package/dist/frontend/assets/stateDiagram-587899a1-OjFTnFLS.js +1 -0
  136. package/dist/frontend/assets/stateDiagram-9c5f0230-BzU36S5C.js +1 -0
  137. package/dist/frontend/assets/stateDiagram-v2-51a3dcff-CfX1qo7U.js +1 -0
  138. package/dist/frontend/assets/stateDiagram-v2-d93cdb3a-BH7Ud4sU.js +1 -0
  139. package/dist/frontend/assets/styles-2ab5d517-CA8x9Nh5.js +116 -0
  140. package/dist/frontend/assets/styles-5f03d8d2-BnO2pLhV.js +160 -0
  141. package/dist/frontend/assets/styles-6aaf32cf-COnot1ea.js +207 -0
  142. package/dist/frontend/assets/styles-9a916d00-C3QkBgTF.js +160 -0
  143. package/dist/frontend/assets/styles-c10674c1-CPAG2_rr.js +116 -0
  144. package/dist/frontend/assets/styles-edf9a4b0-B8Q9fyu0.js +207 -0
  145. package/dist/frontend/assets/subset-shared.chunk.js +84 -0
  146. package/dist/frontend/assets/subset-worker.chunk.js +1 -0
  147. package/dist/frontend/assets/sv-SE-XGPEYMSR-t6voGQSI.js +10 -0
  148. package/dist/frontend/assets/svgDrawCommon-08f97a94-CdT21LWf.js +1 -0
  149. package/dist/frontend/assets/svgDrawCommon-3ba9043b-D1MU7jAE.js +1 -0
  150. package/dist/frontend/assets/ta-IN-2NMHFXQM-7VmfDDyV.js +9 -0
  151. package/dist/frontend/assets/th-TH-HPSO5L25-B5rLEY9P.js +2 -0
  152. package/dist/frontend/assets/timeline-definition-7e6b55e7-BIcL44O_.js +61 -0
  153. package/dist/frontend/assets/timeline-definition-85554ec2-TgrYYwpU.js +61 -0
  154. package/dist/frontend/assets/tr-TR-DEFEU3FU-0CgOHXTn.js +7 -0
  155. package/dist/frontend/assets/uk-UA-QMV73CPH-CzSFsIsx.js +6 -0
  156. package/dist/frontend/assets/union-CnQAJu0l.js +1 -0
  157. package/dist/frontend/assets/vi-VN-M7AON7JQ-Bhx3rYU9.js +5 -0
  158. package/dist/frontend/assets/xml-BOsq7VnW.js +1 -0
  159. package/dist/frontend/assets/xychartDiagram-b6496bcd-BMKDop6v.js +7 -0
  160. package/dist/frontend/assets/xychartDiagram-e933f94c-DAWithrN.js +7 -0
  161. package/dist/frontend/assets/zh-CN-LNUGB5OW-Br52_wH4.js +10 -0
  162. package/dist/frontend/assets/zh-HK-E62DVLB3-Cj6mnIQ9.js +1 -0
  163. package/dist/frontend/assets/zh-TW-RAJ6MFWO-yLEsVBbC.js +9 -0
  164. package/dist/frontend/assets/zipObject-BeXj5PVd.js +1 -0
  165. package/dist/frontend/index.html +339 -0
  166. package/dist/index.d.ts +4 -0
  167. package/dist/index.d.ts.map +1 -0
  168. package/dist/index.js +2305 -0
  169. package/dist/index.js.map +1 -0
  170. package/dist/remote.d.ts +4 -0
  171. package/dist/remote.d.ts.map +1 -0
  172. package/dist/remote.js +133 -0
  173. package/dist/remote.js.map +1 -0
  174. package/dist/server.d.ts +4 -0
  175. package/dist/server.d.ts.map +1 -0
  176. package/dist/server.js +1217 -0
  177. package/dist/server.js.map +1 -0
  178. package/dist/target.d.ts +41 -0
  179. package/dist/target.d.ts.map +1 -0
  180. package/dist/target.js +244 -0
  181. package/dist/target.js.map +1 -0
  182. package/dist/types.d.ts +228 -0
  183. package/dist/types.d.ts.map +1 -0
  184. package/dist/types.js +53 -0
  185. package/dist/types.js.map +1 -0
  186. package/dist/utils/auth.d.ts +15 -0
  187. package/dist/utils/auth.d.ts.map +1 -0
  188. package/dist/utils/auth.js +68 -0
  189. package/dist/utils/auth.js.map +1 -0
  190. package/dist/utils/github.d.ts +26 -0
  191. package/dist/utils/github.d.ts.map +1 -0
  192. package/dist/utils/github.js +111 -0
  193. package/dist/utils/github.js.map +1 -0
  194. package/dist/utils/logger.d.ts +4 -0
  195. package/dist/utils/logger.d.ts.map +1 -0
  196. package/dist/utils/logger.js +25 -0
  197. package/dist/utils/logger.js.map +1 -0
  198. package/dist/utils/r2store.d.ts +19 -0
  199. package/dist/utils/r2store.d.ts.map +1 -0
  200. package/dist/utils/r2store.js +75 -0
  201. package/dist/utils/r2store.js.map +1 -0
  202. package/frontend/index.html +338 -0
  203. package/frontend/src/App.tsx +1091 -0
  204. package/frontend/src/main.tsx +15 -0
  205. package/frontend/src/utils/ghSync.ts +171 -0
  206. package/frontend/src/utils/mermaidConverter.ts +49 -0
  207. package/frontend/src/utils/webmcp.ts +285 -0
  208. package/package.json +101 -0
  209. package/scripts/check-local-bind.mjs +164 -0
  210. package/scripts/postinstall.mjs +23 -0
  211. package/scripts/publish-pages.sh +39 -0
  212. package/scripts/seed-r2.mjs +9 -0
  213. package/skills/excalidraw-skill/SKILL.md +303 -0
  214. package/skills/excalidraw-skill/evals/evals.json +23 -0
  215. package/skills/excalidraw-skill/references/cheatsheet.md +142 -0
  216. package/skills/excalidraw-skill/scripts/clear-canvas.cjs +38 -0
  217. package/skills/excalidraw-skill/scripts/create-element.cjs +68 -0
  218. package/skills/excalidraw-skill/scripts/delete-element.cjs +48 -0
  219. package/skills/excalidraw-skill/scripts/export-elements.cjs +53 -0
  220. package/skills/excalidraw-skill/scripts/healthcheck.cjs +35 -0
  221. package/skills/excalidraw-skill/scripts/import-elements.cjs +81 -0
  222. package/skills/excalidraw-skill/scripts/update-element.cjs +70 -0
  223. package/src/cli.ts +559 -0
  224. package/src/index.ts +2557 -0
  225. package/src/remote.ts +96 -0
  226. package/src/server.ts +1349 -0
  227. package/src/target.ts +208 -0
  228. package/src/types.ts +338 -0
  229. package/src/utils/auth.ts +61 -0
  230. package/src/utils/github.ts +93 -0
  231. package/src/utils/logger.ts +35 -0
  232. package/src/utils/r2store.ts +76 -0
  233. package/tsconfig.json +50 -0
  234. package/vite.config.js +37 -0
@@ -0,0 +1,1091 @@
1
+ import React, { useState, useEffect, useRef } from 'react'
2
+ import {
3
+ Excalidraw,
4
+ MainMenu,
5
+ convertToExcalidrawElements,
6
+ CaptureUpdateAction,
7
+ ExcalidrawImperativeAPI,
8
+ exportToBlob,
9
+ exportToSvg
10
+ } from '@excalidraw/excalidraw'
11
+ import type { ExcalidrawElement, NonDeleted, NonDeletedExcalidrawElement } from '@excalidraw/excalidraw/types/element/types'
12
+ import { convertMermaidToExcalidraw, DEFAULT_MERMAID_CONFIG } from './utils/mermaidConverter'
13
+ import type { MermaidConfig } from '@excalidraw/mermaid-to-excalidraw'
14
+ import { APP_INSTALL_URL } from './utils/ghSync'
15
+ import { registerCanvasWebMCP } from './utils/webmcp'
16
+
17
+
18
+ type ExcalidrawAPIRefValue = ExcalidrawImperativeAPI;
19
+
20
+ interface ServerElement {
21
+ id: string;
22
+ type: string;
23
+ x: number;
24
+ y: number;
25
+ width?: number;
26
+ height?: number;
27
+ backgroundColor?: string;
28
+ strokeColor?: string;
29
+ strokeWidth?: number;
30
+ roughness?: number;
31
+ opacity?: number;
32
+ text?: string;
33
+ fontSize?: number;
34
+ fontFamily?: string | number;
35
+ label?: {
36
+ text: string;
37
+ };
38
+ createdAt?: string;
39
+ updatedAt?: string;
40
+ version?: number;
41
+ syncedAt?: string;
42
+ source?: string;
43
+ syncTimestamp?: string;
44
+ boundElements?: any[] | null;
45
+ containerId?: string | null;
46
+ locked?: boolean;
47
+
48
+ start?: { id: string };
49
+ end?: { id: string };
50
+ strokeStyle?: string;
51
+ endArrowhead?: string;
52
+ startArrowhead?: string;
53
+
54
+ fileId?: string;
55
+ status?: string;
56
+ scale?: [number, number];
57
+ angle?: number;
58
+ link?: string | null;
59
+ }
60
+
61
+ interface WebSocketMessage {
62
+ type: string;
63
+ element?: ServerElement;
64
+ elements?: ServerElement[];
65
+ elementId?: string;
66
+ count?: number;
67
+ timestamp?: string;
68
+ source?: string;
69
+ mermaidDiagram?: string;
70
+ config?: MermaidConfig;
71
+ }
72
+
73
+ interface ApiResponse {
74
+ success: boolean;
75
+ elements?: ServerElement[];
76
+ element?: ServerElement;
77
+ files?: Record<string, unknown>;
78
+ count?: number;
79
+ error?: string;
80
+ message?: string;
81
+ }
82
+
83
+ type SyncStatus = 'idle' | 'syncing' | 'success' | 'error';
84
+ const authHeaders = (): Record<string, string> => {
85
+ const t = localStorage.getItem('excalidrop_token');
86
+ return t ? { Authorization: `Bearer ${t}` } : {};
87
+ };
88
+ const AUTO_SYNC_DEBOUNCE_MS = 1200;
89
+
90
+
91
+ const cleanElementForExcalidraw = (element: ServerElement): Partial<ExcalidrawElement> => {
92
+ const {
93
+ createdAt,
94
+ updatedAt,
95
+ version,
96
+ syncedAt,
97
+ source,
98
+ syncTimestamp,
99
+ ...cleanElement
100
+ } = element;
101
+ return cleanElement;
102
+ }
103
+
104
+ const validateAndFixBindings = (elements: Partial<ExcalidrawElement>[]): Partial<ExcalidrawElement>[] => {
105
+ const elementMap = new Map(elements.map(el => [el.id!, el]));
106
+
107
+ return elements.map(element => {
108
+ const fixedElement = { ...element };
109
+
110
+ if (fixedElement.boundElements) {
111
+ if (Array.isArray(fixedElement.boundElements)) {
112
+ fixedElement.boundElements = fixedElement.boundElements.filter((binding: any) => {
113
+ if (!binding || typeof binding !== 'object') return false;
114
+ if (!binding.id || !binding.type) return false;
115
+
116
+ const referencedElement = elementMap.get(binding.id);
117
+ if (!referencedElement) return false;
118
+
119
+ if (!['text', 'arrow'].includes(binding.type)) return false;
120
+
121
+ return true;
122
+ });
123
+
124
+ if (fixedElement.boundElements.length === 0) {
125
+ fixedElement.boundElements = null;
126
+ }
127
+ } else {
128
+ fixedElement.boundElements = null;
129
+ }
130
+ }
131
+
132
+ if (fixedElement.containerId) {
133
+ const containerElement = elementMap.get(fixedElement.containerId);
134
+ if (!containerElement) {
135
+ fixedElement.containerId = null;
136
+ }
137
+ }
138
+
139
+ return fixedElement;
140
+ });
141
+ }
142
+
143
+ const isImageElement = (element: Partial<ExcalidrawElement>): boolean => {
144
+ return element.type === 'image'
145
+ }
146
+
147
+ const isShapeContainerType = (type: string | undefined): boolean => {
148
+ return type === 'rectangle' || type === 'ellipse' || type === 'diamond'
149
+ }
150
+
151
+ const recenterBoundShapeTextElements = (
152
+ elements: Partial<ExcalidrawElement>[]
153
+ ): Partial<ExcalidrawElement>[] => {
154
+ const elementMap = new Map(elements.map((el) => [el.id, el]))
155
+
156
+ return elements.map((element) => {
157
+ if (element.type !== 'text' || !element.containerId) {
158
+ return element
159
+ }
160
+
161
+ const textElement = element as ExcalidrawElement & { type: 'text'; containerId: string; autoResize?: boolean }
162
+ const container = elementMap.get(textElement.containerId) as (ExcalidrawElement & { x: number; y: number; width: number; height: number }) | undefined
163
+ if (!container || !isShapeContainerType(container.type)) {
164
+ return element
165
+ }
166
+
167
+ if (textElement.autoResize === false) {
168
+ return element
169
+ }
170
+
171
+ if (
172
+ typeof container.x !== 'number' ||
173
+ typeof container.y !== 'number' ||
174
+ typeof container.width !== 'number' ||
175
+ typeof container.height !== 'number' ||
176
+ typeof textElement.width !== 'number' ||
177
+ typeof textElement.height !== 'number'
178
+ ) {
179
+ return element
180
+ }
181
+
182
+ return {
183
+ ...element,
184
+ x: container.x + (container.width - textElement.width) / 2,
185
+ y: container.y + (container.height - textElement.height) / 2,
186
+ }
187
+ })
188
+ }
189
+
190
+ const normalizeImageElement = (element: Partial<ExcalidrawElement>): Partial<ExcalidrawElement> => {
191
+ const img = element as any
192
+ return {
193
+ ...img,
194
+ angle: img.angle || 0,
195
+ strokeColor: img.strokeColor || 'transparent',
196
+ backgroundColor: img.backgroundColor || 'transparent',
197
+ fillStyle: img.fillStyle || 'solid',
198
+ strokeWidth: img.strokeWidth || 1,
199
+ strokeStyle: img.strokeStyle || 'solid',
200
+ roughness: img.roughness ?? 0,
201
+ opacity: img.opacity ?? 100,
202
+ groupIds: img.groupIds || [],
203
+ roundness: null,
204
+ seed: img.seed || Math.floor(Math.random() * 1000000),
205
+ version: img.version || 1,
206
+ versionNonce: img.versionNonce || Math.floor(Math.random() * 1000000),
207
+ isDeleted: img.isDeleted ?? false,
208
+ boundElements: img.boundElements || null,
209
+ link: img.link || null,
210
+ locked: img.locked || false,
211
+ status: img.status || 'saved',
212
+ fileId: img.fileId,
213
+ scale: img.scale || [1, 1],
214
+ }
215
+ }
216
+
217
+ const restoreBindings = (
218
+ convertedElements: readonly any[],
219
+ originalElements: Partial<ExcalidrawElement>[]
220
+ ): any[] => {
221
+ const originalMap = new Map<string, any>();
222
+ for (const el of originalElements) {
223
+ if (el.id) originalMap.set(el.id, el);
224
+ }
225
+
226
+ return convertedElements.map((el: any) => {
227
+ const orig = originalMap.get(el.id);
228
+ if (!orig) return el;
229
+
230
+ const patched = { ...el };
231
+
232
+ if (orig.startBinding && !el.startBinding) {
233
+ patched.startBinding = orig.startBinding;
234
+ }
235
+ if (orig.endBinding && !el.endBinding) {
236
+ patched.endBinding = orig.endBinding;
237
+ }
238
+ if (orig.boundElements && (!el.boundElements || el.boundElements.length === 0)) {
239
+ patched.boundElements = orig.boundElements;
240
+ }
241
+ if (orig.elbowed !== undefined && el.elbowed === undefined) {
242
+ patched.elbowed = orig.elbowed;
243
+ }
244
+
245
+ return patched;
246
+ });
247
+ };
248
+
249
+ const convertElementsPreservingImageProps = (
250
+ elements: Partial<ExcalidrawElement>[]
251
+ ): Partial<ExcalidrawElement>[] => {
252
+ if (elements.length === 0) return []
253
+
254
+ const validatedElements = validateAndFixBindings(elements)
255
+ const imageElements = validatedElements.filter(isImageElement).map(normalizeImageElement)
256
+ const nonImageElements = validatedElements.filter(el => !isImageElement(el))
257
+ const convertedNonImageElements = convertToExcalidrawElements(nonImageElements as any, { regenerateIds: false })
258
+ const restoredNonImageElements = restoreBindings(convertedNonImageElements, nonImageElements)
259
+ return recenterBoundShapeTextElements([...restoredNonImageElements, ...imageElements])
260
+ }
261
+
262
+ function App(): JSX.Element {
263
+ const [excalidrawAPI, setExcalidrawAPI] = useState<ExcalidrawAPIRefValue | null>(null)
264
+ const excalidrawAPIRef = useRef<ExcalidrawAPIRefValue | null>(null)
265
+ useEffect(() => {
266
+ excalidrawAPIRef.current = excalidrawAPI
267
+ }, [excalidrawAPI])
268
+ useEffect(() => {
269
+ if (!excalidrawAPI) return
270
+ const handle = registerCanvasWebMCP(() => excalidrawAPIRef.current)
271
+ return () => handle.cleanup()
272
+ }, [excalidrawAPI])
273
+ const [isConnected, setIsConnected] = useState<boolean>(false)
274
+ const websocketRef = useRef<WebSocket | null>(null)
275
+
276
+ const [syncStatus, setSyncStatus] = useState<SyncStatus>('idle')
277
+ const [lastSyncTime, setLastSyncTime] = useState<Date | null>(null)
278
+ const autoSyncTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
279
+ const syncInFlightRef = useRef<boolean>(false)
280
+ const suppressAutoSyncCountRef = useRef<number>(0)
281
+ const userInteractedRef = useRef<boolean>(false)
282
+
283
+ const applySceneUpdateWithoutAutoSync = (
284
+ scene: Parameters<ExcalidrawImperativeAPI['updateScene']>[0]
285
+ ): void => {
286
+ suppressAutoSyncCountRef.current += 1
287
+ api.updateScene(scene)
288
+ setTimeout(() => {
289
+ suppressAutoSyncCountRef.current = Math.max(0, suppressAutoSyncCountRef.current - 1)
290
+ }, 0)
291
+ }
292
+
293
+ useEffect(() => {
294
+ return () => {
295
+ if (autoSyncTimerRef.current) {
296
+ clearTimeout(autoSyncTimerRef.current)
297
+ }
298
+ }
299
+ }, [])
300
+
301
+ const [serverMode, setServerMode] = useState<boolean>(false)
302
+ const [ghToken, setGhToken] = useState<string | null>(null)
303
+ const [access, setAccess] = useState<'unknown' | 'editor' | 'viewer' | 'denied'>('unknown')
304
+ const [ghLogin, setGhLogin] = useState<string>('')
305
+ const [ghRepo, setGhRepo] = useState<import('./utils/ghSync').RepoRef | null>(null)
306
+ const [accessDetail, setAccessDetail] = useState<import('./utils/ghSync').AccessDetail>(null)
307
+ const [ghDirty, setGhDirty] = useState<boolean>(false)
308
+ const ghShaRef = useRef<string | null>(null)
309
+ const ghPushInFlightRef = useRef<boolean>(false)
310
+
311
+ useEffect(() => {
312
+ fetch('/api/health', { headers: authHeaders() }).then(r => {
313
+ if (r.ok) { setServerMode(true); connectWebSocket() }
314
+ else { initStaticMode() }
315
+ }).catch(() => initStaticMode())
316
+ return () => {
317
+ if (websocketRef.current) {
318
+ websocketRef.current.close()
319
+ }
320
+ }
321
+ // eslint-disable-next-line react-hooks/exhaustive-deps
322
+ }, [])
323
+
324
+ const initStaticMode = async (): Promise<void> => {
325
+ const gh = await import('./utils/ghSync')
326
+ setGhRepo(gh.detectRepo())
327
+ setGhToken(gh.getToken())
328
+ }
329
+
330
+ useEffect(() => {
331
+ if (serverMode || !ghRepo) return
332
+ if (!ghToken) { setAccess('denied'); setGhLogin(''); setAccessDetail(null); return }
333
+ setAccess('unknown')
334
+ void (async () => {
335
+ try {
336
+ const gh = await import('./utils/ghSync')
337
+ const { access: a, login, detail } = await gh.checkAccess(ghRepo, ghToken)
338
+ setAccess(a)
339
+ setGhLogin(login)
340
+ setAccessDetail(detail)
341
+ } catch {
342
+ setAccess('denied')
343
+ setGhLogin('')
344
+ setAccessDetail(null)
345
+ }
346
+ })()
347
+ }, [serverMode, ghToken, ghRepo])
348
+
349
+ useEffect(() => {
350
+ if (serverMode) return
351
+ const id = setInterval(() => { void pushToGitHub(false) }, 10000)
352
+ const flush = () => { void pushToGitHub(true) }
353
+ const guard = (e: BeforeUnloadEvent) => {
354
+ if (ghDirty || ghPushInFlightRef.current) { e.preventDefault() }
355
+ }
356
+ document.addEventListener('visibilitychange', () => { if (document.hidden) flush() })
357
+ window.addEventListener('pagehide', flush)
358
+ window.addEventListener('beforeunload', guard)
359
+ return () => {
360
+ clearInterval(id)
361
+ document.removeEventListener('visibilitychange', () => { })
362
+ window.removeEventListener('pagehide', flush)
363
+ window.removeEventListener('beforeunload', guard)
364
+ }
365
+ // eslint-disable-next-line react-hooks/exhaustive-deps
366
+ }, [serverMode, ghToken, ghRepo, ghDirty, excalidrawAPI])
367
+
368
+ const pushToGitHub = async (isClosing: boolean): Promise<void> => {
369
+ if (serverMode || !excalidrawAPI || !ghToken || !ghRepo) return
370
+ if (access !== 'editor') return
371
+ if ((!ghDirty && !isClosing) || ghPushInFlightRef.current) return
372
+ ghPushInFlightRef.current = true
373
+ if (!isClosing) setSyncStatus('syncing')
374
+ try {
375
+ const gh = await import('./utils/ghSync')
376
+ const elements = excalidrawAPI.getSceneElements().filter(el => !el.isDeleted)
377
+ const files = excalidrawAPI.getFiles()
378
+ if (isClosing) {
379
+ // Best-effort synchronous flush on close
380
+ const cur = await fetch(`https://api.github.com/repos/${ghRepo.owner}/${ghRepo.repo}/contents/canvas.excalidraw?ref=${ghRepo.branch}`, {
381
+ headers: { Authorization: `Bearer ${ghToken}`, Accept: 'application/vnd.github+json' },
382
+ }).then(r => r.json()).catch(() => null)
383
+ await fetch(`https://api.github.com/repos/${ghRepo.owner}/${ghRepo.repo}/contents/canvas.excalidraw`, {
384
+ method: 'PUT',
385
+ keepalive: true,
386
+ headers: { Authorization: `Bearer ${ghToken}`, Accept: 'application/vnd.github+json', 'Content-Type': 'application/json' },
387
+ body: JSON.stringify({
388
+ message: `excalidrop: force-save on close (${elements.length} elements)`,
389
+ content: btoa(unescape(encodeURIComponent(JSON.stringify({ type: 'excalidraw', version: 2, source: 'excalidrop', elements }, null, 2)))),
390
+ branch: ghRepo.branch,
391
+ ...(cur?.sha ? { sha: cur.sha } : {}),
392
+ }),
393
+ }).catch(() => { })
394
+ } else {
395
+ ghShaRef.current = await gh.pushScene(ghRepo, ghToken, { elements: elements as any, files: files as any })
396
+ setLastSyncTime(new Date())
397
+ setSyncStatus('success')
398
+ setTimeout(() => setSyncStatus('idle'), 2000)
399
+ }
400
+ setGhDirty(false)
401
+ } catch (error) {
402
+ console.error('GitHub push failed:', error)
403
+ if (String((error as Error).message).includes('403') && ghRepo && ghToken) {
404
+ try {
405
+ const gh = await import('./utils/ghSync')
406
+ const { access: a, detail } = await gh.checkAccess(ghRepo, ghToken)
407
+ setAccess(a)
408
+ setAccessDetail(detail)
409
+ } catch { setAccess('viewer') }
410
+ }
411
+ if (!isClosing) setSyncStatus('error')
412
+ } finally {
413
+ ghPushInFlightRef.current = false
414
+ }
415
+ }
416
+
417
+ useEffect(() => {
418
+ if (excalidrawAPI) {
419
+ loadExistingElements()
420
+
421
+ if (!isConnected) {
422
+ connectWebSocket()
423
+ }
424
+ }
425
+ }, [excalidrawAPI, isConnected])
426
+
427
+ const loadExistingElements = async (): Promise<void> => {
428
+ try {
429
+ const response = await fetch('/api/elements', { headers: authHeaders() }).catch(() => null)
430
+ if (response?.ok) {
431
+ const result: ApiResponse = await response.json()
432
+ if (result.success && result.elements && result.elements.length > 0) {
433
+ const cleanedElements = result.elements.map(cleanElementForExcalidraw)
434
+ const convertedElements = convertElementsPreservingImageProps(cleanedElements)
435
+ if (excalidrawAPI) {
436
+ applySceneUpdateWithoutAutoSync(excalidrawAPI, {
437
+ elements: convertedElements,
438
+ captureUpdate: CaptureUpdateAction.NEVER
439
+ })
440
+ }
441
+ }
442
+ const filesResponse = await fetch('/api/files', { headers: authHeaders() })
443
+ if (filesResponse.ok) {
444
+ const filesResult = await filesResponse.json() as ApiResponse
445
+ if (filesResult.files) {
446
+ excalidrawAPI?.addFiles(Object.values(filesResult.files))
447
+ }
448
+ }
449
+ return
450
+ }
451
+ const gh = await import('./utils/ghSync')
452
+ const scene = await gh.loadStaticScene()
453
+ if (scene.elements.length > 0 && excalidrawAPI) {
454
+ const converted = convertElementsPreservingImageProps(scene.elements.map(cleanElementForExcalidraw))
455
+ applySceneUpdateWithoutAutoSync(excalidrawAPI, { elements: converted, captureUpdate: CaptureUpdateAction.NEVER })
456
+ }
457
+ if (scene.files) excalidrawAPI?.addFiles(Object.values(scene.files))
458
+ } catch (error) {
459
+ console.error('Error loading existing elements:', error)
460
+ }
461
+ }
462
+
463
+ const connectWebSocket = (): void => {
464
+ if (websocketRef.current && websocketRef.current.readyState === WebSocket.OPEN) {
465
+ return
466
+ }
467
+
468
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
469
+ const wsUrl = `${protocol}//${window.location.host}`
470
+
471
+ websocketRef.current = new WebSocket(wsUrl)
472
+
473
+ websocketRef.current.onopen = () => {
474
+ setIsConnected(true)
475
+
476
+ if (excalidrawAPI) {
477
+ setTimeout(loadExistingElements, 100)
478
+ }
479
+ }
480
+
481
+ websocketRef.current.onmessage = (event: MessageEvent) => {
482
+ try {
483
+ const data: WebSocketMessage = JSON.parse(event.data)
484
+ handleWebSocketMessage(data)
485
+ } catch (error) {
486
+ console.error('Error parsing WebSocket message:', error, event.data)
487
+ }
488
+ }
489
+
490
+ websocketRef.current.onclose = (event: CloseEvent) => {
491
+ setIsConnected(false)
492
+
493
+ // Reconnect after 3 seconds if not a clean close
494
+ if (event.code !== 1000) {
495
+ setTimeout(connectWebSocket, 3000)
496
+ }
497
+ }
498
+
499
+ websocketRef.current.onerror = (error: Event) => {
500
+ console.error('WebSocket error:', error)
501
+ setIsConnected(false)
502
+ }
503
+ }
504
+
505
+ const handleWebSocketMessage = async (data: WebSocketMessage): Promise<void> => {
506
+ const excalidrawAPI = excalidrawAPIRef.current
507
+ if (!excalidrawAPI) {
508
+ return
509
+ }
510
+
511
+ try {
512
+ const currentElements = excalidrawAPI.getSceneElements()
513
+ const mergeAndApplySceneElements = (incomingElements: Partial<ExcalidrawElement>[]): void => {
514
+ if (incomingElements.length === 0) return
515
+
516
+ const incomingById = new Map<string, Partial<ExcalidrawElement>>()
517
+ incomingElements.forEach((element) => {
518
+ if (element.id) {
519
+ incomingById.set(element.id, element)
520
+ }
521
+ })
522
+
523
+ const mergedElements: Partial<ExcalidrawElement>[] = currentElements.map((element) => {
524
+ const incoming = incomingById.get(element.id)
525
+ if (!incoming) return element
526
+ incomingById.delete(element.id)
527
+ return { ...element, ...incoming }
528
+ })
529
+
530
+ mergedElements.push(...incomingById.values())
531
+
532
+ const convertedElements = convertElementsPreservingImageProps(mergedElements)
533
+ applySceneUpdateWithoutAutoSync(excalidrawAPI, {
534
+ elements: convertedElements,
535
+ captureUpdate: CaptureUpdateAction.NEVER
536
+ })
537
+ }
538
+
539
+ switch (data.type) {
540
+ case 'initial_elements':
541
+ if (data.elements && data.elements.length > 0) {
542
+ const cleanedElements = data.elements.map(cleanElementForExcalidraw)
543
+ const convertedElements = convertElementsPreservingImageProps(cleanedElements)
544
+ applySceneUpdateWithoutAutoSync(excalidrawAPI, {
545
+ elements: convertedElements,
546
+ captureUpdate: CaptureUpdateAction.NEVER
547
+ })
548
+ }
549
+ // Load files for image elements
550
+ if ((data as any).files) {
551
+ excalidrawAPI.addFiles(Object.values((data as any).files))
552
+ }
553
+ break
554
+
555
+ case 'files_added':
556
+ if (Array.isArray((data as any).files)) {
557
+ excalidrawAPI.addFiles((data as any).files)
558
+ }
559
+ break
560
+
561
+ case 'element_created':
562
+ if (data.element) {
563
+ const cleanedNewElement = cleanElementForExcalidraw(data.element)
564
+ // Rebuild against full scene so text/container bindings remain intact.
565
+ mergeAndApplySceneElements([cleanedNewElement])
566
+ }
567
+ break
568
+
569
+ case 'element_updated':
570
+ if (data.element) {
571
+ const cleanedUpdatedElement = cleanElementForExcalidraw(data.element)
572
+ // Convert with full scene context so text metrics/container placement can refresh.
573
+ mergeAndApplySceneElements([cleanedUpdatedElement])
574
+ }
575
+ break
576
+
577
+ case 'element_deleted':
578
+ if (data.elementId) {
579
+ const filteredElements = currentElements.filter(el => el.id !== data.elementId)
580
+ applySceneUpdateWithoutAutoSync(excalidrawAPI, {
581
+ elements: filteredElements,
582
+ captureUpdate: CaptureUpdateAction.NEVER
583
+ })
584
+ }
585
+ break
586
+
587
+ case 'elements_batch_created':
588
+ if (data.elements) {
589
+ const cleanedBatchElements = data.elements.map(cleanElementForExcalidraw)
590
+ mergeAndApplySceneElements(cleanedBatchElements)
591
+ }
592
+ break
593
+
594
+ case 'elements_synced':
595
+ console.log(`Sync confirmed by server: ${data.count} elements`)
596
+ // Sync confirmation already handled by HTTP response
597
+ break
598
+
599
+ case 'sync_status':
600
+ console.log(`Server sync status: ${data.count} elements`)
601
+ break
602
+
603
+ case 'canvas_cleared':
604
+ console.log('Canvas cleared by server')
605
+ applySceneUpdateWithoutAutoSync(excalidrawAPI, {
606
+ elements: [],
607
+ captureUpdate: CaptureUpdateAction.NEVER
608
+ })
609
+ break
610
+
611
+ case 'export_image_request':
612
+ if (data.requestId) {
613
+ try {
614
+ const elements = excalidrawAPI.getSceneElements()
615
+ const appState = excalidrawAPI.getAppState()
616
+ const files = excalidrawAPI.getFiles()
617
+
618
+ if (data.format === 'svg') {
619
+ const svg = await exportToSvg({
620
+ elements,
621
+ appState: {
622
+ ...appState,
623
+ exportBackground: data.background !== false
624
+ },
625
+ files
626
+ })
627
+ const svgString = new XMLSerializer().serializeToString(svg)
628
+ await fetch('/api/export/image/result', {
629
+ method: 'POST',
630
+ headers: { 'Content-Type': 'application/json' },
631
+ body: JSON.stringify({
632
+ requestId: data.requestId,
633
+ format: 'svg',
634
+ data: svgString
635
+ })
636
+ })
637
+ } else {
638
+ const blob = await exportToBlob({
639
+ elements,
640
+ appState: {
641
+ ...appState,
642
+ exportBackground: data.background !== false
643
+ },
644
+ files,
645
+ mimeType: 'image/png'
646
+ })
647
+ const reader = new FileReader()
648
+ reader.onload = async () => {
649
+ try {
650
+ const resultString = reader.result as string
651
+ const base64 = resultString?.split(',')[1]
652
+ if (!base64) {
653
+ throw new Error('Could not extract base64 data from result')
654
+ }
655
+ await fetch('/api/export/image/result', {
656
+ method: 'POST',
657
+ headers: { 'Content-Type': 'application/json' },
658
+ body: JSON.stringify({
659
+ requestId: data.requestId,
660
+ format: 'png',
661
+ data: base64
662
+ })
663
+ })
664
+ } catch (readerError) {
665
+ console.error('Image export (FileReader) failed:', readerError)
666
+ await fetch('/api/export/image/result', {
667
+ method: 'POST',
668
+ headers: { 'Content-Type': 'application/json' },
669
+ body: JSON.stringify({
670
+ requestId: data.requestId,
671
+ error: (readerError as Error).message
672
+ })
673
+ }).catch(() => { })
674
+ }
675
+ }
676
+ reader.onerror = async () => {
677
+ console.error('FileReader error:', reader.error)
678
+ await fetch('/api/export/image/result', {
679
+ method: 'POST',
680
+ headers: { 'Content-Type': 'application/json' },
681
+ body: JSON.stringify({
682
+ requestId: data.requestId,
683
+ error: reader.error?.message || 'FileReader failed'
684
+ })
685
+ }).catch(() => { })
686
+ }
687
+ reader.readAsDataURL(blob)
688
+ }
689
+ } catch (exportError) {
690
+ console.error('Image export failed:', exportError)
691
+ await fetch('/api/export/image/result', {
692
+ method: 'POST',
693
+ headers: { 'Content-Type': 'application/json' },
694
+ body: JSON.stringify({
695
+ requestId: data.requestId,
696
+ error: (exportError as Error).message
697
+ })
698
+ })
699
+ }
700
+ }
701
+ break
702
+
703
+ case 'set_viewport':
704
+ console.log('Received viewport control request', data)
705
+ if (data.requestId) {
706
+ try {
707
+ if (data.scrollToContent) {
708
+ const allElements = excalidrawAPI.getSceneElements()
709
+ if (allElements.length > 0) {
710
+ excalidrawAPI.scrollToContent(allElements, { fitToViewport: true, animate: true })
711
+ }
712
+ } else if (data.scrollToElementId) {
713
+ const allElements = excalidrawAPI.getSceneElements()
714
+ const targetElement = allElements.find(el => el.id === data.scrollToElementId)
715
+ if (targetElement) {
716
+ excalidrawAPI.scrollToContent([targetElement], { fitToViewport: false, animate: true })
717
+ } else {
718
+ throw new Error(`Element ${data.scrollToElementId} not found`)
719
+ }
720
+ } else {
721
+ // Direct zoom/scroll control
722
+ const appState: any = {}
723
+ if (data.zoom !== undefined) {
724
+ appState.zoom = { value: data.zoom }
725
+ }
726
+ if (data.offsetX !== undefined) {
727
+ appState.scrollX = data.offsetX
728
+ }
729
+ if (data.offsetY !== undefined) {
730
+ appState.scrollY = data.offsetY
731
+ }
732
+ if (Object.keys(appState).length > 0) {
733
+ applySceneUpdateWithoutAutoSync(excalidrawAPI, { appState })
734
+ }
735
+ }
736
+
737
+ await fetch('/api/viewport/result', {
738
+ method: 'POST',
739
+ headers: { 'Content-Type': 'application/json' },
740
+ body: JSON.stringify({
741
+ requestId: data.requestId,
742
+ success: true,
743
+ message: 'Viewport updated'
744
+ })
745
+ })
746
+ } catch (viewportError) {
747
+ console.error('Viewport control failed:', viewportError)
748
+ await fetch('/api/viewport/result', {
749
+ method: 'POST',
750
+ headers: { 'Content-Type': 'application/json' },
751
+ body: JSON.stringify({
752
+ requestId: data.requestId,
753
+ error: (viewportError as Error).message
754
+ })
755
+ }).catch(() => { })
756
+ }
757
+ }
758
+ break
759
+
760
+ case 'mermaid_convert':
761
+ console.log('Received Mermaid conversion request from MCP')
762
+ if (data.mermaidDiagram) {
763
+ try {
764
+ const result = await convertMermaidToExcalidraw(data.mermaidDiagram, data.config || DEFAULT_MERMAID_CONFIG)
765
+
766
+ if (result.error) {
767
+ console.error('Mermaid conversion error:', result.error)
768
+ return
769
+ }
770
+
771
+ if (result.elements && result.elements.length > 0) {
772
+ const convertedElements = convertToExcalidrawElements(result.elements, { regenerateIds: false })
773
+ applySceneUpdateWithoutAutoSync(excalidrawAPI, {
774
+ elements: convertedElements,
775
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY
776
+ })
777
+
778
+ if (result.files) {
779
+ excalidrawAPI.addFiles(Object.values(result.files))
780
+ }
781
+
782
+ console.log('Mermaid diagram converted successfully:', result.elements.length, 'elements')
783
+
784
+ // Sync to backend automatically after creating elements
785
+ await syncToBackend()
786
+ }
787
+ } catch (error) {
788
+ console.error('Error converting Mermaid diagram from WebSocket:', error)
789
+ }
790
+ }
791
+ break
792
+
793
+ default:
794
+ console.log('Unknown WebSocket message type:', data.type)
795
+ }
796
+ } catch (error) {
797
+ console.error('Error processing WebSocket message:', error, data)
798
+ }
799
+ }
800
+
801
+ const convertToBackendFormat = (element: ExcalidrawElement): ServerElement => {
802
+ return {
803
+ ...element
804
+ } as ServerElement
805
+ }
806
+
807
+ const formatSyncTime = (time: Date | null): string => {
808
+ if (!time) return ''
809
+ return time.toLocaleTimeString('zh-CN', {
810
+ hour: '2-digit',
811
+ minute: '2-digit',
812
+ second: '2-digit'
813
+ })
814
+ }
815
+
816
+ const syncToBackend = async (options: { silent?: boolean } = {}): Promise<void> => {
817
+ const { silent = false } = options
818
+
819
+ if (!excalidrawAPI) {
820
+ console.warn('Excalidraw API not available')
821
+ return
822
+ }
823
+
824
+ if (syncInFlightRef.current) {
825
+ return
826
+ }
827
+
828
+ if (autoSyncTimerRef.current) {
829
+ clearTimeout(autoSyncTimerRef.current)
830
+ autoSyncTimerRef.current = null
831
+ }
832
+
833
+ syncInFlightRef.current = true
834
+ if (!silent) {
835
+ setSyncStatus('syncing')
836
+ }
837
+
838
+ try {
839
+ const currentElements = excalidrawAPI.getSceneElements()
840
+ console.log(`Syncing ${currentElements.length} elements to backend`)
841
+
842
+ const activeElements = currentElements.filter(el => !el.isDeleted)
843
+
844
+ const currentFiles = excalidrawAPI.getFiles()
845
+ const filesArray = Object.values(currentFiles)
846
+
847
+ const backendElements = activeElements.map(convertToBackendFormat)
848
+
849
+ const response = await fetch('/api/elements/sync', {
850
+ method: 'POST',
851
+ headers: {
852
+ 'Content-Type': 'application/json',
853
+ },
854
+ body: JSON.stringify({
855
+ elements: backendElements,
856
+ timestamp: new Date().toISOString()
857
+ })
858
+ })
859
+
860
+ if (response.ok) {
861
+ const result: ApiResponse = await response.json()
862
+ console.log(`Sync successful: ${result.count} elements synced`)
863
+
864
+ if (filesArray.length > 0) {
865
+ console.log(`Syncing ${filesArray.length} files to backend`)
866
+ const filesResponse = await fetch('/api/files', {
867
+ method: 'POST',
868
+ headers: {
869
+ 'Content-Type': 'application/json',
870
+ },
871
+ body: JSON.stringify(filesArray)
872
+ })
873
+
874
+ if (filesResponse.ok) {
875
+ const filesResult = await filesResponse.json()
876
+ console.log(`Files sync successful: ${filesResult.count} files synced`)
877
+ } else {
878
+ console.error('Files sync failed:', await filesResponse.text())
879
+ }
880
+ }
881
+
882
+ setLastSyncTime(new Date())
883
+
884
+ if (!silent) {
885
+ setSyncStatus('success')
886
+ setTimeout(() => setSyncStatus('idle'), 2000)
887
+ }
888
+ } else {
889
+ const error: ApiResponse = await response.json()
890
+ console.error('Sync failed:', error.error)
891
+ if (!silent) {
892
+ setSyncStatus('error')
893
+ }
894
+ }
895
+ } catch (error) {
896
+ console.error('Sync error:', error)
897
+ if (!silent) {
898
+ setSyncStatus('error')
899
+ }
900
+ } finally {
901
+ syncInFlightRef.current = false
902
+ }
903
+ }
904
+
905
+ const scheduleAutoSync = (): void => {
906
+ if (!isConnected || !excalidrawAPI) {
907
+ return
908
+ }
909
+ if (!userInteractedRef.current) {
910
+ return
911
+ }
912
+ if (suppressAutoSyncCountRef.current > 0) {
913
+ return
914
+ }
915
+ if (autoSyncTimerRef.current) {
916
+ clearTimeout(autoSyncTimerRef.current)
917
+ }
918
+
919
+ autoSyncTimerRef.current = setTimeout(() => {
920
+ autoSyncTimerRef.current = null
921
+ if (suppressAutoSyncCountRef.current > 0 || syncInFlightRef.current) {
922
+ return
923
+ }
924
+ void syncToBackend({ silent: true })
925
+ }, AUTO_SYNC_DEBOUNCE_MS)
926
+ }
927
+
928
+ const clearCanvas = async (): Promise<void> => {
929
+ if (excalidrawAPI) {
930
+ try {
931
+ const response = await fetch('/api/elements', { headers: authHeaders() })
932
+ const result: ApiResponse = await response.json()
933
+
934
+ if (result.success && result.elements) {
935
+ const deletePromises = result.elements.map(element =>
936
+ fetch(`/api/elements/${element.id}`, { method: 'DELETE' })
937
+ )
938
+ await Promise.all(deletePromises)
939
+ }
940
+
941
+ applySceneUpdateWithoutAutoSync(excalidrawAPI, {
942
+ elements: [],
943
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY
944
+ })
945
+ } catch (error) {
946
+ console.error('Error clearing canvas:', error)
947
+ applySceneUpdateWithoutAutoSync(excalidrawAPI, {
948
+ elements: [],
949
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY
950
+ })
951
+ }
952
+ }
953
+ }
954
+
955
+ const [showLogin, setShowLogin] = useState<boolean>(false)
956
+ const [loginCode, setLoginCode] = useState<string>('')
957
+ const [deviceInfo, setDeviceInfo] = useState<{ user_code: string; verification_uri: string } | null>(null)
958
+
959
+ const startLogin = async (): Promise<void> => {
960
+ const gh = await import('./utils/ghSync')
961
+ if (loginCode.trim()) {
962
+ gh.setToken(loginCode.trim())
963
+ setGhToken(loginCode.trim())
964
+ setLoginCode('')
965
+ setShowLogin(false)
966
+ return
967
+ }
968
+ try {
969
+ const flow = await gh.startDeviceFlow(gh.CLIENT_ID)
970
+ setDeviceInfo({ user_code: flow.user_code, verification_uri: flow.verification_uri })
971
+ window.open(flow.verification_uri, '_blank')
972
+ } catch (err) {
973
+ console.warn('Device flow blocked by CORS, showing agent handoff:', err)
974
+ setDeviceInfo({ user_code: 'ASK-YOUR-AGENT', verification_uri: 'ask the agent to run the github_login tool' })
975
+ }
976
+ }
977
+
978
+ return (
979
+ <div className="app">
980
+ {/* Floating status pill (no header — canvas is fullscreen) */}
981
+ <div className="pill">
982
+ <div className={`status-dot ${(serverMode ? isConnected : access === 'editor') ? 'status-connected' : 'status-disconnected'}`}></div>
983
+ <span>
984
+ {serverMode
985
+ ? (isConnected ? 'Live' : 'Offline')
986
+ : access === 'unknown'
987
+ ? 'Checking access…'
988
+ : access === 'editor'
989
+ ? (syncStatus === 'syncing' ? 'Saving…' : ghDirty ? 'Unsaved changes' : lastSyncTime ? `Saved ${formatSyncTime(lastSyncTime)}${ghLogin ? ` · ${ghLogin}` : ''}` : `Can edit${ghLogin ? ` · ${ghLogin}` : ''}`)
990
+ : access === 'viewer'
991
+ ? `Read-only${ghLogin ? ` · ${ghLogin}` : ''} — your changes won't save`
992
+ : 'No access — login with a collaborator account'}
993
+ </span>
994
+ {!serverMode && (
995
+ access === 'editor'
996
+ ? <button className="pill-btn" onClick={() => { void pushToGitHub(false) }}>Save now</button>
997
+ : <button className="pill-btn" onClick={() => setShowLogin(true)}>Login with GitHub</button>
998
+ )}
999
+ </div>
1000
+
1001
+ {showLogin && !serverMode && (
1002
+ <div className="login-modal">
1003
+ <h3>Login with GitHub</h3>
1004
+ {accessDetail === 'repo-not-covered' && ghLogin && (
1005
+ <p><b>Almost there, {ghLogin}:</b> the app is installed on your account but <b>this repo isn't included</b>. Open your <a href={`https://github.com/settings/installations`} target="_blank" rel="noreferrer">app installations</a>, pick Excalidrop → Configure → add <code>{ghRepo?.owner}/{ghRepo?.repo}</code> (or switch to All repositories), then close and reopen this login.</p>
1006
+ )}
1007
+ {accessDetail === 'app-not-installed' && (
1008
+ <p>First time on this repo? <a href={APP_INSTALL_URL} target="_blank" rel="noreferrer">Install the Excalidrop app</a> on it — repo access is canvas access.</p>
1009
+ )}
1010
+ {accessDetail !== 'repo-not-covered' && accessDetail !== 'app-not-installed' && (
1011
+ <p>First time on this repo? <a href={APP_INSTALL_URL} target="_blank" rel="noreferrer">Install the Excalidrop app</a> on it — repo access is canvas access.</p>
1012
+ )}
1013
+ <p><b>Easiest:</b> ask your agent to run <code>github_login</code> for this site — it shows a code, you approve it (2FA via GitHub), and this page unlocks. No copy-paste.</p>
1014
+ <details>
1015
+ <summary>Or paste a token manually</summary>
1016
+ <input
1017
+ placeholder="Paste token (gh auth token)…"
1018
+ value={loginCode}
1019
+ onChange={(e) => setLoginCode(e.target.value)}
1020
+ />
1021
+ <div className="login-row">
1022
+ <button className="btn-primary" onClick={() => { void startLogin() }}>Save token</button>
1023
+ </div>
1024
+ </details>
1025
+ <div className="login-row">
1026
+ <button className="btn-secondary" onClick={() => { setShowLogin(false); setDeviceInfo(null) }}>Close</button>
1027
+ </div>
1028
+ {deviceInfo && deviceInfo.user_code !== 'ASK-YOUR-AGENT' && (
1029
+ <div style={{ background: '#f0f0f0', padding: '12px', borderRadius: '8px', marginTop: '8px' }}>
1030
+ <p style={{ margin: '0 0 8px 0' }}><b>Device Flow Started:</b></p>
1031
+ <p style={{ margin: '0 0 8px 0' }}>Open <a href={deviceInfo.verification_uri} target="_blank" rel="noreferrer">{deviceInfo.verification_uri}</a> and enter code:</p>
1032
+ <p style={{ margin: '0', fontSize: '20px', fontWeight: 'bold', fontFamily: 'monospace', letterSpacing: '2px' }}>{deviceInfo.user_code}</p>
1033
+ </div>
1034
+ )}
1035
+ {deviceInfo && deviceInfo.user_code === 'ASK-YOUR-AGENT' && (
1036
+ <p>Ask your agent to run <code>github_login</code> for this site.</p>
1037
+ )}
1038
+ </div>
1039
+ )}
1040
+
1041
+ {/* Canvas Container */}
1042
+ <div className="canvas-container">
1043
+ <div
1044
+ onPointerDownCapture={() => {
1045
+ userInteractedRef.current = true
1046
+ }}
1047
+ onKeyDownCapture={() => {
1048
+ userInteractedRef.current = true
1049
+ }}
1050
+ style={{ width: '100%', height: '100%' }}
1051
+ >
1052
+ <Excalidraw
1053
+ viewModeEnabled={!serverMode && access !== 'editor'}
1054
+ excalidrawAPI={(api: ExcalidrawAPIRefValue) => setExcalidrawAPI(api)}
1055
+ onChange={() => {
1056
+ if (serverMode) scheduleAutoSync()
1057
+ else if (!suppressAutoSyncCountRef.current) setGhDirty(true)
1058
+ }}
1059
+ initialData={{
1060
+ elements: [],
1061
+ appState: {
1062
+ theme: 'light',
1063
+ viewBackgroundColor: '#ffffff'
1064
+ }
1065
+ }}
1066
+ >
1067
+ {/* Custom hamburger menu: functional items only.
1068
+ Rendering <MainMenu> replaces Excalidraw defaults entirely,
1069
+ so Help / Socials / ItemLink entries (excalidraw.com, Discord,
1070
+ GitHub, docs links) are gone. */}
1071
+ <MainMenu>
1072
+ <MainMenu.DefaultItems.LoadScene />
1073
+ <MainMenu.DefaultItems.SaveToActiveFile />
1074
+ <MainMenu.DefaultItems.Export />
1075
+ <MainMenu.DefaultItems.SaveAsImage />
1076
+ <MainMenu.Separator />
1077
+ <MainMenu.DefaultItems.ClearCanvas />
1078
+ <MainMenu.DefaultItems.ChangeCanvasBackground />
1079
+ <MainMenu.DefaultItems.ToggleTheme />
1080
+ <MainMenu.Separator />
1081
+ <MainMenu.DefaultItems.SearchMenu />
1082
+ <MainMenu.DefaultItems.CommandPalette />
1083
+ </MainMenu>
1084
+ </Excalidraw>
1085
+ </div>
1086
+ </div>
1087
+ </div>
1088
+ )
1089
+ }
1090
+
1091
+ export default App