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.
- package/.github/extensions/pmx-canvas/runtime.json +1 -1
- package/.github/extensions/pmx-canvas/tools.json +601 -22
- package/CHANGELOG.md +66 -0
- package/Readme.md +131 -532
- package/dist/canvas/global.css +2759 -510
- package/dist/canvas/index.js +168 -107
- package/dist/canvas/mcp-app.html +2936 -632
- package/dist/json-render/index.css +81 -1
- package/dist/json-render/index.js +20 -20
- package/dist/types/client/canvas/BarHint.d.ts +12 -2
- package/dist/types/client/canvas/BoardLinkDialog.d.ts +3 -1
- package/dist/types/client/canvas/BoardPinButton.d.ts +10 -0
- package/dist/types/client/canvas/BoardSwitcher.d.ts +1 -1
- package/dist/types/client/canvas/CanvasViewport.d.ts +1 -1
- package/dist/types/client/canvas/CommandBar.d.ts +3 -4
- package/dist/types/client/canvas/ConnectionBanner.d.ts +2 -1
- package/dist/types/client/canvas/ContextChip.d.ts +3 -0
- package/dist/types/client/canvas/CrossBoardReferences.d.ts +5 -0
- package/dist/types/client/canvas/EdgeLayer.d.ts +14 -3
- package/dist/types/client/canvas/EmptyState.d.ts +4 -4
- package/dist/types/client/canvas/HomeGraphView.d.ts +2 -0
- package/dist/types/client/canvas/HomeMapView.d.ts +1 -0
- package/dist/types/client/canvas/HomeReviewView.d.ts +6 -0
- package/dist/types/client/canvas/HomeView.d.ts +9 -7
- package/dist/types/client/canvas/KnowledgeDialog.d.ts +4 -0
- package/dist/types/client/canvas/LifecycleChrome.d.ts +5 -0
- package/dist/types/client/canvas/LifecycleFields.d.ts +7 -0
- package/dist/types/client/canvas/LifecyclePanel.d.ts +1 -0
- package/dist/types/client/canvas/MemoryInspector.d.ts +7 -0
- package/dist/types/client/canvas/NearPinLayer.d.ts +14 -0
- package/dist/types/client/canvas/NodeContextMark.d.ts +40 -7
- package/dist/types/client/canvas/RelationPresetsDialog.d.ts +4 -0
- package/dist/types/client/canvas/RelationsPanel.d.ts +4 -0
- package/dist/types/client/canvas/SessionPanel.d.ts +2 -0
- package/dist/types/client/canvas/canvas-area.d.ts +1 -1
- package/dist/types/client/canvas/graph-geometry.d.ts +27 -0
- package/dist/types/client/canvas/graph-model.d.ts +37 -0
- package/dist/types/client/canvas/memory-layout.d.ts +23 -0
- package/dist/types/client/canvas/use-chrome-height.d.ts +2 -0
- package/dist/types/client/canvas/use-focus-trap.d.ts +1 -0
- package/dist/types/client/canvas/use-memory-graph.d.ts +8 -0
- package/dist/types/client/canvas/use-pan-zoom.d.ts +4 -1
- package/dist/types/client/icons.d.ts +0 -10
- package/dist/types/client/nodes/AxStepControls.d.ts +1 -1
- package/dist/types/client/nodes/HtmlNode.d.ts +1 -0
- package/dist/types/client/nodes/InlineMarkdownEditor.d.ts +4 -4
- package/dist/types/client/nodes/MarkdownNode.d.ts +5 -1
- package/dist/types/client/nodes/McpAppNode.d.ts +4 -4
- package/dist/types/client/nodes/viewer-runtime-state.d.ts +1 -1
- package/dist/types/client/state/boards-store.d.ts +81 -9
- package/dist/types/client/state/canvas-store.d.ts +14 -1
- package/dist/types/client/state/context-chip-store.d.ts +22 -0
- package/dist/types/client/state/context-status-store.d.ts +35 -0
- package/dist/types/client/state/intent-bridge.d.ts +11 -8
- package/dist/types/client/state/knowledge-store.d.ts +22 -0
- package/dist/types/client/state/near-pin-store.d.ts +44 -1
- package/dist/types/client/state/presence-store.d.ts +4 -4
- package/dist/types/client/state/presentation.d.ts +28 -0
- package/dist/types/client/state/relation-store.d.ts +10 -0
- package/dist/types/client/state/session-store.d.ts +41 -2
- package/dist/types/client/state/workspace-settings.d.ts +7 -0
- package/dist/types/client/types.d.ts +13 -1
- package/dist/types/client/utils/keyboard.d.ts +2 -0
- package/dist/types/client/utils/time-ago.d.ts +4 -0
- package/dist/types/mcp/context-uri-template.d.ts +3 -1
- package/dist/types/server/agent-presence.d.ts +10 -3
- package/dist/types/server/ax-context.d.ts +1 -1
- package/dist/types/server/canvas-db.d.ts +30 -7
- package/dist/types/server/canvas-operations.d.ts +24 -2
- package/dist/types/server/canvas-provenance.d.ts +22 -2
- package/dist/types/server/canvas-serialization.d.ts +2 -0
- package/dist/types/server/canvas-state.d.ts +93 -15
- package/dist/types/server/canvas-validation.d.ts +1 -1
- package/dist/types/server/context-brief.d.ts +94 -6
- package/dist/types/server/context-reads.d.ts +33 -7
- package/dist/types/server/edit-summary.d.ts +20 -0
- package/dist/types/server/index.d.ts +71 -10
- package/dist/types/server/knowledge-lifecycle.d.ts +221 -0
- package/dist/types/server/lifecycle-status.d.ts +10 -0
- package/dist/types/server/memory-graph.d.ts +9 -0
- package/dist/types/server/mutation-history.d.ts +2 -0
- package/dist/types/server/operations/ops/ax-shared.d.ts +1 -1
- package/dist/types/server/operations/ops/boards.d.ts +2 -1
- package/dist/types/server/operations/ops/lifecycle.d.ts +2 -0
- package/dist/types/server/operations/ops/memory-graph.d.ts +2 -0
- package/dist/types/server/operations/ops/nodes.d.ts +5 -0
- package/dist/types/server/operations/ops/query.d.ts +23 -0
- package/dist/types/server/operations/registry.d.ts +1 -0
- package/dist/types/server/placement.d.ts +7 -0
- package/dist/types/server/scope-fence.d.ts +2 -1
- package/dist/types/server/spatial-analysis.d.ts +6 -3
- package/dist/types/server/tour-control.d.ts +32 -0
- package/dist/types/server/wiki-links.d.ts +14 -0
- package/dist/types/server/workbench-auth.d.ts +2 -0
- package/dist/types/shared/agent-context.d.ts +2 -0
- package/dist/types/shared/agent-presence.d.ts +15 -12
- package/dist/types/shared/auto-arrange.d.ts +1 -0
- package/dist/types/shared/boards.d.ts +4 -4
- package/dist/types/shared/knowledge-lifecycle.d.ts +41 -0
- package/dist/types/shared/knowledge-presets.d.ts +6 -0
- package/dist/types/shared/lifecycle-status.d.ts +24 -0
- package/dist/types/shared/memory-graph.d.ts +53 -0
- package/dist/types/shared/relations.d.ts +68 -0
- package/dist/types/shared/semantic-attention.d.ts +2 -0
- package/dist/types/shared/tour.d.ts +38 -0
- package/dist/types/shared/workspace-settings.d.ts +11 -0
- package/docs/RELEASE.md +21 -3
- package/docs/adr-001-bun-only-runtime.md +7 -1
- package/docs/adr-002-shared-product-separate-runtimes.md +93 -0
- package/docs/api-reference.md +109 -0
- package/docs/ax-host-adapter-contract.md +1 -1
- package/docs/ax-state-contract.md +5 -4
- package/docs/cli.md +153 -15
- package/docs/demos/pmx-canvas-demo.mp4 +0 -0
- package/docs/design/AgentContext.dc.html +3 -3
- package/docs/design/BoardPins.dc.html +37 -22
- package/docs/design/Categories.dc.html +208 -0
- package/docs/design/CategoryEditor.dc.html +33 -0
- package/docs/design/Chrome.dc.html +55 -9
- package/docs/design/ContextChip.dc.html +224 -0
- package/docs/design/ContextChip600.dc.html +136 -0
- package/docs/design/EmptyState.dc.html +45 -0
- package/docs/design/Flows.dc.html +23 -1
- package/docs/design/HeaderMarks.dc.html +183 -0
- package/docs/design/Home.dc.html +19 -1
- package/docs/design/HomeGraph.dc.html +86 -202
- package/docs/design/HomeMap.dc.html +23 -1
- package/docs/design/HomeTeam.dc.html +32 -14
- package/docs/design/Lifecycle.dc.html +22 -0
- package/docs/design/Lifecycle600.dc.html +18 -0
- package/docs/design/LifecycleVariants.dc.html +157 -0
- package/docs/design/LinkAuthorship.dc.html +139 -0
- package/docs/design/LinksChanged.dc.html +203 -0
- package/docs/design/LinksOptions.dc.html +137 -0
- package/docs/design/LinksOptions600.dc.html +134 -0
- package/docs/design/NearPin.dc.html +9 -10
- package/docs/design/Pane600.dc.html +4 -3
- package/docs/design/Relations.dc.html +3 -2
- package/docs/design/Restore.dc.html +152 -0
- package/docs/design/SeeChange.dc.html +267 -0
- package/docs/design/Uses.dc.html +12 -6
- package/docs/design/canvas.json +146 -19
- package/docs/design.md +454 -18
- package/docs/environment.md +0 -1
- package/docs/evals/curation-effect.md +128 -0
- package/docs/http-api.md +465 -38
- package/docs/mcp.md +150 -19
- package/docs/node-types.md +58 -3
- package/docs/plans/design-gaps-2026-10-08.md +53 -0
- package/docs/plans/plan-009-tech-debt-backlog.md +379 -0
- package/docs/plans/plan-016-context-made-visible-wave-1.md +150 -5
- package/docs/plans/plan-017-knowledge-categories-and-lifecycle.md +175 -0
- package/docs/plans/plan-018-connected-memory-wave-2.md +594 -0
- package/docs/plans/plan-019-post-0.9-roadmap.md +198 -0
- package/docs/product-context-vision-2026-09.md +17 -1
- package/docs/product-vision-2026-09.md +1082 -43
- package/docs/product-vision-review-2026-09.md +13 -1
- package/docs/screenshots/workbench-live-dark.png +0 -0
- package/docs/screenshots/workbench-live-light.png +0 -0
- package/docs/sdk.md +62 -2
- package/package.json +1 -1
- package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.json +1 -1
- package/plugins/pmx-canvas/plugin.json +1 -1
- package/plugins/pmx-canvas/runtime.json +1 -1
- package/plugins/pmx-canvas/tools.json +601 -22
- package/skills/pmx-canvas/SKILL.md +234 -43
- package/skills/pmx-canvas/evals/evals.json +62 -0
- package/skills/pmx-canvas/references/ax-html-control-surface.md +6 -0
- package/skills/pmx-canvas/references/full-reference.md +120 -21
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +2 -2
- package/skills/pmx-canvas/references/importing-context.md +132 -0
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +6 -6
- package/skills/pmx-canvas/references/tours-and-recording.md +16 -1
- package/skills/pmx-canvas-testing/SKILL.md +20 -0
- package/src/cli/agent.ts +2 -0
- package/src/cli/commands/ax.ts +12 -0
- package/src/cli/commands/boards.ts +87 -11
- package/src/cli/commands/edges.ts +28 -0
- package/src/cli/commands/groups.ts +7 -0
- package/src/cli/commands/nodes.ts +4 -2
- package/src/cli/commands/query.ts +34 -2
- package/src/cli/commands/record.ts +199 -48
- package/src/cli/index.ts +7 -1
- package/src/client/App.tsx +177 -70
- package/src/client/canvas/AttentionHistory.tsx +0 -2
- package/src/client/canvas/BarHint.tsx +40 -2
- package/src/client/canvas/BoardCopyDialog.tsx +1 -1
- package/src/client/canvas/BoardFolderDialog.tsx +7 -7
- package/src/client/canvas/BoardLinkDialog.tsx +138 -33
- package/src/client/canvas/BoardPinButton.tsx +33 -0
- package/src/client/canvas/BoardSwitcher.tsx +26 -40
- package/src/client/canvas/CanvasNode.tsx +360 -237
- package/src/client/canvas/CanvasViewport.tsx +45 -7
- package/src/client/canvas/CommandBar.tsx +29 -23
- package/src/client/canvas/CommandPalette.tsx +5 -12
- package/src/client/canvas/ConnectionBanner.tsx +3 -3
- package/src/client/canvas/ContextChip.tsx +296 -0
- package/src/client/canvas/ContextMenu.tsx +63 -5
- package/src/client/canvas/CrossBoardReferences.tsx +60 -0
- package/src/client/canvas/EdgeLayer.tsx +339 -76
- package/src/client/canvas/EmptyState.tsx +33 -18
- package/src/client/canvas/ExpandedNodeOverlay.tsx +72 -40
- package/src/client/canvas/ExternalWriters.tsx +57 -42
- package/src/client/canvas/HomeGraphView.tsx +942 -0
- package/src/client/canvas/HomeMapView.tsx +641 -0
- package/src/client/canvas/HomeReviewView.tsx +167 -0
- package/src/client/canvas/HomeView.tsx +687 -160
- package/src/client/canvas/IntentLayer.tsx +19 -12
- package/src/client/canvas/KnowledgeDialog.tsx +488 -0
- package/src/client/canvas/LifecycleChrome.tsx +133 -0
- package/src/client/canvas/LifecycleFields.tsx +91 -0
- package/src/client/canvas/LifecyclePanel.tsx +533 -0
- package/src/client/canvas/MemoryInspector.tsx +104 -0
- package/src/client/canvas/Minimap.tsx +3 -3
- package/src/client/canvas/NearPinLayer.tsx +187 -0
- package/src/client/canvas/NodeContextMark.tsx +175 -97
- package/src/client/canvas/Presentation.tsx +23 -57
- package/src/client/canvas/RelationPresetsDialog.tsx +413 -0
- package/src/client/canvas/RelationsPanel.tsx +82 -0
- package/src/client/canvas/SelectionBar.tsx +3 -6
- package/src/client/canvas/SessionPanel.tsx +90 -50
- package/src/client/canvas/SessionReceipt.tsx +311 -39
- package/src/client/canvas/ShortcutOverlay.tsx +1 -1
- package/src/client/canvas/SnapshotPanel.tsx +27 -36
- package/src/client/canvas/ToolRail.tsx +367 -192
- package/src/client/canvas/TopBar.tsx +354 -124
- package/src/client/canvas/canvas-area.ts +10 -3
- package/src/client/canvas/graph-geometry.ts +195 -0
- package/src/client/canvas/graph-model.ts +254 -0
- package/src/client/canvas/memory-layout.ts +106 -0
- package/src/client/canvas/use-chrome-height.ts +20 -0
- package/src/client/canvas/use-focus-trap.ts +31 -5
- package/src/client/canvas/use-memory-graph.ts +58 -0
- package/src/client/canvas/use-node-drag.ts +2 -1
- package/src/client/canvas/use-pan-zoom.ts +64 -7
- package/src/client/icons.tsx +0 -63
- package/src/client/mcp-app/style.css +0 -6
- package/src/client/nodes/AxStepControls.tsx +74 -50
- package/src/client/nodes/BoardNode.tsx +5 -1
- package/src/client/nodes/ContextNode.tsx +18 -11
- package/src/client/nodes/ExtAppFrame.tsx +0 -1
- package/src/client/nodes/FileNode.tsx +46 -42
- package/src/client/nodes/HtmlNode.tsx +44 -2
- package/src/client/nodes/ImageNode.tsx +1 -1
- package/src/client/nodes/InlineFormatBar.tsx +6 -9
- package/src/client/nodes/InlineMarkdownEditor.tsx +56 -17
- package/src/client/nodes/MarkdownNode.tsx +377 -109
- package/src/client/nodes/McpAppNode.tsx +50 -20
- package/src/client/nodes/MdFormatBar.tsx +21 -18
- package/src/client/nodes/PromptNode.tsx +1 -1
- package/src/client/nodes/ResponseNode.tsx +1 -1
- package/src/client/nodes/viewer-runtime-state.ts +54 -22
- package/src/client/state/attention-bridge.ts +64 -15
- package/src/client/state/boards-store.ts +193 -39
- package/src/client/state/canvas-store.ts +99 -12
- package/src/client/state/context-chip-store.ts +70 -0
- package/src/client/state/context-status-store.ts +53 -0
- package/src/client/state/intent-bridge.ts +51 -30
- package/src/client/state/knowledge-store.ts +43 -0
- package/src/client/state/near-pin-store.ts +115 -6
- package/src/client/state/presence-store.ts +7 -10
- package/src/client/state/presentation.ts +91 -0
- package/src/client/state/relation-store.ts +81 -0
- package/src/client/state/session-store.ts +144 -18
- package/src/client/state/sse-bridge.ts +206 -29
- package/src/client/state/workspace-settings.ts +11 -0
- package/src/client/theme/global.css +2759 -510
- package/src/client/types.ts +12 -0
- package/src/client/utils/keyboard.ts +10 -0
- package/src/client/utils/time-ago.ts +14 -0
- package/src/json-render/renderer/index.css +6 -1
- package/src/json-render/renderer/index.tsx +31 -1
- package/src/mcp/canvas-app.ts +12 -2
- package/src/mcp/context-uri-template.ts +7 -4
- package/src/mcp/server.ts +63 -41
- package/src/mcp/workbench-app.ts +39 -6
- package/src/server/agent-presence.ts +128 -28
- package/src/server/ax-context.ts +27 -13
- package/src/server/board-export.ts +12 -1
- package/src/server/canvas-db.ts +331 -42
- package/src/server/canvas-operations.ts +196 -84
- package/src/server/canvas-provenance.ts +53 -22
- package/src/server/canvas-serialization.ts +2 -0
- package/src/server/canvas-state.ts +950 -122
- package/src/server/canvas-validation.ts +9 -2
- package/src/server/context-brief.ts +345 -38
- package/src/server/context-reads.ts +179 -30
- package/src/server/demo-state.json +355 -24
- package/src/server/edit-summary.ts +74 -0
- package/src/server/html-surface.ts +8 -1
- package/src/server/index.ts +203 -13
- package/src/server/knowledge-lifecycle.ts +304 -0
- package/src/server/lifecycle-status.ts +232 -0
- package/src/server/memory-graph.ts +166 -0
- package/src/server/mutation-history.ts +18 -6
- package/src/server/operations/composites.ts +32 -11
- package/src/server/operations/http.ts +7 -2
- package/src/server/operations/index.ts +4 -0
- package/src/server/operations/mcp.ts +32 -1
- package/src/server/operations/ops/ax-presence.ts +1 -1
- package/src/server/operations/ops/ax-read.ts +57 -6
- package/src/server/operations/ops/ax-timeline.ts +28 -2
- package/src/server/operations/ops/backup.ts +5 -3
- package/src/server/operations/ops/batch.ts +35 -4
- package/src/server/operations/ops/boards.ts +188 -47
- package/src/server/operations/ops/canvas-wire.ts +31 -1
- package/src/server/operations/ops/edges.ts +36 -2
- package/src/server/operations/ops/groups.ts +6 -1
- package/src/server/operations/ops/json-render.ts +5 -1
- package/src/server/operations/ops/lifecycle.ts +262 -0
- package/src/server/operations/ops/memory-graph.ts +45 -0
- package/src/server/operations/ops/nodes.ts +107 -24
- package/src/server/operations/ops/query.ts +281 -68
- package/src/server/operations/ops/snapshots.ts +73 -1
- package/src/server/operations/ops/validate.ts +5 -1
- package/src/server/operations/ops/viewport.ts +97 -3
- package/src/server/operations/registry.ts +77 -26
- package/src/server/placement.ts +29 -0
- package/src/server/scope-fence.ts +78 -6
- package/src/server/server.ts +115 -16
- package/src/server/spatial-analysis.ts +48 -23
- package/src/server/tour-control.ts +82 -0
- package/src/server/wiki-links.ts +137 -0
- package/src/server/workbench-auth.ts +11 -0
- package/src/shared/agent-context.ts +3 -0
- package/src/shared/agent-presence.ts +15 -15
- package/src/shared/auto-arrange.ts +2 -0
- package/src/shared/boards.ts +4 -4
- package/src/shared/knowledge-lifecycle.ts +22 -0
- package/src/shared/knowledge-presets.ts +80 -0
- package/src/shared/lifecycle-status.ts +26 -0
- package/src/shared/memory-graph.ts +74 -0
- package/src/shared/relations.ts +98 -0
- package/src/shared/semantic-attention.ts +15 -1
- package/src/shared/tour.ts +23 -5
- package/src/shared/workspace-settings.ts +13 -0
- package/dist/types/client/canvas/ContextPinBar.d.ts +0 -1
- package/dist/types/client/canvas/FocusFieldLayer.d.ts +0 -1
- package/dist/types/client/canvas/kind-colors.d.ts +0 -3
- package/dist/types/server/agent-context.d.ts +0 -22
- package/src/client/canvas/ContextPinBar.tsx +0 -25
|
@@ -1,24 +1,59 @@
|
|
|
1
1
|
import type { JSX } from 'preact';
|
|
2
|
-
import {
|
|
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,
|
|
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 =
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
218
|
+
const dirty = draft.dirty.value;
|
|
98
219
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
99
|
-
const persistTimerRef =
|
|
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
|
|
102
|
-
const
|
|
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
|
-
|
|
111
|
-
editBase.current = { boardId
|
|
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 (
|
|
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
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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(
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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; //
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
await
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}, [
|
|
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={
|
|
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={
|
|
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
|
|
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
|
|
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
|
/>
|