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
69 changes: 69 additions & 0 deletions packages/viewer/e2e/focus.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { expect, test, type Page } from '@playwright/test';

const inspector = (page: Page) => page.getByRole('complementary', { name: 'Inspector' });
const header = (page: Page) => page.getByRole('banner');
const tool = (page: Page, name: string | RegExp) => page.getByRole('button', { name });
const zoom = async (page: Page) => Number((await page.locator('span.tabular-nums', { hasText: /^\d+%$/ }).textContent())!.replace('%', ''));

test('Z leaves only the canvas, which still works, and Z again brings everything back', async ({ page }) => {
await page.goto('/?page=release-delivery');
await expect(page.locator('.sm-card').first()).toBeVisible();
const before = (await page.locator('#sm-diagram').boundingBox())!;
const fitted = await zoom(page);
await page.keyboard.press('z');
// The bigger stage frames the diagram again.
await expect.poll(() => zoom(page)).toBeGreaterThan(fitted);
await expect(header(page)).toHaveCount(0);
await expect(inspector(page)).toHaveCount(0);
await expect(tool(page, 'Leave focus (Z)')).toHaveAttribute('aria-pressed', 'true');
const after = (await page.locator('#sm-diagram').boundingBox())!;
expect(after.width).toBeGreaterThan(before.width);
expect(after.height).toBeGreaterThan(before.height);
// Selecting still works; its details wait until focus ends.
await page.locator('.sm-card[data-card-id="deploy"]').click();
await expect(page).toHaveURL(/#node=deploy$/);
await expect(inspector(page)).toHaveCount(0);
await page.keyboard.press('z');
await expect(header(page)).toBeVisible();
await expect(inspector(page)).toContainText('Deploy');
});

test('the toolbar button enters focus, and the URL never keeps it', async ({ page }) => {
await page.goto('/?page=release-delivery');
await tool(page, 'Focus on the canvas (Z)').click();
await expect(header(page)).toHaveCount(0);
await expect(page).not.toHaveURL(/focus/);
await tool(page, 'Leave focus (Z)').click();
await expect(header(page)).toBeVisible();
});

test('in focus, H hides the toolbar and shows the way back; leaving focus restores it', async ({ page }) => {
await page.goto('/?page=release-delivery');
await expect(page.locator('.sm-card').first()).toBeVisible();
// Outside focus H does nothing.
await page.keyboard.press('h');
await expect(tool(page, 'Focus on the canvas (Z)')).toBeVisible();
await page.keyboard.press('z');
await page.keyboard.press('h');
await expect(tool(page, 'Leave focus (Z)')).toHaveCount(0);
await expect(tool(page, /^Play the flow/)).toHaveCount(0);
const back = tool(page, 'Show the toolbar (H)');
await expect(back).toBeVisible();
await back.click();
await expect(tool(page, /^Play the flow/)).toBeVisible();
await tool(page, 'Hide the toolbar (H)').click();
await expect(tool(page, /^Play the flow/)).toHaveCount(0);
await page.keyboard.press('z');
await expect(header(page)).toBeVisible();
await expect(tool(page, /^Play the flow/)).toBeVisible();
await expect(tool(page, 'Hide the toolbar (H)')).toHaveCount(0);
});

test('Z and H are ignored while typing in search', async ({ page }) => {
await page.goto('/?page=release-delivery');
await expect(page.locator('.sm-card').first()).toBeVisible();
await page.keyboard.press('/');
await page.keyboard.type('zh');
await expect(header(page)).toBeVisible();
await expect(page.getByRole('combobox', { name: 'Search nodes' })).toHaveValue('zh');
});
Binary file modified packages/viewer/e2e/visual.spec.ts-snapshots/grouped-dark-linux.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified packages/viewer/e2e/visual.spec.ts-snapshots/sample-dark-linux.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified packages/viewer/e2e/visual.spec.ts-snapshots/sample-light-linux.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified packages/viewer/e2e/visual.spec.ts-snapshots/stages-dark-linux.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified packages/viewer/e2e/visual.spec.ts-snapshots/stages-light-linux.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 11 additions & 5 deletions packages/viewer/src/canvas/DiagramCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,17 +92,22 @@ function DotGrid({ x, y, k }: Transform) {
);
}

