pmx-canvas 0.4.7 → 0.5.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/extension.mjs +154 -5
- package/.github/extensions/pmx-canvas/steering-delivery.mjs +79 -0
- package/CHANGELOG.md +156 -0
- package/Readme.md +72 -23
- package/dist/canvas/global.css +3069 -796
- package/dist/canvas/index.js +104 -104
- package/dist/json-render/index.css +1 -1
- package/dist/types/cli/agent.d.ts +1 -0
- package/dist/types/cli/commands/pump.d.ts +9 -0
- package/dist/types/client/App.d.ts +1 -1
- package/dist/types/client/canvas/AgentPresenceLayer.d.ts +11 -0
- package/dist/types/client/canvas/BarHint.d.ts +22 -0
- package/dist/types/client/canvas/CanvasNode.d.ts +5 -0
- package/dist/types/client/canvas/CanvasViewport.d.ts +4 -4
- package/dist/types/client/canvas/CommandBar.d.ts +10 -0
- package/dist/types/client/canvas/ConnectionBanner.d.ts +13 -0
- package/dist/types/client/canvas/ContextMenu.d.ts +6 -0
- package/dist/types/client/canvas/EdgeLayer.d.ts +2 -1
- package/dist/types/client/canvas/EmptyState.d.ts +11 -0
- package/dist/types/client/canvas/ExternalWriters.d.ts +5 -0
- package/dist/types/client/canvas/HumanPresenceLayer.d.ts +8 -0
- package/dist/types/client/canvas/Minimap.d.ts +12 -1
- package/dist/types/client/canvas/ScopeFenceLayer.d.ts +8 -0
- package/dist/types/client/canvas/SelectionBar.d.ts +6 -0
- package/dist/types/client/canvas/SessionPanel.d.ts +1 -0
- package/dist/types/client/canvas/SessionReceipt.d.ts +17 -0
- package/dist/types/client/canvas/TextPrompt.d.ts +25 -0
- package/dist/types/client/canvas/ToolRail.d.ts +21 -0
- package/dist/types/client/canvas/TopBar.d.ts +7 -0
- package/dist/types/client/canvas/canvas-area.d.ts +25 -0
- package/dist/types/client/canvas/create-in-view.d.ts +18 -0
- package/dist/types/client/canvas/import-files.d.ts +8 -0
- package/dist/types/client/canvas/kind-colors.d.ts +3 -0
- package/dist/types/client/canvas/use-focus-trap.d.ts +14 -0
- package/dist/types/client/canvas/use-now.d.ts +6 -0
- package/dist/types/client/icons.d.ts +11 -4
- package/dist/types/client/nodes/ExtAppFrame.d.ts +32 -0
- package/dist/types/client/nodes/GroupNode.d.ts +6 -1
- package/dist/types/client/nodes/MarkdownNode.d.ts +2 -0
- package/dist/types/client/nodes/inline-editor-commands.d.ts +3 -4
- package/dist/types/client/state/canvas-store.d.ts +49 -5
- package/dist/types/client/state/human-store.d.ts +26 -0
- package/dist/types/client/state/iframe-mode.d.ts +1 -0
- package/dist/types/client/state/intent-bridge.d.ts +37 -5
- package/dist/types/client/state/node-factory.d.ts +0 -1
- package/dist/types/client/state/presence-store.d.ts +78 -0
- package/dist/types/client/state/session-store.d.ts +176 -0
- package/dist/types/client/types.d.ts +2 -1
- package/dist/types/client/utils/platform.d.ts +2 -0
- package/dist/types/server/agent-presence.d.ts +192 -0
- package/dist/types/server/ax-gate-ttl.d.ts +6 -0
- package/dist/types/server/ax-interaction.d.ts +4 -10
- package/dist/types/server/ax-state-manager.d.ts +21 -2
- package/dist/types/server/ax-state.d.ts +49 -3
- package/dist/types/server/ax-wait.d.ts +13 -0
- package/dist/types/server/canvas-db.d.ts +23 -1
- package/dist/types/server/canvas-operations.d.ts +1 -0
- package/dist/types/server/canvas-state.d.ts +27 -13
- package/dist/types/server/canvas-validation.d.ts +0 -14
- package/dist/types/server/html-surface.d.ts +1 -1
- package/dist/types/server/human-presence.d.ts +40 -0
- package/dist/types/server/index.d.ts +29 -3
- package/dist/types/server/intent-registry.d.ts +7 -0
- package/dist/types/server/mutation-history.d.ts +15 -2
- package/dist/types/server/operations/invoker.d.ts +13 -2
- package/dist/types/server/operations/ops/ax-presence.d.ts +2 -0
- package/dist/types/server/operations/ops/human-presence.d.ts +3 -0
- package/dist/types/server/operations/registry.d.ts +11 -0
- package/dist/types/server/operations/types.d.ts +1 -1
- package/dist/types/server/scope-fence.d.ts +62 -0
- package/dist/types/server/server.d.ts +0 -12
- package/dist/types/shared/agent-presence.d.ts +130 -0
- package/dist/types/shared/approval-gates.d.ts +19 -0
- package/dist/types/shared/auto-arrange.d.ts +0 -1
- package/dist/types/shared/ax-kinds.d.ts +11 -0
- package/dist/types/shared/human-presence.d.ts +31 -0
- package/dist/types/shared/iframe-probe.d.ts +8 -0
- package/dist/types/shared/scope-fence.d.ts +21 -0
- package/docs/RELEASE.md +54 -0
- package/docs/ax-host-adapter-contract.md +30 -14
- package/docs/cli.md +37 -1
- package/docs/environment.md +3 -0
- package/docs/http-api.md +222 -5
- package/docs/mcp.md +6 -5
- package/docs/node-types.md +3 -1
- package/docs/plans/plan-009-tech-debt-backlog.md +13 -0
- package/docs/screenshot.png +0 -0
- package/docs/screenshots/multi-agent-live.png +0 -0
- package/docs/screenshots/workbench-live-dark.png +0 -0
- package/docs/screenshots/workbench-live-light.png +0 -0
- package/docs/sdk.md +1 -1
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +109 -18
- package/skills/pmx-canvas/references/codex-app-adapter.md +28 -0
- package/skills/pmx-canvas/references/full-reference.md +55 -16
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +41 -24
- package/skills/pmx-canvas-orchestration/SKILL.md +56 -2
- package/skills/pmx-canvas-testing/SKILL.md +57 -0
- package/src/cli/agent.ts +1 -0
- package/src/cli/commands/copilot.ts +15 -8
- package/src/cli/commands/nodes.ts +1 -19
- package/src/cli/commands/pump.ts +252 -0
- package/src/cli/index.ts +1 -5
- package/src/cli/shared.ts +1 -1
- package/src/client/App.tsx +209 -585
- package/src/client/canvas/AgentPresenceLayer.tsx +74 -0
- package/src/client/canvas/AttentionHistory.tsx +0 -10
- package/src/client/canvas/BarHint.tsx +49 -0
- package/src/client/canvas/CanvasNode.tsx +488 -136
- package/src/client/canvas/CanvasViewport.tsx +190 -94
- package/src/client/canvas/CommandBar.tsx +140 -0
- package/src/client/canvas/CommandPalette.tsx +133 -58
- package/src/client/canvas/ConnectionBanner.tsx +65 -0
- package/src/client/canvas/ContextMenu.tsx +88 -27
- package/src/client/canvas/ContextPinBar.tsx +2 -2
- package/src/client/canvas/EdgeLayer.tsx +121 -38
- package/src/client/canvas/EmptyState.tsx +93 -0
- package/src/client/canvas/ExpandedNodeOverlay.tsx +64 -118
- package/src/client/canvas/ExternalWriters.tsx +308 -0
- package/src/client/canvas/FocusFieldLayer.tsx +12 -1
- package/src/client/canvas/HumanPresenceLayer.tsx +45 -0
- package/src/client/canvas/IntentLayer.tsx +1 -1
- package/src/client/canvas/Minimap.tsx +117 -190
- package/src/client/canvas/ScopeFenceLayer.tsx +38 -0
- package/src/client/canvas/SelectionBar.tsx +175 -23
- package/src/client/canvas/SessionPanel.tsx +481 -0
- package/src/client/canvas/SessionReceipt.tsx +141 -0
- package/src/client/canvas/ShortcutOverlay.tsx +21 -6
- package/src/client/canvas/SnapshotPanel.tsx +166 -80
- package/src/client/canvas/TextPrompt.tsx +128 -0
- package/src/client/canvas/ToolRail.tsx +447 -0
- package/src/client/canvas/TopBar.tsx +308 -0
- package/src/client/canvas/auto-fit.ts +1 -5
- package/src/client/canvas/canvas-area.ts +41 -0
- package/src/client/canvas/create-in-view.ts +48 -0
- package/src/client/canvas/import-files.ts +64 -0
- package/src/client/canvas/kind-colors.ts +22 -0
- package/src/client/canvas/snap-guides.ts +1 -1
- package/src/client/canvas/use-focus-trap.ts +54 -0
- package/src/client/canvas/use-now.ts +17 -0
- package/src/client/canvas/use-pan-zoom.ts +13 -2
- package/src/client/icons.tsx +57 -18
- package/src/client/nodes/ExtAppFrame.tsx +79 -2
- package/src/client/nodes/GroupNode.tsx +9 -28
- package/src/client/nodes/InlineFormatBar.tsx +4 -3
- package/src/client/nodes/InlineMarkdownEditor.tsx +27 -0
- package/src/client/nodes/MarkdownNode.tsx +59 -24
- package/src/client/nodes/MdFormatBar.tsx +2 -4
- package/src/client/nodes/inline-editor-commands.ts +17 -6
- package/src/client/nodes/use-surface-frame.ts +3 -1
- package/src/client/state/attention-bridge.ts +3 -0
- package/src/client/state/canvas-store.ts +273 -84
- package/src/client/state/human-store.ts +175 -0
- package/src/client/state/iframe-mode.ts +33 -9
- package/src/client/state/intent-bridge.ts +75 -13
- package/src/client/state/node-factory.ts +2 -8
- package/src/client/state/presence-store.ts +168 -0
- package/src/client/state/session-store.ts +419 -0
- package/src/client/state/sse-bridge.ts +93 -30
- package/src/client/theme/global.css +3069 -796
- package/src/client/types.ts +3 -1
- package/src/client/utils/platform.ts +6 -1
- package/src/mcp/canvas-access.ts +2 -2
- package/src/server/agent-presence.ts +755 -0
- package/src/server/ax-context.ts +45 -0
- package/src/server/ax-gate-ttl.ts +61 -0
- package/src/server/ax-interaction.ts +29 -1
- package/src/server/ax-state-manager.ts +78 -6
- package/src/server/ax-state.ts +82 -15
- package/src/server/ax-wait.ts +33 -0
- package/src/server/canvas-db.ts +237 -61
- package/src/server/canvas-operations.ts +17 -8
- package/src/server/canvas-state.ts +131 -58
- package/src/server/canvas-validation.ts +3 -53
- package/src/server/demo-state.json +2 -70
- package/src/server/html-surface.ts +11 -2
- package/src/server/human-presence.ts +160 -0
- package/src/server/index.ts +107 -4
- package/src/server/intent-registry.ts +13 -0
- package/src/server/mutation-history.ts +47 -2
- package/src/server/operations/composites.ts +6 -3
- package/src/server/operations/http.ts +10 -1
- package/src/server/operations/index.ts +4 -0
- package/src/server/operations/invoker.ts +33 -6
- package/src/server/operations/ops/ax-presence.ts +62 -0
- package/src/server/operations/ops/ax-read.ts +8 -0
- package/src/server/operations/ops/ax-state.ts +106 -1
- package/src/server/operations/ops/ax-timeline.ts +54 -5
- package/src/server/operations/ops/ax-work.ts +46 -0
- package/src/server/operations/ops/batch.ts +3 -1
- package/src/server/operations/ops/edges.ts +88 -2
- package/src/server/operations/ops/groups.ts +4 -3
- package/src/server/operations/ops/human-presence.ts +35 -0
- package/src/server/operations/ops/json-render.ts +11 -1
- package/src/server/operations/ops/nodes.ts +65 -16
- package/src/server/operations/ops/query.ts +1 -0
- package/src/server/operations/ops/snapshots.ts +16 -0
- package/src/server/operations/registry.ts +196 -1
- package/src/server/operations/types.ts +1 -1
- package/src/server/scope-fence.ts +207 -0
- package/src/server/server.ts +177 -79
- package/src/server/trace-manager.ts +0 -2
- package/src/server/web-artifacts.ts +0 -1
- package/src/shared/agent-presence.ts +162 -0
- package/src/shared/approval-gates.ts +35 -0
- package/src/shared/auto-arrange.ts +121 -13
- package/src/shared/ax-kinds.ts +23 -0
- package/src/shared/human-presence.ts +30 -0
- package/src/shared/iframe-probe.ts +8 -0
- package/src/shared/scope-fence.ts +31 -0
- package/dist/types/client/canvas/DockedNode.d.ts +0 -4
- package/src/client/canvas/DockedNode.tsx +0 -165
package/src/client/icons.tsx
CHANGED
|
@@ -34,20 +34,6 @@ export function IconFitAll(p: IconProps): JSX.Element {
|
|
|
34
34
|
);
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
/** Framed crosshair — reset view 1:1 */
|
|
38
|
-
export function IconResetView(p: IconProps): JSX.Element {
|
|
39
|
-
return (
|
|
40
|
-
<Icon {...p}>
|
|
41
|
-
<rect x="2" y="2" width="12" height="12" rx="2" />
|
|
42
|
-
<line x1="8" y1="4.5" x2="8" y2="6.5" />
|
|
43
|
-
<line x1="8" y1="9.5" x2="8" y2="11.5" />
|
|
44
|
-
<line x1="4.5" y1="8" x2="6.5" y2="8" />
|
|
45
|
-
<line x1="9.5" y1="8" x2="11.5" y2="8" />
|
|
46
|
-
<circle cx="8" cy="8" r="1" fill="currentColor" stroke="none" />
|
|
47
|
-
</Icon>
|
|
48
|
-
);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
37
|
/** Magnifier with + */
|
|
52
38
|
export function IconZoomIn(p: IconProps): JSX.Element {
|
|
53
39
|
return (
|
|
@@ -214,12 +200,11 @@ export function IconShortcuts(p: IconProps): JSX.Element {
|
|
|
214
200
|
);
|
|
215
201
|
}
|
|
216
202
|
|
|
217
|
-
/**
|
|
218
|
-
export function
|
|
203
|
+
/** Speech bubble — steer the agent (command bar composer). */
|
|
204
|
+
export function IconSteer(p: IconProps): JSX.Element {
|
|
219
205
|
return (
|
|
220
206
|
<Icon {...p}>
|
|
221
|
-
<
|
|
222
|
-
<path d="M8 2 A 6 6 0 0 1 8 14 Z" fill="currentColor" stroke="none" />
|
|
207
|
+
<path d="M2 13 L2 4 A2 2 0 0 1 4 2 L12 2 A2 2 0 0 1 14 4 L14 9 A2 2 0 0 1 12 11 L5 11 Z" />
|
|
223
208
|
</Icon>
|
|
224
209
|
);
|
|
225
210
|
}
|
|
@@ -277,6 +262,60 @@ export function IconLogo({ size = 22, class: className }: IconProps): JSX.Elemen
|
|
|
277
262
|
/* ── Node-type icons · Focus Field family ─────────────────── */
|
|
278
263
|
|
|
279
264
|
/** Framed document with lines — markdown */
|
|
265
|
+
export function IconCursorTool({ size = 15 }: { size?: number }) {
|
|
266
|
+
return (
|
|
267
|
+
<svg
|
|
268
|
+
width={size}
|
|
269
|
+
height={size}
|
|
270
|
+
viewBox="0 0 16 16"
|
|
271
|
+
fill="none"
|
|
272
|
+
stroke="currentColor"
|
|
273
|
+
stroke-width="1.5"
|
|
274
|
+
stroke-linecap="round"
|
|
275
|
+
stroke-linejoin="round"
|
|
276
|
+
aria-hidden="true"
|
|
277
|
+
>
|
|
278
|
+
<path d="M3 2l10 5.5-4.2 1.3L7.5 13z" />
|
|
279
|
+
</svg>
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function IconHandTool({ size = 15 }: { size?: number }) {
|
|
284
|
+
return (
|
|
285
|
+
<svg
|
|
286
|
+
width={size}
|
|
287
|
+
height={size}
|
|
288
|
+
viewBox="0 0 16 16"
|
|
289
|
+
fill="none"
|
|
290
|
+
stroke="currentColor"
|
|
291
|
+
stroke-width="1.5"
|
|
292
|
+
stroke-linecap="round"
|
|
293
|
+
stroke-linejoin="round"
|
|
294
|
+
aria-hidden="true"
|
|
295
|
+
>
|
|
296
|
+
<path d="M5 7.5V3.8a1 1 0 0 1 2 0V7m0-3.9a1 1 0 0 1 2 0V7m0-2.7a1 1 0 0 1 2 0V7.4m0-.9a1 1 0 0 1 2 0v3.2A4.3 4.3 0 0 1 8.7 14H8a4.3 4.3 0 0 1-3.6-2L2.7 9.3a1 1 0 0 1 1.6-1.2L5 9.2" />
|
|
297
|
+
</svg>
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export function IconNodeHtml({ size = 15 }: { size?: number }) {
|
|
302
|
+
return (
|
|
303
|
+
<svg
|
|
304
|
+
width={size}
|
|
305
|
+
height={size}
|
|
306
|
+
viewBox="0 0 16 16"
|
|
307
|
+
fill="none"
|
|
308
|
+
stroke="currentColor"
|
|
309
|
+
stroke-width="1.5"
|
|
310
|
+
stroke-linecap="round"
|
|
311
|
+
stroke-linejoin="round"
|
|
312
|
+
aria-hidden="true"
|
|
313
|
+
>
|
|
314
|
+
<path d="M6 4.5L2.5 8 6 11.5M10 4.5L13.5 8 10 11.5" />
|
|
315
|
+
</svg>
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
280
319
|
export function IconNodeMarkdown(p: IconProps): JSX.Element {
|
|
281
320
|
return (
|
|
282
321
|
<Icon {...p}>
|
|
@@ -65,6 +65,36 @@ export function isWebKitOnlyHost(userAgent: string): boolean {
|
|
|
65
65
|
return /AppleWebKit/.test(userAgent) && !/Chrome|Chromium|CriOS|Edg|Android/.test(userAgent);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Finding N (0.4.7 report): the GitHub Copilot panel's WKWebView reports
|
|
70
|
+
* `document.visibilityState === 'hidden'` CONTINUOUSLY — the workbench document
|
|
71
|
+
* never transitions to 'visible' even with the panel open and on-screen. Two
|
|
72
|
+
* things break as a result: the `visibilitychange` re-arm below can never fire
|
|
73
|
+
* (there is no transition to listen for), and `paint-ok` becomes a false green —
|
|
74
|
+
* the app document happily answers the double-rAF paint probe while the
|
|
75
|
+
* compositor still shows black. The instrumented trail from that session carries
|
|
76
|
+
* `visibility: "hidden"` on essentially every recovery event.
|
|
77
|
+
*
|
|
78
|
+
* So: when the document CLAIMS hidden but the frame is demonstrably on-screen,
|
|
79
|
+
* do not trust the claim — spend one more recovery round as if visible. A host
|
|
80
|
+
* that ever reports 'visible' has a working signal and is left to the
|
|
81
|
+
* visibilitychange path, which keeps a genuinely backgrounded panel quiet.
|
|
82
|
+
*/
|
|
83
|
+
export function shouldAssumeVisibleRearm(options: {
|
|
84
|
+
userAgent: string;
|
|
85
|
+
visibilityState: string;
|
|
86
|
+
rect: { top: number; left: number; bottom: number; right: number; width: number; height: number };
|
|
87
|
+
viewport: { width: number; height: number };
|
|
88
|
+
alreadyRearmed: boolean;
|
|
89
|
+
}): boolean {
|
|
90
|
+
const { userAgent, visibilityState, rect, viewport, alreadyRearmed } = options;
|
|
91
|
+
if (alreadyRearmed) return false;
|
|
92
|
+
if (!isWebKitOnlyHost(userAgent)) return false;
|
|
93
|
+
if (visibilityState !== 'hidden') return false;
|
|
94
|
+
if (rect.width <= 0 || rect.height <= 0) return false;
|
|
95
|
+
return rect.bottom > 0 && rect.right > 0 && rect.top < viewport.height && rect.left < viewport.width;
|
|
96
|
+
}
|
|
97
|
+
|
|
68
98
|
// Finding F (0.2.5, reworked 0.3.1): BOOT-AWARE serialized WebKit remount queue.
|
|
69
99
|
// The black tile is a cold-hydration BURST problem — a single ext-app repaints fine
|
|
70
100
|
// into an idle panel (like a live-created node, or expand+close), but several
|
|
@@ -352,6 +382,9 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
352
382
|
// Recovery ran while the document was hidden (compositing suppressed) — re-arm
|
|
353
383
|
// one fresh remount round when the panel becomes visible (Finding N).
|
|
354
384
|
const bootedWhileHiddenRef = useRef(false);
|
|
385
|
+
// Finding N (0.4.7): at most one "assume visible" round per frame lifetime, so
|
|
386
|
+
// a host that is hidden forever cannot turn the re-arm into a remount loop.
|
|
387
|
+
const assumeVisibleRearmDoneRef = useRef(false);
|
|
355
388
|
const unmountedRef = useRef(false);
|
|
356
389
|
const [status, setStatus] = useState<ExtAppFrameStatus>('loading');
|
|
357
390
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -567,21 +600,64 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
567
600
|
});
|
|
568
601
|
});
|
|
569
602
|
|
|
603
|
+
// Finding N (0.4.7): the Copilot WKWebView never reports 'visible', so the
|
|
604
|
+
// visibilitychange re-arm below is dead code there and the ladder's own
|
|
605
|
+
// paint-ok cannot be trusted. When the document claims hidden but this frame
|
|
606
|
+
// is on-screen, spend one more full recovery round with a fresh budget —
|
|
607
|
+
// the same round the visibilitychange path would have run.
|
|
608
|
+
const ASSUME_VISIBLE_REARM_DELAY_MS = 600;
|
|
609
|
+
const maybeAssumeVisibleRearm = (): void => {
|
|
610
|
+
if (typeof document === 'undefined' || typeof navigator === 'undefined' || typeof window === 'undefined') return;
|
|
611
|
+
const frame = iframeRef.current;
|
|
612
|
+
if (!frame || unmountedRef.current) return;
|
|
613
|
+
if (
|
|
614
|
+
!shouldAssumeVisibleRearm({
|
|
615
|
+
userAgent: navigator.userAgent,
|
|
616
|
+
visibilityState: document.visibilityState,
|
|
617
|
+
rect: frame.getBoundingClientRect(),
|
|
618
|
+
viewport: { width: window.innerWidth, height: window.innerHeight },
|
|
619
|
+
alreadyRearmed: assumeVisibleRearmDoneRef.current,
|
|
620
|
+
})
|
|
621
|
+
)
|
|
622
|
+
return;
|
|
623
|
+
assumeVisibleRearmDoneRef.current = true;
|
|
624
|
+
// Re-check after a beat: a host whose visibility signal merely lagged the
|
|
625
|
+
// panel opening resolves itself here, and the visibilitychange path owns it.
|
|
626
|
+
window.setTimeout(() => {
|
|
627
|
+
if (unmountedRef.current || document.visibilityState === 'visible') return;
|
|
628
|
+
webkitRemountAttemptsRef.current = 0;
|
|
629
|
+
setRecoveryExhausted(false);
|
|
630
|
+
bootedWhileHiddenRef.current = false;
|
|
631
|
+
extAppRecoveryLog(nodeId, 'assume-visible-rearm');
|
|
632
|
+
scheduleWebkitRemount('assume-visible');
|
|
633
|
+
}, ASSUME_VISIBLE_REARM_DELAY_MS);
|
|
634
|
+
};
|
|
635
|
+
|
|
636
|
+
// A paint-ok collected while the document claims hidden proves only that the
|
|
637
|
+
// app's rAF pipeline is alive — NOT that anything composited (Finding N,
|
|
638
|
+
// 0.4.7). Say so in the trail instead of recording an unqualified success.
|
|
639
|
+
const paintOkLabel = (suffix = ''): string =>
|
|
640
|
+
typeof document !== 'undefined' && document.visibilityState === 'hidden'
|
|
641
|
+
? `paint-ok${suffix} (unverified: host hidden)`
|
|
642
|
+
: `paint-ok${suffix}`;
|
|
643
|
+
|
|
570
644
|
const runPaintLadder = async (): Promise<void> => {
|
|
571
645
|
if (expanded) return;
|
|
572
646
|
if (typeof navigator === 'undefined' || typeof window === 'undefined') return;
|
|
573
647
|
if (!isWebKitOnlyHost(navigator.userAgent)) return;
|
|
574
648
|
setPaintPending(true);
|
|
575
649
|
if (await probePaint()) {
|
|
576
|
-
extAppRecoveryLog(nodeId,
|
|
650
|
+
extAppRecoveryLog(nodeId, paintOkLabel());
|
|
577
651
|
setPaintPending(false);
|
|
652
|
+
maybeAssumeVisibleRearm();
|
|
578
653
|
return;
|
|
579
654
|
}
|
|
580
655
|
extAppRecoveryLog(nodeId, 'paint-fail');
|
|
581
656
|
await runSoftExpandCycle();
|
|
582
657
|
if (await probePaint()) {
|
|
583
|
-
extAppRecoveryLog(nodeId, '
|
|
658
|
+
extAppRecoveryLog(nodeId, paintOkLabel(' (after soft-expand)'));
|
|
584
659
|
setPaintPending(false);
|
|
660
|
+
maybeAssumeVisibleRearm();
|
|
585
661
|
return;
|
|
586
662
|
}
|
|
587
663
|
if (unmountedRef.current) return;
|
|
@@ -590,6 +666,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
590
666
|
extAppRecoveryLog(nodeId, 'recovery-exhausted');
|
|
591
667
|
setPaintPending(false);
|
|
592
668
|
setRecoveryExhausted(true);
|
|
669
|
+
maybeAssumeVisibleRearm();
|
|
593
670
|
}
|
|
594
671
|
};
|
|
595
672
|
|
|
@@ -1,37 +1,18 @@
|
|
|
1
|
-
import type { CanvasNodeState } from '../types';
|
|
2
1
|
import { nodes } from '../state/canvas-store';
|
|
2
|
+
import type { CanvasNodeState } from '../types';
|
|
3
3
|
|
|
4
4
|
interface GroupNodeProps {
|
|
5
5
|
node: CanvasNodeState;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Group frame body (rail-chrome-v2 groups v2): the children live in the world
|
|
10
|
+
* layer and the name/count/actions sit on the frame edge, so the body is just
|
|
11
|
+
* the wash — plus a hint while the group is empty.
|
|
12
|
+
*/
|
|
8
13
|
export function GroupNode({ node }: GroupNodeProps) {
|
|
9
14
|
const childIds = (node.data.children as string[]) ?? [];
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const liveChildren = childIds.filter((id) => allNodes.has(id));
|
|
14
|
-
const childCount = liveChildren.length;
|
|
15
|
-
|
|
16
|
-
// Build a type summary for the collapsed view
|
|
17
|
-
const typeCounts: Record<string, number> = {};
|
|
18
|
-
for (const id of liveChildren) {
|
|
19
|
-
const child = allNodes.get(id);
|
|
20
|
-
if (child) typeCounts[child.type] = (typeCounts[child.type] ?? 0) + 1;
|
|
21
|
-
}
|
|
22
|
-
const typeSummary = Object.entries(typeCounts)
|
|
23
|
-
.map(([type, count]) => `${count} ${type}`)
|
|
24
|
-
.join(', ');
|
|
25
|
-
|
|
26
|
-
return (
|
|
27
|
-
<div class="group-node-body">
|
|
28
|
-
<div class="group-summary">
|
|
29
|
-
<span class="group-child-count">
|
|
30
|
-
{childCount} node{childCount !== 1 ? 's' : ''}
|
|
31
|
-
</span>
|
|
32
|
-
{typeSummary && <span class="group-type-summary">{typeSummary}</span>}
|
|
33
|
-
</div>
|
|
34
|
-
{childCount === 0 && <div class="group-empty-hint">Drag nodes here or use the selection bar to group nodes</div>}
|
|
35
|
-
</div>
|
|
36
|
-
);
|
|
15
|
+
const liveChildren = childIds.filter((id) => nodes.value.has(id));
|
|
16
|
+
if (liveChildren.length > 0) return null;
|
|
17
|
+
return <div class="group-empty-hint">Drop nodes here — release while the frame lights up to add them</div>;
|
|
37
18
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { RefObject } from 'preact';
|
|
2
2
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
|
|
3
|
+
import { modChord } from '../utils/platform';
|
|
3
4
|
import { promptAndInsertLink, wrapSelectionInCode } from './inline-editor-commands';
|
|
4
5
|
|
|
5
6
|
const GAP = 8;
|
|
@@ -15,8 +16,8 @@ type Action =
|
|
|
15
16
|
| { kind: 'link'; icon: string; title: string; dividerBefore?: boolean };
|
|
16
17
|
|
|
17
18
|
const ACTIONS: Action[] = [
|
|
18
|
-
{ kind: 'exec', command: 'bold', icon: 'B', title:
|
|
19
|
-
{ kind: 'exec', command: 'italic', icon: 'I', title:
|
|
19
|
+
{ kind: 'exec', command: 'bold', icon: 'B', title: `Bold (${modChord('B')})` },
|
|
20
|
+
{ kind: 'exec', command: 'italic', icon: 'I', title: `Italic (${modChord('I')})` },
|
|
20
21
|
{ kind: 'exec', command: 'strikeThrough', icon: 'S', title: 'Strikethrough' },
|
|
21
22
|
{ kind: 'code', icon: '{ }', title: 'Inline code', dividerBefore: true },
|
|
22
23
|
{ kind: 'block', tag: 'H1', icon: 'H1', title: 'Heading 1', dividerBefore: true },
|
|
@@ -26,7 +27,7 @@ const ACTIONS: Action[] = [
|
|
|
26
27
|
{ kind: 'block', tag: 'BLOCKQUOTE', icon: '❝', title: 'Quote', dividerBefore: true },
|
|
27
28
|
{ kind: 'exec', command: 'insertUnorderedList', icon: '•', title: 'Bullet list' },
|
|
28
29
|
{ kind: 'exec', command: 'insertOrderedList', icon: '1.', title: 'Numbered list' },
|
|
29
|
-
{ kind: 'link', icon: '🔗', title:
|
|
30
|
+
{ kind: 'link', icon: '🔗', title: `Link (${modChord('K')})`, dividerBefore: true },
|
|
30
31
|
];
|
|
31
32
|
|
|
32
33
|
function runAction(action: Action): void {
|
|
@@ -46,6 +46,11 @@ export function InlineMarkdownEditor({
|
|
|
46
46
|
const el = rootRef.current;
|
|
47
47
|
if (!el) return;
|
|
48
48
|
el.innerHTML = initialHtmlRef.current;
|
|
49
|
+
// GFM task checkboxes are live in the editor too: marked emits them
|
|
50
|
+
// `disabled`, which also swallows clicks in the expanded view.
|
|
51
|
+
for (const box of el.querySelectorAll('input[type="checkbox"][disabled]')) {
|
|
52
|
+
box.removeAttribute('disabled');
|
|
53
|
+
}
|
|
49
54
|
}, []);
|
|
50
55
|
|
|
51
56
|
const serialize = useCallback((): string => {
|
|
@@ -59,6 +64,10 @@ export function InlineMarkdownEditor({
|
|
|
59
64
|
}, [onChange, serialize]);
|
|
60
65
|
|
|
61
66
|
const handleSave = useCallback(() => {
|
|
67
|
+
// A blur fired by unmounting (Esc closes the overlay while the editor is
|
|
68
|
+
// focused) reads back an empty tree — saving that would WIPE the document.
|
|
69
|
+
const el = rootRef.current;
|
|
70
|
+
if (!el || !el.isConnected || el.innerHTML === '') return;
|
|
62
71
|
onSave?.(serialize());
|
|
63
72
|
}, [onSave, serialize]);
|
|
64
73
|
|
|
@@ -91,6 +100,23 @@ export function InlineMarkdownEditor({
|
|
|
91
100
|
[handleInput, handleSave],
|
|
92
101
|
);
|
|
93
102
|
|
|
103
|
+
// A checkbox click inside contenteditable toggles the DOM property but
|
|
104
|
+
// fires no `input` event — serialize explicitly (turndown's GFM rule reads
|
|
105
|
+
// `node.checked`) so the tick persists like any other edit.
|
|
106
|
+
const handleClick = useCallback(
|
|
107
|
+
(e: MouseEvent) => {
|
|
108
|
+
const target = e.target as HTMLElement;
|
|
109
|
+
if (target instanceof HTMLInputElement && target.type === 'checkbox') {
|
|
110
|
+
// Serialization parses innerHTML — a string, which carries ATTRIBUTES
|
|
111
|
+
// only. Mirror the clicked property onto the attribute or the tick
|
|
112
|
+
// vanishes on the next round-trip.
|
|
113
|
+
target.toggleAttribute('checked', target.checked);
|
|
114
|
+
handleInput();
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
[handleInput],
|
|
118
|
+
);
|
|
119
|
+
|
|
94
120
|
// Arbitrary rich HTML (Word, the web) would force turndown to sanitize a
|
|
95
121
|
// far wider surface and tends to lose fidelity — plain text is safer.
|
|
96
122
|
const handlePaste = useCallback(
|
|
@@ -114,6 +140,7 @@ export function InlineMarkdownEditor({
|
|
|
114
140
|
onInput={handleInput}
|
|
115
141
|
onKeyDown={handleKeyDown}
|
|
116
142
|
onPaste={handlePaste}
|
|
143
|
+
onClick={handleClick}
|
|
117
144
|
onBlur={handleSave}
|
|
118
145
|
/>
|
|
119
146
|
<InlineFormatBar hostRef={rootRef} onChange={handleInput} />
|
|
@@ -11,12 +11,23 @@ function RenderedMarkdown({
|
|
|
11
11
|
html,
|
|
12
12
|
className,
|
|
13
13
|
style,
|
|
14
|
+
onTaskToggle,
|
|
14
15
|
}: {
|
|
15
16
|
html: string;
|
|
16
17
|
className?: string;
|
|
17
18
|
style?: string | JSX.CSSProperties;
|
|
19
|
+
/** Enables the GFM task checkboxes and reports which one was clicked (document order). */
|
|
20
|
+
onTaskToggle?: (index: number) => void;
|
|
18
21
|
}) {
|
|
19
22
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
23
|
+
// The handler lives in a ref so the DOM effect depends on `html` ALONE. With
|
|
24
|
+
// the inline prop in the deps, every unrelated re-render (bring-to-front on
|
|
25
|
+
// pointerdown, presence frames) replaced the children — and a HUMAN press is
|
|
26
|
+
// ~100ms, so the checkbox under the finger was swapped mid-press and the
|
|
27
|
+
// click never fired. Synthetic test clicks are fast enough to win the race,
|
|
28
|
+
// which is exactly why this shipped "verified".
|
|
29
|
+
const onTaskToggleRef = useRef(onTaskToggle);
|
|
30
|
+
onTaskToggleRef.current = onTaskToggle;
|
|
20
31
|
|
|
21
32
|
useEffect(() => {
|
|
22
33
|
const container = containerRef.current;
|
|
@@ -26,9 +37,48 @@ function RenderedMarkdown({
|
|
|
26
37
|
const template = document.createElement('template');
|
|
27
38
|
template.innerHTML = html;
|
|
28
39
|
container.append(template.content.cloneNode(true));
|
|
40
|
+
if (onTaskToggleRef.current) {
|
|
41
|
+
for (const box of container.querySelectorAll('input[type="checkbox"][disabled]')) {
|
|
42
|
+
box.removeAttribute('disabled');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
29
45
|
}, [html]);
|
|
30
46
|
|
|
31
|
-
|
|
47
|
+
const handleClick = onTaskToggle
|
|
48
|
+
? (e: MouseEvent) => {
|
|
49
|
+
const onToggle = onTaskToggleRef.current;
|
|
50
|
+
const container = containerRef.current;
|
|
51
|
+
if (!container || !onToggle) return;
|
|
52
|
+
const target = e.target as HTMLElement;
|
|
53
|
+
// The box itself, or anywhere in the row's checkbox gutter — a 15px
|
|
54
|
+
// input alone is a miserable target at zoom and in embedded panes
|
|
55
|
+
// that forward clicks imprecisely.
|
|
56
|
+
let box: HTMLInputElement | null = null;
|
|
57
|
+
if (target instanceof HTMLInputElement && target.type === 'checkbox') {
|
|
58
|
+
box = target;
|
|
59
|
+
} else {
|
|
60
|
+
const row = target.closest('li');
|
|
61
|
+
const rowBox = row?.querySelector<HTMLInputElement>(':scope > input[type="checkbox"]');
|
|
62
|
+
if (rowBox && e.clientX <= rowBox.getBoundingClientRect().right + 6) box = rowBox;
|
|
63
|
+
}
|
|
64
|
+
if (!box) return;
|
|
65
|
+
e.stopPropagation();
|
|
66
|
+
const index = [...container.querySelectorAll('input[type="checkbox"]')].indexOf(box);
|
|
67
|
+
if (index >= 0) onToggle(index);
|
|
68
|
+
}
|
|
69
|
+
: undefined;
|
|
70
|
+
|
|
71
|
+
return <div ref={containerRef} class={className} style={style} onClick={handleClick} />;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Flip the index-th GFM task marker in the source (document order matches the rendered boxes). */
|
|
75
|
+
export function toggleTaskMarker(markdown: string, index: number): string {
|
|
76
|
+
let seen = -1;
|
|
77
|
+
return markdown.replace(/^(\s*(?:[-*+]|\d+[.)])\s+\[)([ xX])(\])/gm, (match, pre, state, post) => {
|
|
78
|
+
seen += 1;
|
|
79
|
+
if (seen !== index) return match;
|
|
80
|
+
return `${pre}${state === ' ' ? 'x' : ' '}${post}`;
|
|
81
|
+
});
|
|
32
82
|
}
|
|
33
83
|
|
|
34
84
|
export function MarkdownNode({ node, expanded = false }: { node: CanvasNodeState; expanded?: boolean }) {
|
|
@@ -317,34 +367,19 @@ export function MarkdownNode({ node, expanded = false }: { node: CanvasNodeState
|
|
|
317
367
|
<RenderedMarkdown
|
|
318
368
|
html={rendered}
|
|
319
369
|
style={{ padding: rendered ? '0' : '12px', color: rendered ? undefined : 'var(--c-dim)' }}
|
|
370
|
+
// Task checkboxes are live on the card itself — no need to expand to tick one.
|
|
371
|
+
onTaskToggle={(index) => {
|
|
372
|
+
const next = toggleTaskMarker(content, index);
|
|
373
|
+
if (next !== content) handleInlineSave(next);
|
|
374
|
+
}}
|
|
320
375
|
/>
|
|
321
376
|
{!loaded && <div style={{ color: 'var(--c-dim)', fontStyle: 'italic', padding: '12px' }}>Loading…</div>}
|
|
322
377
|
{loaded && !rendered && (
|
|
323
378
|
<div style={{ color: 'var(--c-dim)', fontStyle: 'italic', padding: '12px' }}>Empty node</div>
|
|
324
379
|
)}
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
style={{
|
|
329
|
-
position: 'absolute',
|
|
330
|
-
top: '4px',
|
|
331
|
-
right: '4px',
|
|
332
|
-
padding: '3px 8px',
|
|
333
|
-
fontSize: '10px',
|
|
334
|
-
background: 'var(--c-panel-overlay)',
|
|
335
|
-
border: '1px solid var(--c-line)',
|
|
336
|
-
borderRadius: '4px',
|
|
337
|
-
color: 'var(--c-text-soft)',
|
|
338
|
-
cursor: 'pointer',
|
|
339
|
-
opacity: 0.7,
|
|
340
|
-
}}
|
|
341
|
-
onMouseEnter={(e) => {
|
|
342
|
-
(e.target as HTMLElement).style.opacity = '1';
|
|
343
|
-
}}
|
|
344
|
-
onMouseLeave={(e) => {
|
|
345
|
-
(e.target as HTMLElement).style.opacity = '0.7';
|
|
346
|
-
}}
|
|
347
|
-
>
|
|
380
|
+
{/* Hover-revealed, solid-backed chip — always-on it sat on the first
|
|
381
|
+
line of content (double-click edits inline; this is discovery). */}
|
|
382
|
+
<button type="button" class="md-edit-btn" onClick={() => expandNode(node.id)}>
|
|
348
383
|
Edit
|
|
349
384
|
</button>
|
|
350
385
|
</div>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
|
2
|
-
import {
|
|
2
|
+
import { modChord } from '../utils/platform';
|
|
3
3
|
import { type FormatAction, FORMAT_ACTIONS, getSelectionRect } from './md-format';
|
|
4
4
|
|
|
5
5
|
const PRIMARY_ACTIONS = FORMAT_ACTIONS.filter((a) => a.shortcut);
|
|
@@ -75,8 +75,6 @@ export function MdFormatBar({ textareaRef }: { textareaRef: { current: HTMLTextA
|
|
|
75
75
|
|
|
76
76
|
if (!visible) return null;
|
|
77
77
|
|
|
78
|
-
const modLabel = isMac ? '⌘' : 'Ctrl';
|
|
79
|
-
|
|
80
78
|
return (
|
|
81
79
|
<div
|
|
82
80
|
ref={barRef}
|
|
@@ -89,7 +87,7 @@ export function MdFormatBar({ textareaRef }: { textareaRef: { current: HTMLTextA
|
|
|
89
87
|
key={a.key}
|
|
90
88
|
type="button"
|
|
91
89
|
class={`md-format-btn md-format-btn-${a.key}`}
|
|
92
|
-
title={`${a.label} (${
|
|
90
|
+
title={`${a.label} (${modChord(a.shortcut!.toUpperCase())})`}
|
|
93
91
|
onClick={() => runAction(a)}
|
|
94
92
|
>
|
|
95
93
|
{a.icon}
|
|
@@ -1,16 +1,27 @@
|
|
|
1
1
|
/** Shared contentEditable command helpers used by both the inline editor
|
|
2
2
|
* and its floating toolbar. Kept in a module that neither component
|
|
3
3
|
* depends on transitively so we don't get a circular import. */
|
|
4
|
+
import { askText } from '../canvas/TextPrompt';
|
|
4
5
|
|
|
5
6
|
/** Prompt for a URL and insert it as a link on the current selection.
|
|
6
7
|
* Rejects `javascript:` and `data:` schemes so a link can't execute script
|
|
7
|
-
* when clicked.
|
|
8
|
+
* when clicked. Uses the in-canvas prompt (`window.prompt` is a silent no-op
|
|
9
|
+
* in embedded panes); the selection is saved before the dialog takes focus
|
|
10
|
+
* and restored before inserting. */
|
|
8
11
|
export function promptAndInsertLink(): void {
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
const selection = window.getSelection();
|
|
13
|
+
const saved = selection && selection.rangeCount > 0 ? selection.getRangeAt(0).cloneRange() : null;
|
|
14
|
+
void askText('Link URL', 'https://example.com').then((url) => {
|
|
15
|
+
if (!url) return;
|
|
16
|
+
const trimmed = url.trim().toLowerCase();
|
|
17
|
+
if (trimmed.startsWith('javascript:') || trimmed.startsWith('data:')) return;
|
|
18
|
+
if (saved) {
|
|
19
|
+
const restore = window.getSelection();
|
|
20
|
+
restore?.removeAllRanges();
|
|
21
|
+
restore?.addRange(saved);
|
|
22
|
+
}
|
|
23
|
+
document.execCommand('createLink', false, url);
|
|
24
|
+
});
|
|
14
25
|
}
|
|
15
26
|
|
|
16
27
|
/** Wrap the current non-empty selection in an inline `<code>` element and
|
|
@@ -27,7 +27,9 @@ export function useSurfaceFrame(url: string): { src?: string; srcdoc?: string }
|
|
|
27
27
|
useEffect(() => {
|
|
28
28
|
if (!wantsSrcdoc) return;
|
|
29
29
|
let cancelled = false;
|
|
30
|
-
|
|
30
|
+
// Ask the server to inline bundle assets (the mermaid renderer): hosts
|
|
31
|
+
// that force srcdoc mode block sub-frame subresource requests too.
|
|
32
|
+
void fetch(`${url}${url.includes('?') ? '&' : '?'}inline-assets=1`, { credentials: 'same-origin' })
|
|
31
33
|
.then((res) => (res.ok ? res.text() : Promise.reject(new Error(`HTTP ${res.status}`))))
|
|
32
34
|
.then((html) => {
|
|
33
35
|
if (!cancelled) setDoc({ url, html });
|
|
@@ -266,6 +266,9 @@ function flushToastQueue(): void {
|
|
|
266
266
|
|
|
267
267
|
function enqueueToast(entry: AttentionEntry): void {
|
|
268
268
|
toastQueue.push(entry);
|
|
269
|
+
// A burst of writes must not replay as a minute of stale toasts — keep the
|
|
270
|
+
// newest few; the history drawer still has every entry.
|
|
271
|
+
if (toastQueue.length > 3) toastQueue.splice(0, toastQueue.length - 3);
|
|
269
272
|
flushToastQueue();
|
|
270
273
|
}
|
|
271
274
|
|