25 Commits

Author SHA1 Message Date
schihei 16491c00ce Add IMPROVEMENTS.md with code audit findings across bugs, code quality, performance, and maintainability 2026-05-17 09:00:22 +02:00
schihei e82df2f308 Add UML diagrams to documentation directory 2025-03-31 20:31:31 +02:00
schihei 626b7aaa7a Fix spacing inconsistency between Grid and Guidelines sections by removing redundant margin 2025-03-22 19:58:37 +01:00
schihei 1aeef10ebe Standardize documentation across all *.tsx files
- Add file-level JSDoc comments to components that were missing them
- Ensure consistent documentation format across all components
- Improve code maintainability and readability
2025-03-22 19:53:09 +01:00
schihei b1699a74bc Optimize GridGuidelineManager component
- Improve performance by batching API calls and reducing context.sync() calls
- Add memoization with useCallback for event handlers
- Extract ColorButton component and create reusable numeric input hook
- Define slide dimensions as constants
- Add accessibility attributes to input fields
- Add comprehensive JSDoc documentation
2025-03-22 19:47:18 +01:00
schihei b76b34a332 Optimize AlignmentButtons component with improved documentation, code structure, and performance 2025-03-22 19:37:54 +01:00
schihei 9bb1bf2cdb Optimize DraftButtons component with improved documentation, code structure, and performance 2025-03-22 19:33:46 +01:00
schihei 54328baaec Optimize ConfidentialButtons component with improved documentation, code structure, and performance 2025-03-22 19:31:07 +01:00
schihei fea088fe26 Optimize ProgressBarButtons with performance improvements and documentation
- Reduce API calls by batching operations
- Add performance monitoring
- Create configuration constants
- Improve code documentation
- Enhance variable naming for readability
2025-03-22 19:23:38 +01:00
schihei d6c32b778c Remove unused HeroList component 2025-03-22 19:10:59 +01:00
schihei 1ed34ab888 Refactor RoundImage component for improved maintainability and consistency with other components 2025-03-22 18:11:23 +01:00
schihei e0343dfd65 Add slide numbers toggle dropdown to InsertTitles component 2025-03-22 17:24:03 +01:00
schihei d67db17f4b Align pull-down menu to the right in InsertTitles component 2025-03-22 17:20:38 +01:00
schihei 36190f1ba5 Improve title detection algorithm and UI layout in InsertTitles component 2025-03-22 17:12:50 +01:00
schihei 4ca09b8098 Se version number to 1.2.0-dev 2025-03-22 16:06:18 +01:00
schihei 5e02bfc81e Refactor InsertTitles component for better maintainability and performance 2025-03-22 16:01:10 +01:00
schihei ba304f0047 Refactor SwapPositions component for better maintainability and performance 2025-03-22 15:57:53 +01:00
schihei 577d5b15c8 Refactor MatchSizes component for better maintainability and performance 2025-03-22 15:54:13 +01:00
schihei 04c86c6162 Refactor MatchProperties component for better maintainability and performance 2025-03-22 15:50:19 +01:00
schihei 3dc9afea50 Make version numbers consistent across all files
- Updated manifest.xml from 1.0.0.0 to 1.1.0.0
- Updated README.md version history with v1.1.0 entry
- Synchronized package-lock.json version with package.json

All application files now consistently refer to version 1.1.0

🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-03-15 00:19:11 +01:00
schihei 338810ef60 Update version to 1.1.0 and enhance documentation
- Bumped version from 1.0.1 to 1.1.0 in package.json and UI
- Updated USER_GUIDE.md with new features in v1.1.0
- Enhanced DEVELOPERS.md with:
  - Information about reusable components (ActionButton, Section)
  - Documentation for type-safe utilities
  - Code examples for performance optimizations
  - Updated best practices
  - Added information about the improved project structure

🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-03-15 00:16:42 +01:00
schihei 50966867b2 Improve TypeScript types and error handling
- Created proper TypeScript types for Office API operations
- Replaced 'any' casts with appropriate types
- Added type-safe utility functions in office-types.ts
- Improved error handling with proper unknown type
- Added helper functions for PowerPoint shape operations
- Created proper TypeScript interface for webpack HMR

These changes improve code quality, maintainability, and help catch potential bugs at compile time rather than runtime.

🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-03-15 00:02:35 +01:00
schihei 07b0232726 Optimize performance by batching context.sync() calls
- GridGuidelineManager: Load all shape names upfront and apply operations in batches
- AlignmentButtons: Replaced for loops with forEach and reduced sync calls
- MatchProperties: Reorganized code to batch load operations and property assignments

This optimization significantly reduces round-trips between JavaScript and the Office application,
improving performance and responsiveness of the add-in.

🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-03-14 23:56:54 +01:00
schihei d09dec4706 Refactor UI components for better reusability
- Created reusable ActionButton component to handle common button behavior
- Created reusable Section component for consistent section styling
- Refactored App, MatchSizes, and SwapPositions to use new components
- Fixed scrolling issues in the main container
- Improved code organization and reduced duplication

🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-03-14 23:50:19 +01:00
schihei 0cbb9c948e Minor changes 2025-03-14 23:40:29 +01:00
27 changed files with 3058 additions and 1368 deletions
+77
View File
@@ -0,0 +1,77 @@
# Improvements & Bugfixes
---
## Critical Bugs
| # | Issue | Location |
|---|-------|----------|
| 1 | **Alignment broken**`shape.width` / `shape.height` are read without calling `shape.load()`. Office.js only returns loaded properties, so values are always `undefined` or `0`, causing incorrect positioning. Compare `MatchSizes.tsx:67` which correctly calls `sourceShape.load(...)` before reading dimensions. | `src/taskpane/components/AlignmentButtons.tsx:127-130, 176-179` |
| 2 | **Performance disaster**`findTitleShape()` in `InsertTitles.tsx` does up to 3 sequential `await context.sync()` calls per shape per slide (load shape → sync, load textFrame → sync, load textRange → sync). A 50-slide, 10-shape presentation = up to 1,500 IPC round-trips. Should batch all loads before syncing. | `src/taskpane/components/InsertTitles.tsx:108, 116-128` |
| 3 | **Dead / broken code**`commands.ts` imports Outlook-only API (`Office.context.mailbox`) which is `undefined` in PowerPoint. Also binds a function to `Office.actions.associate` that is never referenced in `manifest.xml`. The entire file is boilerplate from an Outlook add-in template. | `src/commands/commands.ts:25, 35` |
---
## Major Improvements
| # | Issue | Location |
|---|-------|----------|
| 4 | **Massive code duplication**`ConfidentialButtons.tsx` and `DraftButtons.tsx` share ~200 identical lines: same `ProcessingResult` interface (duplicated), same `handleError()` pattern, same `processAdd*()` / `processRemove*()` loop structure, same CSS grid button styles. Also partially duplicated in `ProgressBarButtons.tsx`. Extract into a shared "BatchSlideOperation" component or hook. | `src/taskpane/components/ConfidentialButtons.tsx`, `DraftButtons.tsx`, `ProgressBarButtons.tsx` |
| 5 | **No business-logic layer** — All PowerPoint API calls are embedded directly in React components. No separation between UI and data access. Each component independently calls `PowerPoint.run()`, loads shapes, and validates selections. Should have a service / API layer. | All components |
| 6 | **`StatusContext` tightly coupled** — Defined in `App.tsx` instead of its own file. Every component that needs status imports from `App.tsx`, creating coupling and circular import risk. Move to a dedicated context file. | `src/taskpane/components/App.tsx` + all consuming components |
| 7 | **Inconsistent error handling**`ActionButton.tsx` wraps operations in `try/catch/finally` with proper `isProcessing` state management. Other components use raw `<Button>` elements with ad-hoc error handling. `ProgressBarButtons.tsx:162, 239` accesses `error.message` without type-check (crashes if a non-Error is thrown). `ConfidentialButtons.tsx:206-218` silently swallows font loading errors, producing invisible or wrongly colored markings. | Multiple files |
| 8 | **No testing infrastructure** — Zero test files, no test runner (Jest / Vitest), no testing libraries in `devDependencies`, no mock utilities for the Office.js API. Regression risk grows with every feature. | Entire project |
---
## Medium Issues
| # | Issue | Location |
|---|-------|----------|
| 9 | **Magic numbers everywhere** — Slide dimensions (`960` / `540`) duplicated in 4+ files. Alignment offsets (`81.1`, `480`, `879.75`, `136.75`, `321`, `505.25`) are unexplained. Extract to a shared constants file. | `ConfidentialButtons.tsx:35-37`, `GridGuidelineManager.tsx:33-34`, `ProgressBarButtons.tsx:23-24`, `InsertTitles.tsx:17`, `AlignmentButtons.tsx:30-38` |
| 10 | **No confirmation for destructive operations** — Removing all confidential markings or draft watermarks from every slide / master has no "Are you sure?" prompt. | `ConfidentialButtons.tsx`, `DraftButtons.tsx` |
| 11 | **`gridOpacity` unbounded** — `useNumericInputHandler` has no upper-bound validation. Values > 100 produce negative transparency in `line.fill.transparency = (100 - gridOpacity) / 100`, which is invalid. Should clamp to `[0, 1]`. | `src/taskpane/components/GridGuidelineManager.tsx:280, 301` |
| 12 | **No progress feedback for batch operations**`InsertTitles` processes all slides, `ConfidentialButtons` processes every slide — only a spinner is shown, with no progress bar or slide counter. Users have no indication of how long the operation will take. | `InsertTitles.tsx`, `ConfidentialButtons.tsx`, `DraftButtons.tsx` |
| 13 | **Misleading status type**`RoundImage.tsx` sets `setStatusType("warning")` after a *successful* mask creation. The mask was created, but the user needs to perform a manual step. Use `"info"` or a distinct instructional state instead. | `src/taskpane/components/RoundImage.tsx:124` |
| 14 | **Manifest uses template GUID** — Add-in ID `be5c6f61-4bbf-4bd2-a8da-5031abf096a5` is from the Yeoman Office template. Should be regenerated for production to avoid collisions. | `manifest.xml:3` |
| 15 | **No Content Security Policy** — Neither `taskpane.html` nor `commands.html` defines a `<meta>` CSP tag. | `src/taskpane/taskpane.html`, `src/commands/commands.html` |
| 16 | **Placeholder support URL**`SupportUrl` in `manifest.xml:11` points to `https://www.contoso.com/help`, a Microsoft template placeholder. | `manifest.xml:11` |
---
## Quick Wins (Low Effort, High Value)
| # | Issue | Location |
|---|-------|----------|
| 17 | **Fix `any` types** — Replace `error: any` with `error: unknown` in `catch` blocks to enforce proper type narrowing (3 locations). | `ConfidentialButtons.tsx:96`, `DraftButtons.tsx:104`, `AlignmentButtons.tsx:92` |
| 18 | **Remove unused imports**`ArrowMaximizeRegular` / `ArrowMinimizeRegular` imported but unused in `MatchSizes.tsx`. | `src/taskpane/components/MatchSizes.tsx:9` |
| 19 | **Expose width-only / height-only match**`SizeMatchType.Width` and `SizeMatchType.Height` enums exist and the logic supports them, but only "Match Sizes" (both dimensions) has a UI button. | `src/taskpane/components/MatchSizes.tsx` |
| 20 | **Fix `package.json` repo URL** — Points to `Office-Addin-TaskPane-React` (Microsoft template repo), not the actual project repository. | `package.json:7` |
| 21 | **Bump `tsconfig.json` target**`es5` is unnecessary since the add-in explicitly requires modern Office. Move to `ES2020+` to drop `core-js` polyfills and reduce bundle size. | `tsconfig.json:16` |
| 22 | **Remove dead config**`ts-node` section exists in `tsconfig.json` but `ts-node` is not a dependency. Also, `ie 11` in `browserslist` but the HTML file explicitly rejects IE / Edge Legacy. | `tsconfig.json:31-36`, `package.json:73-75` |
| 23 | **Remove empty spacer `<div>` elements**`App.tsx` uses `<div style={{ marginTop: "8px" }}></div>` as spacers. Use CSS `gap` on the parent flex container instead. | `src/taskpane/components/App.tsx:153-161` |
| 24 | **Inconsistent TypeScript compilation**`.ts` files use `babel-loader` (no type checking) while `.tsx` files use `ts-loader` (with type checking). This can cause files to pass build but contain type errors. | `webpack.config.js:37-48` |
| 25 | **Fragile HMR pattern**`index.tsx` casts `module` to a custom `HotModule` interface. Should use `import.meta.webpackHot` or `@types/webpack-env`. The `require("./components/App")` on line 32 circumvents ES module system. | `src/taskpane/index.tsx:30-32` |
| 26 | **Hardcoded production domain**`urlProd` in `webpack.config.js:9` is hardcoded. If the deployment domain changes, this and `manifest.xml` could diverge. | `webpack.config.js:9` |
| 27 | **Incomplete dark mode** — Dark mode check runs once on mount with no listener for system theme changes or Office theme events. | `src/taskpane/components/App.tsx:109-113` |
| 28 | **`StandardiseSizes.tsx` loads but never uses line weight** — `RoundImage.tsx:82-83` loads `imageShape.lineFormat.weight` but never uses the value, causing an unnecessary sync. | `src/taskpane/components/RoundImage.tsx:82-83` |
---
## Security
| # | Issue | Location |
|---|-------|----------|
| 29 | **Overly permissive CORS**`Access-Control-Allow-Origin: *` on dev server. While dev-only, restrict for best practice. | `webpack.config.js:101` |
| 30 | **Template `GetStarted.LearnMoreUrl`** — Points to `https://go.microsoft.com/fwlink/?LinkId=276812`, a generic Microsoft link. | `manifest.xml:71` |
---
## Recommended Execution Order
1. **Fix alignment bug** (#1) — functional breakage
2. **Refactor `InsertTitles` sync pattern** (#2) — unusably slow on real presentations
3. **Extract shared logic** (#4, #5, #6) — clean the foundation before adding features
4. **Fix easy bugs** (#11, #13, #17, #18, #22, #28)
5. **Add infrastructure** (#8, #15, #19, #20, #21)
6. **Add UX improvements** (#10, #12, #27)
+1
View File
@@ -159,5 +159,6 @@ This project is licensed under the MIT License - see the LICENSE file for detail
## Version History ## Version History
- v1.1.0 - Performance improvements, code quality, and UI enhancements
- v1.0.0 - First stable release - v1.0.0 - First stable release
- v0.1.0 - Initial release with core functionality - v0.1.0 - Initial release with core functionality
+104 -7
View File
@@ -12,6 +12,17 @@ Edison is a PowerPoint add-in built with:
The application follows a component-based architecture where each tool is encapsulated in its own React component. The application follows a component-based architecture where each tool is encapsulated in its own React component.
## UML Diagrams
To better understand the architecture and structure of the Edison PowerPoint Add-in, refer to the following UML diagrams:
- [UML Diagrams Index](powerpoint-toolbox-uml-index.md) - Overview and index of all UML diagrams
- [Component Relationships](powerpoint-toolbox-uml.md) - Shows the relationships between React components
- [Project Structure](powerpoint-toolbox-structure-uml.md) - Illustrates the file and directory organization
- [Data Flow](powerpoint-toolbox-dataflow-uml.md) - Demonstrates the flow of data and interactions
These diagrams provide visual representations of the application's architecture, component relationships, and data flow patterns, which can help new developers understand the codebase more quickly.
## Project Structure ## Project Structure
``` ```
@@ -24,18 +35,24 @@ The application follows a component-based architecture where each tool is encaps
│ │ └── commands.ts # Command implementations │ │ └── commands.ts # Command implementations
│ └── taskpane/ # Main add-in UI │ └── taskpane/ # Main add-in UI
│ ├── components/ # React components │ ├── components/ # React components
│ │ ├── ActionButton.tsx # Reusable button with error handling
│ │ ├── AlignmentButtons.tsx │ │ ├── AlignmentButtons.tsx
│ │ ├── App.tsx # Main application component │ │ ├── App.tsx # Main application component
│ │ ├── ConfidentialButtons.tsx │ │ ├── ConfidentialButtons.tsx
│ │ ├── DraftButtons.tsx │ │ ├── DraftButtons.tsx
│ │ ├── GridGuidelineManager.tsx
│ │ ├── Header.tsx │ │ ├── Header.tsx
│ │ ├── InsertTitles.tsx │ │ ├── InsertTitles.tsx
│ │ ├── MatchProperties.tsx │ │ ├── MatchProperties.tsx
│ │ ├── MatchSizes.tsx │ │ ├── MatchSizes.tsx
│ │ ├── ProgressBarButtons.tsx │ │ ├── ProgressBarButtons.tsx
│ │ ├── RoundImage.tsx │ │ ├── RoundImage.tsx
│ │ ├── Section.tsx # Reusable section component
│ │ ├── SmartTextFormatter.tsx
│ │ ├── SwapPositions.tsx │ │ ├── SwapPositions.tsx
│ │ └── commonStyles.tsx │ │ └── commonStyles.tsx
│ ├── types/ # TypeScript type definitions
│ │ └── office-types.ts # Office API type utilities
│ ├── index.tsx # Entry point │ ├── index.tsx # Entry point
│ └── taskpane.html # Main HTML container │ └── taskpane.html # Main HTML container
├── babel.config.json # Babel configuration ├── babel.config.json # Babel configuration
@@ -92,6 +109,34 @@ npx office-addin-dev-certs install
## Component Development Guidelines ## Component Development Guidelines
### Reusable Components
#### ActionButton
A reusable button component that handles error states, loading states, and consistent styling:
```tsx
import ActionButton from "./ActionButton";
// In your component:
<ActionButton
title="Match Sizes"
icon={<ArrowFitInRegular />}
onClick={myAsyncFunction}
/>
```
#### Section
A consistent container for organizing the UI:
```tsx
import Section from "./Section";
// In your component:
<Section title="My Feature Group">
<MyFeatureComponent />
</Section>
```
### Creating a New Tool ### Creating a New Tool
1. Create a new component file in `src/taskpane/components/` 1. Create a new component file in `src/taskpane/components/`
@@ -142,6 +187,25 @@ export default MyNewTool;
### Common PowerPoint.js Operations ### Common PowerPoint.js Operations
#### Type-Safe Utilities
The application provides type-safe utility functions in `src/taskpane/types/office-types.ts`:
```typescript
// Get error message safely from any error type
const errorMsg = getErrorMessage(error);
// Check if a shape is an image
if (isPictureShape(shape)) {
// It's a picture shape
}
// Get the first selected slide
const slide = getFirstSelectedSlide(context);
// Select shapes by ID
selectShapesById(slide, [shape1.id, shape2.id]);
```
#### Selecting Shapes #### Selecting Shapes
```typescript ```typescript
const shapes = context.presentation.getSelectedShapes(); const shapes = context.presentation.getSelectedShapes();
@@ -217,10 +281,40 @@ Recommended testing approach:
## Performance Considerations ## Performance Considerations
- Minimize `context.sync()` calls, especially in loops ### Optimizing context.sync() calls
- Batch operations when possible
A key performance optimization is to batch context.sync() calls:
```typescript
// BAD: Calling context.sync() in a loop
for (let i = 0; i < shapes.items.length; i++) {
shapes.items[i].load("name");
await context.sync(); // Slow! One network roundtrip per shape
if (shapes.items[i].name === "Target") {
// Do something
}
}
// GOOD: Batching context.sync() calls
// Load all properties at once
for (let i = 0; i < shapes.items.length; i++) {
shapes.items[i].load("name");
}
await context.sync(); // Single network roundtrip for all shapes
// Process the data locally
for (let i = 0; i < shapes.items.length; i++) {
if (shapes.items[i].name === "Target") {
// Do something
}
}
```
Additional performance tips:
- Use memoization for expensive calculations - Use memoization for expensive calculations
- Ensure proper cleanup of event listeners - Ensure proper cleanup of event listeners
- Use the ActionButton component for consistent loading states
- Consider using Web Workers for heavy computations
## Common Issues and Solutions ## Common Issues and Solutions
@@ -236,11 +330,14 @@ Recommended testing approach:
## Best Practices ## Best Practices
1. **Error Handling**: Always use try/catch blocks with PowerPoint API calls 1. **Error Handling**: Always use try/catch blocks with PowerPoint API calls and use proper types (see `office-types.ts`)
2. **User Feedback**: Provide clear status messages for all operations 2. **User Feedback**: Provide clear status messages for all operations using the StatusContext
3. **Accessibility**: Ensure all UI elements have proper labels and keyboard access 3. **Accessibility**: Ensure all UI elements have proper ARIA attributes and support keyboard navigation
4. **Theme Support**: Use Fluent UI theme tokens for consistent theming 4. **Theme Support**: Use Fluent UI theme tokens for consistent theming
5. **Code Organization**: Keep component files focused on a single responsibility 5. **Code Organization**: Keep component files focused on a single responsibility
6. **Performance**: Batch context.sync() calls and avoid calling them inside loops
7. **Reusability**: Use custom hooks for common operations and shared components like ActionButton and Section
8. **Type Safety**: Avoid using "any" type and leverage TypeScript's static typing
## Contributing ## Contributing
@@ -254,4 +351,4 @@ Recommended testing approach:
- [Office Add-ins Documentation](https://docs.microsoft.com/en-us/office/dev/add-ins/) - [Office Add-ins Documentation](https://docs.microsoft.com/en-us/office/dev/add-ins/)
- [Office.js API Reference](https://docs.microsoft.com/en-us/javascript/api/overview/office) - [Office.js API Reference](https://docs.microsoft.com/en-us/javascript/api/overview/office)
- [Fluent UI Documentation](https://developer.microsoft.com/en-us/fluentui) - [Fluent UI Documentation](https://developer.microsoft.com/en-us/fluentui)
- [React Documentation](https://reactjs.org/docs/getting-started.html) - [React Documentation](https://reactjs.org/docs/getting-started.html)
+9 -1
View File
@@ -212,4 +212,12 @@ If you encounter issues not covered in this guide, contact your IT department or
## Version Information ## Version Information
This user guide is for Edison PowerPoint Add-in v1.0.0. This user guide is for Edison PowerPoint Add-in v1.1.0.
### What's New in v1.1.0
* **Performance Improvements** - Faster operations especially when working with multiple shapes
* **Enhanced Stability** - Improved error handling and recovery
* **Better UI Experience** - More responsive interface with improved scrolling
* **Accessibility Enhancements** - Better support for screen readers and keyboard navigation
* **Code Quality** - Improved TypeScript typing for increased reliability
+117
View File
@@ -0,0 +1,117 @@
# PowerPoint Toolbox Data Flow UML
This UML diagram represents the data flow and interaction patterns in the PowerPoint toolbox add-in.
```mermaid
sequenceDiagram
participant User
participant UI as UI Components
participant Context as Status Context
participant Office as Office JS API
participant PowerPoint as PowerPoint Application
%% Initial Load
User->>UI: Open Add-in
UI->>Office: Initialize
Office->>PowerPoint: Connect
PowerPoint-->>Office: Connection Established
Office-->>UI: Ready
%% Feature Interaction (e.g., Match Sizes)
User->>UI: Click "Match Sizes"
UI->>Context: Set isProcessing(true)
UI->>Office: Get Selected Shapes
Office->>PowerPoint: getSelectedShapes()
PowerPoint-->>Office: Selected Shapes
Office-->>UI: Shape Collection
%% Processing Logic
Note over UI: Validate selection
Note over UI: Extract dimensions from first shape
Note over UI: Apply to other shapes
%% Apply Changes
UI->>Office: Update Shape Properties
Office->>PowerPoint: Apply Changes
PowerPoint-->>Office: Changes Applied
Office-->>UI: Success
%% Status Update
UI->>Context: Set statusMessage("Resized N shapes...")
UI->>Context: Set statusType("success")
UI->>Context: Set isProcessing(false)
Context-->>UI: Update UI with Status
UI-->>User: Show Success Message
```
## Data Flow Explanation
This sequence diagram illustrates the typical data flow in the PowerPoint toolbox add-in:
1. **Initialization Flow**:
- User opens the add-in in PowerPoint
- The UI initializes and connects to the Office JS API
- The Office JS API establishes a connection with PowerPoint
2. **Feature Interaction** (using "Match Sizes" as an example):
- User clicks a feature button in the UI
- The UI sets the processing state via the Status Context
- The UI requests selected shapes from the Office JS API
- PowerPoint returns the selected shapes
3. **Processing Logic**:
- The UI component validates the selection
- It extracts necessary information from the first shape
- It applies changes to the other shapes
4. **Apply Changes**:
- The UI sends updated properties to the Office JS API
- The Office JS API applies the changes in PowerPoint
- PowerPoint confirms the changes
5. **Status Update**:
- The UI updates the status message and type via the Status Context
- The Status Context updates the UI with the new status
- The user sees a success message
## Error Handling Flow
```mermaid
sequenceDiagram
participant User
participant UI as UI Components
participant Context as Status Context
participant Office as Office JS API
participant PowerPoint as PowerPoint Application
%% Feature Interaction with Error
User->>UI: Click Feature Button
UI->>Context: Set isProcessing(true)
UI->>Office: Perform Operation
Office->>PowerPoint: Execute Command
PowerPoint-->>Office: Error Occurs
Office-->>UI: Error Response
%% Error Handling
UI->>Context: Set statusMessage("Error: ...")
UI->>Context: Set statusType("error")
UI->>Context: Set isProcessing(false)
Context-->>UI: Update UI with Error
UI-->>User: Show Error Message
```
## Component Communication Pattern
```mermaid
flowchart TD
User([User]) --> |Interacts with| UI[UI Components]
UI --> |Updates| Context[Status Context]
UI --> |Calls| Office[Office JS API]
Office --> |Communicates with| PowerPoint[PowerPoint]
PowerPoint --> |Returns data to| Office
Office --> |Returns results to| UI
Context --> |Provides state to| UI
UI --> |Displays results to| User
```
This diagram set illustrates how data flows through the application, from user interaction to PowerPoint manipulation and back to user feedback. The architecture follows a unidirectional data flow pattern with the Status Context serving as a central state management system.
+148
View File
@@ -0,0 +1,148 @@
# PowerPoint Toolbox Project Structure UML
This UML diagram represents the file and directory structure of the PowerPoint toolbox add-in.
```mermaid
classDiagram
%% Project Structure
class Repository {
<<Root>>
Configuration Files
Source Code
Assets
Documentation
}
%% Configuration Files
class ConfigFiles {
<<Config>>
.eslintrc.json
.gitignore
.hintrc
babel.config.json
manifest.xml
package.json
tsconfig.json
webpack.config.js
}
%% Source Code Structure
class SourceCode {
<<Source>>
/src
}
class Commands {
<<Module>>
/src/commands
commands.html
commands.ts
}
class Taskpane {
<<Module>>
/src/taskpane
index.tsx
taskpane.html
}
class Components {
<<Module>>
/src/taskpane/components
ActionButton.tsx
AlignmentButtons.tsx
App.tsx
commonStyles.tsx
ConfidentialButtons.tsx
DraftButtons.tsx
GridGuidelineManager.tsx
Header.tsx
InsertTitles.tsx
MatchProperties.tsx
MatchSizes.tsx
ProgressBarButtons.tsx
RoundImage.tsx
Section.tsx
SwapPositions.tsx
}
class Types {
<<Module>>
/src/taskpane/types
office-types.ts
}
%% Assets
class Assets {
<<Resources>>
/assets
edison-16.png
edison-32.png
edison-64.png
edison-80.png
edison-128.png
edison-filled.png
}
%% Documentation
class Documentation {
<<Docs>>
/docs
DEVELOPERS.md
USER_GUIDE.md
}
%% Relationships
Repository *-- ConfigFiles : contains
Repository *-- SourceCode : contains
Repository *-- Assets : contains
Repository *-- Documentation : contains
SourceCode *-- Commands : contains
SourceCode *-- Taskpane : contains
Taskpane *-- Components : contains
Taskpane *-- Types : contains
%% Dependency Relationships
Components --> Types : imports
Commands --> Components : may use
```
## Project Structure Explanation
This UML diagram illustrates the file and directory organization of the PowerPoint toolbox add-in:
1. **Repository Root**: Contains configuration files, source code, assets, and documentation.
2. **Configuration Files**: Various configuration files for the development environment, including:
- manifest.xml: Office add-in manifest
- package.json: NPM package configuration
- webpack.config.js: Webpack bundler configuration
- tsconfig.json: TypeScript configuration
- babel.config.json: Babel configuration
- .eslintrc.json: ESLint configuration
- .gitignore: Git ignore rules
- .hintrc: Hint configuration
3. **Source Code Structure**:
- **/src/commands**: Command-related files for Office ribbon integration
- **/src/taskpane**: Main taskpane UI implementation
- **/components**: React components for the UI
- **/types**: TypeScript type definitions
4. **Assets**: Icon files in various sizes for the add-in
5. **Documentation**: Developer and user documentation
## Build and Execution Flow
The PowerPoint toolbox add-in follows this general build and execution flow:
1. Source code is written in TypeScript and React
2. Webpack bundles the code according to webpack.config.js
3. The Office add-in manifest (manifest.xml) defines how the add-in appears in PowerPoint
4. When loaded in PowerPoint, the add-in renders the taskpane UI
5. The taskpane components interact with PowerPoint through the Office JS API
This structure follows Microsoft's recommended patterns for Office add-in development using React and TypeScript.
+57
View File
@@ -0,0 +1,57 @@
# PowerPoint Toolbox UML Diagrams
This document serves as an index for the UML diagrams created for the PowerPoint toolbox add-in repository.
## Available Diagrams
1. [Component Relationships](powerpoint-toolbox-uml.md) - Shows the relationships between React components and their interactions.
2. [Project Structure](powerpoint-toolbox-structure-uml.md) - Illustrates the file and directory organization of the repository.
3. [Data Flow](powerpoint-toolbox-dataflow-uml.md) - Demonstrates the flow of data and interactions between components and the PowerPoint API.
## Repository Overview
The PowerPoint toolbox add-in is a Microsoft Office add-in built with React and TypeScript that provides various tools for enhancing PowerPoint presentations. The add-in is structured as follows:
### Key Technologies
- **React**: For building the user interface
- **TypeScript**: For type-safe JavaScript development
- **Office JS API**: For interacting with PowerPoint
- **Fluent UI**: For consistent Microsoft-style UI components
- **Webpack**: For bundling and building the application
### Architecture Highlights
- **Component-Based Structure**: The application is organized into reusable UI components
- **Context API for State Management**: Uses React's Context API for sharing state across components
- **Unidirectional Data Flow**: Data flows from user interaction through the application to PowerPoint and back
- **Centralized Error Handling**: Error handling is managed through the Status Context
### Main Features
The add-in provides tools for:
- Matching sizes and properties between shapes
- Rounding image corners
- Swapping positions of shapes
- Inserting title slides
- Adding confidential markings and draft watermarks
- Managing progress bars
- Aligning shapes
- Managing grid guidelines
## How to View the Diagrams
Each diagram is contained in a separate Markdown file with embedded Mermaid syntax. To view the diagrams:
1. Open the desired diagram file in a Markdown viewer that supports Mermaid diagrams (such as GitHub, VS Code with the Mermaid extension, or any Mermaid-compatible viewer).
2. The diagrams will render automatically in compatible viewers.
## Diagram Types
- **Class Diagrams**: Used for component relationships and project structure
- **Sequence Diagrams**: Used for data flow and interaction patterns
- **Flowcharts**: Used for component communication patterns
+195
View File
@@ -0,0 +1,195 @@
# PowerPoint Toolbox UML Diagram
This UML diagram represents the structure and relationships of the PowerPoint toolbox add-in.
```mermaid
classDiagram
%% Main Application Component
class App {
+title: string
+statusMessage: string
+statusType: StatusType
+isProcessing: boolean
+setStatusMessage(message: string)
+setStatusType(type: StatusType)
+setIsProcessing(processing: boolean)
+getStatusIcon()
}
%% Context Provider
class StatusContext {
<<Context>>
+statusMessage: string
+setStatusMessage: function
+statusType: StatusType
+setStatusType: function
+isProcessing: boolean
+setIsProcessing: function
}
%% Common UI Components
class Section {
+title: string
+children: ReactNode
}
class ActionButton {
+icon: ReactNode
+onClick: function
+disabled: boolean
+title: string
+appearance: string
+className: string
+handleClick()
}
%% Feature Components
class MatchSizes {
-validateShapeSelection(shapes): boolean
-loadSourceShape(sourceShape, context, matchType): Promise
-applySizeChanges(sourceShape, shapes, matchType): number
-generateStatusMessage(resizedCount, matchType): string
-matchSizeToFirstSelected(matchType): Promise
}
class MatchProperties {
<<Component>>
}
class RoundImage {
<<Component>>
}
class SwapPositions {
<<Component>>
}
class InsertTitles {
<<Component>>
}
class ConfidentialButtons {
<<Component>>
}
class DraftButtons {
<<Component>>
}
class ProgressBarButtons {
<<Component>>
}
class AlignmentButtons {
<<Component>>
}
class GridGuidelineManager {
<<Component>>
}
%% Utility Types
class OfficeTypes {
<<Utility>>
+getErrorMessage(error): string
+isPictureShape(shape): boolean
+getFirstSelectedSlide(context): PowerPoint.Slide
+selectShapesById(slide, shapeIds): void
}
class CommonStyles {
<<Styles>>
+container: style
+buttonGroup: style
+actionButton: style
+statusContainer: style
+successStatus: style
+warningStatus: style
+errorStatus: style
+statusIcon: style
+statusText: style
}
%% Relationships
App *-- StatusContext : provides
App *-- Section : contains
Section *-- MatchSizes : contains
Section *-- MatchProperties : contains
Section *-- RoundImage : contains
Section *-- SwapPositions : contains
Section *-- InsertTitles : contains
Section *-- ConfidentialButtons : contains
Section *-- DraftButtons : contains
Section *-- ProgressBarButtons : contains
Section *-- AlignmentButtons : contains
Section *-- GridGuidelineManager : contains
MatchSizes --> ActionButton : uses
MatchSizes --> OfficeTypes : uses
MatchSizes --> StatusContext : consumes
MatchSizes --> CommonStyles : uses
ActionButton --> StatusContext : consumes
ActionButton --> CommonStyles : uses
ActionButton --> OfficeTypes : uses
%% Other components follow similar patterns
MatchProperties --> ActionButton : uses
MatchProperties --> StatusContext : consumes
RoundImage --> ActionButton : uses
RoundImage --> StatusContext : consumes
SwapPositions --> ActionButton : uses
SwapPositions --> StatusContext : consumes
InsertTitles --> ActionButton : uses
InsertTitles --> StatusContext : consumes
ConfidentialButtons --> ActionButton : uses
ConfidentialButtons --> StatusContext : consumes
DraftButtons --> ActionButton : uses
DraftButtons --> StatusContext : consumes
ProgressBarButtons --> ActionButton : uses
ProgressBarButtons --> StatusContext : consumes
AlignmentButtons --> ActionButton : uses
AlignmentButtons --> StatusContext : consumes
GridGuidelineManager --> ActionButton : uses
GridGuidelineManager --> StatusContext : consumes
```
## Diagram Explanation
This UML diagram shows:
1. **App Component**: The main container component that provides the StatusContext to all child components.
2. **StatusContext**: A React context that manages status messages and processing state across the application.
3. **UI Components**:
- **Section**: A reusable component for grouping related functionality
- **ActionButton**: A reusable button component with built-in error handling and status updates
4. **Feature Components**: Various tools for PowerPoint manipulation:
- MatchSizes: For matching dimensions between shapes
- MatchProperties: For matching properties between shapes
- RoundImage: For rounding image corners
- SwapPositions: For swapping positions of shapes
- InsertTitles: For inserting title slides
- ConfidentialButtons: For adding confidential markings
- DraftButtons: For adding draft watermarks
- ProgressBarButtons: For managing progress bars
- AlignmentButtons: For aligning shapes
- GridGuidelineManager: For managing grid guidelines
5. **Utility Types**: Helper functions and types for Office JS API integration
6. **Relationships**: Shows how components are composed and how they consume the StatusContext
## Project Architecture
The PowerPoint toolbox add-in follows a React-based architecture with the following characteristics:
- **Component-Based Structure**: The application is organized into reusable UI components
- **Context API for State Management**: Uses React's Context API for sharing state across components
- **Office JS API Integration**: Interacts with PowerPoint through the Office JS API
- **Fluent UI Components**: Uses Microsoft's Fluent UI for consistent styling
- **Error Handling**: Centralized error handling through the StatusContext
The add-in is designed to provide various tools for PowerPoint presentations, including shape manipulation, slide formatting, and layout management.
+1 -1
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0" xmlns:ov="http://schemas.microsoft.com/office/taskpaneappversionoverrides" xsi:type="TaskPaneApp"> <OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0" xmlns:ov="http://schemas.microsoft.com/office/taskpaneappversionoverrides" xsi:type="TaskPaneApp">
<Id>be5c6f61-4bbf-4bd2-a8da-5031abf096a5</Id> <Id>be5c6f61-4bbf-4bd2-a8da-5031abf096a5</Id>
<Version>1.0.0.0</Version> <Version>1.1.0.0</Version>
<ProviderName>Contoso</ProviderName> <ProviderName>Contoso</ProviderName>
<DefaultLocale>en-US</DefaultLocale> <DefaultLocale>en-US</DefaultLocale>
<DisplayName DefaultValue="Edison"/> <DisplayName DefaultValue="Edison"/>
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "edison-powerpoint-addin", "name": "edison-powerpoint-addin",
"version": "0.0.1", "version": "1.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "edison-powerpoint-addin", "name": "edison-powerpoint-addin",
"version": "0.0.1", "version": "1.1.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@fluentui/react-components": "^9.55.1", "@fluentui/react-components": "^9.55.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "edison-powerpoint-addin", "name": "edison-powerpoint-addin",
"version": "1.0.0", "version": "1.1.0",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/OfficeDev/Office-Addin-TaskPane-React.git" "url": "https://github.com/OfficeDev/Office-Addin-TaskPane-React.git"
+64
View File
@@ -0,0 +1,64 @@
import * as React from "react";
import { Button, ButtonProps } from "@fluentui/react-components";
import { useStatusContext } from "./App";
import { useCommonStyles } from "./commonStyles";
import { getErrorMessage } from "../types/office-types";
export interface ActionButtonProps {
icon: ButtonProps["icon"];
onClick: () => Promise<void>;
disabled?: boolean;
title: string;
appearance?: "primary" | "secondary" | "outline" | "subtle";
className?: string;
}
/**
* Reusable action button component with consistent styling and error handling
*/
export const ActionButton: React.FC<ActionButtonProps> = ({
icon,
onClick,
disabled = false,
title,
appearance = "primary",
className = "",
}) => {
const styles = useCommonStyles();
const {
setStatusMessage,
setStatusType,
isProcessing,
setIsProcessing
} = useStatusContext();
const handleClick = async () => {
if (isProcessing) return;
setIsProcessing(true);
try {
await onClick();
} catch (error: unknown) {
const errorMessage = getErrorMessage(error);
setStatusMessage(`Error: ${errorMessage}`);
setStatusType("error");
console.error(`Error in ${title} action:`, error);
} finally {
setIsProcessing(false);
}
};
return (
<Button
appearance={appearance}
className={`${styles.actionButton} ${className}`}
onClick={handleClick}
icon={icon}
disabled={disabled || isProcessing}
>
{title}
</Button>
);
};
export default ActionButton;
+143 -151
View File
@@ -1,3 +1,10 @@
/**
* @file AlignmentButtons.tsx
* @description Component that provides functionality to align selected shapes
* in PowerPoint presentations. Supports horizontal alignment (left, center, right)
* and vertical alignment (top, middle, bottom).
*/
import * as React from "react"; import * as React from "react";
import { import {
Button, Button,
@@ -14,6 +21,35 @@ import {
import { useStatusContext } from "./App"; import { useStatusContext } from "./App";
import { useCommonStyles } from "./commonStyles"; import { useCommonStyles } from "./commonStyles";
/**
* Configuration constants for alignment positions
*/
const ALIGNMENT_CONFIG = {
// Horizontal alignment positions
HORIZONTAL: {
LEFT: 81.1,
CENTER: 480, // Center point of slide
RIGHT: 879.75
},
// Vertical alignment positions
VERTICAL: {
TOP: 136.75,
MIDDLE: 321, // Middle point of slide
BOTTOM: 505.25
},
// Warning message when no shapes are selected
NO_SELECTION_WARNING: "No shapes are selected. Please select shapes first."
};
/**
* Alignment types for horizontal and vertical alignment
*/
type HorizontalAlignmentType = 'left' | 'center' | 'right';
type VerticalAlignmentType = 'top' | 'middle' | 'bottom';
/**
* Component-specific styles
*/
const useStyles = makeStyles({ const useStyles = makeStyles({
alignmentGrid: { alignmentGrid: {
display: "grid", display: "grid",
@@ -31,16 +67,41 @@ const useStyles = makeStyles({
} }
}); });
/**
* AlignmentButtons component provides UI and functionality to align
* selected shapes in PowerPoint presentations.
*
* @returns React component
*/
export const AlignmentButtons: React.FC = () => { export const AlignmentButtons: React.FC = () => {
const styles = useStyles(); const styles = useStyles();
const commonStyles = useCommonStyles(); const commonStyles = useCommonStyles();
const { const {
statusMessage, setStatusMessage, setStatusMessage,
statusType, setStatusType, setStatusType,
isProcessing, setIsProcessing isProcessing,
setIsProcessing
} = useStatusContext(); } = useStatusContext();
const alignLeft = async () => { /**
* Handles errors and updates the status message
*
* @param error - The error object
* @param operation - The operation being performed (for logging)
*/
const handleError = (error: any, operation: string): void => {
setStatusMessage(`Error: ${error.message || "Unknown error occurred"}`);
setStatusType("error");
console.error(`${operation} error:`, error);
setIsProcessing(false);
};
/**
* Generic function to handle horizontal alignment of shapes
*
* @param alignmentType - The type of horizontal alignment to apply
*/
const handleHorizontalAlignment = async (alignmentType: HorizontalAlignmentType): Promise<void> => {
setIsProcessing(true); setIsProcessing(true);
try { try {
await PowerPoint.run(async (context) => { await PowerPoint.run(async (context) => {
@@ -51,30 +112,45 @@ export const AlignmentButtons: React.FC = () => {
// Check if shapes are selected // Check if shapes are selected
if (shapes.items.length === 0) { if (shapes.items.length === 0) {
setStatusMessage("No shapes are selected. Please select shapes first."); setStatusMessage(ALIGNMENT_CONFIG.NO_SELECTION_WARNING);
setStatusType("warning"); setStatusType("warning");
return; return;
} }
// Loop through all select shapes and align them // Set alignment for all shapes at once based on alignment type
for (let i = 0; i < shapes.items.length; i++) { shapes.items.forEach(shape => {
shapes.items[i].left = 81.1; switch (alignmentType) {
} case 'left':
shape.left = ALIGNMENT_CONFIG.HORIZONTAL.LEFT;
break;
case 'center':
shape.left = ALIGNMENT_CONFIG.HORIZONTAL.CENTER - (shape.width / 2);
break;
case 'right':
shape.left = ALIGNMENT_CONFIG.HORIZONTAL.RIGHT - shape.width;
break;
}
});
// Single sync after all updates
await context.sync(); await context.sync();
setStatusMessage("Objects aligned to left."); setStatusMessage(`Objects aligned to ${alignmentType}.`);
setStatusType("success"); setStatusType("success");
}); });
} catch (error) { } catch (error) {
setStatusMessage(`Error: ${error.message}`); handleError(error, `Align ${alignmentType}`);
setStatusType("error");
console.error("Align left error:", error);
} finally { } finally {
setIsProcessing(false); setIsProcessing(false);
} }
}; };
const alignCenter = async () => { /**
* Generic function to handle vertical alignment of shapes
*
* @param alignmentType - The type of vertical alignment to apply
*/
const handleVerticalAlignment = async (alignmentType: VerticalAlignmentType): Promise<void> => {
setIsProcessing(true); setIsProcessing(true);
try { try {
await PowerPoint.run(async (context) => { await PowerPoint.run(async (context) => {
@@ -85,163 +161,79 @@ export const AlignmentButtons: React.FC = () => {
// Check if shapes are selected // Check if shapes are selected
if (shapes.items.length === 0) { if (shapes.items.length === 0) {
setStatusMessage("No shapes are selected. Please select shapes first."); setStatusMessage(ALIGNMENT_CONFIG.NO_SELECTION_WARNING);
setStatusType("warning"); setStatusType("warning");
return; return;
} }
// Loop through all select shapes and align them // Set alignment for all shapes at once based on alignment type
for (let i = 0; i < shapes.items.length; i++) { shapes.items.forEach(shape => {
shapes.items[i].left = 480 - (shapes.items[i].width / 2); switch (alignmentType) {
} case 'top':
shape.top = ALIGNMENT_CONFIG.VERTICAL.TOP;
break;
case 'middle':
shape.top = ALIGNMENT_CONFIG.VERTICAL.MIDDLE - (shape.height / 2);
break;
case 'bottom':
shape.top = ALIGNMENT_CONFIG.VERTICAL.BOTTOM - shape.height;
break;
}
});
// Single sync after all updates
await context.sync(); await context.sync();
setStatusMessage("Objects aligned to center."); setStatusMessage(`Objects aligned to ${alignmentType}.`);
setStatusType("success"); setStatusType("success");
}); });
} catch (error) { } catch (error) {
setStatusMessage(`Error: ${error.message}`); handleError(error, `Align ${alignmentType}`);
setStatusType("error");
console.error("Align center error:", error);
} finally { } finally {
setIsProcessing(false); setIsProcessing(false);
} }
}; };
const alignRight = async () => { /**
setIsProcessing(true); * Aligns selected shapes to the left edge of the slide
try { */
await PowerPoint.run(async (context) => { const alignLeft = async (): Promise<void> => {
// Get the selected shapes await handleHorizontalAlignment('left');
const shapes = context.presentation.getSelectedShapes();
shapes.load("items");
await context.sync();
// Check if shapes are selected
if (shapes.items.length === 0) {
setStatusMessage("No shapes are selected. Please select shapes first.");
setStatusType("warning");
return;
}
// Loop through all select shapes and align them
for (let i = 0; i < shapes.items.length; i++) {
shapes.items[i].left = 879.75 - shapes.items[i].width;
}
await context.sync();
setStatusMessage("Objects aligned to right.");
setStatusType("success");
});
} catch (error) {
setStatusMessage(`Error: ${error.message}`);
setStatusType("error");
console.error("Align right error:", error);
} finally {
setIsProcessing(false);
}
}; };
const alignTop = async () => { /**
setIsProcessing(true); * Aligns selected shapes to the horizontal center of the slide
try { */
await PowerPoint.run(async (context) => { const alignCenter = async (): Promise<void> => {
// Get the selected shapes await handleHorizontalAlignment('center');
const shapes = context.presentation.getSelectedShapes();
shapes.load("items");
await context.sync();
// Check if shapes are selected
if (shapes.items.length === 0) {
setStatusMessage("No shapes are selected. Please select shapes first.");
setStatusType("warning");
return;
}
// Loop through all select shapes and align them
for (let i = 0; i < shapes.items.length; i++) {
shapes.items[i].top = 136.75;
}
await context.sync();
setStatusMessage("Objects aligned to top.");
setStatusType("success");
});
} catch (error) {
setStatusMessage(`Error: ${error.message}`);
setStatusType("error");
console.error("Align top error:", error);
} finally {
setIsProcessing(false);
}
}; };
const alignMiddle = async () => { /**
setIsProcessing(true); * Aligns selected shapes to the right edge of the slide
try { */
await PowerPoint.run(async (context) => { const alignRight = async (): Promise<void> => {
// Get the selected shapes await handleHorizontalAlignment('right');
const shapes = context.presentation.getSelectedShapes();
shapes.load("items");
await context.sync();
// Check if shapes are selected
if (shapes.items.length === 0) {
setStatusMessage("No shapes are selected. Please select shapes first.");
setStatusType("warning");
return;
}
// Loop through all select shapes and align them
for (let i = 0; i < shapes.items.length; i++) {
shapes.items[i].top = 321 - (shapes.items[i].height / 2);
}
await context.sync();
setStatusMessage("Objects aligned to middle.");
setStatusType("success");
});
} catch (error) {
setStatusMessage(`Error: ${error.message}`);
setStatusType("error");
console.error("Align middle error:", error);
} finally {
setIsProcessing(false);
}
}; };
const alignBottom = async () => { /**
setIsProcessing(true); * Aligns selected shapes to the top edge of the slide
try { */
await PowerPoint.run(async (context) => { const alignTop = async (): Promise<void> => {
// Get the selected shapes await handleVerticalAlignment('top');
const shapes = context.presentation.getSelectedShapes(); };
shapes.load("items");
await context.sync();
// Check if shapes are selected /**
if (shapes.items.length === 0) { * Aligns selected shapes to the vertical middle of the slide
setStatusMessage("No shapes are selected. Please select shapes first."); */
setStatusType("warning"); const alignMiddle = async (): Promise<void> => {
return; await handleVerticalAlignment('middle');
} };
// Loop through all select shapes and align them /**
for (let i = 0; i < shapes.items.length; i++) { * Aligns selected shapes to the bottom edge of the slide
shapes.items[i].top = 505.25 - shapes.items[i].height; */
} const alignBottom = async (): Promise<void> => {
await context.sync(); await handleVerticalAlignment('bottom');
setStatusMessage("Objects aligned to bottom.");
setStatusType("success");
});
} catch (error) {
setStatusMessage(`Error: ${error.message}`);
setStatusType("error");
console.error("Align bottom error:", error);
} finally {
setIsProcessing(false);
}
}; };
return ( return (
@@ -312,4 +304,4 @@ export const AlignmentButtons: React.FC = () => {
); );
}; };
export default AlignmentButtons; export default AlignmentButtons;
+27 -50
View File
@@ -1,3 +1,10 @@
/**
* @file App.tsx
* @description Main application component for the PowerPoint toolbox add-in.
* This component serves as the container for all tool components and provides
* a context for status messages and processing state that can be used by child components.
*/
import * as React from "react"; import * as React from "react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import MatchSizes from "./MatchSizes"; import MatchSizes from "./MatchSizes";
@@ -10,7 +17,8 @@ import DraftButtons from "./DraftButtons";
import ProgressBarButtons from "./ProgressBarButtons"; import ProgressBarButtons from "./ProgressBarButtons";
import AlignmentButtons from "./AlignmentButtons"; import AlignmentButtons from "./AlignmentButtons";
import GridGuidelineManager from "./GridGuidelineManager"; import GridGuidelineManager from "./GridGuidelineManager";
import { makeStyles, Text, Subtitle1, tokens, Theme, Spinner } from "@fluentui/react-components"; import Section from "./Section";
import { makeStyles, Text, tokens, Theme, Spinner } from "@fluentui/react-components";
import { ShapeUnionRegular, SquareRegular, InfoRegular } from "@fluentui/react-icons"; import { ShapeUnionRegular, SquareRegular, InfoRegular } from "@fluentui/react-icons";
interface AppProps { interface AppProps {
@@ -42,12 +50,15 @@ const useStyles = makeStyles({
root: { root: {
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
height: "100%",
padding: "16px", padding: "16px",
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif", fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
backgroundColor: tokens.colorNeutralBackground1, backgroundColor: tokens.colorNeutralBackground1,
overflow: "auto", overflow: "auto",
maxHeight: "100vh", position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
}, },
statusContainer: { statusContainer: {
marginTop: "4px", marginTop: "4px",
@@ -76,22 +87,6 @@ const useStyles = makeStyles({
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
}, },
section: {
marginBottom: "6px",
padding: "12px",
borderRadius: "8px",
backgroundColor: tokens.colorNeutralBackground2,
boxShadow: "0 1px 2px rgba(0, 0, 0, 0.06)",
transition: "all 0.2s ease",
":hover": {
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.1)",
},
},
sectionTitle: {
marginBottom: "12px",
fontWeight: tokens.fontWeightSemibold,
color: tokens.colorBrandForeground1,
},
footer: { footer: {
fontSize: "12px", fontSize: "12px",
color: tokens.colorNeutralForeground3, color: tokens.colorNeutralForeground3,
@@ -153,10 +148,7 @@ const App: React.FC<AppProps> = () => {
setIsProcessing setIsProcessing
}}> }}>
<div className={styles.root} data-theme={theme}> <div className={styles.root} data-theme={theme}>
<div className={styles.section}> <Section title="Content">
<Subtitle1 block className={styles.sectionTitle}>
Content
</Subtitle1>
<MatchProperties /> <MatchProperties />
<div style={{ marginTop: "8px" }}></div> <div style={{ marginTop: "8px" }}></div>
<MatchSizes /> <MatchSizes />
@@ -166,42 +158,27 @@ const App: React.FC<AppProps> = () => {
<InsertTitles /> <InsertTitles />
<div style={{ marginTop: "8px" }}></div> <div style={{ marginTop: "8px" }}></div>
<RoundImage /> <RoundImage />
</div> </Section>
<div className={styles.section}> <Section title="Progress Bar">
<Subtitle1 block className={styles.sectionTitle}>
Progress Bar
</Subtitle1>
<ProgressBarButtons /> <ProgressBarButtons />
</div> </Section>
<div className={styles.section}> <Section title="Confidential Marking">
<Subtitle1 block className={styles.sectionTitle}>
Confidential Marking
</Subtitle1>
<ConfidentialButtons /> <ConfidentialButtons />
</div> </Section>
<div className={styles.section}> <Section title="Draft Watermark">
<Subtitle1 block className={styles.sectionTitle}>
Draft Watermark
</Subtitle1>
<DraftButtons /> <DraftButtons />
</div> </Section>
<div className={styles.section}> <Section title="Alignment">
<Subtitle1 block className={styles.sectionTitle}>
Alignment
</Subtitle1>
<AlignmentButtons /> <AlignmentButtons />
</div> </Section>
<div className={styles.section}> <Section title="Layout">
<Subtitle1 block className={styles.sectionTitle}>
Layout
</Subtitle1>
<GridGuidelineManager /> <GridGuidelineManager />
</div> </Section>
{/* Status message area at the bottom */} {/* Status message area at the bottom */}
{isProcessing && ( {isProcessing && (
@@ -223,7 +200,7 @@ const App: React.FC<AppProps> = () => {
)} )}
<div className={styles.footer}> <div className={styles.footer}>
<Text size={100}>Edison v1.0.0</Text> <Text size={100}>Edison v1.2.0-dev</Text>
</div> </div>
</div> </div>
</StatusContext.Provider> </StatusContext.Provider>
+266 -187
View File
@@ -1,3 +1,9 @@
/**
* @file ConfidentialButtons.tsx
* @description Component that provides functionality to add and remove confidential markings
* to PowerPoint slides. The markings appear as text at the bottom of each slide.
*/
import * as React from "react"; import * as React from "react";
import { import {
Button, Button,
@@ -10,6 +16,35 @@ import {
import { useStatusContext } from "./App"; import { useStatusContext } from "./App";
import { useCommonStyles } from "./commonStyles"; import { useCommonStyles } from "./commonStyles";
/**
* Configuration constants for confidential markings
*/
const CONFIDENTIAL_CONFIG = {
// Marking text that appears on slides
TEXT: " Confidential ",
// Shape name used to identify confidential markings
SHAPE_NAME: "ConfidentialMarking",
// Font configuration
FONT: {
NAME: "Inter",
SIZE: 8,
COLOR: "#DA1335" // RGB(218, 19, 53)
},
// Default slide dimensions in points (if we can't get actual dimensions)
SLIDE: {
WIDTH: 960, // 10 inches * 72 points
HEIGHT: 540, // 7.5 inches * 72 points
},
// Text box configuration
TEXT_BOX: {
HEIGHT: 25,
POSITION_FROM_BOTTOM: 23
}
};
/**
* Component-specific styles
*/
const useStyles = makeStyles({ const useStyles = makeStyles({
buttonGrid: { buttonGrid: {
display: "grid", display: "grid",
@@ -27,220 +62,264 @@ const useStyles = makeStyles({
} }
}); });
/**
* Interface for slide processing results
*/
interface ProcessingResult {
processedSlides: number;
errorSlides: number;
affectedCount: number; // Either added or removed markings
}
/**
* ConfidentialButtons component provides UI and functionality to add or remove
* confidential markings on PowerPoint slides.
*
* @returns React component
*/
export const ConfidentialButtons: React.FC = () => { export const ConfidentialButtons: React.FC = () => {
const styles = useStyles(); const styles = useStyles();
const commonStyles = useCommonStyles(); const commonStyles = useCommonStyles();
const { const {
statusMessage, setStatusMessage, setStatusMessage,
statusType, setStatusType, setStatusType,
isProcessing, setIsProcessing isProcessing,
setIsProcessing
} = useStatusContext(); } = useStatusContext();
const addConfidentialMarking = async () => { /**
* Handles errors and updates the status message
*
* @param error - The error object
* @param operation - The operation being performed (for logging)
*/
const handleError = (error: any, operation: string): void => {
setStatusMessage(`Error: ${error.message || "Unknown error occurred"}`);
setStatusType("error");
console.error(`${operation} error:`, error);
setIsProcessing(false);
};
/**
* Updates the status message based on processing results
*
* @param result - The processing result object
* @param isAddOperation - Whether this was an add operation (true) or remove operation (false)
*/
const updateStatusFromResult = (result: ProcessingResult, isAddOperation: boolean): void => {
const { processedSlides, errorSlides, affectedCount } = result;
if (affectedCount > 0) {
const action = isAddOperation ? "Added" : "Removed";
setStatusMessage(`${action} confidential marking to ${affectedCount} slides.`);
setStatusType("success");
} else if (errorSlides > 0) {
setStatusMessage(`Failed to ${isAddOperation ? "add" : "remove"} markings. Errors on ${errorSlides} slides.`);
setStatusType("error");
} else {
if (isAddOperation) {
setStatusMessage("No slides found to add confidential marking.");
} else {
setStatusMessage("No confidential markings found to remove.");
}
setStatusType(isAddOperation ? "warning" : "info");
}
};
/**
* Adds confidential markings to all slides in the presentation
*/
const addConfidentialMarking = async (): Promise<void> => {
setIsProcessing(true); setIsProcessing(true);
try { try {
await PowerPoint.run(async (context) => { await PowerPoint.run(async (context) => {
try { // Get all slides in the presentation
// Get all slides in the presentation const slides = context.presentation.slides;
const slides = context.presentation.slides; slides.load("items");
slides.load("items"); await context.sync();
await context.sync();
if (slides.items.length === 0) {
// Get a reference slide to determine dimensions setStatusMessage("No slides found in the presentation.");
// Since we can't access presentation width/height directly setStatusType("warning");
if (slides.items.length === 0) { return;
setStatusMessage("No slides found in the presentation.");
setStatusType("warning");
return;
}
// Get dimensions from the first slide
const firstSlide = slides.items[0];
// Standard PowerPoint slide dimensions (in points)
// We'll use these as fallbacks and adjust if we can get actual dimensions
let slideWidth = 960; // Default slide width (10 inches * 72 points)
let slideHeight = 540; // Default slide height (7.5 inches * 72 points)
// Process counter
let processedSlides = 0;
let errorSlides = 0;
// Process each slide
for (let i = 0; i < slides.items.length; i++) {
try {
const slide = slides.items[i];
const positionFromTop = slideHeight - 23;
// Create the textbox - make sure it spans the full width of the slide
// This is important for proper centering
const textBox = slide.shapes.addTextBox("");
// Make it span most of the width of the slide (90% centered)
// This ensures we have room for the text to be centered
const textBoxWidth = slideWidth * 1; // 0.9
textBox.left = (slideWidth - textBoxWidth) / 2; // Center it horizontally
textBox.top = positionFromTop;
textBox.width = textBoxWidth;
textBox.height = 25; // Smaller height for footer text
await context.sync();
// Load textFrame to set text properties
textBox.load("textFrame");
await context.sync();
if (textBox.textFrame) {
// Load textRange to set text and properties
textBox.textFrame.load("textRange");
await context.sync();
// Need to load font and paragraphFormat
textBox.textFrame.textRange.load("font,paragraphFormat");
await context.sync();
// Set font properties
try {
// Ensure the font is loaded properly before setting properties
textBox.textFrame.textRange.font.load();
await context.sync();
// Set font properties exactly as in the VBA code
textBox.textFrame.textRange.font.name = "Inter";
textBox.textFrame.textRange.font.size = 8;
textBox.textFrame.textRange.paragraphFormat.horizontalAlignment = "Center"
// Set the color to RGB(218, 19, 53)
// Different APIs may need different color formats
textBox.textFrame.textRange.font.color = "#DA1335";
// Set the text
textBox.textFrame.textRange.text = " Confidential ";
} catch (fontError) {
console.error("Error setting font properties:", fontError);
// Even if we can't set the font properties exactly, continue with default font
}
// Add a name/tag to the shape for identification
textBox.name = "ConfidentialMarking";
await context.sync();
processedSlides++;
}
} catch (slideError) {
console.error(`Error processing slide ${i+1}:`, slideError);
errorSlides++;
// Continue to the next slide
continue;
}
}
// Report results
if (processedSlides > 0) {
setStatusMessage(`Added confidential marking to ${processedSlides} slides.`);
setStatusType("success");
} else if (errorSlides > 0) {
setStatusMessage(`Failed to add markings. Errors on ${errorSlides} slides.`);
setStatusType("error");
} else {
setStatusMessage("No slides found to add confidential marking.");
setStatusType("warning");
}
} catch (innerError) {
console.error("Inner error:", innerError);
throw innerError; // Re-throw to outer catch
} }
const result = await processAddMarkings(context, slides.items);
updateStatusFromResult(result, true);
}); });
} catch (error) { } catch (error) {
setStatusMessage(`Error: ${error.message}`); handleError(error, "Add confidential");
setStatusType("error");
console.error("Add confidential error:", error);
} finally { } finally {
setIsProcessing(false); setIsProcessing(false);
} }
}; };
const removeConfidentialMarking = async () => { /**
* Processes all slides to add confidential markings
*
* @param context - The PowerPoint API context
* @param slides - Array of slides to process
* @returns Processing result with counts of processed and error slides
*/
const processAddMarkings = async (
context: PowerPoint.RequestContext,
slides: PowerPoint.Slide[]
): Promise<ProcessingResult> => {
const result: ProcessingResult = {
processedSlides: 0,
errorSlides: 0,
affectedCount: 0
};
const { WIDTH, HEIGHT } = CONFIDENTIAL_CONFIG.SLIDE;
const slideWidth = WIDTH;
const slideHeight = HEIGHT;
// Process each slide
for (let i = 0; i < slides.length; i++) {
try {
const slide = slides[i];
const positionFromTop = slideHeight - CONFIDENTIAL_CONFIG.TEXT_BOX.POSITION_FROM_BOTTOM;
// Create the textbox spanning the full width of the slide
const textBox = slide.shapes.addTextBox("");
textBox.left = 0; // Start from left edge
textBox.top = positionFromTop;
textBox.width = slideWidth;
textBox.height = CONFIDENTIAL_CONFIG.TEXT_BOX.HEIGHT;
textBox.name = CONFIDENTIAL_CONFIG.SHAPE_NAME;
// Load textFrame and its properties in a single batch
textBox.load("textFrame");
await context.sync();
if (textBox.textFrame) {
// Load textRange and its properties
textBox.textFrame.load("textRange");
await context.sync();
// Set text and formatting in a single batch
textBox.textFrame.textRange.text = CONFIDENTIAL_CONFIG.TEXT;
textBox.textFrame.textRange.load("font,paragraphFormat");
await context.sync();
try {
// Apply font properties in a single batch
const font = textBox.textFrame.textRange.font;
font.name = CONFIDENTIAL_CONFIG.FONT.NAME;
font.size = CONFIDENTIAL_CONFIG.FONT.SIZE;
font.color = CONFIDENTIAL_CONFIG.FONT.COLOR;
textBox.textFrame.textRange.paragraphFormat.horizontalAlignment = "Center";
await context.sync();
result.affectedCount++;
} catch (fontError) {
console.error("Error setting font properties:", fontError);
// Continue with default font if custom font fails
}
}
result.processedSlides++;
} catch (slideError) {
console.error(`Error processing slide ${i+1}:`, slideError);
result.errorSlides++;
// Continue to the next slide
}
}
return result;
};
/**
* Removes confidential markings from all slides in the presentation
*/
const removeConfidentialMarking = async (): Promise<void> => {
setIsProcessing(true); setIsProcessing(true);
try { try {
await PowerPoint.run(async (context) => { await PowerPoint.run(async (context) => {
try { // Get all slides in the presentation
// Get all slides in the presentation const slides = context.presentation.slides;
const slides = context.presentation.slides; slides.load("items");
slides.load("items"); await context.sync();
await context.sync();
if (slides.items.length === 0) {
if (slides.items.length === 0) { setStatusMessage("No slides found in the presentation.");
setStatusMessage("No slides found in the presentation."); setStatusType("warning");
setStatusType("warning"); return;
return;
}
// Process counter
let processedSlides = 0;
let errorSlides = 0;
let removedCount = 0;
// Process each slide
for (let i = 0; i < slides.items.length; i++) {
try {
const slide = slides.items[i];
// Load all shapes on the slide
slide.shapes.load("items");
await context.sync();
// Find shapes with name "ConfidentialMarking"
for (let j = 0; j < slide.shapes.items.length; j++) {
const shape = slide.shapes.items[j];
shape.load("name");
await context.sync();
if (shape.name === "ConfidentialMarking") {
// Delete the confidential marking shape
shape.delete();
removedCount++;
}
}
await context.sync();
processedSlides++;
} catch (slideError) {
console.error(`Error processing slide ${i+1}:`, slideError);
errorSlides++;
// Continue to the next slide
continue;
}
}
// Report results
if (removedCount > 0) {
setStatusMessage(`Removed ${removedCount} confidential markings from ${processedSlides} slides.`);
setStatusType("success");
} else if (errorSlides > 0) {
setStatusMessage(`Failed to remove markings. Errors on ${errorSlides} slides.`);
setStatusType("error");
} else {
setStatusMessage("No confidential markings found to remove.");
setStatusType("info");
}
} catch (innerError) {
console.error("Inner error:", innerError);
throw innerError; // Re-throw to outer catch
} }
const result = await processRemoveMarkings(context, slides.items);
updateStatusFromResult(result, false);
}); });
} catch (error) { } catch (error) {
setStatusMessage(`Error: ${error.message}`); handleError(error, "Remove confidential");
setStatusType("error");
console.error("Remove confidential error:", error);
} finally { } finally {
setIsProcessing(false); setIsProcessing(false);
} }
}; };
/**
* Processes all slides to remove confidential markings
*
* @param context - The PowerPoint API context
* @param slides - Array of slides to process
* @returns Processing result with counts of processed and affected slides
*/
const processRemoveMarkings = async (
context: PowerPoint.RequestContext,
slides: PowerPoint.Slide[]
): Promise<ProcessingResult> => {
const result: ProcessingResult = {
processedSlides: 0,
errorSlides: 0,
affectedCount: 0
};
// Process each slide
for (let i = 0; i < slides.length; i++) {
try {
const slide = slides[i];
// Load all shapes on the slide
slide.shapes.load("items");
await context.sync();
// Collect shapes to delete
const shapesToDelete: PowerPoint.Shape[] = [];
// Find shapes with the confidential marking name
for (let j = 0; j < slide.shapes.items.length; j++) {
const shape = slide.shapes.items[j];
shape.load("name");
}
// Wait for all shape names to load
await context.sync();
// Now check names and collect shapes to delete
for (let j = 0; j < slide.shapes.items.length; j++) {
const shape = slide.shapes.items[j];
if (shape.name === CONFIDENTIAL_CONFIG.SHAPE_NAME) {
shapesToDelete.push(shape);
}
}
// Delete all matching shapes in one batch
shapesToDelete.forEach(shape => shape.delete());
result.affectedCount += shapesToDelete.length;
await context.sync();
result.processedSlides++;
} catch (slideError) {
console.error(`Error processing slide ${i+1}:`, slideError);
result.errorSlides++;
// Continue to the next slide
}
}
return result;
};
return ( return (
<div className={commonStyles.container}> <div className={commonStyles.container}>
<div className={styles.buttonGrid}> <div className={styles.buttonGrid}>
@@ -269,4 +348,4 @@ export const ConfidentialButtons: React.FC = () => {
); );
}; };
export default ConfidentialButtons; export default ConfidentialButtons;
+268 -168
View File
@@ -1,3 +1,10 @@
/**
* @file DraftButtons.tsx
* @description Component that provides functionality to add and remove draft watermarks
* on PowerPoint master slides. The watermarks appear as large, light-colored text
* across the slide background.
*/
import * as React from "react"; import * as React from "react";
import { import {
Button, Button,
@@ -10,6 +17,42 @@ import {
import { useStatusContext } from "./App"; import { useStatusContext } from "./App";
import { useCommonStyles } from "./commonStyles"; import { useCommonStyles } from "./commonStyles";
/**
* Configuration constants for draft watermarks
*/
const DRAFT_CONFIG = {
// Shape name used to identify draft watermarks
SHAPE_NAME: "DraftWatermark",
// Font configuration
FONT: {
NAME: "Inter",
SIZE: 54,
COLOR: "#FFE9E8" // Light pink RGB(255, 233, 232)
},
// Text box configuration
TEXT_BOX: {
LEFT: -330,
TOP: 32,
WIDTH: 2400,
HEIGHT: 540
},
// Watermark text (repeated pattern)
TEXT: [
"DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT",
"DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT",
"DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT",
"DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT",
"DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT",
"DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT",
"DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT",
"DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT",
"DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT"
].join("\n")
};
/**
* Component-specific styles
*/
const useStyles = makeStyles({ const useStyles = makeStyles({
buttonGrid: { buttonGrid: {
display: "grid", display: "grid",
@@ -27,16 +70,78 @@ const useStyles = makeStyles({
} }
}); });
/**
* Interface for master slide processing results
*/
interface ProcessingResult {
processedMasters: number;
errorMasters: number;
affectedCount: number; // Either added or removed watermarks
}
/**
* DraftButtons component provides UI and functionality to add or remove
* draft watermarks on PowerPoint master slides.
*
* @returns React component
*/
export const DraftButtons: React.FC = () => { export const DraftButtons: React.FC = () => {
const styles = useStyles(); const styles = useStyles();
const commonStyles = useCommonStyles(); const commonStyles = useCommonStyles();
const { const {
statusMessage, setStatusMessage, setStatusMessage,
statusType, setStatusType, setStatusType,
isProcessing, setIsProcessing isProcessing,
setIsProcessing
} = useStatusContext(); } = useStatusContext();
const addDraftWatermark = async () => { /**
* Handles errors and updates the status message
*
* @param error - The error object
* @param operation - The operation being performed (for logging)
*/
const handleError = (error: any, operation: string): void => {
setStatusMessage(`Error: ${error.message || "Unknown error occurred"}`);
setStatusType("error");
console.error(`${operation} error:`, error);
setIsProcessing(false);
};
/**
* Updates the status message based on processing results
*
* @param result - The processing result object
* @param isAddOperation - Whether this was an add operation (true) or remove operation (false)
*/
const updateStatusFromResult = (result: ProcessingResult, isAddOperation: boolean): void => {
const { processedMasters, errorMasters, affectedCount } = result;
if (affectedCount > 0) {
const action = isAddOperation ? "Added" : "Removed";
const pluralWatermark = affectedCount > 1 ? 's' : '';
const pluralMasters = processedMasters > 1 ? 's' : '';
setStatusMessage(`${action} draft watermark${pluralWatermark} to ${processedMasters} master slide${pluralMasters}.`);
setStatusType("success");
} else if (errorMasters > 0) {
const pluralMasters = errorMasters > 1 ? 's' : '';
setStatusMessage(`Failed to ${isAddOperation ? "add" : "remove"} watermarks. Errors on ${errorMasters} master slide${pluralMasters}.`);
setStatusType("error");
} else {
if (isAddOperation) {
setStatusMessage("No master slides found to add draft watermark.");
} else {
setStatusMessage("No draft watermark found to remove.");
}
setStatusType(isAddOperation ? "warning" : "info");
}
};
/**
* Adds draft watermarks to all master slides in the presentation
*/
const addDraftWatermark = async (): Promise<void> => {
setIsProcessing(true); setIsProcessing(true);
try { try {
await PowerPoint.run(async (context) => { await PowerPoint.run(async (context) => {
@@ -51,187 +156,182 @@ export const DraftButtons: React.FC = () => {
return; return;
} }
// Process counter const result = await processAddWatermarks(context, masters.items);
let processedMasters = 0; updateStatusFromResult(result, true);
let errorMasters = 0;
// Process each slide master (usually there's just one)
for (let i = 0; i < masters.items.length; i++) {
try {
const master = masters.items[i];
// Create the textbox on the master slide
const textBox = master.shapes.addTextBox("");
// textBox.left = 0; // Center it horizontally
textBox.left = -330
textBox.top = 32;
// textBox.width = 960;
textBox.width = 2400;
textBox.height = 540;
await context.sync();
// Load textFrame to set text properties
textBox.load("textFrame");
await context.sync();
if (textBox.textFrame) {
// Load textRange to set text and properties
textBox.textFrame.load("textRange");
await context.sync();
// Need to load font and paragraphFormat
textBox.textFrame.textRange.load("font,paragraphFormat");
await context.sync();
// Set font properties
try {
// Ensure the font is loaded properly before setting properties
textBox.textFrame.textRange.font.load();
await context.sync();
// Set font properties exactly as in the VBA code
textBox.textFrame.textRange.font.name = "Inter";
// textBox.textFrame.textRange.font.size = 256;
textBox.textFrame.textRange.font.size = 54;
textBox.textFrame.textRange.font.bold = true;
textBox.textFrame.verticalAlignment = "MiddleCentered"
// Set the color to RGB(255, 233, 232)
// Different APIs may need different color formats
textBox.textFrame.textRange.font.color = "#FFE9E8";
// Set the text
textBox.textFrame.textRange.text =
"DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT\n" +
"DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT\n" +
"DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT\n" +
"DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT\n" +
"DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT\n" +
"DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT\n" +
"DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT\n" +
"DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT\n" +
"DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT\n";
} catch (fontError) {
console.error("Error setting font properties:", fontError);
// Even if we can't set the font properties exactly, continue with default font
}
// Add a name/tag to the shape for identification
textBox.name = "DraftWatermark";
await context.sync();
processedMasters++;
}
} catch (masterError) {
console.error(`Error processing master ${i+1}:`, masterError);
errorMasters++;
// Continue to the next master
continue;
}
}
// Report results
if (processedMasters > 0) {
setStatusMessage(`Added draft watermark to ${processedMasters} master slide${processedMasters > 1 ? 's' : ''}.`);
setStatusType("success");
} else if (errorMasters > 0) {
setStatusMessage(`Failed to add markings. Errors on ${errorMasters} master slide${errorMasters > 1 ? 's' : ''}.`);
setStatusType("error");
} else {
setStatusMessage("No master slides found to add draft watermark.");
setStatusType("warning");
}
}); });
} catch (error) { } catch (error) {
setStatusMessage(`Error: ${error.message}`); handleError(error, "Add draft watermark");
setStatusType("error");
console.error("Add draft watermark error:", error);
} finally { } finally {
setIsProcessing(false); setIsProcessing(false);
} }
}; };
const removeDraftWatermark = async () => { /**
* Processes all master slides to add draft watermarks
*
* @param context - The PowerPoint API context
* @param masters - Array of master slides to process
* @returns Processing result with counts of processed and error slides
*/
const processAddWatermarks = async (
context: PowerPoint.RequestContext,
masters: PowerPoint.SlideMaster[]
): Promise<ProcessingResult> => {
const result: ProcessingResult = {
processedMasters: 0,
errorMasters: 0,
affectedCount: 0
};
// Process each slide master (usually there's just one)
for (let i = 0; i < masters.length; i++) {
try {
const master = masters[i];
// Create the textbox on the master slide
const textBox = master.shapes.addTextBox("");
// Set position and size in a single batch
textBox.left = DRAFT_CONFIG.TEXT_BOX.LEFT;
textBox.top = DRAFT_CONFIG.TEXT_BOX.TOP;
textBox.width = DRAFT_CONFIG.TEXT_BOX.WIDTH;
textBox.height = DRAFT_CONFIG.TEXT_BOX.HEIGHT;
textBox.name = DRAFT_CONFIG.SHAPE_NAME;
// Load textFrame to set text properties
textBox.load("textFrame");
await context.sync();
if (textBox.textFrame) {
// Load textRange to set text and properties
textBox.textFrame.load("textRange");
await context.sync();
// Set text and load font properties in a single batch
textBox.textFrame.textRange.text = DRAFT_CONFIG.TEXT;
textBox.textFrame.textRange.load("font,paragraphFormat");
await context.sync();
try {
// Apply all font properties in a single batch
const font = textBox.textFrame.textRange.font;
font.name = DRAFT_CONFIG.FONT.NAME;
font.size = DRAFT_CONFIG.FONT.SIZE;
font.bold = true;
font.color = DRAFT_CONFIG.FONT.COLOR;
// Set alignment
textBox.textFrame.verticalAlignment = "MiddleCentered";
await context.sync();
result.affectedCount++;
} catch (fontError) {
console.error("Error setting font properties:", fontError);
// Continue with default font if custom font fails
}
}
result.processedMasters++;
} catch (masterError) {
console.error(`Error processing master ${i+1}:`, masterError);
result.errorMasters++;
// Continue to the next master
}
}
return result;
};
/**
* Removes draft watermarks from all master slides in the presentation
*/
const removeDraftWatermark = async (): Promise<void> => {
setIsProcessing(true); setIsProcessing(true);
try { try {
await PowerPoint.run(async (context) => { await PowerPoint.run(async (context) => {
try { // Get the slide masters collection
// Get the slide masters collection const masters = context.presentation.slideMasters;
const masters = context.presentation.slideMasters; masters.load("items");
masters.load("items"); await context.sync();
await context.sync();
if (masters.items.length === 0) {
if (masters.items.length === 0) { setStatusMessage("Could not access slide masters.");
setStatusMessage("Could not access slide masters."); setStatusType("error");
setStatusType("error"); return;
return;
}
// Process counter
let processedMasters = 0;
let errorMasters = 0;
let removedCount = 0;
// Process each master slide
for (let i = 0; i < masters.items.length; i++) {
try {
const master = masters.items[i];
// Load all shapes on the master slide
master.shapes.load("items");
await context.sync();
// Find shapes with name "DraftWatermark"
for (let j = 0; j < master.shapes.items.length; j++) {
const shape = master.shapes.items[j];
shape.load("name");
await context.sync();
if (shape.name === "DraftWatermark") {
// Delete the draft watermark shape
shape.delete();
removedCount++;
}
}
await context.sync();
processedMasters++;
} catch (masterError) {
console.error(`Error processing master slide ${i+1}:`, masterError);
errorMasters++;
// Continue to the next master slide
continue;
}
}
// Report results
if (removedCount > 0) {
setStatusMessage(`Removed ${removedCount} draft watermark${removedCount > 1 ? 's' : ''} from ${processedMasters} master slide${processedMasters > 1 ? 's' : ''}.`);
setStatusType("success");
} else if (errorMasters > 0) {
setStatusMessage(`Failed to remove draft watermarks. Errors on ${errorMasters} master slide${errorMasters > 1 ? 's' : ''}.`);
setStatusType("error");
} else {
setStatusMessage("No draft watermark found to remove.");
setStatusType("info");
}
} catch (innerError) {
console.error("Inner error:", innerError);
throw innerError; // Re-throw to outer catch
} }
const result = await processRemoveWatermarks(context, masters.items);
updateStatusFromResult(result, false);
}); });
} catch (error) { } catch (error) {
setStatusMessage(`Error: ${error.message}`); handleError(error, "Remove draft watermark");
setStatusType("error");
console.error("Remove draft watermark error:", error);
} finally { } finally {
setIsProcessing(false); setIsProcessing(false);
} }
}; };
/**
* Processes all master slides to remove draft watermarks
*
* @param context - The PowerPoint API context
* @param masters - Array of master slides to process
* @returns Processing result with counts of processed and affected slides
*/
const processRemoveWatermarks = async (
context: PowerPoint.RequestContext,
masters: PowerPoint.SlideMaster[]
): Promise<ProcessingResult> => {
const result: ProcessingResult = {
processedMasters: 0,
errorMasters: 0,
affectedCount: 0
};
// Process each master slide
for (let i = 0; i < masters.length; i++) {
try {
const master = masters[i];
// Load all shapes on the master slide
master.shapes.load("items");
await context.sync();
// Collect shapes to delete
const shapesToDelete: PowerPoint.Shape[] = [];
// Load all shape names in a single batch
for (let j = 0; j < master.shapes.items.length; j++) {
const shape = master.shapes.items[j];
shape.load("name");
}
// Wait for all shape names to load
await context.sync();
// Now check names and collect shapes to delete
for (let j = 0; j < master.shapes.items.length; j++) {
const shape = master.shapes.items[j];
if (shape.name === DRAFT_CONFIG.SHAPE_NAME) {
shapesToDelete.push(shape);
}
}
// Delete all matching shapes in one batch
shapesToDelete.forEach(shape => shape.delete());
result.affectedCount += shapesToDelete.length;
await context.sync();
result.processedMasters++;
} catch (masterError) {
console.error(`Error processing master slide ${i+1}:`, masterError);
result.errorMasters++;
// Continue to the next master slide
}
}
return result;
};
return ( return (
<div className={commonStyles.container}> <div className={commonStyles.container}>
<div className={styles.buttonGrid}> <div className={styles.buttonGrid}>
@@ -260,4 +360,4 @@ export const DraftButtons: React.FC = () => {
); );
}; };
export default DraftButtons; export default DraftButtons;
+202 -118
View File
@@ -1,27 +1,76 @@
/**
* GridGuidelineManager Component
*
* This component provides functionality to create and manage grids and guidelines
* on PowerPoint slides. It allows users to add customizable grids with adjustable
* spacing, opacity, and color, as well as add individual guidelines with custom
* positioning and color.
*
* @module GridGuidelineManager
*/
import * as React from "react"; import * as React from "react";
import { import {
Button, Button,
makeStyles, makeStyles,
Label, Label,
Slider,
SpinButton,
Divider,
ToggleButton, ToggleButton,
tokens, tokens
Input
} from "@fluentui/react-components"; } from "@fluentui/react-components";
import { import {
GridRegular,
DismissRegular, DismissRegular,
AddRegular, AddRegular,
LineHorizontal3Regular, LineHorizontal3Regular,
SplitVerticalRegular, SplitVerticalRegular,
GridDotsRegular, GridDotsRegular
ArrowResetRegular
} from "@fluentui/react-icons"; } from "@fluentui/react-icons";
import { useStatusContext } from "./App"; import { useStatusContext } from "./App";
import { useCommonStyles } from "./commonStyles"; import { useCommonStyles } from "./commonStyles";
/**
* Standard dimensions for PowerPoint slides in points
*/
const SLIDE_WIDTH = 960; // Width in points
const SLIDE_HEIGHT = 540; // Height in points
/**
* Available colors for grids and guidelines
*/
type GridColor = "blue" | "red" | "yellow" | "green";
/**
* Direction options for guidelines
*/
type GuideDirection = "horizontal" | "vertical";
/**
* Prefixes used for naming shapes to identify them later
*/
const GRID_PREFIX = "edison_grid_";
const GUIDE_PREFIX = "edison_guide_";
/**
* Color values mapping for the available grid and guideline colors
*/
const colorValues = {
blue: "#4472C4",
red: "#C00000",
yellow: "#FFC000",
green: "#70AD47"
};
/**
* Props for the ColorButton component
*/
interface ColorButtonProps {
/** The color this button represents */
color: GridColor;
/** The currently selected color */
selectedColor: GridColor;
/** Callback function when the color is clicked */
onClick: (color: GridColor) => void;
}
const useStyles = makeStyles({ const useStyles = makeStyles({
buttonGrid: { buttonGrid: {
display: "grid", display: "grid",
@@ -81,18 +130,55 @@ const useStyles = makeStyles({
} }
}); });
type GridColor = "blue" | "red" | "yellow" | "green"; /**
* ColorButton component - Renders a color selection button
const GRID_PREFIX = "edison_grid_"; *
const GUIDE_PREFIX = "edison_guide_"; * @param props - Component props
* @returns React component
const colorValues = { */
blue: "#4472C4", const ColorButton: React.FC<ColorButtonProps> = ({ color, selectedColor, onClick }) => {
red: "#C00000", const styles = useStyles();
yellow: "#FFC000", return (
green: "#70AD47" <Button
className={`${styles.colorButton} ${color === selectedColor ? styles.colorButtonSelected : ''}`}
style={{ backgroundColor: colorValues[color] }}
onClick={() => onClick(color)}
title={color.charAt(0).toUpperCase() + color.slice(1)}
/>
);
}; };
/**
* Custom hook for handling numeric input fields
*
* @param initialValue - The initial value for the input
* @returns A tuple containing the current value and a change handler function
*/
const useNumericInputHandler = (
initialValue: number
): [number, (e: React.ChangeEvent<HTMLInputElement>) => void] => {
const [value, setValue] = React.useState(initialValue);
const handleChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
const parsed = parseInt(newValue);
if (newValue === '') {
setValue(0);
} else if (!isNaN(parsed)) {
setValue(parsed);
}
}, []);
return [value, handleChange];
};
/**
* GridGuidelineManager component - Provides UI and functionality for creating
* and managing grids and guidelines on PowerPoint slides
*
* @returns React component
*/
export const GridGuidelineManager: React.FC = () => { export const GridGuidelineManager: React.FC = () => {
const styles = useStyles(); const styles = useStyles();
const commonStyles = useCommonStyles(); const commonStyles = useCommonStyles();
@@ -103,17 +189,26 @@ export const GridGuidelineManager: React.FC = () => {
} = useStatusContext(); } = useStatusContext();
// Grid state // Grid state
const [gridSpacing, setGridSpacing] = React.useState(50); const [gridSpacing, handleGridSpacingChange] = useNumericInputHandler(50);
const [gridColor, setGridColor] = React.useState<GridColor>("blue"); const [gridColor, setGridColor] = React.useState<GridColor>("blue");
const [gridOpacity, setGridOpacity] = React.useState(30); const [gridOpacity, handleGridOpacityChange] = useNumericInputHandler(30);
// Guidelines state // Guidelines state
const [guidePosition, setGuidePosition] = React.useState(240); const [guidePosition, handleGuidePositionChange] = useNumericInputHandler(240);
const [guideDirection, setGuideDirection] = React.useState<"horizontal" | "vertical">("horizontal"); const [guideDirection, setGuideDirection] = React.useState<GuideDirection>("horizontal");
const [guideColor, setGuideColor] = React.useState<GridColor>("red"); const [guideColor, setGuideColor] = React.useState<GridColor>("red");
// Helper function to create a line shape /**
const createLineShape = ( * Creates a line shape on the slide using a thin rectangle
*
* @param slide - The PowerPoint slide to add the shape to
* @param startX - Starting X coordinate
* @param startY - Starting Y coordinate
* @param endX - Ending X coordinate
* @param endY - Ending Y coordinate
* @returns The created shape object
*/
const createLineShape = React.useCallback((
slide: PowerPoint.Slide, slide: PowerPoint.Slide,
startX: number, startX: number,
startY: number, startY: number,
@@ -138,9 +233,12 @@ export const GridGuidelineManager: React.FC = () => {
} }
return line; return line;
}; }, []);
const createGrid = async () => { /**
* Creates a grid on the current slide based on the configured settings
*/
const createGrid = React.useCallback(async () => {
setIsProcessing(true); setIsProcessing(true);
try { try {
await PowerPoint.run(async (context) => { await PowerPoint.run(async (context) => {
@@ -158,23 +256,19 @@ export const GridGuidelineManager: React.FC = () => {
const currentSlide = selectedSlides.items[0]; const currentSlide = selectedSlides.items[0];
// Standard dimensions for PowerPoint slides
const slideWidth = 960; // Width in points
const slideHeight = 540; // Height in points
// Calculate number of lines needed // Calculate number of lines needed
const numHorizontalLines = Math.floor(slideHeight / gridSpacing); const numHorizontalLines = Math.floor(SLIDE_HEIGHT / gridSpacing);
const numVerticalLines = Math.floor(slideWidth / gridSpacing); const numVerticalLines = Math.floor(SLIDE_WIDTH / gridSpacing);
// Create horizontal grid lines // Create horizontal grid lines
for (let i = 1; i <= numHorizontalLines; i++) { for (let i = 1; i <= numHorizontalLines; i++) {
const yPosition = i * gridSpacing; const yPosition = i * gridSpacing;
// Skip if we're at the edge of the slide // Skip if we're at the edge of the slide
if (yPosition >= slideHeight) continue; if (yPosition >= SLIDE_HEIGHT) continue;
// Create horizontal line // Create horizontal line
const line = createLineShape(currentSlide, 0, yPosition, slideWidth, yPosition); const line = createLineShape(currentSlide, 0, yPosition, SLIDE_WIDTH, yPosition);
line.name = `${GRID_PREFIX}h_${i}`; line.name = `${GRID_PREFIX}h_${i}`;
// Set line properties // Set line properties
@@ -191,10 +285,10 @@ export const GridGuidelineManager: React.FC = () => {
const xPosition = i * gridSpacing; const xPosition = i * gridSpacing;
// Skip if we're at the edge of the slide // Skip if we're at the edge of the slide
if (xPosition >= slideWidth) continue; if (xPosition >= SLIDE_WIDTH) continue;
// Create vertical line // Create vertical line
const line = createLineShape(currentSlide, xPosition, 0, xPosition, slideHeight); const line = createLineShape(currentSlide, xPosition, 0, xPosition, SLIDE_HEIGHT);
line.name = `${GRID_PREFIX}v_${i}`; line.name = `${GRID_PREFIX}v_${i}`;
// Set line properties // Set line properties
@@ -216,9 +310,14 @@ export const GridGuidelineManager: React.FC = () => {
} finally { } finally {
setIsProcessing(false); setIsProcessing(false);
} }
}; }, [createLineShape, gridColor, gridOpacity, gridSpacing, setIsProcessing, setStatusMessage, setStatusType]);
const removeGrid = async (showStatus = true) => { /**
* Removes the grid from the current slide
*
* @param showStatus - Whether to show status messages (default: true)
*/
const removeGrid = React.useCallback(async (showStatus = true) => {
if (showStatus) { if (showStatus) {
setIsProcessing(true); setIsProcessing(true);
} }
@@ -241,13 +340,17 @@ export const GridGuidelineManager: React.FC = () => {
shapes.load("items"); shapes.load("items");
await context.sync(); await context.sync();
// Find shapes that are part of our grid // Load all shape names at once
const allShapes = shapes.items;
for (let i = 0; i < allShapes.length; i++) {
allShapes[i].load("name");
}
await context.sync();
// Find shapes that are part of our grid and mark them for deletion
let removedCount = 0; let removedCount = 0;
for (let i = 0; i < shapes.items.length; i++) { for (let i = 0; i < allShapes.length; i++) {
const shape = shapes.items[i]; const shape = allShapes[i];
shape.load("name");
await context.sync();
// Check if this shape is part of our grid // Check if this shape is part of our grid
if (shape.name && shape.name.startsWith(GRID_PREFIX)) { if (shape.name && shape.name.startsWith(GRID_PREFIX)) {
shape.delete(); shape.delete();
@@ -255,6 +358,7 @@ export const GridGuidelineManager: React.FC = () => {
} }
} }
// Single sync after all deletions
await context.sync(); await context.sync();
if (showStatus) { if (showStatus) {
@@ -277,9 +381,12 @@ export const GridGuidelineManager: React.FC = () => {
setIsProcessing(false); setIsProcessing(false);
} }
} }
}; }, [setIsProcessing, setStatusMessage, setStatusType]);
const addGuideline = async () => { /**
* Adds a guideline to the current slide based on the configured settings
*/
const addGuideline = React.useCallback(async () => {
setIsProcessing(true); setIsProcessing(true);
try { try {
await PowerPoint.run(async (context) => { await PowerPoint.run(async (context) => {
@@ -294,9 +401,6 @@ export const GridGuidelineManager: React.FC = () => {
const currentSlide = selectedSlides.items[0]; const currentSlide = selectedSlides.items[0];
// Standard dimensions for PowerPoint slides
const slideWidth = 960; // Width in points
const slideHeight = 540; // Height in points
// Create a unique identifier for this guide // Create a unique identifier for this guide
const timestamp = new Date().getTime(); const timestamp = new Date().getTime();
@@ -306,10 +410,10 @@ export const GridGuidelineManager: React.FC = () => {
let line; let line;
if (guideDirection === "horizontal") { if (guideDirection === "horizontal") {
// Create horizontal guideline // Create horizontal guideline
line = createLineShape(currentSlide, 0, guidePosition, slideWidth, guidePosition); line = createLineShape(currentSlide, 0, guidePosition, SLIDE_WIDTH, guidePosition);
} else { } else {
// Create vertical guideline // Create vertical guideline
line = createLineShape(currentSlide, guidePosition, 0, guidePosition, slideHeight); line = createLineShape(currentSlide, guidePosition, 0, guidePosition, SLIDE_HEIGHT);
} }
line.name = guideId; line.name = guideId;
@@ -331,9 +435,12 @@ export const GridGuidelineManager: React.FC = () => {
} finally { } finally {
setIsProcessing(false); setIsProcessing(false);
} }
}; }, [createLineShape, guideColor, guideDirection, guidePosition, setIsProcessing, setStatusMessage, setStatusType]);
const removeGuidelines = async () => { /**
* Removes all guidelines from the current slide
*/
const removeGuidelines = React.useCallback(async () => {
setIsProcessing(true); setIsProcessing(true);
try { try {
await PowerPoint.run(async (context) => { await PowerPoint.run(async (context) => {
@@ -353,13 +460,17 @@ export const GridGuidelineManager: React.FC = () => {
shapes.load("items"); shapes.load("items");
await context.sync(); await context.sync();
// Find shapes that are guidelines // Load all shape names at once
const allShapes = shapes.items;
for (let i = 0; i < allShapes.length; i++) {
allShapes[i].load("name");
}
await context.sync();
// Find shapes that are guidelines and mark them for deletion
let removedCount = 0; let removedCount = 0;
for (let i = 0; i < shapes.items.length; i++) { for (let i = 0; i < allShapes.length; i++) {
const shape = shapes.items[i]; const shape = allShapes[i];
shape.load("name");
await context.sync();
// Check if this shape is a guideline // Check if this shape is a guideline
if (shape.name && shape.name.startsWith(GUIDE_PREFIX)) { if (shape.name && shape.name.startsWith(GUIDE_PREFIX)) {
shape.delete(); shape.delete();
@@ -367,6 +478,7 @@ export const GridGuidelineManager: React.FC = () => {
} }
} }
// Single sync after all deletions
await context.sync(); await context.sync();
if (removedCount > 0) { if (removedCount > 0) {
@@ -383,20 +495,7 @@ export const GridGuidelineManager: React.FC = () => {
} finally { } finally {
setIsProcessing(false); setIsProcessing(false);
} }
}; }, [setIsProcessing, setStatusMessage, setStatusType]);
// Color picker button component
const ColorButton = ({ color, selectedColor, onClick }:
{ color: GridColor, selectedColor: GridColor, onClick: (color: GridColor) => void }) => {
return (
<Button
className={`${styles.colorButton} ${color === selectedColor ? styles.colorButtonSelected : ''}`}
style={{ backgroundColor: colorValues[color] }}
onClick={() => onClick(color)}
title={color.charAt(0).toUpperCase() + color.slice(1)}
/>
);
};
return ( return (
<div className={commonStyles.container} style={{ maxHeight: "400px", overflowY: "auto" }}> <div className={commonStyles.container} style={{ maxHeight: "400px", overflowY: "auto" }}>
@@ -415,16 +514,11 @@ export const GridGuidelineManager: React.FC = () => {
<input <input
type="text" type="text"
value={gridSpacing} value={gridSpacing}
onChange={(e) => { onChange={handleGridSpacingChange}
const newValue = e.target.value;
const parsed = parseInt(newValue);
if (newValue === '') {
setGridSpacing(0);
} else if (!isNaN(parsed)) {
setGridSpacing(parsed);
}
}}
onClick={(e) => (e.target as HTMLInputElement).select()} onClick={(e) => (e.target as HTMLInputElement).select()}
title="Grid Spacing"
placeholder="Grid Spacing"
aria-label="Grid Spacing"
style={{ style={{
width: "100%", width: "100%",
padding: "5px 8px", padding: "5px 8px",
@@ -442,16 +536,11 @@ export const GridGuidelineManager: React.FC = () => {
<input <input
type="text" type="text"
value={gridOpacity} value={gridOpacity}
onChange={(e) => { onChange={handleGridOpacityChange}
const newValue = e.target.value;
const parsed = parseInt(newValue);
if (newValue === '') {
setGridOpacity(0);
} else if (!isNaN(parsed)) {
setGridOpacity(parsed);
}
}}
onClick={(e) => (e.target as HTMLInputElement).select()} onClick={(e) => (e.target as HTMLInputElement).select()}
title="Grid Opacity"
placeholder="Grid Opacity"
aria-label="Grid Opacity"
style={{ style={{
width: "100%", width: "100%",
padding: "5px 8px", padding: "5px 8px",
@@ -473,7 +562,7 @@ export const GridGuidelineManager: React.FC = () => {
</div> </div>
</div> </div>
<div className={styles.buttonGrid} style={{ marginTop: "8px" }}> <div className={styles.buttonGrid}>
<Button <Button
appearance="primary" appearance="primary"
className={styles.gridButton} className={styles.gridButton}
@@ -506,32 +595,27 @@ export const GridGuidelineManager: React.FC = () => {
Guidelines Guidelines
</Label> </Label>
<div className={styles.controlRow}> <div className={styles.controlRow}>
<Label className={styles.controlLabel}>Position</Label> <Label className={styles.controlLabel}>Position</Label>
<div style={{ width: "80px", flex: "none" }}> <div style={{ width: "80px", flex: "none" }}>
<input <input
type="text" type="text"
value={guidePosition} value={guidePosition}
onChange={(e) => { onChange={handleGuidePositionChange}
const newValue = e.target.value; onClick={(e) => (e.target as HTMLInputElement).select()}
const parsed = parseInt(newValue); title="Guide Position"
if (newValue === '') { placeholder="Guide Position"
setGuidePosition(0); aria-label="Guide Position"
} else if (!isNaN(parsed)) { style={{
setGuidePosition(parsed); width: "100%",
} padding: "5px 8px",
}} border: "1px solid #d1d1d1",
onClick={(e) => (e.target as HTMLInputElement).select()} borderRadius: "4px",
style={{ fontSize: "14px"
width: "100%", }}
padding: "5px 8px", />
border: "1px solid #d1d1d1",
borderRadius: "4px",
fontSize: "14px"
}}
/>
</div>
</div> </div>
</div>
<div className={styles.controlRow}> <div className={styles.controlRow}>
<Label className={styles.controlLabel}>Type</Label> <Label className={styles.controlLabel}>Type</Label>
@@ -596,4 +680,4 @@ export const GridGuidelineManager: React.FC = () => {
); );
}; };
export default GridGuidelineManager; export default GridGuidelineManager;
-62
View File
@@ -1,62 +0,0 @@
import * as React from "react";
import { tokens, makeStyles } from "@fluentui/react-components";
export interface HeroListItem {
icon: React.JSX.Element;
primaryText: string;
}
export interface HeroListProps {
message: string;
items: HeroListItem[];
}
const useStyles = makeStyles({
list: {
marginTop: "20px",
},
listItem: {
paddingBottom: "20px",
display: "flex",
},
icon: {
marginRight: "10px",
},
itemText: {
fontSize: tokens.fontSizeBase300,
fontColor: tokens.colorNeutralBackgroundStatic,
},
welcome__main: {
width: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
},
message: {
fontSize: tokens.fontSizeBase500,
fontColor: tokens.colorNeutralBackgroundStatic,
fontWeight: tokens.fontWeightRegular,
paddingLeft: "10px",
paddingRight: "10px",
},
});
const HeroList: React.FC<HeroListProps> = (props: HeroListProps) => {
const { items, message } = props;
const styles = useStyles();
const listItems = items.map((item, index) => (
<li className={styles.listItem} key={index}>
<i className={styles.icon}>{item.icon}</i>
<span className={styles.itemText}>{item.primaryText}</span>
</li>
));
return (
<div className={styles.welcome__main}>
<h2 className={styles.message}>{message}</h2>
<ul className={styles.list}>{listItems}</ul>
</div>
);
};
export default HeroList;
+401 -139
View File
@@ -1,172 +1,434 @@
/**
* @file InsertTitles.tsx
* @description Component that provides functionality to collect and insert slide titles
* from all slides in a PowerPoint presentation into a selected text box. This allows users
* to quickly generate a table of contents or agenda slide with all presentation titles.
*/
import * as React from "react"; import * as React from "react";
import { import { TextBulletListRegular } from "@fluentui/react-icons";
Button
} from "@fluentui/react-components";
import {
TextBulletListRegular
} from "@fluentui/react-icons";
import { useStatusContext } from "./App"; import { useStatusContext } from "./App";
import { useCommonStyles } from "./commonStyles"; import { useCommonStyles } from "./commonStyles";
import ActionButton from "./ActionButton";
import { getErrorMessage } from "../types/office-types";
// Configuration constants
const TITLE_POSITION_THRESHOLD = 95; // Pixels from top of slide to consider a shape as a title
const STANDARD_SLIDE_WIDTH = 720; // Standard PowerPoint slide width
// Title detection methods
type TitleDetectionMethod = "auto" | "position" | "first" | "name";
// Title collection options
interface TitleCollectionOptions {
includeSlideNumbers: boolean;
detectionMethod?: TitleDetectionMethod;
}
// Title collection result
interface TitleCollectionResult {
titleText: string;
titlesCollected: number;
}
// Title candidate with position and size information
interface TitleCandidate {
text: string;
top: number;
left: number;
width: number;
height: number;
fontSize?: number;
isBold?: boolean;
}
export const InsertTitles: React.FC = () => { export const InsertTitles: React.FC = () => {
const styles = useCommonStyles(); const styles = useCommonStyles();
const { const { setStatusMessage, setStatusType } = useStatusContext();
statusMessage, setStatusMessage, const [titleDetectionMethod, setTitleDetectionMethod] = React.useState<TitleDetectionMethod>("auto");
statusType, setStatusType, const [includeSlideNumbers, setIncludeSlideNumbers] = React.useState<boolean>(false);
isProcessing, setIsProcessing
} = useStatusContext();
const collectAndInsertTitles = async () => { /**
setIsProcessing(true); * Validates that a text box is selected
* @param shapes The collection of selected shapes
* @param context The PowerPoint request context
* @returns The selected text box shape or null if validation fails
*/
const validateSelection = async (
shapes: PowerPoint.ShapeScopedCollection,
context: PowerPoint.RequestContext
): Promise<PowerPoint.Shape | null> => {
// Check if any shape is selected
if (shapes.items.length === 0) {
setStatusMessage("Please select a text box to insert slide titles.");
setStatusType("warning");
return null;
}
// Get the first selected shape
const selectedShape = shapes.items[0];
// Check if the selected shape has a text frame
selectedShape.load("textFrame");
await context.sync();
if (!selectedShape.textFrame) {
setStatusMessage("Please select a text box to insert slide titles.");
setStatusType("warning");
return null;
}
return selectedShape;
};
/**
* Finds the title shape on a slide using multiple heuristics
* @param shapes The collection of shapes on a slide
* @param context The PowerPoint request context
* @param slideNumber The slide number (for logging)
* @param method The title detection method to use
* @returns The title shape text or null if not found
*/
const findTitleShape = async (
shapes: PowerPoint.ShapeCollection,
context: PowerPoint.RequestContext,
slideNumber: number,
method: TitleDetectionMethod = "auto"
): Promise<{ text: string } | null> => {
try { try {
await PowerPoint.run(async (context) => { let titleCandidates: TitleCandidate[] = [];
try {
// Get the selected shape // First pass: look for title shapes by name
const selectedShapes = context.presentation.getSelectedShapes(); // This is the most reliable method if available
selectedShapes.load("items"); if (method === "auto" || method === "name") {
await context.sync(); for (let j = 0; j < shapes.items.length; j++) {
const shape = shapes.items[j];
// Check if any shape is selected
if (selectedShapes.items.length === 0) {
setStatusMessage("Please select a text box to insert slide titles.");
setStatusType("warning");
return;
}
// Get the first selected shape
const selectedShape = selectedShapes.items[0];
// Check if the selected shape has a text frame // Check if this shape has a name that indicates it's a title
selectedShape.load("textFrame"); shape.load("name,textFrame");
await context.sync(); await context.sync();
if (!selectedShape.textFrame) { // Check if this might be a title shape by name
setStatusMessage("Please select a text box to insert slide titles."); if (shape.name &&
setStatusType("warning"); (shape.name.toLowerCase().includes("title") ||
return; shape.name.toLowerCase().includes("heading"))) {
}
if (shape.textFrame) {
// Get all slides in the presentation shape.textFrame.load("textRange");
const slides = context.presentation.slides;
slides.load("items");
await context.sync();
// Collect all slide titles using a simple approach
let titleText = "";
let titlesCollected = 0;
// Process each slide
for (let i = 0; i < slides.items.length; i++) {
try {
const slide = slides.items[i];
// Load only necessary shape properties
slide.load("shapes");
await context.sync(); await context.sync();
// Get title text using a simplified approach if (shape.textFrame.textRange) {
// This gets all the shapes and looks for one with text content positioned at the top shape.textFrame.textRange.load("text");
const shapes = slide.shapes; await context.sync();
shapes.load("items");
await context.sync(); const shapeText = shape.textFrame.textRange.text;
if (shapeText && shapeText.trim() !== "") {
// Look for shapes with text content return { text: shapeText };
for (let j = 0; j < shapes.items.length; j++) {
try {
const shape = shapes.items[j];
// Only load textFrame property initially
shape.load("textFrame");
await context.sync();
// Only proceed with shapes that have text frames
if (shape.textFrame) {
// Load text range to see if there's content
shape.textFrame.load("textRange");
await context.sync();
if (shape.textFrame.textRange) {
shape.textFrame.textRange.load("text");
await context.sync();
const shapeText = shape.textFrame.textRange.text;
// Check if the shape has text and might be a title
if (shapeText && shapeText.trim() !== "") {
// Load position to see if it's at the top
shape.load("top");
await context.sync();
// Titles are usually at the top of the slide
// Since we can't directly identify a title placeholder,
// we're using position as a heuristic
if (shape.top < 150) {
// Add the title to our collection
// titleText += `Slide ${i+1}: ${shapeText}\n`;
titleText += `${shapeText}\n`;
titlesCollected++;
break; // Only use the first potential title shape on each slide
}
}
}
}
} catch (shapeError) {
console.error(`Error processing shape on slide ${i+1}:`, shapeError);
// Continue to the next shape
continue;
} }
} }
} catch (slideError) {
console.error(`Error processing slide ${i+1}:`, slideError);
// Continue to the next slide
continue;
} }
} }
// Insert the collected titles into the selected text frame
if (titleText) {
// Make sure we have a textRange on the selected shape
selectedShape.textFrame.load("textRange");
await context.sync();
selectedShape.textFrame.textRange.text = titleText;
await context.sync();
setStatusMessage(`Collected and inserted ${titlesCollected} slide titles.`);
setStatusType("success");
} else {
setStatusMessage("No slide titles found to insert.");
setStatusType("warning");
}
} catch (innerError) {
console.error("Inner error:", innerError);
throw innerError; // Re-throw to outer catch
} }
}
// If we're only looking for title shapes by name and didn't find any, return null
if (method === "name") {
return null;
}
// Second pass: collect all text shapes based on the selected method
for (let j = 0; j < shapes.items.length; j++) {
const shape = shapes.items[j];
// Load basic properties for all shapes
shape.load("textFrame,top,left,width,height");
await context.sync();
if (shape.textFrame) {
shape.textFrame.load("textRange");
await context.sync();
if (shape.textFrame.textRange) {
shape.textFrame.textRange.load("text,font/size,font/bold");
await context.sync();
const shapeText = shape.textFrame.textRange.text;
if (shapeText && shapeText.trim() !== "") {
// For "first" method, return the first text shape we find
if (method === "first") {
return { text: shapeText };
}
// For position method or auto, check position
if (method === "position" || method === "auto") {
// Use position as a heuristic for identifying titles
if (shape.top < TITLE_POSITION_THRESHOLD) {
// For position-only method, return the first match
if (method === "position") {
return { text: shapeText };
}
// For auto method, add to candidates with priority score
const fontSize = shape.textFrame.textRange.font.size || 12;
const isBold = shape.textFrame.textRange.font.bold || false;
titleCandidates.push({
text: shapeText,
top: shape.top,
left: shape.left,
width: shape.width,
height: shape.height,
fontSize: fontSize,
isBold: isBold
});
}
}
}
}
}
}
// If we have candidates, sort them by position and size heuristics
if (titleCandidates.length > 0) {
// First sort by vertical position (top-most first)
titleCandidates.sort((a, b) => a.top - b.top);
// Then refine by other heuristics for the top candidates
// Only consider shapes that are close to the top
const topCandidates = titleCandidates.filter(
c => c.top <= titleCandidates[0].top + 50
);
if (topCandidates.length > 1) {
// Sort by a combination of factors:
// 1. How centered the shape is
// 2. Font size (larger is better)
// 3. Bold text is preferred
// 4. Width (wider is better for titles)
topCandidates.sort((a, b) => {
// Calculate how centered each shape is
const aCenterOffset = Math.abs((a.left + a.width/2) - (STANDARD_SLIDE_WIDTH/2));
const bCenterOffset = Math.abs((b.left + b.width/2) - (STANDARD_SLIDE_WIDTH/2));
// Calculate font score (size + bold bonus)
const aFontScore = (a.fontSize || 12) * (a.isBold ? 1.5 : 1);
const bFontScore = (b.fontSize || 12) * (b.isBold ? 1.5 : 1);
// Combine factors into a single score (lower is better)
// Weight factors by importance
const aScore = aCenterOffset * 2 - aFontScore * 5 - a.width * 0.5;
const bScore = bCenterOffset * 2 - bFontScore * 5 - b.width * 0.5;
return aScore - bScore;
});
}
// Return the best candidate
return { text: topCandidates[0].text };
}
} catch (error) {
console.error(`Error finding title on slide ${slideNumber}:`, getErrorMessage(error));
}
return null;
};
/**
* Collects titles from all slides in the presentation
* @param slides The collection of slides
* @param context The PowerPoint request context
* @param options Options for title collection
* @returns The collected titles and count
*/
const collectSlideTitles = async (
slides: PowerPoint.SlideCollection,
context: PowerPoint.RequestContext,
options: TitleCollectionOptions = {
includeSlideNumbers: false,
detectionMethod: "auto"
}
): Promise<TitleCollectionResult> => {
let titleText = "";
let titlesCollected = 0;
try {
// Process each slide
for (let i = 0; i < slides.items.length; i++) {
const slide = slides.items[i];
// Load shapes collection
slide.load("shapes");
await context.sync();
const shapes = slide.shapes;
shapes.load("items");
await context.sync();
// Find the title shape on this slide
const titleShape = await findTitleShape(
shapes,
context,
i + 1,
options.detectionMethod || "auto"
);
if (titleShape) {
// Format the title text based on options
const slideTitle = titleShape.text.trim();
const formattedTitle = options.includeSlideNumbers
? `Slide ${i + 1}: ${slideTitle}`
: slideTitle;
// Add to our collection
titleText += `${formattedTitle}\n`;
titlesCollected++;
}
}
} catch (error) {
console.error("Error collecting slide titles:", getErrorMessage(error));
}
return { titleText, titlesCollected };
};
/**
* Inserts collected titles into a shape
* @param shape The shape to insert titles into
* @param titleText The collected title text
* @param context The PowerPoint request context
* @returns True if insertion was successful
*/
const insertTitlesIntoShape = async (
shape: PowerPoint.Shape,
titleText: string,
context: PowerPoint.RequestContext
): Promise<boolean> => {
try {
// Make sure we have a textRange on the selected shape
shape.textFrame.load("textRange");
await context.sync();
shape.textFrame.textRange.text = titleText;
await context.sync();
return true;
} catch (error) {
console.error("Error inserting titles:", getErrorMessage(error));
return false;
}
};
/**
* Generates an appropriate status message based on the results
* @param titlesCollected The number of titles collected
* @param insertSuccess Whether insertion was successful
* @returns The formatted status message
*/
const generateStatusMessage = (
titlesCollected: number,
insertSuccess: boolean
): string => {
if (titlesCollected === 0) {
return "No slide titles found to insert.";
}
if (!insertSuccess) {
return "Error inserting titles into the selected shape.";
}
return `Collected and inserted ${titlesCollected} slide titles.`;
};
/**
* Main function to collect and insert slide titles
*/
const collectAndInsertTitles = async (): Promise<void> => {
try {
await PowerPoint.run(async (context) => {
// Get the selected shapes
const selectedShapes = context.presentation.getSelectedShapes();
selectedShapes.load("items");
await context.sync();
// Validate selection and get the target shape
const targetShape = await validateSelection(selectedShapes, context);
if (!targetShape) {
return;
}
// Get all slides in the presentation
const slides = context.presentation.slides;
slides.load("items");
await context.sync();
// Collect titles from all slides
const { titleText, titlesCollected } = await collectSlideTitles(
slides,
context,
{
includeSlideNumbers: includeSlideNumbers,
detectionMethod: titleDetectionMethod
}
);
// Insert titles if any were found
let insertSuccess = false;
if (titleText) {
insertSuccess = await insertTitlesIntoShape(targetShape, titleText, context);
}
// Update status message
const statusMessage = generateStatusMessage(titlesCollected, insertSuccess);
setStatusMessage(statusMessage);
setStatusType(
titlesCollected === 0 ? "warning" :
insertSuccess ? "success" : "error"
);
}); });
} catch (error) { } catch (error) {
setStatusMessage(`Error: ${error.message}`); console.error("Error in collectAndInsertTitles:", getErrorMessage(error));
setStatusMessage(`Error: ${getErrorMessage(error)}`);
setStatusType("error"); setStatusType("error");
console.error("Collect titles error:", error);
} finally {
setIsProcessing(false);
} }
}; };
return ( return (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.buttonGroup}> <div className={styles.buttonGroup}>
<Button <ActionButton
appearance="primary" title="Insert Titles"
className={styles.actionButton}
onClick={collectAndInsertTitles}
icon={<TextBulletListRegular />} icon={<TextBulletListRegular />}
disabled={isProcessing} onClick={collectAndInsertTitles}
/>
</div>
<div style={{ marginTop: '10px', display: 'flex', justifyContent: 'flex-end' }}>
<select
value={titleDetectionMethod}
onChange={(e) => setTitleDetectionMethod(e.target.value as TitleDetectionMethod)}
style={{ width: '75%' }}
aria-label="Title detection method"
title="Select title detection method"
> >
Insert Titles <option value="auto">Auto-detect titles</option>
</Button> <option value="name">Title shapes by name</option>
<option value="position">Position-based detection</option>
<option value="first">First text on slide</option>
</select>
</div>
<div style={{ marginTop: '10px', display: 'flex', justifyContent: 'flex-end' }}>
<select
value={includeSlideNumbers ? "yes" : "no"}
onChange={(e) => setIncludeSlideNumbers(e.target.value === "yes")}
style={{ width: '75%' }}
aria-label="Include slide numbers"
title="Include slide numbers in output"
>
<option value="no">Exclude slide numbers</option>
<option value="yes">Include slide numbers</option>
</select>
</div> </div>
</div> </div>
); );
}; };
export default InsertTitles; export default InsertTitles;
+328 -206
View File
@@ -1,225 +1,347 @@
/**
* @file MatchProperties.tsx
* @description Component that provides functionality to match visual properties between shapes
* in PowerPoint presentations. This allows users to copy line, fill, and text properties from
* one shape to multiple other shapes, ensuring consistent styling across elements.
*/
import * as React from "react"; import * as React from "react";
import { import { ColorRegular } from "@fluentui/react-icons";
Button
} from "@fluentui/react-components";
import {
ColorRegular
} from "@fluentui/react-icons";
import { useStatusContext } from "./App"; import { useStatusContext } from "./App";
import { useCommonStyles } from "./commonStyles"; import { useCommonStyles } from "./commonStyles";
import ActionButton from "./ActionButton";
import { getErrorMessage } from "../types/office-types";
// Property types that can be matched
enum PropertyType {
Line = "line",
Fill = "fill",
Text = "text"
}
// Shape property loading configuration
const SHAPE_PROPERTIES = {
line: [
"lineFormat/weight",
"lineFormat/dashStyle",
"lineFormat/color"
],
fill: [
"fill/transparency",
"fill/foregroundColor"
],
text: [
"textFrame"
]
};
// Font property loading configuration
const FONT_PROPERTIES = [
"font/name",
"font/size",
"font/bold",
"font/italic",
"font/underline",
"font/color"
];
export const MatchProperties: React.FC = () => { export const MatchProperties: React.FC = () => {
const styles = useCommonStyles(); const styles = useCommonStyles();
const { const { setStatusMessage, setStatusType } = useStatusContext();
statusMessage, setStatusMessage,
statusType, setStatusType,
isProcessing, setIsProcessing
} = useStatusContext();
const matchPropertiesToFirstSelected = async () => { /**
setIsProcessing(true); * Validates that multiple shapes are selected
try { * @param shapes The collection of selected shapes
await PowerPoint.run(async (context) => { * @returns True if validation passes, false otherwise
// Get the selected shapes */
const shapes = context.presentation.getSelectedShapes(); const validateShapeSelection = (shapes: PowerPoint.ShapeScopedCollection): boolean => {
shapes.load("items"); if (shapes.items.length === 0) {
await context.sync(); setStatusMessage("No shapes are selected. Please select shapes first.");
setStatusType("warning");
// Check if shapes are selected return false;
if (shapes.items.length === 0) {
setStatusMessage("No shapes are selected. Please select shapes first.");
setStatusType("warning");
return;
}
// Check if there is more than one shape selected
if (shapes.items.length === 1) {
setStatusMessage("Please select multiple shapes to copy properties.");
setStatusType("warning");
return;
}
// Get the first shape to use as template
const firstShape = shapes.items[0];
// Load all necessary properties from the first shape
firstShape.load([
"lineFormat/weight",
"lineFormat/dashStyle",
"lineFormat/color",
"fill/transparency",
"fill/foregroundColor",
"textFrame"
]);
await context.sync();
// Check if the shape has text and load text properties
let hasText = false;
if (firstShape.textFrame) {
firstShape.textFrame.load("textRange");
await context.sync();
if (firstShape.textFrame.textRange) {
firstShape.textFrame.textRange.load([
"font/name",
"font/size",
"font/bold",
"font/italic",
"font/underline",
"font/color"
]);
await context.sync();
hasText = true;
}
}
let successCount = 0;
let textStyleCount = 0;
// Loop through remaining shapes and apply properties
for (let i = 1; i < shapes.items.length; i++) {
try {
const targetShape = shapes.items[i];
let propertiesApplied = false;
// Apply line formatting properties
try {
// Line weight
if (firstShape.lineFormat.weight !== undefined) {
targetShape.lineFormat.weight = firstShape.lineFormat.weight;
}
// Line style
if (firstShape.lineFormat.dashStyle !== undefined) {
targetShape.lineFormat.dashStyle = firstShape.lineFormat.dashStyle;
}
// Line color
if (firstShape.lineFormat.color !== undefined) {
targetShape.lineFormat.color = firstShape.lineFormat.color;
}
await context.sync();
propertiesApplied = true;
} catch (err) {
console.error(`Error applying line format to shape ${i}:`, err);
}
// Apply fill properties
try {
// Fill transparency
if (firstShape.fill.transparency !== undefined) {
targetShape.fill.transparency = firstShape.fill.transparency;
}
if (firstShape.fill.foregroundColor !== undefined) {
targetShape.fill.foregroundColor = firstShape.fill.foregroundColor;
}
await context.sync();
propertiesApplied = true;
} catch (err) {
console.error(`Error applying fill format to shape ${i}:`, err);
}
// Apply text properties if the source has text
if (hasText) {
try {
// First check if target shape has text
targetShape.load("textFrame");
await context.sync();
if (targetShape.textFrame) {
targetShape.textFrame.load("textRange");
await context.sync();
if (targetShape.textFrame.textRange) {
const sourceFont = firstShape.textFrame.textRange.font;
const targetFont = targetShape.textFrame.textRange.font;
// Apply font properties
if (sourceFont.name !== undefined) {
targetFont.name = sourceFont.name;
}
if (sourceFont.size !== undefined) {
targetFont.size = sourceFont.size;
}
if (sourceFont.bold !== undefined) {
targetFont.bold = sourceFont.bold;
}
if (sourceFont.italic !== undefined) {
targetFont.italic = sourceFont.italic;
}
if (sourceFont.underline !== undefined) {
targetFont.underline = sourceFont.underline;
}
if (sourceFont.color !== undefined) {
targetFont.color = sourceFont.color;
}
await context.sync();
textStyleCount++;
}
}
} catch (err) {
console.error(`Error applying text format to shape ${i}:`, err);
}
}
if (propertiesApplied) {
successCount++;
}
} catch (err) {
console.error(`Error updating shape ${i}:`, err);
}
}
// Final status message based on what was applied
if (successCount > 0) {
let message = `Applied properties to ${successCount} shapes`;
if (textStyleCount > 0) {
message += ` (including text styling on ${textStyleCount})`;
}
setStatusMessage(message);
setStatusType("success");
} else {
setStatusMessage("Couldn't apply properties. Try selecting different shapes.");
setStatusType("error");
}
// Timeout is handled in App.tsx now
});
} catch (error) {
setStatusMessage(`Error: ${error.message}`);
setStatusType("error");
console.error("Main error:", error);
} finally {
setIsProcessing(false);
} }
if (shapes.items.length === 1) {
setStatusMessage("Please select multiple shapes to copy properties.");
setStatusType("warning");
return false;
}
return true;
};
/**
* Loads all necessary properties from the source shape
* @param sourceShape The shape to copy properties from
* @param context The PowerPoint request context
* @returns Object containing loaded property information
*/
const loadSourceProperties = async (
sourceShape: PowerPoint.Shape,
context: PowerPoint.RequestContext
): Promise<{ hasText: boolean }> => {
// Load basic shape properties
const propertiesToLoad = [
...SHAPE_PROPERTIES.line,
...SHAPE_PROPERTIES.fill,
...SHAPE_PROPERTIES.text
];
sourceShape.load(propertiesToLoad);
await context.sync();
// Check if the shape has text and load text properties if it does
let hasText = false;
if (sourceShape.textFrame) {
sourceShape.textFrame.load("textRange");
await context.sync();
if (sourceShape.textFrame.textRange) {
sourceShape.textFrame.textRange.load(FONT_PROPERTIES);
await context.sync();
hasText = true;
}
}
return { hasText };
};
/**
* Preloads text properties for all target shapes in batches
* @param shapes The collection of shapes
* @param hasText Whether the source shape has text
* @param context The PowerPoint request context
*/
const preloadTargetTextProperties = async (
shapes: PowerPoint.ShapeScopedCollection,
hasText: boolean,
context: PowerPoint.RequestContext
): Promise<void> => {
if (!hasText) return;
// Batch 1: Load textFrame for all target shapes
for (let i = 1; i < shapes.items.length; i++) {
shapes.items[i].load("textFrame");
}
await context.sync();
// Batch 2: For shapes that have textFrames, load their textRanges
for (let i = 1; i < shapes.items.length; i++) {
if (shapes.items[i].textFrame) {
shapes.items[i].textFrame.load("textRange");
}
}
await context.sync();
};
/**
* Applies line properties from source to target shape
* @param sourceShape The shape to copy properties from
* @param targetShape The shape to apply properties to
* @returns True if any properties were applied
*/
const applyLineProperties = (
sourceShape: PowerPoint.Shape,
targetShape: PowerPoint.Shape
): boolean => {
let propertiesApplied = false;
try {
// Line weight
if (sourceShape.lineFormat.weight !== undefined) {
targetShape.lineFormat.weight = sourceShape.lineFormat.weight;
propertiesApplied = true;
}
// Line style
if (sourceShape.lineFormat.dashStyle !== undefined) {
targetShape.lineFormat.dashStyle = sourceShape.lineFormat.dashStyle;
propertiesApplied = true;
}
// Line color
if (sourceShape.lineFormat.color !== undefined) {
targetShape.lineFormat.color = sourceShape.lineFormat.color;
propertiesApplied = true;
}
} catch (err) {
console.error("Error applying line format:", getErrorMessage(err));
}
return propertiesApplied;
};
/**
* Applies fill properties from source to target shape
* @param sourceShape The shape to copy properties from
* @param targetShape The shape to apply properties to
* @returns True if any properties were applied
*/
const applyFillProperties = (
sourceShape: PowerPoint.Shape,
targetShape: PowerPoint.Shape
): boolean => {
let propertiesApplied = false;
try {
// Fill transparency
if (sourceShape.fill.transparency !== undefined) {
targetShape.fill.transparency = sourceShape.fill.transparency;
propertiesApplied = true;
}
// Fill color
if (sourceShape.fill.foregroundColor !== undefined) {
targetShape.fill.foregroundColor = sourceShape.fill.foregroundColor;
propertiesApplied = true;
}
} catch (err) {
console.error("Error applying fill format:", getErrorMessage(err));
}
return propertiesApplied;
};
/**
* Applies text properties from source to target shape
* @param sourceShape The shape to copy properties from
* @param targetShape The shape to apply properties to
* @returns True if text properties were applied
*/
const applyTextProperties = (
sourceShape: PowerPoint.Shape,
targetShape: PowerPoint.Shape
): boolean => {
try {
if (sourceShape.textFrame?.textRange && targetShape.textFrame?.textRange) {
const sourceFont = sourceShape.textFrame.textRange.font;
const targetFont = targetShape.textFrame.textRange.font;
// Apply font properties in batch
if (sourceFont.name !== undefined) {
targetFont.name = sourceFont.name;
}
if (sourceFont.size !== undefined) {
targetFont.size = sourceFont.size;
}
if (sourceFont.bold !== undefined) {
targetFont.bold = sourceFont.bold;
}
if (sourceFont.italic !== undefined) {
targetFont.italic = sourceFont.italic;
}
if (sourceFont.underline !== undefined) {
targetFont.underline = sourceFont.underline;
}
if (sourceFont.color !== undefined) {
targetFont.color = sourceFont.color;
}
return true;
}
} catch (err) {
console.error("Error applying text format:", getErrorMessage(err));
}
return false;
};
/**
* Main function to match properties from the first selected shape to others
*/
const matchPropertiesToFirstSelected = async (): Promise<void> => {
await PowerPoint.run(async (context) => {
// Get the selected shapes
const shapes = context.presentation.getSelectedShapes();
shapes.load("items");
await context.sync();
// Validate shape selection
if (!validateShapeSelection(shapes)) {
return;
}
// Get the first shape to use as template
const sourceShape = shapes.items[0];
// Load all necessary properties from the source shape
const { hasText } = await loadSourceProperties(sourceShape, context);
// Preload text properties for target shapes if needed
await preloadTargetTextProperties(shapes, hasText, context);
// Apply properties to all target shapes
let successCount = 0;
let textStyleCount = 0;
for (let i = 1; i < shapes.items.length; i++) {
const targetShape = shapes.items[i];
let shapeUpdated = false;
// Apply different property types
const lineApplied = applyLineProperties(sourceShape, targetShape);
const fillApplied = applyFillProperties(sourceShape, targetShape);
// Apply text properties if available
if (hasText) {
const textApplied = applyTextProperties(sourceShape, targetShape);
if (textApplied) {
textStyleCount++;
}
}
// Count successful shape updates
if (lineApplied || fillApplied) {
shapeUpdated = true;
}
if (shapeUpdated) {
successCount++;
}
}
// Single sync after all property changes
await context.sync();
// Generate appropriate status message
if (successCount > 0) {
const propertyTypes = [];
// Build a detailed status message
if (sourceShape.lineFormat) propertyTypes.push("line");
if (sourceShape.fill) propertyTypes.push("fill");
let message = `Applied ${propertyTypes.join(", ")} properties to ${successCount} shapes`;
if (textStyleCount > 0) {
message += ` (including text styling on ${textStyleCount})`;
}
setStatusMessage(message);
setStatusType("success");
} else {
setStatusMessage("Couldn't apply properties. Try selecting different shapes.");
setStatusType("error");
}
});
}; };
return ( return (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.buttonGroup}> <div className={styles.buttonGroup}>
<Button <ActionButton
appearance="primary" title="Match Properties"
className={styles.actionButton}
onClick={matchPropertiesToFirstSelected}
icon={<ColorRegular />} icon={<ColorRegular />}
disabled={isProcessing} onClick={matchPropertiesToFirstSelected}
> />
Match Properties
</Button>
</div> </div>
</div> </div>
); );
}; };
export default MatchProperties; export default MatchProperties;
+160 -60
View File
@@ -1,29 +1,148 @@
/**
* @file MatchSizes.tsx
* @description Component that provides functionality to match dimensions between shapes
* in PowerPoint presentations. This allows users to make multiple shapes the same width,
* height, or both dimensions, based on the first selected shape.
*/
import * as React from "react"; import * as React from "react";
import { import { ArrowFitInRegular, ArrowMaximizeRegular, ArrowMinimizeRegular } from "@fluentui/react-icons";
Button,
Text,
Tooltip,
InfoLabel,
Card
} from "@fluentui/react-components";
import {
ArrowFitInRegular,
ArrowSortDownRegular,
ArrowSortUpRegular
} from "@fluentui/react-icons";
import { useStatusContext } from "./App"; import { useStatusContext } from "./App";
import { useCommonStyles } from "./commonStyles"; import { useCommonStyles } from "./commonStyles";
import ActionButton from "./ActionButton";
import { getErrorMessage } from "../types/office-types";
// Size matching options
enum SizeMatchType {
Both = "both",
Width = "width",
Height = "height"
}
// Dimension properties to load and match
const DIMENSION_PROPERTIES = {
[SizeMatchType.Both]: ["width", "height"],
[SizeMatchType.Width]: ["width"],
[SizeMatchType.Height]: ["height"]
};
export const MatchSizes: React.FC = () => { export const MatchSizes: React.FC = () => {
const styles = useCommonStyles(); const styles = useCommonStyles();
const { const { setStatusMessage, setStatusType } = useStatusContext();
statusMessage, setStatusMessage,
statusType, setStatusType,
isProcessing, setIsProcessing
} = useStatusContext();
const matchSizeToFirstSelected = async () => { /**
setIsProcessing(true); * Validates that multiple shapes are selected
* @param shapes The collection of selected shapes
* @returns True if validation passes, false otherwise
*/
const validateShapeSelection = (shapes: PowerPoint.ShapeScopedCollection): boolean => {
if (shapes.items.length === 0) {
setStatusMessage("No shapes are selected. Please select shapes first.");
setStatusType("warning");
return false;
}
if (shapes.items.length === 1) {
setStatusMessage("Please select multiple shapes to resize.");
setStatusType("warning");
return false;
}
return true;
};
/**
* Loads dimension properties from the source shape
* @param sourceShape The shape to copy dimensions from
* @param context The PowerPoint request context
* @param matchType The type of dimension matching to perform
* @returns The loaded source shape
*/
const loadSourceShape = async (
sourceShape: PowerPoint.Shape,
context: PowerPoint.RequestContext,
matchType: SizeMatchType = SizeMatchType.Both
): Promise<PowerPoint.Shape> => {
// Load the appropriate dimension properties
sourceShape.load(DIMENSION_PROPERTIES[matchType]);
await context.sync();
return sourceShape;
};
/**
* Applies size changes to target shapes
* @param sourceShape The shape to copy dimensions from
* @param shapes The collection of shapes to resize
* @param matchType The type of dimension matching to perform
* @returns The number of shapes that were resized
*/
const applySizeChanges = (
sourceShape: PowerPoint.Shape,
shapes: PowerPoint.ShapeScopedCollection,
matchType: SizeMatchType = SizeMatchType.Both
): number => {
let resizedCount = 0;
try {
// Apply dimensions to all target shapes
for (let i = 1; i < shapes.items.length; i++) {
const targetShape = shapes.items[i];
// Apply width if needed
if (matchType === SizeMatchType.Both || matchType === SizeMatchType.Width) {
targetShape.width = sourceShape.width;
}
// Apply height if needed
if (matchType === SizeMatchType.Both || matchType === SizeMatchType.Height) {
targetShape.height = sourceShape.height;
}
resizedCount++;
}
} catch (err) {
console.error("Error applying size changes:", getErrorMessage(err));
}
return resizedCount;
};
/**
* Generates an appropriate status message based on the results
* @param resizedCount The number of shapes that were resized
* @param matchType The type of dimension matching performed
* @returns The formatted status message
*/
const generateStatusMessage = (
resizedCount: number,
matchType: SizeMatchType = SizeMatchType.Both
): string => {
if (resizedCount === 0) {
return "No shapes were resized. Please try again with different shapes.";
}
let dimensionText = "";
switch (matchType) {
case SizeMatchType.Width:
dimensionText = "width";
break;
case SizeMatchType.Height:
dimensionText = "height";
break;
default:
dimensionText = "dimensions";
}
return `Resized ${resizedCount} shapes to match the ${dimensionText} of the first selected shape.`;
};
/**
* Main function to match sizes from the first selected shape to others
* @param matchType The type of dimension matching to perform
*/
const matchSizeToFirstSelected = async (
matchType: SizeMatchType = SizeMatchType.Both
): Promise<void> => {
try { try {
await PowerPoint.run(async (context) => { await PowerPoint.run(async (context) => {
// Get the selected shapes // Get the selected shapes
@@ -31,62 +150,43 @@ export const MatchSizes: React.FC = () => {
shapes.load("items"); shapes.load("items");
await context.sync(); await context.sync();
// Check if shapes are selected // Validate shape selection
if (shapes.items.length === 0) { if (!validateShapeSelection(shapes)) {
setStatusMessage("No shapes are selected. Please select shapes first.");
setStatusType("warning");
return; return;
} }
// Check if there is more than one shape selected // Get the first shape to use as template
if (shapes.items.length === 1) { const sourceShape = await loadSourceShape(shapes.items[0], context, matchType);
setStatusMessage("Please select multiple shapes to resize.");
setStatusType("warning");
return;
}
// Get the first shape's dimensions
const firstShape = shapes.items[0];
firstShape.load("width,height");
await context.sync();
// Loop through the remaining shapes and resize them
for (let i = 1; i < shapes.items.length; i++) {
shapes.items[i].width = firstShape.width;
shapes.items[i].height = firstShape.height;
}
await context.sync();
setStatusMessage(`Resized ${shapes.items.length - 1} shapes to match the first selected shape.`);
setStatusType("success");
// Timeout is handled in App.tsx now // Apply size changes to all target shapes
const resizedCount = applySizeChanges(sourceShape, shapes, matchType);
// Sync changes to PowerPoint
await context.sync();
// Update status message
const statusMessage = generateStatusMessage(resizedCount, matchType);
setStatusMessage(statusMessage);
setStatusType(resizedCount > 0 ? "success" : "error");
}); });
} catch (error) { } catch (error) {
setStatusMessage(`Error: ${error.message}`); console.error("Error in matchSizeToFirstSelected:", getErrorMessage(error));
setStatusMessage(`Error: ${getErrorMessage(error)}`);
setStatusType("error"); setStatusType("error");
console.error(error);
} finally {
setIsProcessing(false);
} }
}; };
return ( return (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.buttonGroup}> <div className={styles.buttonGroup}>
<Button <ActionButton
appearance="primary" title="Match Sizes"
className={styles.actionButton} icon={<ArrowFitInRegular />}
onClick={matchSizeToFirstSelected} onClick={() => matchSizeToFirstSelected(SizeMatchType.Both)}
icon={<ArrowFitInRegular />} />
disabled={isProcessing}
>
Match Sizes
</Button>
</div> </div>
</div> </div>
); );
}; };
export default MatchSizes; export default MatchSizes;
+120 -88
View File
@@ -1,3 +1,10 @@
/**
* @file ProgressBarButtons.tsx
* @description Component that provides functionality to add and remove progress bars
* on PowerPoint slides. The progress bars visually indicate the current position within
* the presentation, with colored segments showing completed and upcoming slides.
*/
import * as React from "react"; import * as React from "react";
import { import {
Button, Button,
@@ -10,6 +17,28 @@ import {
import { useStatusContext } from "./App"; import { useStatusContext } from "./App";
import { useCommonStyles } from "./commonStyles"; import { useCommonStyles } from "./commonStyles";
// Constants for progress bar configuration
const PROGRESS_BAR_CONFIG = {
// Standard PowerPoint slide dimensions in points
slideWidth: 960,
slideHeight: 540,
// Progress bar appearance
barHeight: 1,
distanceFromBottom: 28,
overlap: 0.5, // Small overlap to ensure no gaps between segments
// Colors
completedColor: "#205170", // Blue for progressed segments (RGB 32, 81, 112)
pendingColor: "#F2F2F2" // Grey for segments not yet progressed (RGB 242, 242, 242)
};
// Layouts that should receive progress bars
const SUPPORTED_LAYOUTS = [
"Title",
"Content",
"Two Content",
"Blank"
];
const useStyles = makeStyles({ const useStyles = makeStyles({
buttonGrid: { buttonGrid: {
display: "grid", display: "grid",
@@ -36,108 +65,96 @@ export const ProgressBarButtons: React.FC = () => {
isProcessing, setIsProcessing isProcessing, setIsProcessing
} = useStatusContext(); } = useStatusContext();
/**
* Adds a progress bar to the bottom of each slide in the presentation
* The progress bar shows the current position within the presentation
* with colored segments indicating progress
*/
const addProgressBar = async () => { const addProgressBar = async () => {
setIsProcessing(true); setIsProcessing(true);
const startTime = performance.now();
try { try {
await PowerPoint.run(async (context) => { await PowerPoint.run(async (context) => {
// Get all slides // Get all slides at once
const presentation = context.presentation; const presentation = context.presentation;
const slides = presentation.slides; const slides = presentation.slides;
slides.load("items"); slides.load("items");
// Need to load the first slide to get access to dimensions // Single sync to get all slides
await context.sync(); await context.sync();
if (slides.items.length === 0) { if (slides.items.length === 0) {
throw new Error("No slides in the presentation"); throw new Error("No slides in the presentation");
} }
// Get dimensions from first slide // Calculate dimensions based on slide count
const firstSlide = slides.items[0]; const { slideWidth, slideHeight, barHeight, distanceFromBottom, overlap } = PROGRESS_BAR_CONFIG;
// We'll create our own width and height based on standard values
// since direct access to slide dimensions is tricky in Office.js
const slideWidth = 960; // Standard PowerPoint slide width in points
const slideHeight = 540; // Standard PowerPoint slide height in points
// Define progress bar properties
const progressBarHeight = 1; // Height in points
const segmentGap = 0; // Gap between segments
const startY = slideHeight - 28; // 28 points from bottom
// Load slides first to get count
await context.sync();
const slideCount = slides.items.length; const slideCount = slides.items.length;
const segmentWidth = slideWidth / slideCount; const segmentWidth = slideWidth / slideCount;
const startY = slideHeight - distanceFromBottom;
// Process each slide // Load all slide layouts at once to reduce API calls
for (let i = 0; i < slideCount; i++) {
slides.items[i].load("layout");
}
// Single sync to get all layouts
await context.sync();
// Process each slide without unnecessary syncs
for (let i = 0; i < slideCount; i++) { for (let i = 0; i < slideCount; i++) {
const slide = slides.items[i]; const slide = slides.items[i];
// Load slide properties to check layout
slide.load("layout");
await context.sync();
const layoutName = slide.layout.name; const layoutName = slide.layout.name;
// Check if this is a layout we want to add the progress bar to // Check if this is a layout we want to add the progress bar to
// Note: Office.js might not have exact same layout names as VBA, const isCompatibleLayout = SUPPORTED_LAYOUTS.some(layout =>
// so we're checking for common layouts layoutName.includes(layout)
if (layoutName.includes("Title") || );
layoutName.includes("Content") ||
layoutName.includes("Two Content") || if (isCompatibleLayout) {
layoutName.includes("Blank")) { // Create all segments for this slide in a batch
// Add progress bar segments to this slide
for (let j = 0; j < slideCount; j++) { for (let j = 0; j < slideCount; j++) {
// Create line for this segment with a small overlap to prevent gaps // Calculate segment position and dimensions
const overlap = 0.5; // Small overlap to ensure no gaps between segments
const startX = j * segmentWidth; const startX = j * segmentWidth;
const endX = (j + 1) * segmentWidth; const endX = (j + 1) * segmentWidth;
// Create a rectangle with minimal height to simulate a line // Create a rectangle with minimal height to simulate a line
// since proper line creation is challenging with the Office JS API const segment = slide.shapes.addGeometricShape("Rectangle");
const line = slide.shapes.addGeometricShape("Rectangle");
// Set the rectangle's position to look like a line // Set the rectangle's position to look like a line
// Adding a tiny bit of overlap to ensure no gaps segment.left = startX - (j > 0 ? overlap : 0);
line.left = startX - (j > 0 ? overlap : 0); // Overlap with previous segment except for first segment.top = startY;
line.top = startY; segment.width = (endX - startX) + (j > 0 ? overlap : 0) + (j < slideCount - 1 ? overlap : 0);
line.width = (endX - startX) + (j > 0 ? overlap : 0) + (j < slideCount - 1 ? overlap : 0); segment.height = barHeight;
line.height = progressBarHeight; // Very small height
// Set fill color based on progress instead of line color // Set fill color based on progress
// We're using a rectangle, so fill is more appropriate than line const isCompleted = j <= i;
if (j <= i) { segment.fill.setSolidColor(isCompleted ?
// Blue for progressed segments (RGB 32, 81, 112) PROGRESS_BAR_CONFIG.completedColor :
line.fill.setSolidColor("#205170"); PROGRESS_BAR_CONFIG.pendingColor
);
// Make the current slide indicator (last blue segment) slightly more prominent
if (j == i) { // Make the current slide indicator slightly more prominent
// Make it wider and taller for emphasis if (j == i) {
line.width += 1; // Add extra width segment.width += 1;
}
} else {
// Grey for segments not yet progressed (RGB 242, 242, 242)
line.fill.setSolidColor("#F2F2F2");
// Make the current slide indicator (last blue segment) slightly more prominent
if (j == i) {
// Make it wider and taller for emphasis
line.width += 1; // Add extra width
}
} }
// Remove the border/outline entirely // Remove the border/outline entirely
line.lineFormat.visible = false; segment.lineFormat.visible = false;
// Since tags API might not be fully supported, use shape name as identifier // Use shape name as identifier for later removal
line.name = "progress_bar_" + j; segment.name = "progress_bar_" + j;
} }
} }
} }
// Single final sync to commit all changes
await context.sync();
const endTime = performance.now();
console.log(`Progress bar creation took ${endTime - startTime}ms`);
setStatusMessage("Added progress bar to slides."); setStatusMessage("Added progress bar to slides.");
setStatusType("success"); setStatusType("success");
}); });
@@ -150,53 +167,68 @@ export const ProgressBarButtons: React.FC = () => {
} }
}; };
/**
* Removes all progress bar elements from all slides in the presentation
* Identifies progress bar elements by their name prefix "progress_bar_"
*/
const removeProgressBar = async () => { const removeProgressBar = async () => {
setIsProcessing(true); setIsProcessing(true);
const startTime = performance.now();
try { try {
await PowerPoint.run(async (context) => { await PowerPoint.run(async (context) => {
// Get all slides // Get all slides at once
const presentation = context.presentation; const presentation = context.presentation;
const slides = presentation.slides; const slides = presentation.slides;
slides.load("items"); slides.load("items");
await context.sync(); await context.sync();
let removedCount = 0; let removedCount = 0;
const shapesToLoad = [];
// Process each slide // First, load all shapes from all slides in a batch
for (let i = 0; i < slides.items.length; i++) { for (let i = 0; i < slides.items.length; i++) {
const slide = slides.items[i]; const slide = slides.items[i];
// Load all shapes in this slide
const shapes = slide.shapes; const shapes = slide.shapes;
shapes.load("items"); shapes.load("items");
await context.sync(); shapesToLoad.push({ slide, shapes });
}
// Find shapes with our tag
const progressBarShapes = []; // Single sync to get all shapes
await context.sync();
// Now load all shape names in a batch
const allShapesWithNames = [];
for (const { slide, shapes } of shapesToLoad) {
for (let j = 0; j < shapes.items.length; j++) { for (let j = 0; j < shapes.items.length; j++) {
const shape = shapes.items[j]; const shape = shapes.items[j];
// Check if this shape name indicates it's a progress bar
shape.load("name"); shape.load("name");
await context.sync(); allShapesWithNames.push({ slide, shape });
// Look for shapes with names starting with "progress_bar_"
if (shape.name && shape.name.startsWith("progress_bar_")) {
progressBarShapes.push(shape);
}
}
// Delete all progress bar shapes
for (const shape of progressBarShapes) {
shape.delete();
removedCount++;
} }
} }
// Single sync to get all shape names
await context.sync(); await context.sync();
// Filter shapes that are progress bars and delete them
const progressBarShapes = allShapesWithNames.filter(
({ shape }) => shape.name && shape.name.startsWith("progress_bar_")
);
// Delete all progress bar shapes
for (const { shape } of progressBarShapes) {
shape.delete();
removedCount++;
}
// Single final sync to commit all deletions
await context.sync();
const endTime = performance.now();
console.log(`Progress bar removal took ${endTime - startTime}ms`);
if (removedCount > 0) { if (removedCount > 0) {
// setStatusMessage(`Removed ${removedCount} progress bar elements from slides.`);
// Added progress bar to slides.
setStatusMessage(`Removed progress bar from slides.`); setStatusMessage(`Removed progress bar from slides.`);
} else { } else {
setStatusMessage("No progress bar found to remove."); setStatusMessage("No progress bar found to remove.");
@@ -240,4 +272,4 @@ export const ProgressBarButtons: React.FC = () => {
); );
}; };
export default ProgressBarButtons; export default ProgressBarButtons;
+141 -76
View File
@@ -1,23 +1,133 @@
/**
* @file RoundImage.tsx
* @description Component that provides functionality to create circular or rounded images
* in PowerPoint presentations. This tool creates a mask shape that can be used with
* PowerPoint's built-in shape intersection feature to crop images into a circular shape.
*/
import * as React from "react"; import * as React from "react";
import { import { CircleRegular } from "@fluentui/react-icons";
Button
} from "@fluentui/react-components";
import {
CircleRegular
} from "@fluentui/react-icons";
import { useStatusContext } from "./App"; import { useStatusContext } from "./App";
import { useCommonStyles } from "./commonStyles"; import { useCommonStyles } from "./commonStyles";
import ActionButton from "./ActionButton";
import { getErrorMessage, isPictureShape, getFirstSelectedSlide, selectShapesById } from "../types/office-types";
// Configuration constants
const MASK_COLOR = "red";
const SUCCESS_MESSAGE = "Created mask shape. Please select both the image and the oval, then use the 'Shape Format > Merge Shapes > Intersect' command in PowerPoint.";
export const RoundImage: React.FC = () => { export const RoundImage: React.FC = () => {
const styles = useCommonStyles(); const styles = useCommonStyles();
const { const { setStatusMessage, setStatusType } = useStatusContext();
statusMessage, setStatusMessage,
statusType, setStatusType,
isProcessing, setIsProcessing
} = useStatusContext();
const convertToRoundImage = async () => { /**
setIsProcessing(true); * Validates that an image shape is selected
* @param shapes The collection of selected shapes
* @param context The PowerPoint request context
* @returns The selected image shape or null if validation fails
*/
const validateShapeSelection = async (
shapes: PowerPoint.ShapeScopedCollection,
context: PowerPoint.RequestContext
): Promise<PowerPoint.Shape | null> => {
// Check if any shape is selected
if (shapes.items.length === 0) {
setStatusMessage("No shapes are selected. Please select an image.");
setStatusType("warning");
return null;
}
// Get the first selected shape
const shape = shapes.items[0];
// Load essential properties
shape.load(["type"]);
await context.sync();
// Ensure the shape is a picture using our type-safe utility
if (!isPictureShape(shape)) {
setStatusMessage("Please select an image.");
setStatusType("warning");
return null;
}
return shape;
};
/**
* Creates an elliptical mask shape for the image
* @param slide The slide to add the mask to
* @param imageShape The image shape to mask
* @param context The PowerPoint request context
* @returns The created mask shape
*/
const createMaskShape = async (
slide: PowerPoint.Slide,
imageShape: PowerPoint.Shape,
context: PowerPoint.RequestContext
): Promise<PowerPoint.Shape> => {
// Load current dimensions to maintain aspect ratio
imageShape.load(["width", "height", "left", "top", "id"]);
await context.sync();
// Create elliptical mask with proper type
const maskShape = slide.shapes.addGeometricShape(PowerPoint.GeometricShapeType.ellipse);
maskShape.load(["width", "height", "left", "top", "id"]);
await context.sync();
// Position the mask to match the image
maskShape.left = imageShape.left;
imageShape.lineFormat.load(["weight"]);
await context.sync();
maskShape.top = imageShape.top;
maskShape.width = imageShape.width;
maskShape.height = imageShape.height;
// Style the mask
maskShape.fill.setSolidColor(MASK_COLOR);
maskShape.lineFormat.visible = false;
return maskShape;
};
/**
* Applies the mask to the image and selects both shapes
* @param slide The slide containing the shapes
* @param imageShape The image shape to mask
* @param maskShape The mask shape
* @param context The PowerPoint request context
*/
const applyMaskToImage = async (
slide: PowerPoint.Slide,
imageShape: PowerPoint.Shape,
maskShape: PowerPoint.Shape,
context: PowerPoint.RequestContext
): Promise<void> => {
// Store original dimensions to maintain after selection
const width = imageShape.width;
const height = imageShape.height;
// Select both shapes for the user to apply the intersection
selectShapesById(slide, [imageShape.id, maskShape.id]);
// Ensure we maintain the same size
imageShape.width = width;
imageShape.height = height;
await context.sync();
// Update status message with instructions
setStatusMessage(SUCCESS_MESSAGE);
setStatusType("warning");
};
/**
* Main function to convert an image to a round image
*/
const convertToRoundImage = async (): Promise<void> => {
try { try {
await PowerPoint.run(async (context) => { await PowerPoint.run(async (context) => {
// Get the selected shapes // Get the selected shapes
@@ -25,85 +135,40 @@ export const RoundImage: React.FC = () => {
shapes.load("items"); shapes.load("items");
await context.sync(); await context.sync();
if (shapes.items.length === 0) { // Validate selection and get the image shape
setStatusMessage("No shapes are selected. Please select an image."); const imageShape = await validateShapeSelection(shapes, context);
setStatusType("warning"); if (!imageShape) {
return; return;
} }
// Get the first selected shape // Get the current slide using our type-safe utility
const shape = shapes.items[0]; const slide = getFirstSelectedSlide(context);
// Load essential properties // Create the mask shape
shape.load(["type"]); const maskShape = await createMaskShape(slide, imageShape, context);
await context.sync();
// Ensure the shape is a picture
if (shape.type !== PowerPoint.ShapeType.image) {
setStatusMessage("Please select an image.");
setStatusType("warning");
return;
}
// Load current dimensions to maintain aspect ratio // Apply the mask and select both shapes
shape.load(["width", "height", "left", "top", "id"]); await applyMaskToImage(slide, imageShape, maskShape, context);
await context.sync();
// Store current dimensions
const width = shape.width;
const height = shape.height;
const slide = context.presentation.getSelectedSlides().getItemAt(0);
const maskShape = slide.shapes.addGeometricShape("Ellipse" as any);
maskShape.load(["width", "height", "left", "top", "id"]);
await context.sync();
maskShape.left = shape.left;
shape.lineFormat.load(["weight"]);
await context.sync();
maskShape.top = shape.top; // + shape.lineFormat.weight;
maskShape.width = shape.width;
maskShape.height = shape.height;
maskShape.fill.setSolidColor("red");
maskShape.lineFormat.visible = false;
setStatusMessage("Created mask shape. Please select both the image and the oval, then use the 'Shape Forma > Merge Shapes > Intersect' command in PowerPoint.");
setStatusType("warning");
slide.setSelectedShapes([shape.id, maskShape.id]);
// Ensure we maintain the same size
shape.width = width;
shape.height = height;
await context.sync();
}); });
} catch (error) { } catch (error: unknown) {
setStatusMessage(`Error: ${error.message}`); const errorMessage = getErrorMessage(error);
setStatusMessage(`Error: ${errorMessage}`);
setStatusType("error"); setStatusType("error");
console.error("Main error:", error); console.error("Round image error:", error);
} finally {
setIsProcessing(false);
} }
}; };
return ( return (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.buttonGroup}> <div className={styles.buttonGroup}>
<Button <ActionButton
appearance="primary" title="Round Image"
className={styles.actionButton}
onClick={convertToRoundImage}
icon={<CircleRegular />} icon={<CircleRegular />}
disabled={isProcessing} onClick={convertToRoundImage}
> />
Round Image
</Button>
</div> </div>
</div> </div>
); );
}; };
export default RoundImage; export default RoundImage;
+44
View File
@@ -0,0 +1,44 @@
import * as React from "react";
import { Subtitle1, makeStyles, tokens } from "@fluentui/react-components";
interface SectionProps {
title: string;
children: React.ReactNode;
}
const useStyles = makeStyles({
section: {
marginBottom: "6px",
padding: "12px",
borderRadius: "8px",
backgroundColor: tokens.colorNeutralBackground2,
boxShadow: "0 1px 2px rgba(0, 0, 0, 0.06)",
transition: "all 0.2s ease",
":hover": {
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.1)",
},
},
sectionTitle: {
marginBottom: "12px",
fontWeight: tokens.fontWeightSemibold,
color: tokens.colorBrandForeground1,
},
});
/**
* Reusable section component with consistent styling
*/
export const Section: React.FC<SectionProps> = ({ title, children }) => {
const styles = useStyles();
return (
<div className={styles.section}>
<Subtitle1 block className={styles.sectionTitle}>
{title}
</Subtitle1>
{children}
</div>
);
};
export default Section;
+119 -48
View File
@@ -1,85 +1,156 @@
/**
* @file SwapPositions.tsx
* @description Component that provides functionality to swap the positions of two selected shapes
* in PowerPoint presentations. This allows users to quickly exchange the locations of two objects
* while maintaining their individual properties and content.
*/
import * as React from "react"; import * as React from "react";
import { import { ArrowSwapRegular } from "@fluentui/react-icons";
Button
} from "@fluentui/react-components";
import {
ArrowSwapRegular
} from "@fluentui/react-icons";
import { useStatusContext } from "./App"; import { useStatusContext } from "./App";
import { useCommonStyles } from "./commonStyles"; import { useCommonStyles } from "./commonStyles";
import ActionButton from "./ActionButton";
import { getErrorMessage } from "../types/office-types";
// Position properties to load and swap
const POSITION_PROPERTIES = ["left", "top"];
// Shape position data interface
interface ShapePosition {
left: number;
top: number;
}
export const SwapPositions: React.FC = () => { export const SwapPositions: React.FC = () => {
const styles = useCommonStyles(); const styles = useCommonStyles();
const { const { setStatusMessage, setStatusType } = useStatusContext();
statusMessage, setStatusMessage,
statusType, setStatusType,
isProcessing, setIsProcessing
} = useStatusContext();
const swapPositionsOfTwoSelectedObjects = async () => { /**
setIsProcessing(true); * Validates that exactly two shapes are selected
* @param shapes The collection of selected shapes
* @returns True if validation passes, false otherwise
*/
const validateShapeSelection = (shapes: PowerPoint.ShapeScopedCollection): boolean => {
if (shapes.items.length !== 2) {
setStatusMessage("Please select exactly two shapes to swap their positions.");
setStatusType("warning");
return false;
}
return true;
};
/**
* Loads position properties from the shapes
* @param shape1 The first shape
* @param shape2 The second shape
* @param context The PowerPoint request context
* @returns The loaded shapes
*/
const loadShapePositions = async (
shape1: PowerPoint.Shape,
shape2: PowerPoint.Shape,
context: PowerPoint.RequestContext
): Promise<[PowerPoint.Shape, PowerPoint.Shape]> => {
// Load position properties for both shapes in a single batch
shape1.load(POSITION_PROPERTIES);
shape2.load(POSITION_PROPERTIES);
await context.sync();
return [shape1, shape2];
};
/**
* Swaps the positions of two shapes
* @param shape1 The first shape
* @param shape2 The second shape
* @returns True if the swap was successful
*/
const swapPositions = (
shape1: PowerPoint.Shape,
shape2: PowerPoint.Shape
): boolean => {
try {
// Store the position of the first shape
const tempPosition: ShapePosition = {
left: shape1.left,
top: shape1.top
};
// Swap positions
shape1.left = shape2.left;
shape1.top = shape2.top;
shape2.left = tempPosition.left;
shape2.top = tempPosition.top;
return true;
} catch (err) {
console.error("Error swapping positions:", getErrorMessage(err));
return false;
}
};
/**
* Generates an appropriate status message based on the results
* @param success Whether the swap was successful
* @returns The formatted status message
*/
const generateStatusMessage = (success: boolean): string => {
return success
? "Positions of the two shapes have been swapped successfully."
: "Failed to swap positions. Please try again with different shapes.";
};
/**
* Main function to swap positions of two selected shapes
*/
const swapPositionsOfTwoSelectedObjects = async (): Promise<void> => {
try { try {
await PowerPoint.run(async (context) => { await PowerPoint.run(async (context) => {
// Get the selected shapes // Get the selected shapes
const shapes = context.presentation.getSelectedShapes(); const shapes = context.presentation.getSelectedShapes();
shapes.load("items/count"); shapes.load("items");
await context.sync(); await context.sync();
// Check if exactly two shapes are selected // Validate shape selection
if (shapes.items.length !== 2) { if (!validateShapeSelection(shapes)) {
setStatusMessage("Please select exactly two shapes to swap their positions.");
setStatusType("warning");
return; return;
} }
// Get the two shapes // Get the two shapes
const shapeObj1 = shapes.items[0]; const [shape1, shape2] = await loadShapePositions(
const shapeObj2 = shapes.items[1]; shapes.items[0],
shapes.items[1],
// Load position properties context
shapeObj1.load("left,top"); );
shapeObj2.load("left,top");
await context.sync();
// Store the position of the first shape
const tempLeft = shapeObj1.left;
const tempTop = shapeObj1.top;
// Swap positions // Swap positions
shapeObj1.left = shapeObj2.left; const swapSuccess = swapPositions(shape1, shape2);
shapeObj1.top = shapeObj2.top;
shapeObj2.left = tempLeft;
shapeObj2.top = tempTop;
// Sync changes to PowerPoint
await context.sync(); await context.sync();
setStatusMessage("Positions of the two shapes have been swapped successfully."); // Update status message
setStatusType("success"); const statusMessage = generateStatusMessage(swapSuccess);
setStatusMessage(statusMessage);
setStatusType(swapSuccess ? "success" : "error");
}); });
} catch (error) { } catch (error) {
setStatusMessage(`Error: ${error.message}`); console.error("Error in swapPositionsOfTwoSelectedObjects:", getErrorMessage(error));
setStatusMessage(`Error: ${getErrorMessage(error)}`);
setStatusType("error"); setStatusType("error");
console.error("Swap positions error:", error);
} finally {
setIsProcessing(false);
} }
}; };
return ( return (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.buttonGroup}> <div className={styles.buttonGroup}>
<Button <ActionButton
appearance="primary" title="Swap Positions"
className={styles.actionButton}
onClick={swapPositionsOfTwoSelectedObjects}
icon={<ArrowSwapRegular />} icon={<ArrowSwapRegular />}
disabled={isProcessing} onClick={swapPositionsOfTwoSelectedObjects}
> />
Swap Positions
</Button>
</div> </div>
</div> </div>
); );
}; };
export default SwapPositions; export default SwapPositions;
+15 -3
View File
@@ -19,9 +19,21 @@ Office.onReady(() => {
); );
}); });
if ((module as any).hot) { // Define proper module hot interface for webpack hot module replacement
(module as any).hot.accept("./components/App", () => { interface HotModule extends NodeModule {
hot?: {
accept(path: string, callback: () => void): void;
};
}
// Use the proper type for module with HMR
if ((module as HotModule).hot) {
(module as HotModule).hot?.accept("./components/App", () => {
const NextApp = require("./components/App").default; const NextApp = require("./components/App").default;
root?.render(NextApp); root?.render(
<FluentProvider theme={webLightTheme}>
<NextApp title={title} />
</FluentProvider>
);
}); });
} }
+48
View File
@@ -0,0 +1,48 @@
/**
* Type definitions for the Office JS API
*/
/**
* Extended Error interface for Office/PowerPoint API errors
*/
export interface OfficeApiError extends Error {
code?: string;
debugInfo?: {
code?: string;
message?: string;
errorLocation?: string;
statement?: string;
innerError?: any;
};
}
/**
* Helper to safely extract error message from any error object
*/
export function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
/**
* Type-safe wrapper for PowerPoint API operations on shapes
*/
export function isPictureShape(shape: PowerPoint.Shape): boolean {
return shape.type === PowerPoint.ShapeType.image;
}
/**
* Type-safe wrapper for working with PowerPoint slides
*/
export function getFirstSelectedSlide(context: PowerPoint.RequestContext): PowerPoint.Slide {
return context.presentation.getSelectedSlides().getItemAt(0);
}
/**
* Type-safe utility for selecting shapes
*/
export function selectShapesById(slide: PowerPoint.Slide, shapeIds: string[]): void {
slide.setSelectedShapes(shapeIds);
}