pmx-canvas 0.8.0 → 0.9.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 (341) hide show
  1. package/.github/extensions/pmx-canvas/runtime.json +1 -1
  2. package/.github/extensions/pmx-canvas/tools.json +601 -22
  3. package/CHANGELOG.md +66 -0
  4. package/Readme.md +131 -532
  5. package/dist/canvas/global.css +2759 -510
  6. package/dist/canvas/index.js +168 -107
  7. package/dist/canvas/mcp-app.html +2936 -632
  8. package/dist/json-render/index.css +81 -1
  9. package/dist/json-render/index.js +20 -20
  10. package/dist/types/client/canvas/BarHint.d.ts +12 -2
  11. package/dist/types/client/canvas/BoardLinkDialog.d.ts +3 -1
  12. package/dist/types/client/canvas/BoardPinButton.d.ts +10 -0
  13. package/dist/types/client/canvas/BoardSwitcher.d.ts +1 -1
  14. package/dist/types/client/canvas/CanvasViewport.d.ts +1 -1
  15. package/dist/types/client/canvas/CommandBar.d.ts +3 -4
  16. package/dist/types/client/canvas/ConnectionBanner.d.ts +2 -1
  17. package/dist/types/client/canvas/ContextChip.d.ts +3 -0
  18. package/dist/types/client/canvas/CrossBoardReferences.d.ts +5 -0
  19. package/dist/types/client/canvas/EdgeLayer.d.ts +14 -3
  20. package/dist/types/client/canvas/EmptyState.d.ts +4 -4
  21. package/dist/types/client/canvas/HomeGraphView.d.ts +2 -0
  22. package/dist/types/client/canvas/HomeMapView.d.ts +1 -0
  23. package/dist/types/client/canvas/HomeReviewView.d.ts +6 -0
  24. package/dist/types/client/canvas/HomeView.d.ts +9 -7
  25. package/dist/types/client/canvas/KnowledgeDialog.d.ts +4 -0
  26. package/dist/types/client/canvas/LifecycleChrome.d.ts +5 -0
  27. package/dist/types/client/canvas/LifecycleFields.d.ts +7 -0
  28. package/dist/types/client/canvas/LifecyclePanel.d.ts +1 -0
  29. package/dist/types/client/canvas/MemoryInspector.d.ts +7 -0
  30. package/dist/types/client/canvas/NearPinLayer.d.ts +14 -0
  31. package/dist/types/client/canvas/NodeContextMark.d.ts +40 -7
  32. package/dist/types/client/canvas/RelationPresetsDialog.d.ts +4 -0
  33. package/dist/types/client/canvas/RelationsPanel.d.ts +4 -0
  34. package/dist/types/client/canvas/SessionPanel.d.ts +2 -0
  35. package/dist/types/client/canvas/canvas-area.d.ts +1 -1
  36. package/dist/types/client/canvas/graph-geometry.d.ts +27 -0
  37. package/dist/types/client/canvas/graph-model.d.ts +37 -0
  38. package/dist/types/client/canvas/memory-layout.d.ts +23 -0
  39. package/dist/types/client/canvas/use-chrome-height.d.ts +2 -0
  40. package/dist/types/client/canvas/use-focus-trap.d.ts +1 -0
  41. package/dist/types/client/canvas/use-memory-graph.d.ts +8 -0
  42. package/dist/types/client/canvas/use-pan-zoom.d.ts +4 -1
  43. package/dist/types/client/icons.d.ts +0 -10
  44. package/dist/types/client/nodes/AxStepControls.d.ts +1 -1
  45. package/dist/types/client/nodes/HtmlNode.d.ts +1 -0
  46. package/dist/types/client/nodes/InlineMarkdownEditor.d.ts +4 -4
  47. package/dist/types/client/nodes/MarkdownNode.d.ts +5 -1
  48. package/dist/types/client/nodes/McpAppNode.d.ts +4 -4
  49. package/dist/types/client/nodes/viewer-runtime-state.d.ts +1 -1
  50. package/dist/types/client/state/boards-store.d.ts +81 -9
  51. package/dist/types/client/state/canvas-store.d.ts +14 -1
  52. package/dist/types/client/state/context-chip-store.d.ts +22 -0
  53. package/dist/types/client/state/context-status-store.d.ts +35 -0
  54. package/dist/types/client/state/intent-bridge.d.ts +11 -8
  55. package/dist/types/client/state/knowledge-store.d.ts +22 -0
  56. package/dist/types/client/state/near-pin-store.d.ts +44 -1
  57. package/dist/types/client/state/presence-store.d.ts +4 -4
  58. package/dist/types/client/state/presentation.d.ts +28 -0
  59. package/dist/types/client/state/relation-store.d.ts +10 -0
  60. package/dist/types/client/state/session-store.d.ts +41 -2
  61. package/dist/types/client/state/workspace-settings.d.ts +7 -0
  62. package/dist/types/client/types.d.ts +13 -1
  63. package/dist/types/client/utils/keyboard.d.ts +2 -0
  64. package/dist/types/client/utils/time-ago.d.ts +4 -0
  65. package/dist/types/mcp/context-uri-template.d.ts +3 -1
  66. package/dist/types/server/agent-presence.d.ts +10 -3
  67. package/dist/types/server/ax-context.d.ts +1 -1
  68. package/dist/types/server/canvas-db.d.ts +30 -7
  69. package/dist/types/server/canvas-operations.d.ts +24 -2
  70. package/dist/types/server/canvas-provenance.d.ts +22 -2
  71. package/dist/types/server/canvas-serialization.d.ts +2 -0
  72. package/dist/types/server/canvas-state.d.ts +93 -15
  73. package/dist/types/server/canvas-validation.d.ts +1 -1
  74. package/dist/types/server/context-brief.d.ts +94 -6
  75. package/dist/types/server/context-reads.d.ts +33 -7
  76. package/dist/types/server/edit-summary.d.ts +20 -0
  77. package/dist/types/server/index.d.ts +71 -10
  78. package/dist/types/server/knowledge-lifecycle.d.ts +221 -0
  79. package/dist/types/server/lifecycle-status.d.ts +10 -0
  80. package/dist/types/server/memory-graph.d.ts +9 -0
  81. package/dist/types/server/mutation-history.d.ts +2 -0
  82. package/dist/types/server/operations/ops/ax-shared.d.ts +1 -1
  83. package/dist/types/server/operations/ops/boards.d.ts +2 -1
  84. package/dist/types/server/operations/ops/lifecycle.d.ts +2 -0
  85. package/dist/types/server/operations/ops/memory-graph.d.ts +2 -0
  86. package/dist/types/server/operations/ops/nodes.d.ts +5 -0
  87. package/dist/types/server/operations/ops/query.d.ts +23 -0
  88. package/dist/types/server/operations/registry.d.ts +1 -0
  89. package/dist/types/server/placement.d.ts +7 -0
  90. package/dist/types/server/scope-fence.d.ts +2 -1
  91. package/dist/types/server/spatial-analysis.d.ts +6 -3
  92. package/dist/types/server/tour-control.d.ts +32 -0
  93. package/dist/types/server/wiki-links.d.ts +14 -0
  94. package/dist/types/server/workbench-auth.d.ts +2 -0
  95. package/dist/types/shared/agent-context.d.ts +2 -0
  96. package/dist/types/shared/agent-presence.d.ts +15 -12
  97. package/dist/types/shared/auto-arrange.d.ts +1 -0
  98. package/dist/types/shared/boards.d.ts +4 -4
  99. package/dist/types/shared/knowledge-lifecycle.d.ts +41 -0
  100. package/dist/types/shared/knowledge-presets.d.ts +6 -0
  101. package/dist/types/shared/lifecycle-status.d.ts +24 -0
  102. package/dist/types/shared/memory-graph.d.ts +53 -0
  103. package/dist/types/shared/relations.d.ts +68 -0
  104. package/dist/types/shared/semantic-attention.d.ts +2 -0
  105. package/dist/types/shared/tour.d.ts +38 -0
  106. package/dist/types/shared/workspace-settings.d.ts +11 -0
  107. package/docs/RELEASE.md +21 -3
  108. package/docs/adr-001-bun-only-runtime.md +7 -1
  109. package/docs/adr-002-shared-product-separate-runtimes.md +93 -0
  110. package/docs/api-reference.md +109 -0
  111. package/docs/ax-host-adapter-contract.md +1 -1
  112. package/docs/ax-state-contract.md +5 -4
  113. package/docs/cli.md +153 -15
  114. package/docs/demos/pmx-canvas-demo.mp4 +0 -0
  115. package/docs/design/AgentContext.dc.html +3 -3
  116. package/docs/design/BoardPins.dc.html +37 -22
  117. package/docs/design/Categories.dc.html +208 -0
  118. package/docs/design/CategoryEditor.dc.html +33 -0
  119. package/docs/design/Chrome.dc.html +55 -9
  120. package/docs/design/ContextChip.dc.html +224 -0
  121. package/docs/design/ContextChip600.dc.html +136 -0
  122. package/docs/design/EmptyState.dc.html +45 -0
  123. package/docs/design/Flows.dc.html +23 -1
  124. package/docs/design/HeaderMarks.dc.html +183 -0
  125. package/docs/design/Home.dc.html +19 -1
  126. package/docs/design/HomeGraph.dc.html +86 -202
  127. package/docs/design/HomeMap.dc.html +23 -1
  128. package/docs/design/HomeTeam.dc.html +32 -14
  129. package/docs/design/Lifecycle.dc.html +22 -0
  130. package/docs/design/Lifecycle600.dc.html +18 -0
  131. package/docs/design/LifecycleVariants.dc.html +157 -0
  132. package/docs/design/LinkAuthorship.dc.html +139 -0
  133. package/docs/design/LinksChanged.dc.html +203 -0
  134. package/docs/design/LinksOptions.dc.html +137 -0
  135. package/docs/design/LinksOptions600.dc.html +134 -0
  136. package/docs/design/NearPin.dc.html +9 -10
  137. package/docs/design/Pane600.dc.html +4 -3
  138. package/docs/design/Relations.dc.html +3 -2
  139. package/docs/design/Restore.dc.html +152 -0
  140. package/docs/design/SeeChange.dc.html +267 -0
  141. package/docs/design/Uses.dc.html +12 -6
  142. package/docs/design/canvas.json +146 -19
  143. package/docs/design.md +454 -18
  144. package/docs/environment.md +0 -1
  145. package/docs/evals/curation-effect.md +128 -0
  146. package/docs/http-api.md +465 -38
  147. package/docs/mcp.md +150 -19
  148. package/docs/node-types.md +58 -3
  149. package/docs/plans/design-gaps-2026-10-08.md +53 -0
  150. package/docs/plans/plan-009-tech-debt-backlog.md +379 -0
  151. package/docs/plans/plan-016-context-made-visible-wave-1.md +150 -5
  152. package/docs/plans/plan-017-knowledge-categories-and-lifecycle.md +175 -0
  153. package/docs/plans/plan-018-connected-memory-wave-2.md +594 -0
  154. package/docs/plans/plan-019-post-0.9-roadmap.md +198 -0
  155. package/docs/product-context-vision-2026-09.md +17 -1
  156. package/docs/product-vision-2026-09.md +1082 -43
  157. package/docs/product-vision-review-2026-09.md +13 -1
  158. package/docs/screenshots/workbench-live-dark.png +0 -0
  159. package/docs/screenshots/workbench-live-light.png +0 -0
  160. package/docs/sdk.md +62 -2
  161. package/package.json +1 -1
  162. package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.json +1 -1
  163. package/plugins/pmx-canvas/plugin.json +1 -1
  164. package/plugins/pmx-canvas/runtime.json +1 -1
  165. package/plugins/pmx-canvas/tools.json +601 -22
  166. package/skills/pmx-canvas/SKILL.md +234 -43
  167. package/skills/pmx-canvas/evals/evals.json +62 -0
  168. package/skills/pmx-canvas/references/ax-html-control-surface.md +6 -0
  169. package/skills/pmx-canvas/references/full-reference.md +120 -21
  170. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +2 -2
  171. package/skills/pmx-canvas/references/importing-context.md +132 -0
  172. package/skills/pmx-canvas/references/installing-pmx-canvas.md +6 -6
  173. package/skills/pmx-canvas/references/tours-and-recording.md +16 -1
  174. package/skills/pmx-canvas-testing/SKILL.md +20 -0
  175. package/src/cli/agent.ts +2 -0
  176. package/src/cli/commands/ax.ts +12 -0
  177. package/src/cli/commands/boards.ts +87 -11
  178. package/src/cli/commands/edges.ts +28 -0
  179. package/src/cli/commands/groups.ts +7 -0
  180. package/src/cli/commands/nodes.ts +4 -2
  181. package/src/cli/commands/query.ts +34 -2
  182. package/src/cli/commands/record.ts +199 -48
  183. package/src/cli/index.ts +7 -1
  184. package/src/client/App.tsx +177 -70
  185. package/src/client/canvas/AttentionHistory.tsx +0 -2
  186. package/src/client/canvas/BarHint.tsx +40 -2
  187. package/src/client/canvas/BoardCopyDialog.tsx +1 -1
  188. package/src/client/canvas/BoardFolderDialog.tsx +7 -7
  189. package/src/client/canvas/BoardLinkDialog.tsx +138 -33
  190. package/src/client/canvas/BoardPinButton.tsx +33 -0
  191. package/src/client/canvas/BoardSwitcher.tsx +26 -40
  192. package/src/client/canvas/CanvasNode.tsx +360 -237
  193. package/src/client/canvas/CanvasViewport.tsx +45 -7
  194. package/src/client/canvas/CommandBar.tsx +29 -23
  195. package/src/client/canvas/CommandPalette.tsx +5 -12
  196. package/src/client/canvas/ConnectionBanner.tsx +3 -3
  197. package/src/client/canvas/ContextChip.tsx +296 -0
  198. package/src/client/canvas/ContextMenu.tsx +63 -5
  199. package/src/client/canvas/CrossBoardReferences.tsx +60 -0
  200. package/src/client/canvas/EdgeLayer.tsx +339 -76
  201. package/src/client/canvas/EmptyState.tsx +33 -18
  202. package/src/client/canvas/ExpandedNodeOverlay.tsx +72 -40
  203. package/src/client/canvas/ExternalWriters.tsx +57 -42
  204. package/src/client/canvas/HomeGraphView.tsx +942 -0
  205. package/src/client/canvas/HomeMapView.tsx +641 -0
  206. package/src/client/canvas/HomeReviewView.tsx +167 -0
  207. package/src/client/canvas/HomeView.tsx +687 -160
  208. package/src/client/canvas/IntentLayer.tsx +19 -12
  209. package/src/client/canvas/KnowledgeDialog.tsx +488 -0
  210. package/src/client/canvas/LifecycleChrome.tsx +133 -0
  211. package/src/client/canvas/LifecycleFields.tsx +91 -0
  212. package/src/client/canvas/LifecyclePanel.tsx +533 -0
  213. package/src/client/canvas/MemoryInspector.tsx +104 -0
  214. package/src/client/canvas/Minimap.tsx +3 -3
  215. package/src/client/canvas/NearPinLayer.tsx +187 -0
  216. package/src/client/canvas/NodeContextMark.tsx +175 -97
  217. package/src/client/canvas/Presentation.tsx +23 -57
  218. package/src/client/canvas/RelationPresetsDialog.tsx +413 -0
  219. package/src/client/canvas/RelationsPanel.tsx +82 -0
  220. package/src/client/canvas/SelectionBar.tsx +3 -6
  221. package/src/client/canvas/SessionPanel.tsx +90 -50
  222. package/src/client/canvas/SessionReceipt.tsx +311 -39
  223. package/src/client/canvas/ShortcutOverlay.tsx +1 -1
  224. package/src/client/canvas/SnapshotPanel.tsx +27 -36
  225. package/src/client/canvas/ToolRail.tsx +367 -192
  226. package/src/client/canvas/TopBar.tsx +354 -124
  227. package/src/client/canvas/canvas-area.ts +10 -3
  228. package/src/client/canvas/graph-geometry.ts +195 -0
  229. package/src/client/canvas/graph-model.ts +254 -0
  230. package/src/client/canvas/memory-layout.ts +106 -0
  231. package/src/client/canvas/use-chrome-height.ts +20 -0
  232. package/src/client/canvas/use-focus-trap.ts +31 -5
  233. package/src/client/canvas/use-memory-graph.ts +58 -0
  234. package/src/client/canvas/use-node-drag.ts +2 -1
  235. package/src/client/canvas/use-pan-zoom.ts +64 -7
  236. package/src/client/icons.tsx +0 -63
  237. package/src/client/mcp-app/style.css +0 -6
  238. package/src/client/nodes/AxStepControls.tsx +74 -50
  239. package/src/client/nodes/BoardNode.tsx +5 -1
  240. package/src/client/nodes/ContextNode.tsx +18 -11
  241. package/src/client/nodes/ExtAppFrame.tsx +0 -1
  242. package/src/client/nodes/FileNode.tsx +46 -42
  243. package/src/client/nodes/HtmlNode.tsx +44 -2
  244. package/src/client/nodes/ImageNode.tsx +1 -1
  245. package/src/client/nodes/InlineFormatBar.tsx +6 -9
  246. package/src/client/nodes/InlineMarkdownEditor.tsx +56 -17
  247. package/src/client/nodes/MarkdownNode.tsx +377 -109
  248. package/src/client/nodes/McpAppNode.tsx +50 -20
  249. package/src/client/nodes/MdFormatBar.tsx +21 -18
  250. package/src/client/nodes/PromptNode.tsx +1 -1
  251. package/src/client/nodes/ResponseNode.tsx +1 -1
  252. package/src/client/nodes/viewer-runtime-state.ts +54 -22
  253. package/src/client/state/attention-bridge.ts +64 -15
  254. package/src/client/state/boards-store.ts +193 -39
  255. package/src/client/state/canvas-store.ts +99 -12
  256. package/src/client/state/context-chip-store.ts +70 -0
  257. package/src/client/state/context-status-store.ts +53 -0
  258. package/src/client/state/intent-bridge.ts +51 -30
  259. package/src/client/state/knowledge-store.ts +43 -0
  260. package/src/client/state/near-pin-store.ts +115 -6
  261. package/src/client/state/presence-store.ts +7 -10
  262. package/src/client/state/presentation.ts +91 -0
  263. package/src/client/state/relation-store.ts +81 -0
  264. package/src/client/state/session-store.ts +144 -18
  265. package/src/client/state/sse-bridge.ts +206 -29
  266. package/src/client/state/workspace-settings.ts +11 -0
  267. package/src/client/theme/global.css +2759 -510
  268. package/src/client/types.ts +12 -0
  269. package/src/client/utils/keyboard.ts +10 -0
  270. package/src/client/utils/time-ago.ts +14 -0
  271. package/src/json-render/renderer/index.css +6 -1
  272. package/src/json-render/renderer/index.tsx +31 -1
  273. package/src/mcp/canvas-app.ts +12 -2
  274. package/src/mcp/context-uri-template.ts +7 -4
  275. package/src/mcp/server.ts +63 -41
  276. package/src/mcp/workbench-app.ts +39 -6
  277. package/src/server/agent-presence.ts +128 -28
  278. package/src/server/ax-context.ts +27 -13
  279. package/src/server/board-export.ts +12 -1
  280. package/src/server/canvas-db.ts +331 -42
  281. package/src/server/canvas-operations.ts +196 -84
  282. package/src/server/canvas-provenance.ts +53 -22
  283. package/src/server/canvas-serialization.ts +2 -0
  284. package/src/server/canvas-state.ts +950 -122
  285. package/src/server/canvas-validation.ts +9 -2
  286. package/src/server/context-brief.ts +345 -38
  287. package/src/server/context-reads.ts +179 -30
  288. package/src/server/demo-state.json +355 -24
  289. package/src/server/edit-summary.ts +74 -0
  290. package/src/server/html-surface.ts +8 -1
  291. package/src/server/index.ts +203 -13
  292. package/src/server/knowledge-lifecycle.ts +304 -0
  293. package/src/server/lifecycle-status.ts +232 -0
  294. package/src/server/memory-graph.ts +166 -0
  295. package/src/server/mutation-history.ts +18 -6
  296. package/src/server/operations/composites.ts +32 -11
  297. package/src/server/operations/http.ts +7 -2
  298. package/src/server/operations/index.ts +4 -0
  299. package/src/server/operations/mcp.ts +32 -1
  300. package/src/server/operations/ops/ax-presence.ts +1 -1
  301. package/src/server/operations/ops/ax-read.ts +57 -6
  302. package/src/server/operations/ops/ax-timeline.ts +28 -2
  303. package/src/server/operations/ops/backup.ts +5 -3
  304. package/src/server/operations/ops/batch.ts +35 -4
  305. package/src/server/operations/ops/boards.ts +188 -47
  306. package/src/server/operations/ops/canvas-wire.ts +31 -1
  307. package/src/server/operations/ops/edges.ts +36 -2
  308. package/src/server/operations/ops/groups.ts +6 -1
  309. package/src/server/operations/ops/json-render.ts +5 -1
  310. package/src/server/operations/ops/lifecycle.ts +262 -0
  311. package/src/server/operations/ops/memory-graph.ts +45 -0
  312. package/src/server/operations/ops/nodes.ts +107 -24
  313. package/src/server/operations/ops/query.ts +281 -68
  314. package/src/server/operations/ops/snapshots.ts +73 -1
  315. package/src/server/operations/ops/validate.ts +5 -1
  316. package/src/server/operations/ops/viewport.ts +97 -3
  317. package/src/server/operations/registry.ts +77 -26
  318. package/src/server/placement.ts +29 -0
  319. package/src/server/scope-fence.ts +78 -6
  320. package/src/server/server.ts +115 -16
  321. package/src/server/spatial-analysis.ts +48 -23
  322. package/src/server/tour-control.ts +82 -0
  323. package/src/server/wiki-links.ts +137 -0
  324. package/src/server/workbench-auth.ts +11 -0
  325. package/src/shared/agent-context.ts +3 -0
  326. package/src/shared/agent-presence.ts +15 -15
  327. package/src/shared/auto-arrange.ts +2 -0
  328. package/src/shared/boards.ts +4 -4
  329. package/src/shared/knowledge-lifecycle.ts +22 -0
  330. package/src/shared/knowledge-presets.ts +80 -0
  331. package/src/shared/lifecycle-status.ts +26 -0
  332. package/src/shared/memory-graph.ts +74 -0
  333. package/src/shared/relations.ts +98 -0
  334. package/src/shared/semantic-attention.ts +15 -1
  335. package/src/shared/tour.ts +23 -5
  336. package/src/shared/workspace-settings.ts +13 -0
  337. package/dist/types/client/canvas/ContextPinBar.d.ts +0 -1
  338. package/dist/types/client/canvas/FocusFieldLayer.d.ts +0 -1
  339. package/dist/types/client/canvas/kind-colors.d.ts +0 -3
  340. package/dist/types/server/agent-context.d.ts +0 -22
  341. package/src/client/canvas/ContextPinBar.tsx +0 -25
