@fraziym/axoniz 0.1.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.
Files changed (512) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +76 -0
  3. package/dist/agents/definition.d.ts +15 -0
  4. package/dist/agents/definition.d.ts.map +1 -0
  5. package/dist/agents/definition.js +41 -0
  6. package/dist/agents/definition.js.map +1 -0
  7. package/dist/agents/index.d.ts +12 -0
  8. package/dist/agents/index.d.ts.map +1 -0
  9. package/dist/agents/index.js +11 -0
  10. package/dist/agents/index.js.map +1 -0
  11. package/dist/agents/registry.d.ts +17 -0
  12. package/dist/agents/registry.d.ts.map +1 -0
  13. package/dist/agents/registry.js +23 -0
  14. package/dist/agents/registry.js.map +1 -0
  15. package/dist/agents/specialized.d.ts +31 -0
  16. package/dist/agents/specialized.d.ts.map +1 -0
  17. package/dist/agents/specialized.js +75 -0
  18. package/dist/agents/specialized.js.map +1 -0
  19. package/dist/awareness/index.d.ts +14 -0
  20. package/dist/awareness/index.d.ts.map +1 -0
  21. package/dist/awareness/index.js +22 -0
  22. package/dist/awareness/index.js.map +1 -0
  23. package/dist/awareness/service.d.ts +180 -0
  24. package/dist/awareness/service.d.ts.map +1 -0
  25. package/dist/awareness/service.js +789 -0
  26. package/dist/awareness/service.js.map +1 -0
  27. package/dist/cli/entry.d.ts +13 -0
  28. package/dist/cli/entry.d.ts.map +1 -0
  29. package/dist/cli/entry.js +35 -0
  30. package/dist/cli/entry.js.map +1 -0
  31. package/dist/comms/telegram.d.ts +41 -0
  32. package/dist/comms/telegram.d.ts.map +1 -0
  33. package/dist/comms/telegram.js +332 -0
  34. package/dist/comms/telegram.js.map +1 -0
  35. package/dist/core/agent.d.ts +252 -0
  36. package/dist/core/agent.d.ts.map +1 -0
  37. package/dist/core/agent.js +1399 -0
  38. package/dist/core/agent.js.map +1 -0
  39. package/dist/core/auth.d.ts +23 -0
  40. package/dist/core/auth.d.ts.map +1 -0
  41. package/dist/core/auth.js +113 -0
  42. package/dist/core/auth.js.map +1 -0
  43. package/dist/core/authority.d.ts +118 -0
  44. package/dist/core/authority.d.ts.map +1 -0
  45. package/dist/core/authority.js +402 -0
  46. package/dist/core/authority.js.map +1 -0
  47. package/dist/core/backend/async_queue.d.ts +17 -0
  48. package/dist/core/backend/async_queue.d.ts.map +1 -0
  49. package/dist/core/backend/async_queue.js +66 -0
  50. package/dist/core/backend/async_queue.js.map +1 -0
  51. package/dist/core/backend/index.d.ts +24 -0
  52. package/dist/core/backend/index.d.ts.map +1 -0
  53. package/dist/core/backend/index.js +128 -0
  54. package/dist/core/backend/index.js.map +1 -0
  55. package/dist/core/backend/llama_server.d.ts +39 -0
  56. package/dist/core/backend/llama_server.d.ts.map +1 -0
  57. package/dist/core/backend/llama_server.js +182 -0
  58. package/dist/core/backend/llama_server.js.map +1 -0
  59. package/dist/core/backend/llamacpp.d.ts +40 -0
  60. package/dist/core/backend/llamacpp.d.ts.map +1 -0
  61. package/dist/core/backend/llamacpp.js +200 -0
  62. package/dist/core/backend/llamacpp.js.map +1 -0
  63. package/dist/core/backend/lmstudio.d.ts +53 -0
  64. package/dist/core/backend/lmstudio.d.ts.map +1 -0
  65. package/dist/core/backend/lmstudio.js +274 -0
  66. package/dist/core/backend/lmstudio.js.map +1 -0
  67. package/dist/core/backend/ollama.d.ts +25 -0
  68. package/dist/core/backend/ollama.d.ts.map +1 -0
  69. package/dist/core/backend/ollama.js +86 -0
  70. package/dist/core/backend/ollama.js.map +1 -0
  71. package/dist/core/backend/openai.d.ts +27 -0
  72. package/dist/core/backend/openai.d.ts.map +1 -0
  73. package/dist/core/backend/openai.js +84 -0
  74. package/dist/core/backend/openai.js.map +1 -0
  75. package/dist/core/backend/types.d.ts +73 -0
  76. package/dist/core/backend/types.d.ts.map +1 -0
  77. package/dist/core/backend/types.js +137 -0
  78. package/dist/core/backend/types.js.map +1 -0
  79. package/dist/core/chat_store.d.ts +77 -0
  80. package/dist/core/chat_store.d.ts.map +1 -0
  81. package/dist/core/chat_store.js +332 -0
  82. package/dist/core/chat_store.js.map +1 -0
  83. package/dist/core/cli.d.ts +82 -0
  84. package/dist/core/cli.d.ts.map +1 -0
  85. package/dist/core/cli.js +602 -0
  86. package/dist/core/cli.js.map +1 -0
  87. package/dist/core/config.d.ts +127 -0
  88. package/dist/core/config.d.ts.map +1 -0
  89. package/dist/core/config.js +295 -0
  90. package/dist/core/config.js.map +1 -0
  91. package/dist/core/debug.d.ts +8 -0
  92. package/dist/core/debug.d.ts.map +1 -0
  93. package/dist/core/debug.js +8 -0
  94. package/dist/core/debug.js.map +1 -0
  95. package/dist/core/downloader.d.ts +31 -0
  96. package/dist/core/downloader.d.ts.map +1 -0
  97. package/dist/core/downloader.js +180 -0
  98. package/dist/core/downloader.js.map +1 -0
  99. package/dist/core/errors.d.ts +80 -0
  100. package/dist/core/errors.d.ts.map +1 -0
  101. package/dist/core/errors.js +134 -0
  102. package/dist/core/errors.js.map +1 -0
  103. package/dist/core/extras.d.ts +160 -0
  104. package/dist/core/extras.d.ts.map +1 -0
  105. package/dist/core/extras.js +553 -0
  106. package/dist/core/extras.js.map +1 -0
  107. package/dist/core/health.d.ts +27 -0
  108. package/dist/core/health.d.ts.map +1 -0
  109. package/dist/core/health.js +96 -0
  110. package/dist/core/health.js.map +1 -0
  111. package/dist/core/history.d.ts +50 -0
  112. package/dist/core/history.d.ts.map +1 -0
  113. package/dist/core/history.js +276 -0
  114. package/dist/core/history.js.map +1 -0
  115. package/dist/core/index.d.ts +6 -0
  116. package/dist/core/index.d.ts.map +1 -0
  117. package/dist/core/index.js +6 -0
  118. package/dist/core/index.js.map +1 -0
  119. package/dist/core/intelligence/ast_index.d.ts +255 -0
  120. package/dist/core/intelligence/ast_index.d.ts.map +1 -0
  121. package/dist/core/intelligence/ast_index.js +1369 -0
  122. package/dist/core/intelligence/ast_index.js.map +1 -0
  123. package/dist/core/intelligence/confidence.d.ts +113 -0
  124. package/dist/core/intelligence/confidence.d.ts.map +1 -0
  125. package/dist/core/intelligence/confidence.js +298 -0
  126. package/dist/core/intelligence/confidence.js.map +1 -0
  127. package/dist/core/intelligence/context_compressor.d.ts +80 -0
  128. package/dist/core/intelligence/context_compressor.d.ts.map +1 -0
  129. package/dist/core/intelligence/context_compressor.js +245 -0
  130. package/dist/core/intelligence/context_compressor.js.map +1 -0
  131. package/dist/core/intelligence/daemon.d.ts +247 -0
  132. package/dist/core/intelligence/daemon.d.ts.map +1 -0
  133. package/dist/core/intelligence/daemon.js +842 -0
  134. package/dist/core/intelligence/daemon.js.map +1 -0
  135. package/dist/core/intelligence/index.d.ts +60 -0
  136. package/dist/core/intelligence/index.d.ts.map +1 -0
  137. package/dist/core/intelligence/index.js +101 -0
  138. package/dist/core/intelligence/index.js.map +1 -0
  139. package/dist/core/intelligence/predictor.d.ts +246 -0
  140. package/dist/core/intelligence/predictor.d.ts.map +1 -0
  141. package/dist/core/intelligence/predictor.js +590 -0
  142. package/dist/core/intelligence/predictor.js.map +1 -0
  143. package/dist/core/intelligence/reflex.d.ts +84 -0
  144. package/dist/core/intelligence/reflex.d.ts.map +1 -0
  145. package/dist/core/intelligence/reflex.js +344 -0
  146. package/dist/core/intelligence/reflex.js.map +1 -0
  147. package/dist/core/intelligence/self_correction.d.ts +121 -0
  148. package/dist/core/intelligence/self_correction.d.ts.map +1 -0
  149. package/dist/core/intelligence/self_correction.js +327 -0
  150. package/dist/core/intelligence/self_correction.js.map +1 -0
  151. package/dist/core/intelligence/skill_distiller.d.ts +100 -0
  152. package/dist/core/intelligence/skill_distiller.d.ts.map +1 -0
  153. package/dist/core/intelligence/skill_distiller.js +246 -0
  154. package/dist/core/intelligence/skill_distiller.js.map +1 -0
  155. package/dist/core/intelligence/swarm.d.ts +330 -0
  156. package/dist/core/intelligence/swarm.d.ts.map +1 -0
  157. package/dist/core/intelligence/swarm.js +1064 -0
  158. package/dist/core/intelligence/swarm.js.map +1 -0
  159. package/dist/core/intelligence/trajectory.d.ts +107 -0
  160. package/dist/core/intelligence/trajectory.d.ts.map +1 -0
  161. package/dist/core/intelligence/trajectory.js +330 -0
  162. package/dist/core/intelligence/trajectory.js.map +1 -0
  163. package/dist/core/logger.d.ts +111 -0
  164. package/dist/core/logger.d.ts.map +1 -0
  165. package/dist/core/logger.js +640 -0
  166. package/dist/core/logger.js.map +1 -0
  167. package/dist/core/loop.d.ts +123 -0
  168. package/dist/core/loop.d.ts.map +1 -0
  169. package/dist/core/loop.js +423 -0
  170. package/dist/core/loop.js.map +1 -0
  171. package/dist/core/memory.d.ts +81 -0
  172. package/dist/core/memory.d.ts.map +1 -0
  173. package/dist/core/memory.js +328 -0
  174. package/dist/core/memory.js.map +1 -0
  175. package/dist/core/metrics.d.ts +29 -0
  176. package/dist/core/metrics.d.ts.map +1 -0
  177. package/dist/core/metrics.js +109 -0
  178. package/dist/core/metrics.js.map +1 -0
  179. package/dist/core/models.d.ts +51 -0
  180. package/dist/core/models.d.ts.map +1 -0
  181. package/dist/core/models.js +146 -0
  182. package/dist/core/models.js.map +1 -0
  183. package/dist/core/optimizer.d.ts +34 -0
  184. package/dist/core/optimizer.d.ts.map +1 -0
  185. package/dist/core/optimizer.js +131 -0
  186. package/dist/core/optimizer.js.map +1 -0
  187. package/dist/core/persona.d.ts +35 -0
  188. package/dist/core/persona.d.ts.map +1 -0
  189. package/dist/core/persona.js +126 -0
  190. package/dist/core/persona.js.map +1 -0
  191. package/dist/core/rate_limit.d.ts +23 -0
  192. package/dist/core/rate_limit.d.ts.map +1 -0
  193. package/dist/core/rate_limit.js +62 -0
  194. package/dist/core/rate_limit.js.map +1 -0
  195. package/dist/core/runner.d.ts +63 -0
  196. package/dist/core/runner.d.ts.map +1 -0
  197. package/dist/core/runner.js +671 -0
  198. package/dist/core/runner.js.map +1 -0
  199. package/dist/core/stream_parser.d.ts +43 -0
  200. package/dist/core/stream_parser.d.ts.map +1 -0
  201. package/dist/core/stream_parser.js +140 -0
  202. package/dist/core/stream_parser.js.map +1 -0
  203. package/dist/core/tool_schemas.d.ts +26 -0
  204. package/dist/core/tool_schemas.d.ts.map +1 -0
  205. package/dist/core/tool_schemas.js +124 -0
  206. package/dist/core/tool_schemas.js.map +1 -0
  207. package/dist/core/which.d.ts +2 -0
  208. package/dist/core/which.d.ts.map +1 -0
  209. package/dist/core/which.js +35 -0
  210. package/dist/core/which.js.map +1 -0
  211. package/dist/goals/index.d.ts +9 -0
  212. package/dist/goals/index.d.ts.map +1 -0
  213. package/dist/goals/index.js +17 -0
  214. package/dist/goals/index.js.map +1 -0
  215. package/dist/goals/service.d.ts +115 -0
  216. package/dist/goals/service.d.ts.map +1 -0
  217. package/dist/goals/service.js +415 -0
  218. package/dist/goals/service.js.map +1 -0
  219. package/dist/goals/types.d.ts +47 -0
  220. package/dist/goals/types.d.ts.map +1 -0
  221. package/dist/goals/types.js +66 -0
  222. package/dist/goals/types.js.map +1 -0
  223. package/dist/index.d.ts +22 -0
  224. package/dist/index.d.ts.map +1 -0
  225. package/dist/index.js +22 -0
  226. package/dist/index.js.map +1 -0
  227. package/dist/integrations/autonomous_startup.d.ts +51 -0
  228. package/dist/integrations/autonomous_startup.d.ts.map +1 -0
  229. package/dist/integrations/autonomous_startup.js +419 -0
  230. package/dist/integrations/autonomous_startup.js.map +1 -0
  231. package/dist/integrations/goose.d.ts +105 -0
  232. package/dist/integrations/goose.d.ts.map +1 -0
  233. package/dist/integrations/goose.js +408 -0
  234. package/dist/integrations/goose.js.map +1 -0
  235. package/dist/integrations/index.d.ts +21 -0
  236. package/dist/integrations/index.d.ts.map +1 -0
  237. package/dist/integrations/index.js +17 -0
  238. package/dist/integrations/index.js.map +1 -0
  239. package/dist/integrations/mcp.d.ts +73 -0
  240. package/dist/integrations/mcp.d.ts.map +1 -0
  241. package/dist/integrations/mcp.js +307 -0
  242. package/dist/integrations/mcp.js.map +1 -0
  243. package/dist/integrations/mempalace.d.ts +18 -0
  244. package/dist/integrations/mempalace.d.ts.map +1 -0
  245. package/dist/integrations/mempalace.js +25 -0
  246. package/dist/integrations/mempalace.js.map +1 -0
  247. package/dist/integrations/multi_agent.d.ts +82 -0
  248. package/dist/integrations/multi_agent.d.ts.map +1 -0
  249. package/dist/integrations/multi_agent.js +280 -0
  250. package/dist/integrations/multi_agent.js.map +1 -0
  251. package/dist/integrations/unified_memory.d.ts +415 -0
  252. package/dist/integrations/unified_memory.d.ts.map +1 -0
  253. package/dist/integrations/unified_memory.js +1489 -0
  254. package/dist/integrations/unified_memory.js.map +1 -0
  255. package/dist/roles/beru.yaml +48 -0
  256. package/dist/sidecar/client.d.ts +63 -0
  257. package/dist/sidecar/client.d.ts.map +1 -0
  258. package/dist/sidecar/client.js +222 -0
  259. package/dist/sidecar/client.js.map +1 -0
  260. package/dist/sidecar/index.d.ts +7 -0
  261. package/dist/sidecar/index.d.ts.map +1 -0
  262. package/dist/sidecar/index.js +6 -0
  263. package/dist/sidecar/index.js.map +1 -0
  264. package/dist/startup.d.ts +22 -0
  265. package/dist/startup.d.ts.map +1 -0
  266. package/dist/startup.js +194 -0
  267. package/dist/startup.js.map +1 -0
  268. package/dist/tools/_internal.d.ts +90 -0
  269. package/dist/tools/_internal.d.ts.map +1 -0
  270. package/dist/tools/_internal.js +375 -0
  271. package/dist/tools/_internal.js.map +1 -0
  272. package/dist/tools/_smoke.d.ts +2 -0
  273. package/dist/tools/_smoke.d.ts.map +1 -0
  274. package/dist/tools/_smoke.js +203 -0
  275. package/dist/tools/_smoke.js.map +1 -0
  276. package/dist/tools/axodex_tools.d.ts +51 -0
  277. package/dist/tools/axodex_tools.d.ts.map +1 -0
  278. package/dist/tools/axodex_tools.js +181 -0
  279. package/dist/tools/axodex_tools.js.map +1 -0
  280. package/dist/tools/code_tools.d.ts +33 -0
  281. package/dist/tools/code_tools.d.ts.map +1 -0
  282. package/dist/tools/code_tools.js +191 -0
  283. package/dist/tools/code_tools.js.map +1 -0
  284. package/dist/tools/computer_tools.d.ts +59 -0
  285. package/dist/tools/computer_tools.d.ts.map +1 -0
  286. package/dist/tools/computer_tools.js +348 -0
  287. package/dist/tools/computer_tools.js.map +1 -0
  288. package/dist/tools/file_tools.d.ts +51 -0
  289. package/dist/tools/file_tools.d.ts.map +1 -0
  290. package/dist/tools/file_tools.js +424 -0
  291. package/dist/tools/file_tools.js.map +1 -0
  292. package/dist/tools/index.d.ts +13 -0
  293. package/dist/tools/index.d.ts.map +1 -0
  294. package/dist/tools/index.js +13 -0
  295. package/dist/tools/index.js.map +1 -0
  296. package/dist/tools/shell_tools.d.ts +39 -0
  297. package/dist/tools/shell_tools.d.ts.map +1 -0
  298. package/dist/tools/shell_tools.js +163 -0
  299. package/dist/tools/shell_tools.js.map +1 -0
  300. package/dist/tools/web_tools.d.ts +31 -0
  301. package/dist/tools/web_tools.d.ts.map +1 -0
  302. package/dist/tools/web_tools.js +117 -0
  303. package/dist/tools/web_tools.js.map +1 -0
  304. package/dist/voice/index.d.ts +41 -0
  305. package/dist/voice/index.d.ts.map +1 -0
  306. package/dist/voice/index.js +58 -0
  307. package/dist/voice/index.js.map +1 -0
  308. package/dist/voice/light_daemon.d.ts +197 -0
  309. package/dist/voice/light_daemon.d.ts.map +1 -0
  310. package/dist/voice/light_daemon.js +746 -0
  311. package/dist/voice/light_daemon.js.map +1 -0
  312. package/dist/voice/setup.d.ts +63 -0
  313. package/dist/voice/setup.d.ts.map +1 -0
  314. package/dist/voice/setup.js +443 -0
  315. package/dist/voice/setup.js.map +1 -0
  316. package/dist/voice/stt.d.ts +148 -0
  317. package/dist/voice/stt.d.ts.map +1 -0
  318. package/dist/voice/stt.js +361 -0
  319. package/dist/voice/stt.js.map +1 -0
  320. package/dist/voice/tts.d.ts +168 -0
  321. package/dist/voice/tts.d.ts.map +1 -0
  322. package/dist/voice/tts.js +698 -0
  323. package/dist/voice/tts.js.map +1 -0
  324. package/dist/voice/voice_loop.d.ts +101 -0
  325. package/dist/voice/voice_loop.d.ts.map +1 -0
  326. package/dist/voice/voice_loop.js +463 -0
  327. package/dist/voice/voice_loop.js.map +1 -0
  328. package/dist/voice/wake_word.d.ts +153 -0
  329. package/dist/voice/wake_word.d.ts.map +1 -0
  330. package/dist/voice/wake_word.js +458 -0
  331. package/dist/voice/wake_word.js.map +1 -0
  332. package/dist/voice/wav.d.ts +99 -0
  333. package/dist/voice/wav.d.ts.map +1 -0
  334. package/dist/voice/wav.js +306 -0
  335. package/dist/voice/wav.js.map +1 -0
  336. package/dist/web/broker.d.ts +33 -0
  337. package/dist/web/broker.d.ts.map +1 -0
  338. package/dist/web/broker.js +58 -0
  339. package/dist/web/broker.js.map +1 -0
  340. package/dist/web/open_browser.d.ts +2 -0
  341. package/dist/web/open_browser.d.ts.map +1 -0
  342. package/dist/web/open_browser.js +42 -0
  343. package/dist/web/open_browser.js.map +1 -0
  344. package/dist/web/server.d.ts +54 -0
  345. package/dist/web/server.d.ts.map +1 -0
  346. package/dist/web/server.js +831 -0
  347. package/dist/web/server.js.map +1 -0
  348. package/dist/web/static/app.js +4121 -0
  349. package/dist/web/static/index.html +2281 -0
  350. package/dist/web/static/js/MODULE_SUMMARY.md +124 -0
  351. package/dist/web/static/js/a11y.js +165 -0
  352. package/dist/web/static/js/admin.js +2145 -0
  353. package/dist/web/static/js/assistant.js +475 -0
  354. package/dist/web/static/js/calendar/reminders.js +114 -0
  355. package/dist/web/static/js/calendar/utils.js +166 -0
  356. package/dist/web/static/js/calendar.js +3348 -0
  357. package/dist/web/static/js/censor.js +356 -0
  358. package/dist/web/static/js/chat.js +4546 -0
  359. package/dist/web/static/js/chatRenderer.js +2294 -0
  360. package/dist/web/static/js/chatStream.js +276 -0
  361. package/dist/web/static/js/codeRunner.js +398 -0
  362. package/dist/web/static/js/color/hex.js +14 -0
  363. package/dist/web/static/js/colorPicker.js +453 -0
  364. package/dist/web/static/js/compare/icons.js +77 -0
  365. package/dist/web/static/js/compare/index.js +1482 -0
  366. package/dist/web/static/js/compare/models.js +104 -0
  367. package/dist/web/static/js/compare/panes.js +817 -0
  368. package/dist/web/static/js/compare/probe.js +78 -0
  369. package/dist/web/static/js/compare/scoreboard.js +223 -0
  370. package/dist/web/static/js/compare/selector.js +1335 -0
  371. package/dist/web/static/js/compare/state.js +58 -0
  372. package/dist/web/static/js/compare/stream.js +695 -0
  373. package/dist/web/static/js/compare/vote.js +254 -0
  374. package/dist/web/static/js/cookbook-diagnosis.js +694 -0
  375. package/dist/web/static/js/cookbook-hwfit.js +1888 -0
  376. package/dist/web/static/js/cookbook.js +2093 -0
  377. package/dist/web/static/js/cookbookDownload.js +633 -0
  378. package/dist/web/static/js/cookbookProgressSignal.js +29 -0
  379. package/dist/web/static/js/cookbookRunning.js +3506 -0
  380. package/dist/web/static/js/cookbookServe.js +2128 -0
  381. package/dist/web/static/js/document.js +9731 -0
  382. package/dist/web/static/js/documentLibrary.js +3376 -0
  383. package/dist/web/static/js/dragSort.js +265 -0
  384. package/dist/web/static/js/editor/ai-inpaint.js +374 -0
  385. package/dist/web/static/js/editor/ai-models.js +273 -0
  386. package/dist/web/static/js/editor/ai-rembg.js +230 -0
  387. package/dist/web/static/js/editor/ai-tool-runner.js +147 -0
  388. package/dist/web/static/js/editor/ai-tools-misc.js +216 -0
  389. package/dist/web/static/js/editor/build/controls.js +366 -0
  390. package/dist/web/static/js/editor/build/popups.js +112 -0
  391. package/dist/web/static/js/editor/build/right-panel.js +200 -0
  392. package/dist/web/static/js/editor/build/toolbar.js +73 -0
  393. package/dist/web/static/js/editor/build/topbar.js +131 -0
  394. package/dist/web/static/js/editor/build/transform-popup.js +109 -0
  395. package/dist/web/static/js/editor/canvas-coords.js +21 -0
  396. package/dist/web/static/js/editor/canvas-events.js +197 -0
  397. package/dist/web/static/js/editor/canvas-transforms.js +132 -0
  398. package/dist/web/static/js/editor/checkerboard.js +24 -0
  399. package/dist/web/static/js/editor/clipboard-and-drop.js +133 -0
  400. package/dist/web/static/js/editor/composite-helpers.js +83 -0
  401. package/dist/web/static/js/editor/filters/blur.js +118 -0
  402. package/dist/web/static/js/editor/filters/edge-feather.js +70 -0
  403. package/dist/web/static/js/editor/fx/adj-popup.js +677 -0
  404. package/dist/web/static/js/editor/fx/filter-string.js +38 -0
  405. package/dist/web/static/js/editor/fx/histogram.js +67 -0
  406. package/dist/web/static/js/editor/fx/pixel-pass.js +249 -0
  407. package/dist/web/static/js/editor/harmonize-masks.js +116 -0
  408. package/dist/web/static/js/editor/history-panel.js +176 -0
  409. package/dist/web/static/js/editor/keyboard-shortcuts.js +271 -0
  410. package/dist/web/static/js/editor/layer-helpers.js +131 -0
  411. package/dist/web/static/js/editor/layer-panel.js +600 -0
  412. package/dist/web/static/js/editor/mask-utils.js +91 -0
  413. package/dist/web/static/js/editor/shortcuts-popover.js +149 -0
  414. package/dist/web/static/js/editor/slider-ux.js +191 -0
  415. package/dist/web/static/js/editor/snap.js +107 -0
  416. package/dist/web/static/js/editor/state.js +257 -0
  417. package/dist/web/static/js/editor/stroke-pipeline.js +162 -0
  418. package/dist/web/static/js/editor/stroke-tool-sliders.js +64 -0
  419. package/dist/web/static/js/editor/tools/clone.js +81 -0
  420. package/dist/web/static/js/editor/tools/crop.js +137 -0
  421. package/dist/web/static/js/editor/tools/flood-fill.js +72 -0
  422. package/dist/web/static/js/editor/tools/lasso-mask.js +171 -0
  423. package/dist/web/static/js/editor/tools/lasso.js +65 -0
  424. package/dist/web/static/js/editor/tools/move.js +79 -0
  425. package/dist/web/static/js/editor/tools/stroke.js +123 -0
  426. package/dist/web/static/js/editor/tools/transform-drag.js +174 -0
  427. package/dist/web/static/js/editor/tools/transform-handles.js +271 -0
  428. package/dist/web/static/js/editor/tools/transform-session.js +381 -0
  429. package/dist/web/static/js/editor/tools/wand.js +46 -0
  430. package/dist/web/static/js/editor/wire-import.js +148 -0
  431. package/dist/web/static/js/editor/wire-inpaint-controls.js +164 -0
  432. package/dist/web/static/js/editor/wire-merge-buttons.js +103 -0
  433. package/dist/web/static/js/editor/wire-selection-controls.js +170 -0
  434. package/dist/web/static/js/editor/wire-topbar-menus.js +174 -0
  435. package/dist/web/static/js/editor/wire-topbar-overflow.js +49 -0
  436. package/dist/web/static/js/editor/wire-topbar.js +188 -0
  437. package/dist/web/static/js/emailInbox.js +1245 -0
  438. package/dist/web/static/js/emailLibrary/replyRecipients.js +27 -0
  439. package/dist/web/static/js/emailLibrary/signatureFold.js +339 -0
  440. package/dist/web/static/js/emailLibrary/state.js +34 -0
  441. package/dist/web/static/js/emailLibrary/utils.js +202 -0
  442. package/dist/web/static/js/emailLibrary.js +5217 -0
  443. package/dist/web/static/js/emojiPicker.js +311 -0
  444. package/dist/web/static/js/escMenuStack.js +102 -0
  445. package/dist/web/static/js/fileHandler.js +295 -0
  446. package/dist/web/static/js/gallery.js +2835 -0
  447. package/dist/web/static/js/galleryEditor.js +3798 -0
  448. package/dist/web/static/js/group.js +955 -0
  449. package/dist/web/static/js/init.js +405 -0
  450. package/dist/web/static/js/keyboard-shortcuts.js +292 -0
  451. package/dist/web/static/js/langIcons.js +187 -0
  452. package/dist/web/static/js/markdown/tableRow.js +19 -0
  453. package/dist/web/static/js/markdown.js +806 -0
  454. package/dist/web/static/js/memory.js +1418 -0
  455. package/dist/web/static/js/modalManager.js +1552 -0
  456. package/dist/web/static/js/modalSnap.js +782 -0
  457. package/dist/web/static/js/model/matchKey.js +19 -0
  458. package/dist/web/static/js/modelPicker.js +695 -0
  459. package/dist/web/static/js/modelSort.js +33 -0
  460. package/dist/web/static/js/models.js +642 -0
  461. package/dist/web/static/js/notes.js +5054 -0
  462. package/dist/web/static/js/package.json +1 -0
  463. package/dist/web/static/js/platform.js +47 -0
  464. package/dist/web/static/js/presets.js +1110 -0
  465. package/dist/web/static/js/providers.js +93 -0
  466. package/dist/web/static/js/rag.js +177 -0
  467. package/dist/web/static/js/research/jobs.js +335 -0
  468. package/dist/web/static/js/research/panel.js +1233 -0
  469. package/dist/web/static/js/researchSynapse.js +225 -0
  470. package/dist/web/static/js/search-chat.js +201 -0
  471. package/dist/web/static/js/search.js +52 -0
  472. package/dist/web/static/js/section-management.js +260 -0
  473. package/dist/web/static/js/sessions.js +3108 -0
  474. package/dist/web/static/js/settings.js +4991 -0
  475. package/dist/web/static/js/sidebar-layout.js +573 -0
  476. package/dist/web/static/js/signature.js +504 -0
  477. package/dist/web/static/js/skills.js +1908 -0
  478. package/dist/web/static/js/slashAutocomplete.js +267 -0
  479. package/dist/web/static/js/slashCommands.js +6099 -0
  480. package/dist/web/static/js/spinner.js +406 -0
  481. package/dist/web/static/js/storage.js +123 -0
  482. package/dist/web/static/js/tasks.js +2699 -0
  483. package/dist/web/static/js/theme.js +2086 -0
  484. package/dist/web/static/js/tileManager.js +386 -0
  485. package/dist/web/static/js/tourAutoplay.js +133 -0
  486. package/dist/web/static/js/tourHints.js +179 -0
  487. package/dist/web/static/js/tts-ai.js +521 -0
  488. package/dist/web/static/js/ui.js +1288 -0
  489. package/dist/web/static/js/util/ordinal.js +13 -0
  490. package/dist/web/static/js/voiceRecorder.js +283 -0
  491. package/dist/web/static/js/windowDrag.js +326 -0
  492. package/dist/web/static/js/windowResize.js +233 -0
  493. package/dist/web/static/landing.html +824 -0
  494. package/dist/web/static/lib/docx.umd.min.js +20116 -0
  495. package/dist/web/static/lib/highlight.min.js +1213 -0
  496. package/dist/web/static/lib/html2pdf.bundle.min.js +3 -0
  497. package/dist/web/static/lib/mammoth.browser.min.js +21 -0
  498. package/dist/web/static/lib/qrcode.min.js +8 -0
  499. package/dist/web/static/lib/xlsx.full.min.js +24 -0
  500. package/dist/web/static/login.html +582 -0
  501. package/dist/web/static/manifest.json +15 -0
  502. package/dist/web/static/style.css +35733 -0
  503. package/dist/web/static/sw.js +144 -0
  504. package/dist/workflows/engine.d.ts +162 -0
  505. package/dist/workflows/engine.d.ts.map +1 -0
  506. package/dist/workflows/engine.js +625 -0
  507. package/dist/workflows/engine.js.map +1 -0
  508. package/dist/workflows/index.d.ts +8 -0
  509. package/dist/workflows/index.d.ts.map +1 -0
  510. package/dist/workflows/index.js +14 -0
  511. package/dist/workflows/index.js.map +1 -0
  512. package/package.json +112 -0
