From c2519275e65f20c1cf980ef805d9fe7a8bb38a52 Mon Sep 17 00:00:00 2001 From: cxxxxxn Date: Fri, 28 Aug 2026 15:38:04 +0800 Subject: [PATCH 01/10] feat(canvas): refine zoom and agent neighbourhood scope --- .../modules/canvas/node-neighbourhood.test.ts | 75 +++++++++++++++++++ .../src/modules/canvas/node-neighbourhood.ts | 60 ++++++++++++++- apps/web/src/config/canvas.ts | 2 +- apps/web/src/hooks/useCanvasGestures.test.ts | 3 +- docs/architecture/agent-context.md | 2 +- docs/architecture/canvas-zoom-rendering.md | 2 +- docs/architecture/question-node.md | 12 +-- 7 files changed, 143 insertions(+), 13 deletions(-) create mode 100644 apps/server/src/modules/canvas/node-neighbourhood.test.ts diff --git a/apps/server/src/modules/canvas/node-neighbourhood.test.ts b/apps/server/src/modules/canvas/node-neighbourhood.test.ts new file mode 100644 index 000000000..497ee79fb --- /dev/null +++ b/apps/server/src/modules/canvas/node-neighbourhood.test.ts @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { describe, expect, it } from 'vitest'; + +import { buildNodeNeighbourhoodContext } from './node-neighbourhood.js'; + +import type { SpatialNode } from '@huabu/shared'; + +function node( + id: string, + x: number, + parentId?: string, + type = 'note', +): SpatialNode { + return { + id, + type, + label: id, + rect: { x, y: 0, width: 100, height: 100 }, + ...(parentId ? { parentId } : {}), + }; +} + +function includedIds( + nodes: SpatialNode[], + edges: Array<{ source: string; target: string }>, +) { + const context = buildNodeNeighbourhoodContext(nodes[0], nodes, edges); + return new Set( + context.layers.flatMap((layer) => + layer.groups.flatMap((group) => group.nodes.map((item) => item.id)), + ), + ); +} + +describe('buildNodeNeighbourhoodContext', () => { + it('uses a narrow default radius for ordinary spatial neighbours', () => { + const ids = includedIds( + [node('anchor', 0), node('nearby', 500), node('distant', 800)], + [], + ); + + expect(ids).toContain('nearby'); + expect(ids).not.toContain('distant'); + }); + + it('retains the containing frame and all direct siblings beyond the radius', () => { + const frame = node('frame', 0, undefined, 'frame'); + const anchor = node('anchor', 0, 'frame'); + const sibling = node('sibling', 5000, 'frame'); + const siblingFrame = node('sibling-frame', 7000, 'frame', 'frame'); + const ids = includedIds([anchor, frame, sibling, siblingFrame], []); + + expect(ids).toContain('frame'); + expect(ids).toContain('sibling'); + expect(ids).toContain('sibling-frame'); + }); + + it('retains every directly connected node beyond the radius', () => { + const anchor = node('anchor', 0); + const connectedSource = node('connected-source', 5000); + const connectedTarget = node('connected-target', 7000); + const ids = includedIds( + [anchor, connectedSource, connectedTarget], + [ + { source: 'connected-source', target: 'anchor' }, + { source: 'anchor', target: 'connected-target' }, + ], + ); + + expect(ids).toContain('connected-source'); + expect(ids).toContain('connected-target'); + }); +}); diff --git a/apps/server/src/modules/canvas/node-neighbourhood.ts b/apps/server/src/modules/canvas/node-neighbourhood.ts index 350f3d78b..2da9bc457 100644 --- a/apps/server/src/modules/canvas/node-neighbourhood.ts +++ b/apps/server/src/modules/canvas/node-neighbourhood.ts @@ -51,6 +51,8 @@ import { getCanvasStore } from '../storage/index.js'; import type { AgentNodePreview } from '../agent/node-ref.js'; import type { CanvasNodeType, SpatialNode } from '@huabu/shared'; +const DEFAULT_NEIGHBOURHOOD_RADIUS = 600; + // ─── Public entry point ───────────────────────────────────────────────────── // ─── Adapter: canvasId + anchorNodeId → NodeNeighbourhoodContext ──────────── @@ -201,7 +203,12 @@ export function buildNodeNeighbourhoodContext( opts?: { maxDistance?: number }, ): NodeNeighbourhoodContext { const nodeById = new Map(allNodes.map((n) => [n.id, n])); - const maxDistance = opts?.maxDistance ?? 2000; + const maxDistance = opts?.maxDistance ?? DEFAULT_NEIGHBOURHOOD_RADIUS; + const connectedIds = new Set(); + for (const edge of edges) { + if (edge.source === anchorNode.id) connectedIds.add(edge.target); + if (edge.target === anchorNode.id) connectedIds.add(edge.source); + } // All content nodes (non-frame, non-self). const contentNodes = allNodes.filter( @@ -214,13 +221,15 @@ export function buildNodeNeighbourhoodContext( // ── Walk from inside-out, starting from the anchor node ── let currentRef: SpatialNode = anchorNode; let currentFrameId: string | null | undefined = anchorNode.parentId; + let isAnchorFrame = true; while (true) { const frame = currentFrameId ? nodeById.get(currentFrameId) : undefined; if (frame) { // ── Inner layer: currentRef vs siblings inside this frame ── - const siblings = contentNodes.filter( + const siblingCandidates = isAnchorFrame ? allNodes : contentNodes; + const siblings = siblingCandidates.filter( (n) => n.parentId === currentFrameId && n.id !== currentRef.id, ); const siblingGroups = buildGroupsFromNodes( @@ -228,7 +237,28 @@ export function buildNodeNeighbourhoodContext( siblings, nodeById, describe, - ).filter((g) => g._minEdgeDist <= maxDistance); + ).filter((g) => isAnchorFrame || g._minEdgeDist <= maxDistance); + + if (isAnchorFrame) { + const frameCenter = rectCenter(frame.rect); + const refCenter = rectCenter(currentRef.rect); + siblingGroups.unshift({ + dx: Math.round(frameCenter.x - refCenter.x), + dy: Math.round(frameCenter.y - refCenter.y), + _minEdgeDist: 0, + arrangement: 'containing frame', + frameId: frame.id, + frameLabel: frame.label, + nodes: [ + describe?.(frame) ?? + buildAgentNodePreview({ + id: frame.id, + type: 'frame' as CanvasNodeType, + ...(frame.label ? { label: frame.label } : {}), + }), + ], + }); + } layers.push({ frameId: frame.id, frameLabel: frame.label, @@ -239,6 +269,7 @@ export function buildNodeNeighbourhoodContext( // Move outward: the frame itself becomes the reference entity. currentRef = frame; currentFrameId = frame.parentId; + isAnchorFrame = false; } else { // ── Outermost layer: currentRef vs everything outside ── // Collect ancestors to exclude. @@ -339,6 +370,29 @@ export function buildNodeNeighbourhoodContext( } } + // Explicit relationships outrank proximity. A connected endpoint may sit + // beyond the radius or inside an outer frame whose descendants are normally + // represented only by that frame, so append any endpoint not already shown. + const includedIds = new Set( + allGroups.flatMap((group) => group.nodes.map((node) => node.id)), + ); + const missingConnectedNodes = [...connectedIds] + .map((id) => nodeById.get(id)) + .filter( + (node): node is SpatialNode => + node !== undefined && !includedIds.has(node.id), + ); + if (missingConnectedNodes.length > 0) { + const connectedGroups = buildGroupsFromNodes( + anchorNode, + missingConnectedNodes, + nodeById, + describe, + ); + layers.push({ groups: connectedGroups }); + allGroups.push(...connectedGroups); + } + // If no layers at all, the node is isolated. if (layers.length === 0 && allGroups.length === 0) { return { layers: [], relevantEdges: [] }; diff --git a/apps/web/src/config/canvas.ts b/apps/web/src/config/canvas.ts index 8b58023fc..febe3e1ea 100644 --- a/apps/web/src/config/canvas.ts +++ b/apps/web/src/config/canvas.ts @@ -23,7 +23,7 @@ export const SNAP_GRID: [number, number] = [GRID_SIZE, GRID_SIZE]; * touch-pinch handlers. Keeping a single source of truth ensures all gesture * paths clamp to the same limits. */ -export const MIN_ZOOM = 0.1; +export const MIN_ZOOM = 0.05; export const MAX_ZOOM = 5; /** Screen-space movement required before a touch gesture becomes a drag. */ diff --git a/apps/web/src/hooks/useCanvasGestures.test.ts b/apps/web/src/hooks/useCanvasGestures.test.ts index ffcc91a97..00093b241 100644 --- a/apps/web/src/hooks/useCanvasGestures.test.ts +++ b/apps/web/src/hooks/useCanvasGestures.test.ts @@ -117,7 +117,8 @@ describe('touch viewport geometry', () => { }); it('clamps touch zoom to the shared canvas range', () => { - expect(clampZoom(0.01)).toBe(0.1); + expect(clampZoom(0.01)).toBe(0.05); + expect(clampZoom(0.05)).toBe(0.05); expect(clampZoom(6)).toBe(5); expect(clampZoom(2)).toBe(2); }); diff --git a/docs/architecture/agent-context.md b/docs/architecture/agent-context.md index 656122d83..6c3d196c2 100644 --- a/docs/architecture/agent-context.md +++ b/docs/architecture/agent-context.md @@ -37,7 +37,7 @@ POST /api/agent (agent.route.ts) `buildChatEnvelope()` ([envelope.ts](../../apps/server/src/modules/agent/conversation/envelope.ts)) renders this turn into tagged blocks: - `` — selected nodes `id/type/label/filename/preview?`, **no content** ([prompt/selected-nodes.ts](../../apps/server/src/modules/agent/conversation/prompt/selected-nodes.ts)) -- `` — spatial neighbourhood of the anchor node (used by prompt/question nodes, [prompt/neighbourhood.ts](../../apps/server/src/modules/agent/conversation/prompt/neighbourhood.ts)). Rendered **only on the live turn**: `rebuildContextMessages` passes `includeNeighbourhood: false`, so replayed history turns drop it while the current turn re-injects a fresh snapshot. This keeps the neighbourhood accurate (never a stale copy) and the committed message prefix cache-friendly (no N stale blocks piling up). The stored envelope keeps its snapshot untouched — this is a render-time gate only. +- `` — bounded spatial neighbourhood of the anchor node (used by prompt/question nodes, [prompt/neighbourhood.ts](../../apps/server/src/modules/agent/conversation/prompt/neighbourhood.ts)). Ordinary spatial neighbours are limited to a 600 px edge-distance radius; every directly connected node, the direct containing Frame, and every direct sibling in that Frame remain included regardless of distance. Rendered **only on the live turn**: `rebuildContextMessages` passes `includeNeighbourhood: false`, so replayed history turns drop it while the current turn re-injects a fresh snapshot. This keeps the neighbourhood accurate (never a stale copy) and the committed message prefix cache-friendly (no N stale blocks piling up). The stored envelope keeps its snapshot untouched — this is a render-time gate only. - `` — skills explicitly invoked via `/cmd` (see §3.2) - attachments → vision parts ([prompt/attachments.ts](../../apps/server/src/modules/agent/conversation/prompt/attachments.ts)) diff --git a/docs/architecture/canvas-zoom-rendering.md b/docs/architecture/canvas-zoom-rendering.md index 9bab705e6..1fc5e425b 100644 --- a/docs/architecture/canvas-zoom-rendering.md +++ b/docs/architecture/canvas-zoom-rendering.md @@ -5,7 +5,7 @@ ## 1. Scope and coordinate spaces -The canvas supports zoom values from `0.1` through `5`, with the shared bounds in [`apps/web/src/config/canvas.ts`](../../apps/web/src/config/canvas.ts) applied to React Flow and the custom pinch handlers. +The canvas supports zoom values from `0.05` through `5`, with the shared bounds in [`apps/web/src/config/canvas.ts`](../../apps/web/src/config/canvas.ts) applied to React Flow and the custom pinch handlers. Zoom-sensitive rendering uses two coordinate spaces deliberately. Canvas-space content participates in the viewport transform and therefore grows or shrinks with the canvas; screen-space overlays are positioned from transformed coordinates but retain stable physical size for controls or labels that must remain operable. diff --git a/docs/architecture/question-node.md b/docs/architecture/question-node.md index c90610194..47f8d7d9a 100644 --- a/docs/architecture/question-node.md +++ b/docs/architecture/question-node.md @@ -144,13 +144,13 @@ All questions run through `/api/agent` ([agent.ts](../../apps/web/src/api/agent. ### 5.3 Spatial context (server-side) -Resolved entirely on the server — no spatial geometry crosses the wire. `renderNodeNeighbourhoodMarkdown(canvasId, anchorNodeId)` ([node-neighbourhood.ts](../../apps/server/src/modules/canvas/node-neighbourhood.ts)) walks inside-out (frame → grandframe → canvas) and serialises a priority-tiered neighbourhood into the agent's preamble: +Resolved entirely on the server — no spatial geometry crosses the wire. `renderNodeNeighbourhoodMarkdown(canvasId, anchorNodeId)` ([node-neighbourhood.ts](../../apps/server/src/modules/canvas/node-neighbourhood.ts)) serialises a bounded, priority-tiered neighbourhood into the agent's preamble: -| Priority | Source | Detail | Why | -| -------- | ---------------------------- | --------------- | --------------------- | -| P0 | edges touching the node | full snippet | explicit user intent | -| P1 | same-frame siblings | summary + label | topically related | -| P2 | distance-sorted nearby nodes | label + snippet | proximity ≈ relevance | +| Priority | Source | Inclusion rule | Why | +| -------- | --------------------------------------------------- | ------------------------------------------- | ------------------------------------ | +| P0 | nodes connected directly to the anchor | always, regardless of distance | explicit user intent | +| P1 | the direct containing Frame and its direct siblings | always, regardless of distance | preserves the anchor's local context | +| P2 | other distance-sorted spatial neighbours | at most 600 px edge-to-edge from the anchor | bounds prompt token consumption | The LLM gets natural-language topology; for exact coordinates it calls `get_space_outline` / `inspect_nodes` on demand. From 627b28bd8c616f1033a9dea223fcf39240cb69ce Mon Sep 17 00:00:00 2001 From: cxxxxxn Date: Fri, 28 Aug 2026 15:40:49 +0800 Subject: [PATCH 02/10] fix(agent): narrow neighbourhood radius to 400px --- apps/server/src/modules/canvas/node-neighbourhood.test.ts | 2 +- apps/server/src/modules/canvas/node-neighbourhood.ts | 2 +- docs/architecture/agent-context.md | 2 +- docs/architecture/question-node.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/server/src/modules/canvas/node-neighbourhood.test.ts b/apps/server/src/modules/canvas/node-neighbourhood.test.ts index 497ee79fb..51869f52b 100644 --- a/apps/server/src/modules/canvas/node-neighbourhood.test.ts +++ b/apps/server/src/modules/canvas/node-neighbourhood.test.ts @@ -37,7 +37,7 @@ function includedIds( describe('buildNodeNeighbourhoodContext', () => { it('uses a narrow default radius for ordinary spatial neighbours', () => { const ids = includedIds( - [node('anchor', 0), node('nearby', 500), node('distant', 800)], + [node('anchor', 0), node('nearby', 500), node('distant', -501)], [], ); diff --git a/apps/server/src/modules/canvas/node-neighbourhood.ts b/apps/server/src/modules/canvas/node-neighbourhood.ts index 2da9bc457..65ca55b18 100644 --- a/apps/server/src/modules/canvas/node-neighbourhood.ts +++ b/apps/server/src/modules/canvas/node-neighbourhood.ts @@ -51,7 +51,7 @@ import { getCanvasStore } from '../storage/index.js'; import type { AgentNodePreview } from '../agent/node-ref.js'; import type { CanvasNodeType, SpatialNode } from '@huabu/shared'; -const DEFAULT_NEIGHBOURHOOD_RADIUS = 600; +const DEFAULT_NEIGHBOURHOOD_RADIUS = 400; // ─── Public entry point ───────────────────────────────────────────────────── diff --git a/docs/architecture/agent-context.md b/docs/architecture/agent-context.md index 6c3d196c2..904db19d7 100644 --- a/docs/architecture/agent-context.md +++ b/docs/architecture/agent-context.md @@ -37,7 +37,7 @@ POST /api/agent (agent.route.ts) `buildChatEnvelope()` ([envelope.ts](../../apps/server/src/modules/agent/conversation/envelope.ts)) renders this turn into tagged blocks: - `` — selected nodes `id/type/label/filename/preview?`, **no content** ([prompt/selected-nodes.ts](../../apps/server/src/modules/agent/conversation/prompt/selected-nodes.ts)) -- `` — bounded spatial neighbourhood of the anchor node (used by prompt/question nodes, [prompt/neighbourhood.ts](../../apps/server/src/modules/agent/conversation/prompt/neighbourhood.ts)). Ordinary spatial neighbours are limited to a 600 px edge-distance radius; every directly connected node, the direct containing Frame, and every direct sibling in that Frame remain included regardless of distance. Rendered **only on the live turn**: `rebuildContextMessages` passes `includeNeighbourhood: false`, so replayed history turns drop it while the current turn re-injects a fresh snapshot. This keeps the neighbourhood accurate (never a stale copy) and the committed message prefix cache-friendly (no N stale blocks piling up). The stored envelope keeps its snapshot untouched — this is a render-time gate only. +- `` — bounded spatial neighbourhood of the anchor node (used by prompt/question nodes, [prompt/neighbourhood.ts](../../apps/server/src/modules/agent/conversation/prompt/neighbourhood.ts)). Ordinary spatial neighbours are limited to a 400 px edge-distance radius; every directly connected node, the direct containing Frame, and every direct sibling in that Frame remain included regardless of distance. Rendered **only on the live turn**: `rebuildContextMessages` passes `includeNeighbourhood: false`, so replayed history turns drop it while the current turn re-injects a fresh snapshot. This keeps the neighbourhood accurate (never a stale copy) and the committed message prefix cache-friendly (no N stale blocks piling up). The stored envelope keeps its snapshot untouched — this is a render-time gate only. - `` — skills explicitly invoked via `/cmd` (see §3.2) - attachments → vision parts ([prompt/attachments.ts](../../apps/server/src/modules/agent/conversation/prompt/attachments.ts)) diff --git a/docs/architecture/question-node.md b/docs/architecture/question-node.md index 47f8d7d9a..a6b2eb442 100644 --- a/docs/architecture/question-node.md +++ b/docs/architecture/question-node.md @@ -150,7 +150,7 @@ Resolved entirely on the server — no spatial geometry crosses the wire. `rende | -------- | --------------------------------------------------- | ------------------------------------------- | ------------------------------------ | | P0 | nodes connected directly to the anchor | always, regardless of distance | explicit user intent | | P1 | the direct containing Frame and its direct siblings | always, regardless of distance | preserves the anchor's local context | -| P2 | other distance-sorted spatial neighbours | at most 600 px edge-to-edge from the anchor | bounds prompt token consumption | +| P2 | other distance-sorted spatial neighbours | at most 400 px edge-to-edge from the anchor | bounds prompt token consumption | The LLM gets natural-language topology; for exact coordinates it calls `get_space_outline` / `inspect_nodes` on demand. From a4dd010dbea08bd1bbc953ba0d3e0481c7093fcd Mon Sep 17 00:00:00 2001 From: cxxxxxn Date: Fri, 28 Aug 2026 17:48:37 +0800 Subject: [PATCH 03/10] fix(canvas): align unframe drag preview geometry --- apps/web/src/handler/liveDragGeometry.test.ts | 57 ++++++++++++++++++- apps/web/src/handler/liveDragGeometry.ts | 22 +++++++ apps/web/src/store/canvasStore.ts | 23 +++++--- .../canvas-command-architecture.md | 2 +- 4 files changed, 95 insertions(+), 9 deletions(-) diff --git a/apps/web/src/handler/liveDragGeometry.test.ts b/apps/web/src/handler/liveDragGeometry.test.ts index 858ba4c56..8d5d60bf8 100644 --- a/apps/web/src/handler/liveDragGeometry.test.ts +++ b/apps/web/src/handler/liveDragGeometry.test.ts @@ -3,7 +3,10 @@ import { describe, expect, it } from 'vitest'; -import { mergeLiveDragGeometry } from './liveDragGeometry'; +import { + compensateDetachedDragPosition, + mergeLiveDragGeometry, +} from './liveDragGeometry'; import type { NestableNode } from '@huabu/shared/canvas-engine'; import type { Node } from '@xyflow/react'; @@ -45,3 +48,55 @@ describe('mergeLiveDragGeometry', () => { }); }); }); + +describe('compensateDetachedDragPosition', () => { + it('keeps the detached world position stable when the previewed parent moves', () => { + const liveNode = { + id: 'dragged', + type: 'note', + parentId: 'frame', + position: { x: 400, y: 400 }, + data: {}, + } as NestableNode; + const projectedNodes = [ + { + id: 'frame', + type: 'frame', + position: { x: 140, y: 140 }, + data: {}, + }, + { + ...liveNode, + parentId: undefined, + position: { x: 500, y: 500 }, + }, + ] as NestableNode[]; + + expect(compensateDetachedDragPosition(liveNode, projectedNodes)).toEqual({ + x: 360, + y: 360, + }); + }); + + it('does not compensate a drag that remains in its parent', () => { + const liveNode = { + id: 'dragged', + type: 'note', + parentId: 'frame', + position: { x: 40, y: 40 }, + data: {}, + } as NestableNode; + + expect( + compensateDetachedDragPosition(liveNode, [ + { + id: 'frame', + type: 'frame', + position: { x: 100, y: 100 }, + data: {}, + } as NestableNode, + liveNode, + ]), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/handler/liveDragGeometry.ts b/apps/web/src/handler/liveDragGeometry.ts index c559233e8..128cd9199 100644 --- a/apps/web/src/handler/liveDragGeometry.ts +++ b/apps/web/src/handler/liveDragGeometry.ts @@ -2,6 +2,7 @@ // Licensed under the MIT license. import { + getAbsolutePosition, getNodeDefaultSize, getNodeSize, type NestableNode, @@ -25,3 +26,24 @@ export function mergeLiveDragGeometry( measured: { ...stored.measured, width, height }, }; } + +export function compensateDetachedDragPosition( + liveNode: NestableNode, + projectedNodes: NestableNode[], +): { x: number; y: number } | null { + if (!liveNode.parentId) return null; + const projectedNode = projectedNodes.find((node) => node.id === liveNode.id); + if (!projectedNode || projectedNode.parentId) return null; + + const projectedNodeAbs = getAbsolutePosition(projectedNodes, liveNode.id); + const projectedParentAbs = getAbsolutePosition( + projectedNodes, + liveNode.parentId, + ); + if (!projectedNodeAbs || !projectedParentAbs) return null; + + return { + x: projectedNodeAbs.x - projectedParentAbs.x, + y: projectedNodeAbs.y - projectedParentAbs.y, + }; +} diff --git a/apps/web/src/store/canvasStore.ts b/apps/web/src/store/canvasStore.ts index 2d2b208a0..7d51aea58 100644 --- a/apps/web/src/store/canvasStore.ts +++ b/apps/web/src/store/canvasStore.ts @@ -59,7 +59,10 @@ import { type CanvasUiIntent, type UiResolverState, } from '@/handler/canvasCommand/uiIntent'; -import { mergeLiveDragGeometry } from '@/handler/liveDragGeometry'; +import { + compensateDetachedDragPosition, + mergeLiveDragGeometry, +} from '@/handler/liveDragGeometry'; import { projectStructuredTargetGeometry } from '@/handler/projectStructuredTargetGeometry'; import { applySnap, @@ -2628,18 +2631,24 @@ const useCanvasStore = create()( ).nodes; const liveById = new Map(liveNodes.map((node) => [node.id, node])); const publishGeometryProjection = (projection: NestableNode[]) => { - const geometryPreviews = projection.filter((node) => { - if (draggedIds.has(node.id)) return false; + const geometryPreviews = projection.flatMap((node) => { const current = liveById.get(node.id); - if (!current) return false; + if (!current) return []; + if (draggedIds.has(node.id)) { + const position = compensateDetachedDragPosition( + current, + projection, + ); + return position ? [{ ...node, position }] : []; + } const currentSize = getNodeSize(current); const nextSize = getNodeSize(node); - return ( + const changed = current.position.x !== node.position.x || current.position.y !== node.position.y || currentSize.width !== nextSize.width || - currentSize.height !== nextSize.height - ); + currentSize.height !== nextSize.height; + return changed ? [node] : []; }); useGesturePreviewStore .getState() diff --git a/docs/architecture/canvas-command-architecture.md b/docs/architecture/canvas-command-architecture.md index 1531d47da..744aa0c82 100644 --- a/docs/architecture/canvas-command-architecture.md +++ b/docs/architecture/canvas-command-architecture.md @@ -119,7 +119,7 @@ What a drop _means_ is resolved by `planStructuredDrop`: which track the dragged The drop target is resolved before the frame-fit preview pass rather than after it, so that pass can skip the frame the drop zone is about to solve anyway; the skipped frame's size is reported from the zone, and only recomputed if the zone fails to resolve. Solving it in both places was the same work twice, with the fit pass's answer discarded. -The preview never touches `canvasStore.nodes`. The complete future geometry is published once through `gesturePreviewStore.nodeGeometryPreviews` and folded into the node array at the render boundary only (`Canvas.tsx`'s `displayNodes`), so React Flow moves and resizes affected Frames and peers — and reroutes their edges — while the authoritative geometry stays exactly as the user left it. Selection HUD geometry builds the same transient tree before resolving nested absolute coordinates. Writing projections onto the real nodes, even through `_setStateNoAutosave`, made a per-tick future state indistinguishable from committed geometry to everything that reads the store: an agent write or history snapshot landing mid-drag would capture geometry the user never committed, and every picker had to strip the preview back off before it could reason about the drag at all. +The preview never touches `canvasStore.nodes`. The complete future geometry is published once through `gesturePreviewStore.nodeGeometryPreviews` and folded into the node array at the render boundary only (`Canvas.tsx`'s `displayNodes`), so React Flow moves and resizes affected Frames and peers — and reroutes their edges — while the authoritative geometry stays exactly as the user left it. Selection HUD geometry builds the same transient tree before resolving nested absolute coordinates. A dragged node that is previewed leaving a Frame keeps its current `parentId` until release so React Flow retains drag ownership, but receives a compensated parent-local preview position derived from the projected detached world position and the projected source-Frame origin; its body, HUD, and eventual detach therefore share one absolute position even when a Hug source Frame moves while shrinking. Writing projections onto the real nodes, even through `_setStateNoAutosave`, made a per-tick future state indistinguishable from committed geometry to everything that reads the store: an agent write or history snapshot landing mid-drag would capture geometry the user never committed, and every picker had to strip the preview back off before it could reason about the drag at all. Two properties follow from keeping it out of the store, rather than being maintained by hand: From ffa84ef0e657dc13ebc9c973abf77e159b64eaf9 Mon Sep 17 00:00:00 2001 From: cxxxxxn Date: Sat, 29 Aug 2026 12:54:46 +0800 Subject: [PATCH 04/10] fix(note): preserve provenance for reference links --- .../__tests__/blockFingerprintParity.test.ts | 34 ++++++++++++++ docs/architecture/note-node.md | 16 +++---- .../__tests__/noteProvenance.executor.test.ts | 26 +++++++++++ .../provenance/blockFingerprint.ts | 44 ++++++++++++++++--- 4 files changed, 107 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/Milkdown/__tests__/blockFingerprintParity.test.ts b/apps/web/src/components/Milkdown/__tests__/blockFingerprintParity.test.ts index 846cb3997..c49ed9d62 100644 --- a/apps/web/src/components/Milkdown/__tests__/blockFingerprintParity.test.ts +++ b/apps/web/src/components/Milkdown/__tests__/blockFingerprintParity.test.ts @@ -74,6 +74,28 @@ async function createHarness(): Promise { const FIXTURES = ['simple.md', 'math.md', 'complex.md', 'ai-half-baked.md']; +const AGENT_MARKDOWN_CASES: Array<[string, string]> = [ + ['blockquote', '> **Finding:** The result changed.\n>\n> Follow-up detail.'], + ['ordered-list-start', '3. Third item\n4. Fourth item'], + ['thematic-break', 'Before\n\n---\n\nAfter'], + ['inline-link', 'See [the documentation](https://example.com "Docs").'], + ['image', '![Diagram](artifacts/diagram.png "Architecture")'], + ['styled-span', 'Important text'], + ['nested-quote-list', '> Summary\n>\n> - First\n> - Second'], + ['mixed-inline-marks', 'This is ***important*** and ~~obsolete~~.'], + ['setext-heading', 'Agent-generated heading\n======================='], + ['indented-code', ' const value = 42;\n console.log(value);'], + [ + 'reference-link', + 'Read [the guide][guide].\n\n[guide]: https://example.com', + ], + ['autolink', 'Contact or visit .'], + ['escaped-punctuation', String.raw`Literal \*stars\* and \[brackets\].`], + ['html-block', '
\nImportant content\n
'], + ['details-block', '
\nDetails\nBody\n
'], + ['definition', 'Term\n: Definition emitted by an agent'], +]; + describe('block-key parity: server(raw md) ↔ client(Milkdown round-trip)', () => { let harness: Harness; beforeAll(async () => { @@ -97,4 +119,16 @@ describe('block-key parity: server(raw md) ↔ client(Milkdown round-trip)', () expect(rawKeys.length).toBe(pmBlockCount); }); } + + it.each(AGENT_MARKDOWN_CASES)( + '%s: agent markdown survives Milkdown fingerprinting', + (_name, raw) => { + const { serialized, pmBlockCount } = harness.roundTrip(raw); + const rawKeys = fingerprintMarkdownKeys(raw); + const roundTrippedKeys = fingerprintMarkdownKeys(serialized); + + expect(roundTrippedKeys).toEqual(rawKeys); + expect(rawKeys.length).toBe(pmBlockCount); + }, + ); }); diff --git a/docs/architecture/note-node.md b/docs/architecture/note-node.md index 852c1aa1c..b20c8eb0a 100644 --- a/docs/architecture/note-node.md +++ b/docs/architecture/note-node.md @@ -113,14 +113,14 @@ An anchor's `javascript:` URL can only be activated by a click — browsers refu These exist only for notes, and none of them are guessable from the node model above. -| Behaviour | What it is | -| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Block provenance | The server stamps agent edits into `data.provenance` at the mutation source; the editor realigns markers to live block keys on re-serialization, and `` renders Accept / Reject / Restore. Edited-block diffs open only from the narrow right-gutter marker hit area, leaving the text body free for reading and selection. A top-level Markdown list remains one provenance action but displays each top-level item as a separate diff row; nested items stay with their parent item. `VITE_PROVENANCE=off` disables it. | -| Block drag-out | Dragging a block out of a note creates a new note and deletes the block from the source as **one** undo entry (`MOVE_NOTE_EXCERPT`). | -| Block move between notes | Dropping a block onto another note deletes and inserts atomically, again as one undo entry (`MOVE_NOTE_BLOCK_INTO_NOTE`). | -| Drop onto a note | Huabu payloads dropped on a note append a block; the copy modifier decides move vs. copy, and locked notes decline the drop so the canvas creates a new node instead. | -| Drop into an open note | The insertion point is read verbatim out of `prosemirror-drop-indicator`'s own state — the exact position the blue bar is drawing — so what the user sees and what lands can never disagree. That plugin targets the nearest block edge at any depth, so content can land inside a nested list item rather than after the whole list. Falls back to appending when no bar was showing. | -| External `.md` import | A `.md` file dropped into `/nodes/` from the OS file manager is picked up by a per-Space watcher and imported as a note — see [canvas-storage.md](./canvas-storage.md). | +| Behaviour | What it is | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Block provenance | The server stamps agent edits into `data.provenance` at the mutation source; the editor realigns markers to live block keys on re-serialization, and `` renders Accept / Reject / Restore. Fingerprints canonicalize reference-style links and images to the inline forms Milkdown emits and exclude their non-rendered definition blocks, so server-authored markers survive the editor round trip. Edited-block diffs open only from the narrow right-gutter marker hit area, leaving the text body free for reading and selection. A top-level Markdown list remains one provenance action but displays each top-level item as a separate diff row; nested items stay with their parent item. `VITE_PROVENANCE=off` disables it. | +| Block drag-out | Dragging a block out of a note creates a new note and deletes the block from the source as **one** undo entry (`MOVE_NOTE_EXCERPT`). | +| Block move between notes | Dropping a block onto another note deletes and inserts atomically, again as one undo entry (`MOVE_NOTE_BLOCK_INTO_NOTE`). | +| Drop onto a note | Huabu payloads dropped on a note append a block; the copy modifier decides move vs. copy, and locked notes decline the drop so the canvas creates a new node instead. | +| Drop into an open note | The insertion point is read verbatim out of `prosemirror-drop-indicator`'s own state — the exact position the blue bar is drawing — so what the user sees and what lands can never disagree. That plugin targets the nearest block edge at any depth, so content can land inside a nested list item rather than after the whole list. Falls back to appending when no bar was showing. | +| External `.md` import | A `.md` file dropped into `/nodes/` from the OS file manager is picked up by a per-Space watcher and imported as a note — see [canvas-storage.md](./canvas-storage.md). | --- diff --git a/packages/shared/src/canvas-engine/__tests__/noteProvenance.executor.test.ts b/packages/shared/src/canvas-engine/__tests__/noteProvenance.executor.test.ts index 9d90bb54b..87f600e8b 100644 --- a/packages/shared/src/canvas-engine/__tests__/noteProvenance.executor.test.ts +++ b/packages/shared/src/canvas-engine/__tests__/noteProvenance.executor.test.ts @@ -4,6 +4,7 @@ import { describe, it, expect } from 'vitest'; import { executeCanvasCommands } from '../index.js'; +import { fingerprintMarkdownKeys } from '../provenance/blockFingerprint.js'; import type { CanvasExecutionSource, @@ -89,4 +90,29 @@ describe('executeCanvasCommands: AI note provenance', () => { expect(provenance?.blocks ?? []).toHaveLength(0); expect(provenance?.deletedBlocks ?? []).toHaveLength(0); }); + + it('canonicalizes reference links to the same visible block as Milkdown', () => { + const reference = + 'Read [the guide][guide].\n\n[guide]: https://example.com "Docs"'; + const inline = 'Read [the guide](https://example.com "Docs").'; + + expect(fingerprintMarkdownKeys(reference)).toEqual( + fingerprintMarkdownKeys(inline), + ); + + const start = note('n1', { content: 'Read the old guide.' }); + const { provenance } = runContentEdit('agent', start, reference); + expect(provenance?.blocks).toHaveLength(1); + expect(provenance?.blocks[0]?.key).toBe(fingerprintMarkdownKeys(inline)[0]); + }); + + it('canonicalizes reference images to the same visible block as Milkdown', () => { + const reference = + '![Diagram][diagram]\n\n[diagram]: artifacts/diagram.png "Architecture"'; + const inline = '![Diagram](artifacts/diagram.png "Architecture")'; + + expect(fingerprintMarkdownKeys(reference)).toEqual( + fingerprintMarkdownKeys(inline), + ); + }); }); diff --git a/packages/shared/src/canvas-engine/provenance/blockFingerprint.ts b/packages/shared/src/canvas-engine/provenance/blockFingerprint.ts index 188d9f927..5f22fbec8 100644 --- a/packages/shared/src/canvas-engine/provenance/blockFingerprint.ts +++ b/packages/shared/src/canvas-engine/provenance/blockFingerprint.ts @@ -136,14 +136,41 @@ function isBreakPlaceholder(node: MdastNode): boolean { * Recursively project an mdast node to a canonical, style-independent * form suitable for hashing. */ -function normalizeMdast(node: MdastNode): unknown { +function normalizeMdast( + node: MdastNode, + definitions: ReadonlyMap, +): unknown { + if (node.type === 'linkReference' || node.type === 'imageReference') { + const identifier = + typeof node.identifier === 'string' ? node.identifier : undefined; + const definition = identifier ? definitions.get(identifier) : undefined; + if (definition) { + return normalizeMdast( + node.type === 'linkReference' + ? { + type: 'link', + title: definition.title ?? null, + url: definition.url, + children: node.children, + } + : { + type: 'image', + title: definition.title ?? null, + url: definition.url, + alt: node.alt, + }, + definitions, + ); + } + } + const out: Record = {}; for (const [k, v] of Object.entries(node)) { if (VOLATILE_FIELDS.has(k)) continue; if (k === 'children' && Array.isArray(v)) { const kids = (v as MdastNode[]) .filter((child) => !isBreakPlaceholder(child)) - .map(normalizeMdast); + .map((child) => normalizeMdast(child, definitions)); // Omit empty children so a cell rendered `
` by one host and // left blank by the other collapse to the same shape. if (kids.length > 0) out.children = kids; @@ -171,7 +198,7 @@ function parseTopLevel(markdown: string): MdastNode[] { * block. Two blocks with identical normalized content hash equal. */ export function fingerprintMdastBlock(node: MdastNode): string { - return hash(stableStringify(normalizeMdast(node))); + return hash(stableStringify(normalizeMdast(node, new Map()))); } /** @@ -183,10 +210,17 @@ export function fingerprintMdastBlock(node: MdastNode): string { export function fingerprintMarkdownBlocks( markdown: string, ): FingerprintedBlock[] { - const blocks = parseTopLevel(markdown); + const parsed = parseTopLevel(markdown); + const definitions = new Map(); + for (const node of parsed) { + if (node.type === 'definition' && typeof node.identifier === 'string') { + definitions.set(node.identifier, node); + } + } + const blocks = parsed.filter((node) => node.type !== 'definition'); const counts = new Map(); return blocks.map((node) => { - const base = fingerprintMdastBlock(node); + const base = hash(stableStringify(normalizeMdast(node, definitions))); const n = (counts.get(base) ?? 0) + 1; counts.set(base, n); const key = n === 1 ? base : `${base}#${n}`; From 7cbbee1d8337b03a5547c0d840cd331a076b0250 Mon Sep 17 00:00:00 2001 From: cxxxxxn Date: Sun, 30 Aug 2026 13:45:18 +0800 Subject: [PATCH 05/10] feat(notes): normalize math delimiters and align provenance fingerprints --- .../__tests__/blockFingerprintParity.test.ts | 6 +- .../src/components/Milkdown/markdownUtils.ts | 259 +----------------- docs/architecture/note-node.md | 16 +- .../__tests__/noteProvenance.executor.test.ts | 25 ++ packages/shared/src/canvas-engine/index.ts | 1 + .../provenance/blockFingerprint.ts | 7 +- .../provenance/normalizeMathDelimiters.ts | 148 ++++++++++ 7 files changed, 194 insertions(+), 268 deletions(-) create mode 100644 packages/shared/src/canvas-engine/provenance/normalizeMathDelimiters.ts diff --git a/apps/web/src/components/Milkdown/__tests__/blockFingerprintParity.test.ts b/apps/web/src/components/Milkdown/__tests__/blockFingerprintParity.test.ts index c49ed9d62..6ac41006e 100644 --- a/apps/web/src/components/Milkdown/__tests__/blockFingerprintParity.test.ts +++ b/apps/web/src/components/Milkdown/__tests__/blockFingerprintParity.test.ts @@ -30,6 +30,8 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { fingerprintMarkdownKeys } from '@huabu/shared/canvas-engine'; +import { normalizeMathDelimiters } from '../markdownUtils'; + import type { Node as PMNode } from '@milkdown/prose/model'; const here = dirname(fileURLToPath(import.meta.url)); @@ -60,7 +62,7 @@ async function createHarness(): Promise { return crepe.editor.action((ctx) => { const parser = ctx.get(parserCtx); const serializer = ctx.get(serializerCtx); - const doc = parser(markdown) as PMNode | null; + const doc = parser(normalizeMathDelimiters(markdown)) as PMNode | null; if (!doc) return { serialized: '', pmBlockCount: 0 }; return { serialized: serializer(doc), pmBlockCount: doc.childCount }; }); @@ -91,6 +93,8 @@ const AGENT_MARKDOWN_CASES: Array<[string, string]> = [ ], ['autolink', 'Contact or visit .'], ['escaped-punctuation', String.raw`Literal \*stars\* and \[brackets\].`], + ['latex-inline-math', String.raw`The result is \(x + y\).`], + ['latex-display-math', String.raw`\[x^2 + y^2 = z^2\]`], ['html-block', '
\nImportant content\n
'], ['details-block', '
\nDetails\nBody\n
'], ['definition', 'Term\n: Definition emitted by an agent'], diff --git a/apps/web/src/components/Milkdown/markdownUtils.ts b/apps/web/src/components/Milkdown/markdownUtils.ts index 79112d883..0528ed54d 100644 --- a/apps/web/src/components/Milkdown/markdownUtils.ts +++ b/apps/web/src/components/Milkdown/markdownUtils.ts @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. +export { normalizeMathDelimiters } from '@huabu/shared/canvas-engine'; + /** * Single source of truth for markdown string handling in the Milkdown layer. * @@ -66,260 +68,3 @@ export function markdownEquals(a: string, b: string): boolean { if (a === b) return true; return normalizeMarkdown(a) === normalizeMarkdown(b); } - -/** - * Convert LaTeX-style math delimiters (`\[…\]`, `\(…\)`) used by many - * AI assistants into the canonical Markdown math delimiters that - * `remark-math` (Crepe's `latex` feature) understands: `$$…$$` for - * display math and `$…$` for inline math. - * - * Why this lives at the Milkdown entry point: - * - `remark-math` only supports `$…$` / `$$…$$`. There is no - * upstream option to accept `\[…\]` / `\(…\)`. - * - LLMs frequently emit the LaTeX forms, so we normalize on the - * way IN to the editor. - * - * SCOPE — per product decision this helper targets AI-generated - * content only. The CommonMark escape sequence `\[` (literal `[`) - * will be incorrectly converted by this function; we accept that - * trade-off because users are not expected to type LaTeX-style - * bracket escapes in this app. - * - * Safeguards still in place: - * - Fenced code blocks (` ``` ` / `~~~`) are skipped entirely so - * LaTeX SOURCE pasted into a code block remains literal. - * - Inline code spans (`` ` ``) are skipped for the same reason. - * - Unpaired `\[` or `\(` (e.g. a partial AI stream chunk) is - * left alone — only matched pairs are rewritten. The closing - * delimiter arriving in a later chunk completes the rewrite. - * - * Output shape: - * - `\[…\]` is emitted as a block-form math paragraph - * (`\n\n$$\n\n$$\n\n`) so that `remark-math` parses it as - * display math (it requires the opening / closing `$$` to sit on - * their own lines). - * - `\(…\)` is emitted as inline math `$$` and is not - * allowed to span newlines. - * - `$$…$$` whose inner contains a newline (e.g. AI emits the - * opener / closer glued to `\begin{aligned}` / `\end{aligned}` - * on the same line as content) is rewritten to the same - * canonical paragraph-form block. Single-line `$$x$$` is left - * alone because `micromark-extension-math` accepts it as - * inline-style block math; multi-line is the only shape - * `remark-math` rejects without the fences on their own lines. - * - The transformation is idempotent: running it on the converted - * output is a no-op. - */ -/** - * NUL-byte sentinel that stands in for fenced code segments while we - * collapse stray blank lines on the rest of the document. NUL is not - * valid in markdown text we receive, which makes it safe to use as a - * marker without escaping. - */ -const FENCE_SENTINEL = '\x00\x00FENCE\x00\x00'; - -export function normalizeMathDelimiters(md: string): string { - if (!md) return md; - // Replace each fenced code segment with a sentinel so we can safely - // collapse runs of 3+ newlines on the rest of the document in a - // single pass — a run of `\n\n\n+` outside code is always either an - // artefact of the `\n\n…\n\n` padding we add around block math, or - // of a seam between a converted outside segment and its neighbour, - // and is never semantically meaningful. The sentinel keeps code - // content (which may legitimately contain blank lines) verbatim. - const codes: string[] = []; - const stitched = splitFencedCode(md) - .map((seg) => { - if (!seg.isCode) return convertOutsideCode(seg.text); - codes.push(seg.text); - return FENCE_SENTINEL; - }) - .join('\n') - .replace(/\n{3,}/g, '\n\n'); - // Stitch the original code segments back in. `split` gives us - // `codes.length + 1` pieces interleaved with the sentinel positions, - // so a simple zip-and-join reproduces the document. - const parts = stitched.split(FENCE_SENTINEL); - if (parts.length === 1) return parts[0]; - let out = parts[0]; - for (let i = 1; i < parts.length; i++) { - out += codes[i - 1] + parts[i]; - } - return out; -} - -interface MarkdownSegment { - text: string; - isCode: boolean; -} - -/** - * Cheap CommonMark fenced-code splitter. Splits the input into a - * sequence of segments, each marked as either fenced code (left - * verbatim) or outside-code (eligible for math delimiter rewriting). - * - * Not a full CommonMark parser — it intentionally only recognises - * fences that: - * - start with up to three spaces of indent, - * - are made of three or more ` ` ` or `~` characters, - * - close with the same character at the same or greater length on - * a line that contains nothing but fence characters. - * - * Joining the segments back with a single `\n` reproduces the original - * input exactly. - */ -function splitFencedCode(md: string): MarkdownSegment[] { - const lines = md.split('\n'); - const out: MarkdownSegment[] = []; - let codeStart = -1; - let outsideStart = 0; - let fenceChar: string | null = null; - let fenceLen = 0; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - const match = /^ {0,3}(`{3,}|~{3,})/.exec(line); - if (match && fenceChar === null) { - if (outsideStart < i) { - out.push({ - text: lines.slice(outsideStart, i).join('\n'), - isCode: false, - }); - } else if (outsideStart === i && i > 0) { - // Two adjacent fences with no outside content between them — - // emit an empty outside segment to preserve the `\n` between - // the closing and opening fences when we rejoin. - out.push({ text: '', isCode: false }); - } - codeStart = i; - fenceChar = match[1][0]; - fenceLen = match[1].length; - continue; - } - if (match && fenceChar !== null) { - const ch = match[1][0]; - const len = match[1].length; - if (ch === fenceChar && len >= fenceLen && line.trim().length === len) { - out.push({ - text: lines.slice(codeStart, i + 1).join('\n'), - isCode: true, - }); - outsideStart = i + 1; - codeStart = -1; - fenceChar = null; - fenceLen = 0; - } - } - } - - if (fenceChar !== null) { - // Unclosed fence — treat the trailing region as code so the - // (likely in-progress) source isn't accidentally rewritten. - out.push({ text: lines.slice(codeStart).join('\n'), isCode: true }); - } else if (outsideStart < lines.length) { - out.push({ text: lines.slice(outsideStart).join('\n'), isCode: false }); - } else if (outsideStart === lines.length && out.length > 0) { - // The doc ended exactly at a closing fence; nothing to emit. - } - - return out; -} - -/** - * Walk the segment splitting out inline code spans (`` `…` ``, - * `` ``…`` ``, etc.) so we never rewrite math-like content that the - * author put inside backticks. - * - * Implemented with index-based scanning (no `text.slice` inside the - * loop) so cost is strictly O(N) regardless of how many backtick runs - * the input contains. - */ -function convertOutsideCode(text: string): string { - if (!text) return text; - // Fast path: no backticks → no inline code spans to protect. - if (text.indexOf('`') === -1) return convertMathInPlain(text); - const out: string[] = []; - let i = 0; - while (i < text.length) { - const tickStart = text.indexOf('`', i); - if (tickStart === -1) { - out.push(convertMathInPlain(text.slice(i))); - break; - } - if (tickStart > i) { - out.push(convertMathInPlain(text.slice(i, tickStart))); - } - // Count the backtick run in place. - let runEnd = tickStart + 1; - while (runEnd < text.length && text.charCodeAt(runEnd) === 96 /* ` */) { - runEnd++; - } - const runLen = runEnd - tickStart; - // Find the matching closing run of the same length starting at `runEnd`. - let closeStart = runEnd; - let matched = -1; - while (closeStart < text.length) { - const candidate = text.indexOf('`', closeStart); - if (candidate === -1) break; - let candidateEnd = candidate + 1; - while ( - candidateEnd < text.length && - text.charCodeAt(candidateEnd) === 96 - ) { - candidateEnd++; - } - if (candidateEnd - candidate === runLen) { - matched = candidateEnd; - break; - } - closeStart = candidateEnd; - } - if (matched === -1) { - // Unmatched backtick run — treat the tail as plain text. - out.push(convertMathInPlain(text.slice(tickStart))); - break; - } - out.push(text.slice(tickStart, matched)); - i = matched; - } - return out.join(''); -} - -function convertMathInPlain(text: string): string { - // Block math: \[ … \]. The inner is matched lazily and is not - // allowed to contain a `\]` (so multiple block formulas on the - // same line don't fuse into one). - let out = text.replace( - /\\\[((?:(?!\\\])[\s\S])*)\\\]/g, - (_match, inner: string) => { - const trimmed = inner.trim(); - return `\n\n$$\n${trimmed}\n$$\n\n`; - }, - ); - // Block math: multi-line `$$ … $$`. `micromark-extension-math` - // requires the opening and closing `$$` to sit on their own - // lines for block math. AI assistants frequently emit a tight - // form (`$$\begin{aligned}\n…\n\end{aligned}$$`) that fails to - // parse and renders as broken inline math. Rewrite any `$$…$$` - // whose inner spans a newline AND whose fences are glued to - // content. Single-line `$$x$$` and already-canonical - // `$$\n…\n$$` are left untouched (the latter to keep the - // transform idempotent). - out = out.replace( - /\$\$((?:(?!\$\$)[\s\S])*?\n(?:(?!\$\$)[\s\S])*?)\$\$/g, - (match, inner: string) => { - const openerGlued = !inner.startsWith('\n'); - const closerGlued = !inner.endsWith('\n'); - if (!openerGlued && !closerGlued) return match; - const trimmed = inner.trim(); - return `\n\n$$\n${trimmed}\n$$\n\n`; - }, - ); - // Inline math: \( … \). Disallow newlines inside the content so a - // stray `\(` doesn't swallow paragraphs of text. - out = out.replace( - /\\\(((?:(?!\\\))[^\n])*)\\\)/g, - (_match, inner: string) => `$${inner}$`, - ); - return out; -} diff --git a/docs/architecture/note-node.md b/docs/architecture/note-node.md index b20c8eb0a..f2fc87d86 100644 --- a/docs/architecture/note-node.md +++ b/docs/architecture/note-node.md @@ -113,14 +113,14 @@ An anchor's `javascript:` URL can only be activated by a click — browsers refu These exist only for notes, and none of them are guessable from the node model above. -| Behaviour | What it is | -| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Block provenance | The server stamps agent edits into `data.provenance` at the mutation source; the editor realigns markers to live block keys on re-serialization, and `` renders Accept / Reject / Restore. Fingerprints canonicalize reference-style links and images to the inline forms Milkdown emits and exclude their non-rendered definition blocks, so server-authored markers survive the editor round trip. Edited-block diffs open only from the narrow right-gutter marker hit area, leaving the text body free for reading and selection. A top-level Markdown list remains one provenance action but displays each top-level item as a separate diff row; nested items stay with their parent item. `VITE_PROVENANCE=off` disables it. | -| Block drag-out | Dragging a block out of a note creates a new note and deletes the block from the source as **one** undo entry (`MOVE_NOTE_EXCERPT`). | -| Block move between notes | Dropping a block onto another note deletes and inserts atomically, again as one undo entry (`MOVE_NOTE_BLOCK_INTO_NOTE`). | -| Drop onto a note | Huabu payloads dropped on a note append a block; the copy modifier decides move vs. copy, and locked notes decline the drop so the canvas creates a new node instead. | -| Drop into an open note | The insertion point is read verbatim out of `prosemirror-drop-indicator`'s own state — the exact position the blue bar is drawing — so what the user sees and what lands can never disagree. That plugin targets the nearest block edge at any depth, so content can land inside a nested list item rather than after the whole list. Falls back to appending when no bar was showing. | -| External `.md` import | A `.md` file dropped into `/nodes/` from the OS file manager is picked up by a per-Space watcher and imported as a note — see [canvas-storage.md](./canvas-storage.md). | +| Behaviour | What it is | +| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Block provenance | The server stamps agent edits into `data.provenance` at the mutation source; the editor realigns markers to live block keys on re-serialization, and `` renders Accept / Reject / Restore. Fingerprints run the same math-delimiter normalization as Milkdown, canonicalize reference-style links and images to the inline forms Milkdown emits, and exclude their non-rendered definition blocks, so server-authored markers survive the editor round trip. Edited-block diffs open only from the narrow right-gutter marker hit area, leaving the text body free for reading and selection. A top-level Markdown list remains one provenance action but displays each top-level item as a separate diff row; nested items stay with their parent item. `VITE_PROVENANCE=off` disables it. | +| Block drag-out | Dragging a block out of a note creates a new note and deletes the block from the source as **one** undo entry (`MOVE_NOTE_EXCERPT`). | +| Block move between notes | Dropping a block onto another note deletes and inserts atomically, again as one undo entry (`MOVE_NOTE_BLOCK_INTO_NOTE`). | +| Drop onto a note | Huabu payloads dropped on a note append a block; the copy modifier decides move vs. copy, and locked notes decline the drop so the canvas creates a new node instead. | +| Drop into an open note | The insertion point is read verbatim out of `prosemirror-drop-indicator`'s own state — the exact position the blue bar is drawing — so what the user sees and what lands can never disagree. That plugin targets the nearest block edge at any depth, so content can land inside a nested list item rather than after the whole list. Falls back to appending when no bar was showing. | +| External `.md` import | A `.md` file dropped into `/nodes/` from the OS file manager is picked up by a per-Space watcher and imported as a note — see [canvas-storage.md](./canvas-storage.md). | --- diff --git a/packages/shared/src/canvas-engine/__tests__/noteProvenance.executor.test.ts b/packages/shared/src/canvas-engine/__tests__/noteProvenance.executor.test.ts index 87f600e8b..13fc6d5c9 100644 --- a/packages/shared/src/canvas-engine/__tests__/noteProvenance.executor.test.ts +++ b/packages/shared/src/canvas-engine/__tests__/noteProvenance.executor.test.ts @@ -115,4 +115,29 @@ describe('executeCanvasCommands: AI note provenance', () => { fingerprintMarkdownKeys(inline), ); }); + + it('canonicalizes LaTeX-style inline math before stamping provenance', () => { + const latexDelimiters = String.raw`The result is \(x + y\).`; + const milkdownDelimiters = 'The result is $x + y$.'; + + expect(fingerprintMarkdownKeys(latexDelimiters)).toEqual( + fingerprintMarkdownKeys(milkdownDelimiters), + ); + + const start = note('n1', { content: 'The old result.' }); + const { provenance } = runContentEdit('agent', start, latexDelimiters); + expect(provenance?.blocks).toHaveLength(1); + expect(provenance?.blocks[0]?.key).toBe( + fingerprintMarkdownKeys(milkdownDelimiters)[0], + ); + }); + + it('canonicalizes LaTeX-style display math to Milkdown block math', () => { + const latexDelimiters = String.raw`\[x^2 + y^2 = z^2\]`; + const milkdownDelimiters = '$$\nx^2 + y^2 = z^2\n$$'; + + expect(fingerprintMarkdownKeys(latexDelimiters)).toEqual( + fingerprintMarkdownKeys(milkdownDelimiters), + ); + }); }); diff --git a/packages/shared/src/canvas-engine/index.ts b/packages/shared/src/canvas-engine/index.ts index 103ab184c..2dcbcda9b 100644 --- a/packages/shared/src/canvas-engine/index.ts +++ b/packages/shared/src/canvas-engine/index.ts @@ -112,6 +112,7 @@ export { medianOfChildExtents, } from './utils/constants.js'; export { stripMarkdown } from './utils/markdown.js'; +export { normalizeMathDelimiters } from './provenance/normalizeMathDelimiters.js'; export { type AutoHeightFreshness, type AutoHeightHintRead, diff --git a/packages/shared/src/canvas-engine/provenance/blockFingerprint.ts b/packages/shared/src/canvas-engine/provenance/blockFingerprint.ts index 5f22fbec8..34f512d36 100644 --- a/packages/shared/src/canvas-engine/provenance/blockFingerprint.ts +++ b/packages/shared/src/canvas-engine/provenance/blockFingerprint.ts @@ -37,6 +37,8 @@ import { mathFromMarkdown } from 'mdast-util-math'; import { gfm } from 'micromark-extension-gfm'; import { math } from 'micromark-extension-math'; +import { normalizeMathDelimiters } from './normalizeMathDelimiters.js'; + /** Loose mdast node shape — we only ever read a handful of fields. */ interface MdastNode { type: string; @@ -210,7 +212,8 @@ export function fingerprintMdastBlock(node: MdastNode): string { export function fingerprintMarkdownBlocks( markdown: string, ): FingerprintedBlock[] { - const parsed = parseTopLevel(markdown); + const canonicalMarkdown = normalizeMathDelimiters(markdown); + const parsed = parseTopLevel(canonicalMarkdown); const definitions = new Map(); for (const node of parsed) { if (node.type === 'definition' && typeof node.identifier === 'string') { @@ -230,7 +233,7 @@ export function fingerprintMarkdownBlocks( | undefined; const start = pos?.start?.offset ?? 0; const end = pos?.end?.offset ?? 0; - const md = end > start ? markdown.slice(start, end).trim() : ''; + const md = end > start ? canonicalMarkdown.slice(start, end).trim() : ''; return { key, markdown: md }; }); } diff --git a/packages/shared/src/canvas-engine/provenance/normalizeMathDelimiters.ts b/packages/shared/src/canvas-engine/provenance/normalizeMathDelimiters.ts new file mode 100644 index 000000000..becc1428f --- /dev/null +++ b/packages/shared/src/canvas-engine/provenance/normalizeMathDelimiters.ts @@ -0,0 +1,148 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +const FENCE_SENTINEL = '\x00\x00FENCE\x00\x00'; + +interface MarkdownSegment { + text: string; + isCode: boolean; +} + +export function normalizeMathDelimiters(markdown: string): string { + if (!markdown) return markdown; + const codeSegments: string[] = []; + const stitched = splitFencedCode(markdown) + .map((segment) => { + if (!segment.isCode) return convertOutsideCode(segment.text); + codeSegments.push(segment.text); + return FENCE_SENTINEL; + }) + .join('\n') + .replace(/\n{3,}/g, '\n\n'); + const parts = stitched.split(FENCE_SENTINEL); + if (parts.length === 1) return parts[0]; + let result = parts[0]; + for (let index = 1; index < parts.length; index++) { + result += codeSegments[index - 1] + parts[index]; + } + return result; +} + +function splitFencedCode(markdown: string): MarkdownSegment[] { + const lines = markdown.split('\n'); + const segments: MarkdownSegment[] = []; + let codeStart = -1; + let outsideStart = 0; + let fenceCharacter: string | null = null; + let fenceLength = 0; + + for (let index = 0; index < lines.length; index++) { + const line = lines[index]; + const match = /^ {0,3}(`{3,}|~{3,})/.exec(line); + if (match && fenceCharacter === null) { + if (outsideStart < index) { + segments.push({ + text: lines.slice(outsideStart, index).join('\n'), + isCode: false, + }); + } else if (outsideStart === index && index > 0) { + segments.push({ text: '', isCode: false }); + } + codeStart = index; + fenceCharacter = match[1][0]; + fenceLength = match[1].length; + continue; + } + if (match && fenceCharacter !== null) { + const character = match[1][0]; + const length = match[1].length; + if ( + character === fenceCharacter && + length >= fenceLength && + line.trim().length === length + ) { + segments.push({ + text: lines.slice(codeStart, index + 1).join('\n'), + isCode: true, + }); + outsideStart = index + 1; + codeStart = -1; + fenceCharacter = null; + fenceLength = 0; + } + } + } + + if (fenceCharacter !== null) { + segments.push({ text: lines.slice(codeStart).join('\n'), isCode: true }); + } else if (outsideStart < lines.length) { + segments.push({ + text: lines.slice(outsideStart).join('\n'), + isCode: false, + }); + } + return segments; +} + +function convertOutsideCode(text: string): string { + if (!text) return text; + if (text.indexOf('`') === -1) return convertMathInPlain(text); + const output: string[] = []; + let index = 0; + while (index < text.length) { + const tickStart = text.indexOf('`', index); + if (tickStart === -1) { + output.push(convertMathInPlain(text.slice(index))); + break; + } + if (tickStart > index) { + output.push(convertMathInPlain(text.slice(index, tickStart))); + } + let runEnd = tickStart + 1; + while (runEnd < text.length && text.charCodeAt(runEnd) === 96) runEnd++; + const runLength = runEnd - tickStart; + let closeStart = runEnd; + let matched = -1; + while (closeStart < text.length) { + const candidate = text.indexOf('`', closeStart); + if (candidate === -1) break; + let candidateEnd = candidate + 1; + while ( + candidateEnd < text.length && + text.charCodeAt(candidateEnd) === 96 + ) { + candidateEnd++; + } + if (candidateEnd - candidate === runLength) { + matched = candidateEnd; + break; + } + closeStart = candidateEnd; + } + if (matched === -1) { + output.push(convertMathInPlain(text.slice(tickStart))); + break; + } + output.push(text.slice(tickStart, matched)); + index = matched; + } + return output.join(''); +} + +function convertMathInPlain(text: string): string { + let result = text.replace( + /\\\[((?:(?!\\\])[\s\S])*)\\\]/g, + (_match, inner: string) => `\n\n$$\n${inner.trim()}\n$$\n\n`, + ); + result = result.replace( + /\$\$((?:(?!\$\$)[\s\S])*?\n(?:(?!\$\$)[\s\S])*?)\$\$/g, + (match, inner: string) => { + if (inner.startsWith('\n') && inner.endsWith('\n')) return match; + return `\n\n$$\n${inner.trim()}\n$$\n\n`; + }, + ); + return result.replace( + /\\\(((?:(?!\\\))[^\n])*)\\\)/g, + (_match, inner: string) => `$${inner}$`, + ); +} From aabd9a699a6b3cb371b744bc6dbcfa1edb128b05 Mon Sep 17 00:00:00 2001 From: cxxxxxn Date: Sun, 30 Aug 2026 13:45:19 +0800 Subject: [PATCH 06/10] fix(canvas): preserve native text copy before copying nodes --- .../useCanvasShortcuts.lockKeys.test.ts | 38 +++++++++++++++++++ .../src/hooks/shortcuts/useCanvasShortcuts.ts | 23 ++++++++--- docs/architecture/web-architecture.md | 2 + 3 files changed, 57 insertions(+), 6 deletions(-) diff --git a/apps/web/src/hooks/shortcuts/useCanvasShortcuts.lockKeys.test.ts b/apps/web/src/hooks/shortcuts/useCanvasShortcuts.lockKeys.test.ts index 6104e234a..33a9fc806 100644 --- a/apps/web/src/hooks/shortcuts/useCanvasShortcuts.lockKeys.test.ts +++ b/apps/web/src/hooks/shortcuts/useCanvasShortcuts.lockKeys.test.ts @@ -118,6 +118,44 @@ describe('useCanvasShortcuts catalog key lock', () => { expect(canvasActions.sendSelectedToOrder).toHaveBeenLastCalledWith('top'); }); + it('copies selected nodes when an editor retains focus without selected text', () => { + const editor = document.createElement('textarea'); + editor.value = 'Note text'; + editor.setSelectionRange(4, 4); + container.appendChild(editor); + + act(() => { + editor.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'c', + metaKey: true, + bubbles: true, + cancelable: true, + }), + ); + }); + + expect(canvasActions.copySelectedNodes).toHaveBeenCalledOnce(); + }); + + it('preserves native copy when text is selected in an editor', () => { + const editor = document.createElement('textarea'); + editor.value = 'Note text'; + editor.setSelectionRange(0, 4); + container.appendChild(editor); + + const event = new KeyboardEvent('keydown', { + key: 'c', + metaKey: true, + bubbles: true, + cancelable: true, + }); + act(() => editor.dispatchEvent(event)); + + expect(canvasActions.copySelectedNodes).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(false); + }); + it('keeps temporary pan active until the primary pointer is released', () => { act(() => { window.dispatchEvent( diff --git a/apps/web/src/hooks/shortcuts/useCanvasShortcuts.ts b/apps/web/src/hooks/shortcuts/useCanvasShortcuts.ts index 843749635..ebcf4be76 100644 --- a/apps/web/src/hooks/shortcuts/useCanvasShortcuts.ts +++ b/apps/web/src/hooks/shortcuts/useCanvasShortcuts.ts @@ -46,6 +46,19 @@ export interface UseCanvasShortcutsOptions { export type CanvasTool = 'select' | 'lasso' | 'pan'; +function hasNativeCopySelection(target: EventTarget | null): boolean { + if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { + return ( + target.selectionStart !== null && + target.selectionEnd !== null && + target.selectionStart !== target.selectionEnd + ); + } + + const selection = window.getSelection(); + return !!selection && !selection.isCollapsed; +} + /** * All keyboard / paste handling for the canvas, extracted from Canvas.tsx. * @@ -379,12 +392,10 @@ export function useCanvasShortcuts( e.preventDefault(); frameSelectedNodes(); } else if (lowerKey === 'c') { - if (editable) return; - // If the user has selected text (e.g. in a panel), let the browser - // handle the native copy instead of overwriting the clipboard with - // serialized node data. - const selection = window.getSelection(); - if (selection && !selection.isCollapsed) return; + // Editors can retain focus after their node is selected. Preserve + // native copy only when the user has an actual text selection; + // otherwise copy the selected Canvas nodes. + if (hasNativeCopySelection(e.target)) return; e.preventDefault(); copySelectedNodes(); } else if (lowerKey === 'v') { diff --git a/docs/architecture/web-architecture.md b/docs/architecture/web-architecture.md index 2952fd3ae..39f9b0e99 100644 --- a/docs/architecture/web-architecture.md +++ b/docs/architecture/web-architecture.md @@ -87,6 +87,8 @@ Space Preview is the intentional exception to ordinary node rendering: it consum Canvas copy carries Huabu's serialized node payload so that pasting back into Huabu preserves node identity and artifact ownership. The payload always rides in `text/html`; the other representations exist for applications outside Huabu: +`Cmd/Ctrl+C` preserves the browser's native copy behavior when the user has an actual text selection in an input, editor, preview, or panel. If an editor merely retains focus with a collapsed caret, the shortcut copies the selected Canvas nodes instead; retained editor focus must not turn node copy into a silent no-op. + | Selection | `text/plain` | `text/html` | `image/png` | | ---------------------- | ---------------------------------- | ------------------------------------------------------------ | ----------- | | Exactly one image node | — | `` | the image | From 4c2779cb7316e1a2d811299e61399d99a1094065 Mon Sep 17 00:00:00 2001 From: cxxxxxn Date: Sun, 30 Aug 2026 14:54:17 +0800 Subject: [PATCH 07/10] fix note preview search scope --- .../src/components/Nodes/note/NotePreview.tsx | 56 ++++----- apps/web/src/hooks/searchDom.test.ts | 43 +++++++ apps/web/src/hooks/searchDom.ts | 110 +++++++++++++----- apps/web/src/hooks/useTextHighlight.ts | 54 ++------- docs/architecture/note-node.md | 2 + 5 files changed, 169 insertions(+), 96 deletions(-) create mode 100644 apps/web/src/hooks/searchDom.test.ts diff --git a/apps/web/src/components/Nodes/note/NotePreview.tsx b/apps/web/src/components/Nodes/note/NotePreview.tsx index 21c85b597..e7071f4d8 100644 --- a/apps/web/src/components/Nodes/note/NotePreview.tsx +++ b/apps/web/src/components/Nodes/note/NotePreview.tsx @@ -606,17 +606,19 @@ export const NotePreview = ({ surfaceRef={containerRef} /> ) : null} - +
+ +
{PROVENANCE_ENABLED && !readOnly ? ( ) : ( - - {t('node.loadingSourceEditor')} - - } - > - - +
+ + {t('node.loadingSourceEditor')} +
+ } + > + + + )} {showProvenanceChip ? ( diff --git a/apps/web/src/hooks/searchDom.test.ts b/apps/web/src/hooks/searchDom.test.ts new file mode 100644 index 000000000..717d2b98c --- /dev/null +++ b/apps/web/src/hooks/searchDom.test.ts @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { describe, expect, it } from 'vitest'; + +import { findNthRange, findRanges } from './searchDom'; + +function rootWith(html: string): HTMLElement { + const root = document.createElement('div'); + root.style.display = 'block'; + root.style.visibility = 'visible'; + root.innerHTML = html; + document.body.appendChild(root); + return root; +} + +describe('preview DOM search boundaries', () => { + it('searches marked document content instead of adjacent editor chrome', () => { + const root = rootWith(` +
Task List
+
+ Write the report + hidden task + +
+ `); + + expect(findRanges(root, 'task')).toHaveLength(0); + expect(findRanges(root, 'report')).toHaveLength(1); + root.remove(); + }); + + it('supports navigation within marked document content', () => { + const root = rootWith(` +
Task List
+
first task and second task
+ `); + + expect(findRanges(root, 'task')).toHaveLength(2); + expect(findNthRange(root, 'task', 1)?.toString()).toBe('task'); + root.remove(); + }); +}); \ No newline at end of file diff --git a/apps/web/src/hooks/searchDom.ts b/apps/web/src/hooks/searchDom.ts index 17ade1999..96ec5ab1a 100644 --- a/apps/web/src/hooks/searchDom.ts +++ b/apps/web/src/hooks/searchDom.ts @@ -19,8 +19,10 @@ * inside `root`'s text nodes and return it as a `Range`, or `null` * if there aren't that many matches. * - * Walks in document order, skipping `