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..51869f52b --- /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', -501)], + [], + ); + + 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..65ca55b18 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 = 400; + // ─── 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/components/Milkdown/__tests__/blockFingerprintParity.test.ts b/apps/web/src/components/Milkdown/__tests__/blockFingerprintParity.test.ts index 846cb3997..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 }; }); @@ -74,6 +76,30 @@ 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\].`], + ['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'], +]; + describe('block-key parity: server(raw md) ↔ client(Milkdown round-trip)', () => { let harness: Harness; beforeAll(async () => { @@ -97,4 +123,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/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/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/components/Nodes/question/QuestionNode.tsx b/apps/web/src/components/Nodes/question/QuestionNode.tsx index add59841e..ff3e904f0 100644 --- a/apps/web/src/components/Nodes/question/QuestionNode.tsx +++ b/apps/web/src/components/Nodes/question/QuestionNode.tsx @@ -125,8 +125,11 @@ export const QuestionNode = memo( data.threadId ? s.pendingForkThreadIds[data.threadId] === true : false, ); - /** Whether this node has been executed at least once. */ + /** Whether this node has an explicit terminal execution state. */ const hasRun = status === 'done' || status === 'error'; + const hasConversation = + !!data.threadId && + (hasRun || status === 'running' || displayText.trim().length > 0); /** * Whether the chat panel can be opened to this question's thread — @@ -134,8 +137,7 @@ export const QuestionNode = memo( * or finished (replay). A pending paste-fork blocks opening until its * history has finished copying. */ - const canOpenInChat = - !!data.threadId && (hasRun || status === 'running') && !isForkPending; + const canOpenInChat = hasConversation && !isForkPending; const needsApproval = useChatStore((s) => { if (!data.threadId) return false; 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/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/hooks/searchDom.test.ts b/apps/web/src/hooks/searchDom.test.ts new file mode 100644 index 000000000..bad53a1d9 --- /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(); + }); +}); diff --git a/apps/web/src/hooks/searchDom.ts b/apps/web/src/hooks/searchDom.ts index 17ade1999..af38aa8dc 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 `