@@ -0,0 +1,3798 @@
1
+ /**
2
+ * Gallery Editor — canvas-based image editor with layers, brush, eraser, text, crop, inpaint mask.
3
+ */
4
+
5
+ import uiModule from './ui.js';
6
+ import dragSortModule from './dragSort.js';
7
+ import spinnerModule from './spinner.js';
8
+ import { attachColorPicker } from './colorPicker.js';
9
+ import modalManager from './modalManager.js';
10
+ import { canvasCoords as _canvasCoords } from './editor/canvas-coords.js';
11
+ import { drawCheckerboard as _drawCheckerboard } from './editor/checkerboard.js';
12
+ import { dilateMask as _dilateMask, applyInpaintFeather as _applyInpaintFeather } from './editor/mask-utils.js';
13
+ import {
14
+ lassoOffsetPoints as _lassoOffsetPointsImpl,
15
+ getLassoPath as _getLassoPathImpl,
16
+ buildLassoMask as _buildLassoMaskImpl,
17
+ } from './editor/tools/lasso-mask.js';
18
+ import { floodFillMask as _floodFillMask } from './editor/tools/flood-fill.js';
19
+ import { drawHistogram as _drawHistogram } from './editor/fx/histogram.js';
20
+ import {
21
+ applyAdjustment as _applyAdjToCanvas,
22
+ renderLayerPixelAdjustments as _renderLayerPixelAdjustmentsImpl,
23
+ renderLayerWithAdjLayers as _renderLayerWithAdjLayers,
24
+ } from './editor/fx/pixel-pass.js';
25
+ import {
26
+ layerFilterString as _layerFilterString,
27
+ fxFilterToSlider as _fxFilterToSlider,
28
+ } from './editor/fx/filter-string.js';
29
+ import {
30
+ layerHasAdjustments as _layerHasAdjustments,
31
+ layerNeedsPixelPass as _layerNeedsPixelPass,
32
+ adjustmentsKey as _adjustmentsKey,
33
+ defaultAdjParams as _defaultAdjParams,
34
+ adjLayerLabel as _adjLayerLabel,
35
+ ADJ_ICONS as _ADJ_ICONS,
36
+ HISTORY_ICON as _HISTORY_ICON,
37
+ isMaskCanvasEmpty as _isMaskCanvasEmpty,
38
+ isLayerEmpty as _isLayerEmpty,
39
+ relTime as _relTime,
40
+ } from './editor/layer-helpers.js';
41
+ import { computeSnap as _computeSnapImpl, cursorForHandle as _cursorForHandle } from './editor/snap.js';
42
+ import {
43
+ layerUnionAlpha as _layerUnionAlphaImpl,
44
+ seamMask as _seamMaskImpl,
45
+ layerBodyMask as _layerBodyMaskImpl,
46
+ } from './editor/harmonize-masks.js';
47
+ import {
48
+ gaussianBlur as _gaussianBlur,
49
+ zoomBlur as _zoomBlur,
50
+ motionBlur as _motionBlur,
51
+ } from './editor/filters/blur.js';
52
+ import { edgeFeather as _edgeFeather } from './editor/filters/edge-feather.js';
53
+ import {
54
+ buildThumbnail as _buildThumbnailImpl,
55
+ buildMergedMaskCanvas as _buildMergedMaskCanvasImpl,
56
+ } from './editor/composite-helpers.js';
57
+ import { buildToolbar as _buildToolbar } from './editor/build/toolbar.js';
58
+ import { buildTopbar as _buildTopbar } from './editor/build/topbar.js';
59
+ import {
60
+ controlsHTML as _controlsHTML,
61
+ layerPanelHTML as _layerPanelHTML,
62
+ } from './editor/build/controls.js';
63
+ import {
64
+ transformPopupHTML as _transformPopupHTML,
65
+ attachSpinRepeat as _attachSpinRepeat,
66
+ } from './editor/build/transform-popup.js';
67
+ import {
68
+ shortcutsPopupHTML as _shortcutsPopupHTML,
69
+ historyPanelHTML as _historyPanelHTMLImpl,
70
+ canvasSizePromptHTML as _canvasSizePromptHTML,
71
+ } from './editor/build/popups.js';
72
+ import { state } from './editor/state.js';
73
+ import { createMoveTool } from './editor/tools/move.js';
74
+ import { createCropTool } from './editor/tools/crop.js';
75
+ import { createLassoTool } from './editor/tools/lasso.js';
76
+ import { createWandTool } from './editor/tools/wand.js';
77
+ import { createCloneTool } from './editor/tools/clone.js';
78
+ import { createTransformDragTool } from './editor/tools/transform-drag.js';
79
+ import { createStrokeTool } from './editor/tools/stroke.js';
80
+ import { createLayerPanelRenderer } from './editor/layer-panel.js';
81
+ import {
82
+ syncOverlay as _syncTransformOverlayImpl,
83
+ drawHandles as _drawTransformHandlesImpl,
84
+ getHandleAt as _getTransformHandleImpl,
85
+ } from './editor/tools/transform-handles.js';
86
+ import { createCanvasTransforms } from './editor/canvas-transforms.js';
87
+ import { createApplyImageTool } from './editor/ai-tool-runner.js';
88
+ import { createStrokePipeline } from './editor/stroke-pipeline.js';
89
+ import { createAdjPopupSystem } from './editor/fx/adj-popup.js';
90
+ import { createHistoryPanel } from './editor/history-panel.js';
91
+ import { createTransformSession } from './editor/tools/transform-session.js';
92
+ import { wireCanvasEvents } from './editor/canvas-events.js';
93
+ import { buildRightPanel } from './editor/build/right-panel.js';
94
+ import { wireSliderUx } from './editor/slider-ux.js';
95
+ import { createShortcutsPopover } from './editor/shortcuts-popover.js';
96
+ import { wireKeyboardShortcuts } from './editor/keyboard-shortcuts.js';
97
+ import { wireClipboardAndDrop } from './editor/clipboard-and-drop.js';
98
+ import { wireAIModelSelectors } from './editor/ai-models.js';
99
+ import { wireInpaintButtons } from './editor/ai-inpaint.js';
100
+ import { wireAIToolsMisc } from './editor/ai-tools-misc.js';
101
+ import { wireRembgAndSharpen } from './editor/ai-rembg.js';
102
+ import { wireStrokeToolSliders } from './editor/stroke-tool-sliders.js';
103
+ import { wireImport } from './editor/wire-import.js';
104
+ import { wireMergeButtons } from './editor/wire-merge-buttons.js';
105
+ import { wireSelectionControls } from './editor/wire-selection-controls.js';
106
+ import { wireInpaintControls } from './editor/wire-inpaint-controls.js';
107
+ import { wireTopbar, closeOtherTopbarMenus as _closeOtherTopbarMenus } from './editor/wire-topbar.js';
108
+ import { wireTopbarOverflow } from './editor/wire-topbar-overflow.js';
109
+ import { wireTopbarMenus } from './editor/wire-topbar-menus.js';
110
+
111
+ const API_BASE = window.location.origin;
112
+ // ── State ──
113
+ // Transform-overlay canvas — sits over the main canvas with extra margin
114
+ // so resize / rotation handles render OUTSIDE the image edges. Pointer
115
+ // events disabled; the main canvas still handles all input.
116
+ const _TRANSFORM_OVERLAY_MARGIN = 60; // image-space px of slack on each side
117
+ // Thin wrappers around the transform-handles impls — the refactor
118
+ // imported them under *Impl aliases but several call sites still use
119
+ // the bare names. Without these, _startTransform threw a ReferenceError
120
+ // before opening the popup, so Transform showed no handles / no popup.
121
+ function _drawTransformHandles() { _drawTransformHandlesImpl(_TRANSFORM_OVERLAY_MARGIN); }
122
+ function _getTransformHandle(x, y) { return _getTransformHandleImpl(x, y); }
123
+ function _syncTransformOverlay() { _syncTransformOverlayImpl(_TRANSFORM_OVERLAY_MARGIN); }
124
+ // Inpaint uses a much bigger default brush — when the user enters
125
+ // the inpaint tool for the first time in this editor session we bump
126
+ // the slider to this value (without touching other tools).
127
+ const _INPAINT_DEFAULT_BRUSH = 100;
128
+
129
+ function _galleryEditMounted() {
130
+ return !!document.querySelector('#gallery-editor-container .gallery-editor');
131
+ }
132
+
133
+ if (!window.__galleryEditEscHardGuardInstalled) {
134
+ window.__galleryEditEscHardGuardInstalled = true;
135
+ window.addEventListener('keydown', (e) => {
136
+ if (e.key !== 'Escape') return;
137
+ if (window.__galleryEditLive || _galleryEditMounted()) {
138
+ e.preventDefault();
139
+ e.stopPropagation();
140
+ e.stopImmediatePropagation();
141
+ }
142
+ }, true);
143
+ }
144
+
145
+ // Document-level click-away handlers for topbar dropdowns. Each
146
+ // openEditor invocation adds 6 of these (save / edge / image / filter /
147
+ // resize / more), and without removal they accumulated across reopens.
148
+ // Tracked here and removed wholesale in closeEditor.
149
+ function _registerDocClickAway(handler) {
150
+ document.addEventListener('click', handler);
151
+ state.editorDocClickHandlers.push(handler);
152
+ }
153
+
154
+ // Drawing state
155
+
156
+ // Move tool state
157
+ // Crop state
158
+ // Persistent mode toggle for wand clicks. 'replace' = a new click
159
+ // replaces the selection (default); 'add' = always union; 'subtract' =
160
+ // always remove from the existing selection. Shift / Alt held during a
161
+ // click still override this transiently.
162
+ // Last seed click so the tolerance slider can re-run the wand live.
163
+ // Stored in canvas coords (same units `_runMagicWand` accepts).
164
+ // Lasso state
165
+
166
+ // Transform state
167
+ // Snapshot of the layer's pixels at the moment the transform started.
168
+ // Lets the popup live-preview by re-applying from the original on every
169
+ // input change instead of stacking destructive edits.
170
+ // Current popup-driven values (re-applied on every change).
171
+
172
+ // Inpaint mask (separate canvas, same dimensions as image)
173
+ // Cached canvas reused each composite() to merge every visible mask
174
+ // sub-layer into a single tinted overlay (avoids re-allocating on each
175
+ // frame). Recreated lazily if dimensions change.
176
+ // Softer default than the original full-saturation red — the user
177
+ // found the previous tint distracting. Tweakable via the color picker
178
+ // under the Paint/Erase row.
179
+ // Persistent paint/erase toggle for the Inpaint brush. False = paint
180
+ // (default), true = erase. Ctrl+Alt held during a stroke flips this
181
+ // transiently for the duration of that one stroke.
182
+ // Resolved per-stroke at pointerdown: state.inpaintEraseMode XOR (Ctrl+Alt).
183
+ // Most-recent inpaint result layer id — the post-generation Feather
184
+ // slider edits this layer's alpha edge live.
185
+
186
+ // _dilateMask + _applyInpaintFeather live in editor/mask-utils.js
187
+ // — see import at top of file.
188
+
189
+ // Eraser settings
190
+ // Edge softness, 0..100. 0 = hard pixel edge; higher values blur the
191
+ // stroke's alpha so the eraser fades out at the brush perimeter.
192
+ // Brush settings (same shape as eraser).
193
+ // Clone Stamp brush modifiers — independent from the Brush tool's
194
+ // settings so users can dial in cloning without losing their brush
195
+ // preset (and vice-versa).
196
+ // `state.cloneSourceX/Y` is the sample anchor set by Alt-click. While
197
+ // painting, the source point moves in lockstep with the brush so the
198
+ // sampled offset stays constant (Photoshop "aligned" mode).
199
+ // First brush coord of the current stroke — used to compute the
200
+ // running offset (`sample = source + (current - strokeStart)`).
201
+ // Snapshot of the source layer's pixels at stroke-start so we can keep
202
+ // sampling clean pixels even after the brush has painted over them
203
+ // (avoids feedback / smearing).
204
+ // Double-tap detection for the Clone tool on touch devices — sets the
205
+ // sample anchor without a keyboard Alt modifier.
206
+
207
+ // Undo/Redo
208
+ const MAX_HISTORY = 20;
209
+
210
+ /** Get the selected AI endpoint+model. Returns { endpoint, model }.
211
+ * Dropdown values are encoded as "<base_url>::<model_id>" so users can pick
212
+ * a specific model on a multi-model endpoint (e.g. dall-e-2 vs gpt-image-1). */
213
+ function _getSelectedAIEndpoint(type) {
214
+ let raw = '';
215
+ if (type === 'inpaint') {
216
+ raw = document.getElementById('ge-ai-inpaint')?.value || '';
217
+ } else if (type) {
218
+ // Per-tool dropdowns (harmonize/upscale/style). Each lives in its
219
+ // own section's panel and is marked with data-ge-tool-model="<name>".
220
+ const sel = document.querySelector(`select[data-ge-tool-model="${type}"]`);
221
+ raw = sel?.value || '';
222
+ }
223
+ if (!raw) raw = document.getElementById('ge-ai-model')?.value || '';
224
+ if (!raw) return { endpoint: '', model: '' };
225
+ const idx = raw.indexOf('::');
226
+ if (idx < 0) return { endpoint: raw, model: '' };
227
+ return { endpoint: raw.slice(0, idx), model: raw.slice(idx + 2) };
228
+ }
229
+
230
+ /** Shared helper: flatten layers → POST to API → add result as new layer. */
231
+ // Maps a layer-name (the past-participle returned from each AI tool —
232
+ // "BG Removed", "Sharpened", etc.) into a present-progressive label for
233
+ // the busy button state ("Removing…", "Sharpening…"). Falls back to a
234
+ // neutral "Processing…" when the layer name doesn't match a known verb.
235
+ const _BUSY_LABELS = {
236
+ 'bg removed': 'Removing…',
237
+ 'sharpened': 'Sharpening…',
238
+ 'enhanced': 'Enhancing…',
239
+ 'harmonized': 'Harmonizing…',
240
+ 'upscaled': 'Upscaling…',
241
+ 'styled': 'Styling…',
242
+ };
243
+ function _deriveBusyLabel(layerName) {
244
+ if (!layerName) return 'Processing…';
245
+ return _BUSY_LABELS[String(layerName).toLowerCase()] || 'Processing…';
246
+ }
247
+
248
+ // AI-tool runner — sharpen / harmonize / upscale / style / bg-remove
249
+ // all flatten the doc, POST a PNG to a server endpoint, and drop the
250
+ // result back as a new layer. Full implementation in editor/ai-tool-
251
+ // runner.js; instantiated lazily (so it can reference function decls
252
+ // that haven't hoisted at module load time? — actually all named
253
+ // function decls hoist, so we instantiate at module top).
254
+ const _applyImageTool = createApplyImageTool({
255
+ flatten: () => flatten(),
256
+ saveState: _saveState,
257
+ createLayer,
258
+ composite,
259
+ renderLayerPanel: () => _renderLayerPanel(),
260
+ deriveBusyLabel: (name) => _deriveBusyLabel(name),
261
+ getSelectedAIEndpoint: (type) => _getSelectedAIEndpoint(type),
262
+ openCookbookForDependency: (pkg) => _openCookbookForDependency(pkg),
263
+ openCookbookForImg2img: () => _openCookbookForImg2img(),
264
+ spinnerModule,
265
+ uiModule,
266
+ });
267
+
268
+ // Layer offsets for move tool
269
+
270
+ // ── Layer class ──
271
+
272
+
273
+ function createLayer(name, width, height) {
274
+ const canvas = document.createElement('canvas');
275
+ canvas.width = width;
276
+ canvas.height = height;
277
+ const layer = {
278
+ id: 'layer-' + (state.nextLayerId++),
279
+ name,
280
+ canvas,
281
+ ctx: canvas.getContext('2d'),
282
+ visible: true,
283
+ opacity: 1,
284
+ locked: false,
285
+ // Mask sub-layers — same shape as adjLayers, parallel concept.
286
+ // Each entry: {id, name, canvas, visible}. The "active" mask is the
287
+ // one that paint / lasso / inpaint operations target; rendered as a
288
+ // red overlay in composite().
289
+ masks: [],
290
+ activeMaskId: null,
291
+ // Non-destructive adjustments. B/C/S/H are applied at composite()
292
+ // via ctx.filter (fast, CSS). Levels + Color Balance need per-pixel
293
+ // math and are baked into a cached canvas (layer._adjCache) that
294
+ // gets re-rendered only when those values change.
295
+ adjustments: {
296
+ brightness: 1, // 0..2 (1 = neutral)
297
+ contrast: 1, // 0..2 (1 = neutral)
298
+ saturation: 1, // 0..2 (0 = grayscale, 1 = neutral)
299
+ hue: 0, // degrees, -180..180
300
+ // Levels — Photoshop-style three-stop adjust applied per channel.
301
+ // input 0..255, gamma 0.1..9.9. Default is identity.
302
+ levels: { inBlack: 0, inWhite: 255, gamma: 1.0, outBlack: 0, outWhite: 255 },
303
+ // Color Balance — additive per-channel shifts weighted by tone.
304
+ // Each value is -100..+100 mapping to roughly ±60 in 0..255 space.
305
+ colorBalance: {
306
+ shadows: { r: 0, g: 0, b: 0 },
307
+ midtones: { r: 0, g: 0, b: 0 },
308
+ highlights: { r: 0, g: 0, b: 0 },
309
+ },
310
+ },
311
+ };
312
+ state.layerOffsets.set(layer.id, { x: 0, y: 0 });
313
+ return layer;
314
+ }
315
+
316
+ // _layerFilterString + _fxFilterToSlider live in editor/fx/filter-string.js
317
+ // — see import at top.
318
+
319
+ // _layerHasAdjustments lives in editor/layer-helpers.js — see import at top.
320
+
321
+ // ── Mask sub-layers ──
322
+ // Resolves to the parent layer that should own masks for the current
323
+ // edit. The "active" parent is whichever layer the user has selected,
324
+ // excluding mask-sublayer entries themselves.
325
+ function _activeParentLayer() {
326
+ return state.layers.find(l => l.id === state.activeLayerId) || state.layers[state.layers.length - 1] || null;
327
+ }
328
+
329
+ // Find the active mask sub-layer (the one paint/lasso/inpaint ops
330
+ // target). Returns null if the parent has no masks OR if no mask is
331
+ // currently activated (i.e. the user explicitly selected the parent
332
+ // pixels as the paint target). Earlier code fell back to "last mask
333
+ // in the list" which meant clicking the parent row couldn't escape
334
+ // mask-paint mode — that surprised the user, so the fallback is
335
+ // gone.
336
+ function _getActiveMaskLayer() {
337
+ const parent = _activeParentLayer();
338
+ if (!parent || !parent.masks || !parent.masks.length) return null;
339
+ if (!parent.activeMaskId) return null;
340
+ return parent.masks.find(m => m.id === parent.activeMaskId) || null;
341
+ }
342
+
343
+ // Get-or-create a mask sub-layer on the active parent. Used by tools
344
+ // that need a mask to write into (Brush on mask, Inpaint stroke,
345
+ // lasso→mask, wand→mask).
346
+ function _ensureActiveMaskLayer() {
347
+ const parent = _activeParentLayer();
348
+ if (!parent) return null;
349
+ if (!parent.masks) parent.masks = [];
350
+ let mask = _getActiveMaskLayer();
351
+ if (mask) return mask;
352
+ const c = document.createElement('canvas');
353
+ c.width = state.imgWidth;
354
+ c.height = state.imgHeight;
355
+ mask = {
356
+ id: 'mask-' + (state.nextLayerId++),
357
+ name: 'Mask ' + (parent.masks.length + 1),
358
+ canvas: c,
359
+ ctx: c.getContext('2d'),
360
+ visible: true,
361
+ };
362
+ parent.masks.push(mask);
363
+ parent.activeMaskId = mask.id;
364
+ return mask;
365
+ }
366
+
367
+ // True if any visible layer in the doc carries a mask sub-layer; drives
368
+ // the "red overlay" pass in composite().
369
+ function _hasAnyMasks() {
370
+ for (const l of state.layers) {
371
+ if (l.masks && l.masks.length) return true;
372
+ }
373
+ return false;
374
+ }
375
+
376
+ // Union of every VISIBLE mask sub-layer across the whole document,
377
+ // returned as a fresh image-sized canvas with white = masked area.
378
+ // Used by inpaint Generate/Remove so the AI sees the combined region
379
+ // instead of just the active mask. Returns null when no masks exist
380
+ // (caller should fall back to the active mask plumbing in that case).
381
+ function _buildMergedMaskCanvas() {
382
+ return _buildMergedMaskCanvasImpl(state.layers, state.imgWidth, state.imgHeight);
383
+ }
384
+
385
+ // True if the layer needs the (slower) per-pixel LUT pass — i.e. Levels
386
+ // or Color Balance are non-identity. Brightness/Contrast/Saturation/Hue
387
+ // alone can stay on the fast CSS-filter path.
388
+ // _layerNeedsPixelPass + _adjustmentsKey live in editor/layer-helpers.js.
389
+
390
+ // Per-pixel Levels + Color Balance. Renders the layer.canvas into a
391
+ // cached canvas (layer._adjCache) with the LUT-style transforms applied.
392
+ // CSS-filter adjustments (B/C/S/H) are still applied at composite() on
393
+ // top of this cache.
394
+ // Pixel-pass adjustment math lives in editor/fx/pixel-pass.js. This
395
+ // wrapper forwards the layer + a fresh adjustments-cache key so
396
+ // existing callers stay unchanged.
397
+ function _renderLayerPixelAdjustments(layer) {
398
+ return _renderLayerPixelAdjustmentsImpl(layer, _adjustmentsKey(layer.adjustments));
399
+ }
400
+ // Layer FX popup — floating window bound to a specific layer. Sliders
401
+ // edit that layer's adjustments and live-update composite(). The popup
402
+ // stays open across clicks elsewhere unless dismissed via its × button.
403
+
404
+ // FX / adjustment-popup machinery — full implementation in
405
+ // editor/fx/adj-popup.js. Wrappers preserve the legacy names that
406
+ // every layer-row FX button, panel-row click, and undo/redo path
407
+ // already references.
408
+ const _adjPopupSystem = createAdjPopupSystem({
409
+ composite,
410
+ saveState: _saveState,
411
+ renderLayerPanel: () => _renderLayerPanel(),
412
+ });
413
+ const _closeFxPopup = _adjPopupSystem.closeFxPopup;
414
+ const _ensureAdjustments = _adjPopupSystem.ensureAdjustments;
415
+ const _ensureFxDock = _adjPopupSystem.ensureFxDock;
416
+ const _closeFxMenu = _adjPopupSystem.closeFxMenu;
417
+ const _openFxPopup = _adjPopupSystem.openFxPopup;
418
+ const _openAdjPopup = _adjPopupSystem.openAdjPopup;
419
+ const _editAdjLayer = _adjPopupSystem.editAdjLayer;
420
+ const _closeAdjPopup = _adjPopupSystem.closeAdjPopup;
421
+ const _minimiseAdjPopup = _adjPopupSystem.minimiseAdjPopup;
422
+ const _syncFxPanelToActiveLayerIfPresent = _adjPopupSystem.syncFxPanelToActiveLayerIfPresent;
423
+
424
+ function activeLayer() {
425
+ return state.layers.find(l => l.id === state.activeLayerId) || null;
426
+ }
427
+
428
+ // Flood-fill enclosed regions of the inpaint mask. After the user
429
+ // draws a closed shape (circle, lasso, whatever), the interior is
430
+ // alpha=0 surrounded by white mask. We mark all alpha=0 pixels
431
+ // reachable from the canvas edges as "outside"; anything still alpha=0
432
+ // after that pass is enclosed and gets filled with white.
433
+ function _fillEnclosedMaskRegions() {
434
+ if (!state.maskCanvas || !state.maskCtx) return;
435
+ const w = state.maskCanvas.width, h = state.maskCanvas.height;
436
+ if (w * h > 4096 * 4096) return; // safety cap
437
+ const img = state.maskCtx.getImageData(0, 0, w, h);
438
+ const d = img.data;
439
+ // visited bitmap — 0 = unvisited, 1 = reached from edge (outside),
440
+ // 2 = mask (alpha>0). After BFS, alpha=0 pixels with visited[i]=0
441
+ // are enclosed.
442
+ const visited = new Uint8Array(w * h);
443
+ const stack = [];
444
+ // Pre-mark all mask pixels as visited=2 so we don't cross them.
445
+ for (let i = 0, j = 0; i < d.length; i += 4, j++) {
446
+ if (d[i + 3] > 0) visited[j] = 2;
447
+ }
448
+ // Seed flood from every edge pixel that's empty.
449
+ const seed = (x, y) => {
450
+ const k = y * w + x;
451
+ if (visited[k] === 0) { visited[k] = 1; stack.push(k); }
452
+ };
453
+ for (let x = 0; x < w; x++) { seed(x, 0); seed(x, h - 1); }
454
+ for (let y = 0; y < h; y++) { seed(0, y); seed(w - 1, y); }
455
+ // BFS — 4-connected.
456
+ while (stack.length) {
457
+ const k = stack.pop();
458
+ const x = k % w, y = (k - x) / w;
459
+ if (x > 0) { const n = k - 1; if (visited[n] === 0) { visited[n] = 1; stack.push(n); } }
460
+ if (x < w - 1) { const n = k + 1; if (visited[n] === 0) { visited[n] = 1; stack.push(n); } }
461
+ if (y > 0) { const n = k - w; if (visited[n] === 0) { visited[n] = 1; stack.push(n); } }
462
+ if (y < h - 1) { const n = k + w; if (visited[n] === 0) { visited[n] = 1; stack.push(n); } }
463
+ }
464
+ // Anything still visited=0 → enclosed empty region. Fill white.
465
+ let filled = false;
466
+ for (let j = 0, i = 0; j < visited.length; j++, i += 4) {
467
+ if (visited[j] === 0) {
468
+ d[i] = 255; d[i + 1] = 255; d[i + 2] = 255; d[i + 3] = 255;
469
+ filled = true;
470
+ }
471
+ }
472
+ if (filled) state.maskCtx.putImageData(img, 0, 0);
473
+ }
474
+
475
+ // True if a layer has no opaque pixels — used to tag the row in the
476
+ // layer panel as "(empty)" so the user can tell at a glance which
477
+ // layers carry actual content.
478
+ // Lightweight loading overlay anchored to the canvas area. Used for
479
+ // blocking operations (rotation on big images, etc.) so the user gets
480
+ // feedback while the main thread is busy. The actual heavy call should
481
+ // be deferred with rAF so the overlay paints before the block.
482
+ function _showCanvasLoading(message) {
483
+ if (!state.container) return;
484
+ let overlay = state.container.querySelector('.ge-canvas-loading');
485
+ if (!overlay) {
486
+ overlay = document.createElement('div');
487
+ overlay.className = 'ge-canvas-loading ge-frosted';
488
+ overlay.innerHTML = `
489
+ <div class="ge-canvas-loading-spinner"></div>
490
+ <div class="ge-canvas-loading-msg"></div>
491
+ `;
492
+ state.container.appendChild(overlay);
493
+ }
494
+ overlay.querySelector('.ge-canvas-loading-msg').textContent = message || 'Working…';
495
+ overlay.style.display = '';
496
+ }
497
+ function _hideCanvasLoading() {
498
+ const overlay = state.container && state.container.querySelector('.ge-canvas-loading');
499
+ if (overlay) overlay.style.display = 'none';
500
+ }
501
+
502
+ // Cheap "is this mask canvas blank?" check. Used to suffix "(empty)" on
503
+ // mask sub-layer rows in the panel so the user can tell at a glance
504
+ // which masks have actually been painted on.
505
+ // _isMaskCanvasEmpty lives in editor/layer-helpers.js.
506
+
507
+ // Document-wide rotate / flip — implementations in
508
+ // editor/canvas-transforms.js. Wrappers preserve the legacy names that
509
+ // the topbar Image menu and shortcuts already wire to.
510
+ const _canvasTransforms = createCanvasTransforms({
511
+ saveState: _saveState,
512
+ composite,
513
+ fitZoom: () => _fitZoom(),
514
+ showCanvasLoading: (label) => _showCanvasLoading(label),
515
+ hideCanvasLoading: () => _hideCanvasLoading(),
516
+ });
517
+ function _rotateAllLayers(deg) { return _canvasTransforms.rotateAll(deg); }
518
+ function _flipAllLayers(axis) { return _canvasTransforms.flipAll(axis); }
519
+
520
+ // _isLayerEmpty lives in editor/layer-helpers.js.
521
+
522
+ // ── Composite ──
523
+
524
+ function composite() {
525
+ if (!state.mainCtx) return;
526
+ state.mainCtx.clearRect(0, 0, state.mainCanvas.width, state.mainCanvas.height);
527
+ // Checkerboard background
528
+ _drawCheckerboard(state.mainCtx, state.mainCanvas.width, state.mainCanvas.height);
529
+ for (const layer of state.layers) {
530
+ if (!layer.visible) continue;
531
+ state.mainCtx.globalAlpha = layer.opacity;
532
+ const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
533
+ // Source = layer.canvas walked through all its adjustment
534
+ // sub-layers (plus any staged-in-progress edit). Falls back to
535
+ // raw layer.canvas when no adjustments are present.
536
+ const source = _renderLayerWithAdjLayers(layer);
537
+ state.mainCtx.drawImage(source, off.x, off.y);
538
+ state.mainCtx.globalAlpha = 1;
539
+ }
540
+ // Show mask overlay as red tint whenever a mask sub-layer is present
541
+ // on the active parent (was previously gated on inpaint-tool only; now
542
+ // masks are first-class layer entities, so users see them in any tool).
543
+ // Mask canvas has white pixels — we tint them red for visibility.
544
+ if (state.maskVisible) {
545
+ // Build a SINGLE merged mask canvas from every visible mask
546
+ // sub-layer (union of alpha — `lighter` keeps max alpha per pixel,
547
+ // so overlapping strokes don't visually stack). Then tint it red
548
+ // once and composite at the configured opacity. Result: the user
549
+ // sees a flat, consistent translucent red over the masked area
550
+ // regardless of how many strokes / masks contributed to it. Mask
551
+ // visibility is INDEPENDENT of parent visibility — hiding the
552
+ // parent layer doesn't hide its masks.
553
+ let _haveAny = false;
554
+ if (!state.compositeMaskUnion) state.compositeMaskUnion = document.createElement('canvas');
555
+ const union = state.compositeMaskUnion;
556
+ union.width = state.mainCanvas.width;
557
+ union.height = state.mainCanvas.height;
558
+ const uctx = union.getContext('2d');
559
+ uctx.clearRect(0, 0, union.width, union.height);
560
+ uctx.globalCompositeOperation = 'lighter';
561
+ for (const ly of state.layers) {
562
+ if (!ly.masks || !ly.masks.length) continue;
563
+ for (const mk of ly.masks) {
564
+ if (!mk.visible) continue;
565
+ if (!mk.canvas || !mk.canvas.width || !mk.canvas.height) continue;
566
+ uctx.drawImage(mk.canvas, 0, 0);
567
+ _haveAny = true;
568
+ }
569
+ }
570
+ uctx.globalCompositeOperation = 'source-over';
571
+ if (_haveAny) {
572
+ // Tint the merged mask in-place: anywhere alpha exists becomes
573
+ // red; everywhere else stays transparent.
574
+ uctx.globalCompositeOperation = 'source-in';
575
+ uctx.fillStyle = state.maskTintColor || 'rgba(255, 50, 50, 1)';
576
+ uctx.fillRect(0, 0, union.width, union.height);
577
+ uctx.globalCompositeOperation = 'source-over';
578
+ state.mainCtx.globalAlpha = state.maskTintOpacity;
579
+ state.mainCtx.drawImage(union, 0, 0);
580
+ state.mainCtx.globalAlpha = 1;
581
+ }
582
+ }
583
+ // Draw transform handles if active
584
+ if (state.transformActive) _drawTransformHandles();
585
+ else if (state.transformOverlay) state.transformOverlay.style.display = 'none';
586
+ // Persist the lasso selection overlay across composite redraws — without
587
+ // this, leaving/re-entering the canvas (which triggers _endDraw →
588
+ // composite) would visually wipe a completed selection even though
589
+ // state.lassoPoints is still populated.
590
+ if (!state.lassoActive && state.lassoPoints.length >= 3) _drawLassoOverlay();
591
+ // Same idea for the crop overlay — once the user releases the drag,
592
+ // the crop rect should remain visible until they Apply or cancel.
593
+ // Hovering over the floating Apply button counts as a mouseleave on
594
+ // the canvas, which used to wipe the overlay.
595
+ if (state.cropRect && !state.cropping) _drawCropOverlay();
596
+ // Snap guides — drawn while the user is moving a layer with Ctrl held.
597
+ if (state.activeSnapGuides && state.activeSnapGuides.length) _drawSnapGuides();
598
+ // Magic-wand selection overlay (translucent red tint of the mask).
599
+ if (state.wandMask && state.wandLayerId && state.wandMaskVisible) _drawWandOverlay();
600
+ // Keep the per-tool clear-X badges in sync. Cheap: two classList
601
+ // toggles. Composite runs on every visible state change, so this
602
+ // catches every lasso/wand mutation site without each one having to
603
+ // remember to call the sync helper.
604
+ _syncToolClearIndicators();
605
+ }
606
+
607
+ function _drawSnapGuides() {
608
+ const ctx = state.mainCtx;
609
+ ctx.save();
610
+ ctx.strokeStyle = 'rgba(224, 108, 117, 0.85)';
611
+ ctx.lineWidth = 1 / state.zoom;
612
+ ctx.setLineDash([4 / state.zoom, 3 / state.zoom]);
613
+ for (const g of state.activeSnapGuides) {
614
+ ctx.beginPath();
615
+ if (g.vertical) {
616
+ ctx.moveTo(g.x, 0);
617
+ ctx.lineTo(g.x, state.imgHeight);
618
+ } else {
619
+ ctx.moveTo(0, g.y);
620
+ ctx.lineTo(state.imgWidth, g.y);
621
+ }
622
+ ctx.stroke();
623
+ }
624
+ ctx.restore();
625
+ }
626
+
627
+ // Draw the dim-everything-else + cleared-crop-window overlay for the
628
+ // current `state.cropRect`. Shared by _continueCrop (live preview during drag)
629
+ // and composite() (re-draw after canvas redraws while the crop is held).
630
+ function _drawCropOverlay() {
631
+ if (!state.cropRect || !state.mainCtx || !state.mainCanvas) return;
632
+ const { x, y, w, h } = state.cropRect;
633
+ state.mainCtx.fillStyle = 'rgba(0,0,0,0.4)';
634
+ state.mainCtx.fillRect(0, 0, state.mainCanvas.width, state.mainCanvas.height);
635
+ state.mainCtx.clearRect(x, y, w, h);
636
+ state.mainCtx.save();
637
+ state.mainCtx.beginPath();
638
+ state.mainCtx.rect(x, y, w, h);
639
+ state.mainCtx.clip();
640
+ _drawCheckerboard(state.mainCtx, state.mainCanvas.width, state.mainCanvas.height);
641
+ for (const layer of state.layers) {
642
+ if (!layer.visible) continue;
643
+ state.mainCtx.globalAlpha = layer.opacity;
644
+ const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
645
+ state.mainCtx.drawImage(layer.canvas, off.x, off.y);
646
+ }
647
+ state.mainCtx.globalAlpha = 1;
648
+ state.mainCtx.restore();
649
+ state.mainCtx.strokeStyle = '#fff';
650
+ state.mainCtx.lineWidth = 1;
651
+ state.mainCtx.setLineDash([4, 4]);
652
+ state.mainCtx.strokeRect(x, y, w, h);
653
+ state.mainCtx.setLineDash([]);
654
+ }
655
+
656
+ // _drawCheckerboard lives in editor/checkerboard.js — see import at top.
657
+
658
+ // ── History ──
659
+
660
+ function _snapshotState() {
661
+ let wand = null;
662
+ if (state.wandMask) {
663
+ try {
664
+ const wctx = state.wandMask.getContext('2d');
665
+ wand = {
666
+ layerId: state.wandLayerId,
667
+ w: state.wandMask.width,
668
+ h: state.wandMask.height,
669
+ imageData: wctx.getImageData(0, 0, state.wandMask.width, state.wandMask.height),
670
+ seed: state.wandLastSeed ? { ...state.wandLastSeed } : null,
671
+ };
672
+ } catch {}
673
+ }
674
+ return {
675
+ imgWidth: state.imgWidth,
676
+ imgHeight: state.imgHeight,
677
+ wand,
678
+ layers: state.layers.map(l => {
679
+ // getImageData throws on a 0-sized canvas — guard so a single
680
+ // broken layer/mask can't take down the whole snapshot (which
681
+ // would silently break undo/redo for brush strokes etc.).
682
+ let imageData = null;
683
+ try {
684
+ if (l.canvas.width > 0 && l.canvas.height > 0) {
685
+ imageData = l.ctx.getImageData(0, 0, l.canvas.width, l.canvas.height);
686
+ }
687
+ } catch (_) { /* keep imageData=null, restore will skip */ }
688
+ return {
689
+ id: l.id, name: l.name, visible: l.visible, opacity: l.opacity, locked: l.locked,
690
+ canvasW: l.canvas.width,
691
+ canvasH: l.canvas.height,
692
+ imageData,
693
+ offset: { ...(state.layerOffsets.get(l.id) || { x: 0, y: 0 }) },
694
+ // Deep-clone defensively — a non-serializable / circular value here
695
+ // would throw out of the whole snapshot (and historically aborted
696
+ // every mutating op). Fall back to [] rather than blow up.
697
+ adjLayers: (() => {
698
+ try { return l.adjLayers ? JSON.parse(JSON.stringify(l.adjLayers)) : []; }
699
+ catch (e) { console.error('[gallery] adjLayers not serializable, dropping from snapshot:', e); return []; }
700
+ })(),
701
+ masks: (l.masks || []).map(m => {
702
+ let mImageData = null;
703
+ try {
704
+ if (m.canvas.width > 0 && m.canvas.height > 0) {
705
+ mImageData = m.ctx.getImageData(0, 0, m.canvas.width, m.canvas.height);
706
+ }
707
+ } catch (_) {}
708
+ return {
709
+ id: m.id,
710
+ name: m.name,
711
+ visible: m.visible !== false,
712
+ canvasW: m.canvas.width,
713
+ canvasH: m.canvas.height,
714
+ imageData: mImageData,
715
+ };
716
+ }),
717
+ activeMaskId: l.activeMaskId || null,
718
+ isBase: !!l.isBase,
719
+ };
720
+ }),
721
+ };
722
+ }
723
+
724
+ function _saveState(label) {
725
+ // saveState() runs FIRST in every mutating op (import, paste, copy,
726
+ // merge, mask, delete, brush, …). If anything here throws, the whole
727
+ // operation aborts before its real work runs — which silently breaks
728
+ // import ("no layer created") and every layer button. So each step is
729
+ // isolated: a history-snapshot failure must degrade (lose one undo
730
+ // step) rather than kill the user's action.
731
+ try {
732
+ const snap = _snapshotState();
733
+ snap._label = label || 'Edit';
734
+ snap._ts = Date.now();
735
+ state.undoStack.push(snap);
736
+ if (state.undoStack.length > MAX_HISTORY) state.undoStack.shift();
737
+ state.redoStack = [];
738
+ } catch (e) {
739
+ console.error('[gallery] saveState snapshot failed (continuing without this undo step):', e);
740
+ }
741
+ try { _invalidateWandCache(); } catch (e) { console.error('[gallery] invalidateWandCache:', e); }
742
+ try { _schedulePersist(); } catch (e) { console.error('[gallery] schedulePersist:', e); }
743
+ try { _refreshHistoryPanelIfOpen(); } catch (e) { console.error('[gallery] refreshHistoryPanel:', e); }
744
+ }
745
+
746
+ // ────────── Persistent edit drafts (server-backed) ──────────
747
+ // The previous implementation keyed drafts by gallery image-id in
748
+ // localStorage; that meant blank-canvas sessions silently lost work and
749
+ // drafts couldn't roam between devices. We now hold a server-side draft
750
+ // row identified by a uuid (`state.draftId`) and PUT updates to it on a
751
+ // debounced timer. `state.imageId` (gallery id) is still tracked separately
752
+ // for "save back to the original photo" behaviour.
753
+ const PERSIST_DEBOUNCE_MS = 800;
754
+ const THUMB_MAX = 160;
755
+
756
+ function _schedulePersist() {
757
+ if (!state.editorOpen || !state.layers.length) return;
758
+ if (state.persistTimer) clearTimeout(state.persistTimer);
759
+ state.persistTimer = setTimeout(() => { state.persistTimer = null; _persistDraft(); }, PERSIST_DEBOUNCE_MS);
760
+ }
761
+
762
+ function _buildDraftPayload() {
763
+ return {
764
+ v: 2,
765
+ imageId: state.imageId,
766
+ imgWidth: state.imgWidth,
767
+ imgHeight: state.imgHeight,
768
+ activeLayerId: state.activeLayerId,
769
+ nextLayerId: state.nextLayerId,
770
+ layers: state.layers.map(l => ({
771
+ id: l.id,
772
+ name: l.name,
773
+ visible: l.visible,
774
+ opacity: l.opacity,
775
+ locked: l.locked,
776
+ isBase: !!l.isBase,
777
+ canvasW: l.canvas.width,
778
+ canvasH: l.canvas.height,
779
+ offset: { ...(state.layerOffsets.get(l.id) || { x: 0, y: 0 }) },
780
+ dataUrl: l.canvas.toDataURL('image/png'),
781
+ })),
782
+ };
783
+ }
784
+
785
+ function _buildThumbnail() {
786
+ return _buildThumbnailImpl(state.layers, state.imgWidth, state.imgHeight, state.layerOffsets, THUMB_MAX, 0.6);
787
+ }
788
+
789
+ async function _persistDraft() {
790
+ if (!state.editorOpen || !state.layers.length) return;
791
+ // Coalesce concurrent saves — if one's already in-flight, mark dirty
792
+ // and let the running call kick off another when it returns.
793
+ if (state.persistInFlight) { state.persistDirty = true; return; }
794
+ const payload = _buildDraftPayload();
795
+ const thumbnail = _buildThumbnail();
796
+ const body = {
797
+ name: state.draftName || 'Untitled',
798
+ source_image_id: state.imageId || null,
799
+ width: state.imgWidth,
800
+ height: state.imgHeight,
801
+ payload,
802
+ thumbnail,
803
+ };
804
+ const doRequest = async () => {
805
+ if (state.draftId) {
806
+ const res = await fetch(`/api/editor-drafts/${encodeURIComponent(state.draftId)}`, {
807
+ method: 'PUT', credentials: 'same-origin',
808
+ headers: { 'Content-Type': 'application/json' },
809
+ body: JSON.stringify(body),
810
+ });
811
+ // 404 means our row was deleted while editing — fall through to
812
+ // create a fresh one so the user doesn't lose work.
813
+ if (res.status === 404) {
814
+ state.draftId = null;
815
+ return doRequest();
816
+ }
817
+ if (!res.ok) throw new Error(`PUT failed: ${res.status}`);
818
+ return res.json();
819
+ } else {
820
+ const res = await fetch('/api/editor-drafts', {
821
+ method: 'POST', credentials: 'same-origin',
822
+ headers: { 'Content-Type': 'application/json' },
823
+ body: JSON.stringify(body),
824
+ });
825
+ if (!res.ok) throw new Error(`POST failed: ${res.status}`);
826
+ const out = await res.json();
827
+ if (out && out.id) state.draftId = out.id;
828
+ return out;
829
+ }
830
+ };
831
+ state.persistInFlight = doRequest()
832
+ .catch((e) => { console.warn('[ge] draft save failed', e); })
833
+ .then(() => {
834
+ state.persistInFlight = null;
835
+ if (state.persistDirty) {
836
+ state.persistDirty = false;
837
+ _schedulePersist();
838
+ }
839
+ });
840
+ return state.persistInFlight;
841
+ }
842
+
843
+ async function _loadDraftById(draftId) {
844
+ if (!draftId) return null;
845
+ try {
846
+ const res = await fetch(`/api/editor-drafts/${encodeURIComponent(draftId)}`, {
847
+ credentials: 'same-origin',
848
+ });
849
+ if (!res.ok) return null;
850
+ const out = await res.json();
851
+ if (!out || !out.payload || !Array.isArray(out.payload.layers)) return null;
852
+ return out;
853
+ } catch (_) {
854
+ return null;
855
+ }
856
+ }
857
+
858
+ async function _findDraftForImage(imageId) {
859
+ if (!imageId) return null;
860
+ try {
861
+ const res = await fetch('/api/editor-drafts', { credentials: 'same-origin' });
862
+ if (!res.ok) return null;
863
+ const out = await res.json();
864
+ const match = (out.drafts || []).find(d => d.source_image_id === imageId);
865
+ if (!match) return null;
866
+ return _loadDraftById(match.id);
867
+ } catch (_) {
868
+ return null;
869
+ }
870
+ }
871
+
872
+ async function _clearDraftServer(draftId) {
873
+ if (!draftId) return;
874
+ try {
875
+ await fetch(`/api/editor-drafts/${encodeURIComponent(draftId)}`, {
876
+ method: 'DELETE', credentials: 'same-origin',
877
+ });
878
+ } catch (_) { /* best-effort */ }
879
+ }
880
+
881
+ // Hydrate state.layers from a previously-persisted draft. Accepts either the
882
+ // raw payload (v1 localStorage shape) or a server response with
883
+ // {payload: {...}}. Returns a promise that resolves once every layer's
884
+ // dataURL has decoded into its canvas.
885
+ function _restoreDraft(draft) {
886
+ return new Promise((resolve) => {
887
+ // Server response: {id, name, payload:{...}, ...}. Unwrap.
888
+ const data = draft.payload && draft.payload.layers ? draft.payload : draft;
889
+ state.imgWidth = data.imgWidth;
890
+ state.imgHeight = data.imgHeight;
891
+ _initCanvasFromDims(data.imgWidth, data.imgHeight);
892
+ state.layers = [];
893
+ state.layerOffsets.clear();
894
+ let pending = data.layers.length;
895
+ if (pending === 0) { resolve(); return; }
896
+ data.layers.forEach((s, idx) => {
897
+ const layer = createLayer(s.name || 'Layer', s.canvasW || state.imgWidth, s.canvasH || state.imgHeight);
898
+ layer.id = s.id;
899
+ layer.visible = s.visible !== false;
900
+ layer.opacity = typeof s.opacity === 'number' ? s.opacity : 1;
901
+ layer.locked = !!s.locked;
902
+ if (s.isBase) layer.isBase = true;
903
+ state.layers[idx] = layer;
904
+ state.layerOffsets.set(layer.id, { ...(s.offset || { x: 0, y: 0 }) });
905
+ const img = new Image();
906
+ img.onload = () => {
907
+ if (!state.editorOpen) { resolve(); return; }
908
+ layer.ctx.clearRect(0, 0, layer.canvas.width, layer.canvas.height);
909
+ layer.ctx.drawImage(img, 0, 0);
910
+ if (--pending === 0) resolve();
911
+ };
912
+ img.onerror = () => { if (--pending === 0) resolve(); };
913
+ img.src = s.dataUrl;
914
+ });
915
+ state.nextLayerId = data.nextLayerId || (state.layers.reduce((m, l) => Math.max(m, l.id || 0), 0) + 1);
916
+ state.activeLayerId = data.activeLayerId || (state.layers[state.layers.length - 1]?.id ?? null);
917
+ });
918
+ }
919
+
920
+ // Used both by the fresh openEditor path and by _restoreDraft. The full
921
+ // _initCanvas in openEditor is closure-scoped, so factored out here.
922
+ function _initCanvasFromDims(w, h) {
923
+ state.imgWidth = w;
924
+ state.imgHeight = h;
925
+ if (state.mainCanvas) {
926
+ state.mainCanvas.width = w;
927
+ state.mainCanvas.height = h;
928
+ }
929
+ state.maskCanvas = document.createElement('canvas');
930
+ state.maskCanvas.width = w;
931
+ state.maskCanvas.height = h;
932
+ state.maskCtx = state.maskCanvas.getContext('2d');
933
+ }
934
+
935
+ function _restoreState(snap) {
936
+ // Restore canvas dimensions first so layer imageData fits cleanly. This
937
+ // is what makes Ctrl+Z work for crops (which change the main canvas
938
+ // size) in addition to paint strokes.
939
+ const dimsChanged = snap.imgWidth && snap.imgHeight &&
940
+ (snap.imgWidth !== state.imgWidth || snap.imgHeight !== state.imgHeight);
941
+ if (snap.imgWidth && snap.imgHeight) {
942
+ state.imgWidth = snap.imgWidth;
943
+ state.imgHeight = snap.imgHeight;
944
+ if (state.mainCanvas) {
945
+ state.mainCanvas.width = snap.imgWidth;
946
+ state.mainCanvas.height = snap.imgHeight;
947
+ }
948
+ if (state.maskCanvas) {
949
+ state.maskCanvas.width = snap.imgWidth;
950
+ state.maskCanvas.height = snap.imgHeight;
951
+ }
952
+ }
953
+ const layerStates = snap.layers || snap;
954
+ // Rebuild the state.layers array from the snapshot order. This lets Ctrl+Z
955
+ // restore deleted layers (previously the loop only updated existing
956
+ // ones and silently dropped any layer the snapshot still knew about).
957
+ // Layers absent from the snapshot are dropped — that's the desired
958
+ // behavior for undoing an "+Add" or a paste.
959
+ const _existingById = new Map(state.layers.map(l => [l.id, l]));
960
+ const _rebuilt = [];
961
+ for (const s of layerStates) {
962
+ let layer = _existingById.get(s.id);
963
+ if (!layer) {
964
+ // Layer was deleted (or merged away). Recreate it from the
965
+ // snapshot's ImageData so Ctrl+Z brings it back.
966
+ const c = document.createElement('canvas');
967
+ c.width = s.canvasW || state.imgWidth;
968
+ c.height = s.canvasH || state.imgHeight;
969
+ layer = { id: s.id, name: s.name, canvas: c, ctx: c.getContext('2d'),
970
+ visible: true, opacity: 1, locked: false };
971
+ } else {
972
+ _existingById.delete(s.id);
973
+ }
974
+ layer.name = s.name;
975
+ layer.visible = s.visible;
976
+ layer.opacity = s.opacity;
977
+ layer.locked = s.locked;
978
+ if (s.canvasW && s.canvasH) {
979
+ layer.canvas.width = s.canvasW;
980
+ layer.canvas.height = s.canvasH;
981
+ }
982
+ try { if (s.imageData) layer.ctx.putImageData(s.imageData, 0, 0); } catch (_) {}
983
+ state.layerOffsets.set(layer.id, { ...s.offset });
984
+ // Restore adjustment sub-layers + invalidate the composite cache
985
+ // so the live render reflects the rolled-back FX state.
986
+ layer.adjLayers = s.adjLayers ? JSON.parse(JSON.stringify(s.adjLayers)) : [];
987
+ if (s.isBase !== undefined) layer.isBase = s.isBase;
988
+ // Restore mask sub-layers — rebuild each mask's canvas from the
989
+ // snapshot's imageData. We don't reuse old mask canvases (snapshot
990
+ // dims might differ after a transform) so a fresh canvas is safer.
991
+ layer.masks = (s.masks || []).map(ms => {
992
+ const mc = document.createElement('canvas');
993
+ mc.width = ms.canvasW || state.imgWidth;
994
+ mc.height = ms.canvasH || state.imgHeight;
995
+ const mctx = mc.getContext('2d');
996
+ try { if (ms.imageData) mctx.putImageData(ms.imageData, 0, 0); } catch {}
997
+ return { id: ms.id, name: ms.name, canvas: mc, ctx: mctx, visible: ms.visible !== false };
998
+ });
999
+ layer.activeMaskId = s.activeMaskId || (layer.masks[0]?.id ?? null);
1000
+ layer._adjFinal = null;
1001
+ layer._adjFinalKey = null;
1002
+ layer._stagedAdj = null;
1003
+ layer._editingAdjId = null;
1004
+ _rebuilt.push(layer);
1005
+ }
1006
+ // Drop any layer that's no longer in the snapshot.
1007
+ for (const lost of _existingById.values()) state.layerOffsets.delete(lost.id);
1008
+ state.layers = _rebuilt;
1009
+ if (!state.layers.find(l => l.id === state.activeLayerId) && state.layers.length) {
1010
+ state.activeLayerId = state.layers[state.layers.length - 1].id;
1011
+ }
1012
+ // Repoint the global mask plumbing at the active parent's active
1013
+ // mask sub-layer (if any) — undo can swap the actual canvas object.
1014
+ {
1015
+ const m = _getActiveMaskLayer();
1016
+ if (m) { state.maskCanvas = m.canvas; state.maskCtx = m.ctx; }
1017
+ }
1018
+ // Restore wand selection (or clear it if the snapshot had none).
1019
+ if (snap.wand && snap.wand.imageData) {
1020
+ const mc = document.createElement('canvas');
1021
+ mc.width = snap.wand.w;
1022
+ mc.height = snap.wand.h;
1023
+ mc.getContext('2d').putImageData(snap.wand.imageData, 0, 0);
1024
+ state.wandMask = mc;
1025
+ state.wandLayerId = snap.wand.layerId;
1026
+ state.wandLastSeed = snap.wand.seed ? { ...snap.wand.seed } : null;
1027
+ } else {
1028
+ state.wandMask = null;
1029
+ state.wandLayerId = null;
1030
+ state.wandLastSeed = null;
1031
+ }
1032
+ composite();
1033
+ _renderLayerPanel();
1034
+ _syncToolClearIndicators();
1035
+ // Refit the viewport when canvas size changed (crop undo/redo) so the
1036
+ // user sees the full restored image, not the zoomed-in upper-left
1037
+ // corner left over from the previous fit.
1038
+ if (dimsChanged) _fitZoom();
1039
+ // Update the topbar canvas-size badge directly (the helper is scoped
1040
+ // inside _buildEditor, so we touch the DOM here).
1041
+ const sizeLabel = document.getElementById('ge-canvas-size');
1042
+ if (sizeLabel) sizeLabel.textContent = `${state.imgWidth}×${state.imgHeight}`;
1043
+ }
1044
+
1045
+ function undo() {
1046
+ if (state.undoStack.length === 0) return;
1047
+ const cur = _snapshotState();
1048
+ cur._label = 'Current';
1049
+ cur._ts = Date.now();
1050
+ state.redoStack.push(cur);
1051
+ _restoreState(state.undoStack.pop());
1052
+ _refreshHistoryPanelIfOpen();
1053
+ }
1054
+
1055
+ function redo() {
1056
+ if (state.redoStack.length === 0) return;
1057
+ const cur = _snapshotState();
1058
+ cur._label = 'Current';
1059
+ cur._ts = Date.now();
1060
+ state.undoStack.push(cur);
1061
+ _restoreState(state.redoStack.pop());
1062
+ _refreshHistoryPanelIfOpen();
1063
+ }
1064
+
1065
+ // Jump to any state in the labeled history. Negative offsets go back
1066
+ // (into state.undoStack), positive go forward (into state.redoStack). 0 = current.
1067
+ // Used by the history panel.
1068
+ // History panel — full implementation in editor/history-panel.js.
1069
+ // Wrappers preserve the legacy names that the topbar History button
1070
+ // + undo/redo paths already reference.
1071
+ const _historyPanel = createHistoryPanel({ undo, redo });
1072
+ const _jumpToHistory = _historyPanel.jumpToHistory;
1073
+ const _toggleHistoryPanel = _historyPanel.toggleHistoryPanel;
1074
+ const _refreshHistoryPanelIfOpen = _historyPanel.refreshHistoryPanelIfOpen;
1075
+
1076
+ // _relTime lives in editor/layer-helpers.js.
1077
+
1078
+ // ── Canvas event helpers ──
1079
+
1080
+ // _canvasCoords lives in editor/canvas-coords.js — see import at top.
1081
+
1082
+ // ── Drawing ──
1083
+
1084
+ function _beginDraw(e) {
1085
+ // Fall back to the parent resolver so a stale activeLayerId doesn't
1086
+ // block strokes when there ARE layers present.
1087
+ const layer = activeLayer() || _activeParentLayer();
1088
+ // Transform-tool drag (handle grab or move-fallback) — handler in
1089
+ // editor/tools/transform-drag.js.
1090
+ if (_transformDragTool.tryBegin(e)) return;
1091
+ // Magic wand is selection-only — works even on locked layers because
1092
+ // it doesn't mutate the layer until an action (Erase/Copy) is taken.
1093
+ // Full implementation in editor/tools/wand.js.
1094
+ if (state.tool === 'wand') return _wandTool.click(e);
1095
+ // Inpaint can create its own layer + mask on the fly, so skip the
1096
+ // "no active layer → bail" gate for it specifically.
1097
+ if (state.tool !== 'inpaint' && (!layer || layer.locked)) return;
1098
+ // Keep activeLayerId in sync so downstream lookups resolve.
1099
+ if (layer && state.activeLayerId !== layer.id) state.activeLayerId = layer.id;
1100
+ if (state.tool === 'move') return _beginMove(e);
1101
+ if (state.tool === 'crop') return _beginCrop(e);
1102
+ if (state.tool === 'lasso') return _beginLasso(e);
1103
+ // Clone-stamp — source pick + stroke start. Full implementation in
1104
+ // editor/tools/clone.js; per-sample stamping continues through the
1105
+ // shared `_strokeTo` pipeline below.
1106
+ if (state.tool === 'clone') return _cloneTool.begin(e);
1107
+ // Brush / Eraser / Inpaint share a stroke pipeline — handler in
1108
+ // editor/tools/stroke.js. Returns true for those tools, false
1109
+ // otherwise (any other tool that reached here is a no-op).
1110
+ _strokeTool.tryBegin(e);
1111
+ }
1112
+
1113
+ function _continueDraw(e) {
1114
+ // _continueDraw is now bound to the window so drags can extend past
1115
+ // the canvas. The brush-cursor overlay should only follow the cursor
1116
+ // when it's actually over the canvas, otherwise hide it.
1117
+ const overCanvas = state.mainCanvas && e.target === state.mainCanvas;
1118
+ if (['eraser', 'inpaint', 'lasso', 'brush'].includes(state.tool) && state.mainCanvas) {
1119
+ if (overCanvas) _updateBrushCursor(e);
1120
+ else if (state.cursorEl) state.cursorEl.style.display = 'none';
1121
+ }
1122
+ // Transform-tool hover-cursor + handle drag — handler in
1123
+ // editor/tools/transform-drag.js. Returns true when the drag is
1124
+ // consuming the event (rotation / resize); the hover-cursor pass
1125
+ // returns false so the dispatcher can still fall through to other
1126
+ // tools that share the canvas hover (none currently, but kept for
1127
+ // future-proofing).
1128
+ if (_transformDragTool.tryContinue(e)) return;
1129
+ if (state.lassoActive) return _continueLasso(e);
1130
+ if (!state.drawing) {
1131
+ if (state.moving) return _continueMove(e);
1132
+ if (state.cropping || state.cropMoving) return _continueCrop(e);
1133
+ return;
1134
+ }
1135
+ // In-progress stroke (brush / eraser / inpaint / clone) — handler in
1136
+ // editor/tools/stroke.js.
1137
+ _strokeTool.tryContinue(e);
1138
+ }
1139
+
1140
+ function _endDraw() {
1141
+ // Transform-tool drag end — handler in editor/tools/transform-drag.js.
1142
+ if (_transformDragTool.tryEnd()) return;
1143
+ if (state.lassoActive) return _endLasso();
1144
+ if (state.moving) return _endMove();
1145
+ if (state.cropping || state.cropMoving) return _endCrop();
1146
+ // Stroke end (brush / eraser / inpaint / clone) — handler in
1147
+ // editor/tools/stroke.js.
1148
+ _strokeTool.tryEnd();
1149
+ }
1150
+
1151
+ // Floating popup that appears after an inpaint stroke so the user can
1152
+ // type a prompt and Generate without diverting to the side panel. The
1153
+ // popup re-uses the existing #ge-inpaint-prompt and #ge-inpaint-run
1154
+ // elements by reparenting them into a positioned wrapper, so all the
1155
+ // existing handlers (Enter to submit, generate-button click) still fire.
1156
+ // Inpaint-stroke prompt popup feature was removed — the user types in
1157
+ // the side panel and hits Generate there. Helpers _showInpaintPrompt /
1158
+ // _dismissInpaintPrompt and their dismiss-handlers were dead code and
1159
+ // have been deleted.
1160
+
1161
+ // Clone Stamp painter — stamps circular samples from the source
1162
+ // snapshot at every interpolated point between the last brush position
1163
+ // and the current one, so a drag produces a continuous clone. The
1164
+ // sample offset is fixed at stroke-start (Photoshop "aligned" mode):
1165
+ // `sample = source + (cursor − strokeStart)`.
1166
+ // Stroke pipeline — paints one segment last→current onto the active
1167
+ // layer (or active mask sub-layer). Full implementation in
1168
+ // editor/stroke-pipeline.js.
1169
+ const _strokePipeline = createStrokePipeline({
1170
+ // Use the fallback-capable parent resolver so the stroke pipeline
1171
+ // and _getActiveMaskLayer() agree on which layer is active. Plain
1172
+ // activeLayer() returns null when activeLayerId is stale, which made
1173
+ // strokeTo bail even though a mask had been created on the fallback
1174
+ // parent — the "inpaint draws nothing" bug.
1175
+ activeLayer: () => activeLayer() || _activeParentLayer(),
1176
+ getActiveMaskLayer: () => _getActiveMaskLayer(),
1177
+ composite,
1178
+ });
1179
+ const _strokeTo = _strokePipeline.strokeTo;
1180
+ const _cloneStrokeTo = _strokePipeline.cloneStrokeTo;
1181
+
1182
+ // ── Brush cursor overlay ──
1183
+
1184
+
1185
+ function _updateBrushCursor(e) {
1186
+ if (!state.mainCanvas) return;
1187
+ const rect = state.mainCanvas.getBoundingClientRect();
1188
+ const clientX = e.touches ? e.touches[0].clientX : e.clientX;
1189
+ const clientY = e.touches ? e.touches[0].clientY : e.clientY;
1190
+
1191
+ if (!state.cursorEl) {
1192
+ state.cursorEl = document.createElement('div');
1193
+ state.cursorEl.className = 'ge-brush-cursor';
1194
+ document.body.appendChild(state.cursorEl);
1195
+ }
1196
+
1197
+ // Lasso uses the feather radius (or a sensible default) so the circle
1198
+ // shows the area that will be selected. Other tools use the brush size.
1199
+ let basePx;
1200
+ if (state.tool === 'lasso') {
1201
+ const f = parseInt(document.getElementById('ge-lasso-feather')?.value || '0');
1202
+ basePx = Math.max(10, f * 2);
1203
+ } else {
1204
+ basePx = state.brushSize;
1205
+ }
1206
+ const diameter = basePx * state.zoom;
1207
+ state.cursorEl.style.width = diameter + 'px';
1208
+ state.cursorEl.style.height = diameter + 'px';
1209
+ state.cursorEl.style.left = (clientX - diameter / 2) + 'px';
1210
+ state.cursorEl.style.top = (clientY - diameter / 2) + 'px';
1211
+ state.cursorEl.style.display = '';
1212
+ if (state.tool === 'inpaint') {
1213
+ // Visual cue for paint vs erase mode. Ctrl+Alt held mid-hover also
1214
+ // flips the cursor so the user sees the effective mode before they
1215
+ // click. Red = paint mask, white-dashed = erase mask.
1216
+ const flip = e && e.ctrlKey && e.altKey;
1217
+ const eraseEffective = flip ? !state.inpaintEraseMode : state.inpaintEraseMode;
1218
+ if (eraseEffective) {
1219
+ state.cursorEl.style.borderColor = 'rgba(255,255,255,0.9)';
1220
+ state.cursorEl.style.background = 'rgba(255,255,255,0.10)';
1221
+ state.cursorEl.style.borderStyle = 'dashed';
1222
+ } else {
1223
+ state.cursorEl.style.borderColor = 'rgba(255,80,80,0.8)';
1224
+ state.cursorEl.style.background = 'rgba(255,50,50,0.25)';
1225
+ state.cursorEl.style.borderStyle = 'solid';
1226
+ }
1227
+ } else if (state.tool === 'lasso') {
1228
+ state.cursorEl.style.borderColor = 'rgba(255,255,255,0.85)';
1229
+ state.cursorEl.style.background = 'rgba(0,0,0,0.15)';
1230
+ state.cursorEl.style.borderStyle = 'solid';
1231
+ } else {
1232
+ state.cursorEl.style.borderColor = state.tool === 'eraser' ? 'rgba(255,255,255,0.6)' : state.color;
1233
+ state.cursorEl.style.background = 'transparent';
1234
+ state.cursorEl.style.borderStyle = 'solid';
1235
+ }
1236
+ }
1237
+
1238
+ // ── Move tool ──
1239
+
1240
+ // Move tool — full implementation lives in editor/tools/move.js. Wrap
1241
+ // `_beginMove` / `_continueMove` / `_endMove` to the factory output so
1242
+ // the existing dispatcher (_beginDraw / _continueDraw / _endDraw) keeps
1243
+ // working without changes.
1244
+ const _moveTool = createMoveTool({ activeLayer, saveState: _saveState, composite });
1245
+ const _beginMove = _moveTool.begin;
1246
+ const _continueMove = _moveTool.drag;
1247
+ const _endMove = _moveTool.end;
1248
+
1249
+ // ── Crop tool ──
1250
+
1251
+ // Crop tool — full implementation in editor/tools/crop.js. Wire
1252
+ // `_beginCrop` / `_continueCrop` / `_endCrop` to the factory output so
1253
+ // the existing dispatcher keeps working without changes.
1254
+ const _cropTool = createCropTool({ composite, showCropApply: () => _showCropApply() });
1255
+ const _beginCrop = _cropTool.begin;
1256
+ const _continueCrop = _cropTool.drag;
1257
+ const _endCrop = _cropTool.end;
1258
+
1259
+ function _showCropApply() {
1260
+ let pop = state.container.querySelector('.ge-crop-apply');
1261
+ if (pop) pop.remove();
1262
+ // A small floating panel: W × H inputs and the Apply button.
1263
+ pop = document.createElement('div');
1264
+ pop.className = 'ge-crop-apply';
1265
+ pop.innerHTML = `
1266
+ <input type="number" class="ge-crop-w" min="1" max="20000" value="${Math.round(state.cropRect.w)}" title="Width">
1267
+ <span class="ge-crop-x">×</span>
1268
+ <input type="number" class="ge-crop-h" min="1" max="20000" value="${Math.round(state.cropRect.h)}" title="Height">
1269
+ <button class="ge-crop-apply-btn">Apply</button>
1270
+ `;
1271
+ const area = state.container.querySelector('.ge-canvas-area');
1272
+ if (!area || !state.cropRect || !state.mainCanvas) return;
1273
+ area.appendChild(pop);
1274
+
1275
+ pop.querySelector('.ge-crop-apply-btn').addEventListener('click', () => _applyCrop());
1276
+ // Editing W/H updates the crop rect anchored at its top-left so the
1277
+ // user sees the dimensions live in the overlay.
1278
+ const wInput = pop.querySelector('.ge-crop-w');
1279
+ const hInput = pop.querySelector('.ge-crop-h');
1280
+ const onSize = () => {
1281
+ if (!state.cropRect) return;
1282
+ const w = Math.max(1, parseInt(wInput.value, 10) || state.cropRect.w);
1283
+ const h = Math.max(1, parseInt(hInput.value, 10) || state.cropRect.h);
1284
+ state.cropRect = { ...state.cropRect, w, h };
1285
+ composite();
1286
+ };
1287
+ wInput.addEventListener('input', onSize);
1288
+ hInput.addEventListener('input', onSize);
1289
+ // Enter in either field triggers apply.
1290
+ [wInput, hInput].forEach(inp => {
1291
+ inp.addEventListener('keydown', (e) => { if (e.key === 'Enter') _applyCrop(); });
1292
+ });
1293
+
1294
+ // Position the panel just outside the bottom-right corner of the
1295
+ // crop rectangle (where the user finished dragging), in area coords.
1296
+ const canvasRect = state.mainCanvas.getBoundingClientRect();
1297
+ const areaRect = area.getBoundingClientRect();
1298
+ const scaleX = canvasRect.width / state.mainCanvas.width;
1299
+ const scaleY = canvasRect.height / state.mainCanvas.height;
1300
+ const localX = (canvasRect.left - areaRect.left) + (state.cropRect.x + state.cropRect.w) * scaleX;
1301
+ const localY = (canvasRect.top - areaRect.top) + (state.cropRect.y + state.cropRect.h) * scaleY;
1302
+ pop.style.position = 'absolute';
1303
+ pop.style.left = (localX + 6) + 'px';
1304
+ pop.style.top = (localY + 6) + 'px';
1305
+ // Clamp inside the CANVAS image bounds (not just the canvas-area) so
1306
+ // the panel doesn't sit on the dark padding around the canvas — it
1307
+ // stays anchored over the actual image.
1308
+ requestAnimationFrame(() => {
1309
+ const bRect = pop.getBoundingClientRect();
1310
+ const canvasLeft = canvasRect.left - areaRect.left;
1311
+ const canvasTop = canvasRect.top - areaRect.top;
1312
+ const canvasRight = canvasLeft + canvasRect.width;
1313
+ const canvasBottom = canvasTop + canvasRect.height;
1314
+ let nx = parseFloat(pop.style.left) || 0;
1315
+ let ny = parseFloat(pop.style.top) || 0;
1316
+ if (nx + bRect.width > canvasRight - 4) nx = canvasRight - bRect.width - 4;
1317
+ if (ny + bRect.height > canvasBottom - 4) ny = canvasBottom - bRect.height - 4;
1318
+ nx = Math.max(canvasLeft + 4, nx);
1319
+ ny = Math.max(canvasTop + 4, ny);
1320
+ pop.style.left = nx + 'px';
1321
+ pop.style.top = ny + 'px';
1322
+ });
1323
+ }
1324
+
1325
+ function _applyCrop() {
1326
+ if (!state.cropRect) return;
1327
+ _saveState('Crop');
1328
+ const { x, y, w, h } = state.cropRect;
1329
+ const cw = Math.round(w);
1330
+ const ch = Math.round(h);
1331
+ for (const layer of state.layers) {
1332
+ const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
1333
+ const data = layer.ctx.getImageData(x - off.x, y - off.y, cw, ch);
1334
+ layer.canvas.width = cw;
1335
+ layer.canvas.height = ch;
1336
+ layer.ctx.putImageData(data, 0, 0);
1337
+ state.layerOffsets.set(layer.id, { x: 0, y: 0 });
1338
+ }
1339
+ state.mainCanvas.width = cw;
1340
+ state.mainCanvas.height = ch;
1341
+ state.imgWidth = cw;
1342
+ state.imgHeight = ch;
1343
+ if (state.maskCanvas) { state.maskCanvas.width = cw; state.maskCanvas.height = ch; }
1344
+ state.cropRect = null;
1345
+ const btn = state.container.querySelector('.ge-crop-apply');
1346
+ if (btn) btn.remove();
1347
+ composite();
1348
+ _fitZoom();
1349
+ }
1350
+
1351
+ // Text tool was removed from the toolbar; the _placeText implementation
1352
+ // (and the `state.tool === 'text'` dispatcher branch) used to live here
1353
+ // but had no remaining call sites and was dead code.
1354
+
1355
+ // ── Free Transform (Ctrl+Alt+T) ──
1356
+
1357
+ // Transform session — full implementation in
1358
+ // editor/tools/transform-session.js. Wrappers preserve the legacy
1359
+ // names that the dispatcher / toolbar / shortcuts already reference.
1360
+ const _transformSession = createTransformSession({
1361
+ activeLayer,
1362
+ saveState: _saveState,
1363
+ composite,
1364
+ fitZoom: () => _fitZoom(),
1365
+ drawTransformHandles: () => _drawTransformHandles(),
1366
+ showCanvasLoading: (label) => _showCanvasLoading(label),
1367
+ hideCanvasLoading: () => _hideCanvasLoading(),
1368
+ undo,
1369
+ uiModule,
1370
+ });
1371
+ const _startTransform = _transformSession.startTransform;
1372
+ const _openTransformPopup = _transformSession.openTransformPopup;
1373
+ const _closeTransformPopup = _transformSession.closeTransformPopup;
1374
+ const _reapplyTransform = _transformSession.reapplyTransform;
1375
+ const _confirmTransform = _transformSession.confirmTransform;
1376
+ const _cancelTransform = _transformSession.cancelTransform;
1377
+
1378
+ // ── Lasso tool ──
1379
+
1380
+ // Lasso tool — full implementation in editor/tools/lasso.js.
1381
+ const _lassoTool = createLassoTool({
1382
+ composite,
1383
+ drawLassoOverlay: () => _drawLassoOverlay(),
1384
+ syncToolClearIndicators: () => _syncToolClearIndicators(),
1385
+ });
1386
+ const _beginLasso = _lassoTool.begin;
1387
+ const _continueLasso = _lassoTool.drag;
1388
+ const _endLasso = _lassoTool.end;
1389
+
1390
+ // Magic wand — selection-only click handler in editor/tools/wand.js.
1391
+ const _wandTool = createWandTool({
1392
+ activeLayer,
1393
+ saveState: _saveState,
1394
+ composite,
1395
+ wandHits: (x, y) => _wandHits(x, y),
1396
+ runMagicWand: (x, y, mode) => _runMagicWand(x, y, mode),
1397
+ });
1398
+
1399
+ // Clone-stamp tool — source-pick + stroke-start handler in
1400
+ // editor/tools/clone.js. Per-sample stamping still runs through the
1401
+ // shared stroke pipeline (`_strokeTo`) since clone-mode is detected
1402
+ // there from state.cloneSourceSnapshot.
1403
+ const _cloneTool = createCloneTool({
1404
+ activeLayer,
1405
+ saveState: _saveState,
1406
+ strokeTo: (x, y) => _strokeTo(x, y),
1407
+ showToast: (msg) => { if (uiModule) uiModule.showToast(msg); },
1408
+ });
1409
+
1410
+ // Transform-tool drag interactions (handle picking, rotation, resize)
1411
+ // in editor/tools/transform-drag.js. The dispatcher calls
1412
+ // `tryBegin/tryContinue/tryEnd` and short-circuits when they return true.
1413
+ const _transformDragTool = createTransformDragTool({
1414
+ beginMove: (e) => _beginMove(e),
1415
+ composite,
1416
+ drawTransformHandles: () => _drawTransformHandles(),
1417
+ reapplyTransform: () => _reapplyTransform(),
1418
+ getTransformHandle: (x, y) => _getTransformHandle(x, y),
1419
+ cursorForHandle: _cursorForHandle,
1420
+ });
1421
+
1422
+ // Shared stroke pipeline (brush / eraser / inpaint) in
1423
+ // editor/tools/stroke.js. Clone reuses tryContinue / tryEnd via the
1424
+ // shared drawing flag; clone's own begin is in editor/tools/clone.js.
1425
+ const _strokeTool = createStrokeTool({
1426
+ saveState: _saveState,
1427
+ strokeTo: (x, y) => _strokeTo(x, y),
1428
+ composite,
1429
+ getActiveMaskLayer: () => _getActiveMaskLayer(),
1430
+ activeParentLayer: () => _activeParentLayer(),
1431
+ ensureActiveMaskLayer: () => _ensureActiveMaskLayer(),
1432
+ createLayer,
1433
+ renderLayerPanel: () => _renderLayerPanel(),
1434
+ syncToolClearIndicators: () => _syncToolClearIndicators(),
1435
+ });
1436
+
1437
+ // Compute the outward-normal offset of the lasso polygon by `grow`
1438
+ // pixels at each vertex. Lets the Edge stroke slider visually move
1439
+ // the dashed outline in/out without re-running the mask raster.
1440
+ // Thin wrapper around the pure helper in editor/tools/lasso-mask.js
1441
+ // so existing callers using module state stay unchanged.
1442
+ function _lassoOffsetPoints(grow) {
1443
+ return _lassoOffsetPointsImpl(state.lassoPoints, grow);
1444
+ }
1445
+
1446
+ function _drawLassoOverlay() {
1447
+ if (state.lassoPoints.length < 3) return;
1448
+ // Read live slider values so the overlay shows the actual edge that
1449
+ // will be committed: Edge stroke shifts the polygon outline in/out;
1450
+ // Feather draws a soft red halo to suggest the alpha fade.
1451
+ const featherEl = document.getElementById('ge-lasso-feather');
1452
+ const growEl = document.getElementById('ge-lasso-grow');
1453
+ const feather = featherEl ? parseInt(featherEl.value || '0', 10) : 0;
1454
+ const grow = growEl ? parseInt(growEl.value || '0', 10) : 0;
1455
+ const ringPts = grow ? _lassoOffsetPoints(grow) : state.lassoPoints;
1456
+ const tracePath = (pts) => {
1457
+ state.mainCtx.beginPath();
1458
+ state.mainCtx.moveTo(pts[0].x, pts[0].y);
1459
+ for (let i = 1; i < pts.length; i++) state.mainCtx.lineTo(pts[i].x, pts[i].y);
1460
+ state.mainCtx.closePath();
1461
+ };
1462
+ if (feather > 0) {
1463
+ // Concentric outer outlines that fade out, suggesting the feather
1464
+ // fade band that will be applied to the mask alpha at commit.
1465
+ const rings = 4;
1466
+ for (let r = 1; r <= rings; r++) {
1467
+ const offset = (feather * r) / rings;
1468
+ tracePath(_lassoOffsetPoints(grow + offset));
1469
+ state.mainCtx.strokeStyle = `rgba(255, 80, 80, ${0.4 * (1 - r / rings)})`;
1470
+ state.mainCtx.lineWidth = 1 / state.zoom;
1471
+ state.mainCtx.setLineDash([]);
1472
+ state.mainCtx.stroke();
1473
+ }
1474
+ }
1475
+ tracePath(ringPts);
1476
+ state.mainCtx.strokeStyle = '#fff';
1477
+ state.mainCtx.lineWidth = 1 / state.zoom;
1478
+ state.mainCtx.setLineDash([4 / state.zoom, 4 / state.zoom]);
1479
+ state.mainCtx.stroke();
1480
+ state.mainCtx.setLineDash([]);
1481
+ state.mainCtx.fillStyle = 'rgba(255, 80, 80, 0.1)';
1482
+ state.mainCtx.fill();
1483
+ }
1484
+
1485
+ function _getLassoPath(ctx) {
1486
+ _getLassoPathImpl(ctx, state.lassoPoints);
1487
+ }
1488
+
1489
+ /**
1490
+ * Build a feathered selection mask from the current lasso polygon.
1491
+ * Implementation lives in editor/tools/lasso-mask.js — this wrapper
1492
+ * forwards the current `state.lassoPoints` so existing callers keep
1493
+ * working unchanged.
1494
+ */
1495
+ function _buildLassoMask(w, h, offX, offY, feather, grow) {
1496
+ return _buildLassoMaskImpl(state.lassoPoints, w, h, offX, offY, feather, grow);
1497
+ }
1498
+
1499
+ // ── Magic Wand ──
1500
+
1501
+ // Click-fill from (cx, cy) on the active layer. Builds a binary mask of
1502
+ // all pixels reachable from the seed whose RGB distance is within
1503
+ // state.wandTolerance × 4.42 (4.42 ≈ scale factor so tolerance=100 ≈ max).
1504
+ //
1505
+ // `mode`:
1506
+ // 'replace' (default) — replaces any previous selection
1507
+ // 'add' — unions the new region with the existing selection
1508
+ // 'subtract' — removes the new region from the existing selection
1509
+ // Cached layer pixel data + dimensions for the wand. `getImageData` is
1510
+ // the dominant cost when live-retuning tolerance (millions of pixels →
1511
+ // 50–200 ms per call on a 4K canvas). Invalidated by _invalidateWandCache
1512
+ // whenever the active layer changes or the editor closes.
1513
+ // Pristine snapshot of the last Bg-Removed cutout so the Edge cleanup
1514
+ // sliders can live-rebuild the alpha without re-running the model.
1515
+ function _invalidateWandCache() { state.wandSrcCache = null; }
1516
+ function _getWandSource(layer) {
1517
+ if (state.wandSrcCache && state.wandSrcCache.layerId === layer.id
1518
+ && state.wandSrcCache.w === layer.canvas.width
1519
+ && state.wandSrcCache.h === layer.canvas.height) {
1520
+ return state.wandSrcCache;
1521
+ }
1522
+ const w = layer.canvas.width, h = layer.canvas.height;
1523
+ state.wandSrcCache = {
1524
+ layerId: layer.id, w, h,
1525
+ data: layer.ctx.getImageData(0, 0, w, h).data,
1526
+ };
1527
+ return state.wandSrcCache;
1528
+ }
1529
+
1530
+ // Click-deselect helper: returns true if (cx, cy) lands inside the
1531
+ // existing wand selection on the same layer. Used by the mousedown
1532
+ // handler to make a second click "in the selection" toggle it off.
1533
+ function _wandHits(cx, cy) {
1534
+ if (!state.wandMask || !state.wandLayerId) return false;
1535
+ const layer = state.layers.find(l => l.id === state.wandLayerId);
1536
+ if (!layer) return false;
1537
+ const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
1538
+ const lx = Math.floor(cx - off.x);
1539
+ const ly = Math.floor(cy - off.y);
1540
+ if (lx < 0 || ly < 0 || lx >= state.wandMask.width || ly >= state.wandMask.height) return false;
1541
+ try {
1542
+ const px = state.wandMask.getContext('2d').getImageData(lx, ly, 1, 1).data;
1543
+ return px[3] > 128;
1544
+ } catch { return false; }
1545
+ }
1546
+
1547
+ function _runMagicWand(cx, cy, mode = 'replace', opts = {}) {
1548
+ if (!opts.retune && !opts.deferred) {
1549
+ const cleanup = _showWandLoading();
1550
+ requestAnimationFrame(() => {
1551
+ requestAnimationFrame(() => {
1552
+ try {
1553
+ _runMagicWand(cx, cy, mode, { ...opts, deferred: true });
1554
+ } finally {
1555
+ cleanup();
1556
+ }
1557
+ });
1558
+ });
1559
+ return;
1560
+ }
1561
+ const layer = activeLayer();
1562
+ if (!layer || layer.locked) return;
1563
+ // If an active mask sub-layer is selected, the wand operates on the
1564
+ // MASK pixels rather than the parent layer's pixels — lets the user
1565
+ // click inside / outside an existing mask to select that region for
1566
+ // further editing. Mask canvases are doc-sized with no per-layer
1567
+ // offset, so the seed coords are used as-is.
1568
+ const activeMask = _getActiveMaskLayer();
1569
+ const sourceCanvas = activeMask ? activeMask.canvas : layer.canvas;
1570
+ const sourceCtx = activeMask ? activeMask.ctx : layer.ctx;
1571
+ // Snapshot current state to undo BEFORE mutating the selection, but
1572
+ // skip when called via the tolerance slider (`opts.retune`) so dragging
1573
+ // the slider doesn't fill the undo stack with intermediate states.
1574
+ if (!opts.retune) _saveState();
1575
+ // Remember the seed so the tolerance slider can re-run the wand live.
1576
+ state.wandLastSeed = { x: cx, y: cy, mode };
1577
+ const off = activeMask ? { x: 0, y: 0 } : (state.layerOffsets.get(layer.id) || { x: 0, y: 0 });
1578
+ const lx = Math.floor(cx - off.x);
1579
+ const ly = Math.floor(cy - off.y);
1580
+ const w = sourceCanvas.width, h = sourceCanvas.height;
1581
+ if (lx < 0 || ly < 0 || lx >= w || ly >= h) return;
1582
+ // Read pixels from the chosen source. Bypass the cache when sourcing
1583
+ // from a mask — masks change frequently and the cache is keyed by
1584
+ // parent layer id, not by mask id.
1585
+ const src = activeMask
1586
+ ? sourceCtx.getImageData(0, 0, w, h).data
1587
+ : _getWandSource(layer).data;
1588
+ // Pixel-level flood fill lives in editor/tools/flood-fill.js.
1589
+ // Returns a mask canvas at (w × h) with white where the fill landed.
1590
+ const mask = _floodFillMask(src, w, h, lx, ly, state.wandTolerance);
1591
+ if (!mask) return;
1592
+ // Merge with existing selection per `mode`. If the existing mask is
1593
+ // for a different layer or has different dimensions, treat as replace
1594
+ // since merging doesn't make sense across canvases.
1595
+ const compatible = state.wandMask && state.wandLayerId === layer.id &&
1596
+ state.wandMask.width === mask.width && state.wandMask.height === mask.height;
1597
+ if (compatible && mode === 'add') {
1598
+ // Union: paint new selection on top of the existing one.
1599
+ state.wandMask.getContext('2d').drawImage(mask, 0, 0);
1600
+ } else if (compatible && mode === 'subtract') {
1601
+ // Difference: erase new selection from the existing one.
1602
+ const ec = state.wandMask.getContext('2d');
1603
+ ec.save();
1604
+ ec.globalCompositeOperation = 'destination-out';
1605
+ ec.drawImage(mask, 0, 0);
1606
+ ec.restore();
1607
+ } else {
1608
+ state.wandMask = mask;
1609
+ state.wandLayerId = layer.id;
1610
+ }
1611
+ composite();
1612
+ _syncToolClearIndicators();
1613
+ }
1614
+
1615
+ function _showWandLoading() {
1616
+ const area = state.container?.querySelector('.ge-canvas-area');
1617
+ if (!area) return () => {};
1618
+ const overlay = document.createElement('div');
1619
+ overlay.className = 'ge-wand-loading';
1620
+ let spinner = null;
1621
+ try {
1622
+ spinner = spinnerModule.createWhirlpool(30);
1623
+ spinner.element.style.cssText = 'width:30px;height:30px;margin:0;';
1624
+ overlay.appendChild(spinner.element);
1625
+ } catch (_) {
1626
+ overlay.textContent = 'Selecting...';
1627
+ }
1628
+ area.appendChild(overlay);
1629
+ return () => {
1630
+ try { spinner?.destroy?.(); } catch {}
1631
+ overlay.remove();
1632
+ };
1633
+ }
1634
+
1635
+ // Draw the wand selection as a translucent red overlay, mirroring the
1636
+ // inpaint-mask visual so users know what's selected.
1637
+ function _drawWandOverlay() {
1638
+ if (!state.wandMask || !state.mainCtx) return;
1639
+ const layer = state.layers.find(l => l.id === state.wandLayerId);
1640
+ if (!layer) return;
1641
+ const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
1642
+ // Tint the white mask red, draw at the layer's offset on the main canvas.
1643
+ const tint = document.createElement('canvas');
1644
+ tint.width = state.wandMask.width;
1645
+ tint.height = state.wandMask.height;
1646
+ const tc = tint.getContext('2d');
1647
+ tc.drawImage(state.wandMask, 0, 0);
1648
+ tc.globalCompositeOperation = 'source-in';
1649
+ tc.fillStyle = 'rgba(255, 60, 60, 1)';
1650
+ tc.fillRect(0, 0, tint.width, tint.height);
1651
+ state.mainCtx.save();
1652
+ state.mainCtx.globalAlpha = 0.4;
1653
+ state.mainCtx.drawImage(tint, off.x, off.y);
1654
+ state.mainCtx.globalAlpha = 1;
1655
+ state.mainCtx.restore();
1656
+ }
1657
+
1658
+ function _wandClear() {
1659
+ state.wandMask = null;
1660
+ state.wandLayerId = null;
1661
+ state.wandLastSeed = null;
1662
+ _invalidateWandCache();
1663
+ composite();
1664
+ _syncToolClearIndicators();
1665
+ }
1666
+
1667
+ // Hover thumbnail — generated by downscaling the layer's canvas into a
1668
+ // small floating panel. Lives in document.body so panel `overflow:
1669
+ // hidden` can't clip it. One singleton element, repositioned per hover.
1670
+ function _showLayerThumb(rowEl, layer) {
1671
+ if (!layer || !layer.canvas) return;
1672
+ if (!state.layerThumbEl) {
1673
+ state.layerThumbEl = document.createElement('div');
1674
+ state.layerThumbEl.className = 'ge-layer-thumb';
1675
+ document.body.appendChild(state.layerThumbEl);
1676
+ }
1677
+ const SIZE = 120;
1678
+ // Downscale layer onto a small canvas, preserving aspect.
1679
+ const lw = layer.canvas.width, lh = layer.canvas.height;
1680
+ const scale = Math.min(SIZE / lw, SIZE / lh);
1681
+ const tw = Math.max(1, Math.round(lw * scale));
1682
+ const th = Math.max(1, Math.round(lh * scale));
1683
+ const c = document.createElement('canvas');
1684
+ c.width = tw; c.height = th;
1685
+ // Checker bg so transparency reads
1686
+ const ctx = c.getContext('2d');
1687
+ const tile = 8;
1688
+ for (let y = 0; y < th; y += tile) for (let x = 0; x < tw; x += tile) {
1689
+ ctx.fillStyle = ((x / tile + y / tile) & 1) ? '#444' : '#333';
1690
+ ctx.fillRect(x, y, tile, tile);
1691
+ }
1692
+ ctx.drawImage(layer.canvas, 0, 0, tw, th);
1693
+ state.layerThumbEl.innerHTML = '';
1694
+ state.layerThumbEl.appendChild(c);
1695
+ // Position to the LEFT of the row so it doesn't cover other layers.
1696
+ const r = rowEl.getBoundingClientRect();
1697
+ state.layerThumbEl.style.top = Math.max(8, r.top - 4) + 'px';
1698
+ state.layerThumbEl.style.right = (window.innerWidth - r.left + 8) + 'px';
1699
+ state.layerThumbEl.style.left = '';
1700
+ state.layerThumbEl.style.display = 'block';
1701
+ }
1702
+ function _hideLayerThumb() {
1703
+ if (state.layerThumbEl) state.layerThumbEl.style.display = 'none';
1704
+ }
1705
+
1706
+ // Shift+click on a layer row → use that layer's opaque pixels as a
1707
+ // wand-style selection. Lifts pixel alpha > 0 into the wand mask so the
1708
+ // user can immediately Bg-Remove / Erase / Copy through the layer.
1709
+ function _loadLayerAlphaAsSelection(layer) {
1710
+ if (!layer || !layer.canvas) return;
1711
+ const w = layer.canvas.width, h = layer.canvas.height;
1712
+ const src = layer.ctx.getImageData(0, 0, w, h).data;
1713
+ const mask = document.createElement('canvas');
1714
+ mask.width = w; mask.height = h;
1715
+ const mctx = mask.getContext('2d');
1716
+ const mdata = mctx.createImageData(w, h);
1717
+ for (let i = 0; i < w * h; i++) {
1718
+ if (src[i * 4 + 3] > 0) {
1719
+ mdata.data[i * 4] = 255;
1720
+ mdata.data[i * 4 + 1] = 255;
1721
+ mdata.data[i * 4 + 2] = 255;
1722
+ mdata.data[i * 4 + 3] = 255;
1723
+ }
1724
+ }
1725
+ mctx.putImageData(mdata, 0, 0);
1726
+ _saveState();
1727
+ state.wandMask = mask;
1728
+ state.wandLayerId = layer.id;
1729
+ state.wandLastSeed = null;
1730
+ composite();
1731
+ if (uiModule) uiModule.showToast('Layer pixels selected');
1732
+ }
1733
+
1734
+ // Invert the active selection: lasso (point list — turn into a polygon
1735
+ // covering the canvas with the lasso polygon as a hole) or wand (flip
1736
+ // the mask alpha). Wired to Ctrl+Alt+I.
1737
+ function _invertSelection() {
1738
+ if (state.wandMask && state.wandLayerId) {
1739
+ _saveState();
1740
+ const w = state.wandMask.width, h = state.wandMask.height;
1741
+ const ctx = state.wandMask.getContext('2d');
1742
+ const data = ctx.getImageData(0, 0, w, h);
1743
+ const d = data.data;
1744
+ for (let i = 0; i < d.length; i += 4) {
1745
+ const a = d[i + 3] > 128 ? 0 : 255;
1746
+ d[i] = 255; d[i + 1] = 255; d[i + 2] = 255; d[i + 3] = a;
1747
+ }
1748
+ ctx.putImageData(data, 0, 0);
1749
+ composite();
1750
+ if (uiModule) uiModule.showToast('Selection inverted');
1751
+ return true;
1752
+ }
1753
+ if (state.lassoPoints.length >= 3 && !state.lassoActive) {
1754
+ // Build polygon covering the whole canvas, with the lasso as a hole.
1755
+ // Easiest: convert lasso to wand mask, then invert.
1756
+ _saveState();
1757
+ const w = state.imgWidth, h = state.imgHeight;
1758
+ const c = document.createElement('canvas');
1759
+ c.width = w; c.height = h;
1760
+ const cctx = c.getContext('2d');
1761
+ cctx.fillStyle = '#fff';
1762
+ cctx.fillRect(0, 0, w, h);
1763
+ cctx.globalCompositeOperation = 'destination-out';
1764
+ cctx.beginPath();
1765
+ cctx.moveTo(state.lassoPoints[0].x, state.lassoPoints[0].y);
1766
+ for (let i = 1; i < state.lassoPoints.length; i++) cctx.lineTo(state.lassoPoints[i].x, state.lassoPoints[i].y);
1767
+ cctx.closePath();
1768
+ cctx.fill();
1769
+ state.wandMask = c;
1770
+ state.wandLayerId = state.activeLayerId;
1771
+ state.wandLastSeed = null;
1772
+ state.lassoPoints = [];
1773
+ state.lassoActive = false;
1774
+ composite();
1775
+ if (uiModule) uiModule.showToast('Selection inverted (converted to wand)');
1776
+ return true;
1777
+ }
1778
+ return false;
1779
+ }
1780
+
1781
+ // Convert the wand selection into the inpaint mask, mirroring _lassoToMask.
1782
+ // Switches to the inpaint tool so the user sees the result right away.
1783
+ function _wandToMask() {
1784
+ if (!state.wandMask || !state.wandLayerId) return;
1785
+ const layer = state.layers.find(l => l.id === state.wandLayerId);
1786
+ if (!layer) return;
1787
+ const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
1788
+ // Make the wand's parent active so the mask is attached to it, then
1789
+ // get-or-create a mask sub-layer on it. Repoint the global mask
1790
+ // plumbing at the new sub-layer's canvas/ctx.
1791
+ state.activeLayerId = layer.id;
1792
+ const mask = _ensureActiveMaskLayer();
1793
+ if (!mask) return;
1794
+ state.maskCanvas = mask.canvas;
1795
+ state.maskCtx = mask.ctx;
1796
+ // Refine the wand mask with the panel's Feather + Edge stroke values
1797
+ // before merging into the inpaint mask. Grow/shrink uses the same
1798
+ // blur+threshold dilate/erode as the lasso path; feather blurs the
1799
+ // result's alpha for a soft edge.
1800
+ const wFeather = parseInt(document.getElementById('ge-wand-feather')?.value || '0', 10);
1801
+ const wGrow = parseInt(document.getElementById('ge-wand-grow')?.value || '0', 10);
1802
+ let refinedWand = state.wandMask;
1803
+ if (wGrow !== 0) {
1804
+ const c = document.createElement('canvas');
1805
+ c.width = state.wandMask.width; c.height = state.wandMask.height;
1806
+ const bctx = c.getContext('2d');
1807
+ bctx.filter = `blur(${Math.abs(wGrow)}px)`;
1808
+ bctx.drawImage(state.wandMask, 0, 0);
1809
+ bctx.filter = 'none';
1810
+ const blurred = bctx.getImageData(0, 0, c.width, c.height).data;
1811
+ const out = bctx.createImageData(c.width, c.height);
1812
+ const od = out.data;
1813
+ const thr = wGrow > 0 ? 32 : 200;
1814
+ for (let i = 0; i < od.length; i += 4) {
1815
+ const a = blurred[i + 3] >= thr ? 255 : 0;
1816
+ od[i] = a; od[i + 1] = a; od[i + 2] = a; od[i + 3] = a;
1817
+ }
1818
+ bctx.putImageData(out, 0, 0);
1819
+ refinedWand = c;
1820
+ }
1821
+ if (wFeather > 0) {
1822
+ const c = document.createElement('canvas');
1823
+ c.width = refinedWand.width; c.height = refinedWand.height;
1824
+ const fctx = c.getContext('2d');
1825
+ fctx.filter = `blur(${wFeather}px)`;
1826
+ fctx.drawImage(refinedWand, 0, 0);
1827
+ fctx.filter = 'none';
1828
+ refinedWand = c;
1829
+ }
1830
+ // Draw the refined wand mask into the inpaint mask canvas at the
1831
+ // layer's offset. OR-like merge: any painted pixel in the wand mask
1832
+ // is added to the inpaint mask (max alpha wins). Matches the lasso
1833
+ // path's semantics.
1834
+ const tmp = document.createElement('canvas');
1835
+ tmp.width = state.maskCanvas.width;
1836
+ tmp.height = state.maskCanvas.height;
1837
+ const tctx = tmp.getContext('2d');
1838
+ tctx.drawImage(refinedWand, off.x, off.y);
1839
+ const incoming = tctx.getImageData(0, 0, tmp.width, tmp.height);
1840
+ const cur = state.maskCtx.getImageData(0, 0, state.maskCanvas.width, state.maskCanvas.height);
1841
+ for (let i = 0; i < incoming.data.length; i += 4) {
1842
+ if (incoming.data[i + 3] > cur.data[i + 3]) {
1843
+ cur.data[i] = 255;
1844
+ cur.data[i + 1] = 255;
1845
+ cur.data[i + 2] = 255;
1846
+ cur.data[i + 3] = incoming.data[i + 3];
1847
+ }
1848
+ }
1849
+ state.maskCtx.putImageData(cur, 0, 0);
1850
+ // Stay on the Wand tool — just bake the selection into the mask.
1851
+ // Clear the wand selection so a re-click starts fresh (and the red
1852
+ // overlay doesn't double up over the inpaint-mask red tint).
1853
+ state.wandMask = null;
1854
+ state.wandLayerId = null;
1855
+ state.wandLastSeed = null;
1856
+ composite();
1857
+ _renderLayerPanel();
1858
+ if (uiModule) uiModule.showToast('Selection added to mask');
1859
+ }
1860
+
1861
+ // Reveal/hide the small "X" badge on the Lasso and Wand tool buttons
1862
+ // based on whether each tool currently holds a selection. Called from
1863
+ // anywhere selection state mutates (wand click, lasso close, undo, etc.).
1864
+ function _syncToolClearIndicators() {
1865
+ // Selection state drives:
1866
+ // (1) the "from-selection" highlight on each layer's Add-mask btn
1867
+ // (2) the visibility of the post-selection refine rows (Feather +
1868
+ // Edge stroke) on the lasso / wand panels.
1869
+ // (3) the topbar Fill button — visible whenever lasso/wand/active
1870
+ // mask gives us a region to fill.
1871
+ const lassoHasSel = state.lassoPoints.length >= 3 && !state.lassoActive;
1872
+ const wandHasSel = !!state.wandMask;
1873
+ const hasMaskTarget = !!_getActiveMaskLayer();
1874
+ const hasSel = lassoHasSel || wandHasSel;
1875
+ document.querySelectorAll('.ge-layer-mask-btn').forEach(b => {
1876
+ b.classList.toggle('from-selection', hasSel);
1877
+ });
1878
+ // Fill action now lives in the Image menu — enable when there's
1879
+ // something fillable (selection or active mask).
1880
+ const fillItem = document.getElementById('ge-image-action-fill');
1881
+ if (fillItem) {
1882
+ fillItem.disabled = !(hasSel || hasMaskTarget);
1883
+ fillItem.title = fillItem.disabled
1884
+ ? 'Make a selection or pick a mask first'
1885
+ : 'Fill the active selection / mask with the current color';
1886
+ }
1887
+ // Topbar Selection button only makes sense with an active selection.
1888
+ const edgeWrap = document.getElementById('ge-edge-wrap');
1889
+ if (edgeWrap) edgeWrap.hidden = !hasSel;
1890
+ const lFeather = document.getElementById('ge-lasso-refine-feather');
1891
+ const lGrow = document.getElementById('ge-lasso-refine-grow');
1892
+ if (lFeather) lFeather.style.display = lassoHasSel ? '' : 'none';
1893
+ if (lGrow) lGrow.style.display = lassoHasSel ? '' : 'none';
1894
+ const wFeather = document.getElementById('ge-wand-refine-feather');
1895
+ const wGrow = document.getElementById('ge-wand-refine-grow');
1896
+ if (wFeather) wFeather.style.display = wandHasSel ? '' : 'none';
1897
+ if (wGrow) wGrow.style.display = wandHasSel ? '' : 'none';
1898
+ if (!state.container) return;
1899
+ const lassoBtn = state.container.querySelector('.ge-tool-btn[data-tool="lasso"]');
1900
+ const wandBtn = state.container.querySelector('.ge-tool-btn[data-tool="wand"]');
1901
+ const inpaintBtn = state.container.querySelector('.ge-tool-btn[data-tool="inpaint"]');
1902
+ if (lassoBtn) lassoBtn.classList.toggle('has-selection', state.lassoPoints.length >= 3 && !state.lassoActive);
1903
+ if (wandBtn) wandBtn.classList.toggle('has-selection', !!state.wandMask);
1904
+ // Inpaint no longer carries a clear-X badge; masks live as sub-layers
1905
+ // in the layer panel and are deleted from there.
1906
+ if (inpaintBtn) inpaintBtn.classList.remove('has-selection');
1907
+ }
1908
+
1909
+ function _hasMaskPixels() {
1910
+ if (!state.maskCanvas || !state.maskCtx) return false;
1911
+ try {
1912
+ const d = state.maskCtx.getImageData(0, 0, state.maskCanvas.width, state.maskCanvas.height).data;
1913
+ for (let i = 3; i < d.length; i += 4) if (d[i] > 0) return true;
1914
+ } catch (_) {}
1915
+ return false;
1916
+ }
1917
+
1918
+ function _wandDeleteSelection() {
1919
+ if (!state.wandMask) return;
1920
+ const layer = state.layers.find(l => l.id === state.wandLayerId);
1921
+ if (!layer || layer.locked) return;
1922
+ _saveState();
1923
+ // Use destination-out with the mask to erase the selected pixels.
1924
+ layer.ctx.save();
1925
+ layer.ctx.globalCompositeOperation = 'destination-out';
1926
+ layer.ctx.drawImage(state.wandMask, 0, 0);
1927
+ layer.ctx.restore();
1928
+ _wandClear();
1929
+ }
1930
+
1931
+ function _wandCopyToNewLayer() {
1932
+ if (!state.wandMask) return;
1933
+ const src = state.layers.find(l => l.id === state.wandLayerId);
1934
+ if (!src) return;
1935
+ _saveState();
1936
+ // Clip the source by the mask, put it on a new layer.
1937
+ const tmp = document.createElement('canvas');
1938
+ tmp.width = src.canvas.width;
1939
+ tmp.height = src.canvas.height;
1940
+ const tCtx = tmp.getContext('2d');
1941
+ tCtx.drawImage(src.canvas, 0, 0);
1942
+ tCtx.globalCompositeOperation = 'destination-in';
1943
+ tCtx.drawImage(state.wandMask, 0, 0);
1944
+ const newLayer = createLayer('Wand copy', src.canvas.width, src.canvas.height);
1945
+ newLayer.ctx.drawImage(tmp, 0, 0);
1946
+ const srcOff = state.layerOffsets.get(src.id) || { x: 0, y: 0 };
1947
+ state.layerOffsets.set(newLayer.id, { ...srcOff });
1948
+ const idx = state.layers.findIndex(l => l.id === src.id);
1949
+ state.layers.splice(idx + 1, 0, newLayer);
1950
+ state.activeLayerId = newLayer.id;
1951
+ composite();
1952
+ _renderLayerPanel();
1953
+ _revealLayerPanel();
1954
+ if (uiModule) uiModule.showToast('Copied to new layer');
1955
+ }
1956
+
1957
+ function _lassoDeleteSelection() {
1958
+ const layer = activeLayer();
1959
+ if (!layer || state.lassoPoints.length < 3) return;
1960
+ const feather = parseInt(document.getElementById('ge-lasso-feather')?.value || '0');
1961
+ const grow = parseInt(document.getElementById('ge-lasso-grow')?.value || '0');
1962
+ _saveState();
1963
+ const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
1964
+ const w = layer.canvas.width, h = layer.canvas.height;
1965
+
1966
+ const mask = _buildLassoMask(w, h, off.x, off.y, feather, grow);
1967
+ const maskData = mask.getContext('2d').getImageData(0, 0, w, h);
1968
+ const imgData = layer.ctx.getImageData(0, 0, w, h);
1969
+
1970
+ for (let i = 0; i < w * h; i++) {
1971
+ const maskVal = maskData.data[i * 4]; // red channel
1972
+ if (maskVal > 0) {
1973
+ const fade = maskVal / 255;
1974
+ imgData.data[i * 4 + 3] = Math.round(imgData.data[i * 4 + 3] * (1 - fade));
1975
+ }
1976
+ }
1977
+ layer.ctx.putImageData(imgData, 0, 0);
1978
+
1979
+ state.lassoPoints = [];
1980
+ composite();
1981
+ uiModule.showToast('Selection deleted');
1982
+ }
1983
+
1984
+ function _lassoCopyToLayer() {
1985
+ const layer = activeLayer();
1986
+ if (!layer || state.lassoPoints.length < 3) return;
1987
+ const feather = parseInt(document.getElementById('ge-lasso-feather')?.value || '0');
1988
+ const grow = parseInt(document.getElementById('ge-lasso-grow')?.value || '0');
1989
+ _saveState();
1990
+ const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
1991
+ const w = layer.canvas.width, h = layer.canvas.height;
1992
+
1993
+ const mask = _buildLassoMask(w, h, off.x, off.y, feather, grow);
1994
+ const newLayer = createLayer('Selection', state.imgWidth, state.imgHeight);
1995
+
1996
+ // Copy layer pixels masked by the selection
1997
+ const srcData = layer.ctx.getImageData(0, 0, w, h);
1998
+ const maskData = mask.getContext('2d').getImageData(0, 0, w, h);
1999
+ const outData = newLayer.ctx.createImageData(w, h);
2000
+
2001
+ for (let i = 0; i < w * h; i++) {
2002
+ const maskVal = maskData.data[i * 4];
2003
+ if (maskVal > 0) {
2004
+ const fade = maskVal / 255;
2005
+ outData.data[i * 4] = srcData.data[i * 4];
2006
+ outData.data[i * 4 + 1] = srcData.data[i * 4 + 1];
2007
+ outData.data[i * 4 + 2] = srcData.data[i * 4 + 2];
2008
+ outData.data[i * 4 + 3] = Math.round(srcData.data[i * 4 + 3] * fade);
2009
+ }
2010
+ }
2011
+ newLayer.ctx.putImageData(outData, 0, 0);
2012
+
2013
+ state.layers.push(newLayer);
2014
+ state.activeLayerId = newLayer.id;
2015
+ state.lassoPoints = [];
2016
+ _renderLayerPanel();
2017
+ _revealLayerPanel();
2018
+ composite();
2019
+ uiModule.showToast('Selection copied to new layer');
2020
+ }
2021
+
2022
+ function _lassoToMask() {
2023
+ if (state.lassoPoints.length < 3) return;
2024
+ // Get-or-create a mask sub-layer on the active parent layer and
2025
+ // repoint the global mask plumbing at it.
2026
+ const mask = _ensureActiveMaskLayer();
2027
+ if (!mask) return;
2028
+ state.maskCanvas = mask.canvas;
2029
+ state.maskCtx = mask.ctx;
2030
+
2031
+ // Fill selection into the mask with feather + grow/shrink applied.
2032
+ const feather = parseInt(document.getElementById('ge-lasso-feather')?.value || '0');
2033
+ const grow = parseInt(document.getElementById('ge-lasso-grow')?.value || '0');
2034
+ const lassoFill = _buildLassoMask(state.maskCanvas.width, state.maskCanvas.height, 0, 0, feather, grow);
2035
+ const maskData = lassoFill.getContext('2d').getImageData(0, 0, state.maskCanvas.width, state.maskCanvas.height);
2036
+ const curData = state.maskCtx.getImageData(0, 0, state.maskCanvas.width, state.maskCanvas.height);
2037
+ // Merge: add the new selection to existing mask
2038
+ for (let i = 0; i < maskData.data.length; i += 4) {
2039
+ const val = maskData.data[i];
2040
+ if (val > curData.data[i]) {
2041
+ curData.data[i] = val;
2042
+ curData.data[i + 1] = val;
2043
+ curData.data[i + 2] = val;
2044
+ curData.data[i + 3] = val;
2045
+ }
2046
+ }
2047
+ state.maskCtx.putImageData(curData, 0, 0);
2048
+
2049
+ // Stay on the Lasso tool — just bake the selection into the mask.
2050
+ // Keep the lasso points so the user can keep tweaking; clear the
2051
+ // active-shape state so the next click starts fresh if they want.
2052
+ state.lassoPoints = [];
2053
+ composite();
2054
+ _renderLayerPanel();
2055
+ uiModule.showToast('Selection added to mask');
2056
+ }
2057
+
2058
+ // ── Edge feather ──
2059
+
2060
+ // Themed slider modal for filter parameters. Builds a single in-line
2061
+ // overlay anchored to the canvas-area's centre. `params` is an array
2062
+ // of `{ key, label, min, max, step, value, suffix }`. As the user
2063
+ // drags any slider the `onPreview(values)` callback fires for live
2064
+ // rendering; clicking Apply commits and resolves the returned Promise
2065
+ // with the final values; Cancel / Esc resolves with null. The caller
2066
+ // is responsible for snapshotting state BEFORE opening (so Cancel can
2067
+ // restore the layer's pixels).
2068
+ function _filterSliderPrompt(title, params, onPreview) {
2069
+ return new Promise((resolve) => {
2070
+ if (!state.container) { resolve(null); return; }
2071
+ const overlay = document.createElement('div');
2072
+ overlay.className = 'ge-filter-overlay';
2073
+ let rows = '';
2074
+ for (const p of params) {
2075
+ // Reuse the editor's eraser-row class so the slider picks up the
2076
+ // standard slim red-thumb styling instead of the bare browser
2077
+ // default. Value chip on the same line as the label.
2078
+ rows += `
2079
+ <div class="ge-filter-row ge-eraser-row">
2080
+ <label>${p.label} <span class="ge-filter-row-value" data-val-for="${p.key}">${p.value}${p.suffix || ''}</span></label>
2081
+ <input type="range" data-key="${p.key}" min="${p.min}" max="${p.max}" step="${p.step || 1}" value="${p.value}" />
2082
+ </div>
2083
+ `;
2084
+ }
2085
+ overlay.innerHTML = `
2086
+ <div class="ge-filter-modal">
2087
+ <div class="ge-filter-modal-head">${title}</div>
2088
+ ${rows}
2089
+ <div class="ge-filter-modal-actions">
2090
+ <button type="button" class="ge-btn ge-btn-sm" data-action="cancel">Cancel</button>
2091
+ <button type="button" class="ge-btn ge-btn-sm ge-btn-primary" data-action="apply">Apply</button>
2092
+ </div>
2093
+ </div>
2094
+ `;
2095
+ state.container.appendChild(overlay);
2096
+ const values = {};
2097
+ for (const p of params) values[p.key] = p.value;
2098
+ // Initial preview render.
2099
+ try { onPreview(values); } catch {}
2100
+ overlay.querySelectorAll('input[type="range"]').forEach(inp => {
2101
+ inp.addEventListener('input', (e) => {
2102
+ const k = e.target.dataset.key;
2103
+ const v = parseFloat(e.target.value);
2104
+ values[k] = v;
2105
+ const lbl = overlay.querySelector(`[data-val-for="${k}"]`);
2106
+ const param = params.find(p => p.key === k);
2107
+ if (lbl) lbl.textContent = v + (param && param.suffix ? param.suffix : '');
2108
+ try { onPreview(values); } catch {}
2109
+ });
2110
+ });
2111
+ const cleanup = (result) => {
2112
+ try { overlay.remove(); } catch {}
2113
+ document.removeEventListener('keydown', onKey, true);
2114
+ resolve(result);
2115
+ };
2116
+ const onKey = (e) => {
2117
+ if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); cleanup(null); }
2118
+ else if (e.key === 'Enter') { e.preventDefault(); cleanup(values); }
2119
+ };
2120
+ document.addEventListener('keydown', onKey, true);
2121
+ overlay.querySelector('[data-action="apply"]').addEventListener('click', () => cleanup(values));
2122
+ overlay.querySelector('[data-action="cancel"]').addEventListener('click', () => cleanup(null));
2123
+ // Click outside the modal (on the dim backdrop) = cancel.
2124
+ overlay.addEventListener('click', (e) => { if (e.target === overlay) cleanup(null); });
2125
+ });
2126
+ }
2127
+
2128
+ // Generic helper for live-preview blur filters. Saves the PRE-blur
2129
+ // state to the undo stack first (so Ctrl-Z reverts cleanly), snapshots
2130
+ // the layer for re-rendering, applies `renderer(snap, values)` into
2131
+ // the layer on every slider change for instant feedback. Apply keeps
2132
+ // the result; Cancel / Esc restores the snapshot AND pops the undo
2133
+ // entry we pre-saved so the canceled run leaves no trace.
2134
+ async function _applyLiveBlur({ title, params, label, renderer }) {
2135
+ const layer = activeLayer();
2136
+ if (!layer || layer.locked) { if (uiModule) uiModule.showToast('Select an unlocked layer'); return; }
2137
+ const w = layer.canvas.width, h = layer.canvas.height;
2138
+ const snap = document.createElement('canvas');
2139
+ snap.width = w; snap.height = h;
2140
+ snap.getContext('2d').drawImage(layer.canvas, 0, 0);
2141
+ // Save state BEFORE any preview — the undo stack now holds the
2142
+ // pre-blur pixels. Apply leaves it; Cancel pops it.
2143
+ _saveState(label);
2144
+ const draw = (values) => {
2145
+ layer.ctx.clearRect(0, 0, w, h);
2146
+ try { renderer(snap, values, layer.ctx); } catch (_) { layer.ctx.drawImage(snap, 0, 0); }
2147
+ composite();
2148
+ };
2149
+ const result = await _filterSliderPrompt(title, params, draw);
2150
+ if (result === null) {
2151
+ layer.ctx.clearRect(0, 0, w, h);
2152
+ layer.ctx.drawImage(snap, 0, 0);
2153
+ composite();
2154
+ // Drop the snapshot we pushed — there's nothing to undo to.
2155
+ if (state.undoStack.length) state.undoStack.pop();
2156
+ _refreshHistoryPanelIfOpen();
2157
+ return;
2158
+ }
2159
+ // Final render from snapshot for a clean commit.
2160
+ layer.ctx.clearRect(0, 0, w, h);
2161
+ renderer(snap, result, layer.ctx);
2162
+ composite();
2163
+ if (uiModule) uiModule.showToast(label + ' applied');
2164
+ }
2165
+
2166
+ function _applyGaussianBlur() {
2167
+ _applyLiveBlur({
2168
+ title: 'Gaussian Blur',
2169
+ label: 'Gaussian Blur',
2170
+ params: [{ key: 'radius', label: 'Radius', min: 0, max: 100, step: 1, value: 6, suffix: 'px' }],
2171
+ renderer: _gaussianBlur,
2172
+ });
2173
+ }
2174
+
2175
+ function _applyZoomBlur() {
2176
+ _applyLiveBlur({
2177
+ title: 'Zoom Blur',
2178
+ label: 'Zoom Blur',
2179
+ params: [{ key: 'strength', label: 'Strength', min: 1, max: 50, step: 1, value: 15 }],
2180
+ renderer: _zoomBlur,
2181
+ });
2182
+ }
2183
+
2184
+ function _applyMotionBlur() {
2185
+ _applyLiveBlur({
2186
+ title: 'Motion Blur',
2187
+ label: 'Motion Blur',
2188
+ params: [
2189
+ { key: 'length', label: 'Length', min: 1, max: 200, step: 1, value: 20, suffix: 'px' },
2190
+ { key: 'angle', label: 'Angle', min: -180, max: 180, step: 1, value: 0, suffix: '°' },
2191
+ ],
2192
+ renderer: _motionBlur,
2193
+ });
2194
+ }
2195
+
2196
+ function _applyEdgeFeather(layer, width, hardDelete) {
2197
+ const w = layer.canvas.width;
2198
+ const h = layer.canvas.height;
2199
+ const imgData = layer.ctx.getImageData(0, 0, w, h);
2200
+ _edgeFeather(imgData, width, hardDelete);
2201
+ layer.ctx.putImageData(imgData, 0, 0);
2202
+ }
2203
+
2204
+ // ── Zoom ──
2205
+
2206
+ function _fitZoom() {
2207
+ const fit = _getFitZoom();
2208
+ if (!fit) return;
2209
+ state.zoom = fit;
2210
+ _applyZoom();
2211
+ }
2212
+
2213
+ function _getFitZoom() {
2214
+ const area = state.container.querySelector('.ge-canvas-area');
2215
+ if (!area || !state.imgWidth) return null;
2216
+ const pad = 20;
2217
+ const maxW = area.clientWidth - pad * 2;
2218
+ const maxH = area.clientHeight - pad * 2;
2219
+ return Math.min(1, maxW / state.imgWidth, maxH / state.imgHeight);
2220
+ }
2221
+
2222
+ function _applyZoom() {
2223
+ if (!state.mainCanvas) return;
2224
+ state.mainCanvas.style.width = (state.imgWidth * state.zoom) + 'px';
2225
+ state.mainCanvas.style.height = (state.imgHeight * state.zoom) + 'px';
2226
+ const label = state.container.querySelector('.ge-zoom-label');
2227
+ if (label) label.textContent = Math.round(state.zoom * 100) + '%';
2228
+ _syncZoomControls();
2229
+ const area = state.container && state.container.querySelector('.ge-canvas-area');
2230
+ if (area && area._resetPan) area._resetPan();
2231
+ }
2232
+
2233
+ function _syncZoomControls() {
2234
+ const fitBtn = document.getElementById('ge-zoom-fit');
2235
+ const actualBtn = document.getElementById('ge-zoom-100');
2236
+ const fit = _getFitZoom();
2237
+ const isFit = fit !== null && Math.abs(state.zoom - fit) < 0.001;
2238
+ const isActual = !isFit && Math.abs(state.zoom - 1) < 0.001;
2239
+ if (fitBtn) {
2240
+ fitBtn.classList.toggle('active', isFit);
2241
+ fitBtn.setAttribute('aria-pressed', isFit ? 'true' : 'false');
2242
+ }
2243
+ if (actualBtn) {
2244
+ actualBtn.classList.toggle('active', isActual);
2245
+ actualBtn.setAttribute('aria-pressed', isActual ? 'true' : 'false');
2246
+ }
2247
+ }
2248
+
2249
+ function _positionInpaintPanel(anchorBtn) {
2250
+ const panel = document.getElementById('ge-inpaint-section');
2251
+ if (!panel || window.innerWidth <= 820) return;
2252
+ if (panel.dataset.userMoved === '1') {
2253
+ panel.classList.add('ge-inpaint-popover');
2254
+ return;
2255
+ }
2256
+ panel.classList.add('ge-inpaint-popover');
2257
+ // Anchor to the Layers header on the right panel so the popover
2258
+ // appears to slide out from there. The toolbar button on the left
2259
+ // shifts around as controls reflow, which was causing the popover
2260
+ // to land in different spots on each open and look "jumpy" when the
2261
+ // user grabbed it to move. Anchoring to the right panel — which has
2262
+ // a stable position — keeps the docked appearance steady.
2263
+ const layersHeader = document.querySelector('.ge-layers-header');
2264
+ const rightPanel = document.querySelector('.ge-right-panel');
2265
+ const ref = layersHeader || rightPanel;
2266
+ if (!ref) {
2267
+ // Fallback to the old toolbar-button anchor if the layers panel
2268
+ // isn't on screen yet.
2269
+ const r = anchorBtn?.getBoundingClientRect?.();
2270
+ if (!r) return;
2271
+ requestAnimationFrame(() => {
2272
+ const panelW = panel.offsetWidth || 320;
2273
+ const panelH = panel.offsetHeight || 520;
2274
+ const left = Math.min(window.innerWidth - panelW - 12, Math.max(12, r.right + 10));
2275
+ const top = Math.min(window.innerHeight - panelH - 12, Math.max(12, r.top));
2276
+ panel.style.left = `${left}px`;
2277
+ panel.style.top = `${top}px`;
2278
+ });
2279
+ return;
2280
+ }
2281
+ requestAnimationFrame(() => {
2282
+ const refRect = ref.getBoundingClientRect();
2283
+ const panelW = panel.offsetWidth || 320;
2284
+ const panelH = panel.offsetHeight || 520;
2285
+ // Sit immediately to the left of the right panel, top-aligned with
2286
+ // the Layers header. 10px gap so it's clearly a separate window
2287
+ // and not visually fused with the panel.
2288
+ let left = refRect.left - panelW - 10;
2289
+ let top = refRect.top;
2290
+ // Clamp into the viewport so the popover never leaves the screen.
2291
+ left = Math.max(12, Math.min(window.innerWidth - panelW - 12, left));
2292
+ top = Math.max(12, Math.min(window.innerHeight - panelH - 12, top));
2293
+ panel.style.left = `${left}px`;
2294
+ panel.style.top = `${top}px`;
2295
+ });
2296
+ }
2297
+
2298
+ function _wireInpaintPopoverWindow() {
2299
+ const panel = document.getElementById('ge-inpaint-section');
2300
+ if (!panel || panel.dataset.windowWired === '1') return;
2301
+ panel.dataset.windowWired = '1';
2302
+ const closeBtn = document.getElementById('ge-inpaint-popover-close');
2303
+ closeBtn?.addEventListener('click', (e) => {
2304
+ e.preventDefault();
2305
+ e.stopPropagation();
2306
+ panel.classList.add('dismissed');
2307
+ panel.style.display = 'none';
2308
+ document.getElementById('ge-controls')?.classList.remove('ge-inpaint-popover-host');
2309
+ });
2310
+ const head = panel.querySelector('[data-inpaint-drag]');
2311
+ if (!head) return;
2312
+ head.addEventListener('pointerdown', (e) => {
2313
+ if (window.innerWidth <= 820 || e.target.closest('button')) return;
2314
+ e.preventDefault();
2315
+ e.stopPropagation();
2316
+ panel.classList.add('ge-inpaint-popover');
2317
+ const startX = e.clientX;
2318
+ const startY = e.clientY;
2319
+ const r0 = panel.getBoundingClientRect();
2320
+ head.setPointerCapture(e.pointerId);
2321
+ head.style.cursor = 'grabbing';
2322
+ const onMove = (ev) => {
2323
+ const w = panel.offsetWidth || r0.width;
2324
+ const h = panel.offsetHeight || r0.height;
2325
+ const nx = Math.max(8, Math.min(window.innerWidth - w - 8, r0.left + ev.clientX - startX));
2326
+ const ny = Math.max(8, Math.min(window.innerHeight - h - 8, r0.top + ev.clientY - startY));
2327
+ panel.dataset.userMoved = '1';
2328
+ panel.style.left = `${nx}px`;
2329
+ panel.style.top = `${ny}px`;
2330
+ };
2331
+ const onUp = () => {
2332
+ try { head.releasePointerCapture(e.pointerId); } catch {}
2333
+ head.style.cursor = '';
2334
+ head.removeEventListener('pointermove', onMove);
2335
+ head.removeEventListener('pointerup', onUp);
2336
+ };
2337
+ head.addEventListener('pointermove', onMove);
2338
+ head.addEventListener('pointerup', onUp);
2339
+ });
2340
+ }
2341
+
2342
+ // ── Build DOM ──
2343
+
2344
+ function _buildEditor(container) {
2345
+ container.innerHTML = '';
2346
+ container.className = 'gallery-editor';
2347
+
2348
+ // Toolbar (left) — DOM construction lives in editor/build/toolbar.js;
2349
+ // the big tool-switch handler stays here so it can touch module state.
2350
+ const { toolbar, toolKeyMap: _toolKeyMap } = _buildToolbar({
2351
+ currentTool: state.tool,
2352
+ onClearSelection: (which) => {
2353
+ if (which === 'lasso') {
2354
+ state.lassoPoints = [];
2355
+ state.lassoActive = false;
2356
+ composite();
2357
+ } else if (which === 'wand') {
2358
+ _wandClear();
2359
+ }
2360
+ _syncToolClearIndicators();
2361
+ },
2362
+ onSelectTool: (toolId, _btn, toolbarEl) => {
2363
+ // Leaving transform mode without confirm? Treat tool change as confirm.
2364
+ if (state.transformActive && toolId !== 'transform') _confirmTransform();
2365
+ // Re-clicking the active tool toggles the mobile control sheet —
2366
+ // lets the user swipe-down to dismiss, then tap the tool again to
2367
+ // bring it back. On desktop this is a no-op visually since the
2368
+ // controls live in the right panel.
2369
+ const reactivated = state.tool === toolId;
2370
+ state.tool = toolId;
2371
+ const controls = document.getElementById('ge-controls') || document.querySelector('.ge-controls');
2372
+ if (controls) {
2373
+ if (reactivated) controls.classList.toggle('dismissed');
2374
+ else controls.classList.remove('dismissed');
2375
+ }
2376
+ // On mobile, picking a tool that's about to SHOW its controls
2377
+ // panel auto-minimises the layers sheet so the controls aren't
2378
+ // covered. Swiping the layers handle back up restores it.
2379
+ const isMobile = window.innerWidth <= 820;
2380
+ const hasToolControls = ['brush', 'eraser', 'clone', 'inpaint'].includes(toolId);
2381
+ const controlsVisible = controls && !controls.classList.contains('dismissed');
2382
+ if (isMobile && hasToolControls && controlsVisible) {
2383
+ const rp = document.querySelector('.ge-right-panel');
2384
+ if (rp) {
2385
+ rp.classList.remove('expanded');
2386
+ rp.classList.add('minimized');
2387
+ }
2388
+ }
2389
+ toolbarEl.querySelectorAll('.ge-tool-btn').forEach(b => b.classList.toggle('active', b.dataset.tool === state.tool));
2390
+ // Activate drag-resize handles when picking the Resize tool
2391
+ if (toolId === 'transform' && !state.transformActive) _startTransform();
2392
+ // Show/hide brush controls. Brush, Eraser AND Clone use the
2393
+ // shared size+color row; Inpaint has its OWN size slider.
2394
+ const brushControls = document.getElementById('ge-brush-controls');
2395
+ const needsBrush = ['brush', 'eraser', 'clone'].includes(toolId);
2396
+ if (brushControls) brushControls.style.display = needsBrush ? '' : 'none';
2397
+ // Eraser and Clone don't care about color — hide the color row.
2398
+ const colorRow = document.getElementById('ge-color-row');
2399
+ if (colorRow) colorRow.style.display = (toolId === 'eraser' || toolId === 'clone') ? 'none' : '';
2400
+ const colorLabel = colorRow?.querySelector('label');
2401
+ if (colorLabel) colorLabel.textContent = 'Color';
2402
+ const sizeLabelEl = brushControls?.querySelector('.ge-size-slider')?.parentElement?.querySelector('label');
2403
+ if (sizeLabelEl && sizeLabelEl.firstChild && sizeLabelEl.firstChild.nodeType === Node.TEXT_NODE) {
2404
+ sizeLabelEl.firstChild.nodeValue = (toolId === 'eraser') ? 'Brush Size ' : 'Size ';
2405
+ }
2406
+ // Per-tool stroke-modifier sections (opacity / flow / softness).
2407
+ const brushSection = document.getElementById('ge-brush-section');
2408
+ if (brushSection) brushSection.style.display = toolId === 'brush' ? '' : 'none';
2409
+ const cloneSection = document.getElementById('ge-clone-section');
2410
+ if (cloneSection) cloneSection.style.display = toolId === 'clone' ? '' : 'none';
2411
+ const lassoSection = document.getElementById('ge-lasso-section');
2412
+ if (lassoSection) lassoSection.style.display = state.tool === 'lasso' ? '' : 'none';
2413
+ const wandSection = document.getElementById('ge-wand-section');
2414
+ if (wandSection) wandSection.style.display = state.tool === 'wand' ? '' : 'none';
2415
+ const inpaintSection = document.getElementById('ge-inpaint-section');
2416
+ if (inpaintSection) {
2417
+ if (state.tool === 'inpaint') {
2418
+ if (reactivated) inpaintSection.classList.toggle('dismissed');
2419
+ else inpaintSection.classList.remove('dismissed');
2420
+ inpaintSection.style.display = inpaintSection.classList.contains('dismissed') ? 'none' : '';
2421
+ const inpaintOpen = !inpaintSection.classList.contains('dismissed');
2422
+ controls?.classList.toggle('ge-inpaint-popover-host', inpaintOpen && window.innerWidth > 820);
2423
+ if (inpaintOpen) _positionInpaintPanel(_btn);
2424
+ } else {
2425
+ controls?.classList.remove('ge-inpaint-popover-host');
2426
+ inpaintSection.classList.remove('dismissed');
2427
+ inpaintSection.style.display = 'none';
2428
+ }
2429
+ }
2430
+ // Entering inpaint mode: make sure the active parent layer has a
2431
+ // mask sub-layer, and point the global mask plumbing at it. Also
2432
+ // force the global mask-visibility flag back on — a previous
2433
+ // Generate cleared it, but on re-entry the user expects to see
2434
+ // their mask again.
2435
+ if (state.tool === 'inpaint') {
2436
+ // First inpaint entry per session: bump the brush size to the
2437
+ // mask-friendly default (other tools keep their own size).
2438
+ if (!state.inpaintBrushInitialised) {
2439
+ state.brushSize = _INPAINT_DEFAULT_BRUSH;
2440
+ state.inpaintBrushInitialised = true;
2441
+ const inp = document.getElementById('ge-inpaint-brush-slider');
2442
+ if (inp) {
2443
+ const pos = Math.round(Math.log(Math.max(1, state.brushSize)) / Math.log(800) * 1000);
2444
+ inp.value = String(pos);
2445
+ const lbl = document.getElementById('ge-inpaint-brush-label');
2446
+ if (lbl) lbl.textContent = `${state.brushSize}px`;
2447
+ }
2448
+ }
2449
+ // If the active parent already carries one or more masks, reuse
2450
+ // the most-recent one instead of creating a new "Mask 2" /
2451
+ // "Mask 3" every time the user re-enters inpaint.
2452
+ const parent = _activeParentLayer();
2453
+ if (parent && parent.masks && parent.masks.length) {
2454
+ if (!parent.activeMaskId) {
2455
+ parent.activeMaskId = parent.masks[parent.masks.length - 1].id;
2456
+ }
2457
+ const m = _getActiveMaskLayer();
2458
+ if (m) { state.maskCanvas = m.canvas; state.maskCtx = m.ctx; }
2459
+ } else {
2460
+ const mask = _ensureActiveMaskLayer();
2461
+ if (mask) {
2462
+ state.maskCanvas = mask.canvas;
2463
+ state.maskCtx = mask.ctx;
2464
+ // Reflect the freshly-created mask sub-row in the panel.
2465
+ _renderLayerPanel();
2466
+ }
2467
+ }
2468
+ if (!state.maskVisible) {
2469
+ state.maskVisible = true;
2470
+ const maskBtn = document.getElementById('ge-mask-vis');
2471
+ if (maskBtn) {
2472
+ maskBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
2473
+ maskBtn.title = 'Hide mask';
2474
+ maskBtn.classList.add('visible');
2475
+ }
2476
+ }
2477
+ }
2478
+ const eraserSection = document.getElementById('ge-eraser-section');
2479
+ if (eraserSection) eraserSection.style.display = state.tool === 'eraser' ? '' : 'none';
2480
+ const sharpenSection = document.getElementById('ge-sharpen-section');
2481
+ if (sharpenSection) sharpenSection.style.display = state.tool === 'sharpen' ? '' : 'none';
2482
+ const rembgSection = document.getElementById('ge-rembg-section');
2483
+ if (rembgSection) {
2484
+ const show = state.tool === 'rembg';
2485
+ rembgSection.style.display = show ? '' : 'none';
2486
+ if (show) _checkRembgInstalled();
2487
+ }
2488
+ const importSection = document.getElementById('ge-import-section');
2489
+ if (importSection) importSection.style.display = state.tool === 'import' ? '' : 'none';
2490
+ const harmonizeSection = document.getElementById('ge-harmonize-section');
2491
+ if (harmonizeSection) harmonizeSection.style.display = state.tool === 'harmonize' ? '' : 'none';
2492
+ const upscaleSection = document.getElementById('ge-upscale-section');
2493
+ if (upscaleSection) upscaleSection.style.display = state.tool === 'upscale' ? '' : 'none';
2494
+ const styleSection = document.getElementById('ge-style-section');
2495
+ if (styleSection) styleSection.style.display = state.tool === 'style' ? '' : 'none';
2496
+ // Toggle cursor — hide native cursor for tools that draw via our
2497
+ // own circle overlay (brush/eraser/inpaint/lasso); for other tools
2498
+ // pick a cursor that matches the tool's affordance.
2499
+ const useCircle = state.tool === 'brush' || state.tool === 'eraser' || state.tool === 'inpaint' || state.tool === 'lasso' || state.tool === 'clone';
2500
+ if (state.mainCanvas) {
2501
+ // Custom SVG cursor for the Move tool — white fill with black
2502
+ // stroke so it reads on both light and dark canvases.
2503
+ const moveCursorSvg = `data:image/svg+xml;utf8,${encodeURIComponent(
2504
+ '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="white" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 L9 5 H11 V11 H5 V9 L2 12 L5 15 V13 H11 V19 H9 L12 22 L15 19 H13 V13 H19 V15 L22 12 L19 9 V11 H13 V5 H15 Z"/></svg>'
2505
+ )}`;
2506
+ let cursor = 'crosshair';
2507
+ if (state.tool === 'move') cursor = `url("${moveCursorSvg}") 12 12, move`;
2508
+ else if (state.tool === 'transform') cursor = 'default';
2509
+ else if (useCircle) cursor = 'crosshair';
2510
+ state.mainCanvas.style.cursor = cursor;
2511
+ }
2512
+ if (state.cursorEl) state.cursorEl.style.display = useCircle ? '' : 'none';
2513
+ composite();
2514
+ },
2515
+ });
2516
+ // Top bar — static DOM lives in editor/build/topbar.js; all click
2517
+ // handlers below wire to the IDs baked into the markup.
2518
+ const topBar = _buildTopbar();
2519
+ container.appendChild(topBar);
2520
+
2521
+ // Editor body (toolbar + canvas + panel)
2522
+ const editorBody = document.createElement('div');
2523
+ editorBody.className = 'ge-editor-body';
2524
+ editorBody.appendChild(toolbar);
2525
+
2526
+ // Canvas area (center)
2527
+ const canvasArea = document.createElement('div');
2528
+ canvasArea.className = 'ge-canvas-area';
2529
+ state.mainCanvas = document.createElement('canvas');
2530
+ state.mainCanvas.className = 'ge-main-canvas';
2531
+ state.mainCtx = state.mainCanvas.getContext('2d');
2532
+ // Initial cursor matches the default tool (move) so the user sees the
2533
+ // four-arrow icon as soon as the editor opens. Uses a filled white
2534
+ // arrow with black stroke for readability on light AND dark canvases.
2535
+ if (state.tool === 'move') {
2536
+ const svg = `data:image/svg+xml;utf8,${encodeURIComponent(
2537
+ '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="white" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 L9 5 H11 V11 H5 V9 L2 12 L5 15 V13 H11 V19 H9 L12 22 L15 19 H13 V13 H19 V15 L22 12 L19 9 V11 H13 V5 H15 Z"/></svg>'
2538
+ )}`;
2539
+ state.mainCanvas.style.cursor = `url("${svg}") 12 12, move`;
2540
+ } else {
2541
+ state.mainCanvas.style.cursor = 'crosshair';
2542
+ }
2543
+ canvasArea.appendChild(state.mainCanvas);
2544
+
2545
+ // Transform overlay — separate canvas positioned over the main canvas
2546
+ // with extra margin so the resize/rotation handles can render OUTSIDE
2547
+ // the image bounds. Sized + zoomed in sync with the main canvas via
2548
+ // _syncTransformOverlay(). The overlay is pointer-events:none so it
2549
+ // doesn't intercept clicks; hit-testing still happens in image coords.
2550
+ state.transformOverlay = document.createElement('canvas');
2551
+ state.transformOverlay.className = 'ge-transform-overlay';
2552
+ state.transformOverlayCtx = state.transformOverlay.getContext('2d');
2553
+ canvasArea.appendChild(state.transformOverlay);
2554
+ // Keep the transform handles glued to the photo while the canvas-area
2555
+ // scrolls (the overlay is anchored to the canvas's live rect, so a
2556
+ // re-draw on scroll re-reads its position).
2557
+ canvasArea.addEventListener('scroll', () => {
2558
+ if (state.transformActive) _drawTransformHandles();
2559
+ }, { passive: true });
2560
+
2561
+ // Canvas events (mouse + touch + pinch-zoom + pan) — full
2562
+ // implementation in editor/canvas-events.js.
2563
+ wireCanvasEvents({
2564
+ canvasArea,
2565
+ beginDraw: _beginDraw,
2566
+ continueDraw: _continueDraw,
2567
+ endDraw: _endDraw,
2568
+ updateBrushCursor: (e) => _updateBrushCursor(e),
2569
+ syncZoomControls: () => _syncZoomControls(),
2570
+ });
2571
+
2572
+ editorBody.appendChild(canvasArea);
2573
+
2574
+ // Right panel (controls + layers + resize handle) — full
2575
+ // implementation in editor/build/right-panel.js.
2576
+ const { rightPanel, controls, layerPanel } = buildRightPanel({
2577
+ controlsHTML: _controlsHTML,
2578
+ layerPanelHTML: _layerPanelHTML,
2579
+ });
2580
+ editorBody.appendChild(rightPanel);
2581
+ container.appendChild(editorBody);
2582
+ _wireInpaintPopoverWindow();
2583
+
2584
+ // Slider UX (expand-while-using, floating bubble, click-to-type) —
2585
+ // full implementation in editor/slider-ux.js.
2586
+ wireSliderUx({ registerDocClickAway: _registerDocClickAway });
2587
+
2588
+ // Shortcuts cheatsheet popover — full implementation in
2589
+ // editor/shortcuts-popover.js. (Dead `_makeShortcutsDraggable`
2590
+ // helper for the old centered-modal version was dropped.)
2591
+ const _shortcutsPopover = createShortcutsPopover();
2592
+ const _toggleShortcuts = _shortcutsPopover.toggleShortcuts;
2593
+ document.getElementById('ge-shortcuts-btn')?.addEventListener('click', () => _toggleShortcuts());
2594
+
2595
+ // Dismiss-listeners for the inpaint popup are attached lazily by
2596
+ // _showInpaintPrompt() and removed by _dismissInpaintPrompt(), so the
2597
+ // active-edit path doesn't pay for them on every event. (Listening on
2598
+ // mousedown/wheel/touchstart/input/change at capture phase, even with
2599
+ // a fast `closest()` check, added up to noticeable lag during heavy
2600
+ // brush use.)
2601
+
2602
+ // Wire up controls
2603
+ controls.querySelector('.ge-color-picker').addEventListener('input', (e) => { state.color = e.target.value; });
2604
+ // Swap the editor's native color inputs for the in-house HSV picker
2605
+ // we built in the theme system — eyedropper, suggestions, recents,
2606
+ // no native OS dialog. Each picker keeps its existing `input` event
2607
+ // wiring so callers just keep reading `e.target.value`.
2608
+ controls.querySelectorAll('.ge-color-picker').forEach(attachColorPicker);
2609
+ controls.querySelectorAll('.ge-color-picker').forEach(el => {
2610
+ // Set the initial swatch background so it reflects the starting value.
2611
+ el.value = el.value;
2612
+ });
2613
+ // Hide brush controls initially (default tool is Move)
2614
+ const initBrushCtrl = document.getElementById('ge-brush-controls');
2615
+ if (initBrushCtrl) initBrushCtrl.style.display = 'none';
2616
+ // Brush-size slider is exponential — slider position 0..1000 maps to
2617
+ // brush size 1..800 via Math.pow(800, pos/1000). This gives fine
2618
+ // control at small sizes (where precision matters most) and bigger
2619
+ // jumps at the high end (where +/-50 px is barely visible anyway).
2620
+ // We expose two sliders (global brush-controls + inpaint section) and
2621
+ // keep them in sync via _brushSizeSync.
2622
+ function _brushSizeSync(source) {
2623
+ const globalLabel = controls.querySelector('.ge-size-label');
2624
+ const globalInput = controls.querySelector('.ge-size-slider');
2625
+ const inpaintLabel = document.getElementById('ge-inpaint-brush-label');
2626
+ const inpaintInput = document.getElementById('ge-inpaint-brush-slider');
2627
+ const pos = Math.round(Math.log(Math.max(1, state.brushSize)) / Math.log(800) * 1000);
2628
+ if (globalLabel) globalLabel.textContent = state.brushSize + 'px';
2629
+ if (inpaintLabel) inpaintLabel.textContent = state.brushSize + 'px';
2630
+ if (globalInput && source !== globalInput) globalInput.value = String(pos);
2631
+ if (inpaintInput && source !== inpaintInput) inpaintInput.value = String(pos);
2632
+ }
2633
+ function _wireBrushSlider(el) {
2634
+ if (!el) return;
2635
+ el.addEventListener('input', (e) => {
2636
+ const pos = parseInt(e.target.value, 10);
2637
+ state.brushSize = Math.max(1, Math.round(Math.pow(800, pos / 1000)));
2638
+ _brushSizeSync(e.target);
2639
+ });
2640
+ }
2641
+ _wireBrushSlider(controls.querySelector('.ge-size-slider'));
2642
+ _wireBrushSlider(document.getElementById('ge-inpaint-brush-slider'));
2643
+ // Topbar wiring (undo/redo/history, Save dropdown, zoom buttons,
2644
+ // Export/Download/Project, Edge popup, cross-dropdown coordination) —
2645
+ // full implementation in editor/wire-topbar.js.
2646
+ wireTopbar({
2647
+ undo, redo,
2648
+ toggleHistoryPanel: _toggleHistoryPanel,
2649
+ fitZoom: () => _fitZoom(),
2650
+ applyZoom: () => _applyZoom(),
2651
+ exportToGallery, downloadPNG,
2652
+ saveProject: () => _saveProject(),
2653
+ loadProjectPrompt: () => _loadProjectPrompt(),
2654
+ activeLayer,
2655
+ saveState: _saveState,
2656
+ applyEdgeFeather: _applyEdgeFeather,
2657
+ composite,
2658
+ registerDocClickAway: _registerDocClickAway,
2659
+ uiModule,
2660
+ });
2661
+ // Fill — visible only when a selection or active mask exists. Pours
2662
+ // the current colour into whichever target is live:
2663
+ // - active mask sub-layer → fills the layer's pixels clipped by
2664
+ // the mask (uses mask alpha as a stencil).
2665
+ // - lasso closed → fills the polygon area on the active layer.
2666
+ // - wand selection → fills the wand mask area on the active layer.
2667
+ // Fill — invoked from the Image menu's "Fill selection / mask" item.
2668
+ // Pours the current colour into whichever target is live:
2669
+ // - active mask sub-layer → fills the layer's pixels clipped by
2670
+ // the mask (uses mask alpha as a stencil).
2671
+ // - lasso closed → fills the polygon area on the active layer.
2672
+ // - wand selection → fills the wand mask area on the active layer.
2673
+ function _doFillSelection() {
2674
+ const layer = activeLayer();
2675
+ if (!layer || layer.locked) return;
2676
+ const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
2677
+ const w = layer.canvas.width;
2678
+ const h = layer.canvas.height;
2679
+ const mask = _getActiveMaskLayer();
2680
+ const hasLasso = state.lassoPoints.length >= 3 && !state.lassoActive;
2681
+ const stencil = document.createElement('canvas');
2682
+ stencil.width = w; stencil.height = h;
2683
+ const sctx = stencil.getContext('2d');
2684
+ if (mask) {
2685
+ sctx.drawImage(mask.canvas, -off.x, -off.y);
2686
+ } else if (hasLasso) {
2687
+ const feather = parseInt(document.getElementById('ge-lasso-feather')?.value || '0');
2688
+ const grow = parseInt(document.getElementById('ge-lasso-grow')?.value || '0');
2689
+ sctx.drawImage(_buildLassoMask(w, h, off.x, off.y, feather, grow), 0, 0);
2690
+ } else if (state.wandMask) {
2691
+ sctx.drawImage(state.wandMask, 0, 0);
2692
+ } else {
2693
+ return;
2694
+ }
2695
+ _saveState('Fill selection');
2696
+ sctx.globalCompositeOperation = 'source-in';
2697
+ sctx.fillStyle = state.color;
2698
+ sctx.fillRect(0, 0, w, h);
2699
+ sctx.globalCompositeOperation = 'source-over';
2700
+ layer.ctx.drawImage(stencil, 0, 0);
2701
+ composite();
2702
+ _renderLayerPanel();
2703
+ if (uiModule) uiModule.showToast('Filled');
2704
+ }
2705
+
2706
+ // AI model selectors (Gen, Inpaint, per-tool) — full
2707
+ // implementation in editor/ai-models.js.
2708
+ wireAIModelSelectors({
2709
+ container,
2710
+ apiBase: API_BASE,
2711
+ openCookbookForImg2img: () => _openCookbookForImg2img(),
2712
+ });
2713
+
2714
+ document.getElementById('ge-save').addEventListener('click', async () => {
2715
+ if (!state.imageId) {
2716
+ await exportToGallery();
2717
+ return;
2718
+ }
2719
+ const endBusy = _saveButtonBusy('Saving…');
2720
+ let blob = null;
2721
+ let savedOk = false;
2722
+ const t0 = performance.now();
2723
+ try {
2724
+ // Encode directly from the flattened canvas via toBlob() to avoid
2725
+ // the dataURL round-trip (which doubles peak memory). Pick JPEG for
2726
+ // photo sources so 24MP uploads don't balloon to 200MB+ PNG —
2727
+ // critical when the editor is accessed over Tailscale Funnel etc.
2728
+ const flat = flatten();
2729
+ const ext = (state.originalExt || 'png').toLowerCase();
2730
+ const isJpeg = ext === 'jpg' || ext === 'jpeg';
2731
+ const mime = isJpeg ? 'image/jpeg' : 'image/png';
2732
+ const quality = isJpeg ? 0.92 : undefined;
2733
+ blob = await new Promise((resolve, reject) => {
2734
+ flat.toBlob(b => b ? resolve(b) : reject(new Error('Canvas encode failed')), mime, quality);
2735
+ });
2736
+ const fd = new FormData();
2737
+ fd.append('image', blob, `edited.${isJpeg ? 'jpg' : 'png'}`);
2738
+ const resp = await fetch(`${API_BASE}/api/gallery/${state.imageId}/replace`, {
2739
+ method: 'POST',
2740
+ credentials: 'same-origin',
2741
+ body: fd,
2742
+ });
2743
+ if (!resp.ok) {
2744
+ let detail = '';
2745
+ try { const j = await resp.json(); detail = j.detail || j.error || ''; } catch {}
2746
+ throw new Error(`HTTP ${resp.status}${detail ? `: ${detail}` : ''}`);
2747
+ }
2748
+ const totalMs = Math.round(performance.now() - t0);
2749
+ if (uiModule) uiModule.showToast(`Saved over original (${(blob.size / 1024 / 1024).toFixed(1)}MB · ${(totalMs / 1000).toFixed(1)}s)`, 4000);
2750
+ window.dispatchEvent(new CustomEvent('gallery-refresh'));
2751
+ savedOk = true;
2752
+ } catch (e) {
2753
+ console.error('[save] error:', e);
2754
+ const sizeMB = blob ? ` (${(blob.size / 1024 / 1024).toFixed(1)}MB)` : '';
2755
+ let msg = e?.message || 'unknown';
2756
+ if (e?.name === 'TypeError' || /fetch|network|load failed/i.test(msg)) {
2757
+ msg = `network dropped${sizeMB} — try "Save as copy" or check connection`;
2758
+ } else {
2759
+ msg += sizeMB;
2760
+ }
2761
+ if (uiModule) uiModule.showToast('Failed to save: ' + msg, 6000);
2762
+ } finally {
2763
+ endBusy();
2764
+ if (savedOk) _flashSaveButtonOk();
2765
+ }
2766
+ });
2767
+
2768
+ // Topbar overflow + canvas-size badge — full implementation in
2769
+ // editor/wire-topbar-overflow.js.
2770
+ wireTopbarOverflow({ container, registerDocClickAway: _registerDocClickAway });
2771
+
2772
+ // Topbar dropdown menus (Image, Filter, Resize) + the resize-canvas
2773
+ // helpers — full implementation in editor/wire-topbar-menus.js. The
2774
+ // returned `_resizeCustomPrompt` is consumed by the keyboard
2775
+ // shortcuts module (Ctrl+Shift+T).
2776
+ const { resizeCustomPrompt: _resizeCustomPrompt } = wireTopbarMenus({
2777
+ closeOtherTopbarMenus: _closeOtherTopbarMenus,
2778
+ registerDocClickAway: _registerDocClickAway,
2779
+ saveState: _saveState,
2780
+ composite,
2781
+ fitZoom: () => _fitZoom(),
2782
+ promptCanvasSize: (opts) => _promptCanvasSize(opts),
2783
+ doFillSelection: () => _doFillSelection(),
2784
+ rotateAllLayers: (deg) => _rotateAllLayers(deg),
2785
+ flipAllLayers: (axis) => _flipAllLayers(axis),
2786
+ applyGaussianBlur: () => _applyGaussianBlur(),
2787
+ applyZoomBlur: () => _applyZoomBlur(),
2788
+ uiModule,
2789
+ });
2790
+
2791
+ // Inpaint side-panel controls (Feather/Strength previews, post-gen
2792
+ // edge tuner, mask vis/invert/clear, paint-erase toggle, mask tint
2793
+ // pickers) — full implementation in editor/wire-inpaint-controls.js.
2794
+ wireInpaintControls({
2795
+ composite,
2796
+ applyInpaintFeather: _applyInpaintFeather,
2797
+ syncToolClearIndicators: () => _syncToolClearIndicators(),
2798
+ attachColorPicker,
2799
+ uiModule,
2800
+ });
2801
+
2802
+ // AI inpaint (Generate / Remove / Outpaint) — full implementation
2803
+ // in editor/ai-inpaint.js.
2804
+ wireInpaintButtons({
2805
+ buildMergedMaskCanvas: () => _buildMergedMaskCanvas(),
2806
+ dilateMask: _dilateMask,
2807
+ applyInpaintFeather: _applyInpaintFeather,
2808
+ getSelectedAIEndpoint: (type) => _getSelectedAIEndpoint(type),
2809
+ ensureActiveMaskLayer: () => _ensureActiveMaskLayer(),
2810
+ saveState: _saveState,
2811
+ createLayer,
2812
+ composite,
2813
+ renderLayerPanel: () => _renderLayerPanel(),
2814
+ spinnerModule,
2815
+ uiModule,
2816
+ });
2817
+
2818
+ // Per-tool Opacity / Flow / Softness sliders (Eraser / Brush /
2819
+ // Clone) — full implementation in editor/stroke-tool-sliders.js.
2820
+ wireStrokeToolSliders();
2821
+
2822
+ // Sharpen + Bg Remove + edge cleanup — full implementation in
2823
+ // editor/ai-rembg.js. Returns the selection-hint-mask builder so
2824
+ // the wand-rembg button (in the wand controls section) can reuse it.
2825
+ const { buildSelectionHintMask: _buildSelectionHintMask } = wireRembgAndSharpen({
2826
+ applyImageTool: _applyImageTool,
2827
+ openCookbookForDependency: (pkg) => _openCookbookForDependency(pkg),
2828
+ composite,
2829
+ renderLayerPanel: () => _renderLayerPanel(),
2830
+ uiModule,
2831
+ });
2832
+
2833
+ // Image import (topbar / panel File / Clipboard / Gallery picker) —
2834
+ // full implementation in editor/wire-import.js. Returns the shared
2835
+ // handleImportedImage sink so drag-drop wires through the same path.
2836
+ const { handleImportedImage: _handleImportedImage } = wireImport({
2837
+ container,
2838
+ saveState: _saveState,
2839
+ createLayer,
2840
+ composite,
2841
+ renderLayerPanel: () => _renderLayerPanel(),
2842
+ uiModule,
2843
+ });
2844
+
2845
+ // Harmonize / Canvas Upscale / AI Upscale / Style Transfer +
2846
+ // Add-Empty-Layer — full implementation in editor/ai-tools-misc.js.
2847
+ const { addEmptyLayer: _addEmptyLayer } = wireAIToolsMisc({
2848
+ apiBase: API_BASE,
2849
+ buildLayerBodyMask: _buildLayerBodyMask,
2850
+ buildSeamMask: _buildSeamMask,
2851
+ applyImageTool: _applyImageTool,
2852
+ flatten,
2853
+ saveState: _saveState,
2854
+ fitZoom: () => _fitZoom(),
2855
+ composite,
2856
+ createLayer,
2857
+ renderLayerPanel: () => _renderLayerPanel(),
2858
+ spinnerModule,
2859
+ uiModule,
2860
+ });
2861
+ // (Merge dropdown removed — Merge Down / Merge All / Flatten Copy
2862
+ // are now three inline icon buttons in the layers header next to
2863
+ // + Add. Their individual click handlers below already bind by id.)
2864
+
2865
+ // Lasso + Magic Wand panel controls — full implementation in
2866
+ // editor/wire-selection-controls.js.
2867
+ wireSelectionControls({
2868
+ composite,
2869
+ invertSelection: _invertSelection,
2870
+ lassoDeleteSelection: _lassoDeleteSelection,
2871
+ lassoCopyToLayer: _lassoCopyToLayer,
2872
+ lassoToMask: _lassoToMask,
2873
+ runMagicWand: (x, y, mode, opts) => _runMagicWand(x, y, mode, opts),
2874
+ wandClear: _wandClear,
2875
+ wandDeleteSelection: _wandDeleteSelection,
2876
+ wandCopyToNewLayer: _wandCopyToNewLayer,
2877
+ wandToMask: _wandToMask,
2878
+ buildSelectionHintMask: _buildSelectionHintMask,
2879
+ applyImageTool: _applyImageTool,
2880
+ uiModule,
2881
+ });
2882
+
2883
+ // Merge / Flatten buttons (layer-panel footer) — full
2884
+ // implementation in editor/wire-merge-buttons.js.
2885
+ wireMergeButtons({
2886
+ saveState: _saveState,
2887
+ createLayer,
2888
+ renderLayerPanel: () => _renderLayerPanel(),
2889
+ composite,
2890
+ uiModule,
2891
+ });
2892
+
2893
+ // Capture-phase Escape interceptor — runs BEFORE any bubble-phase
2894
+ // handler (gallery, keyboard-shortcuts module, etc.) so cancelling a
2895
+ // crop / lasso / transform inside the editor can't ever bubble up and
2896
+ // accidentally close the gallery modal.
2897
+ document.addEventListener('keydown', (e) => {
2898
+ if (!state.editorOpen) return;
2899
+ // Esc on the shortcuts overlay closes it; takes priority over the
2900
+ // other modal cancels so the cheatsheet feels responsive AND so the
2901
+ // gallery's own Esc handler doesn't fire and close gallery instead.
2902
+ if (e.key === 'Escape' && _shortcutsPopover.isOpen()) {
2903
+ e.preventDefault();
2904
+ e.stopPropagation();
2905
+ e.stopImmediatePropagation();
2906
+ _toggleShortcuts(false);
2907
+ return;
2908
+ }
2909
+ // Enter accepts an active crop (same as the Apply button). Skip when
2910
+ // typing in a field — the crop W/H inputs handle their own Enter, and
2911
+ // we don't want to hijack Enter elsewhere.
2912
+ if (e.key === 'Enter' && state.cropRect && !state.cropping && !state.cropMoving) {
2913
+ const t = e.target;
2914
+ if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
2915
+ e.preventDefault();
2916
+ e.stopPropagation();
2917
+ _applyCrop();
2918
+ return;
2919
+ }
2920
+ if (e.key !== 'Escape') return;
2921
+ // Escape is disabled inside Gallery Edit. It must not close the
2922
+ // editor, close Gallery, or cancel active editor state.
2923
+ e.preventDefault();
2924
+ e.stopPropagation();
2925
+ e.stopImmediatePropagation();
2926
+ }, true);
2927
+
2928
+ // Keyboard shortcuts — full implementation in
2929
+ // editor/keyboard-shortcuts.js.
2930
+ wireKeyboardShortcuts({
2931
+ toolbar, toolKeyMap: _toolKeyMap,
2932
+ composite, saveState: _saveState, undo, redo,
2933
+ toggleShortcuts: _toggleShortcuts,
2934
+ confirmTransform: _confirmTransform,
2935
+ cancelTransform: _cancelTransform,
2936
+ startTransform: _startTransform,
2937
+ resizeCustomPrompt: _resizeCustomPrompt,
2938
+ addEmptyLayer: _addEmptyLayer,
2939
+ brushSizeSync: _brushSizeSync,
2940
+ invertSelection: _invertSelection,
2941
+ wandDeleteSelection: _wandDeleteSelection,
2942
+ wandCopyToNewLayer: _wandCopyToNewLayer,
2943
+ lassoDeleteSelection: _lassoDeleteSelection,
2944
+ lassoCopyToLayer: _lassoCopyToLayer,
2945
+ lassoToMask: _lassoToMask,
2946
+ buildLassoMask: _buildLassoMask,
2947
+ drawLassoOverlay: _drawLassoOverlay,
2948
+ activeLayer,
2949
+ uiModule,
2950
+ });
2951
+ container.setAttribute('tabindex', '0');
2952
+
2953
+ // Paste + drag-and-drop image import — full implementation in
2954
+ // editor/clipboard-and-drop.js.
2955
+ wireClipboardAndDrop({
2956
+ container,
2957
+ saveState: _saveState,
2958
+ createLayer,
2959
+ renderLayerPanel: () => _renderLayerPanel(),
2960
+ composite,
2961
+ handleImportedImage: (img) => _handleImportedImage(img),
2962
+ uiModule,
2963
+ });
2964
+ }
2965
+
2966
+ // ── Layer panel rendering ──
2967
+
2968
+ // Layer-panel renderer — implementation in editor/layer-panel.js.
2969
+ // Wrap to the legacy name so the dozens of `_renderLayerPanel()` call
2970
+ // sites scattered across the file keep working unchanged.
2971
+ const _layerPanelRenderer = createLayerPanelRenderer({
2972
+ composite,
2973
+ saveState: _saveState,
2974
+ showLayerThumb: (row, layer) => _showLayerThumb(row, layer),
2975
+ hideLayerThumb: () => _hideLayerThumb(),
2976
+ loadLayerAlphaAsSelection: (layer) => _loadLayerAlphaAsSelection(layer),
2977
+ openFxPopup: (layer, anchor) => _openFxPopup(layer, anchor),
2978
+ editAdjLayer: (layer, adj, anchor) => _editAdjLayer(layer, adj, anchor),
2979
+ createLayer,
2980
+ lassoToMask: () => _lassoToMask(),
2981
+ wandToMask: () => _wandToMask(),
2982
+ getActiveMaskLayer: () => _getActiveMaskLayer(),
2983
+ syncFxPanelToActiveLayerIfPresent: () => _syncFxPanelToActiveLayerIfPresent(),
2984
+ dragSortModule,
2985
+ uiModule,
2986
+ });
2987
+ function _renderLayerPanel() { return _layerPanelRenderer.render(); }
2988
+
2989
+ function _revealLayerPanel() {
2990
+ requestAnimationFrame(() => {
2991
+ const panel = state.container?.querySelector?.('.ge-right-panel') ||
2992
+ document.querySelector('.ge-right-panel');
2993
+ if (!panel) return;
2994
+ panel.classList.remove('minimized');
2995
+ panel.classList.add('expanded');
2996
+ });
2997
+ }
2998
+
2999
+ // ── Flatten / Export ──
3000
+
3001
+ function flatten() {
3002
+ const out = document.createElement('canvas');
3003
+ out.width = state.imgWidth;
3004
+ out.height = state.imgHeight;
3005
+ const ctx = out.getContext('2d');
3006
+ for (const layer of state.layers) {
3007
+ if (!layer.visible) continue;
3008
+ ctx.globalAlpha = layer.opacity;
3009
+ const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
3010
+ ctx.drawImage(layer.canvas, off.x, off.y);
3011
+ }
3012
+ ctx.globalAlpha = 1;
3013
+ return out;
3014
+ }
3015
+
3016
+ // Build the union of all "foreground" visible-layer alphas (binary).
3017
+ // "Background" = the BOTTOMMOST visible layer (Harmonize's colour-match
3018
+ // reference). Everything visible ABOVE it = foreground that goes into
3019
+ // the body mask. Independent of the `isBase` flag, so reordering layers
3020
+ // (or hiding the original photo after a bg-remove) doesn't break the
3021
+ // semantics.
3022
+ // Harmonize-pipeline mask builders live in editor/harmonize-masks.js.
3023
+ // Thin wrappers translate module state into the pure helpers.
3024
+ function _harmonizeLayerList() {
3025
+ return state.layers.map(l => ({
3026
+ visible: l.visible,
3027
+ id: l.id,
3028
+ canvas: l.canvas,
3029
+ offset: state.layerOffsets.get(l.id) || { x: 0, y: 0 },
3030
+ }));
3031
+ }
3032
+ function _buildLayerUnionAlpha() { return _layerUnionAlphaImpl(state.imgWidth, state.imgHeight, _harmonizeLayerList()); }
3033
+ function _buildSeamMask(featherPx = 12) { return _seamMaskImpl(state.imgWidth, state.imgHeight, _harmonizeLayerList(), featherPx); }
3034
+ function _buildLayerBodyMask(featherPx = 12) { return _layerBodyMaskImpl(state.imgWidth, state.imgHeight, _harmonizeLayerList(), featherPx); }
3035
+
3036
+ export function exportPNG() {
3037
+ return flatten().toDataURL('image/png');
3038
+ }
3039
+
3040
+ // Briefly turn the Save button green with a checkmark so the user can't
3041
+ // miss a successful save (the toast alone is easy to miss on remote
3042
+ // connections where focus drifts during the upload).
3043
+ function _flashSaveButtonOk() {
3044
+ const btn = document.getElementById('ge-save-menu-btn');
3045
+ if (!btn) return;
3046
+ const origHTML = btn.innerHTML;
3047
+ const origBg = btn.style.background;
3048
+ btn.style.background = '#3aa75a';
3049
+ btn.style.color = '#fff';
3050
+ btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-3px;margin-right:4px;"><polyline points="20 6 9 17 4 12"/></svg>Saved';
3051
+ setTimeout(() => {
3052
+ btn.style.background = origBg;
3053
+ btn.style.color = '';
3054
+ btn.innerHTML = origHTML;
3055
+ }, 1800);
3056
+ }
3057
+
3058
+ // Show whirlpool + label on the visible "Save ▾" topbar button while a
3059
+ // save operation runs. Returns a function to call when done (or in finally).
3060
+ function _saveButtonBusy(label) {
3061
+ const btn = document.getElementById('ge-save-menu-btn');
3062
+ if (!btn) return () => {};
3063
+ const origHTML = btn.innerHTML;
3064
+ const origWidth = btn.offsetWidth;
3065
+ btn.disabled = true;
3066
+ btn.style.minWidth = origWidth + 'px';
3067
+ btn.innerHTML = '';
3068
+ let sp = null;
3069
+ try {
3070
+ sp = spinnerModule.create('', 'clean', 'whirlpool');
3071
+ btn.appendChild(sp.createElement());
3072
+ const txt = document.createElement('span');
3073
+ txt.className = 'ge-btn-busy-label';
3074
+ txt.textContent = label || 'Saving…';
3075
+ btn.appendChild(txt);
3076
+ sp.start();
3077
+ } catch { btn.textContent = label || 'Saving…'; }
3078
+ return () => {
3079
+ try { sp && sp.stop && sp.stop(); } catch {}
3080
+ btn.disabled = false;
3081
+ btn.innerHTML = origHTML;
3082
+ btn.style.minWidth = '';
3083
+ };
3084
+ }
3085
+
3086
+ export async function exportToGallery() {
3087
+ const endBusy = _saveButtonBusy('Saving copy…');
3088
+ let blob = null;
3089
+ let savedOk = false;
3090
+ const t0 = performance.now();
3091
+ try {
3092
+ // toBlob() avoids the 2x peak memory of dataURL → fetch → blob. JPEG
3093
+ // re-encode for camera photos keeps uploads small enough to make it
3094
+ // through remote tunnels.
3095
+ const flat = flatten();
3096
+ const ext = (state.originalExt || 'png').toLowerCase();
3097
+ const isJpeg = ext === 'jpg' || ext === 'jpeg';
3098
+ const mime = isJpeg ? 'image/jpeg' : 'image/png';
3099
+ const quality = isJpeg ? 0.92 : undefined;
3100
+ blob = await new Promise((resolve, reject) => {
3101
+ flat.toBlob(b => b ? resolve(b) : reject(new Error('Canvas encode failed')), mime, quality);
3102
+ });
3103
+ const formData = new FormData();
3104
+ formData.append('file', blob, `edited.${isJpeg ? 'jpg' : 'png'}`);
3105
+
3106
+ const saveRes = await fetch(`${API_BASE}/api/gallery/upload`, {
3107
+ method: 'POST',
3108
+ credentials: 'same-origin',
3109
+ body: formData,
3110
+ });
3111
+ if (!saveRes.ok) {
3112
+ const errBody = await saveRes.text().catch(() => '');
3113
+ throw new Error(`HTTP ${saveRes.status}: ${errBody.substring(0, 120)}`);
3114
+ }
3115
+ const totalMs = Math.round(performance.now() - t0);
3116
+ window.dispatchEvent(new CustomEvent('gallery-refresh'));
3117
+ if (uiModule) uiModule.showToast(`Saved copy to gallery (${(blob.size / 1024 / 1024).toFixed(1)}MB · ${(totalMs / 1000).toFixed(1)}s)`, 4000);
3118
+ savedOk = true;
3119
+ if (state.draftId) {
3120
+ _clearDraftServer(state.draftId);
3121
+ state.draftId = null;
3122
+ }
3123
+ } catch (e) {
3124
+ console.error('[save-as-copy] error:', e);
3125
+ const sizeMB = blob ? ` (${(blob.size / 1024 / 1024).toFixed(1)}MB)` : '';
3126
+ let msg = e?.message || 'unknown';
3127
+ if (e?.name === 'TypeError' || /fetch|network|load failed/i.test(msg)) {
3128
+ msg = `network dropped${sizeMB} — check connection`;
3129
+ } else {
3130
+ msg += sizeMB;
3131
+ }
3132
+ if (uiModule) uiModule.showToast('Save failed: ' + msg, 6000);
3133
+ } finally {
3134
+ endBusy();
3135
+ if (savedOk) _flashSaveButtonOk();
3136
+ }
3137
+ }
3138
+
3139
+ // Open the Cookbook modal scoped to img2img-capable models so the user
3140
+ // can serve one in a few clicks. Falls back to plain Cookbook if the
3141
+ // filter hook isn't available.
3142
+ // Open Cookbook on its Dependencies tab and highlight a specific
3143
+ // package row. Used for "rembg not installed" → install path.
3144
+ function _openCookbookForDependency(pkgName) {
3145
+ // Use cookbookModule.open({ tab: 'Dependencies' }) so the intent is
3146
+ // honored after Cookbook's async render. The old path clicked the
3147
+ // sidebar button + polled for the modal, but Cookbook's _renderRecipes
3148
+ // runs AFTER an awaited _syncFromServer, so depsTab.click() often
3149
+ // raced and the user landed on Download.
3150
+ const cookbook = window.cookbookModule;
3151
+ if (!cookbook || typeof cookbook.open !== 'function') {
3152
+ // Fall back to the old click-then-poll path if the module isn't
3153
+ // on window for some reason.
3154
+ const btn = document.getElementById('tool-cookbook-btn');
3155
+ if (btn) btn.click();
3156
+ else if (uiModule) uiModule.showToast(`Open Cookbook to install ${pkgName}`, 6000);
3157
+ return;
3158
+ }
3159
+ cookbook.open({ tab: 'Dependencies' });
3160
+ // Now wait for the Dependencies group to render, switch the server
3161
+ // selector to Local, and highlight the package row.
3162
+ const cb = document.getElementById('cookbook-modal');
3163
+ if (cb) cb.style.zIndex = 260;
3164
+ const tryServer = (attempt = 0) => {
3165
+ const serverSel = document.getElementById('hwfit-deps-server');
3166
+ if (!serverSel) {
3167
+ if (attempt < 25) return setTimeout(() => tryServer(attempt + 1), 80);
3168
+ return;
3169
+ }
3170
+ if (serverSel.value !== 'local') {
3171
+ serverSel.value = 'local';
3172
+ serverSel.dispatchEvent(new Event('change', { bubbles: true }));
3173
+ }
3174
+ };
3175
+ tryServer();
3176
+ const tryHighlight = (a2 = 0) => {
3177
+ const rows = document.querySelectorAll('[data-pkg-name]');
3178
+ if (!rows.length) {
3179
+ if (a2 < 40) return setTimeout(() => tryHighlight(a2 + 1), 100);
3180
+ return;
3181
+ }
3182
+ const row = Array.from(rows).find(r => (r.dataset.pkgName || '').toLowerCase() === pkgName.toLowerCase());
3183
+ if (row) {
3184
+ row.scrollIntoView({ block: 'center' });
3185
+ row.classList.add('cookbook-pkg-flash');
3186
+ setTimeout(() => row.classList.remove('cookbook-pkg-flash'), 2000);
3187
+ }
3188
+ };
3189
+ tryHighlight();
3190
+ }
3191
+
3192
+ // Async check whether `rembg` is installed on the Zero server.
3193
+ // Toggles the "install rembg" notice + the Bg Remove run button. The
3194
+ // `/api/cookbook/packages` endpoint is cheap (importlib calls only).
3195
+ async function _checkRembgInstalled() {
3196
+ const noticeEl = document.getElementById('ge-rembg-dep-missing');
3197
+ const runRow = document.getElementById('ge-rembg-run-row');
3198
+ if (!noticeEl || !runRow) return;
3199
+ // Use cached result if we already checked this editor session.
3200
+ if (state.rembgInstalledCache !== null) {
3201
+ noticeEl.style.display = state.rembgInstalledCache ? 'none' : '';
3202
+ runRow.style.display = state.rembgInstalledCache ? '' : 'none';
3203
+ return;
3204
+ }
3205
+ try {
3206
+ const r = await fetch('/api/cookbook/packages', { credentials: 'same-origin' });
3207
+ if (!r.ok) throw new Error('packages query failed');
3208
+ const data = await r.json();
3209
+ const pkg = (data.packages || []).find(p => (p.name || '').toLowerCase() === 'rembg');
3210
+ state.rembgInstalledCache = pkg ? !!pkg.installed : null;
3211
+ } catch (e) {
3212
+ state.rembgInstalledCache = null; // unknown — fall back to silent
3213
+ }
3214
+ if (state.rembgInstalledCache === false) {
3215
+ noticeEl.style.display = '';
3216
+ runRow.style.display = 'none';
3217
+ } else {
3218
+ noticeEl.style.display = 'none';
3219
+ runRow.style.display = '';
3220
+ }
3221
+ }
3222
+
3223
+ function _openCookbookForImg2img() {
3224
+ // Try multiple openers in order — the sidebar button may be hidden on
3225
+ // mobile so we fall back to the rail button, then to modalManager.
3226
+ let opened = false;
3227
+ const btn = document.getElementById('tool-cookbook-btn');
3228
+ const railBtn = document.getElementById('rail-cookbook');
3229
+ if (btn && btn.offsetParent !== null) { btn.click(); opened = true; }
3230
+ else if (railBtn) { railBtn.click(); opened = true; }
3231
+ else { try { modalManager.restore('cookbook-modal'); opened = true; } catch {} }
3232
+ if (opened) {
3233
+ // Two-stage navigation: 1) wait for modal mount, 2) click Serve tab,
3234
+ // 3) after the serve tag chips render, click the "image" one.
3235
+ const tryServe = (attempt = 0) => {
3236
+ const cb = document.getElementById('cookbook-modal');
3237
+ const serveTab = cb ? cb.querySelector('.cookbook-tab[data-backend="Serve"]') : null;
3238
+ // Retry until BOTH the modal mounts AND its tab bar has rendered.
3239
+ // Cookbook builds its body html after the modal opens, so we need
3240
+ // to wait a bit longer than just "modal exists".
3241
+ if (!cb || !serveTab) {
3242
+ if (attempt < 40) return setTimeout(() => tryServe(attempt + 1), 80);
3243
+ return;
3244
+ }
3245
+ cb.style.zIndex = 260;
3246
+ serveTab.click();
3247
+ // Now wait for the serve-tags container to populate (it lazy-loads
3248
+ // after the cached-models fetch resolves) and click the image chip.
3249
+ const tryImageFilter = (a2 = 0) => {
3250
+ const tags = document.getElementById('serve-tags');
3251
+ if (!tags || !tags.querySelector('.memory-cat-chip')) {
3252
+ if (a2 < 20) return setTimeout(() => tryImageFilter(a2 + 1), 100);
3253
+ return;
3254
+ }
3255
+ const imgChip = Array.from(tags.querySelectorAll('.memory-cat-chip'))
3256
+ .find(c => /^image$/i.test(c.dataset.serveTag || '') || /image/i.test(c.textContent || ''));
3257
+ if (imgChip) imgChip.click();
3258
+ };
3259
+ tryImageFilter();
3260
+ };
3261
+ tryServe();
3262
+ return;
3263
+ }
3264
+ if (uiModule) uiModule.showToast('Open Cookbook from the sidebar to serve an img2img model', 6000);
3265
+ }
3266
+
3267
+ export function downloadPNG() {
3268
+ const dataUrl = exportPNG();
3269
+ const a = document.createElement('a');
3270
+ a.href = dataUrl;
3271
+ a.download = 'edited-image.png';
3272
+ a.click();
3273
+ }
3274
+
3275
+ // Save the entire layered editor state as a JSON project file. Each
3276
+ // layer is encoded as a base64 PNG so transparency / partial alpha
3277
+ // survives the round-trip. Use Load Project to restore.
3278
+ function _saveProject() {
3279
+ if (!state.layers.length) {
3280
+ if (uiModule) uiModule.showToast('Nothing to save');
3281
+ return;
3282
+ }
3283
+ const project = {
3284
+ v: 1,
3285
+ type: 'zero-gallery-editor-project',
3286
+ imgWidth: state.imgWidth,
3287
+ imgHeight: state.imgHeight,
3288
+ activeLayerId: state.activeLayerId,
3289
+ nextLayerId: state.nextLayerId,
3290
+ layers: state.layers.map(l => ({
3291
+ id: l.id,
3292
+ name: l.name,
3293
+ visible: l.visible,
3294
+ opacity: l.opacity,
3295
+ locked: l.locked,
3296
+ canvasW: l.canvas.width,
3297
+ canvasH: l.canvas.height,
3298
+ offset: { ...(state.layerOffsets.get(l.id) || { x: 0, y: 0 }) },
3299
+ dataUrl: l.canvas.toDataURL('image/png'),
3300
+ })),
3301
+ };
3302
+ const json = JSON.stringify(project);
3303
+ const blob = new Blob([json], { type: 'application/json' });
3304
+ const url = URL.createObjectURL(blob);
3305
+ const a = document.createElement('a');
3306
+ a.href = url;
3307
+ a.download = 'project.geproj.json';
3308
+ a.click();
3309
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
3310
+ if (uiModule) uiModule.showToast('Project saved', 3000);
3311
+ }
3312
+
3313
+ // Open-file picker for Load Project. Restores layers + canvas size.
3314
+ function _loadProjectPrompt() {
3315
+ const inp = document.createElement('input');
3316
+ inp.type = 'file';
3317
+ inp.accept = 'application/json,.json';
3318
+ inp.addEventListener('change', async () => {
3319
+ const file = inp.files && inp.files[0];
3320
+ if (!file) return;
3321
+ try {
3322
+ const text = await file.text();
3323
+ const proj = JSON.parse(text);
3324
+ if (proj.type !== 'zero-gallery-editor-project') {
3325
+ if (uiModule) uiModule.showToast('Not a project file', 5000);
3326
+ return;
3327
+ }
3328
+ await _restoreDraft(proj);
3329
+ composite();
3330
+ _renderLayerPanel();
3331
+ _fitZoom();
3332
+ if (uiModule) uiModule.showToast('Project loaded', 3000);
3333
+ } catch (e) {
3334
+ if (uiModule) uiModule.showToast('Load failed: ' + (e.message || e), 6000);
3335
+ }
3336
+ });
3337
+ inp.click();
3338
+ }
3339
+
3340
+ // ── Public API ──
3341
+
3342
+ // Styled in-app prompt for canvas size — replaces the browser's
3343
+ // native prompt() which doesn't follow the app theme. Returns a Promise
3344
+ // resolving to {w, h} on submit, or null on cancel. Optional opts:
3345
+ // title, okLabel, initialW, initialH.
3346
+ function _promptCanvasSize(opts) {
3347
+ opts = opts || {};
3348
+ const title = opts.title || 'New canvas';
3349
+ const okLabel = opts.okLabel || 'Create';
3350
+ const initialW = opts.initialW || 1024;
3351
+ const initialH = opts.initialH || 1024;
3352
+ return new Promise(resolve => {
3353
+ let overlay = document.getElementById('ge-canvas-size-overlay');
3354
+ if (!overlay) {
3355
+ overlay = document.createElement('div');
3356
+ overlay.id = 'ge-canvas-size-overlay';
3357
+ overlay.className = 'modal';
3358
+ overlay.innerHTML = _canvasSizePromptHTML();
3359
+ document.body.appendChild(overlay);
3360
+ }
3361
+ overlay.style.display = '';
3362
+ overlay.classList.remove('hidden');
3363
+ const wInput = document.getElementById('ge-canvas-prompt-w');
3364
+ const hInput = document.getElementById('ge-canvas-prompt-h');
3365
+ const okBtn = document.getElementById('ge-canvas-prompt-ok');
3366
+ const cancelBtn = document.getElementById('ge-canvas-prompt-cancel');
3367
+ const titleEl = document.getElementById('ge-canvas-prompt-title');
3368
+ if (titleEl) titleEl.textContent = title;
3369
+ if (okBtn) okBtn.textContent = okLabel;
3370
+ wInput.value = String(initialW);
3371
+ hInput.value = String(initialH);
3372
+ setTimeout(() => { wInput.focus(); wInput.select(); }, 0);
3373
+ function cleanup(result) {
3374
+ overlay.style.display = 'none';
3375
+ okBtn.removeEventListener('click', onOk);
3376
+ cancelBtn.removeEventListener('click', onCancel);
3377
+ overlay.removeEventListener('click', onBackdrop);
3378
+ document.removeEventListener('keydown', onKey);
3379
+ resolve(result);
3380
+ }
3381
+ function onOk() {
3382
+ const dims = _parseCanvasSizePrompt(wInput.value, hInput.value, initialW, initialH);
3383
+ if (!dims) { uiModule.showToast('Invalid size'); return; }
3384
+ cleanup(dims);
3385
+ }
3386
+ function onCancel() { cleanup(null); }
3387
+ function onBackdrop(e) { if (e.target === overlay) cleanup(null); }
3388
+ function onKey(e) {
3389
+ if (e.key === 'Enter') { e.preventDefault(); onOk(); }
3390
+ if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); cleanup(null); }
3391
+ }
3392
+ okBtn.addEventListener('click', onOk);
3393
+ cancelBtn.addEventListener('click', onCancel);
3394
+ overlay.addEventListener('click', onBackdrop);
3395
+ document.addEventListener('keydown', onKey);
3396
+ });
3397
+ }
3398
+
3399
+ function _parseCanvasSizePrompt(widthText, heightText, initialW = 1024, initialH = 1024) {
3400
+ const parseWhole = (value) => {
3401
+ const text = String(value || '').trim();
3402
+ if (!/^\d+$/.test(text)) return null;
3403
+ const n = Number(text);
3404
+ return Number.isSafeInteger(n) && n >= 1 && n <= 8192 ? n : null;
3405
+ };
3406
+ const parseRatio = (value) => {
3407
+ const m = String(value || '').trim().match(/^(\d+(?:\.\d+)?)\s*(?:x|×|:|\/)\s*(\d+(?:\.\d+)?)$/i);
3408
+ if (!m) return null;
3409
+ const rw = Number(m[1]);
3410
+ const rh = Number(m[2]);
3411
+ if (!Number.isFinite(rw) || !Number.isFinite(rh) || rw <= 0 || rh <= 0) return null;
3412
+ const w = Math.max(1, Math.min(8192, Math.round(initialW)));
3413
+ const h = Math.max(1, Math.min(8192, Math.round(w * rh / rw)));
3414
+ return { w, h };
3415
+ };
3416
+ const ratioDims = parseRatio(widthText) || parseRatio(heightText);
3417
+ if (ratioDims) return ratioDims;
3418
+ const w = parseWhole(widthText);
3419
+ const h = parseWhole(heightText);
3420
+ if (!w || !h) return null;
3421
+ return { w, h };
3422
+ }
3423
+
3424
+ // imageUrl=null + presetSize={w,h} → skips the size prompt and creates a
3425
+ // blank canvas at the given dimensions (used by template tiles in the
3426
+ // gallery's Edit-tab landing). `displayName` is optional — when provided,
3427
+ // the Edit tab in the gallery is renamed to "Edit: <name>".
3428
+ // Shared loading-overlay mount/unmount — used by the image-load path AND
3429
+ // the draft-restore paths so every "we're waiting on something" moment
3430
+ // in the editor surfaces the same whirlpool + label instead of a blank
3431
+ // canvas that looks broken.
3432
+ function _mountEditorLoading(label, dims) {
3433
+ if (!state.container) return;
3434
+ const area = state.container.querySelector('.ge-canvas-area');
3435
+ _unmountEditorLoading();
3436
+ // Cover the WHOLE editor (toolbar + canvas + panel), not just the canvas area
3437
+ // — otherwise the toolbar/old content shows above the overlay at the top while
3438
+ // a past project loads, which looks half-rendered.
3439
+ const el = document.createElement('div');
3440
+ el.className = 'ge-loading-overlay ge-loading-overlay-full';
3441
+ // Aspect-ratio placeholder so the user sees the shape of the canvas they're
3442
+ // about to land in. Sized to the canvas area but centered in the overlay.
3443
+ let placeholder = null;
3444
+ if (dims && dims.w > 0 && dims.h > 0 && area) {
3445
+ placeholder = document.createElement('div');
3446
+ placeholder.className = 'ge-canvas-placeholder';
3447
+ const areaRect = area.getBoundingClientRect();
3448
+ const maxW = Math.max(0, areaRect.width - 32);
3449
+ const maxH = Math.max(0, areaRect.height - 32);
3450
+ const ratio = dims.w / dims.h;
3451
+ let w = maxW;
3452
+ let h = w / ratio;
3453
+ if (h > maxH) { h = maxH; w = h * ratio; }
3454
+ placeholder.style.width = w + 'px';
3455
+ placeholder.style.height = h + 'px';
3456
+ el.appendChild(placeholder);
3457
+ }
3458
+ const inner = document.createElement('div');
3459
+ inner.className = 'ge-loading-inner';
3460
+ inner.innerHTML = `<span class="ge-loading-text">${label || 'Loading…'}</span>`;
3461
+ el.appendChild(inner);
3462
+ // Mount on the editor BODY (toolbar + canvas + panel) — it sits below the
3463
+ // gallery's search/select bar, so the cover doesn't bleed up over those.
3464
+ const _mountTarget = state.container.querySelector('.ge-editor-body') || state.container;
3465
+ _mountTarget.appendChild(el);
3466
+ try {
3467
+ const sp = spinnerModule.create('', 'clean', 'whirlpool');
3468
+ inner.insertBefore(sp.createElement(), inner.firstChild);
3469
+ sp.start();
3470
+ el._spinner = sp;
3471
+ } catch {}
3472
+ el._placeholder = placeholder;
3473
+ state.editorLoadingEl = el;
3474
+ }
3475
+ function _unmountEditorLoading() {
3476
+ if (!state.editorLoadingEl) return;
3477
+ try { state.editorLoadingEl._spinner?.destroy(); } catch {}
3478
+ try { state.editorLoadingEl._placeholder?.remove(); } catch {}
3479
+ try { state.editorLoadingEl.remove(); } catch {}
3480
+ state.editorLoadingEl = null;
3481
+ }
3482
+
3483
+ export function openEditor(imageUrl, imageId, presetSize, displayName, draftId) {
3484
+ _setEditTabLabel(displayName || (presetSize ? 'New canvas' : 'Untitled'));
3485
+ state.imageId = imageId || null;
3486
+ // Track original file extension so save-over-original can re-encode in the
3487
+ // same format. JPEG re-encoding cuts upload size 5-10x for camera photos,
3488
+ // which matters over remote tunnels (Tailscale Funnel etc.).
3489
+ try {
3490
+ const m = (imageUrl || '').match(/\.([a-z0-9]{2,5})(?:\?|$)/i);
3491
+ state.originalExt = m ? m[1].toLowerCase() : 'png';
3492
+ } catch { state.originalExt = 'png'; }
3493
+ state.draftId = draftId || null;
3494
+ state.draftName = displayName || (presetSize ? `New ${presetSize.w}×${presetSize.h}` : 'Untitled');
3495
+ state.editorOpen = true;
3496
+ state.layers = [];
3497
+ state.undoStack = [];
3498
+ state.redoStack = [];
3499
+ state.layerOffsets.clear();
3500
+ state.nextLayerId = 1;
3501
+ state.tool = 'move';
3502
+ state.cropRect = null;
3503
+ state.lassoPoints = [];
3504
+ state.lassoActive = false;
3505
+ window.__galleryEditLive = true;
3506
+ if (state.persistTimer) { clearTimeout(state.persistTimer); state.persistTimer = null; }
3507
+ state.persistDirty = false;
3508
+
3509
+ state.container = document.getElementById('gallery-editor-container');
3510
+ if (!state.container) {
3511
+ console.error('[openEditor] #gallery-editor-container not found in DOM — editor cannot open');
3512
+ if (uiModule) uiModule.showError('Editor container missing');
3513
+ return;
3514
+ }
3515
+ state.container.style.display = 'flex';
3516
+
3517
+ try {
3518
+ _buildEditor(state.container);
3519
+ } catch (e) {
3520
+ console.error('[openEditor] _buildEditor threw:', e);
3521
+ if (uiModule) uiModule.showError('Editor failed to build: ' + (e?.message || 'unknown'));
3522
+ return;
3523
+ }
3524
+
3525
+ function _initCanvas(w, h) {
3526
+ state.imgWidth = w;
3527
+ state.imgHeight = h;
3528
+ state.mainCanvas.width = w;
3529
+ state.mainCanvas.height = h;
3530
+ state.maskCanvas = document.createElement('canvas');
3531
+ state.maskCanvas.width = w;
3532
+ state.maskCanvas.height = h;
3533
+ state.maskCtx = state.maskCanvas.getContext('2d');
3534
+ }
3535
+
3536
+ if (!imageUrl && draftId) {
3537
+ // Re-open a saved draft by its server-side id — covers the
3538
+ // "Resume" buttons on the Edit-tab landing.
3539
+ _mountEditorLoading('Loading draft…', presetSize || null);
3540
+ // Bail if the user closes the editor while the async load is in
3541
+ // flight — without this guard, the .then() callbacks fire after
3542
+ // closeEditor and re-mount the spinner / draw into a dead canvas,
3543
+ // leaving "stuck" preview artefacts on the next open.
3544
+ return _loadDraftById(draftId)
3545
+ .then(d => {
3546
+ if (!state.editorOpen) return;
3547
+ if (!d) {
3548
+ _unmountEditorLoading();
3549
+ if (uiModule) uiModule.showToast('Draft not found');
3550
+ closeEditor();
3551
+ return;
3552
+ }
3553
+ state.draftId = d.id;
3554
+ state.draftName = d.name || 'Untitled';
3555
+ _setEditTabLabel(state.draftName);
3556
+ state.imageId = d.source_image_id || null;
3557
+ return _restoreDraft(d).then(() => {
3558
+ if (!state.editorOpen) return;
3559
+ composite();
3560
+ _renderLayerPanel();
3561
+ _fitZoom();
3562
+ const sizeLabel = document.getElementById('ge-canvas-size');
3563
+ if (sizeLabel) sizeLabel.textContent = `${state.imgWidth}×${state.imgHeight}`;
3564
+ _unmountEditorLoading();
3565
+ if (uiModule) uiModule.showToast('Resumed draft');
3566
+ });
3567
+ })
3568
+ .catch(err => {
3569
+ if (!state.editorOpen) return;
3570
+ _unmountEditorLoading();
3571
+ console.warn('[ge] draft load failed', err);
3572
+ if (uiModule) uiModule.showToast('Failed to load draft');
3573
+ closeEditor();
3574
+ });
3575
+ }
3576
+
3577
+ if (!imageUrl) {
3578
+ // Empty canvas — use preset size if supplied, otherwise show the
3579
+ // styled prompt. Asynchronous: we promise-chain so callers can await
3580
+ // openEditor() and still rely on isEditorOpen() afterwards.
3581
+ const _finishBlank = (w, h) => {
3582
+ _initCanvas(w, h);
3583
+ // White-filled Background so the canvas is visible, then a separate
3584
+ // transparent Edit layer on top — keeps user's work isolated from
3585
+ // the underlying canvas, the standard editor pattern.
3586
+ const bgLayer = createLayer('Background', w, h);
3587
+ bgLayer.ctx.fillStyle = '#ffffff';
3588
+ bgLayer.ctx.fillRect(0, 0, w, h);
3589
+ const editLayer = createLayer('Edit', w, h);
3590
+ state.layers.push(bgLayer);
3591
+ state.layers.push(editLayer);
3592
+ state.activeLayerId = editLayer.id;
3593
+ composite();
3594
+ _renderLayerPanel();
3595
+ _fitZoom();
3596
+ // First persist creates the server-side row (blank-canvas drafts).
3597
+ _schedulePersist();
3598
+ };
3599
+ if (presetSize && presetSize.w > 0 && presetSize.h > 0) {
3600
+ _finishBlank(presetSize.w, presetSize.h);
3601
+ return;
3602
+ }
3603
+ return _promptCanvasSize().then(size => {
3604
+ if (!size) { closeEditor(); return; }
3605
+ _finishBlank(size.w, size.h);
3606
+ });
3607
+ }
3608
+
3609
+ // Try to restore a previously-persisted draft for this image — that
3610
+ // way closing the gallery / editor mid-edit doesn't lose progress.
3611
+ // (Server-backed: look up by source_image_id.)
3612
+ _mountEditorLoading('Looking up draft…');
3613
+ _findDraftForImage(imageId).then(_draft => {
3614
+ if (!state.editorOpen) return;
3615
+ if (!_draft) return null;
3616
+ state.draftId = _draft.id;
3617
+ state.draftName = _draft.name || displayName || 'Untitled';
3618
+ const innerLabel = state.editorLoadingEl?.querySelector('.ge-loading-text');
3619
+ if (innerLabel) innerLabel.textContent = 'Resuming draft…';
3620
+ return _restoreDraft(_draft).then(() => {
3621
+ if (!state.editorOpen) return null;
3622
+ // If the draft was broken/empty (0 layers reconstructed), fall
3623
+ // through to loading the source image as a normal edit. Without
3624
+ // this guard the editor would sit empty and the user would be
3625
+ // stuck with no way to recover.
3626
+ if (state.layers.length === 0) {
3627
+ console.warn('[openEditor] draft restored but produced 0 layers — falling back to source image');
3628
+ return null;
3629
+ }
3630
+ composite();
3631
+ _renderLayerPanel();
3632
+ _fitZoom();
3633
+ const sizeLabel = document.getElementById('ge-canvas-size');
3634
+ if (sizeLabel) sizeLabel.textContent = `${state.imgWidth}×${state.imgHeight}`;
3635
+ _unmountEditorLoading();
3636
+ if (uiModule) uiModule.showToast('Resumed previous edit');
3637
+ return 'restored';
3638
+ });
3639
+ }).then(restored => {
3640
+ if (!state.editorOpen) return;
3641
+ if (restored) return;
3642
+ _loadSourceImage();
3643
+ }).catch(err => {
3644
+ if (!state.editorOpen) return;
3645
+ _unmountEditorLoading();
3646
+ console.warn('[openEditor] draft lookup failed', err);
3647
+ _loadSourceImage();
3648
+ });
3649
+ function _loadSourceImage() {
3650
+
3651
+ // Loading overlay — whirlpool + "Loading" label while the source image
3652
+ // downloads / decodes. Especially important for multi-MB photos where
3653
+ // the canvas would otherwise sit blank for several seconds with no
3654
+ // feedback. If a draft-lookup overlay is already mounted, reuse it.
3655
+ if (!state.editorLoadingEl) _mountEditorLoading('Loading…');
3656
+ else {
3657
+ const inner = state.editorLoadingEl.querySelector('.ge-loading-text');
3658
+ if (inner) inner.textContent = 'Loading…';
3659
+ }
3660
+ const _removeLoading = () => _unmountEditorLoading();
3661
+
3662
+ // Load image — single layer named "Photo" (no extra Edit layer; the
3663
+ // user can add one manually if they want isolated edits).
3664
+ const img = new Image();
3665
+ img.crossOrigin = 'anonymous';
3666
+ img.onload = () => {
3667
+ if (!state.editorOpen) return;
3668
+ _initCanvas(img.naturalWidth, img.naturalHeight);
3669
+ const photoLayer = createLayer('Photo', state.imgWidth, state.imgHeight);
3670
+ photoLayer.ctx.drawImage(img, 0, 0);
3671
+ photoLayer.isBase = true;
3672
+ state.layers.push(photoLayer);
3673
+ state.activeLayerId = photoLayer.id;
3674
+ composite();
3675
+ _renderLayerPanel();
3676
+ _fitZoom();
3677
+ _removeLoading();
3678
+ _schedulePersist();
3679
+ };
3680
+ img.onerror = (e) => {
3681
+ console.error('[_loadSourceImage] onerror — failed to load', imageUrl, e);
3682
+ _removeLoading();
3683
+ if (uiModule) uiModule.showToast('Failed to load image');
3684
+ closeEditor();
3685
+ };
3686
+ img.src = imageUrl;
3687
+ }
3688
+ }
3689
+
3690
+ // Update the gallery's Edit tab label to reflect what's currently open.
3691
+ // Pass null to reset to plain "Edit". Only mutates the inner label span
3692
+ // so the SVG icon next to it survives the update.
3693
+ function _setEditTabLabel(name) {
3694
+ const tab = document.getElementById('gallery-editor-tab');
3695
+ if (!tab) return;
3696
+ const labelEl = tab.querySelector('.gallery-tab-label') || tab;
3697
+ if (!name) {
3698
+ labelEl.textContent = 'Edit';
3699
+ tab.classList.remove('has-edit');
3700
+ return;
3701
+ }
3702
+ const trimmed = name.length > 24 ? name.slice(0, 22) + '…' : name;
3703
+ labelEl.textContent = `Edit: ${trimmed}`;
3704
+ tab.classList.add('has-edit');
3705
+ }
3706
+
3707
+ export function closeEditor() {
3708
+ const editorMounted = _galleryEditMounted();
3709
+ if ((state.editorOpen || editorMounted) && !window.__galleryAllowCloseEditor) {
3710
+ try { uiModule.showToast('Close the edit tab first'); } catch {}
3711
+ return false;
3712
+ }
3713
+ // Flush any pending debounced persist + fire one final save so closing
3714
+ // the editor mid-stroke doesn't lose work. The call is fire-and-forget;
3715
+ // the server commit lands shortly after the modal hides.
3716
+ if (state.persistTimer) { clearTimeout(state.persistTimer); state.persistTimer = null; }
3717
+ if (state.layers.length) {
3718
+ try { _persistDraft(); } catch {}
3719
+ }
3720
+ _setEditTabLabel(null);
3721
+ _unmountEditorLoading();
3722
+ state.editorOpen = false;
3723
+ // Drop every document-level click-away handler registered by this
3724
+ // openEditor invocation. Without this, dropdown closers accumulated
3725
+ // across reopens (six handlers × N opens).
3726
+ while (state.editorDocClickHandlers.length) {
3727
+ const h = state.editorDocClickHandlers.pop();
3728
+ try { document.removeEventListener('click', h); } catch {}
3729
+ }
3730
+ if (state.cursorEl) { state.cursorEl.remove(); state.cursorEl = null; }
3731
+ // Tear down all floating popups + the dock so closing the editor
3732
+ // doesn't leave stale chips/panels behind on top of the gallery.
3733
+ try { _closeFxMenu(); } catch {}
3734
+ try { _closeAdjPopup(); } catch {}
3735
+ try { _closeHistoryPanel(); } catch {}
3736
+ try {
3737
+ const dock = document.getElementById('ge-fx-dock');
3738
+ if (dock) dock.remove();
3739
+ } catch {}
3740
+ try {
3741
+ document.querySelectorAll('.ge-inpaint-popup, .ge-fx-popup, .ge-adj-popup').forEach(el => {
3742
+ if (el._escHandler) {
3743
+ document.removeEventListener('keydown', el._escHandler, true);
3744
+ }
3745
+ // v2 review HIGH-2/3: unregister any modalManager entry left over
3746
+ // from FX-popup / History-panel minimise so _state and _LABELS
3747
+ // don't grow unboundedly across editor opens.
3748
+ if (el._modalId) {
3749
+ try { modalManager.unregister(el._modalId); } catch {}
3750
+ }
3751
+ el.remove();
3752
+ });
3753
+ } catch {}
3754
+ // Belt-and-suspenders: scrub any minimized-dock chip + modalManager
3755
+ // entry whose id matches our ephemeral popups (in case the DOM node
3756
+ // was already removed when the user dragged the chip to trash).
3757
+ try {
3758
+ const dock = document.getElementById('minimized-dock');
3759
+ if (dock) {
3760
+ dock.querySelectorAll('[data-modal-id^="ge-fx-popup-"], [data-modal-id="ge-history-panel-min"]').forEach(c => {
3761
+ const mid = c.dataset.modalId;
3762
+ try { modalManager.unregister(mid); } catch {}
3763
+ c.remove();
3764
+ });
3765
+ }
3766
+ } catch {}
3767
+ if (state.container) {
3768
+ state.container.style.display = 'none';
3769
+ state.container.innerHTML = '';
3770
+ }
3771
+ state.layers = [];
3772
+ state.undoStack = [];
3773
+ state.redoStack = [];
3774
+ state.layerOffsets.clear();
3775
+ state.mainCanvas = null;
3776
+ state.mainCtx = null;
3777
+ state.maskCanvas = null;
3778
+ state.maskCtx = null;
3779
+ state.imageId = null;
3780
+ state.container = null;
3781
+ window.__galleryEditLive = false;
3782
+ return true;
3783
+ }
3784
+
3785
+ export function isEditorOpen() {
3786
+ return state.editorOpen;
3787
+ }
3788
+
3789
+ const galleryEditorModule = {
3790
+ openEditor,
3791
+ closeEditor,
3792
+ isEditorOpen,
3793
+ exportPNG,
3794
+ exportToGallery,
3795
+ downloadPNG,
3796
+ };
3797
+
3798
+ export default galleryEditorModule;