Skip to content
Merged
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
29 changes: 11 additions & 18 deletions packages/review/app/src/animation/animation-block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import type { PlayerStop, StagePlayer } from "./stage-player";
import {
type HostMessage,
STAGE_BOOT,
STAGE_MIN_WIDTH,
type SlotLayout,
type StageColorScheme,
type StageInit,
Expand Down Expand Up @@ -301,9 +302,10 @@ function StageBlock({
const { diagramFullscreenOnClick } = useWhiteboardSettings();
const frameRef = useRef<HTMLIFrameElement>(null);
const [contentHeight, setContentHeight] = useState(STAGE_MIN_HEIGHT);
// The widest the stage's content has reached past its edge, in any frame;
// the stage keeps that width and a narrower block scrolls it.
const [minWidth, setMinWidth] = useState(0);
// The board's reading width, or the widest the stage's content has
// reached past its edge in any frame: the stage keeps that width and a
// narrower block scrolls it.
const [minWidth, setMinWidth] = useState(STAGE_MIN_WIDTH);
// The tallest frame of the scenario, from a hidden measuring copy, so the
// stage does not change height as the reader steps through frames.
const [measuredHeight, setMeasuredHeight] = useState(0);
Expand Down Expand Up @@ -360,7 +362,7 @@ function StageBlock({
// of its height, and the width its stage was laid out at.
const holdInline = () => {
setHeld(figureRef.current?.offsetHeight ?? 0);
setInlineWidth(canvasRef.current?.clientWidth ?? 0);
setInlineWidth(Math.max(canvasRef.current?.clientWidth ?? 0, minWidth));
};

const openRef = useRef<HTMLButtonElement>(null);
Expand Down Expand Up @@ -1100,11 +1102,7 @@ function StageBlock({
>
<div
ref={canvasRef}
{...stylex.props(
styles.canvas,
minWidth > 0 && styles.canvasScrolls,
scaled && styles.canvasScaled,
)}
{...stylex.props(styles.canvas, scaled && styles.canvasScaled)}
style={{
height: stageHeight,
...(scaled && {
Expand All @@ -1113,10 +1111,7 @@ function StageBlock({
}),
}}
>
<div
{...stylex.props(styles.sheet)}
style={{ minWidth: minWidth || undefined }}
>
<div {...stylex.props(styles.sheet)} style={{ minWidth }}>
<iframe
key={stageKey}
ref={frameRef}
Expand Down Expand Up @@ -1476,12 +1471,10 @@ const styles = stylex.create({
canvas: {
position: "relative",
flex: "0 0 auto",
overflow: "hidden",
},
// The stage asked for a width: it keeps it, and a narrower block scrolls
// it sideways rather than folding the drawing.
canvasScrolls: {
// The stage keeps its width, and a narrower block scrolls it sideways
// rather than folding the drawing.
overflowX: "auto",
overflowY: "hidden",
},
canvasScaled: {
transformOrigin: "0 0",
Expand Down
5 changes: 5 additions & 0 deletions packages/review/app/src/animation/stage-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,11 @@ export type HostMessage =
/** The tallest an auto-sized stage may report itself. */
export const STAGE_MAX_HEIGHT = 4000;

/** The narrowest a stage lays out: the board's reading width. In a
* narrower block it keeps this width and scrolls sideways, rather than
* squeezing a drawing made for the board. */
export const STAGE_MIN_WIDTH = 720;

/** The widest a stage may ask to stay, scrolling sideways in a narrower
* block. */
export const STAGE_MAX_WIDTH = 4000;
Expand Down
87 changes: 79 additions & 8 deletions packages/review/app/src/animation/stage-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1783,6 +1783,51 @@ export function stageRuntime(port: MessagePort): void {
: [],
);

// Every drawn line, as thin boxes along its curve: a label clear of
// cards can still sit on a parallel edge fanned a few pixels from its
// own, so it avoids every line but its own.
const lines = new Map<ArrowRecord, DOMRect[]>();

for (const arrow of arrows) {
const route = arrow.labelRoute;

if (
!route ||
(arrow.group.dataset.wbState && arrow.group.dataset.wbState !== "on") ||
!arrow.path.checkVisibility({
visibilityProperty: true,
opacityProperty: true,
})
)
continue;

const { start, control, end } = route;
const boxes: DOMRect[] = [];
let previous = start;

for (let step = 1; step <= 24; step++) {
const t = step / 24;
const u = 1 - t;

const point = {
x: u * u * start.x + 2 * u * t * control.x + t * t * end.x,
y: u * u * start.y + 2 * u * t * control.y + t * t * end.y,
};

boxes.push(
new DOMRect(
Math.min(previous.x, point.x) - 1,
Math.min(previous.y, point.y) - 1,
Math.abs(point.x - previous.x) + 2,
Math.abs(point.y - previous.y) + 2,
),
);
previous = point;
}

lines.set(arrow, boxes);
}

for (const arrow of arrows) {
const { text, labelRoute } = arrow;

Expand Down Expand Up @@ -1884,6 +1929,19 @@ export function stageRuntime(port: MessagePort): void {
}

const collisions = [...obstacles, ...endpoints, ...controls, ...flights];

// Only the lines near enough to the label's candidates to meet them.
const reach = new DOMRect(
baseX + relative.left - 150,
baseY + relative.top - 160,
relative.width + 300,
relative.height + 320,
);

const crossing = [...lines]
.filter(([other]) => other !== arrow)
.flatMap(([, boxes]) => boxes.filter((box) => overlaps(box, reach)));

let selected: { x: number; y: number; rect: DOMRect } | undefined;

for (const [normalOffset, tangentOffset] of offsets) {
Expand All @@ -1906,6 +1964,7 @@ export function stageRuntime(port: MessagePort): void {
if (
inViewport &&
!collisions.some((obstacle) => overlaps(rect, obstacle, 3)) &&
!crossing.some((line) => overlaps(rect, line)) &&
!placed.some((other) => overlaps(rect, other, 4))
) {
selected = { x, y, rect };
Expand Down Expand Up @@ -2739,20 +2798,29 @@ export function stageRuntime(port: MessagePort): void {
grid.style.columnGap = `${Math.min(160, Math.max(floor, Math.ceil(widest) + 16))}px`;
};

// The scene keeps its columns at any width: narrower than its cards
// need, it overflows the stage, which asks its host for the width and is
// scrolled sideways, rather than shrinking cards, tables and labels, or
// reflowing the drawing.
// The scene keeps its columns at any width and fits the stage's: a row's
// labels give back the gap they opened before cards narrow. Narrower than
// its cards need, it overflows the stage, which asks its host for the
// width and is scrolled sideways, rather than shrinking cards, tables and
// labels, or reflowing the drawing.
const layout = () => {
sizeGap();
grid.style.columnGap = "";
grid.style.minWidth = "";
const style = getComputedStyle(grid);
const floor = parseFloat(style.columnGap);

const padding =
parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);

const gap = parseFloat(style.columnGap);
sizeGap();
const wanted = parseFloat(getComputedStyle(grid).columnGap);
const effective = Math.ceil(columns / unitSpan());
grid.style.minWidth = `${effective * 140 + (effective - 1) * gap + padding}px`;
const room = grid.clientWidth - padding;

if (effective > 1 && room - (effective - 1) * wanted < effective * 140)
grid.style.columnGap = `${Math.max(floor, (room - effective * 140) / (effective - 1))}px`;

grid.style.minWidth = `${effective * 140 + (effective - 1) * floor + padding}px`;
};

let layoutFrame: number | undefined;
Expand All @@ -2771,7 +2839,10 @@ export function stageRuntime(port: MessagePort): void {
layoutFrame = requestAnimationFrame(runLayout);
};

new ResizeObserver(() => wake()).observe(grid);
new ResizeObserver(() => {
wake();
scheduleLayout();
}).observe(grid);
layout();

const nodes = new Map<string, HTMLElement>();
Expand Down
126 changes: 116 additions & 10 deletions packages/review/app/src/blocks.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { page } from "vitest/browser";

import { STAGE_MAX_HEIGHT, STAGE_MAX_SLOTS } from "./animation/stage-protocol";
import {
STAGE_MAX_HEIGHT,
STAGE_MAX_SLOTS,
STAGE_MIN_WIDTH,
} from "./animation/stage-protocol";
import type { SavedMap } from "./api-document";
import {
BlockErrorBoundary,
Expand Down Expand Up @@ -1307,7 +1311,81 @@ describe("block components", () => {
expect(stages.count("ready")).toBe(1);
});

it("keeps a scene's columns at a minimum width, scrolling the block sideways, and opens columns to fit a row's link labels", async () => {
it("fits a scene to the block, opening columns to fit a row's link labels as far as the width allows", async () => {
const { container } = await mountFixture("animation", {
document: [
{
id: "block-1",
type: "animation",
title: "Scene",
height: "auto",
frames: [{ key: "only", title: "Only" }],
code: {},
// Three cards in a row, each link label as wide as the widest gap
// a label opens. The stage reports its columns, gap and whether
// every card sits inside it through the error line.
html:
"<script>const s = whiteboard.scene({ columns: 3 });" +
["a", "b", "c"]
.map(
(id, i) =>
`s.actor({ id: "${id}", at: [${i + 1}, 1], title: "Sharing host / ${id}" });`,
)
.join("") +
's.link("a", "b", { label: "Existing local board ID, reopened" });' +
's.link("b", "c", { label: "Download the published bundle" });' +
'setTimeout(() => { const grid = document.querySelector(".wb-scene"); const right = document.documentElement.clientWidth; const inside = [...grid.querySelectorAll(".wb-card")].every((card) => card.getBoundingClientRect().right <= right); const style = getComputedStyle(grid); throw new Error(`columns ${style.gridTemplateColumns.split(" ").length} gap ${parseFloat(style.columnGap) > 48} inside ${inside}`); }, 600);' +
"</script>",
pins: { repositoryId: "repo", base: "base", head: "head" },
},
],
});

expect(await settled(() => alertText(container).includes("columns"))).toBe(
true,
);
expect(alertText(container)).toContain("columns 3 gap true inside true");

// The stage is no wider than the block, so nothing scrolls.
const iframe = container.querySelector("iframe")!;
const figure = container.querySelector<HTMLElement>(".animation-block")!;
expect(iframe.getBoundingClientRect().width).toBeLessThanOrEqual(
figure.clientWidth,
);
});

it("keeps a link's label off the parallel line drawn back between the same cards", async () => {
const { container } = await mountFixture("animation", {
document: [
{
id: "block-1",
type: "animation",
title: "Round trip",
height: "auto",
frames: [{ key: "only", title: "Only" }],
code: {},
// The stage reports whether any point of the return line falls
// inside the outbound label through the error line.
html:
"<script>const s = whiteboard.scene({ columns: 2 });" +
's.actor({ id: "a", at: [1, 1], title: "Desktop" });' +
's.actor({ id: "b", at: [2, 1], title: "Host" });' +
's.link("a", "b", { label: "Import link" });' +
's.link("b", "a");' +
'setTimeout(() => { const label = [...document.querySelectorAll(".wb-label")].find((text) => text.textContent === "Import link"); const box = label.getBoundingClientRect(); const back = [...document.querySelectorAll(".wb-arrow")].find((arrow) => !arrow.contains(label)).querySelector(".wb-line"); const length = back.getTotalLength(); let crossed = false; for (let at = 0; at <= length; at += 2) { const p = back.getPointAtLength(at); if (p.x >= box.left && p.x <= box.right && p.y >= box.top && p.y <= box.bottom) crossed = true; } throw new Error(`label crosses ${crossed}`); }, 600);' +
"</script>",
pins: { repositoryId: "repo", base: "base", head: "head" },
},
],
});

expect(await settled(() => alertText(container).includes("label"))).toBe(
true,
);
expect(alertText(container)).toContain("label crosses false");
});

it("keeps a scene's columns in a block narrower than its cards need, scrolling it sideways", async () => {
const { container } = await mountFixture("animation", {
document: [
{
Expand All @@ -1317,10 +1395,9 @@ describe("block components", () => {
height: "auto",
frames: [{ key: "only", title: "Only" }],
code: { status: "head/order.ts#L1" },
// Six cards in a row with a label that opens the gap, and a wide
// peek: wider than any block, so the stage asks for its width. Once
// the stage is as wide as the scene, or time is up, it reports its
// columns, gap and card widths through the error line.
// Six cards in a row and a wide peek: wider than any block, so the stage asks for its width. Once the stage
// is as wide as the scene, or time is up, it reports its columns
// and card widths through the error line.
html:
"<script>const s = whiteboard.scene({ columns: 6 });" +
["a", "b", "c", "d", "e", "f"]
Expand All @@ -1332,7 +1409,7 @@ describe("block components", () => {
's.link("a", "b", { label: "init + port to the stage" });' +
's.peek("status", { at: [1, 2, 1, 6] });' +
"const deadline = Date.now() + 3000;" +
'const check = () => { const grid = document.querySelector(".wb-scene"); const fits = grid.getBoundingClientRect().width <= document.documentElement.clientWidth; if (!fits && Date.now() < deadline) { setTimeout(check, 50); return; } const style = getComputedStyle(grid); const widths = [...grid.querySelectorAll(".wb-card")].map((card) => card.getBoundingClientRect().width); throw new Error(`columns ${style.gridTemplateColumns.split(" ").length} gap ${parseFloat(style.columnGap) > 48} cards ${Math.min(...widths) >= 140} fits ${fits}`); };' +
'const check = () => { const grid = document.querySelector(".wb-scene"); const fits = grid.getBoundingClientRect().width <= document.documentElement.clientWidth; if (!fits && Date.now() < deadline) { setTimeout(check, 50); return; } const style = getComputedStyle(grid); const widths = [...grid.querySelectorAll(".wb-card")].map((card) => card.getBoundingClientRect().width); throw new Error(`columns ${style.gridTemplateColumns.split(" ").length} cards ${Math.min(...widths) >= 140} fits ${fits}`); };' +
"setTimeout(check, 300);" +
"</script>",
pins: { repositoryId: "repo", base: "base", head: "head" },
Expand All @@ -1343,9 +1420,7 @@ describe("block components", () => {
expect(await settled(() => alertText(container).includes("columns"))).toBe(
true,
);
expect(alertText(container)).toContain(
"columns 6 gap true cards true fits true",
);
expect(alertText(container)).toContain("columns 6 cards true fits true");

// The block scrolls the stage, at least six cards and their gaps wide.
const iframe = container.querySelector("iframe")!;
Expand Down Expand Up @@ -1392,6 +1467,37 @@ describe("block components", () => {
).toBe("auto");
});

it("lays a stage out at the board's reading width in a narrower block, scrolling it sideways", async () => {
const { container } = await mountFixture(
"animation",
{
document: documentSchema.parse([
{
id: "block-1",
type: "animation",
title: "Small",
height: 160,
frames: [{ key: "a", title: "One" }],
code: {},
html: '<div class="wb-row">Small</div>',
pins: { repositoryId: "repo", base: "base", head: "head" },
},
]),
},
"width: 480px",
);

const iframe = (await settled(() => container.querySelector("iframe")))!;
const figure = container.querySelector<HTMLElement>(".animation-block")!;
expect(figure.clientWidth).toBeLessThan(STAGE_MIN_WIDTH);
expect(iframe.getBoundingClientRect().width).toBeGreaterThanOrEqual(
STAGE_MIN_WIDTH,
);
expect(
getComputedStyle(iframe.parentElement!.parentElement!).overflowX,
).toBe("auto");
});

it("starts on the first scenario and switches the stage's path from the picker", async () => {
const { container } = await mountFixture("animation", {
document: [
Expand Down
4 changes: 3 additions & 1 deletion packages/review/instructions/animation.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ For a new animation, write the same files into a folder of your own and publish

Before you write HTML, read the two files whose paths `session_checkout` and these instructions give: `whiteboard.css`, the classes and `--wb-*` variables every animation loads, and `whiteboard.d.ts`, the `whiteboard` API. Use the classes and variables rather than your own colors, sizes or fonts, so the animation matches the board in light and dark.

Draw for a stage 720px wide: the width of the board's reading column, and the narrowest the stage gets. Fit that width with nothing past its right edge, so the reader never scrolls sideways on a full-size board: a scene fits three columns of cards, with room for link labels between them. A wider board or fullscreen gives the stage more room, and a narrower window keeps it at 720px and scrolls it sideways, so do not shrink the drawing for small widths. A scene keeps its columns at any width.

Start from the reference closest to what you are drawing, listed under References at the end of these instructions: copy its folder into your draft, then change the data, the prose and the code links.

There are three ways to draw.
Expand All @@ -39,7 +41,7 @@ For a few services, processes or stores with messages moving between them, use `
</script>
```

A scene names frame keys, so a new scenario means new frames and a few new lines like these. While a frame shows, each message it sends is the label of its edge and the edge takes the marker colour, however the reader reached the frame; stepping into the frame rides a chip from the sender that settles into the label. A card's `id` is its element's id, so your own CSS can address it as `#api`. A scene keeps its columns at any width: when the board is narrower than its cards need, the block scrolls the drawing sideways rather than reflowing it.
A scene names frame keys, so a new scenario means new frames and a few new lines like these. While a frame shows, each message it sends is the label of its edge and the edge takes the marker colour, however the reader reached the frame; stepping into the frame rides a chip from the sender that settles into the label. A card's `id` is its element's id, so your own CSS can address it as `#api`.

Give each card the silhouette of what it stands for, so a reader tells the parts apart before reading a title: `form: "window"` for a client, `"bars"` for a service or worker of this codebase, `"cylinder"` for a database, `"drum"` for a cache, `"stack"` for a queue or topic, `"pill"` for a start or end, `"cut"` for a branch. A third-party service or API gets `outline: "dashed"`. `color` sets a border colour at rest (`teal` for stores, `violet` for queues, `accent` for the part under discussion); a frame's `tone` takes over while set. The same `data-form`, `data-outline` and `data-color` work on a `wb-node` in a hand-made layout.

Expand Down
Loading