pmx-canvas 0.4.8 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +3071 -796
- package/dist/canvas/index.js +122 -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 +39 -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 +2 -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 +9 -1
- package/dist/types/server/canvas-operations.d.ts +11 -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 +20 -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 +4 -4
- package/skills/pmx-canvas/SKILL.md +112 -17
- 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 +17 -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 +263 -0
- package/src/cli/index.ts +2 -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 +74 -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 +86 -20
- 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 +285 -90
- 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 +3071 -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 +103 -33
- package/src/server/canvas-operations.ts +58 -14
- 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 +111 -6
- 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 +77 -16
- package/src/server/operations/ops/query.ts +1 -0
- package/src/server/operations/ops/snapshots.ts +7 -4
- 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}>
|
|
@@ -30,6 +30,8 @@ type McpUiTheme = 'light' | 'dark';
|
|
|
30
30
|
type ExtAppBridgeNotifications = Pick<AppBridge, 'sendToolInput' | 'sendToolResult'>;
|
|
31
31
|
type DisplayMode = 'inline' | 'fullscreen' | 'pip';
|
|
32
32
|
type ExtAppFrameStatus = 'loading' | 'ready' | 'done';
|
|
33
|
+
/** Paint probe outcome: only a content-carrying tick counts as painted. */
|
|
34
|
+
export type PaintProbeVerdict = 'content-verified' | 'tick-no-content' | 'timeout';
|
|
33
35
|
|
|
34
36
|
interface ExtAppHostDimensionsTarget {
|
|
35
37
|
clientWidth?: number;
|
|
@@ -285,10 +287,28 @@ export function buildExtAppBootBeaconScript(frameToken: string, nodeId: string):
|
|
|
285
287
|
// rendering pipeline is actually running — a frame whose layer WebKit never
|
|
286
288
|
// composites stays silent, and that silence drives the parent's recovery
|
|
287
289
|
// ladder. One unsolicited tick at boot, then on-demand answers to probes.
|
|
290
|
+
// Content oracle (Finding N, 0.5.0 reopen): a double rAF fires even while
|
|
291
|
+
// WKWebKit composites nothing — the tick alone was a false green. The tick
|
|
292
|
+
// now carries a verdict: does this document actually CONTAIN rendered
|
|
293
|
+
// content (a laid-out canvas/svg, or real text)? The parent only accepts
|
|
294
|
+
// paint-ok when it does.
|
|
295
|
+
function contentReady() {
|
|
296
|
+
try {
|
|
297
|
+
var els = document.querySelectorAll('canvas, svg');
|
|
298
|
+
for (var i = 0; i < els.length; i++) {
|
|
299
|
+
var r = els[i].getBoundingClientRect();
|
|
300
|
+
if (r.width > 8 && r.height > 8) return true;
|
|
301
|
+
}
|
|
302
|
+
var body = document.body;
|
|
303
|
+
return !!(body && body.innerText && body.innerText.trim().length > 0);
|
|
304
|
+
} catch (e) {
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
288
308
|
function sendPaintTick() {
|
|
289
309
|
requestAnimationFrame(function () {
|
|
290
310
|
requestAnimationFrame(function () {
|
|
291
|
-
window.parent.postMessage({ source: '${EXT_APP_BOOT_BEACON_SOURCE}', token: TOKEN, nodeId: NODE_ID, kind: 'paint-tick' }, '*');
|
|
311
|
+
window.parent.postMessage({ source: '${EXT_APP_BOOT_BEACON_SOURCE}', token: TOKEN, nodeId: NODE_ID, kind: 'paint-tick', content: contentReady() }, '*');
|
|
292
312
|
});
|
|
293
313
|
});
|
|
294
314
|
}
|
|
@@ -367,8 +387,9 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
367
387
|
const bridgeReadyRef = useRef(false);
|
|
368
388
|
const themeUnsubRef = useRef<(() => void) | null>(null);
|
|
369
389
|
const webkitRemountAttemptsRef = useRef(0);
|
|
370
|
-
// Paint oracle (Finding N): resolvers waiting for the frame's next paint-tick
|
|
371
|
-
|
|
390
|
+
// Paint oracle (Finding N): resolvers waiting for the frame's next paint-tick,
|
|
391
|
+
// each handed the tick's content verdict.
|
|
392
|
+
const paintTickWaitersRef = useRef<Array<(content: boolean) => void>>([]);
|
|
372
393
|
// Genuine boot signal: set ONLY when the app completes the ui/initialize
|
|
373
394
|
// handshake (bridge.oninitialized) — NOT by the 1200ms bootstrap fallback,
|
|
374
395
|
// which flips status via notifications that resolve even into a dead iframe.
|
|
@@ -450,7 +471,8 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
450
471
|
return;
|
|
451
472
|
appScriptsRanRef.current = true;
|
|
452
473
|
if (data.kind === 'paint-tick') {
|
|
453
|
-
|
|
474
|
+
const content = (data as { content?: unknown }).content === true;
|
|
475
|
+
for (const waiter of paintTickWaitersRef.current.splice(0)) waiter(content);
|
|
454
476
|
}
|
|
455
477
|
}
|
|
456
478
|
window.addEventListener('message', onBootBeacon);
|
|
@@ -503,7 +525,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
503
525
|
enqueueWebkitRemount({
|
|
504
526
|
remount: () => {
|
|
505
527
|
remountQueuedRef.current = false;
|
|
506
|
-
if (unmountedRef.current || expandedNodeId.value === nodeId) {
|
|
528
|
+
if (unmountedRef.current || (!expanded && expandedNodeId.value === nodeId)) {
|
|
507
529
|
extAppRecoveryLog(nodeId, 'remount-skipped');
|
|
508
530
|
// The ladder left paintPending set on the promise that this remount
|
|
509
531
|
// would re-enter it via the new frame's settle path. A skip means
|
|
@@ -555,28 +577,42 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
555
577
|
// again → serialized remount (bounded by the shared attempt cap) → explicit
|
|
556
578
|
// Retry affordance. Never silent eternal black. WebKit inline frames only.
|
|
557
579
|
const PAINT_TICK_TIMEOUT_MS = 1500;
|
|
558
|
-
const probePaint = (): Promise<
|
|
580
|
+
const probePaint = (): Promise<PaintProbeVerdict> =>
|
|
559
581
|
new Promise((resolve) => {
|
|
560
582
|
const contentWindow = iframeRef.current?.contentWindow;
|
|
561
583
|
if (!contentWindow || unmountedRef.current) {
|
|
562
|
-
resolve(
|
|
584
|
+
resolve('timeout');
|
|
563
585
|
return;
|
|
564
586
|
}
|
|
565
587
|
let settled = false;
|
|
566
588
|
const timer = window.setTimeout(() => {
|
|
567
589
|
if (settled) return;
|
|
568
590
|
settled = true;
|
|
569
|
-
resolve(
|
|
591
|
+
resolve('timeout');
|
|
570
592
|
}, PAINT_TICK_TIMEOUT_MS);
|
|
571
|
-
paintTickWaitersRef.current.push(() => {
|
|
593
|
+
paintTickWaitersRef.current.push((content) => {
|
|
572
594
|
if (settled) return;
|
|
573
595
|
settled = true;
|
|
574
596
|
window.clearTimeout(timer);
|
|
575
|
-
|
|
597
|
+
// A tick without content is NOT paint-ok: the rAF pipeline runs while
|
|
598
|
+
// the document is empty cream / never composited (Finding N reopen).
|
|
599
|
+
resolve(content ? 'content-verified' : 'tick-no-content');
|
|
576
600
|
});
|
|
577
601
|
contentWindow.postMessage({ source: EXT_APP_PAINT_PROBE_SOURCE, token: axToken }, '*');
|
|
578
602
|
});
|
|
579
603
|
|
|
604
|
+
// Bounded wait for the frame's content oracle to turn green — the mount
|
|
605
|
+
// queue's release gate (guidance: never green-light the NEXT app while the
|
|
606
|
+
// previous document is still contentless; D-then-E was the repro).
|
|
607
|
+
const awaitContentVerified = async (maxMs: number): Promise<boolean> => {
|
|
608
|
+
const deadline = Date.now() + maxMs;
|
|
609
|
+
while (Date.now() < deadline && !unmountedRef.current) {
|
|
610
|
+
if ((await probePaint()) === 'content-verified') return true;
|
|
611
|
+
await new Promise((resolve) => window.setTimeout(resolve, 400));
|
|
612
|
+
}
|
|
613
|
+
return false;
|
|
614
|
+
};
|
|
615
|
+
|
|
580
616
|
// Force a real layer reallocation: grow the iframe's box meaningfully for two
|
|
581
617
|
// frames, then restore. Invisible under the paint-pending overlay.
|
|
582
618
|
const runSoftExpandCycle = (): Promise<void> =>
|
|
@@ -638,29 +674,44 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
638
674
|
// 0.4.7). Say so in the trail instead of recording an unqualified success.
|
|
639
675
|
const paintOkLabel = (suffix = ''): string =>
|
|
640
676
|
typeof document !== 'undefined' && document.visibilityState === 'hidden'
|
|
641
|
-
? `paint-ok${suffix} (
|
|
642
|
-
: `paint-ok${suffix}`;
|
|
677
|
+
? `paint-ok (content-verified)${suffix} (host hidden)`
|
|
678
|
+
: `paint-ok (content-verified)${suffix}`;
|
|
643
679
|
|
|
644
680
|
const runPaintLadder = async (): Promise<void> => {
|
|
645
|
-
|
|
681
|
+
// Runs for the EXPANDED instance too (Finding N reopen: expanded C was a
|
|
682
|
+
// black frame with no recovery — the ladder used to early-return here).
|
|
646
683
|
if (typeof navigator === 'undefined' || typeof window === 'undefined') return;
|
|
647
684
|
if (!isWebKitOnlyHost(navigator.userAgent)) return;
|
|
648
685
|
setPaintPending(true);
|
|
649
|
-
|
|
686
|
+
let verdict = await probePaint();
|
|
687
|
+
if (verdict === 'content-verified') {
|
|
650
688
|
extAppRecoveryLog(nodeId, paintOkLabel());
|
|
651
689
|
setPaintPending(false);
|
|
652
690
|
maybeAssumeVisibleRearm();
|
|
653
691
|
return;
|
|
654
692
|
}
|
|
655
|
-
extAppRecoveryLog(
|
|
693
|
+
extAppRecoveryLog(
|
|
694
|
+
nodeId,
|
|
695
|
+
verdict === 'tick-no-content' ? 'paint-fail (tick without content)' : 'paint-fail (no tick)',
|
|
696
|
+
);
|
|
656
697
|
await runSoftExpandCycle();
|
|
657
|
-
|
|
698
|
+
verdict = await probePaint();
|
|
699
|
+
if (verdict === 'content-verified') {
|
|
658
700
|
extAppRecoveryLog(nodeId, paintOkLabel(' (after soft-expand)'));
|
|
659
701
|
setPaintPending(false);
|
|
660
702
|
maybeAssumeVisibleRearm();
|
|
661
703
|
return;
|
|
662
704
|
}
|
|
663
705
|
if (unmountedRef.current) return;
|
|
706
|
+
// A contentless-but-alive app may still be DRAWING its scene: give the
|
|
707
|
+
// oracle a bounded settle before burning a remount attempt on it.
|
|
708
|
+
if (verdict === 'tick-no-content' && (await awaitContentVerified(3_000))) {
|
|
709
|
+
extAppRecoveryLog(nodeId, paintOkLabel(' (after content wait)'));
|
|
710
|
+
setPaintPending(false);
|
|
711
|
+
maybeAssumeVisibleRearm();
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
if (unmountedRef.current) return;
|
|
664
715
|
// The remounted frame re-enters this ladder from its own settle path.
|
|
665
716
|
if (!scheduleWebkitRemount('paint-fail')) {
|
|
666
717
|
extAppRecoveryLog(nodeId, 'recovery-exhausted');
|
|
@@ -709,7 +760,8 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
709
760
|
// serialized queue (bounded by the shared attempt cap).
|
|
710
761
|
const WEBKIT_BOOT_WATCHDOG_MS = 6000;
|
|
711
762
|
useEffect(() => {
|
|
712
|
-
|
|
763
|
+
// Covers the expanded instance too — its fresh iframe was black with no
|
|
764
|
+
// recovery at all (Finding N reopen, expanded C).
|
|
713
765
|
if (typeof navigator === 'undefined' || typeof window === 'undefined') return;
|
|
714
766
|
if (!isWebKitOnlyHost(navigator.userAgent)) return;
|
|
715
767
|
if (!iframeDocument.ready || !mountGranted) return;
|
|
@@ -725,7 +777,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
725
777
|
() => () => {
|
|
726
778
|
unmountedRef.current = true;
|
|
727
779
|
for (const waiter of bootWaitersRef.current.splice(0)) waiter();
|
|
728
|
-
for (const waiter of paintTickWaitersRef.current.splice(0)) waiter();
|
|
780
|
+
for (const waiter of paintTickWaitersRef.current.splice(0)) waiter(false);
|
|
729
781
|
},
|
|
730
782
|
[],
|
|
731
783
|
);
|
|
@@ -1035,8 +1087,22 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
1035
1087
|
// its own settle pause covering the draw) only from this genuine path;
|
|
1036
1088
|
// the bootstrap fallback never releases it (a dead iframe must run the
|
|
1037
1089
|
// queue's bounded timeout instead of green-lighting the next remount).
|
|
1038
|
-
|
|
1039
|
-
|
|
1090
|
+
// Release the mount queue only once this document's CONTENT
|
|
1091
|
+
// oracle is green (bounded): a settled-but-empty app must not
|
|
1092
|
+
// green-light the next mount slot (D-then-E repro, Finding N
|
|
1093
|
+
// reopen). WebKit only — elsewhere the oracle race doesn't exist.
|
|
1094
|
+
const releaseQueue = () => {
|
|
1095
|
+
for (const waiter of bootWaitersRef.current.splice(0)) waiter();
|
|
1096
|
+
};
|
|
1097
|
+
if (typeof navigator !== 'undefined' && isWebKitOnlyHost(navigator.userAgent)) {
|
|
1098
|
+
void awaitContentVerified(4_000).then((verified) => {
|
|
1099
|
+
extAppRecoveryLog(nodeId, verified ? 'settled (content-verified)' : 'settled (content-timeout)');
|
|
1100
|
+
releaseQueue();
|
|
1101
|
+
});
|
|
1102
|
+
} else {
|
|
1103
|
+
extAppRecoveryLog(nodeId, 'settled');
|
|
1104
|
+
releaseQueue();
|
|
1105
|
+
}
|
|
1040
1106
|
nudgeHostContextAfterLayout();
|
|
1041
1107
|
schedulePaintLadder();
|
|
1042
1108
|
})
|
|
@@ -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
|
|