/** `chrome` false (presentation): no toolbar, zoom bar or minimap, and the camera refits the bigger stage. */
/**
* `chrome` false (presentation): no toolbar, zoom bar or minimap, and the camera refits the bigger stage. A change
* of `stage` (focus mode) says the stage changed size for another reason, and refits too.
*/
export function DiagramCanvas({
diagram,
children,
chrome = true,
stage,
minimap: withMinimap = true,
wheel = 'zoom',
}: {
diagram: LaidOutDiagram;
children?: ReactNode;
chrome?: boolean;
stage?: string;
/** false: no minimap and no M key */
minimap?: boolean;
wheel?: WheelMode;
Expand All @@ -124,18 +129,19 @@ export function DiagramCanvas({
const { x, y, k } = viewport.transform;
useCameraEffects(scene, camera, viewport.restored);
// The stage changes size when the chrome comes or goes: frame the current view again once it has.
const framed = useRef(chrome);
const layout = `${chrome}|${stage ?? ''}`;
const framed = useRef(layout);
useEffect(() => {
if (framed.current === chrome) return;
framed.current = chrome;
if (framed.current === layout) return;
framed.current = layout;
const id = requestAnimationFrame(() => {
const members = state.view ? (explore.draft.views?.find((v) => v.id === state.view)?.nodes ?? []) : [];
const box = union(members.flatMap((m) => scene.cards.find((c) => c.node.id === m)?.rect ?? []));
if (box) camera.fitRect(box);
else camera.fit();
});
return () => cancelAnimationFrame(id);
}, [chrome, state.view, explore.draft, scene, camera]);
}, [layout, state.view, explore.draft, scene, camera]);
// M toggles the radar from anywhere but a text field or menu; not while presenting (it's hidden then).
const chromeRef = useRef(chrome);
chromeRef.current = chrome;
Expand Down
22 changes: 21 additions & 1 deletion packages/viewer/src/chrome/Toolbar.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Filter, Moon, Presentation, Route, Search, Sun, Waypoints } from 'lucide-react';
import { EyeOff, Filter, Focus, Moon, Presentation, Route, Search, Sun, Waypoints } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { useExplore } from '../explore/ExploreContext';
import { useInScope } from '../explore/scope';
Expand All @@ -16,12 +16,19 @@ export function Toolbar({
theme,
onToggleTheme,
onPresent,
focused = false,
onFocus,
onHide,
exports = true,
trace = true,
}: {
theme: ThemeChoice;
onToggleTheme: () => void;
onPresent?: () => void;
/** focus mode (Z): the canvas alone, and in it a button that hides the toolbar (H) */
focused?: boolean;
onFocus?: () => void;
onHide?: () => void;
/** the export menu; a page that embeds the viewer has no file of its own to export */
exports?: boolean;
/** the trace button; an embed that offers trace elsewhere can leave it out */
Expand Down Expand Up @@ -149,6 +156,19 @@ export function Toolbar({
<Presentation size={17} strokeWidth={1.75} />
</IconButton>
)}
{/* A phone's toolbar has no room for it; Z still works there with a keyboard. */}
{onFocus && (
<span className="contents @max-md:hidden">
<IconButton label={focused ? 'Leave focus (Z)' : 'Focus on the canvas (Z)'} pressed={focused} onClick={onFocus}>
<Focus size={17} strokeWidth={1.75} />
</IconButton>
</span>
)}
{focused && onHide && (
<IconButton label="Hide the toolbar (H)" onClick={onHide}>
<EyeOff size={17} strokeWidth={1.75} />
</IconButton>
)}
<IconButton label={`Switch to ${next} theme`} onClick={onToggleTheme}>
{theme === 'dark' ? <Sun size={17} strokeWidth={1.75} /> : <Moon size={17} strokeWidth={1.75} />}
</IconButton>
Expand Down
62 changes: 48 additions & 14 deletions packages/viewer/src/chrome/ViewerShell.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Eye } from 'lucide-react';
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { KIND_LABELS, type LaidOutDiagram } from '@stackmap/core';
import { CanvasPanel } from '../canvas/CanvasPanel';
Expand All @@ -11,6 +12,7 @@ import { Inspector } from './Inspector';
import { PresentBar } from './Presentation';
import { inMenu } from './Toolbar';
import { Toolbar } from './Toolbar';
import { IconButton, PANEL_CLASS, PANEL_STYLE } from './ui';
import { ViewTabs } from './ViewTabs';

/** Below this width the inspector starts collapsed (Q27). */
Expand Down Expand Up @@ -64,12 +66,25 @@ export function ViewerShell({
if (on) void shell.current?.requestFullscreen?.().catch(() => {});
else if (document.fullscreenElement) void document.exitFullscreen().catch(() => {});
}, []);
// Focus (Z): the canvas alone in the window, everything on it still working; H hides the toolbar too.
const [focused, setFocused] = useState(false);
const [toolbarHidden, setToolbarHidden] = useState(false);
const focus = useCallback((on: boolean) => {
setFocused(on);
// Leaving focus always brings the toolbar back.
if (!on) setToolbarHidden(false);
}, []);
useEffect(() => {
const onKey = (e: globalThis.KeyboardEvent) => {
const t = e.target as HTMLElement | null;
if ((e.key !== 'f' && e.key !== 'F') || e.metaKey || e.ctrlKey || e.altKey || e.repeat || inMenu(t) || (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)))) return;
const key = e.key.toLowerCase();
if ((key !== 'f' && key !== 'z' && key !== 'h') || e.metaKey || e.ctrlKey || e.altKey || e.repeat || inMenu(t) || (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)))) return;
if (key !== 'f' && presenting) return;
if (key === 'h' && !focused) return;
e.preventDefault();
present(!presenting);
if (key === 'f') present(!presenting);
else if (key === 'z') focus(!focused);
else setToolbarHidden((v) => !v);
};
// Leaving full screen (Esc in the browser's own handling) ends the presentation too.
const onFullscreen = () => {
Expand All @@ -83,7 +98,7 @@ export function ViewerShell({
removeEventListener('keydown', onKey);
document.removeEventListener('fullscreenchange', onFullscreen);
};
}, [presenting, present]);
}, [presenting, present, focused, focus]);
// Read during render: the canvas (a child) records what it shows in its own layout effect, which runs first.
const [reloaded] = useState(() => readLiveConfig(document) !== null && readShown() !== null);
useLayoutEffect(() => {
Expand All @@ -95,8 +110,8 @@ export function ViewerShell({
}, [reloaded]);
return (
<ExploreProvider draft={draft}>
<div ref={shell} data-presenting={presenting || undefined} className="@container flex h-full flex-col bg-page font-sans text-fg">
{!presenting && (
<div ref={shell} data-presenting={presenting || undefined} data-focused={focused || undefined} className="@container flex h-full flex-col bg-page font-sans text-fg">
{!presenting && !focused && (
<header className="mx-8 mt-4 flex gap-8 border-b border-divider @max-2xl:mx-4 @max-2xl:mt-2 @max-md:flex-wrap @max-md:gap-x-4 @max-md:gap-y-0">
<div className="flex max-w-[45%] min-w-0 items-center gap-3 py-3 @max-md:max-w-full @max-md:basis-full @max-md:pb-1">
<Title className="truncate text-[20px] leading-7 font-semibold tracking-tight">{draft.title}</Title>
Expand All @@ -113,28 +128,47 @@ export function ViewerShell({
</span>
</header>
)}
{!presenting && <ViewCaption />}
<Main className={`relative flex min-h-0 flex-1 gap-4 @max-2xl:gap-2 ${presenting ? '' : 'px-8 pt-4 pb-6 @max-2xl:px-3 @max-2xl:pt-3 @max-2xl:pb-3'}`}>
{!presenting && !focused && <ViewCaption />}
<Main className={`relative flex min-h-0 flex-1 gap-4 @max-2xl:gap-2 ${presenting ? '' : focused ? 'p-3' : 'px-8 pt-4 pb-6 @max-2xl:px-3 @max-2xl:pt-3 @max-2xl:pb-3'}`}>
<section
id={DIAGRAM_ID}
role="tabpanel"
aria-label="Diagram"
className={`@container/stage relative min-w-0 flex-1 overflow-hidden bg-stage ${presenting ? '' : 'rounded-[20px]'}`}
style={presenting ? undefined : { boxShadow: 'inset 0 0 0 1px var(--sm-panel-border)' }}
>
<DiagramCanvas diagram={diagram} chrome={!presenting}>
<DiagramCanvas diagram={diagram} chrome={!presenting} stage={focused ? 'focus' : undefined}>
{/* Never wider than the stage: the identity card gives way (truncating, then hidden) before the toolbar. */}
<CanvasPanel position="top-left" className="flex max-w-[calc(100%-30px)] gap-2">
{/* Narrow, the header already names the diagram: the toolbar gets the room. */}
<div className="flex min-w-0 @max-[640px]/stage:hidden">
<DiagramIdentity onDetails={() => setInspectorCollapsed(false)} />
</div>
<Toolbar theme={theme} onToggleTheme={onToggleTheme} onPresent={() => present(true)} />
{toolbarHidden ? (
<div className={`${PANEL_CLASS} p-1.5`} style={PANEL_STYLE}>
<IconButton label="Show the toolbar (H)" onClick={() => setToolbarHidden(false)}>
<Eye size={17} strokeWidth={1.75} />
</IconButton>
</div>
) : (
<>
{/* Narrow, the header already names the diagram: the toolbar gets the room. Focus shows the canvas alone. */}
{!focused && (
<div className="flex min-w-0 @max-[640px]/stage:hidden">
<DiagramIdentity onDetails={() => setInspectorCollapsed(false)} />
</div>
)}
<Toolbar
theme={theme}
onToggleTheme={onToggleTheme}
onPresent={() => present(true)}
focused={focused}
onFocus={() => focus(!focused)}
onHide={() => setToolbarHidden(true)}
/>
</>
)}
</CanvasPanel>
</DiagramCanvas>
{presenting && <PresentBar onExit={() => present(false)} />}
</section>
{!presenting && <Inspector collapsed={inspectorCollapsed} onToggle={() => setInspectorCollapsed((v) => !v)} />}
{!presenting && !focused && <Inspector collapsed={inspectorCollapsed} onToggle={() => setInspectorCollapsed((v) => !v)} />}
</Main>
</div>
</ExploreProvider>
Expand Down
6 changes: 5 additions & 1 deletion site/app/docs/viewer/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,15 @@ export default function ViewerPage() {
Parameters join with <C>&amp;</C>: <C>diagram.html#view=checkout&amp;node=api&amp;play=1</C>.
</P>

<H2 id="present">Present</H2>
<H2 id="present">Present and focus</H2>
<P>
Press <Kbd>F</Kbd>, or Present in the toolbar, for the stage alone, full screen where the browser allows it. It steps through Overview and then each view:{' '}
<Kbd>→</Kbd> or <Kbd>Space</Kbd> moves on, <Kbd>←</Kbd> goes back, <Kbd>Home</Kbd> and <Kbd>End</Kbd> jump to the ends, and <Kbd>Esc</Kbd> leaves.
</P>
<P>
For the canvas alone without presenting, press <Kbd>Z</Kbd> or Focus in the toolbar. The header, view tabs and inspector step aside and everything on the canvas keeps
working; <Kbd>H</Kbd> then hides the toolbar too, and <Kbd>Z</Kbd> brings it all back.
</P>

<H2 id="export">Export</H2>
<P>The Export menu in the toolbar saves what the stage shows:</P>
Expand Down
2 changes: 1 addition & 1 deletion site/lib/docs-nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export const DOC_PAGES: DocPage[] = [
sections: [
s('try', 'Try it'),
s('share', 'Share a view'),
s('present', 'Present'),
s('present', 'Present and focus'),
s('export', 'Export'),
s('keys', 'Keyboard'),
s('themes', 'Themes and motion'),
Expand Down
2 changes: 2 additions & 0 deletions site/lib/reference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export const VIEWER_KEYS: { keys: string[]; does: string }[] = [
{ keys: ['R'], does: 'Route: pick where it starts, then where it ends' },
{ keys: ['P'], does: 'Play or stop the flow' },
{ keys: ['F'], does: 'Present: the stage alone, stepping through the views' },
{ keys: ['Z'], does: 'Focus: the canvas alone, without the header, tabs or inspector' },
{ keys: ['H'], does: 'Hide or show the toolbar, in focus' },
{ keys: ['M'], does: 'Show or hide the minimap' },
{ keys: ['Esc'], does: 'Clear the selection, the search or the route' },
{ keys: ['←', '↑', '→', '↓'], does: 'Pan, while the stage has focus' },
Expand Down
2 changes: 1 addition & 1 deletion site/lib/search-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const WORDS: Record<string, string> = {
'/docs/viewer': 'viewer html file inspector select trace route search lens views present play share export keyboard',
'/docs/viewer#try': 'demo interactive select trace route find views play',
'/docs/viewer#share': 'url hash link parameters deep link',
'/docs/viewer#present': 'full screen slides presentation',
'/docs/viewer#present': 'full screen slides presentation focus canvas only hide toolbar zen',
'/docs/viewer#export': 'png jpeg webp svg video download image',
'/docs/viewer#keys': 'shortcuts keyboard hotkeys',
'/docs/viewer#themes': 'dark light theme reduced motion animation',
Expand Down
Loading