@@ -1,24 +1,59 @@
1
1
  import type { JSX } from 'preact';
2
- import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
2
+ import { Marked } from 'marked';
3
+ import { signal, type Signal } from '@preact/signals';
4
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'preact/hooks';
3
5
  import { IconArrowLeft } from '../icons';
4
6
  import { expandNode, nodes, updateNodeData } from '../state/canvas-store';
5
- import { fetchFile, renderMarkdown, saveFile, updateNodeFromClient } from '../state/intent-bridge';
7
+ import { fetchFile, renderMarkdown, updateNodeFromClient } from '../state/intent-bridge';
6
8
  import type { CanvasNodeState } from '../types';
7
- import { activeBoard, setBoardReadme } from '../state/boards-store';
9
+ import { activeBoard, setBoardReadme, openMemoryTarget, boardList } from '../state/boards-store';
10
+ import { showToast } from '../state/attention-bridge';
8
11
  import { MdFormatBar } from './MdFormatBar';
9
12
  import { handleFormatShortcut, handleTab } from './md-format';
10
13
  import { InlineMarkdownEditor } from './InlineMarkdownEditor';
11
14
  import { isHostedWorkbench } from '../state/workbench-transport';
12
- import { activeBoardId } from '../state/boards-store';
15
+ import { activeBoardId, boardIncarnation, claimNavigation } from '../state/boards-store';
13
16
  import { sanitizeMarkdownHtml } from './markdown-sanitize';
