Skip to content

Commit 171a4f9

Browse files
authored
Add export as image feature for visual designer (#19214)
## Summary Add the ability to export the visual designer graph as an image (SVG, PNG, or JPEG). ## Commits | Commit | Description | |--------|-------------| | `ba7a552bc` | **Add graph export feature with SVG/PNG/JPEG support** | | `3de9633c6` | Refactoring; code cleanup | | `49bbc7d1e` | Run format command | | `f2a697e3f` | Fix linting issues | | `77b246724` | Use styled components | | `7041d8c9d` | Set path alias | | `c3463e4cd` | Add Copilot instructions and hooks | ## Review guidance > **Reviewers: please focus your review on commit `ba7a552bc` (\"Add graph export feature with SVG/PNG/JPEG support\") — it contains the only functionality change.** The remaining commits are hardening engineering work (code cleanup, formatting, linting fixes, refactoring to styled-components, path alias setup, and adding Copilot instructions/hooks) with no functionality changes. ###### Microsoft Reviewers: [Open in CodeFlow](https://microsoft.github.io/open-pr/?codeflow=https://github.com/Azure/bicep/pull/19214)
1 parent 691715a commit 171a4f9

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

77 files changed

+1695
-174
lines changed

src/vscode-bicep-ui/.prettierrc

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
1-
{
2-
"printWidth": 120,
3-
"plugins": ["@ianvs/prettier-plugin-sort-imports"],
4-
"importOrder": [
5-
"<TYPES>^(node:)",
6-
"<TYPES>",
7-
"<TYPES>^[.]",
8-
"",
9-
"<BUILTIN_MODULES>",
10-
"<THIRD_PARTY_MODULES>",
11-
"^[./]"
12-
]
13-
}
1+
{
2+
"printWidth": 120,
3+
"plugins": ["@ianvs/prettier-plugin-sort-imports"],
4+
"importOrder": [
5+
"<TYPES>^(node:)",
6+
"<TYPES>",
7+
"<TYPES>^@/",
8+
"<TYPES>^[.]",
9+
"",
10+
"<BUILTIN_MODULES>",
11+
"<THIRD_PARTY_MODULES>",
12+
"^@/",
13+
"^[./]"
14+
]
15+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"hooks": {
3+
"PostToolUse": [
4+
{
5+
"event": "PostToolUse",
6+
"type": "command",
7+
"command": "npx prettier --write \"${filePath}\"",
8+
"windows": "npx prettier --write \"${filePath}\"",
9+
"timeout": 15,
10+
"matchers": {
11+
"toolName": "create_file|replace_string_in_file|multi_replace_string_in_file",
12+
"filePath": "**/*.{ts,tsx,md,json}"
13+
}
14+
},
15+
{
16+
"event": "PostToolUse",
17+
"type": "command",
18+
"command": "npx eslint --fix \"${filePath}\"",
19+
"windows": "npx eslint --fix \"${filePath}\"",
20+
"timeout": 15,
21+
"matchers": {
22+
"toolName": "create_file|replace_string_in_file|multi_replace_string_in_file",
23+
"filePath": "**/*.{ts,tsx}"
24+
}
25+
}
26+
]
27+
}
28+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
applyTo: "src/**/*.tsx"
3+
description: "Use when writing or modifying React components, hooks, or JSX in the visual designer app."
4+
---
5+
6+
# React Best Practices
7+
8+
## Components
9+
10+
- Use function components exclusively. No class components.
11+
- Export one primary component per file. Co-located helpers are fine.
12+
- Prefer named exports over default exports.
13+
- Add `aria-label` and `title` to interactive elements (buttons, inputs).
14+
15+
## Hooks
16+
17+
- Wrap event handlers and callbacks passed to children in `useCallback`.
18+
- Use `useMemo` only for genuinely expensive computations — don't over-memoize.
19+
- Extract shared logic into custom hooks (`use-*.ts`) co-located with the feature.
20+
- Keep hooks side-effect free during render; effects belong in `useEffect`.
21+
22+
## Props & Types
23+
24+
- Use `type` over `interface` for component props unless extending.
25+
- Import types using `import type` to enable proper tree-shaking.
26+
- Colocate types with the code that uses them; only extract to a `types.ts` when shared across files.
27+
28+
## Performance
29+
30+
- Avoid creating objects or arrays inline in JSX props (causes unnecessary re-renders).
31+
- Prefer `useAtomValue` / `useSetAtom` (Jotai) over `useAtom` to minimize subscriptions.
32+
- Wrap expensive subtrees in `<Suspense>` with a fallback when using async atoms.
33+
34+
## File & Folder Naming
35+
36+
- **Components**: PascalCase (`ControlBar.tsx`, `ExportOverlay.tsx`).
37+
- **Non-components** (hooks, utils, atoms, types): kebab-case (`use-fit-view.ts`, `capture-element.ts`, `atoms.ts`).
38+
- **Folders**: kebab-case (`pan-zoom/`, `export/`, `math/`).
39+
40+
## File Organization
41+
42+
- Copyright header: `// Copyright (c) Microsoft Corporation.` + `// Licensed under the MIT License.`
43+
- Imports: type imports first, then library imports, then local imports (handled by Prettier plugin).
44+
- Styled components above the component function.
45+
- Component function at the bottom of the file, exported.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
applyTo: "src/**/*.{ts,tsx}"
3+
description: "Use when working with shared state, atoms, Jotai, or state management patterns in the visual designer app."
4+
---
5+
6+
# State Management (Jotai)
7+
8+
## Core Rules
9+
10+
- Use Jotai as the default for shared feature state.
11+
- Co-locate atoms with the feature they belong to.
12+
- Export feature atoms through the feature `index.ts` barrel.
13+
- Prefer small atoms over one large object atom.
14+
- Use derived atoms for view intent (e.g. `isExportCanvasCoverVisibleAtom`).
15+
- Use action atoms (`open*`, `close*`, `reset*`) when the action touches multiple atoms.
16+
- Use `useAtomValue` for reads and `useSetAtom` for writes to reduce accidental subscriptions.
17+
18+
## Project Layout
19+
20+
Core libraries: `src/lib/` (`graph/`, `messaging/`, `theming/`, `utils/`).
21+
Feature slices: `src/features/` (`controls/`, `export/`, `layout/`, `status/`, `visualization/`, `devtools/`).
22+
23+
Per feature:
24+
25+
- `feature/atoms.ts` — primary atoms, action atoms, derived atoms.
26+
- `feature/components/*` — use atoms directly where practical.
27+
- `feature/hooks/*` — orchestration logic that reacts to external events and writes atoms.
28+
29+
## When NOT to Use Atoms
30+
31+
- Purely presentational local toggles that never leave a component.
32+
- One-off temporary values with no cross-component relevance.
33+
- Expensive values better memoized from props inside one component.
34+
- Ephemeral typing state — keep as `useState` for in-progress input UX.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
applyTo: "src/**/*.tsx"
3+
description: "Use when writing or modifying styled-components, CSS-in-JS, or component styling in TSX files."
4+
---
5+
6+
# Styled-Components Conventions
7+
8+
## Naming
9+
10+
- Prefix styled components with `$`: `const $Wrapper = styled.div\`...\`;`.
11+
- Name describes purpose, not HTML element: `$Toolbar`, `$Separator`, not `$StyledDiv`.
12+
13+
## Rules
14+
15+
- Use styled-components for **all** static styles. Never use inline `style={{}}` for values known at build time.
16+
- For dynamic values computed at render time (positions, transforms, dimensions), use `.attrs()` to keep styles co-located and avoid CSS class regeneration:
17+
```tsx
18+
const $Panel = styled.div.attrs<{ $x: number; $y: number }>(({ $x, $y }) => ({
19+
style: { transform: `translate(${$x}px, ${$y}px)` },
20+
}))`
21+
position: absolute;
22+
`;
23+
```
24+
- Do not use bare inline `style={{}}` in JSX. Prefer `.attrs()` to keep all styling within the styled component definition.
25+
- Access theme values via the `${({ theme }) => ...}` interpolation, not `useTheme()` + inline style.
26+
- Extend existing styled components with `styled(Base)` instead of duplicating CSS.
27+
- Keep styled component definitions in the same file as the React component that uses them, above the component function.
28+
- Do not use `className` props or external CSS files.

src/vscode-bicep-ui/apps/visual-designer/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
"@react-hook/resize-observer": "^2.0.2",
2222
"@vscode-bicep-ui/components": "^0.0.0",
2323
"@vscode-bicep-ui/messaging": "^0.0.0",
24+
"@vscode-elements/elements": "^2.4.0",
25+
"@vscode-elements/react-elements": "^1.15.0",
2426
"elkjs": "^0.11.0",
2527
"html-to-image": "^1.11.13",
2628
"jotai": "^2.13.1",

src/vscode-bicep-ui/apps/visual-designer/src/App.tsx

Lines changed: 86 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,37 @@
22
// Licensed under the MIT License.
33

44
import type { ComponentType } from "react";
5-
import type { NodeKind } from "./features/graph-engine";
5+
import type { NodeKind } from "./lib/graph";
6+
import type { DeploymentGraphPayload } from "./lib/messaging";
67

78
import { PanZoomProvider, useGetPanZoomDimensions } from "@vscode-bicep-ui/components";
8-
import { WebviewMessageChannelProvider, useWebviewMessageChannel, useWebviewNotification } from "@vscode-bicep-ui/messaging";
9-
import { getDefaultStore } from "jotai";
9+
import {
10+
useWebviewMessageChannel,
11+
useWebviewNotification,
12+
WebviewMessageChannelProvider,
13+
} from "@vscode-bicep-ui/messaging";
14+
import { getDefaultStore, useAtomValue, useSetAtom } from "jotai";
1015
import { Suspense, useCallback, useEffect } from "react";
1116
import { styled, ThemeProvider } from "styled-components";
12-
import { GlobalStyle } from "./GlobalStyle";
13-
import { useTheme } from "./theming/use-theme";
14-
import { GraphControlBar } from "./components/GraphControlBar";
15-
import { StatusBar } from "./components/StatusBar";
16-
import { ModuleDeclaration, ResourceDeclaration } from "./features/visualization";
17-
import { nodeConfigAtom, Canvas, Graph } from "./features/graph-engine";
17+
import { ControlBar } from "./features/controls";
1818
import { loadDevAppShell } from "./features/devtools";
19-
import { useAutoLayout } from "./features/layout";
20-
import { useApplyDeploymentGraph } from "./features/messaging";
2119
import {
22-
DEPLOYMENT_GRAPH_NOTIFICATION,
23-
READY_NOTIFICATION,
24-
type DeploymentGraphPayload,
25-
} from "./messages";
20+
effectiveExportThemeAtom,
21+
ExportAreaCover,
22+
ExportAreaPreview,
23+
exportCanvasElementAtom,
24+
exportFileStemAtom,
25+
ExportOverlay,
26+
isExportCanvasCoverVisibleAtom,
27+
isExportPreviewVisibleAtom,
28+
} from "./features/export";
29+
import { useAutoLayout } from "./features/layout";
30+
import { StatusBar } from "./features/status";
31+
import { ModuleDeclaration, ResourceDeclaration } from "./features/visualization";
32+
import { GlobalStyle } from "./GlobalStyle";
33+
import { Canvas, Graph, nodeConfigAtom } from "./lib/graph";
34+
import { DEPLOYMENT_GRAPH_NOTIFICATION, READY_NOTIFICATION, useApplyDeploymentGraph } from "./lib/messaging";
35+
import { useTheme } from "./lib/theming";
2636

2737
const DevAppShell = loadDevAppShell();
2838

@@ -36,6 +46,18 @@ const $ControlBarContainer = styled.div`
3646
z-index: 100;
3747
`;
3848

49+
const $CanvasWrapper = styled.div`
50+
position: absolute;
51+
inset: 0;
52+
`;
53+
54+
function deriveExportFileStem(documentPath?: string, documentFileName?: string): string {
55+
const fileName = (documentFileName || documentPath || "").split(/[\\/]/).pop() ?? "";
56+
const stem = fileName.replace(/\.[^.]+$/, "").trim();
57+
58+
return stem || "bicep-graph";
59+
}
60+
3961
store.set(nodeConfigAtom, {
4062
...nodeConfig,
4163
padding: {
@@ -67,6 +89,9 @@ function GraphContainer() {
6789
}, [getPanZoomDimensions]);
6890
const applyGraph = useApplyDeploymentGraph(getViewportCenter);
6991
const messageChannel = useWebviewMessageChannel();
92+
const exportTheme = useAtomValue(effectiveExportThemeAtom);
93+
const setExportFileStem = useSetAtom(exportFileStemAtom);
94+
const setExportCanvasElement = useSetAtom(exportCanvasElementAtom);
7095

7196
// Send READY notification on mount
7297
useEffect(() => {
@@ -82,25 +107,66 @@ function GraphContainer() {
82107
(params: unknown) => {
83108
const payload = params as DeploymentGraphPayload;
84109
applyGraph(payload.deploymentGraph);
110+
setExportFileStem(deriveExportFileStem(payload.documentPath, payload.documentFileName));
85111
},
86-
[applyGraph],
112+
[applyGraph, setExportFileStem],
87113
),
88114
);
89115

90116
useAutoLayout();
91117

118+
const canvasTheme = exportTheme;
119+
120+
const handleCanvasRef = useCallback(
121+
(element: HTMLDivElement | null) => {
122+
setExportCanvasElement(element);
123+
},
124+
[setExportCanvasElement],
125+
);
126+
92127
return (
93128
<>
94129
<$ControlBarContainer>
95-
<GraphControlBar />
130+
<ControlBar />
96131
</$ControlBarContainer>
97-
<Canvas>
98-
<Graph />
99-
</Canvas>
132+
<ExportUILayer />
133+
<ThemeProvider theme={canvasTheme}>
134+
<$CanvasWrapper ref={handleCanvasRef}>
135+
<Canvas>
136+
<ExportCanvasCoverLayer />
137+
<Graph />
138+
</Canvas>
139+
</$CanvasWrapper>
140+
</ThemeProvider>
100141
</>
101142
);
102143
}
103144

145+
function ExportUILayer() {
146+
const isExportPreviewVisible = useAtomValue(isExportPreviewVisibleAtom);
147+
148+
if (!isExportPreviewVisible) {
149+
return null;
150+
}
151+
152+
return (
153+
<>
154+
<ExportOverlay />
155+
<ExportAreaPreview />
156+
</>
157+
);
158+
}
159+
160+
function ExportCanvasCoverLayer() {
161+
const isExportCanvasCoverVisible = useAtomValue(isExportCanvasCoverVisibleAtom);
162+
163+
if (!isExportCanvasCoverVisible) {
164+
return null;
165+
}
166+
167+
return <ExportAreaCover />;
168+
}
169+
104170
const $AppContainer = styled.div`
105171
flex: 1 1 auto;
106172
position: relative;

0 commit comments

Comments
 (0)