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
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion commitlint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,5 @@ export default {
},
},
],
rules: { 'site-no-release': [2, 'always'] },
rules: { 'site-no-release': [2, 'always'], 'header-max-length': [0] },
};
31 changes: 23 additions & 8 deletions packages/viewer/src/canvas/DiagramCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@ import { motionAllowed } from '../motion/motion';
import { useFlow } from '../motion/useFlow';
import { useSceneReveal } from '../motion/useSceneReveal';
import { useExplore } from '../explore/ExploreContext';
import { useInScope } from '../explore/scope';
import { routeBetween } from '../explore/graph';
import { inMenu } from '../chrome/Toolbar';
import { CanvasPanel } from './CanvasPanel';
import { Minimap } from './Minimap';
import { toScene, type Scene } from './scene';
import { SceneLayers } from './SceneLayers';
import { useZoom } from './useZoom';
import { useZoom, type WheelMode } from './useZoom';
import type { Camera } from './useZoom';
import type { Transform } from './viewport';
import { CameraProvider, ContentProvider, SceneProvider, ViewportProvider } from './ViewportContext';
Expand Down Expand Up @@ -92,11 +93,25 @@ function DotGrid({ x, y, k }: Transform) {
}

/** `chrome` false (presentation): no toolbar, zoom bar or minimap, and the camera refits the bigger stage. */
export function DiagramCanvas({ diagram, children, chrome = true }: { diagram: LaidOutDiagram; children?: ReactNode; chrome?: boolean }) {
export function DiagramCanvas({
diagram,
children,
chrome = true,
minimap: withMinimap = true,
wheel = 'zoom',
}: {
diagram: LaidOutDiagram;
children?: ReactNode;
chrome?: boolean;
/** false: no minimap and no M key */
minimap?: boolean;
wheel?: WheelMode;
}) {
const scene = useMemo(() => toScene(diagram), [diagram]);
const stageRef = useRef<HTMLDivElement>(null);
const sceneRef = useRef<HTMLDivElement>(null);
const viewport = useZoom(stageRef, scene.content);
const viewport = useZoom(stageRef, scene.content, wheel);
const inScope = useInScope();
// Exports frame everything drawn: edge routes can swing outside the card/frame box (U-turns).
const exportBox = useMemo(() => {
const points = scene.edges.flatMap((e) => e.points);
Expand Down Expand Up @@ -128,18 +143,18 @@ export function DiagramCanvas({ diagram, children, chrome = true }: { diagram: L
useEffect(() => {
const onKey = (e: globalThis.KeyboardEvent) => {
const key = e.key.toLowerCase();
if (key !== 'm' && key !== 'p') return;
if ((key !== 'm' && key !== 'p') || !inScope(e)) return;
const t = e.target as HTMLElement | null;
if (e.metaKey || e.ctrlKey || e.altKey || e.repeat || inMenu(t) || (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)))) return;
if (key === 'm' && !chromeRef.current) return;
if (key === 'm' && (!chromeRef.current || !withMinimap)) return;
if (key === 'p' && !motionAllowed()) return;
e.preventDefault();
if (key === 'm') setMinimap((v) => !v);
else dispatch({ type: 'togglePlay' });
};
addEventListener('keydown', onKey);
return () => removeEventListener('keydown', onKey);
}, [dispatch]);
}, [dispatch, inScope, withMinimap]);
const flow = useFlow(scene);
const playing = state.playing && motionAllowed();
useSceneReveal(sceneRef, scene, viewport.restored);
Expand Down Expand Up @@ -192,10 +207,10 @@ export function DiagramCanvas({ diagram, children, chrome = true }: { diagram: L
{/* Overlays are siblings of the stage, so wheel/drag on them never reaches d3-zoom. */}
{chrome && (
<CanvasPanel position="bottom-left">
<ZoomBar minimapOn={minimap} onToggleMinimap={() => setMinimap((v) => !v)} />
<ZoomBar minimapOn={minimap} onToggleMinimap={withMinimap ? () => setMinimap((v) => !v) : undefined} />
</CanvasPanel>
)}
{chrome && minimap && (
{chrome && withMinimap && minimap && (
<CanvasPanel position="bottom-right">
<Minimap scene={scene} emphasis={emphasis} />
</CanvasPanel>
Expand Down
22 changes: 20 additions & 2 deletions packages/viewer/src/canvas/useZoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,25 @@ export interface ViewportApi extends Camera {
restored: boolean;
}

/**
* `zoom`: the wheel zooms, as in a delivered file, which is the whole page. `modifier`: the viewer sits in a page
* that scrolls, so a plain wheel and a one-finger drag are the page's; ctrl/⌘ + wheel (a trackpad pinch) zooms and
* two fingers pan and pinch.
*/
export type WheelMode = 'zoom' | 'modifier';

// d3-zoom's default filter, plus the modifier mode's rules. Events it rejects are never prevented.
const zoomFilter = (wheel: WheelMode) => (event: WheelEvent & TouchEvent & MouseEvent) => {
if (wheel === 'zoom') return (!event.ctrlKey || event.type === 'wheel') && !event.button;
if (event.type === 'wheel') return event.ctrlKey || event.metaKey;
if (event.type === 'touchstart') return event.touches.length > 1;
return !event.ctrlKey && !event.button;
};

const duration = (opts?: MoveOptions) => (opts?.instant || matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 150);
const toZoom = (t: Transform) => zoomIdentity.translate(t.x, t.y).scale(t.k);

export function useZoom(stageRef: RefObject<HTMLDivElement | null>, content: Rect): ViewportApi {
export function useZoom(stageRef: RefObject<HTMLDivElement | null>, content: Rect, wheel: WheelMode = 'zoom'): ViewportApi {
const [transform, setTransform] = useState<Transform>({ x: 0, y: 0, k: 1 });
const current = useRef(transform);
current.current = transform;
Expand All @@ -52,13 +67,16 @@ export function useZoom(stageRef: RefObject<HTMLDivElement | null>, content: Rec
const sel = select(el);
const z = zoom<HTMLDivElement, unknown>()
.scaleExtent([MIN_ZOOM, MAX_ZOOM])
.filter(zoomFilter(wheel))
.on('zoom', (event: D3ZoomEvent<HTMLDivElement, unknown>) => {
if (event.sourceEvent) pending.current = null; // the user took over
const { x, y, k } = event.transform;
setTransform({ x, y, k });
});
// Double-click zoom is a surprise in a viewer where clicks will select nodes (M3).
sel.call(z).on('dblclick.zoom', null);
// d3 claims every touch; in a scrolling page the browser keeps one-finger scrolling.
if (wheel === 'modifier') el.style.touchAction = 'pan-x pan-y';
behavior.current = z;

const size = { width: el.clientWidth, height: el.clientHeight };
Expand All @@ -85,7 +103,7 @@ export function useZoom(stageRef: RefObject<HTMLDivElement | null>, content: Rec
sel.on('.zoom', null);
behavior.current = null;
};
}, [stageRef, content, stored]);
}, [stageRef, content, stored, wheel]);

const animate = useCallback(
(apply: (z: ZoomBehavior<HTMLDivElement, unknown>, el: HTMLDivElement) => void) => {
Expand Down
8 changes: 5 additions & 3 deletions packages/viewer/src/chrome/IdentityCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@ export function IdentityCard({ draft, onDetails }: { draft: DiagramDraft; onDeta
</div>
)}
</div>
<IconButton label="Diagram details" onClick={onDetails}>
<Ellipsis size={17} strokeWidth={1.75} />
</IconButton>
{onDetails && (
<IconButton label="Diagram details" onClick={onDetails}>
<Ellipsis size={17} strokeWidth={1.75} />
</IconButton>
)}
</div>
);
}
58 changes: 46 additions & 12 deletions packages/viewer/src/chrome/Inspector.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ArrowDownLeft, ArrowUpRight, Copy, PanelRightClose, PanelRightOpen, X } from 'lucide-react';
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode, type Ref } from 'react';
import { createContext, useContext, useEffect, useLayoutEffect, useRef, useState, type ReactNode, type Ref } from 'react';
import { countByType, TYPE_LABELS, type DiagramDraft, type DiagramEdge, type DiagramNode, type Evidence } from '@stackmap/core';
import { focusCard } from '../canvas/SceneLayers';
import { routeBetween } from '../explore/graph';
Expand All @@ -10,10 +10,19 @@ import { IconButton, PANEL_STYLE } from './ui';

const eyebrow = 'text-[12.5px] font-medium text-fg-muted';

// The panel's title level; its sections sit one below and their notes two below. A page that embeds the viewer
// under its own headings moves the whole set down.
const Level = createContext<2 | 3>(2);
type Tag = 'h2' | 'h3' | 'h4' | 'h5';
function useHeading(below: 0 | 1 | 2): Tag {
return `h${useContext(Level) + below}` as Tag;
}

function Section({ title, children }: { title: string; children: ReactNode }) {
const H = useHeading(1);
return (
<section className="mt-7">
<h3 className="text-[13px] font-semibold text-fg">{title}</h3>
<H className="text-[13px] font-semibold text-fg">{title}</H>
<div className="mt-3">{children}</div>
</section>
);
Expand Down Expand Up @@ -60,11 +69,12 @@ function Legend({ draft }: { draft: DiagramDraft }) {
}

function Notes({ draft }: { draft: DiagramDraft }) {
const H = useHeading(2);
return (
<div className="space-y-5">
{draft.notes!.map((n, i) => (
<section key={i} aria-label={n.title}>
<h4 className="text-[13px] font-medium text-fg">{n.title}</h4>
<H className="text-[13px] font-medium text-fg">{n.title}</H>
<ul className="mt-1.5 list-disc space-y-1 pl-4 text-[12.5px] leading-5 text-fg-muted marker:text-divider">
{n.items.map((item, j) => (
<li key={j}>{item}</li>
Expand Down Expand Up @@ -147,6 +157,7 @@ function NodeDetail({ node, toggle }: { node: DiagramNode; toggle: ReactNode })
const { draft, dispatch } = useExplore();
const { card } = node;
const lane = node.lane ? draft.lanes?.find((l) => l.id === node.lane)?.label : undefined;
const H = useHeading(0);
return (
<>
<div className="flex items-center justify-between">
Expand All @@ -167,7 +178,7 @@ function NodeDetail({ node, toggle }: { node: DiagramNode; toggle: ReactNode })
{toggle}
</div>
</div>
<h2 className="mt-1 text-[18px] font-semibold tracking-tight break-words text-fg">{card.title}</h2>
<H className="mt-1 text-[18px] font-semibold tracking-tight break-words text-fg">{card.title}</H>
{card.subtitle && <p className="mt-1 text-[13px] break-words text-fg-muted">{card.subtitle}</p>}
{(card.tag || lane) && (
<p className="mt-2 flex flex-wrap items-center gap-2 text-[12.5px] text-fg-muted">
Expand Down Expand Up @@ -229,6 +240,7 @@ function RouteDetail({ toggle }: { toggle: ReactNode }) {
const byId = new Map(draft.nodes.map((n) => [n.id, n]));
const edgeById = new Map(draft.edges.map((e) => [e.id, e]));
const title = (id: string) => byId.get(id)?.card.title ?? id;
const H = useHeading(0);
const header = (
<div className="flex items-center justify-between">
<div className={eyebrow}>Route</div>
Expand All @@ -254,9 +266,9 @@ function RouteDetail({ toggle }: { toggle: ReactNode }) {
return (
<>
{header}
<h2 className="mt-1 text-[18px] font-semibold tracking-tight break-words text-fg">
<H className="mt-1 text-[18px] font-semibold tracking-tight break-words text-fg">
{title(state.route.from)} → {title(state.route.to)}
</h2>
</H>
{!route ? (
<p className="mt-2 text-[13px] text-fg-muted">No directed connections lead from one to the other.</p>
) : (
Expand Down Expand Up @@ -313,7 +325,24 @@ function Toggle({ collapsed, onToggle, ref }: { collapsed: boolean; onToggle: ()
);
}

export function Inspector({ collapsed, onToggle }: { collapsed: boolean; onToggle: () => void }) {
/**
* `onToggle` adds the collapse toggle; without it the panel is always open. `placement="static"` leaves the panel
* where its parent puts it at every width, instead of floating it over a narrow stage. `extra` follows the diagram's
* title while nothing is selected.
*/
export function Inspector({
collapsed = false,
onToggle,
placement = 'auto',
headingLevel = 2,
extra,
}: {
collapsed?: boolean;
onToggle?: () => void;
placement?: 'auto' | 'static';
headingLevel?: 2 | 3;
extra?: ReactNode;
}) {
const { draft, state } = useExplore();
const selected = state.selected ? draft.nodes.find((n) => n.id === state.selected) : undefined;
// The toggle re-renders as its counterpart; keep keyboard focus on it across the switch.
Expand All @@ -332,7 +361,7 @@ export function Inspector({ collapsed, onToggle }: { collapsed: boolean; onToggl
const motion = swapIn([...(body.current?.children ?? [])] as HTMLElement[]);
return () => motion.cancel();
}, [state.selected]);
const toggle = (
const toggle = onToggle && (
<Toggle
ref={toggleRef}
collapsed={collapsed}
Expand All @@ -342,15 +371,18 @@ export function Inspector({ collapsed, onToggle }: { collapsed: boolean; onToggl
}}
/>
);
if (collapsed) {
const Title = `h${headingLevel}` as const;
const narrow = placement === 'auto';
if (collapsed && toggle) {
return (
<aside aria-label="Inspector" className={`shrink-0 rounded-[20px] bg-panel p-1.5 ${NARROW_COLLAPSED}`} style={PANEL_STYLE}>
<aside aria-label="Inspector" className={`shrink-0 rounded-[20px] bg-panel p-1.5 ${narrow ? NARROW_COLLAPSED : ''}`} style={PANEL_STYLE}>
{toggle}
</aside>
);
}
return (
<aside aria-label="Inspector" className={`relative w-[300px] shrink-0 overflow-y-auto rounded-[20px] bg-panel p-5 ${NARROW_OPEN}`} style={PANEL_STYLE}>
<Level.Provider value={headingLevel}>
<aside aria-label="Inspector" className={`relative shrink-0 overflow-y-auto rounded-[20px] bg-panel p-5 ${narrow ? `w-[300px] ${NARROW_OPEN}` : ''}`} style={PANEL_STYLE}>
<div ref={body}>
{state.route || state.routing ? (
<RouteDetail toggle={toggle} />
Expand All @@ -362,8 +394,9 @@ export function Inspector({ collapsed, onToggle }: { collapsed: boolean; onToggl
<div className={eyebrow}>Diagram</div>
{toggle}
</div>
<h2 className="mt-1 text-[18px] font-semibold tracking-tight text-fg">{draft.title}</h2>
<Title className="mt-1 text-[18px] font-semibold tracking-tight text-fg">{draft.title}</Title>
{draft.subtitle && <p className="mt-1 text-[13px] text-fg-muted">{draft.subtitle}</p>}
{extra}
{!!draft.notes?.length && (
<Section title="Notes">
<Notes draft={draft} />
Expand All @@ -379,5 +412,6 @@ export function Inspector({ collapsed, onToggle }: { collapsed: boolean; onToggl
)}
</div>
</aside>
</Level.Provider>
);
}
41 changes: 30 additions & 11 deletions packages/viewer/src/chrome/Toolbar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Filter, 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';
import type { ThemeChoice } from '../theme/theme';
import { ExportMenu } from './ExportMenu';
import { LensPanel } from './LensPanel';
Expand All @@ -11,8 +12,23 @@ import { IconButton, PANEL_CLASS, PANEL_STYLE, ToolbarDivider } from './ui';
const typing = (t: EventTarget | null) => t instanceof HTMLElement && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName));
export const inMenu = (t: EventTarget | null) => t instanceof Element && !!t.closest('[role="menu"]');

export function Toolbar({ theme, onToggleTheme, onPresent }: { theme: ThemeChoice; onToggleTheme: () => void; onPresent?: () => void }) {
export function Toolbar({
theme,
onToggleTheme,
onPresent,
exports = true,
trace = true,
}: {
theme: ThemeChoice;
onToggleTheme: () => void;
onPresent?: () => 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 */
trace?: boolean;
}) {
const { state, dispatch } = useExplore();
const inScope = useInScope();
const [lensOpen, setLensOpen] = useState(false);
const routingRef = useRef(false);
routingRef.current = !!state.routing || !!state.route;
Expand All @@ -38,6 +54,7 @@ export function Toolbar({ theme, onToggleTheme, onPresent }: { theme: ThemeChoic
// (cards, the stage and the popovers handle their own Escape and stop it).
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (!inScope(e)) return;
const onPage = e.target === document.body || e.target === document.documentElement;
// Escape from the page body clears; while a route is picked or shown it ends it from any control too.
if (e.key === 'Escape' && !e.defaultPrevented && !typing(e.target) && (onPage || routingRef.current)) {
Expand All @@ -59,7 +76,7 @@ export function Toolbar({ theme, onToggleTheme, onPresent }: { theme: ThemeChoic
};
addEventListener('keydown', onKey);
return () => removeEventListener('keydown', onKey);
}, [dispatch]);
}, [dispatch, inScope]);

// Clicking outside closes the popovers.
useEffect(() => {
Expand Down Expand Up @@ -111,14 +128,16 @@ export function Toolbar({ theme, onToggleTheme, onPresent }: { theme: ThemeChoic
>
<Filter size={17} strokeWidth={1.75} />
</IconButton>
<IconButton
label="Trace upstream and downstream of the selection"
pressed={state.trace && !state.route && !state.routing}
disabled={!!state.route || !!state.routing}
onClick={() => dispatch({ type: 'toggleTrace' })}
>
<Route size={17} strokeWidth={1.75} />
</IconButton>
{trace && (
<IconButton
label="Trace upstream and downstream of the selection"
pressed={state.trace && !state.route && !state.routing}
disabled={!!state.route || !!state.routing}
onClick={() => dispatch({ type: 'toggleTrace' })}
>
<Route size={17} strokeWidth={1.75} />
</IconButton>
)}
<IconButton label="Route between two nodes (R)" pressed={!!state.routing || !!state.route} onClick={() => dispatch({ type: 'toggleRoute' })}>
<Waypoints size={17} strokeWidth={1.75} />
</IconButton>
Expand All @@ -132,7 +151,7 @@ export function Toolbar({ theme, onToggleTheme, onPresent }: { theme: ThemeChoic
<IconButton label={`Switch to ${next} theme`} onClick={onToggleTheme}>
{theme === 'dark' ? <Sun size={17} strokeWidth={1.75} /> : <Moon size={17} strokeWidth={1.75} />}
</IconButton>
<ExportMenu />
{exports && <ExportMenu />}
{/* Always mounted, so screen readers announce the hint when its text arrives. */}
<p
role="status"
Expand Down
Loading
Loading