14
17
 
18
+ function handleWikiClick(e: MouseEvent, nodeId: string): boolean {
19
+ const link = (e.target as HTMLElement).closest<HTMLAnchorElement>('a.wiki-link');
20
+ if (!link) return false;
21
+ e.preventDefault();
22
+ e.stopPropagation();
23
+ if (link.dataset.wikiBoard) {
24
+ void openMemoryTarget({
25
+ boardId: link.dataset.wikiBoard,
26
+ ...(link.dataset.wikiCard ? { cardId: link.dataset.wikiCard } : {}),
27
+ });
28
+ } else {
29
+ const ref = link.dataset.wikiRef ?? '';
30
+ const name = ref.split('#')[0];
31
+ if (
32
+ link.dataset.wikiMissing ||
33
+ ref.includes('#') ||
34
+ boardList.value.some((board) => board.name.toLocaleLowerCase() === name.toLocaleLowerCase() || board.id === name)
35
+ ) {
36
+ showToast(
37
+ 'remove',
38
+ 'Link unavailable',
39
+ 'The target is missing or ambiguous. Use a board/card ID in the wiki link to choose it explicitly.',
40
+ );
41
+ } else {
42
+ window.dispatchEvent(new CustomEvent('pmx-open-board-link', { detail: { sourceNodeId: nodeId, wikiRef: name } }));
43
+ }
44
+ }
45
+ return true;
46
+ }
47
+
15
48
  function RenderedMarkdown({
16
49
  html,
17
50
  className,
18
51
  style,
19
52
  onTaskToggle,
53
+ nodeId,
20
54
  }: {
21
55
  html: string;
56
+ nodeId: string;
22
57
  className?: string;
23
58
  style?: string | JSX.CSSProperties;
24
59
  /** Enables the GFM task checkboxes and reports which one was clicked (document order). */
@@ -43,113 +78,246 @@ function RenderedMarkdown({
43
78
  template.innerHTML = sanitizeMarkdownHtml(html);
44
79
  container.append(template.content.cloneNode(true));
45
80
  if (onTaskToggleRef.current) {
46
- for (const box of container.querySelectorAll('input[type="checkbox"][disabled]')) {
81
+ for (const box of container.querySelectorAll('input[type="checkbox"][data-pmx-task][disabled]')) {
47
82
  box.removeAttribute('disabled');
48
83
  }
49
84
  }
50
85
  }, [html]);
51
86
 
52
- const handleClick = onTaskToggle
53
- ? (e: MouseEvent) => {
54
- const onToggle = onTaskToggleRef.current;
55
- const container = containerRef.current;
56
- if (!container || !onToggle) return;
57
- const target = e.target as HTMLElement;
58
- // The box itself, or anywhere in the row's checkbox gutter — a 15px
59
- // input alone is a miserable target at zoom and in embedded panes
60
- // that forward clicks imprecisely.
61
- let box: HTMLInputElement | null = null;
62
- if (target instanceof HTMLInputElement && target.type === 'checkbox') {
63
- box = target;
64
- } else {
65
- const row = target.closest('li');
66
- const rowBox = row?.querySelector<HTMLInputElement>(':scope > input[type="checkbox"]');
67
- if (rowBox && e.clientX <= rowBox.getBoundingClientRect().right + 6) box = rowBox;
68
- }
69
- if (!box) return;
70
- e.stopPropagation();
71
- const index = [...container.querySelectorAll('input[type="checkbox"]')].indexOf(box);
72
- if (index >= 0) onToggle(index);
73
- }
74
- : undefined;
87
+ const handleClick = (e: MouseEvent) => {
88
+ if (handleWikiClick(e, nodeId)) return;
89
+ const target = e.target as HTMLElement;
90
+ const onToggle = onTaskToggleRef.current;
91
+ const container = containerRef.current;
92
+ if (!container || !onToggle) return;
93
+ // The box itself, or anywhere in the row's checkbox gutter — a 15px
94
+ // input alone is a miserable target at zoom and in embedded panes
95
+ // that forward clicks imprecisely.
96
+ let box: HTMLInputElement | null = null;
97
+ if (target instanceof HTMLInputElement && target.matches('input[type="checkbox"][data-pmx-task]')) {
98
+ box = target;
99
+ } else {
100
+ const row = target.closest('li');
101
+ const rowBox = row?.querySelector<HTMLInputElement>(
102
+ ':scope > input[data-pmx-task], :scope > p > input[data-pmx-task]',
103
+ );
104
+ if (rowBox && e.clientX <= rowBox.getBoundingClientRect().right + 6) box = rowBox;
105
+ }
106
+ if (!box) return;
107
+ // The source/rendered acknowledgement owns the tick, not the native input's
108
+ // optimistic state, which would otherwise survive a refused or stale click.
109
+ e.preventDefault();
110
+ e.stopPropagation();
111
+ // Inert template content and HTML tree repair can change the live DOM order.
112
+ onToggle(Number(box.dataset.pmxTask));
113
+ };
75
114
 
76
115
  return <div ref={containerRef} class={className} style={style} onClick={handleClick} />;
77
116
  }
78
117
 
79
- /** Flip the index-th GFM task marker in the source (document order matches the rendered boxes). */
118
+ const taskParser = new Marked({ gfm: true, breaks: true });
119
+
120
+ /** Flip exactly one parsed GFM task without reformatting any other source bytes. */
80
121
  export function toggleTaskMarker(markdown: string, index: number): string {
81
- let seen = -1;
82
- return markdown.replace(/^(\s*(?:[-*+]|\d+[.)])\s+\[)([ xX])(\])/gm, (match, pre, state, post) => {
83
- seen += 1;
84
- if (seen !== index) return match;
85
- return `${pre}${state === ' ' ? 'x' : ' '}${post}`;
86
- });
122
+ const states = (source: string): boolean[] => {
123
+ const result: boolean[] = [];
124
+ taskParser.walkTokens(taskParser.lexer(source), (token) => {
125
+ if (token.type === 'list_item' && token.task) result.push(!!token.checked);
126
+ });
127
+ return result;
128
+ };
129
+ const before = states(markdown);
130
+ if (!Number.isInteger(index) || index < 0 || index >= before.length) return markdown;
131
+ // Marked has no source offsets, and container tokens strip quote/indentation
132
+ // prefixes. Probe only list-shaped markers, never reference labels like [x]:
133
+ // whose mutation can change parsing. Marked decides which candidates are tasks.
134
+ const markers = [...markdown.matchAll(/(?:[-*+]|\d{1,9}[.)])[ \t]+\[[ xX]\](?=[ \t]|$)/gm)].map(
135
+ (match) => match.index + match[0].lastIndexOf('[') + 1,
136
+ );
137
+ const flip = (first: number, last: number) => {
138
+ let source = markdown;
139
+ for (let i = last; i >= first; i--) {
140
+ const offset = markers[i];
141
+ source = source.slice(0, offset) + (source[offset] === ' ' ? 'x' : ' ') + source.slice(offset + 1);
142
+ }
143
+ return source;
144
+ };
145
+ let first = 0;
146
+ let last = markers.length - 1;
147
+ while (first < last) {
148
+ const middle = Math.floor((first + last) / 2);
149
+ if (states(flip(first, middle))[index] !== before[index]) last = middle;
150
+ else first = middle + 1;
151
+ }
152
+ const candidate = flip(first, first);
153
+ const after = states(candidate);
154
+ return after.length === before.length &&
155
+ after.every((checked, i) => checked === (i === index ? !before[i] : before[i]))
156
+ ? candidate
157
+ : markdown;
158
+ }
159
+
160
+ // A draft belongs to its qualified card, not the card/overlay component currently
161
+ // showing it. Keep dirty or in-flight work across close, expand and board switches.
162
+ type MarkdownDraft = {
163
+ latestMdRef: { current: string };
164
+ renderedMdRef: { current: { markdown: string; html: string } | null };
165
+ dirty: Signal<boolean>;
166
+ editBase: { current: { boardId: string | null; revision: number } };
167
+ saveQueue: { current: Promise<void> };
168
+ nativeSaveAttempts: Set<{ revision: number }>;
169
+ pending: Signal<number>;
170
+ owners: number;
171
+ };
172
+ const markdownDrafts = new Map<string, MarkdownDraft>();
173
+
174
+ /** Review must refer to persisted content, not an edited or saving preview. */
175
+ export function hasUnsavedMarkdown(boardId: string, nodeId: string): boolean {
176
+ const draft = markdownDrafts.get(JSON.stringify([boardId, nodeId]));
177
+ return !!draft && (draft.dirty.value || draft.pending.value > 0);
178
+ }
179
+
180
+ /** After blur, navigate only when this board/card's save has actually succeeded. */
181
+ export async function waitForMarkdownSave(boardId: string | null, nodeId: string): Promise<boolean> {
182
+ const isCurrentNavigation = claimNavigation();
183
+ const incarnation = boardIncarnation;
184
+ const draft = markdownDrafts.get(JSON.stringify([boardId, nodeId]));
185
+ await draft?.saveQueue.current;
186
+ return (
187
+ isCurrentNavigation() && incarnation === boardIncarnation && activeBoardId.value === boardId && !draft?.dirty.value
188
+ );
87
189
  }
