Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/server/src/prompt/agent-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ describe('renderTemplate {{include}}', () => {

expect(out).toContain('nearNode: { id: "<anchorId>"');
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', () => {
Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/prompt/external-agent/access-huabu.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<anchorId>", sameParent: true, maxCount: 5 }, byType: "<newNodeType>" })`. 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: "<anchorId>", sameParent: true, maxCount: 5 }, byType: "<newNodeType>" })`. 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: "<newNodeType>" })`. 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
Expand Down
78 changes: 76 additions & 2 deletions apps/web/e2e/note-auto-height.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,18 +258,29 @@ 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 },
(_, index) =>
`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(() =>
Expand All @@ -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,
}) => {
Expand Down
72 changes: 61 additions & 11 deletions apps/web/src/components/Nodes/note/NoteNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -69,6 +72,7 @@ export const NoteNode = memo(
({ id, data, selected }: NodeProps<NoteNodeType>) => {
const { t } = useTranslation();
const updateNodeData = useCanvasStore((s) => s.updateNodeData);
const setNoteHeightMode = useCanvasStore((s) => s.setNoteHeightMode);
const moveNoteBlockIntoNote = useCanvasStore(
(s) => s.moveNoteBlockIntoNote,
);
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -487,24 +508,53 @@ export const NoteNode = memo(
)}
</div>
</div>
{isTruncated && (
{(isTruncated || showCollapseControl) && (
<div
aria-hidden
// The container spans the card so the fade reads as an
// edge treatment, but it must never take a click: the
// previous full-width hit target sat exactly where
// selection and resize gestures land and fired by
// accident far more often than on purpose. Only the
// small button below opts back into pointer events.
className="pointer-events-none absolute right-0 bottom-0 left-0 flex h-10 items-end justify-center pb-1"
>
{/* Fade gradient */}
<div
aria-hidden
className="from-fg-subtle/30 absolute inset-0 bg-linear-to-t to-transparent"
/>
{isTruncated && (
<div
aria-hidden
className="from-fg-subtle/30 absolute inset-0 bg-linear-to-t to-transparent"
/>
)}
<div
className="text-fg-subtle relative z-10"
className="relative z-10"
style={{
transform: `scale(${counterZoomScale})`,
transformOrigin: 'bottom center',
}}
>
<ChevronsDown size={14} />
<button
type="button"
// `nodrag` / `nopan` stop React Flow from reading
// the press as the start of a node drag or a
// canvas pan before the click ever lands.
className="nodrag nopan text-fg-subtle hover:text-fg-default hover:bg-hover pointer-events-auto flex size-6 cursor-pointer items-center justify-center rounded"
title={heightToggleLabel}
aria-label={heightToggleLabel}
aria-expanded={!isFixedHeight}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
setNoteHeightMode(
[id],
isFixedHeight ? 'auto' : 'fixed',
);
}}
>
{isFixedHeight ? (
<ChevronsDown size={14} />
) : (
<ChevronsUp size={14} />
)}
</button>
</div>
</div>
)}
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/i18n/resources/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}})",
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/i18n/resources/zh-CN/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,8 @@
},
"node": {
"untitled": "未命名",
"showFullNote": "展开完整笔记",
"collapseNote": "折叠这条笔记",
"rename": "重命名节点",
"preview": "预览",
"navigateUpstream": "导航到来源节点({{shortcut}})",
Expand Down
Loading
Loading