diff --git a/apps/server/src/prompt/agent-loader.test.ts b/apps/server/src/prompt/agent-loader.test.ts index 04cf735e4..6d8aa5e34 100644 --- a/apps/server/src/prompt/agent-loader.test.ts +++ b/apps/server/src/prompt/agent-loader.test.ts @@ -63,7 +63,7 @@ describe('renderTemplate {{include}}', () => { expect(out).toContain('nearNode: { id: ""'); expect(out).toContain('omit `size`'); - expect(out).toContain('Reserve `height: "auto"` for short Notes'); + expect(out).toContain("**Never pin a `note`'s height.**"); }); it('throws when the included path escapes PROMPT_ROOT', () => { diff --git a/apps/server/src/prompt/external-agent/access-huabu.md b/apps/server/src/prompt/external-agent/access-huabu.md index 584c6f979..865f693b1 100644 --- a/apps/server/src/prompt/external-agent/access-huabu.md +++ b/apps/server/src/prompt/external-agent/access-huabu.md @@ -150,14 +150,14 @@ 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 (96px nominal layout height) | | `web`, `pdf`, `office` | 400 × 400px | | `video` | 400 × 300px | | `image` | 400px wide; height follows the source aspect ratio (300px nominal before resolution) | | `frame` | 400 × 300px | | `question` | 200px wide; content-driven height (80px nominal layout height) | -Never pin top-level height for `text` or `question`; change rendered text scale with `data.style.fontSize`. Notes normally auto-size by content but may use an explicit fixed height. For free-form root or frame layouts, a useful starting heuristic is about 50px between nodes and 40px frame padding, adjusted to the actual queried sizes. +Never pin top-level height for `text`, `question`, or `note`; change rendered text scale with `data.style.fontSize`. A `note` sizes itself from its rendered Markdown, and passing a numeric `height` records it as user-pinned — it then keeps that box permanently and truncates its content instead of growing. Omit `height` entirely; omitting it also lets Huabu start a very long note collapsed, with a one-click expand chevron, so it does not bury its neighbours, while `height: "auto"` forces that note to stay expanded no matter how long it is. Omit `width` as well unless inspected neighbours agree on a different number: a note is 400px wide, length is the height's business, and copying one wide neighbour is how a Space ratchets wider with nothing to pull it back. For free-form root or frame layouts, a useful starting heuristic is about 50px between nodes and 40px frame padding, adjusted to the actual queried sizes. Frames default to `free` layout, where child positions remain explicit and parent-local. `column`, `row`, and `grid` are structured layouts configured through `SET_FRAME_LAYOUT`; `gridCount` selects 1–12 tracks (columns for `column` and `grid`, rows for `row`) and defaults to 1. `column` and `row` are masonry: each track stacks independently, so a track holding fewer items pulls its next item up. `grid` additionally aligns rows: every child holds a cell, and a column with no child in a row leaves that cell blank, which is what keeps items in different columns side by side when one column has no counterpart. Structured frames compute final child geometry, so child `position` is only an ordering hint — in `grid` mode it does not decide rows at all. Use the same command's `cells` array to place children: `column` addresses columns, `row` addresses rows, `grid` addresses both. Their default `hug` sizing fits the frame to its content; `manual` preserves a pinned frame size while children still reflow and may overflow. Fetch the `SET_FRAME_LAYOUT` capability before using sizing or track options. diff --git a/apps/server/src/prompt/skills/space/references/layout-recipes.md b/apps/server/src/prompt/skills/space/references/layout-recipes.md index c157d81a3..4bf22b1bb 100644 --- a/apps/server/src/prompt/skills/space/references/layout-recipes.md +++ b/apps/server/src/prompt/skills/space/references/layout-recipes.md @@ -11,10 +11,11 @@ Opinionated layouts for structured diagrams (architecture diagrams, flowcharts, ## Choosing node size -- In an existing layout, inspect the intended anchor or cluster before setting `size`. With an anchor id, the fastest path is one call: `inspect_nodes({ nearNode: { id: "", sameParent: true, maxCount: 5 }, byType: "" })`. Results include `size`; no separate anchor inspection or whole-Space outline is needed. Match peers with the same semantic role and use their median dimensions rather than copying an outlier. +- In an existing layout, inspect the intended anchor or cluster before setting `size`. With an anchor id, the fastest path is one call: `inspect_nodes({ nearNode: { id: "", sameParent: true, maxCount: 5 }, byType: "" })`. Results include `size`; no separate anchor inspection or whole-Space outline is needed. Match peers with the same semantic role and use their **median** dimensions — never their maximum, and never a single outlier. A size you copied from one wide neighbour becomes the size the next agent copies, and the Space ratchets wider with nothing to pull it back. - Without an anchor id but with an intended absolute position, use `inspect_nodes({ nearPoint: { x, y, maxCount: 5 }, byType: "" })`. If neither query returns a comparable peer, do not spend another read trying to manufacture one. - If there is no comparable peer, omit `size` and let Huabu use the node type's canonical default. Do not invent an oversized box merely because the new node has more content. -- For a long or multi-section `note`, prefer a fixed numeric height so the canvas remains scannable: match nearby Notes when available; otherwise use `size: { width: 400, height: 400 }`. Reserve `height: "auto"` for short Notes whose full inline expansion is intentional. +- **Never pin a `note`'s height.** Omit `height` entirely and let Huabu measure the rendered Markdown and size the node itself. A numeric height records the note as user-pinned, which permanently opts it out of re-measurement — the note then keeps that box forever and truncates its content as it grows. Omitting `height` also lets Huabu start a very long note collapsed, with a one-click expand chevron, so a document-length note does not bury its neighbours; pass `height: "auto"` only when that note should be fully expanded regardless of length. +- **A `note` is 400px wide. Omit `width` too, unless you have actually inspected neighbours and they agree on a different width.** Never widen a note because its content is long — length is the height's business, and the height is measured for you. Copying the width of a single wide node is how a Space ratchets: each new node matches the widest thing near it, nothing ever pulls the number back down, and the canvas drifts wider one node at a time. If inspected peers disagree, take 400 rather than their maximum. - `text` and `question` heights are always content-driven; never set their height. For `image`, match a comparable peer's width and omit height so Huabu derives it from the source aspect ratio. For `video`, match both dimensions of a comparable Video or omit `size` entirely. ## Positioning patterns diff --git a/apps/web/e2e/note-auto-height.spec.ts b/apps/web/e2e/note-auto-height.spec.ts index cab6f4814..754ad6dad 100644 --- a/apps/web/e2e/note-auto-height.spec.ts +++ b/apps/web/e2e/note-auto-height.spec.ts @@ -258,10 +258,17 @@ test.describe('note auto height', () => { parseFloat((element as HTMLElement).style.height), ); + // The preview workspace no longer opens itself when a note is + // created, so drive the shipped path: select the note, then hit + // Expand on its floating toolbar. + await note.click({ force: true }); + await page.getByRole('button', { name: 'Expand', exact: true }).click(); + await expect(page.locator('[data-search-scope="node"]')).toHaveCount(1); + const editor = page.locator( '[data-search-scope="node"] .ProseMirror[contenteditable="true"]', ); - await expect(editor).toHaveCount(1); + await expect(editor).toHaveCount(1, { timeout: 15_000 }); const longContent = Array.from( { length: 12 }, @@ -269,7 +276,11 @@ 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(); + // Leave the preview the way a user does. The close control is only + // rendered when the panel is not embedded, so drive the keyboard + // path, which works in both presentations. + await page.keyboard.press('Escape'); + await expect(page.locator('[data-search-scope="node"]')).toHaveCount(0); await expect .poll(() => @@ -283,6 +294,69 @@ test.describe('note auto height', () => { expect(overflow).toBeLessThanOrEqual(1); }); + test('a long agent note is born collapsed and expands from its chevron', async ({ + page, + }) => { + // The two halves of the same promise: a document-length note must + // not bury the canvas it lands on, and the way back out must be one + // click away. Only meaningful together — collapsing without a + // reachable expand affordance is a trap. + await openNewCanvas(page); + const markdown = Array.from( + { length: 20 }, + (_, index) => + `## Section ${index + 1}\n\nThis paragraph is long enough to wrap at the note width and would make an auto-height note thousands of pixels tall.`, + ).join('\n\n'); + + // No `size` at all: the collapse heuristic only applies when the + // caller expressed no height preference, so the shared + // `createAgentNote` helper (which passes `height: 'auto'`) would + // deliberately opt out. + await executeAgentCommands(page, [ + { + type: 'CREATE_NODES', + nodes: [ + { + nodeType: 'note', + data: { label: 'Agent document', content: markdown }, + position: { x: 100, y: 100 }, + }, + ], + }, + ]); + + const note = page.locator('.react-flow__node-note'); + await expect(note.locator('.ProseMirror')).toHaveCount(1); + + // This project emulates a touch device, where the pen tool is armed + // by default and its overlay owns every canvas pointer event. Leave + // that mode the way a user does, so the click below reaches the node + // rather than the sketch surface. + await page.keyboard.press('Escape'); + + const heightOf = () => + note.evaluate((element) => + parseFloat((element as HTMLElement).style.height), + ); + + // Give any stray measurement a chance to land before asserting the + // height stayed put — a collapsed note is pinned, so nothing should. + await page.waitForTimeout(1500); + const collapsedHeight = await heightOf(); + expect(collapsedHeight).toBeLessThan(400); + expect(collapsedHeight).toBeGreaterThan(200); + + await note.click({ force: true }); + await page + .getByRole('button', { name: 'Show the whole note', exact: true }) + .click(); + + await expect.poll(heightOf).toBeGreaterThan(collapsedHeight + 500); + + const [overflow] = await measureOverflows(page); + expect(overflow).toBeLessThanOrEqual(1); + }); + test('every auto note fits the content it was measured from', async ({ page, }) => { diff --git a/apps/web/src/components/Nodes/note/NoteNode.tsx b/apps/web/src/components/Nodes/note/NoteNode.tsx index 221c93626..84bc17eb4 100644 --- a/apps/web/src/components/Nodes/note/NoteNode.tsx +++ b/apps/web/src/components/Nodes/note/NoteNode.tsx @@ -3,11 +3,14 @@ import { type Node, type NodeProps, useStore } from '@xyflow/react'; import clsx from 'clsx'; -import { ChevronsDown, Fullscreen } from 'lucide-react'; +import { ChevronsDown, ChevronsUp, 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 { + autoHeightKey, + NOTE_COLLAPSE_CONTENT_THRESHOLD, +} from '@huabu/shared/canvas-engine'; import { FloatingToolbar } from '@/components/Common/FloatingToolbar'; import { Loading } from '@/components/Common/Loading'; @@ -69,6 +72,7 @@ export const NoteNode = memo( ({ id, data, selected }: NodeProps) => { const { t } = useTranslation(); const updateNodeData = useCanvasStore((s) => s.updateNodeData); + const setNoteHeightMode = useCanvasStore((s) => s.setNoteHeightMode); const moveNoteBlockIntoNote = useCanvasStore( (s) => s.moveNoteBlockIntoNote, ); @@ -222,6 +226,23 @@ export const NoteNode = memo( const isTruncated = contentHeight > 0 && hostHeight > 0 && contentHeight - hostHeight > 1; + // An expanded note has nothing hidden, so `isTruncated` is false and + // the fade never appears — but the user still needs a way back. Show + // the collapse control on an expanded note whose content is long + // enough that it would have been collapsed at birth, and only while + // it is selected, so an unselected canvas stays free of chrome. + const showCollapseControl = + !isFixedHeight && + selected && + markdown.length > NOTE_COLLAPSE_CONTENT_THRESHOLD; + + // A name of its own rather than the toolbar's "Switch to fixed + // height": both controls can be on screen at once, and two buttons + // sharing an accessible name is ambiguous to a screen reader. + const heightToggleLabel = isFixedHeight + ? t('node.showFullNote') + : t('node.collapseNote'); + // Report the measured intrinsic height as a *proposal*. The queue // decides whether it is worth committing and when; the engine owns // the conversion to a layout height and the write to `style.height`. @@ -487,24 +508,53 @@ export const NoteNode = memo( )} - {isTruncated && ( + {(isTruncated || showCollapseControl) && (
- {/* Fade gradient */} -
+ {isTruncated && ( +
+ )}
- +
)} diff --git a/apps/web/src/i18n/resources/en/common.json b/apps/web/src/i18n/resources/en/common.json index 0d3ea5d7d..6d74b5dbd 100644 --- a/apps/web/src/i18n/resources/en/common.json +++ b/apps/web/src/i18n/resources/en/common.json @@ -419,6 +419,8 @@ }, "node": { "untitled": "Untitled", + "showFullNote": "Show the whole note", + "collapseNote": "Collapse this note", "rename": "Rename node", "preview": "Preview", "navigateUpstream": "Navigate to a source node ({{shortcut}})", diff --git a/apps/web/src/i18n/resources/zh-CN/common.json b/apps/web/src/i18n/resources/zh-CN/common.json index 0e3644ad1..7265717fe 100644 --- a/apps/web/src/i18n/resources/zh-CN/common.json +++ b/apps/web/src/i18n/resources/zh-CN/common.json @@ -419,6 +419,8 @@ }, "node": { "untitled": "未命名", + "showFullNote": "展开完整笔记", + "collapseNote": "折叠这条笔记", "rename": "重命名节点", "preview": "预览", "navigateUpstream": "导航到来源节点({{shortcut}})", diff --git a/docs/architecture/node-auto-height.md b/docs/architecture/node-auto-height.md index 57cb38108..d2837fc4e 100644 --- a/docs/architecture/node-auto-height.md +++ b/docs/architecture/node-auto-height.md @@ -21,6 +21,8 @@ The governing rule is that **rendering never causes a geometry change**. Zoom, p 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. +`note` carries two floors, not one, because "how short may a short note be" and "how much of a long note do we preview" are opposite questions. `minIntrinsicHeight` is 91 — measured in a real browser, one paragraph line is 41px intrinsic and each further line adds 25, so that is three lines, rendering as a 96px card. It stays small on purpose: a note under the collapse threshold is meant to size itself to its content, and every pixel of floor above the real content erases the difference between a short note and a slightly longer one. A floor of 244 would render every note of nine lines or fewer at an identical 248px. `collapsedIntrinsicHeight` is 244 — the preview window for a note too long to show at all, generous enough to read under the fade. `nodeSizes.ts` reaches neither: `getNodeCreationStyle` drops the nominal height for auto-by-default types, so the policy is the only knob that changes what you see. + 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. ## 3. Ownership @@ -39,7 +41,9 @@ The scale divides the node's **content** width, its box minus the shell border, A resize gesture on an auto note pins it to `fixed`. That flip is implicit, so the toolbar's auto indicator reflects it at gesture end. -The `fixed → auto` flip is reachable **only** from the node toolbar (and the multi-select toolbar). A truncated note draws a fade + chevron along its bottom edge, but that is a hint, not a control: as a click target it spanned the card's full width right where selection and resize gestures land, so it fired by accident far more often than on purpose. +The `fixed ↔ auto` flip is reachable from the node toolbar (and the multi-select toolbar) and from the chevron a note draws along its bottom edge. That chevron was previously inert, and for a reason worth preserving: as a click target it spanned the card's full width right where selection and resize gestures land, so it fired by accident far more often than on purpose. It is now a control again, but a narrow one — the full-width container stays `pointer-events-none` and only a 24×24 button opts back in, carrying `nodrag nopan` so React Flow cannot read the press as the start of a drag or pan. Without a reachable expand affordance the collapse-on-create rule in §8 would be a trap rather than a default. + +The chevron renders when the note is truncated (pointing down: expand) and also on a selected, already-expanded note whose content is past the collapse threshold (pointing up: collapse). The second case exists because an expanded note hides nothing, so truncation alone would leave no way back. ## 4. Freshness @@ -101,6 +105,8 @@ measure ────────▶ propose → gate → APPLY_MEASURED_HEIGHT **Creation** records `heightMode` and materializes immediately, so the same batch's `fitFrames` and grid solver see a real footprint rather than zero. +A note created with content past [`NOTE_COLLAPSE_CONTENT_THRESHOLD`](../../packages/shared/src/canvas-engine/height/policy.ts) is born **collapsed**: pinned to `collapsedLayoutHeight`, the type's `collapsedIntrinsicHeight` run through the same width scaling a measured height gets. Auto height is right for a note you can take in at a glance; past that the node grows to thousands of pixels and buries its neighbours at the moment it holds the most information. The threshold counts characters because nothing has been measured yet at creation, and it is derived by inverting the measured ~1.34x characters-to-intrinsic ratio from the rendered height we want to trigger on — so moving it means picking a target height first, not picking a rounder number. The rule is consulted only when the caller expressed no height preference at all — a numeric height pins as always, and an explicit `'auto'` is the author saying "expand this one", which outranks the heuristic. It lives in `policy.ts`, not in a creation helper, so the headless engine and the web client cannot disagree and produce a geometry write on every load. + **Load** runs [`normalizeNodeHeights`](../../apps/web/src/store/canvasStore/load/normalizeNodeHeights.ts) — write the inferred owner explicitly, then materialize — and then [`warmupNodeHeights`](../../apps/web/src/store/canvasStore/load/warmupNodeHeights.ts), which measures never-measured notes _before_ the canvas is shown, nearest to the restored viewport first, under a wall-clock budget. Warmup applies the completed measurements to the fetched snapshot through the pure canvas executor, so `APPLY_MEASURED_HEIGHT` performs the same freshness checks and parent Frame/Portal relayout as an interactive measurement without dispatching through the web store or creating load-time history. Normalization never writes a hint; a canvas saved before this model existed would otherwise paint a wall of collapsed cards and expand them one by one. **Prewarming** ([`prewarmQueue.ts`](../../apps/web/src/components/Nodes/shared/height/measure/prewarmQueue.ts)) then measures the rest on idle, ordered by distance from the viewport with never-measured notes ahead of stale ones. Each candidate carries the key of the content it is about to measure, captured before the async work starts, and the commit is dropped if the node's key moved meanwhile. 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..e90bfe67d 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: 96, }); expect( ( diff --git a/packages/shared/src/canvas-engine/__tests__/heightAuthority.test.ts b/packages/shared/src/canvas-engine/__tests__/heightAuthority.test.ts index 79a8a3e20..4b1f4db0e 100644 --- a/packages/shared/src/canvas-engine/__tests__/heightAuthority.test.ts +++ b/packages/shared/src/canvas-engine/__tests__/heightAuthority.test.ts @@ -98,7 +98,7 @@ describe("SET_NODE_GEOMETRY height: 'auto'", () => { type: 'note', content: CONTENT, heightMode: 'auto', - autoHeight: { intrinsicHeight: 200, measuredFor: KEY }, + autoHeight: { intrinsicHeight: 400, measuredFor: KEY }, }, } as Partial); @@ -114,7 +114,7 @@ describe("SET_NODE_GEOMETRY height: 'auto'", () => { expect(styleOf(writeResult.nodes, 'n1')).toEqual({ width: 800, - height: 404, + height: 800, }); }); diff --git a/packages/shared/src/canvas-engine/commands/createNodes.ts b/packages/shared/src/canvas-engine/commands/createNodes.ts index 604414081..3dee9467d 100644 --- a/packages/shared/src/canvas-engine/commands/createNodes.ts +++ b/packages/shared/src/canvas-engine/commands/createNodes.ts @@ -3,8 +3,12 @@ import { noop, type CommandDefinition } from './types.js'; import { createId, type CanvasCommand } from '../../index.js'; +import { collapsedLayoutHeight } from '../height/compute.js'; import { materializeAutoHeight } from '../height/materialize.js'; -import { getHeightPolicy } from '../height/policy.js'; +import { + getHeightPolicy, + shouldCollapseNoteOnCreate, +} from '../height/policy.js'; import { deduplicateLabel, generateNextLabel } from '../utils/labels.js'; import { getNodeCreationStyle, @@ -119,8 +123,23 @@ const createNodes: CommandDefinition = { // that number as a pinned height on the next load. const isToggleableHeight = getHeightPolicy(nodeType).kind === 'toggleable'; + + // A note long enough to bury its neighbours starts collapsed at + // the policy minimum rather than auto-sized to its full content. + // Only when the caller expressed no height preference at all: a + // number pins as always, and an explicit `'auto'` is the author + // saying "expand this one", which outranks the heuristic. + const collapseOnCreate = + explicitSize?.height === undefined && + shouldCollapseNoteOnCreate(nodeType, inputData.content); + + const collapsedHeight = collapseOnCreate + ? collapsedLayoutHeight(nodeType, geometryStyle.width) + : undefined; + const heightMode = isToggleableHeight - ? typeof geometryStyle.height === 'number' + ? typeof geometryStyle.height === 'number' || + collapsedHeight !== undefined ? 'fixed' : 'auto' : undefined; @@ -138,7 +157,10 @@ const createNodes: CommandDefinition = { label, type: nodeType, }, - style: geometryStyle, + style: + collapsedHeight !== undefined + ? { ...geometryStyle, height: collapsedHeight } + : geometryStyle, }; // Materialize immediately so the node has a real footprint before 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..3caa7decd 100644 --- a/packages/shared/src/canvas-engine/height/__tests__/height.test.ts +++ b/packages/shared/src/canvas-engine/height/__tests__/height.test.ts @@ -11,7 +11,9 @@ import { import { HEIGHT_LAYOUT_VERSION, HEIGHT_QUANTIZATION_STEP, + NOTE_COLLAPSE_CONTENT_THRESHOLD, autoHeightKey, + collapsedLayoutHeight, contentScaleFor, getHeightPolicy, getHeightRefWidth, @@ -23,6 +25,7 @@ import { quantizeHeight, readAutoHeightHint, resolveHeightMode, + shouldCollapseNoteOnCreate, } from '../index.js'; import type { Node } from '@xyflow/react'; @@ -210,15 +213,16 @@ describe('intrinsicToLayoutHeight', () => { // 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); + // reference width is 394/400 rather than 1. The intrinsic value is + // above the note minimum so this measures the scaling, not the clamp. + expect(intrinsicToLayoutHeight(400, 'note', 400)).toBe(400); + expect(intrinsicToLayoutHeight(400, 'note', 800)).toBe(800); }); 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 91 unscaled; at half width the scale halves too. + expect(intrinsicToLayoutHeight(10, 'note', 400)).toBe(96); + expect(intrinsicToLayoutHeight(10, 'note', 200)).toBe(52); }); it('does not scale types without a reference width', () => { @@ -233,7 +237,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(800, 'note', 100)).toBe(196); }); it('floors the scale for manual types, whose box the user owns', () => { @@ -460,3 +464,42 @@ describe('materializeAutoHeight', () => { expect(materializeAutoHeights(nodes)).toBe(nodes); }); }); + +describe('collapse on create', () => { + it('collapses only a toggleable type whose content exceeds the threshold', () => { + const long = 'x'.repeat(NOTE_COLLAPSE_CONTENT_THRESHOLD + 1); + const short = 'x'.repeat(NOTE_COLLAPSE_CONTENT_THRESHOLD); + + expect(shouldCollapseNoteOnCreate('note', long)).toBe(true); + expect(shouldCollapseNoteOnCreate('note', short)).toBe(false); + + // `text` and `question` size themselves through a different + // mechanism, and a manual type owns its box outright. + expect(shouldCollapseNoteOnCreate('text', long)).toBe(false); + expect(shouldCollapseNoteOnCreate('image', long)).toBe(false); + }); + + it('ignores content that is not a string', () => { + expect(shouldCollapseNoteOnCreate('note', undefined)).toBe(false); + expect(shouldCollapseNoteOnCreate('note', { text: 'x' })).toBe(false); + }); + + it('collapses to a readable preview, not to the short-note floor', () => { + // The two constants exist precisely so these differ. Collapsing to + // the minimum would turn every long note into a stub, and raising + // the minimum to the collapsed height would render every note of + // nine lines or fewer at the same size. + expect(collapsedLayoutHeight('note', 400)).toBe(248); + expect(intrinsicToLayoutHeight(0, 'note', 400)).toBe(96); + + // Scales with the node's own width, exactly like a measured height. + expect(collapsedLayoutHeight('note', 800)).toBe( + intrinsicToLayoutHeight(244, 'note', 800), + ); + + // A type with no collapsed height still cannot fall below its floor. + expect(collapsedLayoutHeight('image', 400)).toBe( + intrinsicToLayoutHeight(0, 'image', 400), + ); + }); +}); diff --git a/packages/shared/src/canvas-engine/height/compute.ts b/packages/shared/src/canvas-engine/height/compute.ts index 1baa31e4c..d354041a5 100644 --- a/packages/shared/src/canvas-engine/height/compute.ts +++ b/packages/shared/src/canvas-engine/height/compute.ts @@ -106,3 +106,25 @@ export function intrinsicToLayoutHeight( const scaled = clamped * contentScaleFor(policy, width); return quantizeHeight(scaled + (policy.insetY ?? 0)); } + +/** + * Layout height a *collapsed* node occupies: the type's collapsed + * preview height, converted for the node's own width. + * + * Runs through {@link intrinsicToLayoutHeight} rather than returning a + * literal so it scales with the node's width exactly like a measured + * height does. Types without a collapsed height fall through to their + * minimum, which is also what the clamp inside that function guarantees: + * a collapsed node can never end up shorter than a short one. + */ +export function collapsedLayoutHeight( + nodeType: string | undefined, + width: number | undefined, +): number { + const policy = getHeightPolicy(nodeType); + return intrinsicToLayoutHeight( + policy.collapsedIntrinsicHeight ?? 0, + nodeType, + width, + ); +} 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..a23ccd1d7 100644 --- a/packages/shared/src/canvas-engine/height/index.ts +++ b/packages/shared/src/canvas-engine/height/index.ts @@ -18,15 +18,18 @@ export { type HeightMode, type HeightPolicy, NODE_SHELL_INSET, + NOTE_COLLAPSE_CONTENT_THRESHOLD, getHeightPolicy, getHeightRefWidth, isAlwaysAutoHeightType, isAutoHeightByDefaultType, resolveHeightMode, + shouldCollapseNoteOnCreate, } from './policy.js'; export { HEIGHT_QUANTIZATION_STEP, + collapsedLayoutHeight, contentScaleFor, intrinsicToLayoutHeight, quantizeHeight, diff --git a/packages/shared/src/canvas-engine/height/policy.ts b/packages/shared/src/canvas-engine/height/policy.ts index c0419ebf5..42906da40 100644 --- a/packages/shared/src/canvas-engine/height/policy.ts +++ b/packages/shared/src/canvas-engine/height/policy.ts @@ -49,6 +49,18 @@ export interface HeightPolicy { * before scaling. Keeps an empty node from collapsing to nothing. */ minIntrinsicHeight?: number; + /** + * Intrinsic content height (px, unscaled) a *collapsed* node of this + * type is pinned to. Deliberately separate from + * {@link minIntrinsicHeight}: the two look alike but do opposite jobs. + * + * The minimum is a floor on a note that is genuinely short, so it must + * stay small or it erases the difference between a two-line note and a + * nine-line one. The collapsed height is a *preview window* onto a note + * that is far too long to show, so it must be generous enough to read. + * Collapsing to the minimum would make every long note a stub. + */ + collapsedIntrinsicHeight?: number; /** * Chrome (px) that lives outside the measured element but inside the * node box — added after scaling. `0` where the measurement already @@ -104,6 +116,59 @@ const MANUAL_POLICY: HeightPolicy = { kind: 'manual' }; */ export const NODE_SHELL_INSET = 6; +/** + * Authored content length (characters) past which a newly created note + * is born **collapsed** — pinned to the policy minimum with the expand + * affordance showing — instead of auto-sized to its full content. + * + * Auto height is the right default for a note you can take in at a + * glance. Past roughly a screenful it stops being a note and becomes a + * document: the node grows to thousands of pixels, buries its + * neighbours, and makes the canvas unusable at the very moment it holds + * the most information. Collapsing at birth keeps the canvas scannable + * and costs one click to undo. + * + * Only consulted at creation, and only when the caller expressed no + * height preference at all. An explicit numeric height pins as always, + * and an explicit `'auto'` means "expand this one fully" — the author + * has spoken either way, so neither is second-guessed. + * + * The unit is characters because nothing has been measured yet at + * creation time, but it is a good proxy in the range that matters: + * across real agent-written notes past ~600 characters, intrinsic + * height lands at 1.34x the character count with only ~5% spread — + * tighter than a line count predicts the same heights (~9%). + * + * The number is chosen by inverting that ratio from the rendered height + * we actually want to trigger on, not picked for roundness. 550 lands a + * little over 700px, roughly 2.5x a typical short note and about a + * screenful, against the ~248px collapsed preview. An earlier value of + * 800 inverted to ~1080px, so a note had to outgrow an entire screen + * before it collapsed and everything between 600 and 1080px rendered at + * full length — which is exactly what made a wall of long notes look + * arbitrary next to a collapsed one. + */ +export const NOTE_COLLAPSE_CONTENT_THRESHOLD = 550; + +/** + * Whether a note created with this content should start collapsed. + * + * Lives beside the policy rather than in a creation helper because the + * headless engine and the web client must agree on it exactly: a note + * that the server collapses and the client expands would produce a + * geometry write on every load. + */ +export function shouldCollapseNoteOnCreate( + nodeType: string | undefined, + content: unknown, +): boolean { + if (getHeightPolicy(nodeType).kind !== 'toggleable') return false; + return ( + typeof content === 'string' && + content.length > NOTE_COLLAPSE_CONTENT_THRESHOLD + ); +} + /** * Height policy per node type. Types absent from this table are `manual`. * @@ -114,12 +179,30 @@ export const NODE_SHELL_INSET = 6; const HEIGHT_POLICIES: Readonly> = { // The note body measures `.ProseMirror` plus the host's own vertical // padding, so the only thing left to add is the node shell itself. + // + // Two different jobs, two different numbers — see the field docs. + // + // `minIntrinsicHeight` is a floor for a genuinely short note. Measured + // in a real browser at refWidth: one paragraph line is 41px intrinsic + // and each additional line adds 25, so 91 is three lines and renders + // as a 96px card. The old value of 50 rendered as 56 — one line and a + // sliver of the next. It stays deliberately small because a note under + // the collapse threshold is supposed to size itself to its content; + // every pixel of floor above the real content erases the difference + // between a short note and a slightly longer one. (A floor of 244 + // would render every note of nine lines or fewer at the same 248px.) + // + // `collapsedIntrinsicHeight` is the preview window for a note too long + // to show at all, so it is generous: 244 renders as 248px, roughly a + // nine-line excerpt, with the fade and the expand chevron over it. + // // No `minContentScale`: its height is derived from the scale, and a // floor would make the content lay out narrower than `refWidth`. note: { kind: 'toggleable', refWidth: 400, - minIntrinsicHeight: 50, + minIntrinsicHeight: 91, + collapsedIntrinsicHeight: 244, insetY: NODE_SHELL_INSET, }, text: { kind: 'content' }, diff --git a/packages/shared/src/canvas-engine/index.ts b/packages/shared/src/canvas-engine/index.ts index 2dcbcda9b..5abe5dca0 100644 --- a/packages/shared/src/canvas-engine/index.ts +++ b/packages/shared/src/canvas-engine/index.ts @@ -122,7 +122,9 @@ export { HEIGHT_LAYOUT_VERSION, HEIGHT_QUANTIZATION_STEP, NODE_SHELL_INSET, + NOTE_COLLAPSE_CONTENT_THRESHOLD, autoHeightKey, + collapsedLayoutHeight, contentScaleFor, getHeightPolicy, getHeightRefWidth, @@ -135,6 +137,7 @@ export { readAutoHeightHint, resolveAutoLayoutHeight, resolveHeightMode, + shouldCollapseNoteOnCreate, } from './height/index.js'; export { getNodeDefaultSize, diff --git a/packages/shared/src/canvas-engine/utils/nodeSizes.ts b/packages/shared/src/canvas-engine/utils/nodeSizes.ts index 3aae02ab3..67014c46e 100644 --- a/packages/shared/src/canvas-engine/utils/nodeSizes.ts +++ b/packages/shared/src/canvas-engine/utils/nodeSizes.ts @@ -28,11 +28,14 @@ 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. This height is *nominal*: + // `getNodeCreationStyle` drops it for auto-by-default types, so it is + // never written to `style.height` — it exists for layout calculations + // (viewport centring, placement) that need a footprint before the node + // has been measured. It mirrors the rendered minimum, which the note + // policy's `minIntrinsicHeight` (91) converts to 96px at the reference + // width. Change that constant, not this one. + note: { width: 400, height: 96 }, web: { width: 400, height: 400 }, pdf: { width: 400, height: 400 }, office: { width: 400, height: 400 },