19 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
18 changed files with 2748 additions and 1262 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)
+12 -1
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.
## 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
```
@@ -340,4 +351,4 @@ Additional performance tips:
- [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)
- [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)
+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.
+137 -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 {
Button,
@@ -14,6 +21,35 @@ import {
import { useStatusContext } from "./App";
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({
alignmentGrid: {
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 = () => {
const styles = useStyles();
const commonStyles = useCommonStyles();
const {
statusMessage, setStatusMessage,
statusType, setStatusType,
isProcessing, setIsProcessing
setStatusMessage,
setStatusType,
isProcessing,
setIsProcessing
} = 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);
try {
await PowerPoint.run(async (context) => {
@@ -51,31 +112,45 @@ export const AlignmentButtons: React.FC = () => {
// Check if shapes are selected
if (shapes.items.length === 0) {
setStatusMessage("No shapes are selected. Please select shapes first.");
setStatusMessage(ALIGNMENT_CONFIG.NO_SELECTION_WARNING);
setStatusType("warning");
return;
}
// Set alignment for all shapes at once
// Set alignment for all shapes at once based on alignment type
shapes.items.forEach(shape => {
shape.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();
setStatusMessage("Objects aligned to left.");
setStatusMessage(`Objects aligned to ${alignmentType}.`);
setStatusType("success");
});
} catch (error) {
setStatusMessage(`Error: ${error.message}`);
setStatusType("error");
console.error("Align left error:", error);
handleError(error, `Align ${alignmentType}`);
} finally {
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);
try {
await PowerPoint.run(async (context) => {
@@ -86,168 +161,79 @@ export const AlignmentButtons: React.FC = () => {
// Check if shapes are selected
if (shapes.items.length === 0) {
setStatusMessage("No shapes are selected. Please select shapes first.");
setStatusMessage(ALIGNMENT_CONFIG.NO_SELECTION_WARNING);
setStatusType("warning");
return;
}
// Set alignment for all shapes at once
// Set alignment for all shapes at once based on alignment type
shapes.items.forEach(shape => {
shape.left = 480 - (shape.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();
setStatusMessage("Objects aligned to center.");
setStatusMessage(`Objects aligned to ${alignmentType}.`);
setStatusType("success");
});
} catch (error) {
setStatusMessage(`Error: ${error.message}`);
setStatusType("error");
console.error("Align center error:", error);
handleError(error, `Align ${alignmentType}`);
} finally {
setIsProcessing(false);
}
};
const alignRight = async () => {
setIsProcessing(true);
try {
await PowerPoint.run(async (context) => {
// Get the selected shapes
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;
}
// Set alignment for all shapes at once
shapes.items.forEach(shape => {
shape.left = 879.75 - shape.width;
});
// Single sync after all updates
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);
}
/**
* Aligns selected shapes to the left edge of the slide
*/
const alignLeft = async (): Promise<void> => {
await handleHorizontalAlignment('left');
};
const alignTop = async () => {
setIsProcessing(true);
try {
await PowerPoint.run(async (context) => {
// Get the selected shapes
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;
}
// Set alignment for all shapes at once
shapes.items.forEach(shape => {
shape.top = 136.75;
});
// Single sync after all updates
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);
}
/**
* Aligns selected shapes to the horizontal center of the slide
*/
const alignCenter = async (): Promise<void> => {
await handleHorizontalAlignment('center');
};
const alignMiddle = async () => {
setIsProcessing(true);
try {
await PowerPoint.run(async (context) => {
// Get the selected shapes
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;
}
// Set alignment for all shapes at once
shapes.items.forEach(shape => {
shape.top = 321 - (shape.height / 2);
});
// Single sync after all updates
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);
}
/**
* Aligns selected shapes to the right edge of the slide
*/
const alignRight = async (): Promise<void> => {
await handleHorizontalAlignment('right');
};
const alignBottom = async () => {
setIsProcessing(true);
try {
await PowerPoint.run(async (context) => {
// Get the selected shapes
const shapes = context.presentation.getSelectedShapes();
shapes.load("items");
await context.sync();
/**
* Aligns selected shapes to the top edge of the slide
*/
const alignTop = async (): Promise<void> => {
await handleVerticalAlignment('top');
};
// Check if shapes are selected
if (shapes.items.length === 0) {
setStatusMessage("No shapes are selected. Please select shapes first.");
setStatusType("warning");
return;
}
/**
* Aligns selected shapes to the vertical middle of the slide
*/
const alignMiddle = async (): Promise<void> => {
await handleVerticalAlignment('middle');
};
// Set alignment for all shapes at once
shapes.items.forEach(shape => {
shape.top = 505.25 - shape.height;
});
// Single sync after all updates
await context.sync();
setStatusMessage("Objects aligned to bottom.");
setStatusType("success");
});
} catch (error) {
setStatusMessage(`Error: ${error.message}`);
setStatusType("error");
console.error("Align bottom error:", error);
} finally {
setIsProcessing(false);
}
/**
* Aligns selected shapes to the bottom edge of the slide
*/
const alignBottom = async (): Promise<void> => {
await handleVerticalAlignment('bottom');
};
return (
@@ -318,4 +304,4 @@ export const AlignmentButtons: React.FC = () => {
);
};
export default AlignmentButtons;
export default AlignmentButtons;
+8 -1
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 { useEffect, useState } from "react";
import MatchSizes from "./MatchSizes";
@@ -193,7 +200,7 @@ const App: React.FC<AppProps> = () => {
)}
<div className={styles.footer}>
<Text size={100}>Edison v1.1.0</Text>
<Text size={100}>Edison v1.2.0-dev</Text>
</div>
</div>
</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 {
Button,
@@ -10,6 +16,35 @@ import {
import { useStatusContext } from "./App";
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({
buttonGrid: {
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 = () => {
const styles = useStyles();
const commonStyles = useCommonStyles();
const {
statusMessage, setStatusMessage,
statusType, setStatusType,
isProcessing, setIsProcessing
setStatusMessage,
setStatusType,
isProcessing,
setIsProcessing
} = 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);
try {
await PowerPoint.run(async (context) => {
try {
// Get all slides in the presentation
const slides = context.presentation.slides;
slides.load("items");
await context.sync();
// Get a reference slide to determine dimensions
// Since we can't access presentation width/height directly
if (slides.items.length === 0) {
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
// Get all slides in the presentation
const slides = context.presentation.slides;
slides.load("items");
await context.sync();
if (slides.items.length === 0) {
setStatusMessage("No slides found in the presentation.");
setStatusType("warning");
return;
}
const result = await processAddMarkings(context, slides.items);
updateStatusFromResult(result, true);
});
} catch (error) {
setStatusMessage(`Error: ${error.message}`);
setStatusType("error");
console.error("Add confidential error:", error);
handleError(error, "Add confidential");
} finally {
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);
try {
await PowerPoint.run(async (context) => {
try {
// Get all slides in the presentation
const slides = context.presentation.slides;
slides.load("items");
await context.sync();
if (slides.items.length === 0) {
setStatusMessage("No slides found in the presentation.");
setStatusType("warning");
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
// Get all slides in the presentation
const slides = context.presentation.slides;
slides.load("items");
await context.sync();
if (slides.items.length === 0) {
setStatusMessage("No slides found in the presentation.");
setStatusType("warning");
return;
}
const result = await processRemoveMarkings(context, slides.items);
updateStatusFromResult(result, false);
});
} catch (error) {
setStatusMessage(`Error: ${error.message}`);
setStatusType("error");
console.error("Remove confidential error:", error);
handleError(error, "Remove confidential");
} finally {
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 (
<div className={commonStyles.container}>
<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 {
Button,
@@ -10,6 +17,42 @@ import {
import { useStatusContext } from "./App";
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({
buttonGrid: {
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 = () => {
const styles = useStyles();
const commonStyles = useCommonStyles();
const {
statusMessage, setStatusMessage,
statusType, setStatusType,
isProcessing, setIsProcessing
setStatusMessage,
setStatusType,
isProcessing,
setIsProcessing
} = 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);
try {
await PowerPoint.run(async (context) => {
@@ -51,187 +156,182 @@ export const DraftButtons: React.FC = () => {
return;
}
// Process counter
let processedMasters = 0;
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");
}
const result = await processAddWatermarks(context, masters.items);
updateStatusFromResult(result, true);
});
} catch (error) {
setStatusMessage(`Error: ${error.message}`);
setStatusType("error");
console.error("Add draft watermark error:", error);
handleError(error, "Add draft watermark");
} finally {
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);
try {
await PowerPoint.run(async (context) => {
try {
// Get the slide masters collection
const masters = context.presentation.slideMasters;
masters.load("items");
await context.sync();
if (masters.items.length === 0) {
setStatusMessage("Could not access slide masters.");
setStatusType("error");
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
// Get the slide masters collection
const masters = context.presentation.slideMasters;
masters.load("items");
await context.sync();
if (masters.items.length === 0) {
setStatusMessage("Could not access slide masters.");
setStatusType("error");
return;
}
const result = await processRemoveWatermarks(context, masters.items);
updateStatusFromResult(result, false);
});
} catch (error) {
setStatusMessage(`Error: ${error.message}`);
setStatusType("error");
console.error("Remove draft watermark error:", error);
handleError(error, "Remove draft watermark");
} finally {
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 (
<div className={commonStyles.container}>
<div className={styles.buttonGrid}>
@@ -260,4 +360,4 @@ export const DraftButtons: React.FC = () => {
);
};
export default DraftButtons;
export default DraftButtons;
+191 -112
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 {
Button,
makeStyles,
Label,
Slider,
SpinButton,
Divider,
ToggleButton,
tokens,
Input
tokens
} from "@fluentui/react-components";
import {
GridRegular,
DismissRegular,
AddRegular,
LineHorizontal3Regular,
SplitVerticalRegular,
GridDotsRegular,
ArrowResetRegular
GridDotsRegular
} from "@fluentui/react-icons";
import { useStatusContext } from "./App";
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({
buttonGrid: {
display: "grid",
@@ -81,18 +130,55 @@ const useStyles = makeStyles({
}
});
type GridColor = "blue" | "red" | "yellow" | "green";
const GRID_PREFIX = "edison_grid_";
const GUIDE_PREFIX = "edison_guide_";
const colorValues = {
blue: "#4472C4",
red: "#C00000",
yellow: "#FFC000",
green: "#70AD47"
/**
* ColorButton component - Renders a color selection button
*
* @param props - Component props
* @returns React component
*/
const ColorButton: React.FC<ColorButtonProps> = ({ color, selectedColor, onClick }) => {
const styles = useStyles();
return (
<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 = () => {
const styles = useStyles();
const commonStyles = useCommonStyles();
@@ -103,17 +189,26 @@ export const GridGuidelineManager: React.FC = () => {
} = useStatusContext();
// Grid state
const [gridSpacing, setGridSpacing] = React.useState(50);
const [gridSpacing, handleGridSpacingChange] = useNumericInputHandler(50);
const [gridColor, setGridColor] = React.useState<GridColor>("blue");
const [gridOpacity, setGridOpacity] = React.useState(30);
const [gridOpacity, handleGridOpacityChange] = useNumericInputHandler(30);
// Guidelines state
const [guidePosition, setGuidePosition] = React.useState(240);
const [guideDirection, setGuideDirection] = React.useState<"horizontal" | "vertical">("horizontal");
const [guidePosition, handleGuidePositionChange] = useNumericInputHandler(240);
const [guideDirection, setGuideDirection] = React.useState<GuideDirection>("horizontal");
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,
startX: number,
startY: number,
@@ -138,9 +233,12 @@ export const GridGuidelineManager: React.FC = () => {
}
return line;
};
}, []);
const createGrid = async () => {
/**
* Creates a grid on the current slide based on the configured settings
*/
const createGrid = React.useCallback(async () => {
setIsProcessing(true);
try {
await PowerPoint.run(async (context) => {
@@ -158,23 +256,19 @@ export const GridGuidelineManager: React.FC = () => {
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
const numHorizontalLines = Math.floor(slideHeight / gridSpacing);
const numVerticalLines = Math.floor(slideWidth / gridSpacing);
const numHorizontalLines = Math.floor(SLIDE_HEIGHT / gridSpacing);
const numVerticalLines = Math.floor(SLIDE_WIDTH / gridSpacing);
// Create horizontal grid lines
for (let i = 1; i <= numHorizontalLines; i++) {
const yPosition = i * gridSpacing;
// Skip if we're at the edge of the slide
if (yPosition >= slideHeight) continue;
if (yPosition >= SLIDE_HEIGHT) continue;
// 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}`;
// Set line properties
@@ -191,10 +285,10 @@ export const GridGuidelineManager: React.FC = () => {
const xPosition = i * gridSpacing;
// Skip if we're at the edge of the slide
if (xPosition >= slideWidth) continue;
if (xPosition >= SLIDE_WIDTH) continue;
// 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}`;
// Set line properties
@@ -216,9 +310,14 @@ export const GridGuidelineManager: React.FC = () => {
} finally {
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) {
setIsProcessing(true);
}
@@ -282,9 +381,12 @@ export const GridGuidelineManager: React.FC = () => {
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);
try {
await PowerPoint.run(async (context) => {
@@ -299,9 +401,6 @@ export const GridGuidelineManager: React.FC = () => {
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
const timestamp = new Date().getTime();
@@ -311,10 +410,10 @@ export const GridGuidelineManager: React.FC = () => {
let line;
if (guideDirection === "horizontal") {
// Create horizontal guideline
line = createLineShape(currentSlide, 0, guidePosition, slideWidth, guidePosition);
line = createLineShape(currentSlide, 0, guidePosition, SLIDE_WIDTH, guidePosition);
} else {
// Create vertical guideline
line = createLineShape(currentSlide, guidePosition, 0, guidePosition, slideHeight);
line = createLineShape(currentSlide, guidePosition, 0, guidePosition, SLIDE_HEIGHT);
}
line.name = guideId;
@@ -336,9 +435,12 @@ export const GridGuidelineManager: React.FC = () => {
} finally {
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);
try {
await PowerPoint.run(async (context) => {
@@ -358,13 +460,17 @@ export const GridGuidelineManager: React.FC = () => {
shapes.load("items");
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;
for (let i = 0; i < shapes.items.length; i++) {
const shape = shapes.items[i];
shape.load("name");
await context.sync();
for (let i = 0; i < allShapes.length; i++) {
const shape = allShapes[i];
// Check if this shape is a guideline
if (shape.name && shape.name.startsWith(GUIDE_PREFIX)) {
shape.delete();
@@ -372,6 +478,7 @@ export const GridGuidelineManager: React.FC = () => {
}
}
// Single sync after all deletions
await context.sync();
if (removedCount > 0) {
@@ -388,20 +495,7 @@ export const GridGuidelineManager: React.FC = () => {
} finally {
setIsProcessing(false);
}
};
// 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)}
/>
);
};
}, [setIsProcessing, setStatusMessage, setStatusType]);
return (
<div className={commonStyles.container} style={{ maxHeight: "400px", overflowY: "auto" }}>
@@ -420,16 +514,11 @@ export const GridGuidelineManager: React.FC = () => {
<input
type="text"
value={gridSpacing}
onChange={(e) => {
const newValue = e.target.value;
const parsed = parseInt(newValue);
if (newValue === '') {
setGridSpacing(0);
} else if (!isNaN(parsed)) {
setGridSpacing(parsed);
}
}}
onChange={handleGridSpacingChange}
onClick={(e) => (e.target as HTMLInputElement).select()}
title="Grid Spacing"
placeholder="Grid Spacing"
aria-label="Grid Spacing"
style={{
width: "100%",
padding: "5px 8px",
@@ -447,16 +536,11 @@ export const GridGuidelineManager: React.FC = () => {
<input
type="text"
value={gridOpacity}
onChange={(e) => {
const newValue = e.target.value;
const parsed = parseInt(newValue);
if (newValue === '') {
setGridOpacity(0);
} else if (!isNaN(parsed)) {
setGridOpacity(parsed);
}
}}
onChange={handleGridOpacityChange}
onClick={(e) => (e.target as HTMLInputElement).select()}
title="Grid Opacity"
placeholder="Grid Opacity"
aria-label="Grid Opacity"
style={{
width: "100%",
padding: "5px 8px",
@@ -478,7 +562,7 @@ export const GridGuidelineManager: React.FC = () => {
</div>
</div>
<div className={styles.buttonGrid} style={{ marginTop: "8px" }}>
<div className={styles.buttonGrid}>
<Button
appearance="primary"
className={styles.gridButton}
@@ -511,32 +595,27 @@ export const GridGuidelineManager: React.FC = () => {
Guidelines
</Label>
<div className={styles.controlRow}>
<Label className={styles.controlLabel}>Position</Label>
<div style={{ width: "80px", flex: "none" }}>
<input
type="text"
value={guidePosition}
onChange={(e) => {
const newValue = e.target.value;
const parsed = parseInt(newValue);
if (newValue === '') {
setGuidePosition(0);
} else if (!isNaN(parsed)) {
setGuidePosition(parsed);
}
}}
onClick={(e) => (e.target as HTMLInputElement).select()}
style={{
width: "100%",
padding: "5px 8px",
border: "1px solid #d1d1d1",
borderRadius: "4px",
fontSize: "14px"
}}
/>
</div>
<div className={styles.controlRow}>
<Label className={styles.controlLabel}>Position</Label>
<div style={{ width: "80px", flex: "none" }}>
<input
type="text"
value={guidePosition}
onChange={handleGuidePositionChange}
onClick={(e) => (e.target as HTMLInputElement).select()}
title="Guide Position"
placeholder="Guide Position"
aria-label="Guide Position"
style={{
width: "100%",
padding: "5px 8px",
border: "1px solid #d1d1d1",
borderRadius: "4px",
fontSize: "14px"
}}
/>
</div>
</div>
<div className={styles.controlRow}>
<Label className={styles.controlLabel}>Type</Label>
@@ -601,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 {
Button
} from "@fluentui/react-components";
import {
TextBulletListRegular
} from "@fluentui/react-icons";
import { TextBulletListRegular } from "@fluentui/react-icons";
import { useStatusContext } from "./App";
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 = () => {
const styles = useCommonStyles();
const {
statusMessage, setStatusMessage,
statusType, setStatusType,
isProcessing, setIsProcessing
} = useStatusContext();
const { setStatusMessage, setStatusType } = useStatusContext();
const [titleDetectionMethod, setTitleDetectionMethod] = React.useState<TitleDetectionMethod>("auto");
const [includeSlideNumbers, setIncludeSlideNumbers] = React.useState<boolean>(false);
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 {
await PowerPoint.run(async (context) => {
try {
// Get the selected shape
const selectedShapes = context.presentation.getSelectedShapes();
selectedShapes.load("items");
await context.sync();
// 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];
let titleCandidates: TitleCandidate[] = [];
// First pass: look for title shapes by name
// This is the most reliable method if available
if (method === "auto" || method === "name") {
for (let j = 0; j < shapes.items.length; j++) {
const shape = shapes.items[j];
// Check if the selected shape has a text frame
selectedShape.load("textFrame");
// Check if this shape has a name that indicates it's a title
shape.load("name,textFrame");
await context.sync();
if (!selectedShape.textFrame) {
setStatusMessage("Please select a text box to insert slide titles.");
setStatusType("warning");
return;
}
// Get all slides in the presentation
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");
// Check if this might be a title shape by name
if (shape.name &&
(shape.name.toLowerCase().includes("title") ||
shape.name.toLowerCase().includes("heading"))) {
if (shape.textFrame) {
shape.textFrame.load("textRange");
await context.sync();
// Get title text using a simplified approach
// This gets all the shapes and looks for one with text content positioned at the top
const shapes = slide.shapes;
shapes.load("items");
await context.sync();
// Look for shapes with text content
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;
if (shape.textFrame.textRange) {
shape.textFrame.textRange.load("text");
await context.sync();
const shapeText = shape.textFrame.textRange.text;
if (shapeText && shapeText.trim() !== "") {
return { text: shapeText };
}
}
} 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) {
setStatusMessage(`Error: ${error.message}`);
console.error("Error in collectAndInsertTitles:", getErrorMessage(error));
setStatusMessage(`Error: ${getErrorMessage(error)}`);
setStatusType("error");
console.error("Collect titles error:", error);
} finally {
setIsProcessing(false);
}
};
return (
<div className={styles.container}>
<div className={styles.buttonGroup}>
<Button
appearance="primary"
className={styles.actionButton}
onClick={collectAndInsertTitles}
<ActionButton
title="Insert Titles"
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
</Button>
<option value="auto">Auto-detect titles</option>
<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>
);
};
export default InsertTitles;
export default InsertTitles;
+328 -212
View File
@@ -1,231 +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 {
Button
} from "@fluentui/react-components";
import {
ColorRegular
} from "@fluentui/react-icons";
import { ColorRegular } from "@fluentui/react-icons";
import { useStatusContext } from "./App";
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 = () => {
const styles = useCommonStyles();
const {
statusMessage, setStatusMessage,
statusType, setStatusType,
isProcessing, setIsProcessing
} = useStatusContext();
const { setStatusMessage, setStatusType } = useStatusContext();
const matchPropertiesToFirstSelected = async () => {
setIsProcessing(true);
try {
await PowerPoint.run(async (context) => {
// Get the selected shapes
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;
}
// 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;
}
}
// First, load all text frames in a batch
if (hasText) {
// Pre-load textFrame for all target shapes
for (let i = 1; i < shapes.items.length; i++) {
shapes.items[i].load("textFrame");
}
await context.sync();
// 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();
}
// Now apply properties to all shapes
let successCount = 0;
let textStyleCount = 0;
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;
}
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;
}
propertiesApplied = true;
} catch (err) {
console.error(`Error applying fill format to shape ${i}:`, err);
}
// Apply text properties if the source has text
if (hasText && targetShape.textFrame && targetShape.textFrame.textRange) {
try {
const sourceFont = firstShape.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;
}
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);
}
}
// Single sync after all property changes
await context.sync();
// 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);
/**
* 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 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 (
<div className={styles.container}>
<div className={styles.buttonGroup}>
<Button
appearance="primary"
className={styles.actionButton}
onClick={matchPropertiesToFirstSelected}
<ActionButton
title="Match Properties"
icon={<ColorRegular />}
disabled={isProcessing}
>
Match Properties
</Button>
onClick={matchPropertiesToFirstSelected}
/>
</div>
</div>
);
};
export default MatchProperties;
export default MatchProperties;
+162 -32
View File
@@ -1,49 +1,179 @@
/**
* @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 { ArrowFitInRegular } from "@fluentui/react-icons";
import { ArrowFitInRegular, ArrowMaximizeRegular, ArrowMinimizeRegular } from "@fluentui/react-icons";
import { useStatusContext } from "./App";
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 = () => {
const styles = useCommonStyles();
const { setStatusMessage, setStatusType } = useStatusContext();
const matchSizeToFirstSelected = async () => {
await PowerPoint.run(async (context) => {
// Get the selected shapes
const shapes = context.presentation.getSelectedShapes();
shapes.load("items");
await context.sync();
/**
* 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;
}
// Check if shapes are selected
if (shapes.items.length === 0) {
setStatusMessage("No shapes are selected. Please select shapes first.");
setStatusType("warning");
return;
}
if (shapes.items.length === 1) {
setStatusMessage("Please select multiple shapes to resize.");
setStatusType("warning");
return false;
}
// Check if there is more than one shape selected
if (shapes.items.length === 1) {
setStatusMessage("Please select multiple shapes to resize.");
setStatusType("warning");
return;
}
return true;
};
// Get the first shape's dimensions
const firstShape = shapes.items[0];
firstShape.load("width,height");
await context.sync();
/**
* 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;
};
// Loop through the remaining shapes and resize them
/**
* 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++) {
shapes.items[i].width = firstShape.width;
shapes.items[i].height = firstShape.height;
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;
};
await context.sync();
setStatusMessage(`Resized ${shapes.items.length - 1} shapes to match the first selected shape.`);
setStatusType("success");
});
/**
* 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 {
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 = await loadSourceShape(shapes.items[0], context, matchType);
// 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) {
console.error("Error in matchSizeToFirstSelected:", getErrorMessage(error));
setStatusMessage(`Error: ${getErrorMessage(error)}`);
setStatusType("error");
}
};
return (
@@ -52,11 +182,11 @@ export const MatchSizes: React.FC = () => {
<ActionButton
title="Match Sizes"
icon={<ArrowFitInRegular />}
onClick={matchSizeToFirstSelected}
onClick={() => matchSizeToFirstSelected(SizeMatchType.Both)}
/>
</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 {
Button,
@@ -10,6 +17,28 @@ import {
import { useStatusContext } from "./App";
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({
buttonGrid: {
display: "grid",
@@ -36,108 +65,96 @@ export const ProgressBarButtons: React.FC = () => {
isProcessing, setIsProcessing
} = 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 () => {
setIsProcessing(true);
const startTime = performance.now();
try {
await PowerPoint.run(async (context) => {
// Get all slides
// Get all slides at once
const presentation = context.presentation;
const slides = presentation.slides;
slides.load("items");
// Need to load the first slide to get access to dimensions
// Single sync to get all slides
await context.sync();
if (slides.items.length === 0) {
throw new Error("No slides in the presentation");
}
// Get dimensions from first slide
const firstSlide = slides.items[0];
// 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();
// Calculate dimensions based on slide count
const { slideWidth, slideHeight, barHeight, distanceFromBottom, overlap } = PROGRESS_BAR_CONFIG;
const slideCount = slides.items.length;
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++) {
const slide = slides.items[i];
// Load slide properties to check layout
slide.load("layout");
await context.sync();
const layoutName = slide.layout.name;
// 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,
// so we're checking for common layouts
if (layoutName.includes("Title") ||
layoutName.includes("Content") ||
layoutName.includes("Two Content") ||
layoutName.includes("Blank")) {
// Add progress bar segments to this slide
const isCompatibleLayout = SUPPORTED_LAYOUTS.some(layout =>
layoutName.includes(layout)
);
if (isCompatibleLayout) {
// Create all segments for this slide in a batch
for (let j = 0; j < slideCount; j++) {
// Create line for this segment with a small overlap to prevent gaps
const overlap = 0.5; // Small overlap to ensure no gaps between segments
// Calculate segment position and dimensions
const startX = j * segmentWidth;
const endX = (j + 1) * segmentWidth;
// Create a rectangle with minimal height to simulate a line
// since proper line creation is challenging with the Office JS API
const line = slide.shapes.addGeometricShape("Rectangle");
const segment = slide.shapes.addGeometricShape("Rectangle");
// Set the rectangle's position to look like a line
// Adding a tiny bit of overlap to ensure no gaps
line.left = startX - (j > 0 ? overlap : 0); // Overlap with previous segment except for first
line.top = startY;
line.width = (endX - startX) + (j > 0 ? overlap : 0) + (j < slideCount - 1 ? overlap : 0);
line.height = progressBarHeight; // Very small height
segment.left = startX - (j > 0 ? overlap : 0);
segment.top = startY;
segment.width = (endX - startX) + (j > 0 ? overlap : 0) + (j < slideCount - 1 ? overlap : 0);
segment.height = barHeight;
// Set fill color based on progress instead of line color
// We're using a rectangle, so fill is more appropriate than line
if (j <= i) {
// Blue for progressed segments (RGB 32, 81, 112)
line.fill.setSolidColor("#205170");
// 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
}
} 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
}
// Set fill color based on progress
const isCompleted = j <= i;
segment.fill.setSolidColor(isCompleted ?
PROGRESS_BAR_CONFIG.completedColor :
PROGRESS_BAR_CONFIG.pendingColor
);
// Make the current slide indicator slightly more prominent
if (j == i) {
segment.width += 1;
}
// 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
line.name = "progress_bar_" + j;
// Use shape name as identifier for later removal
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.");
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 () => {
setIsProcessing(true);
const startTime = performance.now();
try {
await PowerPoint.run(async (context) => {
// Get all slides
// Get all slides at once
const presentation = context.presentation;
const slides = presentation.slides;
slides.load("items");
await context.sync();
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++) {
const slide = slides.items[i];
// Load all shapes in this slide
const shapes = slide.shapes;
shapes.load("items");
await context.sync();
// Find shapes with our tag
const progressBarShapes = [];
shapesToLoad.push({ slide, shapes });
}
// 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++) {
const shape = shapes.items[j];
// Check if this shape name indicates it's a progress bar
shape.load("name");
await context.sync();
// 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++;
allShapesWithNames.push({ slide, shape });
}
}
// Single sync to get all shape names
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) {
// setStatusMessage(`Removed ${removedCount} progress bar elements from slides.`);
// Added progress bar to slides.
setStatusMessage(`Removed progress bar from slides.`);
} else {
setStatusMessage("No progress bar found to remove.");
@@ -240,4 +272,4 @@ export const ProgressBarButtons: React.FC = () => {
);
};
export default ProgressBarButtons;
export default ProgressBarButtons;
+136 -76
View File
@@ -1,24 +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 {
Button
} from "@fluentui/react-components";
import {
CircleRegular
} from "@fluentui/react-icons";
import { CircleRegular } from "@fluentui/react-icons";
import { useStatusContext } from "./App";
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 = () => {
const styles = useCommonStyles();
const {
statusMessage, setStatusMessage,
statusType, setStatusType,
isProcessing, setIsProcessing
} = useStatusContext();
const { setStatusMessage, setStatusType } = 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 {
await PowerPoint.run(async (context) => {
// Get the selected shapes
@@ -26,89 +135,40 @@ export const RoundImage: React.FC = () => {
shapes.load("items");
await context.sync();
if (shapes.items.length === 0) {
setStatusMessage("No shapes are selected. Please select an image.");
setStatusType("warning");
// Validate selection and get the image shape
const imageShape = await validateShapeSelection(shapes, context);
if (!imageShape) {
return;
}
// 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;
}
// Load current dimensions to maintain aspect ratio
shape.load(["width", "height", "left", "top", "id"]);
await context.sync();
// Store current dimensions
const width = shape.width;
const height = shape.height;
// Get the current slide using our type-safe utility
const slide = getFirstSelectedSlide(context);
// Create elliptical mask with proper type
const maskShape = slide.shapes.addGeometricShape(PowerPoint.GeometricShapeType.ellipse);
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");
// Use our type-safe utility for selecting shapes
selectShapesById(slide, [shape.id, maskShape.id]);
// Ensure we maintain the same size
shape.width = width;
shape.height = height;
await context.sync();
// Create the mask shape
const maskShape = await createMaskShape(slide, imageShape, context);
// Apply the mask and select both shapes
await applyMaskToImage(slide, imageShape, maskShape, context);
});
} catch (error: unknown) {
const errorMessage = getErrorMessage(error);
setStatusMessage(`Error: ${errorMessage}`);
setStatusType("error");
console.error("Round image error:", error);
} finally {
setIsProcessing(false);
}
};
return (
<div className={styles.container}>
<div className={styles.buttonGroup}>
<Button
appearance="primary"
className={styles.actionButton}
onClick={convertToRoundImage}
<ActionButton
title="Round Image"
icon={<CircleRegular />}
disabled={isProcessing}
>
Round Image
</Button>
onClick={convertToRoundImage}
/>
</div>
</div>
);
};
export default RoundImage;
export default RoundImage;
+125 -33
View File
@@ -1,51 +1,143 @@
/**
* @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 { ArrowSwapRegular } from "@fluentui/react-icons";
import { useStatusContext } from "./App";
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 = () => {
const styles = useCommonStyles();
const { setStatusMessage, setStatusType } = useStatusContext();
const swapPositionsOfTwoSelectedObjects = async () => {
await PowerPoint.run(async (context) => {
// Get the selected shapes
const shapes = context.presentation.getSelectedShapes();
shapes.load("items/count");
await context.sync();
/**
* 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;
};
// Check if exactly two shapes are selected
if (shapes.items.length !== 2) {
setStatusMessage("Please select exactly two shapes to swap their positions.");
setStatusType("warning");
return;
}
/**
* 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];
};
// Get the two shapes
const shapeObj1 = shapes.items[0];
const shapeObj2 = shapes.items[1];
// Load position properties
shapeObj1.load("left,top");
shapeObj2.load("left,top");
await context.sync();
/**
* 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 tempLeft = shapeObj1.left;
const tempTop = shapeObj1.top;
const tempPosition: ShapePosition = {
left: shape1.left,
top: shape1.top
};
// Swap positions
shapeObj1.left = shapeObj2.left;
shapeObj1.top = shapeObj2.top;
shapeObj2.left = tempLeft;
shapeObj2.top = tempTop;
shape1.left = shape2.left;
shape1.top = shape2.top;
shape2.left = tempPosition.left;
shape2.top = tempPosition.top;
await context.sync();
setStatusMessage("Positions of the two shapes have been swapped successfully.");
setStatusType("success");
});
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 {
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 two shapes
const [shape1, shape2] = await loadShapePositions(
shapes.items[0],
shapes.items[1],
context
);
// Swap positions
const swapSuccess = swapPositions(shape1, shape2);
// Sync changes to PowerPoint
await context.sync();
// Update status message
const statusMessage = generateStatusMessage(swapSuccess);
setStatusMessage(statusMessage);
setStatusType(swapSuccess ? "success" : "error");
});
} catch (error) {
console.error("Error in swapPositionsOfTwoSelectedObjects:", getErrorMessage(error));
setStatusMessage(`Error: ${getErrorMessage(error)}`);
setStatusType("error");
}
};
return (
@@ -61,4 +153,4 @@ export const SwapPositions: React.FC = () => {
);
};
export default SwapPositions;
export default SwapPositions;