diff --git a/packages/viewer/e2e/focus.spec.ts b/packages/viewer/e2e/focus.spec.ts new file mode 100644 index 0000000..1a24b3f --- /dev/null +++ b/packages/viewer/e2e/focus.spec.ts @@ -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'); +}); diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/grouped-dark-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/grouped-dark-linux.png index 4b82e2f..fe9fe98 100644 Binary files a/packages/viewer/e2e/visual.spec.ts-snapshots/grouped-dark-linux.png and b/packages/viewer/e2e/visual.spec.ts-snapshots/grouped-dark-linux.png differ diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/grouped-light-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/grouped-light-linux.png index a523005..bc9b0ee 100644 Binary files a/packages/viewer/e2e/visual.spec.ts-snapshots/grouped-light-linux.png and b/packages/viewer/e2e/visual.spec.ts-snapshots/grouped-light-linux.png differ diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/lifecycle-dark-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/lifecycle-dark-linux.png index 2900ffe..8d048eb 100644 Binary files a/packages/viewer/e2e/visual.spec.ts-snapshots/lifecycle-dark-linux.png and b/packages/viewer/e2e/visual.spec.ts-snapshots/lifecycle-dark-linux.png differ diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/lifecycle-light-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/lifecycle-light-linux.png index b077ad8..5a2359a 100644 Binary files a/packages/viewer/e2e/visual.spec.ts-snapshots/lifecycle-light-linux.png and b/packages/viewer/e2e/visual.spec.ts-snapshots/lifecycle-light-linux.png differ diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/sample-dark-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/sample-dark-linux.png index f2c5132..d3326f1 100644 Binary files a/packages/viewer/e2e/visual.spec.ts-snapshots/sample-dark-linux.png and b/packages/viewer/e2e/visual.spec.ts-snapshots/sample-dark-linux.png differ diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/sample-light-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/sample-light-linux.png index 9249b95..7727860 100644 Binary files a/packages/viewer/e2e/visual.spec.ts-snapshots/sample-light-linux.png and b/packages/viewer/e2e/visual.spec.ts-snapshots/sample-light-linux.png differ diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/sequence-dark-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/sequence-dark-linux.png index ed0da49..673a8dd 100644 Binary files a/packages/viewer/e2e/visual.spec.ts-snapshots/sequence-dark-linux.png and b/packages/viewer/e2e/visual.spec.ts-snapshots/sequence-dark-linux.png differ diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/sequence-light-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/sequence-light-linux.png index 342c2c6..62d6756 100644 Binary files a/packages/viewer/e2e/visual.spec.ts-snapshots/sequence-light-linux.png and b/packages/viewer/e2e/visual.spec.ts-snapshots/sequence-light-linux.png differ diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/stages-dark-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/stages-dark-linux.png index 6f08369..3faee80 100644 Binary files a/packages/viewer/e2e/visual.spec.ts-snapshots/stages-dark-linux.png and b/packages/viewer/e2e/visual.spec.ts-snapshots/stages-dark-linux.png differ diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/stages-light-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/stages-light-linux.png index 6624e11..78fffb8 100644 Binary files a/packages/viewer/e2e/visual.spec.ts-snapshots/stages-light-linux.png and b/packages/viewer/e2e/visual.spec.ts-snapshots/stages-light-linux.png differ diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/workflow-dark-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/workflow-dark-linux.png index e43e7ef..e36eff1 100644 Binary files a/packages/viewer/e2e/visual.spec.ts-snapshots/workflow-dark-linux.png and b/packages/viewer/e2e/visual.spec.ts-snapshots/workflow-dark-linux.png differ diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/workflow-light-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/workflow-light-linux.png index 0b7a466..a06aaf5 100644 Binary files a/packages/viewer/e2e/visual.spec.ts-snapshots/workflow-light-linux.png and b/packages/viewer/e2e/visual.spec.ts-snapshots/workflow-light-linux.png differ diff --git a/packages/viewer/src/canvas/DiagramCanvas.tsx b/packages/viewer/src/canvas/DiagramCanvas.tsx index 9e2d86e..5384f9a 100644 --- a/packages/viewer/src/canvas/DiagramCanvas.tsx +++ b/packages/viewer/src/canvas/DiagramCanvas.tsx @@ -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; @@ -124,10 +129,11 @@ 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 ?? [])); @@ -135,7 +141,7 @@ export function DiagramCanvas({ 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; diff --git a/packages/viewer/src/chrome/Toolbar.tsx b/packages/viewer/src/chrome/Toolbar.tsx index 8b11feb..a617c59 100644 --- a/packages/viewer/src/chrome/Toolbar.tsx +++ b/packages/viewer/src/chrome/Toolbar.tsx @@ -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'; @@ -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 */ @@ -149,6 +156,19 @@ export function Toolbar({ )} + {/* A phone's toolbar has no room for it; Z still works there with a keyboard. */} + {onFocus && ( + + + + + + )} + {focused && onHide && ( + + + + )} {theme === 'dark' ? : } diff --git a/packages/viewer/src/chrome/ViewerShell.tsx b/packages/viewer/src/chrome/ViewerShell.tsx index e9f6e97..19dbd5a 100644 --- a/packages/viewer/src/chrome/ViewerShell.tsx +++ b/packages/viewer/src/chrome/ViewerShell.tsx @@ -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'; @@ -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). */ @@ -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 = () => { @@ -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(() => { @@ -95,8 +110,8 @@ export function ViewerShell({ }, [reloaded]); return ( -
- {!presenting && ( +
+ {!presenting && !focused && (
{draft.title} @@ -113,8 +128,8 @@ export function ViewerShell({
)} - {!presenting && } -
+ {!presenting && !focused && } +
- + {/* Never wider than the stage: the identity card gives way (truncating, then hidden) before the toolbar. */} - {/* Narrow, the header already names the diagram: the toolbar gets the room. */} -
- setInspectorCollapsed(false)} /> -
- present(true)} /> + {toolbarHidden ? ( +
+ setToolbarHidden(false)}> + + +
+ ) : ( + <> + {/* Narrow, the header already names the diagram: the toolbar gets the room. Focus shows the canvas alone. */} + {!focused && ( +
+ setInspectorCollapsed(false)} /> +
+ )} + present(true)} + focused={focused} + onFocus={() => focus(!focused)} + onHide={() => setToolbarHidden(true)} + /> + + )}
{presenting && present(false)} />}
- {!presenting && setInspectorCollapsed((v) => !v)} />} + {!presenting && !focused && setInspectorCollapsed((v) => !v)} />}
diff --git a/site/app/docs/viewer/page.tsx b/site/app/docs/viewer/page.tsx index b4fb4bb..b317a79 100644 --- a/site/app/docs/viewer/page.tsx +++ b/site/app/docs/viewer/page.tsx @@ -41,11 +41,15 @@ export default function ViewerPage() { Parameters join with &: diagram.html#view=checkout&node=api&play=1.

-

Present

+

Present and focus

Press F, or Present in the toolbar, for the stage alone, full screen where the browser allows it. It steps through Overview and then each view:{' '} → or Space moves on, ← goes back, Home and End jump to the ends, and Esc leaves.

+

+ For the canvas alone without presenting, press Z or Focus in the toolbar. The header, view tabs and inspector step aside and everything on the canvas keeps + working; H then hides the toolbar too, and Z brings it all back. +

Export

The Export menu in the toolbar saves what the stage shows:

diff --git a/site/lib/docs-nav.ts b/site/lib/docs-nav.ts index 7614370..a0b2308 100644 --- a/site/lib/docs-nav.ts +++ b/site/lib/docs-nav.ts @@ -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'), diff --git a/site/lib/reference.ts b/site/lib/reference.ts index a96f5cd..5cac21e 100644 --- a/site/lib/reference.ts +++ b/site/lib/reference.ts @@ -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' }, diff --git a/site/lib/search-index.ts b/site/lib/search-index.ts index b7da448..c1dfe10 100644 --- a/site/lib/search-index.ts +++ b/site/lib/search-index.ts @@ -22,7 +22,7 @@ const WORDS: Record = { '/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',