88
190
 
89
191
  export function MarkdownNode({ node, expanded = false }: { node: CanvasNodeState; expanded?: boolean }) {
90
192
  const board = activeBoard();
193
+ const boardId = activeBoardId.value;
194
+ const draftKey = JSON.stringify([boardId, node.id]);
195
+ const draft = useMemo(() => {
196
+ const existing = markdownDrafts.get(draftKey);
197
+ if (existing) return existing;
198
+ const created: MarkdownDraft = {
199
+ latestMdRef: { current: typeof node.data.content === 'string' ? node.data.content : '' },
200
+ renderedMdRef: { current: null },
201
+ dirty: signal(false),
202
+ editBase: { current: { boardId, revision: node.contentRevision ?? 0 } },
203
+ saveQueue: { current: Promise.resolve() },
204
+ nativeSaveAttempts: new Set(),
205
+ pending: signal(0),
206
+ owners: 0,
207
+ };
208
+ markdownDrafts.set(draftKey, created);
209
+ return created;
210
+ }, [draftKey]);
211
+ const { latestMdRef, renderedMdRef, editBase, saveQueue } = draft;
91
212
  const path = node.data.path as string;
92
213
  const [content, setContent] = useState('');
93
214
  const [rendered, setRendered] = useState('');
94
215
  const [loaded, setLoaded] = useState(false);
95
216
  const [sourceMode, setSourceMode] = useState(false);
96
217
  const [saving, setSaving] = useState(false);
97
- const [dirty, setDirty] = useState(false);
218
+ const dirty = draft.dirty.value;
98
219
  const textareaRef = useRef<HTMLTextAreaElement>(null);
99
- const persistTimerRef = useRef<number | null>(null);
220
+ const persistTimerRef = useMemo<{ current: number | null }>(() => ({ current: null }), [draft]);
100
221
  // Always-current md + saver, so the unmount cleanup can flush a pending
101
- // debounced save without capturing stale closures.
102
- const latestMdRef = useRef<string>('');
103
- const persistFnRef = useRef<((md: string) => Promise<void>) | null>(null);
104
- const dirtyRef = useRef(false);
105
- const editBase = useRef({ boardId: activeBoardId.value, revision: node.contentRevision ?? 0 });
106
- const saveQueue = useRef(Promise.resolve());
222
+ // debounced save without borrowing another board's same-ID card.
223
+ const persistFnRef = useMemo<{ current: ((md: string) => Promise<void>) | null }>(() => ({ current: null }), [draft]);
107
224
  const reviewActive = node.data.reviewActive as boolean | undefined;
225
+ // Binding-only saves/restores and target availability changes invalidate HTML,
226
+ // but position-only layouts must not trigger another render request.
227
+ const savedWikiBindings = JSON.stringify(node.data.wikiLinks ?? []);
228
+
229
+ useLayoutEffect(() => {
230
+ draft.owners++;
231
+ // Reattach when replacing the last clean card instance in the same commit.
232
+ markdownDrafts.set(draftKey, draft);
233
+ setContent(latestMdRef.current);
234
+ const preview = renderedMdRef.current;
235
+ const matches = preview?.markdown === latestMdRef.current;
236
+ setRendered(matches ? preview.html : '');
237
+ setLoaded(matches);
238
+ return () => {
239
+ draft.owners--;
240
+ if (!draft.owners && !draft.pending.value && !draft.dirty.value && markdownDrafts.get(draftKey) === draft)
241
+ markdownDrafts.delete(draftKey);
242
+ };
243
+ }, [draft]);
108
244
 
109
245
  useEffect(() => {
110
- if (isHostedWorkbench() && dirtyRef.current) return;
111
- editBase.current = { boardId: activeBoardId.value, revision: node.contentRevision ?? 0 };
246
+ const renderingDraft = draft.dirty.value || draft.pending.value > 0;
247
+ if (!renderingDraft) editBase.current = { boardId, revision: node.contentRevision ?? 0 };
112
248
  let cancelled = false;
113
249
  (async () => {
114
250
  let raw: string;
115
- if (path) {
251
+ if (renderingDraft) {
252
+ // Refresh link availability in the draft, never replace it with saved text.
253
+ raw = latestMdRef.current;
254
+ } else if (path) {
116
255
  const result = await fetchFile(path);
117
256
  if (cancelled) return;
118
257
  raw = result.content;
119
- } else if (node.data.content) {
120
- raw = node.data.content as string;
258
+ } else if (typeof node.data.content === 'string') {
259
+ raw = node.data.content;
121
260
  } else {
122
261
  setLoaded(true);
123
262
  return;
124
263
  }
125
- setContent(raw);
126
- const html = await renderMarkdown(raw);
127
- if (cancelled || (isHostedWorkbench() && dirtyRef.current)) return;
264
+ const html = await renderMarkdown(raw, node.id, boardId);
265
+ if (
266
+ html === null ||
267
+ cancelled ||
268
+ activeBoardId.value !== boardId ||
269
+ (renderingDraft || draft.dirty.value
270
+ ? latestMdRef.current !== raw
271
+ : !path && nodes.value.get(node.id)?.data.content !== raw)
272
+ )
273
+ return;
274
+ renderedMdRef.current = { markdown: raw, html };
128
275
  setRendered(html);
129
276
  setLoaded(true);
130
- updateNodeData(node.id, { content: raw, rendered: html });
277
+ if (!renderingDraft && !draft.dirty.value) {
278
+ latestMdRef.current = raw;
279
+ setContent(raw);
280
+ updateNodeData(node.id, { content: raw, rendered: html });
281
+ } else if (nodes.value.get(node.id)?.data.content === raw) {
282
+ // A pending save may already contain this text; cache only its HTML.
283
+ updateNodeData(node.id, { rendered: html });
284
+ }
131
285
  })();
132
286
  return () => {
133
287
  cancelled = true;
134
288
  };
135
- }, [path, node.id, node.data.content]);
289
+ }, [boardId, path, node.id, node.data.content, savedWikiBindings, node.linksRevision]);
136
290
 
