From d2778ce63f941eaa6ee8dbba1cd91bb139e402f2 Mon Sep 17 00:00:00 2001 From: Yuqing Yang Date: Fri, 14 Aug 2026 17:06:55 +0000 Subject: [PATCH 1/2] fix(web): keep note titles visible Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/prompt/external-agent/access-huabu.md | 2 +- apps/web/e2e/note-auto-height.spec.ts | 183 ++++++++++++++++- .../src/components/Nodes/note/NoteNode.tsx | 192 ++++++++++-------- .../load/__tests__/warmupNodeHeights.test.ts | 2 +- docs/architecture/canvas-zoom-rendering.md | 3 +- docs/architecture/node-auto-height.md | 14 +- docs/architecture/note-node.md | 6 +- .../__tests__/createNodes.selection.test.ts | 2 +- .../height/__tests__/height.test.ts | 28 ++- .../src/canvas-engine/height/freshness.ts | 2 +- .../shared/src/canvas-engine/height/index.ts | 2 + .../shared/src/canvas-engine/height/policy.ts | 13 +- packages/shared/src/canvas-engine/index.ts | 2 + .../src/canvas-engine/utils/nodeSizes.ts | 8 +- 14 files changed, 337 insertions(+), 122 deletions(-) diff --git a/apps/server/src/prompt/external-agent/access-huabu.md b/apps/server/src/prompt/external-agent/access-huabu.md index 584c6f979..daf82d18c 100644 --- a/apps/server/src/prompt/external-agent/access-huabu.md +++ b/apps/server/src/prompt/external-agent/access-huabu.md @@ -150,7 +150,7 @@ Before placing or resizing relative to existing content, query the relevant node | Node type | Default geometry | | ---------------------- | ------------------------------------------------------------------------------------ | | `text` | 200px wide; content-driven height | -| `note` | 400px wide; content-driven height (56px nominal layout height) | +| `note` | 400px wide; content-driven height (88px nominal layout height) | | `web`, `pdf`, `office` | 400 × 400px | | `video` | 400 × 300px | | `image` | 400px wide; height follows the source aspect ratio (300px nominal before resolution) | diff --git a/apps/web/e2e/note-auto-height.spec.ts b/apps/web/e2e/note-auto-height.spec.ts index cab6f4814..feab904bf 100644 --- a/apps/web/e2e/note-auto-height.spec.ts +++ b/apps/web/e2e/note-auto-height.spec.ts @@ -3,7 +3,12 @@ import { test, expect, type Page } from '@playwright/test'; -import { openNewCanvas, paneCenter } from './helpers'; +import { + openNewCanvas, + paneCenter, + readViewportTransform, + scaleOf, +} from './helpers'; /** * The auto-height invariant, asserted in a real browser. @@ -100,6 +105,43 @@ async function createAgentNote( ]); } +async function zoomCanvasToAtMost(page: Page, target: number): Promise { + const centre = await paneCenter(page); + await page.mouse.move(centre.x, centre.y); + await page.keyboard.down('Control'); + try { + for (let attempt = 0; attempt < 40; attempt += 1) { + const zoom = scaleOf(await readViewportTransform(page)); + if (zoom <= target) return zoom; + await page.mouse.wheel(0, 20); + await page.waitForTimeout(20); + } + } finally { + await page.keyboard.up('Control'); + } + throw new Error(`canvas did not zoom out to ${target}`); +} + +async function zoomCanvasToAtLeast( + page: Page, + target: number, +): Promise { + const centre = await paneCenter(page); + await page.mouse.move(centre.x, centre.y); + await page.keyboard.down('Control'); + try { + for (let attempt = 0; attempt < 40; attempt += 1) { + const zoom = scaleOf(await readViewportTransform(page)); + if (zoom >= target) return zoom; + await page.mouse.wheel(0, -20); + await page.waitForTimeout(20); + } + } finally { + await page.keyboard.up('Control'); + } + throw new Error(`canvas did not zoom in to ${target}`); +} + /** * For every mounted note, how many pixels its content overflows the box * it was given. The reader mirrors `readNoteIntrinsicHeight`: measure @@ -160,6 +202,142 @@ async function pasteAllFixtures(page: Page): Promise { } test.describe('note auto height', () => { + test('the note title remains visible across widths and LOD levels', async ({ + page, + }) => { + await openNewCanvas(page); + await executeAgentCommands(page, [ + { + type: 'CREATE_NODES', + nodes: [ + { + nodeType: 'note', + data: { + label: 'Narrow note title', + content: + '# Narrow note title\n\nBody content must not stand in for the node title.', + }, + position: { x: 100, y: 100 }, + size: { width: 300, height: 'auto' }, + }, + { + nodeType: 'note', + data: { + label: 'Wide note title', + content: + '# Wide note title\n\nDifferent body content makes the label observable.', + }, + position: { x: 500, y: 100 }, + size: { width: 400, height: 'auto' }, + }, + ], + }, + ]); + await expect(page.locator('.react-flow__node-note')).toHaveCount(2); + await expect( + page.locator('[data-note-content-host] .ProseMirror'), + ).toHaveCount(2); + + const narrow = page.locator('.react-flow__node-note').filter({ + hasText: 'Body content must not stand in for the node title.', + }); + const wide = page.locator('.react-flow__node-note').filter({ + hasText: 'Different body content makes the label observable.', + }); + await expect(narrow.locator('[data-note-title]')).toHaveText( + 'Narrow note title', + ); + await expect(wide.locator('[data-note-title]')).toHaveText( + 'Wide note title', + ); + + const boundaryZoom = await zoomCanvasToAtMost(page, 0.44); + expect(boundaryZoom).toBeGreaterThanOrEqual(0.4); + await expect(narrow.locator('.semantic-lod-node')).toHaveAttribute( + 'data-lod', + 'minimal', + ); + await expect(wide.locator('.semantic-lod-node')).toHaveAttribute( + 'data-lod', + 'full', + ); + await expect(narrow.locator('.semantic-lod-placeholder')).toBeVisible(); + await expect(narrow.locator('.semantic-lod-placeholder')).toContainText( + 'Narrow note title', + ); + await expect(wide.locator('[data-note-title]')).toBeVisible(); + + await zoomCanvasToAtMost(page, 0.32); + await expect(wide.locator('.semantic-lod-node')).toHaveAttribute( + 'data-lod', + 'minimal', + ); + await expect(wide.locator('.semantic-lod-placeholder')).toBeVisible(); + await expect(wide.locator('.semantic-lod-placeholder')).toContainText( + 'Wide note title', + ); + + await zoomCanvasToAtLeast(page, 0.56); + await expect(narrow.locator('.semantic-lod-node')).toHaveAttribute( + 'data-lod', + 'full', + ); + await expect(wide.locator('.semantic-lod-node')).toHaveAttribute( + 'data-lod', + 'full', + ); + await expect(narrow.locator('[data-note-title]')).toBeVisible(); + await expect(wide.locator('[data-note-title]')).toBeVisible(); + }); + + test('a fixed-height note preserves its title before its body', async ({ + page, + }) => { + await openNewCanvas(page); + const content = Array.from( + { length: 8 }, + (_, index) => `Body paragraph ${index + 1} must be clipped first.`, + ).join('\n\n'); + await executeAgentCommands(page, [ + { + type: 'CREATE_NODES', + nodes: [ + { + nodeType: 'note', + data: { label: 'Persistent title', content }, + position: { x: 100, y: 100 }, + size: { width: 400, height: 80 }, + }, + ], + }, + ]); + + const note = page.locator('.react-flow__node-note'); + const title = note.locator('[data-note-title]'); + const body = note.locator('[data-note-content-host]'); + await expect(title).toBeVisible(); + await expect(body.locator('.ProseMirror')).toHaveCount(1); + + const geometry = await note.evaluate((element) => { + const titleElement = element.querySelector( + '[data-note-title]', + ) as HTMLElement; + const bodyElement = element.querySelector( + '[data-note-content-host]', + ) as HTMLElement; + const noteRect = element.getBoundingClientRect(); + const titleRect = titleElement.getBoundingClientRect(); + return { + titleInside: + titleRect.top >= noteRect.top && titleRect.bottom <= noteRect.bottom, + bodyTruncated: bodyElement.scrollHeight > bodyElement.clientHeight, + }; + }); + + expect(geometry.titleInside).toBe(true); + expect(geometry.bodyTruncated).toBe(true); + }); + test('an agent-created long note replaces its initial height hint', async ({ page, }) => { @@ -258,6 +436,7 @@ test.describe('note auto height', () => { parseFloat((element as HTMLElement).style.height), ); + await page.getByRole('button', { name: 'Expand', exact: true }).click(); const editor = page.locator( '[data-search-scope="node"] .ProseMirror[contenteditable="true"]', ); @@ -269,7 +448,7 @@ test.describe('note auto height', () => { `Section ${index + 1}. This manually edited paragraph is long enough to wrap and must expand the mounted note.`, ).join('\n\n'); await editor.fill(longContent); - await page.getByRole('button', { name: 'Close', exact: true }).click(); + await page.getByRole('button', { name: /^Close (?!Chat$).+/ }).click(); await expect .poll(() => diff --git a/apps/web/src/components/Nodes/note/NoteNode.tsx b/apps/web/src/components/Nodes/note/NoteNode.tsx index 221c93626..225ab3db7 100644 --- a/apps/web/src/components/Nodes/note/NoteNode.tsx +++ b/apps/web/src/components/Nodes/note/NoteNode.tsx @@ -7,7 +7,11 @@ import { ChevronsDown, Fullscreen } from 'lucide-react'; import { memo, useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { autoHeightKey } from '@huabu/shared/canvas-engine'; +import { + NOTE_MIN_HEIGHT, + NOTE_TITLE_HEIGHT, + autoHeightKey, +} from '@huabu/shared/canvas-engine'; import { FloatingToolbar } from '@/components/Common/FloatingToolbar'; import { Loading } from '@/components/Common/Loading'; @@ -151,6 +155,10 @@ export const NoteNode = memo( ); const markdown = typeof data.content === 'string' ? data.content : ''; + const title = + typeof data.label === 'string' && data.label.trim() + ? data.label.trim() + : t('node.untitled'); // Measurement is shared with the offscreen measurer via // `readNoteIntrinsicHeight`, so the two surfaces cannot answer @@ -403,6 +411,7 @@ export const NoteNode = memo( selected={selected} actions={isContentMissing ? undefined : NoteActions} keepAspectRatio={false} + minHeight={NOTE_MIN_HEIGHT} // Active drop-target highlight: thick `--info-light` ring on // the wrapper's true outer edge plus a translucent // `--info-light` wash over the whole node — same hue family @@ -412,29 +421,44 @@ export const NoteNode = memo( isDropTarget ? 'ring-info-light bg-info-light/60 ring-4' : undefined } > - {isContentMissing ? ( - - ) : ( - <> -
+
+
+ + {title} + +
+
+ {isContentMissing ? ( + + ) : (
- {/* +
+ {/* This card surface is render-only — the expanded editor opened via the toolbar's Expand button is where the user actually types. `MilkdownPreview` mounts Milkdown @@ -442,75 +466,75 @@ export const NoteNode = memo( KaTeX styles are already scoped under `.milkdown` (see `milkdown-overrides.css`) so no extra isolation is required. - */} -
- {hydrated ? ( - - ) : ( - // Lightweight placeholder while the editor mount is - // deferred. The host already constrains to the node's - // layout height in both modes, so filling it is enough — - // the footprint comes from `style.height`, never from - // anything measured here. -
- {/* No shimmer in minimal LOD — the content is + */} +
+ {hydrated ? ( + + ) : ( + // Lightweight placeholder while the editor mount is + // deferred. The host already constrains to the node's + // layout height in both modes, so filling it is enough — + // the footprint comes from `style.height`, never from + // anything measured here. +
+ {/* No shimmer in minimal LOD — the content is hidden behind the SemanticPlaceholder, so an animated placeholder would just be wasted work. */} - {!isMinimalLOD && ( - - )} -
- )} + {!isMinimalLOD && ( + + )} +
+ )} +
-
- {isTruncated && ( -
- {/* Fade gradient */} + {isTruncated && (
-
- +
+
+ +
-
- )} -
- - )} + )} +
+ )} +
+
); }, diff --git a/apps/web/src/store/canvasStore/load/__tests__/warmupNodeHeights.test.ts b/apps/web/src/store/canvasStore/load/__tests__/warmupNodeHeights.test.ts index 94c229545..2693441a0 100644 --- a/apps/web/src/store/canvasStore/load/__tests__/warmupNodeHeights.test.ts +++ b/apps/web/src/store/canvasStore/load/__tests__/warmupNodeHeights.test.ts @@ -26,7 +26,7 @@ function note(overrides: Partial = {}): Node { id: 'n1', type: 'note', position: { x: 0, y: 0 }, - style: { width: 400, height: 56 }, + style: { width: 400, height: 88 }, data: { type: 'note', content: CONTENT, heightMode: 'auto' }, ...overrides, } as Node; diff --git a/docs/architecture/canvas-zoom-rendering.md b/docs/architecture/canvas-zoom-rendering.md index 0c1038a92..56da781de 100644 --- a/docs/architecture/canvas-zoom-rendering.md +++ b/docs/architecture/canvas-zoom-rendering.md @@ -16,6 +16,7 @@ The governing rule is semantic priority rather than uniform scaling: structural | Surface | Coordinate/scale policy | Visibility policy | | ---------------------------------------- | ---------------------------------------- | ---------------------------------------------------------------------- | | Note, PDF, web node body | Canvas space | Switches between `full` and `minimal` LOD from screen-space width | +| Full note title | Canvas space | Always occupies a non-shrinking row while full LOD is visible | | Other node bodies | Canvas space | Always full rendering unless explicitly added to the LOD configuration | | Minimal node label | Canvas-space tiered typography | Wraps and clamps inside the node; naturally scales with the viewport | | Frame body | Canvas space | Always full rendering | @@ -29,7 +30,7 @@ The governing rule is semantic priority rather than uniform scaling: structural [`SEMANTIC_ZOOM_CONFIG`](../../apps/web/src/config/semanticZoom.ts) opts `note`, `pdf`, and `web` into the two-level `full → minimal` pipeline. Unlisted node types remain `full` at every zoom. The `question` node deliberately does **not** use this binary boundary — it uses the continuous zoom takeover described in §3.1. -Participating binary types render the generic tier-sized title label in `minimal`. +Participating binary types render the generic tier-sized title label in `minimal`. A note also renders the same `data.label` in a fixed-height, non-shrinking title row in `full`, so crossing the LOD boundary never removes the node's identity; constrained full-detail notes clip their Markdown body first. PDF rendering has a second cost boundary in the expanded preview. The canvas node lazy-loads pdf.js only when it needs an uncached first-page thumbnail, while the expanded preview keeps a lightweight aspect-ratio placeholder for every page and mounts the expensive canvas and text layer only within one scroll viewport of the visible area. A six-page LRU retains recently visited renders to avoid churn during short reverse scrolls; older pages are unmounted so canvas memory remains bounded. diff --git a/docs/architecture/node-auto-height.md b/docs/architecture/node-auto-height.md index 57cb38108..80e6193d5 100644 --- a/docs/architecture/node-auto-height.md +++ b/docs/architecture/node-auto-height.md @@ -13,13 +13,13 @@ The governing rule is that **rendering never causes a geometry change**. Zoom, p ## 2. The three heights -| Concept | Definition | Where it lives | -| -------------------- | ----------------------------------------------------------------------- | ---------------------------------------------------------- | -| **Intrinsic height** | Content height at the node type's reference width, unscaled, no chrome. | `data.autoHeight.intrinsicHeight` — the persisted truth. | -| **Layout height** | The number every geometry consumer uses. | `style.height` — materialized from the intrinsic height. | -| **Rendered height** | The DOM's actual box. | `measured.height`, a mirror; and the source of a proposal. | +| Concept | Definition | Where it lives | +| -------------------- | -------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | +| **Intrinsic height** | Markdown body height at the node type's reference width, unscaled, no title or shell chrome. | `data.autoHeight.intrinsicHeight` — the persisted truth. | +| **Layout height** | The number every geometry consumer uses. | `style.height` — materialized from the intrinsic height. | +| **Rendered height** | The DOM's actual box. | `measured.height`, a mirror; and the source of a proposal. | -Intrinsic → layout is one pure function, [`intrinsicToLayoutHeight`](../../packages/shared/src/canvas-engine/height/compute.ts): clamp to the type's minimum, scale by the node's width, add the node shell's chrome, quantize to a 4 px step. The order mirrors the DOM — the minimum applies unscaled, the chrome is outside the scaled container and so is added after. +Intrinsic → layout is one pure function, [`intrinsicToLayoutHeight`](../../packages/shared/src/canvas-engine/height/compute.ts): clamp to the type's minimum, scale by the node's width, add fixed canvas-space chrome, then quantize to a 4 px step. For a note that chrome is the 32 px non-shrinking title row plus the 6 px node shell. The order mirrors the DOM — the minimum applies to the Markdown body, while the title and shell live outside its scaled container and are added afterward. The scale divides the node's **content** width, its box minus the shell border, so the logical layout width lands on `refWidth` exactly at every node size. That is the premise the whole hint cache rests on: content measured at one node width wraps identically at any other. A legibility floor on the scale would break it — once engaged, the content stops shrinking and starts laying out _narrower_ than the reference, so `note` deliberately has none. Semantic zoom already replaces a tiny note's body with a placeholder long before its text would become unreadable. `HeightPolicy.minContentScale` carries the floor for the `manual` types, whose box is the user's and whose scale is therefore purely a rendering decision. @@ -115,7 +115,7 @@ Derived geometry is **not yet isolated from the sync path** — see §11. ## 10. The invariant, and how it is checked -An auto note that does not fit its content is a defect report, not a cosmetic complaint: the box was derived from the measurement, so content that overflows means the two disagree. Truncation on a _pinned_ note is normal — the user chose a smaller box. +An auto note that does not fit its content is a defect report, not a cosmetic complaint: the box was derived from the measurement, so content that overflows means the two disagree. Truncation on a _pinned_ note is normal — the user chose a smaller box. The title is never part of that sacrifice: the full card reserves its row first and gives the remaining height to the body, so body content clips before the label can disappear. Two layers assert it. [`useAutoHeightInvariant`](../../apps/web/src/components/Nodes/note/useAutoHeightInvariant.ts) warns in dev once the commit queue has settled, re-reading the DOM after a delay rather than firing on the first short-looking render. [`note-auto-height.spec.ts`](../../apps/web/e2e/note-auto-height.spec.ts) drives a real browser with fixtures chosen for the shapes that have broken it — a leading heading whose margin can escape the measured box, a heading mid-document whose margin cannot, wrapping prose, a list — and asserts both the geometry and that the dev hook stayed silent. diff --git a/docs/architecture/note-node.md b/docs/architecture/note-node.md index 1978dcc2d..fe5fc296d 100644 --- a/docs/architecture/note-node.md +++ b/docs/architecture/note-node.md @@ -29,11 +29,11 @@ Inline text and background colors persist as HTML spans with `data-huabu-text-co **Creation** — notes have no creation path of their own. They go through the generic `ADD_NODES` UI intent, which resolves to a `CREATE_NODES` command; paste, canvas drop and agent-issued creation all funnel through it. See [canvas-command-architecture.md](./canvas-command-architecture.md). -**Rendering** — the canvas card mounts the read-only `MilkdownPreview`; the expanded panel mounts the editable `MilkdownEditor`, which also offers a raw-Markdown source mode alongside WYSIWYG. See §4. +**Rendering** — the canvas card always reserves a single-line title row for `data.label`, then mounts the read-only `MilkdownPreview` in the remaining body area; the expanded panel mounts the editable `MilkdownEditor`, which also offers a raw-Markdown source mode alongside WYSIWYG. The title row is non-shrinking, so a fixed-height note clips its body before sacrificing its identity. At minimal LOD the generic semantic placeholder replaces the full card and renders the same label. See §4 and [canvas-zoom-rendering.md](./canvas-zoom-rendering.md). **Saving** — an edit calls `updateNodeData(id, { content })`, which dispatches `UPDATE_NODE_DATA` → `MERGE_NODE_DATA`. There is **no debounce**: every editor `onChange` writes through. Concurrency is handled at the server by rev-CAS, which rejects a stale write with `409 NODE_CONTENT_CONFLICT` rather than merging it. -**Height** — a note's height is `auto` or `fixed` (`setNoteHeightMode`); measurement, freshness keys and the layout conversion are owned by [node-auto-height.md](./node-auto-height.md). +**Height** — a note's height is `auto` or `fixed` (`setNoteHeightMode`); measurement, freshness keys and the layout conversion are owned by [node-auto-height.md](./node-auto-height.md). The fixed canvas-space title row is height chrome outside the width-scaled Markdown measurement. --- @@ -130,7 +130,7 @@ These exist only for notes, and none of them are guessable from the node model a | ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | [`createMilkdown.ts`](../../apps/web/src/components/Milkdown/createMilkdown.ts) | Sole owner of Crepe/ProseMirror wiring: `tabContext`, `indentSelection`, `outdentSelection`, `handleLinkClick`, `normalizeSafeLinkHref`. | | [`node.ts`](../../packages/shared/src/types/canvas/node.ts) | `NoteNodeData` and the `isNoteNode` guard. | -| [`NoteNode.tsx`](../../apps/web/src/components/Nodes/note/NoteNode.tsx) | Canvas card: layout shell, height-mode toggle, drop handling. | +| [`NoteNode.tsx`](../../apps/web/src/components/Nodes/note/NoteNode.tsx) | Canvas card: persistent title row, Markdown body, height-mode toggle, drop handling. | | [`NotePreview.tsx`](../../apps/web/src/components/Nodes/note/NotePreview.tsx) | Expanded surface: `MilkdownEditor`, WYSIWYG/raw toggle, provenance overlay, write-through to `updateNodeData`. | | [`blockProvenance.ts`](../../apps/web/src/utils/blockProvenance.ts) | Block keys and provenance realignment. | | [`MilkdownEditor.tsx`](../../apps/web/src/components/Milkdown/MilkdownEditor.tsx) | Editable surface; reconciles the `editable` toggle onto a mounted instance. | diff --git a/packages/shared/src/canvas-engine/__tests__/createNodes.selection.test.ts b/packages/shared/src/canvas-engine/__tests__/createNodes.selection.test.ts index 183d8e997..7a6bbc2f7 100644 --- a/packages/shared/src/canvas-engine/__tests__/createNodes.selection.test.ts +++ b/packages/shared/src/canvas-engine/__tests__/createNodes.selection.test.ts @@ -51,7 +51,7 @@ describe('CREATE_NODES selection', () => { // Materialized from the note policy's minimum, not pinned: a // created note must have a real footprint for the same-batch frame // fit and grid solver, and its ownership is recorded explicitly. - height: 56, + height: 88, }); expect( ( diff --git a/packages/shared/src/canvas-engine/height/__tests__/height.test.ts b/packages/shared/src/canvas-engine/height/__tests__/height.test.ts index e8c477195..e36512b6a 100644 --- a/packages/shared/src/canvas-engine/height/__tests__/height.test.ts +++ b/packages/shared/src/canvas-engine/height/__tests__/height.test.ts @@ -206,19 +206,17 @@ describe('intrinsicToLayoutHeight', () => { ); }); - it('adds the node shell inset after scaling, not before', () => { - // The shell border lives outside the scaled container, so doubling - // the width doubles the content but not the 6px chrome. The same - // 6px also narrows the content box, which is why the scale at the - // reference width is 394/400 rather than 1. - expect(intrinsicToLayoutHeight(200, 'note', 400)).toBe(204); - expect(intrinsicToLayoutHeight(200, 'note', 800)).toBe(404); + it('adds fixed title and shell chrome after scaling, not before', () => { + // The 32px title and 6px shell live outside the scaled body. The shell + // still narrows the content box, so the default-width scale is 394/400. + expect(intrinsicToLayoutHeight(200, 'note', 400)).toBe(236); + expect(intrinsicToLayoutHeight(200, 'note', 800)).toBe(436); }); it('applies the minimum before scaling', () => { - // Note minimum is 50 unscaled; at half width the scale clamp is 0.5. - expect(intrinsicToLayoutHeight(10, 'note', 400)).toBe(56); - expect(intrinsicToLayoutHeight(10, 'note', 200)).toBe(32); + // Note minimum is 50 unscaled; title and shell chrome are then added. + expect(intrinsicToLayoutHeight(10, 'note', 400)).toBe(88); + expect(intrinsicToLayoutHeight(10, 'note', 200)).toBe(64); }); it('does not scale types without a reference width', () => { @@ -233,7 +231,7 @@ describe('intrinsicToLayoutHeight', () => { // would render short. Semantic zoom, not this, is what keeps a tiny // note readable — it swaps the body for a placeholder. expect(contentScaleFor(getHeightPolicy('note'), 100)).toBeCloseTo(0.235); - expect(intrinsicToLayoutHeight(200, 'note', 100)).toBe(56); + expect(intrinsicToLayoutHeight(200, 'note', 100)).toBe(88); }); it('floors the scale for manual types, whose box the user owns', () => { @@ -391,8 +389,8 @@ describe('materializeAutoHeight', () => { }, }), ); - // 260 content, scaled by 394/400, plus 6px shell chrome, quantized. - expect((result.style as { height: number }).height).toBe(264); + // 260 content, scaled by 394/400, plus title and shell chrome, quantized. + expect((result.style as { height: number }).height).toBe(296); }); it('materializes a stale hint too — a seed beats a collapse', () => { @@ -406,7 +404,7 @@ describe('materializeAutoHeight', () => { }, }), ); - expect((result.style as { height: number }).height).toBe(264); + expect((result.style as { height: number }).height).toBe(296); }); it('leaves fixed nodes alone', () => { @@ -448,7 +446,7 @@ describe('materializeAutoHeight', () => { }, }), ); - expect(result.measured?.height).toBe(264); + expect(result.measured?.height).toBe(296); expect(result.measured?.width).toBe(400); }); diff --git a/packages/shared/src/canvas-engine/height/freshness.ts b/packages/shared/src/canvas-engine/height/freshness.ts index 6e06cc5ba..18ec9bb4b 100644 --- a/packages/shared/src/canvas-engine/height/freshness.ts +++ b/packages/shared/src/canvas-engine/height/freshness.ts @@ -29,7 +29,7 @@ import type { Node } from '@xyflow/react'; * measurement rule itself. Every stored hint becomes `stale` on the next * load, which costs one re-measurement per node and nothing else. */ -export const HEIGHT_LAYOUT_VERSION = 4; +export const HEIGHT_LAYOUT_VERSION = 5; /** * Identity of the thing an intrinsic height was measured against. diff --git a/packages/shared/src/canvas-engine/height/index.ts b/packages/shared/src/canvas-engine/height/index.ts index 0cf45d820..b3c88652e 100644 --- a/packages/shared/src/canvas-engine/height/index.ts +++ b/packages/shared/src/canvas-engine/height/index.ts @@ -17,6 +17,8 @@ export { type HeightKind, type HeightMode, type HeightPolicy, + NOTE_MIN_HEIGHT, + NOTE_TITLE_HEIGHT, NODE_SHELL_INSET, getHeightPolicy, getHeightRefWidth, diff --git a/packages/shared/src/canvas-engine/height/policy.ts b/packages/shared/src/canvas-engine/height/policy.ts index c0419ebf5..7aa33719a 100644 --- a/packages/shared/src/canvas-engine/height/policy.ts +++ b/packages/shared/src/canvas-engine/height/policy.ts @@ -104,6 +104,17 @@ const MANUAL_POLICY: HeightPolicy = { kind: 'manual' }; */ export const NODE_SHELL_INSET = 6; +/** + * Fixed canvas-space height reserved for a note's identity row. + * + * The title lives outside the width-scaled Markdown body so it remains + * available before body content at every full-detail size. + */ +export const NOTE_TITLE_HEIGHT = 32; + +/** Smallest note box that can still contain its title and shell. */ +export const NOTE_MIN_HEIGHT = NOTE_TITLE_HEIGHT + NODE_SHELL_INSET; + /** * Height policy per node type. Types absent from this table are `manual`. * @@ -120,7 +131,7 @@ const HEIGHT_POLICIES: Readonly> = { kind: 'toggleable', refWidth: 400, minIntrinsicHeight: 50, - insetY: NODE_SHELL_INSET, + insetY: NODE_SHELL_INSET + NOTE_TITLE_HEIGHT, }, text: { kind: 'content' }, question: { kind: 'content' }, diff --git a/packages/shared/src/canvas-engine/index.ts b/packages/shared/src/canvas-engine/index.ts index 50468f626..39149dd54 100644 --- a/packages/shared/src/canvas-engine/index.ts +++ b/packages/shared/src/canvas-engine/index.ts @@ -120,6 +120,8 @@ export { type HeightPolicy, HEIGHT_LAYOUT_VERSION, HEIGHT_QUANTIZATION_STEP, + NOTE_MIN_HEIGHT, + NOTE_TITLE_HEIGHT, NODE_SHELL_INSET, autoHeightKey, contentScaleFor, diff --git a/packages/shared/src/canvas-engine/utils/nodeSizes.ts b/packages/shared/src/canvas-engine/utils/nodeSizes.ts index 6debe0a58..edf7ad40e 100644 --- a/packages/shared/src/canvas-engine/utils/nodeSizes.ts +++ b/packages/shared/src/canvas-engine/utils/nodeSizes.ts @@ -28,11 +28,9 @@ import type { Node } from '@xyflow/react'; // --------------------------------------------------------------------------- const DEFAULT_SIZES: Record = { text: { width: 200 }, - // Note nodes auto-size by content height but have a minimum intrinsic - // height of ~50px (the note policy's `minIntrinsicHeight`) plus borders/padding when empty. - // Use 56px as a nominal default for layout calculations (matches the - // minimum rendered height of an empty note at default zoom). - note: { width: 400, height: 56 }, + // Note nodes auto-size by content height. The nominal height includes the + // empty-body minimum, title row, and shell at the default width. + note: { width: 400, height: 88 }, web: { width: 400, height: 400 }, pdf: { width: 400, height: 400 }, office: { width: 400, height: 400 }, From 56cc82f55fb022363aade57c31879ff0331ce99b Mon Sep 17 00:00:00 2001 From: Yuqing Yang Date: Sat, 15 Aug 2026 02:22:46 +0000 Subject: [PATCH 2/2] test: account for note title height Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../shared/height/__tests__/commitQueue.test.ts | 8 ++++---- .../load/__tests__/normalizeNodeHeights.test.ts | 4 ++-- .../load/__tests__/warmupNodeHeights.test.ts | 4 ++-- .../canvas-engine/__tests__/heightAuthority.test.ts | 12 ++++++------ 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/Nodes/shared/height/__tests__/commitQueue.test.ts b/apps/web/src/components/Nodes/shared/height/__tests__/commitQueue.test.ts index 0d8853727..ec38f0f76 100644 --- a/apps/web/src/components/Nodes/shared/height/__tests__/commitQueue.test.ts +++ b/apps/web/src/components/Nodes/shared/height/__tests__/commitQueue.test.ts @@ -32,7 +32,7 @@ function note(overrides: Partial = {}): Node { id: 'n1', type: 'note', position: { x: 0, y: 0 }, - style: { width: 400, height: 264 }, + style: { width: 400, height: 296 }, data: { type: 'note', heightMode: 'auto' }, ...overrides, } as Node; @@ -48,8 +48,8 @@ beforeEach(() => { describe('height commit queue — threshold', () => { it('collapses sub-quantization jitter to no write at all', () => { // Same content (same key), re-measured 2px taller. Both 256 and 258 - // resolve to a layout height of 264, which is what the node already - // has — so quantization, not a tolerance, is what makes + // resolve to a layout height of 296 after title and shell chrome, + // which is what the node already has — so quantization is what makes // ResizeObserver jitter free rather than merely cheap. storeNodes = [ note({ @@ -301,7 +301,7 @@ describe('height commit queue — gesture suspension', () => { // gesture, so by the time the queue drains there is nothing to do. storeNodes = [ note({ - style: { width: 400, height: 400 }, + style: { width: 400, height: 432 }, data: { type: 'note', heightMode: 'auto', diff --git a/apps/web/src/store/canvasStore/load/__tests__/normalizeNodeHeights.test.ts b/apps/web/src/store/canvasStore/load/__tests__/normalizeNodeHeights.test.ts index 1befb024e..b37ee0deb 100644 --- a/apps/web/src/store/canvasStore/load/__tests__/normalizeNodeHeights.test.ts +++ b/apps/web/src/store/canvasStore/load/__tests__/normalizeNodeHeights.test.ts @@ -53,8 +53,8 @@ describe('normalizeNodeHeights', () => { }, }), ]); - // 260 content, scaled by 394/400, plus 6px shell chrome, quantized. - expect((result.style as { height?: number }).height).toBe(264); + // 260 content, scaled by 394/400, plus title and shell chrome, quantized. + expect((result.style as { height?: number }).height).toBe(296); }); it('never fabricates a hint, not even from the legacy measured height', () => { diff --git a/apps/web/src/store/canvasStore/load/__tests__/warmupNodeHeights.test.ts b/apps/web/src/store/canvasStore/load/__tests__/warmupNodeHeights.test.ts index 2693441a0..c2e54796b 100644 --- a/apps/web/src/store/canvasStore/load/__tests__/warmupNodeHeights.test.ts +++ b/apps/web/src/store/canvasStore/load/__tests__/warmupNodeHeights.test.ts @@ -138,8 +138,8 @@ describe('warmupNodeHeights', () => { const warmedChild = result.nodes.find((node) => node.id === 'n1'); const fittedFrame = result.nodes.find((node) => node.id === 'f1'); - expect(warmedChild?.style?.height).toBe(264); - expect(fittedFrame?.style?.height).toBeGreaterThan(264); + expect(warmedChild?.style?.height).toBe(296); + expect(fittedFrame?.style?.height).toBeGreaterThan(296); expect(fittedFrame?.style?.height).not.toBe(120); }); }); diff --git a/packages/shared/src/canvas-engine/__tests__/heightAuthority.test.ts b/packages/shared/src/canvas-engine/__tests__/heightAuthority.test.ts index 79a8a3e20..beec35f0b 100644 --- a/packages/shared/src/canvas-engine/__tests__/heightAuthority.test.ts +++ b/packages/shared/src/canvas-engine/__tests__/heightAuthority.test.ts @@ -67,12 +67,12 @@ describe("SET_NODE_GEOMETRY height: 'auto'", () => { expect(styleOf(writeResult.nodes, 'n1')).toEqual({ width: 400, - height: 264, + height: 296, }); expect(dataOf(writeResult.nodes, 'n1').heightMode).toBe('auto'); // `getNodeSize` reads `measured` first, so the mirror must agree. expect(writeResult.nodes.find((n) => n.id === 'n1')?.measured?.height).toBe( - 264, + 296, ); }); @@ -114,7 +114,7 @@ describe("SET_NODE_GEOMETRY height: 'auto'", () => { expect(styleOf(writeResult.nodes, 'n1')).toEqual({ width: 800, - height: 404, + height: 436, }); }); @@ -172,9 +172,9 @@ describe('APPLY_MEASURED_HEIGHT', () => { ], ); - expect(styleOf(writeResult.nodes, 'n1')?.height).toBe(264); + expect(styleOf(writeResult.nodes, 'n1')?.height).toBe(296); expect(writeResult.nodes.find((n) => n.id === 'n1')?.measured?.height).toBe( - 264, + 296, ); expect(dataOf(writeResult.nodes, 'n1').autoHeight).toEqual({ intrinsicHeight: 260, @@ -266,7 +266,7 @@ describe('APPLY_MEASURED_HEIGHT', () => { ); expect(dataOf(writeResult.nodes, 'n1').heightMode).toBe('auto'); - expect(styleOf(writeResult.nodes, 'n1')?.height).toBe(264); + expect(styleOf(writeResult.nodes, 'n1')?.height).toBe(296); }); it('reuses node references for an unchanged re-measurement', () => {