137
- const handleInput = useCallback(async (e: Event) => {
138
- const value = (e.target as HTMLTextAreaElement).value;
139
- dirtyRef.current = true;
140
- latestMdRef.current = value;
141
- setContent(value);
142
- setDirty(true);
143
- const html = await renderMarkdown(value);
144
- setRendered(html);
145
- }, []);
291
+ const handleInput = useCallback(
292
+ async (e: Event) => {
293
+ const value = (e.target as HTMLTextAreaElement).value;
294
+ draft.dirty.value = true;
295
+ latestMdRef.current = value;
296
+ setContent(value);
297
+ const html = await renderMarkdown(value, node.id, boardId);
298
+ if (html === null || activeBoardId.value !== boardId || latestMdRef.current !== value) return;
299
+ renderedMdRef.current = { markdown: value, html };
300
+ setRendered(html);
301
+ },
302
+ [boardId, draft, node.id],
303
+ );
146
304
 
147
305
  const persistContent = useCallback(
148
306
  async (newContent: string) => {
149
- if (isHostedWorkbench()) {
150
- // Serialize autosaves against the captured source revision, not whatever
151
- // newer revision a background poll has delivered while typing.
152
- saveQueue.current = saveQueue.current.then(async () => {
307
+ // Human wins over already-observed work, not work arriving while this
308
+ // particular attempt waits in the queue or for its rendered preview.
309
+ const incarnation = boardIncarnation;
310
+ const attempt = { revision: nodes.value.get(node.id)?.contentRevision ?? 0 };
311
+ draft.nativeSaveAttempts.add(attempt);
312
+ draft.pending.value++;
313
+ // Order the complete save, including disk writes and rendering. A later
314
+ // draft must not persist before an older render resumes and overwrites it.
315
+ saveQueue.current = saveQueue.current.then(async () => {
316
+ if (incarnation !== boardIncarnation || activeBoardId.value !== boardId || latestMdRef.current !== newContent)
317
+ return;
318
+ if (isHostedWorkbench()) {
319
+ // Serialize autosaves against the captured source revision, not whatever
320
+ // newer revision a background poll has delivered while typing.
153
321
  setSaving(true);
154
322
  const result = await updateNodeFromClient(node.id, {
155
323
  content: newContent,
@@ -157,44 +325,106 @@ export function MarkdownNode({ node, expanded = false }: { node: CanvasNodeState
157
325
  expectedContentRevision: editBase.current.revision,
158
326
  });
159
327
  setSaving(false);
160
- if (!result.ok) return; // Refusal toast is shown by the request helper; keep the draft.
328
+ if (!result.ok || incarnation !== boardIncarnation) return; // Keep refused or obsolete drafts.
161
329
  editBase.current.revision = result.contentRevision ?? editBase.current.revision;
330
+ const html = await renderMarkdown(newContent, node.id, boardId);
331
+ if (incarnation !== boardIncarnation || activeBoardId.value !== boardId || latestMdRef.current !== newContent)
332
+ return;
162
333
  const current = nodes.value.get(node.id);
163
- if (current)
164
- nodes.value = new Map(nodes.value).set(node.id, { ...current, contentRevision: editBase.current.revision });
165
- if (latestMdRef.current !== newContent) return;
166
- dirtyRef.current = false;
167
- setDirty(false);
168
- const html = await renderMarkdown(newContent);
334
+ if (!current || (current.contentRevision ?? 0) > editBase.current.revision) return;
335
+ nodes.value = new Map(nodes.value).set(node.id, {
336
+ ...current,
337
+ contentRevision: editBase.current.revision,
338
+ lastEditedBy: result.lastEditedBy,
339
+ data: { ...current.data, content: newContent, ...(html !== null ? { rendered: html } : {}) },
340
+ });
341
+ draft.dirty.value = false;
342
+ if (html !== null) {
343
+ renderedMdRef.current = { markdown: newContent, html };
344
+ setRendered(html);
345
+ }
346
+ return;
347
+ }
348
+ const html = await renderMarkdown(newContent, node.id, boardId);
349
+ if (
350
+ html === null ||
351
+ incarnation !== boardIncarnation ||
352
+ activeBoardId.value !== boardId ||
353
+ latestMdRef.current !== newContent
354
+ )
355
+ return;
356
+ // Preview the local draft independently of persistence. Switching Source
357
+ // back to Document must not resurrect saved text while PATCH is pending.
358
+ if (
359
+ activeBoardId.value === boardId &&
360
+ latestMdRef.current === newContent &&
361
+ renderedMdRef.current?.markdown !== newContent
362
+ ) {
363
+ renderedMdRef.current = { markdown: newContent, html };
169
364
  setRendered(html);
170
- updateNodeData(node.id, { content: newContent, rendered: html });
171
- });
172
- await saveQueue.current;
173
- return;
174
- }
175
- if (!path) {
176
- const html = await renderMarkdown(newContent);
177
- setRendered(html);
178
- setDirty(false);
179
- updateNodeData(node.id, { content: newContent, rendered: html });
180
- await updateNodeFromClient(node.id, { content: newContent, data: { rendered: html } });
181
- return;
182
- }
183
- setSaving(true);
184
- const result = await saveFile(path, newContent);
185
- setSaving(false);
186
- if (result.ok) {
187
- const html = await renderMarkdown(newContent);
188
- setRendered(html);
189
- setDirty(false);
190
- updateNodeData(node.id, { content: newContent, rendered: html, savedAt: result.updatedAt });
191
- await updateNodeFromClient(node.id, {
365
+ }
366
+ const expectedContentRevision = attempt.revision;
367
+ const result = await updateNodeFromClient(node.id, {
192
368
  content: newContent,
193
- data: { rendered: html, savedAt: result.updatedAt },
369
+ data: { rendered: html },
370
+ expectedBoardId: boardId,
371
+ expectedContentRevision,
372
+ ...(path ? { persistFile: true } : {}),
194
373
  });
374
+ // A restored/restarted library may reuse this ID with a lower revision.
375
+ // The old ACK grants neither canonical state nor queued-save authority.
376
+ if (incarnation !== boardIncarnation) return;
377
+ if (result.ok && result.contentRevision !== undefined) {
378
+ // Advance only through a successful predecessor in this board/card's
379
+ // queue. An external revision or a refused save grants no retry.
380
+ for (const queued of draft.nativeSaveAttempts) {
381
+ if (queued.revision === expectedContentRevision) queued.revision = result.contentRevision;
382
+ }
383
+ }
384
+ const current = nodes.value.get(node.id);
385
+ if (
386
+ result.ok &&
387
+ result.contentRevision !== undefined &&
388
+ current &&
389
+ activeBoardId.value === boardId &&
390
+ (current.contentRevision ?? 0) <= result.contentRevision
391
+ ) {
392
+ // ACK the canonical revision even while a newer local draft is dirty,
393
+ // so a later save need not wait for polling to observe our own write.
394
+ const preview = renderedMdRef.current;
395
+ const replacePreview = preview?.markdown !== newContent;
396
+ nodes.value = new Map(nodes.value).set(node.id, {
397
+ ...current,
398
+ contentRevision: result.contentRevision,
399
+ lastEditedBy: result.lastEditedBy,
400
+ data: {
401
+ ...current.data,
402
+ content: newContent,
403
+ rendered: preview?.markdown === newContent ? preview.html : html,
404
+ },
405
+ });
406
+ if (latestMdRef.current === newContent) {
407
+ // A remounted editor must not initialize from another source's HTML
408
+ // while the canonical post-save render is still pending.
409
+ if (replacePreview) {
410
+ renderedMdRef.current = { markdown: newContent, html };
411
+ setRendered(html);
412
+ }
413
+ draft.dirty.value = false;
414
+ setContent(newContent);
415
+ }
416
+ }
417
+ });
418
+ try {
419
+ await saveQueue.current;
420
+ } finally {
421
+ draft.nativeSaveAttempts.delete(attempt);
422
+ draft.pending.value--;
423
+ if (!draft.owners && !draft.pending.value && !draft.dirty.value && markdownDrafts.get(draftKey) === draft)
424
+ markdownDrafts.delete(draftKey);
195
425
  }
196
426
  },
197
- [path, node.id],
427
+ [boardId, path, node.id, draft],
198
428
  );
199
429
 
200
430
  const handleSave = useCallback(async () => {
@@ -202,6 +432,26 @@ export function MarkdownNode({ node, expanded = false }: { node: CanvasNodeState
202
432
  await persistContent(content);
203
433
  }, [dirty, content, persistContent]);
204
434
 
435
+ const handleDocumentMode = useCallback(async () => {
436
+ if (renderedMdRef.current?.markdown !== content) {
437
+ const html = await renderMarkdown(content, node.id, boardId);
438
+ // Do not mount the rich editor from another source's HTML, including
439
+ // while a draft render is pending or unavailable. Keep Source retryable.
440
+ if (html === null || activeBoardId.value !== boardId || latestMdRef.current !== content) return;
441
+ if (renderedMdRef.current?.markdown !== content) {
442
+ renderedMdRef.current = { markdown: content, html };
443
+ }
444
+ }
445
+ if (activeBoardId.value !== boardId || latestMdRef.current !== content) return;
446
+ // Another instance may have populated the shared cache. The rich editor
447
+ // mounts from this instance's HTML, which must match that cache as well.
448
+ const preview = renderedMdRef.current;
449
+ if (preview?.markdown !== content) return;
450
+ setRendered(preview.html);
451
+ setLoaded(true);
452
+ setSourceMode(false);
453
+ }, [boardId, content, node.id]);
454
+
205
455
  const handleKeyDown = useCallback(
206
456
  (e: KeyboardEvent) => {
207
457
  if ((e.metaKey || e.ctrlKey) && e.key === 's') {
@@ -227,9 +477,8 @@ export function MarkdownNode({ node, expanded = false }: { node: CanvasNodeState
227
477
  // don't hit the backend on every letter.
228
478
  const handleInlineChange = useCallback(
229
479
  (md: string) => {
230
- dirtyRef.current = true;
480
+ draft.dirty.value = true;
231
481
  setContent(md);
232
- setDirty(true);
233
482
  latestMdRef.current = md;
234
483
  if (persistTimerRef.current !== null) window.clearTimeout(persistTimerRef.current);
235
484
  persistTimerRef.current = window.setTimeout(() => {
@@ -237,7 +486,7 @@ export function MarkdownNode({ node, expanded = false }: { node: CanvasNodeState
237
486
  void persistContent(md);
238
487
  }, 800);
239
488
  },
240
- [persistContent],
489
+ [persistContent, draft],
241
490
  );
242
491
 
243
492
  // Fires on ⌘S and blur — persist immediately with whatever markdown the
@@ -245,7 +494,8 @@ export function MarkdownNode({ node, expanded = false }: { node: CanvasNodeState
245
494
  // write twice with slightly different content.
246
495
  const handleInlineSave = useCallback(
247
496
  (md: string) => {
248
- dirtyRef.current = true;
497
+ if (!draft.dirty.value && md === latestMdRef.current) return;
498
+ draft.dirty.value = true;
249
499
  setContent(md);
250
500
  latestMdRef.current = md;
251
501
  if (persistTimerRef.current !== null) {
@@ -254,7 +504,7 @@ export function MarkdownNode({ node, expanded = false }: { node: CanvasNodeState
254
504
  }
255
505
  void persistContent(md);
256
506
  },
257
- [persistContent],
507
+ [persistContent, draft],
258
508
  );
259
509
 
260
510
  // On unmount / node switch, flush any pending debounced save so trailing
@@ -267,7 +517,7 @@ export function MarkdownNode({ node, expanded = false }: { node: CanvasNodeState
267
517
  const fn = persistFnRef.current;
268
518
  if (fn) void fn(latestMdRef.current);
269
519
  };
270
- }, [node.id]);
520
+ }, [draft]);
271
521
 
272
522
  const reviewBanner = reviewActive ? (
273
523
  <div
@@ -292,7 +542,7 @@ export function MarkdownNode({ node, expanded = false }: { node: CanvasNodeState
292
542
  return (
293
543
  <div class="md-editor-expanded" onKeyDown={handleKeyDown}>
294
544
  <div class="md-editor-toolbar">
295
- <button type="button" class="md-toolbar-btn" onClick={() => setSourceMode(false)}>
545
+ <button type="button" class="md-toolbar-btn" onClick={handleDocumentMode}>
296
546
  <IconArrowLeft size={14} /> Back to document
297
547
  </button>
298
548
  <div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
@@ -312,7 +562,7 @@ export function MarkdownNode({ node, expanded = false }: { node: CanvasNodeState
312
562
  <textarea ref={textareaRef} value={content} onInput={handleInput} spellcheck={false} />
313
563
  <MdFormatBar textareaRef={textareaRef} />
314
564
  </div>
315
- <RenderedMarkdown html={rendered} className="md-preview" />
565
+ <RenderedMarkdown nodeId={node.id} html={rendered} className="md-preview" />
316
566
  </div>
317
567
  </div>
318
568
  );
@@ -325,7 +575,7 @@ export function MarkdownNode({ node, expanded = false }: { node: CanvasNodeState
325
575
  <textarea ref={textareaRef} value={content} onInput={handleInput} spellcheck={false} />
326
576
  <MdFormatBar textareaRef={textareaRef} />
327
577
  </div>
328
- <RenderedMarkdown html={rendered} className="md-preview" />
578
+ <RenderedMarkdown nodeId={node.id} html={rendered} className="md-preview" />
329
579
  <div
330
580
  style={{
331
581
  position: 'absolute',
@@ -337,7 +587,7 @@ export function MarkdownNode({ node, expanded = false }: { node: CanvasNodeState
337
587
  >
338
588
  <button
339
589
  type="button"
340
- onClick={() => setSourceMode(false)}
590
+ onClick={handleDocumentMode}
341
591
  style={{
342
592
  padding: '4px 10px',
343
593
  fontSize: '11px',
@@ -375,12 +625,27 @@ export function MarkdownNode({ node, expanded = false }: { node: CanvasNodeState
375
625
 
376
626
  if (expanded) {
377
627
  return (
378
- <div style={{ height: '100%', position: 'relative' }}>
628
+ <div
629
+ class="md-expanded-document"
630
+ style={{ height: '100%', position: 'relative' }}
631
+ onClick={async (event) => {
632
+ if (!(event.target as HTMLElement).closest('a.wiki-link')) return;
633
+ event.preventDefault();
634
+ event.stopPropagation();
635
+ const focused = document.activeElement;
636
+ // Blur flushes actual rich edits. Finish that save before the
637
+ // link dialog captures its source or navigation changes boards.
638
+ if (focused instanceof HTMLElement && event.currentTarget.contains(focused)) focused.blur();
639
+ if (!(await waitForMarkdownSave(boardId, node.id))) return;
640
+ handleWikiClick(event, node.id);
641
+ }}
642
+ >
379
643
  {reviewBanner}
380
644
  <div class="md-reader">
381
645
  {loaded ? (
382
646
  <InlineMarkdownEditor
383
647
  key={node.id}
648
+ markdown={content}
384
649
  initialHtml={rendered || '<p><br></p>'}
385
650
  className="md-reader-content md-reader-editable"
386
651
  onChange={handleInlineChange}
@@ -404,11 +669,14 @@ export function MarkdownNode({ node, expanded = false }: { node: CanvasNodeState
404
669
  <div class="md-card-content">
405
670
  {reviewBanner}
406
671
  <RenderedMarkdown
672
+ nodeId={node.id}
407
673
  html={rendered}
408
674
  style={{ padding: rendered ? '0' : '12px', color: rendered ? undefined : 'var(--c-dim)' }}
409
675
  // Task checkboxes are live on the card itself — no need to expand to tick one.
410
676
  onTaskToggle={(index) => {
411
- const next = toggleTaskMarker(content, index);
677
+ const preview = renderedMdRef.current;
678
+ if (preview?.html !== rendered || preview.markdown !== latestMdRef.current) return;
679
+ const next = toggleTaskMarker(preview.markdown, index);
412
680
  if (next !== content) handleInlineSave(next);
413
681
  }}
414
682
  />