Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 16491c00ce | |||
| e82df2f308 | |||
| 626b7aaa7a | |||
| 1aeef10ebe | |||
| b1699a74bc | |||
| b76b34a332 | |||
| 9bb1bf2cdb | |||
| 54328baaec | |||
| fea088fe26 | |||
| d6c32b778c | |||
| 1ed34ab888 | |||
| e0343dfd65 | |||
| d67db17f4b | |||
| 36190f1ba5 | |||
| 4ca09b8098 | |||
| 5e02bfc81e | |||
| ba304f0047 | |||
| 577d5b15c8 | |||
| 04c86c6162 | |||
| 3dc9afea50 | |||
| 338810ef60 | |||
| 50966867b2 | |||
| 07b0232726 | |||
| d09dec4706 | |||
| 0cbb9c948e |
@@ -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)
|
||||
@@ -159,5 +159,6 @@ This project is licensed under the MIT License - see the LICENSE file for detail
|
||||
|
||||
## Version History
|
||||
|
||||
- v1.1.0 - Performance improvements, code quality, and UI enhancements
|
||||
- v1.0.0 - First stable release
|
||||
- v0.1.0 - Initial release with core functionality
|
||||
+102
-5
@@ -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
|
||||
|
||||
```
|
||||
@@ -24,18 +35,24 @@ The application follows a component-based architecture where each tool is encaps
|
||||
│ │ └── commands.ts # Command implementations
|
||||
│ └── taskpane/ # Main add-in UI
|
||||
│ ├── components/ # React components
|
||||
│ │ ├── ActionButton.tsx # Reusable button with error handling
|
||||
│ │ ├── AlignmentButtons.tsx
|
||||
│ │ ├── App.tsx # Main application component
|
||||
│ │ ├── ConfidentialButtons.tsx
|
||||
│ │ ├── DraftButtons.tsx
|
||||
│ │ ├── GridGuidelineManager.tsx
|
||||
│ │ ├── Header.tsx
|
||||
│ │ ├── InsertTitles.tsx
|
||||
│ │ ├── MatchProperties.tsx
|
||||
│ │ ├── MatchSizes.tsx
|
||||
│ │ ├── ProgressBarButtons.tsx
|
||||
│ │ ├── RoundImage.tsx
|
||||
│ │ ├── Section.tsx # Reusable section component
|
||||
│ │ ├── SmartTextFormatter.tsx
|
||||
│ │ ├── SwapPositions.tsx
|
||||
│ │ └── commonStyles.tsx
|
||||
│ ├── types/ # TypeScript type definitions
|
||||
│ │ └── office-types.ts # Office API type utilities
|
||||
│ ├── index.tsx # Entry point
|
||||
│ └── taskpane.html # Main HTML container
|
||||
├── babel.config.json # Babel configuration
|
||||
@@ -92,6 +109,34 @@ npx office-addin-dev-certs install
|
||||
|
||||
## Component Development Guidelines
|
||||
|
||||
### Reusable Components
|
||||
|
||||
#### ActionButton
|
||||
A reusable button component that handles error states, loading states, and consistent styling:
|
||||
|
||||
```tsx
|
||||
import ActionButton from "./ActionButton";
|
||||
|
||||
// In your component:
|
||||
<ActionButton
|
||||
title="Match Sizes"
|
||||
icon={<ArrowFitInRegular />}
|
||||
onClick={myAsyncFunction}
|
||||
/>
|
||||
```
|
||||
|
||||
#### Section
|
||||
A consistent container for organizing the UI:
|
||||
|
||||
```tsx
|
||||
import Section from "./Section";
|
||||
|
||||
// In your component:
|
||||
<Section title="My Feature Group">
|
||||
<MyFeatureComponent />
|
||||
</Section>
|
||||
```
|
||||
|
||||
### Creating a New Tool
|
||||
|
||||
1. Create a new component file in `src/taskpane/components/`
|
||||
@@ -142,6 +187,25 @@ export default MyNewTool;
|
||||
|
||||
### Common PowerPoint.js Operations
|
||||
|
||||
#### Type-Safe Utilities
|
||||
The application provides type-safe utility functions in `src/taskpane/types/office-types.ts`:
|
||||
|
||||
```typescript
|
||||
// Get error message safely from any error type
|
||||
const errorMsg = getErrorMessage(error);
|
||||
|
||||
// Check if a shape is an image
|
||||
if (isPictureShape(shape)) {
|
||||
// It's a picture shape
|
||||
}
|
||||
|
||||
// Get the first selected slide
|
||||
const slide = getFirstSelectedSlide(context);
|
||||
|
||||
// Select shapes by ID
|
||||
selectShapesById(slide, [shape1.id, shape2.id]);
|
||||
```
|
||||
|
||||
#### Selecting Shapes
|
||||
```typescript
|
||||
const shapes = context.presentation.getSelectedShapes();
|
||||
@@ -217,10 +281,40 @@ Recommended testing approach:
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- Minimize `context.sync()` calls, especially in loops
|
||||
- Batch operations when possible
|
||||
### Optimizing context.sync() calls
|
||||
|
||||
A key performance optimization is to batch context.sync() calls:
|
||||
|
||||
```typescript
|
||||
// BAD: Calling context.sync() in a loop
|
||||
for (let i = 0; i < shapes.items.length; i++) {
|
||||
shapes.items[i].load("name");
|
||||
await context.sync(); // Slow! One network roundtrip per shape
|
||||
if (shapes.items[i].name === "Target") {
|
||||
// Do something
|
||||
}
|
||||
}
|
||||
|
||||
// GOOD: Batching context.sync() calls
|
||||
// Load all properties at once
|
||||
for (let i = 0; i < shapes.items.length; i++) {
|
||||
shapes.items[i].load("name");
|
||||
}
|
||||
await context.sync(); // Single network roundtrip for all shapes
|
||||
|
||||
// Process the data locally
|
||||
for (let i = 0; i < shapes.items.length; i++) {
|
||||
if (shapes.items[i].name === "Target") {
|
||||
// Do something
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Additional performance tips:
|
||||
- Use memoization for expensive calculations
|
||||
- Ensure proper cleanup of event listeners
|
||||
- Use the ActionButton component for consistent loading states
|
||||
- Consider using Web Workers for heavy computations
|
||||
|
||||
## Common Issues and Solutions
|
||||
|
||||
@@ -236,11 +330,14 @@ Recommended testing approach:
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Error Handling**: Always use try/catch blocks with PowerPoint API calls
|
||||
2. **User Feedback**: Provide clear status messages for all operations
|
||||
3. **Accessibility**: Ensure all UI elements have proper labels and keyboard access
|
||||
1. **Error Handling**: Always use try/catch blocks with PowerPoint API calls and use proper types (see `office-types.ts`)
|
||||
2. **User Feedback**: Provide clear status messages for all operations using the StatusContext
|
||||
3. **Accessibility**: Ensure all UI elements have proper ARIA attributes and support keyboard navigation
|
||||
4. **Theme Support**: Use Fluent UI theme tokens for consistent theming
|
||||
5. **Code Organization**: Keep component files focused on a single responsibility
|
||||
6. **Performance**: Batch context.sync() calls and avoid calling them inside loops
|
||||
7. **Reusability**: Use custom hooks for common operations and shared components like ActionButton and Section
|
||||
8. **Type Safety**: Avoid using "any" type and leverage TypeScript's static typing
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
+9
-1
@@ -212,4 +212,12 @@ If you encounter issues not covered in this guide, contact your IT department or
|
||||
|
||||
## Version Information
|
||||
|
||||
This user guide is for Edison PowerPoint Add-in v1.0.0.
|
||||
This user guide is for Edison PowerPoint Add-in v1.1.0.
|
||||
|
||||
### What's New in v1.1.0
|
||||
|
||||
* **Performance Improvements** - Faster operations especially when working with multiple shapes
|
||||
* **Enhanced Stability** - Improved error handling and recovery
|
||||
* **Better UI Experience** - More responsive interface with improved scrolling
|
||||
* **Accessibility Enhancements** - Better support for screen readers and keyboard navigation
|
||||
* **Code Quality** - Improved TypeScript typing for increased reliability
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,195 @@
|
||||
# PowerPoint Toolbox UML Diagram
|
||||
|
||||
This UML diagram represents the structure and relationships of the PowerPoint toolbox add-in.
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
%% Main Application Component
|
||||
class App {
|
||||
+title: string
|
||||
+statusMessage: string
|
||||
+statusType: StatusType
|
||||
+isProcessing: boolean
|
||||
+setStatusMessage(message: string)
|
||||
+setStatusType(type: StatusType)
|
||||
+setIsProcessing(processing: boolean)
|
||||
+getStatusIcon()
|
||||
}
|
||||
|
||||
%% Context Provider
|
||||
class StatusContext {
|
||||
<<Context>>
|
||||
+statusMessage: string
|
||||
+setStatusMessage: function
|
||||
+statusType: StatusType
|
||||
+setStatusType: function
|
||||
+isProcessing: boolean
|
||||
+setIsProcessing: function
|
||||
}
|
||||
|
||||
%% Common UI Components
|
||||
class Section {
|
||||
+title: string
|
||||
+children: ReactNode
|
||||
}
|
||||
|
||||
class ActionButton {
|
||||
+icon: ReactNode
|
||||
+onClick: function
|
||||
+disabled: boolean
|
||||
+title: string
|
||||
+appearance: string
|
||||
+className: string
|
||||
+handleClick()
|
||||
}
|
||||
|
||||
%% Feature Components
|
||||
class MatchSizes {
|
||||
-validateShapeSelection(shapes): boolean
|
||||
-loadSourceShape(sourceShape, context, matchType): Promise
|
||||
-applySizeChanges(sourceShape, shapes, matchType): number
|
||||
-generateStatusMessage(resizedCount, matchType): string
|
||||
-matchSizeToFirstSelected(matchType): Promise
|
||||
}
|
||||
|
||||
class MatchProperties {
|
||||
<<Component>>
|
||||
}
|
||||
|
||||
class RoundImage {
|
||||
<<Component>>
|
||||
}
|
||||
|
||||
class SwapPositions {
|
||||
<<Component>>
|
||||
}
|
||||
|
||||
class InsertTitles {
|
||||
<<Component>>
|
||||
}
|
||||
|
||||
class ConfidentialButtons {
|
||||
<<Component>>
|
||||
}
|
||||
|
||||
class DraftButtons {
|
||||
<<Component>>
|
||||
}
|
||||
|
||||
class ProgressBarButtons {
|
||||
<<Component>>
|
||||
}
|
||||
|
||||
class AlignmentButtons {
|
||||
<<Component>>
|
||||
}
|
||||
|
||||
class GridGuidelineManager {
|
||||
<<Component>>
|
||||
}
|
||||
|
||||
%% Utility Types
|
||||
class OfficeTypes {
|
||||
<<Utility>>
|
||||
+getErrorMessage(error): string
|
||||
+isPictureShape(shape): boolean
|
||||
+getFirstSelectedSlide(context): PowerPoint.Slide
|
||||
+selectShapesById(slide, shapeIds): void
|
||||
}
|
||||
|
||||
class CommonStyles {
|
||||
<<Styles>>
|
||||
+container: style
|
||||
+buttonGroup: style
|
||||
+actionButton: style
|
||||
+statusContainer: style
|
||||
+successStatus: style
|
||||
+warningStatus: style
|
||||
+errorStatus: style
|
||||
+statusIcon: style
|
||||
+statusText: style
|
||||
}
|
||||
|
||||
%% Relationships
|
||||
App *-- StatusContext : provides
|
||||
App *-- Section : contains
|
||||
Section *-- MatchSizes : contains
|
||||
Section *-- MatchProperties : contains
|
||||
Section *-- RoundImage : contains
|
||||
Section *-- SwapPositions : contains
|
||||
Section *-- InsertTitles : contains
|
||||
Section *-- ConfidentialButtons : contains
|
||||
Section *-- DraftButtons : contains
|
||||
Section *-- ProgressBarButtons : contains
|
||||
Section *-- AlignmentButtons : contains
|
||||
Section *-- GridGuidelineManager : contains
|
||||
|
||||
MatchSizes --> ActionButton : uses
|
||||
MatchSizes --> OfficeTypes : uses
|
||||
MatchSizes --> StatusContext : consumes
|
||||
MatchSizes --> CommonStyles : uses
|
||||
|
||||
ActionButton --> StatusContext : consumes
|
||||
ActionButton --> CommonStyles : uses
|
||||
ActionButton --> OfficeTypes : uses
|
||||
|
||||
%% Other components follow similar patterns
|
||||
MatchProperties --> ActionButton : uses
|
||||
MatchProperties --> StatusContext : consumes
|
||||
RoundImage --> ActionButton : uses
|
||||
RoundImage --> StatusContext : consumes
|
||||
SwapPositions --> ActionButton : uses
|
||||
SwapPositions --> StatusContext : consumes
|
||||
InsertTitles --> ActionButton : uses
|
||||
InsertTitles --> StatusContext : consumes
|
||||
ConfidentialButtons --> ActionButton : uses
|
||||
ConfidentialButtons --> StatusContext : consumes
|
||||
DraftButtons --> ActionButton : uses
|
||||
DraftButtons --> StatusContext : consumes
|
||||
ProgressBarButtons --> ActionButton : uses
|
||||
ProgressBarButtons --> StatusContext : consumes
|
||||
AlignmentButtons --> ActionButton : uses
|
||||
AlignmentButtons --> StatusContext : consumes
|
||||
GridGuidelineManager --> ActionButton : uses
|
||||
GridGuidelineManager --> StatusContext : consumes
|
||||
```
|
||||
|
||||
## Diagram Explanation
|
||||
|
||||
This UML diagram shows:
|
||||
|
||||
1. **App Component**: The main container component that provides the StatusContext to all child components.
|
||||
|
||||
2. **StatusContext**: A React context that manages status messages and processing state across the application.
|
||||
|
||||
3. **UI Components**:
|
||||
- **Section**: A reusable component for grouping related functionality
|
||||
- **ActionButton**: A reusable button component with built-in error handling and status updates
|
||||
|
||||
4. **Feature Components**: Various tools for PowerPoint manipulation:
|
||||
- MatchSizes: For matching dimensions between shapes
|
||||
- MatchProperties: For matching properties between shapes
|
||||
- RoundImage: For rounding image corners
|
||||
- SwapPositions: For swapping positions of shapes
|
||||
- InsertTitles: For inserting title slides
|
||||
- ConfidentialButtons: For adding confidential markings
|
||||
- DraftButtons: For adding draft watermarks
|
||||
- ProgressBarButtons: For managing progress bars
|
||||
- AlignmentButtons: For aligning shapes
|
||||
- GridGuidelineManager: For managing grid guidelines
|
||||
|
||||
5. **Utility Types**: Helper functions and types for Office JS API integration
|
||||
|
||||
6. **Relationships**: Shows how components are composed and how they consume the StatusContext
|
||||
|
||||
## Project Architecture
|
||||
|
||||
The PowerPoint toolbox add-in follows a React-based architecture with the following characteristics:
|
||||
|
||||
- **Component-Based Structure**: The application is organized into reusable UI components
|
||||
- **Context API for State Management**: Uses React's Context API for sharing state across components
|
||||
- **Office JS API Integration**: Interacts with PowerPoint through the Office JS API
|
||||
- **Fluent UI Components**: Uses Microsoft's Fluent UI for consistent styling
|
||||
- **Error Handling**: Centralized error handling through the StatusContext
|
||||
|
||||
The add-in is designed to provide various tools for PowerPoint presentations, including shape manipulation, slide formatting, and layout management.
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0" xmlns:ov="http://schemas.microsoft.com/office/taskpaneappversionoverrides" xsi:type="TaskPaneApp">
|
||||
<Id>be5c6f61-4bbf-4bd2-a8da-5031abf096a5</Id>
|
||||
<Version>1.0.0.0</Version>
|
||||
<Version>1.1.0.0</Version>
|
||||
<ProviderName>Contoso</ProviderName>
|
||||
<DefaultLocale>en-US</DefaultLocale>
|
||||
<DisplayName DefaultValue="Edison"/>
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "edison-powerpoint-addin",
|
||||
"version": "0.0.1",
|
||||
"version": "1.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "edison-powerpoint-addin",
|
||||
"version": "0.0.1",
|
||||
"version": "1.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fluentui/react-components": "^9.55.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "edison-powerpoint-addin",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/OfficeDev/Office-Addin-TaskPane-React.git"
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import * as React from "react";
|
||||
import { Button, ButtonProps } from "@fluentui/react-components";
|
||||
import { useStatusContext } from "./App";
|
||||
import { useCommonStyles } from "./commonStyles";
|
||||
import { getErrorMessage } from "../types/office-types";
|
||||
|
||||
export interface ActionButtonProps {
|
||||
icon: ButtonProps["icon"];
|
||||
onClick: () => Promise<void>;
|
||||
disabled?: boolean;
|
||||
title: string;
|
||||
appearance?: "primary" | "secondary" | "outline" | "subtle";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable action button component with consistent styling and error handling
|
||||
*/
|
||||
export const ActionButton: React.FC<ActionButtonProps> = ({
|
||||
icon,
|
||||
onClick,
|
||||
disabled = false,
|
||||
title,
|
||||
appearance = "primary",
|
||||
className = "",
|
||||
}) => {
|
||||
const styles = useCommonStyles();
|
||||
const {
|
||||
setStatusMessage,
|
||||
setStatusType,
|
||||
isProcessing,
|
||||
setIsProcessing
|
||||
} = useStatusContext();
|
||||
|
||||
const handleClick = async () => {
|
||||
if (isProcessing) return;
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
await onClick();
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = getErrorMessage(error);
|
||||
setStatusMessage(`Error: ${errorMessage}`);
|
||||
setStatusType("error");
|
||||
console.error(`Error in ${title} action:`, error);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
appearance={appearance}
|
||||
className={`${styles.actionButton} ${className}`}
|
||||
onClick={handleClick}
|
||||
icon={icon}
|
||||
disabled={disabled || isProcessing}
|
||||
>
|
||||
{title}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActionButton;
|
||||
@@ -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,30 +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;
|
||||
}
|
||||
|
||||
// Loop through all select shapes and align them
|
||||
for (let i = 0; i < shapes.items.length; i++) {
|
||||
shapes.items[i].left = 81.1;
|
||||
// Set alignment for all shapes at once based on alignment type
|
||||
shapes.items.forEach(shape => {
|
||||
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) => {
|
||||
@@ -85,163 +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;
|
||||
}
|
||||
|
||||
// Loop through all select shapes and align them
|
||||
for (let i = 0; i < shapes.items.length; i++) {
|
||||
shapes.items[i].left = 480 - (shapes.items[i].width / 2);
|
||||
// Set alignment for all shapes at once based on alignment type
|
||||
shapes.items.forEach(shape => {
|
||||
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;
|
||||
}
|
||||
|
||||
// Loop through all select shapes and align them
|
||||
for (let i = 0; i < shapes.items.length; i++) {
|
||||
shapes.items[i].left = 879.75 - shapes.items[i].width;
|
||||
}
|
||||
await context.sync();
|
||||
|
||||
setStatusMessage("Objects aligned to right.");
|
||||
setStatusType("success");
|
||||
});
|
||||
} catch (error) {
|
||||
setStatusMessage(`Error: ${error.message}`);
|
||||
setStatusType("error");
|
||||
console.error("Align right error:", error);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
// Loop through all select shapes and align them
|
||||
for (let i = 0; i < shapes.items.length; i++) {
|
||||
shapes.items[i].top = 136.75;
|
||||
}
|
||||
await context.sync();
|
||||
|
||||
setStatusMessage("Objects aligned to top.");
|
||||
setStatusType("success");
|
||||
});
|
||||
} catch (error) {
|
||||
setStatusMessage(`Error: ${error.message}`);
|
||||
setStatusType("error");
|
||||
console.error("Align top error:", error);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
// Loop through all select shapes and align them
|
||||
for (let i = 0; i < shapes.items.length; i++) {
|
||||
shapes.items[i].top = 321 - (shapes.items[i].height / 2);
|
||||
}
|
||||
await context.sync();
|
||||
|
||||
setStatusMessage("Objects aligned to middle.");
|
||||
setStatusType("success");
|
||||
});
|
||||
} catch (error) {
|
||||
setStatusMessage(`Error: ${error.message}`);
|
||||
setStatusType("error");
|
||||
console.error("Align middle error:", error);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
/**
|
||||
* 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');
|
||||
};
|
||||
|
||||
// Loop through all select shapes and align them
|
||||
for (let i = 0; i < shapes.items.length; i++) {
|
||||
shapes.items[i].top = 505.25 - shapes.items[i].height;
|
||||
}
|
||||
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 (
|
||||
|
||||
@@ -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";
|
||||
@@ -10,7 +17,8 @@ import DraftButtons from "./DraftButtons";
|
||||
import ProgressBarButtons from "./ProgressBarButtons";
|
||||
import AlignmentButtons from "./AlignmentButtons";
|
||||
import GridGuidelineManager from "./GridGuidelineManager";
|
||||
import { makeStyles, Text, Subtitle1, tokens, Theme, Spinner } from "@fluentui/react-components";
|
||||
import Section from "./Section";
|
||||
import { makeStyles, Text, tokens, Theme, Spinner } from "@fluentui/react-components";
|
||||
import { ShapeUnionRegular, SquareRegular, InfoRegular } from "@fluentui/react-icons";
|
||||
|
||||
interface AppProps {
|
||||
@@ -42,12 +50,15 @@ const useStyles = makeStyles({
|
||||
root: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
padding: "16px",
|
||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
|
||||
backgroundColor: tokens.colorNeutralBackground1,
|
||||
overflow: "auto",
|
||||
maxHeight: "100vh",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
statusContainer: {
|
||||
marginTop: "4px",
|
||||
@@ -76,22 +87,6 @@ const useStyles = makeStyles({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
},
|
||||
section: {
|
||||
marginBottom: "6px",
|
||||
padding: "12px",
|
||||
borderRadius: "8px",
|
||||
backgroundColor: tokens.colorNeutralBackground2,
|
||||
boxShadow: "0 1px 2px rgba(0, 0, 0, 0.06)",
|
||||
transition: "all 0.2s ease",
|
||||
":hover": {
|
||||
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.1)",
|
||||
},
|
||||
},
|
||||
sectionTitle: {
|
||||
marginBottom: "12px",
|
||||
fontWeight: tokens.fontWeightSemibold,
|
||||
color: tokens.colorBrandForeground1,
|
||||
},
|
||||
footer: {
|
||||
fontSize: "12px",
|
||||
color: tokens.colorNeutralForeground3,
|
||||
@@ -153,10 +148,7 @@ const App: React.FC<AppProps> = () => {
|
||||
setIsProcessing
|
||||
}}>
|
||||
<div className={styles.root} data-theme={theme}>
|
||||
<div className={styles.section}>
|
||||
<Subtitle1 block className={styles.sectionTitle}>
|
||||
Content
|
||||
</Subtitle1>
|
||||
<Section title="Content">
|
||||
<MatchProperties />
|
||||
<div style={{ marginTop: "8px" }}></div>
|
||||
<MatchSizes />
|
||||
@@ -166,42 +158,27 @@ const App: React.FC<AppProps> = () => {
|
||||
<InsertTitles />
|
||||
<div style={{ marginTop: "8px" }}></div>
|
||||
<RoundImage />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<div className={styles.section}>
|
||||
<Subtitle1 block className={styles.sectionTitle}>
|
||||
Progress Bar
|
||||
</Subtitle1>
|
||||
<Section title="Progress Bar">
|
||||
<ProgressBarButtons />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<div className={styles.section}>
|
||||
<Subtitle1 block className={styles.sectionTitle}>
|
||||
Confidential Marking
|
||||
</Subtitle1>
|
||||
<Section title="Confidential Marking">
|
||||
<ConfidentialButtons />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<div className={styles.section}>
|
||||
<Subtitle1 block className={styles.sectionTitle}>
|
||||
Draft Watermark
|
||||
</Subtitle1>
|
||||
<Section title="Draft Watermark">
|
||||
<DraftButtons />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<div className={styles.section}>
|
||||
<Subtitle1 block className={styles.sectionTitle}>
|
||||
Alignment
|
||||
</Subtitle1>
|
||||
<Section title="Alignment">
|
||||
<AlignmentButtons />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<div className={styles.section}>
|
||||
<Subtitle1 block className={styles.sectionTitle}>
|
||||
Layout
|
||||
</Subtitle1>
|
||||
<Section title="Layout">
|
||||
<GridGuidelineManager />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Status message area at the bottom */}
|
||||
{isProcessing && (
|
||||
@@ -223,7 +200,7 @@ const App: React.FC<AppProps> = () => {
|
||||
)}
|
||||
|
||||
<div className={styles.footer}>
|
||||
<Text size={100}>Edison • v1.0.0</Text>
|
||||
<Text size={100}>Edison • v1.2.0-dev</Text>
|
||||
</div>
|
||||
</div>
|
||||
</StatusContext.Provider>
|
||||
|
||||
@@ -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,146 +62,181 @@ 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
|
||||
}
|
||||
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");
|
||||
@@ -178,67 +248,76 @@ export const ConfidentialButtons: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Process counter
|
||||
let processedSlides = 0;
|
||||
let errorSlides = 0;
|
||||
let removedCount = 0;
|
||||
const result = await processRemoveMarkings(context, slides.items);
|
||||
updateStatusFromResult(result, false);
|
||||
});
|
||||
} catch (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.items.length; i++) {
|
||||
for (let i = 0; i < slides.length; i++) {
|
||||
try {
|
||||
const slide = slides.items[i];
|
||||
const slide = slides[i];
|
||||
|
||||
// Load all shapes on the slide
|
||||
slide.shapes.load("items");
|
||||
await context.sync();
|
||||
|
||||
// Find shapes with name "ConfidentialMarking"
|
||||
// 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();
|
||||
|
||||
if (shape.name === "ConfidentialMarking") {
|
||||
// Delete the confidential marking shape
|
||||
shape.delete();
|
||||
removedCount++;
|
||||
// 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();
|
||||
processedSlides++;
|
||||
result.processedSlides++;
|
||||
} catch (slideError) {
|
||||
console.error(`Error processing slide ${i+1}:`, slideError);
|
||||
errorSlides++;
|
||||
result.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
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
setStatusMessage(`Error: ${error.message}`);
|
||||
setStatusType("error");
|
||||
console.error("Remove confidential error:", error);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -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,26 +156,47 @@ export const DraftButtons: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Process counter
|
||||
let processedMasters = 0;
|
||||
let errorMasters = 0;
|
||||
const result = await processAddWatermarks(context, masters.items);
|
||||
updateStatusFromResult(result, true);
|
||||
});
|
||||
} catch (error) {
|
||||
handleError(error, "Add draft watermark");
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.items.length; i++) {
|
||||
for (let i = 0; i < masters.length; i++) {
|
||||
try {
|
||||
const master = masters.items[i];
|
||||
const master = masters[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();
|
||||
// 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");
|
||||
@@ -81,84 +207,48 @@ export const DraftButtons: React.FC = () => {
|
||||
textBox.textFrame.load("textRange");
|
||||
await context.sync();
|
||||
|
||||
// Need to load font and paragraphFormat
|
||||
// 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();
|
||||
|
||||
// Set font properties
|
||||
try {
|
||||
// Ensure the font is loaded properly before setting properties
|
||||
textBox.textFrame.textRange.font.load();
|
||||
// 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();
|
||||
|
||||
// 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";
|
||||
result.affectedCount++;
|
||||
} catch (fontError) {
|
||||
console.error("Error setting font properties:", fontError);
|
||||
// Even if we can't set the font properties exactly, continue with default font
|
||||
// Continue with default font if custom font fails
|
||||
}
|
||||
}
|
||||
|
||||
// Add a name/tag to the shape for identification
|
||||
textBox.name = "DraftWatermark";
|
||||
|
||||
await context.sync();
|
||||
processedMasters++;
|
||||
}
|
||||
result.processedMasters++;
|
||||
} catch (masterError) {
|
||||
console.error(`Error processing master ${i+1}:`, masterError);
|
||||
errorMasters++;
|
||||
result.errorMasters++;
|
||||
// Continue to the next master
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Report results
|
||||
if (processedMasters > 0) {
|
||||
setStatusMessage(`Added draft watermark to ${processedMasters} master slide${processedMasters > 1 ? 's' : ''}.`);
|
||||
setStatusType("success");
|
||||
} else if (errorMasters > 0) {
|
||||
setStatusMessage(`Failed to add markings. Errors on ${errorMasters} master slide${errorMasters > 1 ? 's' : ''}.`);
|
||||
setStatusType("error");
|
||||
} else {
|
||||
setStatusMessage("No master slides found to add draft watermark.");
|
||||
setStatusType("warning");
|
||||
}
|
||||
|
||||
});
|
||||
} catch (error) {
|
||||
setStatusMessage(`Error: ${error.message}`);
|
||||
setStatusType("error");
|
||||
console.error("Add draft watermark error:", error);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const removeDraftWatermark = async () => {
|
||||
/**
|
||||
* 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");
|
||||
@@ -170,66 +260,76 @@ export const DraftButtons: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Process counter
|
||||
let processedMasters = 0;
|
||||
let errorMasters = 0;
|
||||
let removedCount = 0;
|
||||
const result = await processRemoveWatermarks(context, masters.items);
|
||||
updateStatusFromResult(result, false);
|
||||
});
|
||||
} catch (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.items.length; i++) {
|
||||
for (let i = 0; i < masters.length; i++) {
|
||||
try {
|
||||
const master = masters.items[i];
|
||||
const master = masters[i];
|
||||
|
||||
// Load all shapes on the master slide
|
||||
master.shapes.load("items");
|
||||
await context.sync();
|
||||
|
||||
// Find shapes with name "DraftWatermark"
|
||||
// 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();
|
||||
|
||||
if (shape.name === "DraftWatermark") {
|
||||
// Delete the draft watermark shape
|
||||
shape.delete();
|
||||
removedCount++;
|
||||
// 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();
|
||||
processedMasters++;
|
||||
result.processedMasters++;
|
||||
} catch (masterError) {
|
||||
console.error(`Error processing master slide ${i+1}:`, masterError);
|
||||
errorMasters++;
|
||||
result.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
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
setStatusMessage(`Error: ${error.message}`);
|
||||
setStatusType("error");
|
||||
console.error("Remove draft watermark error:", error);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -241,13 +340,17 @@ export const GridGuidelineManager: React.FC = () => {
|
||||
shapes.load("items");
|
||||
await context.sync();
|
||||
|
||||
// Find shapes that are part of our grid
|
||||
let removedCount = 0;
|
||||
for (let i = 0; i < shapes.items.length; i++) {
|
||||
const shape = shapes.items[i];
|
||||
shape.load("name");
|
||||
// Load all shape names at once
|
||||
const allShapes = shapes.items;
|
||||
for (let i = 0; i < allShapes.length; i++) {
|
||||
allShapes[i].load("name");
|
||||
}
|
||||
await context.sync();
|
||||
|
||||
// Find shapes that are part of our grid and mark them for deletion
|
||||
let removedCount = 0;
|
||||
for (let i = 0; i < allShapes.length; i++) {
|
||||
const shape = allShapes[i];
|
||||
// Check if this shape is part of our grid
|
||||
if (shape.name && shape.name.startsWith(GRID_PREFIX)) {
|
||||
shape.delete();
|
||||
@@ -255,6 +358,7 @@ export const GridGuidelineManager: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Single sync after all deletions
|
||||
await context.sync();
|
||||
|
||||
if (showStatus) {
|
||||
@@ -277,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) => {
|
||||
@@ -294,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();
|
||||
@@ -306,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;
|
||||
@@ -331,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) => {
|
||||
@@ -353,13 +460,17 @@ export const GridGuidelineManager: React.FC = () => {
|
||||
shapes.load("items");
|
||||
await context.sync();
|
||||
|
||||
// Find shapes that are guidelines
|
||||
let removedCount = 0;
|
||||
for (let i = 0; i < shapes.items.length; i++) {
|
||||
const shape = shapes.items[i];
|
||||
shape.load("name");
|
||||
// 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 < allShapes.length; i++) {
|
||||
const shape = allShapes[i];
|
||||
// Check if this shape is a guideline
|
||||
if (shape.name && shape.name.startsWith(GUIDE_PREFIX)) {
|
||||
shape.delete();
|
||||
@@ -367,6 +478,7 @@ export const GridGuidelineManager: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Single sync after all deletions
|
||||
await context.sync();
|
||||
|
||||
if (removedCount > 0) {
|
||||
@@ -383,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" }}>
|
||||
@@ -415,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",
|
||||
@@ -442,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",
|
||||
@@ -473,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}
|
||||
@@ -512,16 +601,11 @@ export const GridGuidelineManager: React.FC = () => {
|
||||
<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);
|
||||
}
|
||||
}}
|
||||
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",
|
||||
|
||||
@@ -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;
|
||||
@@ -1,40 +1,72 @@
|
||||
/**
|
||||
* @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 collectAndInsertTitles = async () => {
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
await PowerPoint.run(async (context) => {
|
||||
try {
|
||||
// Get the selected shape
|
||||
const selectedShapes = context.presentation.getSelectedShapes();
|
||||
selectedShapes.load("items");
|
||||
await context.sync();
|
||||
const { setStatusMessage, setStatusType } = useStatusContext();
|
||||
const [titleDetectionMethod, setTitleDetectionMethod] = React.useState<TitleDetectionMethod>("auto");
|
||||
const [includeSlideNumbers, setIncludeSlideNumbers] = React.useState<boolean>(false);
|
||||
|
||||
/**
|
||||
* 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 (selectedShapes.items.length === 0) {
|
||||
if (shapes.items.length === 0) {
|
||||
setStatusMessage("Please select a text box to insert slide titles.");
|
||||
setStatusType("warning");
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the first selected shape
|
||||
const selectedShape = selectedShapes.items[0];
|
||||
const selectedShape = shapes.items[0];
|
||||
|
||||
// Check if the selected shape has a text frame
|
||||
selectedShape.load("textFrame");
|
||||
@@ -43,45 +75,45 @@ export const InsertTitles: React.FC = () => {
|
||||
if (!selectedShape.textFrame) {
|
||||
setStatusMessage("Please select a text box to insert slide titles.");
|
||||
setStatusType("warning");
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get all slides in the presentation
|
||||
const slides = context.presentation.slides;
|
||||
slides.load("items");
|
||||
await context.sync();
|
||||
return selectedShape;
|
||||
};
|
||||
|
||||
// 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++) {
|
||||
/**
|
||||
* 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 {
|
||||
const slide = slides.items[i];
|
||||
let titleCandidates: TitleCandidate[] = [];
|
||||
|
||||
// Load only necessary shape properties
|
||||
slide.load("shapes");
|
||||
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
|
||||
// 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++) {
|
||||
try {
|
||||
const shape = shapes.items[j];
|
||||
|
||||
// Only load textFrame property initially
|
||||
shape.load("textFrame");
|
||||
// Check if this shape has a name that indicates it's a title
|
||||
shape.load("name,textFrame");
|
||||
await context.sync();
|
||||
|
||||
// Only proceed with shapes that have text frames
|
||||
// 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) {
|
||||
// Load text range to see if there's content
|
||||
shape.textFrame.load("textRange");
|
||||
await context.sync();
|
||||
|
||||
@@ -90,80 +122,310 @@ export const InsertTitles: React.FC = () => {
|
||||
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");
|
||||
return { text: shapeText };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
// 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`;
|
||||
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++;
|
||||
break; // Only use the first potential title shape on each slide
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (shapeError) {
|
||||
console.error(`Error processing shape on slide ${i+1}:`, shapeError);
|
||||
// Continue to the next shape
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} catch (slideError) {
|
||||
console.error(`Error processing slide ${i+1}:`, slideError);
|
||||
// Continue to the next slide
|
||||
continue;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error collecting slide titles:", getErrorMessage(error));
|
||||
}
|
||||
|
||||
// Insert the collected titles into the selected text frame
|
||||
if (titleText) {
|
||||
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
|
||||
selectedShape.textFrame.load("textRange");
|
||||
shape.textFrame.load("textRange");
|
||||
await context.sync();
|
||||
|
||||
selectedShape.textFrame.textRange.text = titleText;
|
||||
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();
|
||||
|
||||
setStatusMessage(`Collected and inserted ${titlesCollected} slide titles.`);
|
||||
setStatusType("success");
|
||||
} else {
|
||||
setStatusMessage("No slide titles found to insert.");
|
||||
setStatusType("warning");
|
||||
// Validate selection and get the target shape
|
||||
const targetShape = await validateSelection(selectedShapes, context);
|
||||
if (!targetShape) {
|
||||
return;
|
||||
}
|
||||
} 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();
|
||||
|
||||
// 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>
|
||||
);
|
||||
|
||||
@@ -1,144 +1,224 @@
|
||||
/**
|
||||
* @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";
|
||||
|
||||
export const MatchProperties: React.FC = () => {
|
||||
const styles = useCommonStyles();
|
||||
const {
|
||||
statusMessage, setStatusMessage,
|
||||
statusType, setStatusType,
|
||||
isProcessing, setIsProcessing
|
||||
} = useStatusContext();
|
||||
// Property types that can be matched
|
||||
enum PropertyType {
|
||||
Line = "line",
|
||||
Fill = "fill",
|
||||
Text = "text"
|
||||
}
|
||||
|
||||
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([
|
||||
// Shape property loading configuration
|
||||
const SHAPE_PROPERTIES = {
|
||||
line: [
|
||||
"lineFormat/weight",
|
||||
"lineFormat/dashStyle",
|
||||
"lineFormat/color",
|
||||
"lineFormat/color"
|
||||
],
|
||||
fill: [
|
||||
"fill/transparency",
|
||||
"fill/foregroundColor",
|
||||
"fill/foregroundColor"
|
||||
],
|
||||
text: [
|
||||
"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 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 { setStatusMessage, setStatusType } = useStatusContext();
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
||||
let successCount = 0;
|
||||
let textStyleCount = 0;
|
||||
return { hasText };
|
||||
};
|
||||
|
||||
// Loop through remaining shapes and apply properties
|
||||
/**
|
||||
* 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++) {
|
||||
try {
|
||||
const targetShape = shapes.items[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;
|
||||
|
||||
// Apply line formatting properties
|
||||
try {
|
||||
// Line weight
|
||||
if (firstShape.lineFormat.weight !== undefined) {
|
||||
targetShape.lineFormat.weight = firstShape.lineFormat.weight;
|
||||
if (sourceShape.lineFormat.weight !== undefined) {
|
||||
targetShape.lineFormat.weight = sourceShape.lineFormat.weight;
|
||||
propertiesApplied = true;
|
||||
}
|
||||
|
||||
// Line style
|
||||
if (firstShape.lineFormat.dashStyle !== undefined) {
|
||||
targetShape.lineFormat.dashStyle = firstShape.lineFormat.dashStyle;
|
||||
if (sourceShape.lineFormat.dashStyle !== undefined) {
|
||||
targetShape.lineFormat.dashStyle = sourceShape.lineFormat.dashStyle;
|
||||
propertiesApplied = true;
|
||||
}
|
||||
|
||||
// Line color
|
||||
if (firstShape.lineFormat.color !== undefined) {
|
||||
targetShape.lineFormat.color = firstShape.lineFormat.color;
|
||||
}
|
||||
|
||||
await context.sync();
|
||||
if (sourceShape.lineFormat.color !== undefined) {
|
||||
targetShape.lineFormat.color = sourceShape.lineFormat.color;
|
||||
propertiesApplied = true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error applying line format to shape ${i}:`, err);
|
||||
console.error("Error applying line format:", getErrorMessage(err));
|
||||
}
|
||||
|
||||
// Apply fill properties
|
||||
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 (firstShape.fill.transparency !== undefined) {
|
||||
targetShape.fill.transparency = firstShape.fill.transparency;
|
||||
}
|
||||
|
||||
if (firstShape.fill.foregroundColor !== undefined) {
|
||||
targetShape.fill.foregroundColor = firstShape.fill.foregroundColor;
|
||||
}
|
||||
|
||||
await context.sync();
|
||||
if (sourceShape.fill.transparency !== undefined) {
|
||||
targetShape.fill.transparency = sourceShape.fill.transparency;
|
||||
propertiesApplied = true;
|
||||
} catch (err) {
|
||||
console.error(`Error applying fill format to shape ${i}:`, err);
|
||||
}
|
||||
|
||||
// Apply text properties if the source has text
|
||||
if (hasText) {
|
||||
// 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 {
|
||||
// First check if target shape has text
|
||||
targetShape.load("textFrame");
|
||||
await context.sync();
|
||||
|
||||
if (targetShape.textFrame) {
|
||||
targetShape.textFrame.load("textRange");
|
||||
await context.sync();
|
||||
|
||||
if (targetShape.textFrame.textRange) {
|
||||
const sourceFont = firstShape.textFrame.textRange.font;
|
||||
if (sourceShape.textFrame?.textRange && targetShape.textFrame?.textRange) {
|
||||
const sourceFont = sourceShape.textFrame.textRange.font;
|
||||
const targetFont = targetShape.textFrame.textRange.font;
|
||||
|
||||
// Apply font properties
|
||||
// Apply font properties in batch
|
||||
if (sourceFont.name !== undefined) {
|
||||
targetFont.name = sourceFont.name;
|
||||
}
|
||||
@@ -163,60 +243,102 @@ export const MatchProperties: React.FC = () => {
|
||||
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++;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error applying text format to shape ${i}:`, err);
|
||||
}
|
||||
|
||||
// Count successful shape updates
|
||||
if (lineApplied || fillApplied) {
|
||||
shapeUpdated = true;
|
||||
}
|
||||
|
||||
if (propertiesApplied) {
|
||||
if (shapeUpdated) {
|
||||
successCount++;
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error(`Error updating shape ${i}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Final status message based on what was applied
|
||||
// Single sync after all property changes
|
||||
await context.sync();
|
||||
|
||||
// Generate appropriate status message
|
||||
if (successCount > 0) {
|
||||
let message = `Applied properties to ${successCount} shapes`;
|
||||
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");
|
||||
}
|
||||
|
||||
// Timeout is handled in App.tsx now
|
||||
});
|
||||
} catch (error) {
|
||||
setStatusMessage(`Error: ${error.message}`);
|
||||
setStatusType("error");
|
||||
console.error("Main error:", error);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -1,29 +1,148 @@
|
||||
/**
|
||||
* @file MatchSizes.tsx
|
||||
* @description Component that provides functionality to match dimensions between shapes
|
||||
* in PowerPoint presentations. This allows users to make multiple shapes the same width,
|
||||
* height, or both dimensions, based on the first selected shape.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
Button,
|
||||
Text,
|
||||
Tooltip,
|
||||
InfoLabel,
|
||||
Card
|
||||
} from "@fluentui/react-components";
|
||||
import {
|
||||
ArrowFitInRegular,
|
||||
ArrowSortDownRegular,
|
||||
ArrowSortUpRegular
|
||||
} 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 {
|
||||
statusMessage, setStatusMessage,
|
||||
statusType, setStatusType,
|
||||
isProcessing, setIsProcessing
|
||||
} = useStatusContext();
|
||||
const { setStatusMessage, setStatusType } = useStatusContext();
|
||||
|
||||
const matchSizeToFirstSelected = async () => {
|
||||
setIsProcessing(true);
|
||||
/**
|
||||
* Validates that multiple shapes are selected
|
||||
* @param shapes The collection of selected shapes
|
||||
* @returns True if validation passes, false otherwise
|
||||
*/
|
||||
const validateShapeSelection = (shapes: PowerPoint.ShapeScopedCollection): boolean => {
|
||||
if (shapes.items.length === 0) {
|
||||
setStatusMessage("No shapes are selected. Please select shapes first.");
|
||||
setStatusType("warning");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (shapes.items.length === 1) {
|
||||
setStatusMessage("Please select multiple shapes to resize.");
|
||||
setStatusType("warning");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Loads dimension properties from the source shape
|
||||
* @param sourceShape The shape to copy dimensions from
|
||||
* @param context The PowerPoint request context
|
||||
* @param matchType The type of dimension matching to perform
|
||||
* @returns The loaded source shape
|
||||
*/
|
||||
const loadSourceShape = async (
|
||||
sourceShape: PowerPoint.Shape,
|
||||
context: PowerPoint.RequestContext,
|
||||
matchType: SizeMatchType = SizeMatchType.Both
|
||||
): Promise<PowerPoint.Shape> => {
|
||||
// Load the appropriate dimension properties
|
||||
sourceShape.load(DIMENSION_PROPERTIES[matchType]);
|
||||
await context.sync();
|
||||
return sourceShape;
|
||||
};
|
||||
|
||||
/**
|
||||
* Applies size changes to target shapes
|
||||
* @param sourceShape The shape to copy dimensions from
|
||||
* @param shapes The collection of shapes to resize
|
||||
* @param matchType The type of dimension matching to perform
|
||||
* @returns The number of shapes that were resized
|
||||
*/
|
||||
const applySizeChanges = (
|
||||
sourceShape: PowerPoint.Shape,
|
||||
shapes: PowerPoint.ShapeScopedCollection,
|
||||
matchType: SizeMatchType = SizeMatchType.Both
|
||||
): number => {
|
||||
let resizedCount = 0;
|
||||
|
||||
try {
|
||||
// Apply dimensions to all target shapes
|
||||
for (let i = 1; i < shapes.items.length; i++) {
|
||||
const targetShape = shapes.items[i];
|
||||
|
||||
// Apply width if needed
|
||||
if (matchType === SizeMatchType.Both || matchType === SizeMatchType.Width) {
|
||||
targetShape.width = sourceShape.width;
|
||||
}
|
||||
|
||||
// Apply height if needed
|
||||
if (matchType === SizeMatchType.Both || matchType === SizeMatchType.Height) {
|
||||
targetShape.height = sourceShape.height;
|
||||
}
|
||||
|
||||
resizedCount++;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error applying size changes:", getErrorMessage(err));
|
||||
}
|
||||
|
||||
return resizedCount;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates an appropriate status message based on the results
|
||||
* @param resizedCount The number of shapes that were resized
|
||||
* @param matchType The type of dimension matching performed
|
||||
* @returns The formatted status message
|
||||
*/
|
||||
const generateStatusMessage = (
|
||||
resizedCount: number,
|
||||
matchType: SizeMatchType = SizeMatchType.Both
|
||||
): string => {
|
||||
if (resizedCount === 0) {
|
||||
return "No shapes were resized. Please try again with different shapes.";
|
||||
}
|
||||
|
||||
let dimensionText = "";
|
||||
switch (matchType) {
|
||||
case SizeMatchType.Width:
|
||||
dimensionText = "width";
|
||||
break;
|
||||
case SizeMatchType.Height:
|
||||
dimensionText = "height";
|
||||
break;
|
||||
default:
|
||||
dimensionText = "dimensions";
|
||||
}
|
||||
|
||||
return `Resized ${resizedCount} shapes to match the ${dimensionText} of the first selected shape.`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Main function to match sizes from the first selected shape to others
|
||||
* @param matchType The type of dimension matching to perform
|
||||
*/
|
||||
const matchSizeToFirstSelected = async (
|
||||
matchType: SizeMatchType = SizeMatchType.Both
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await PowerPoint.run(async (context) => {
|
||||
// Get the selected shapes
|
||||
@@ -31,59 +150,40 @@ export const MatchSizes: React.FC = () => {
|
||||
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");
|
||||
// Validate shape selection
|
||||
if (!validateShapeSelection(shapes)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if there is more than one shape selected
|
||||
if (shapes.items.length === 1) {
|
||||
setStatusMessage("Please select multiple shapes to resize.");
|
||||
setStatusType("warning");
|
||||
return;
|
||||
}
|
||||
// Get the first shape to use as template
|
||||
const sourceShape = await loadSourceShape(shapes.items[0], context, matchType);
|
||||
|
||||
// Get the first shape's dimensions
|
||||
const firstShape = shapes.items[0];
|
||||
firstShape.load("width,height");
|
||||
// Apply size changes to all target shapes
|
||||
const resizedCount = applySizeChanges(sourceShape, shapes, matchType);
|
||||
|
||||
// Sync changes to PowerPoint
|
||||
await context.sync();
|
||||
|
||||
// Loop through the remaining shapes and resize them
|
||||
for (let i = 1; i < shapes.items.length; i++) {
|
||||
shapes.items[i].width = firstShape.width;
|
||||
shapes.items[i].height = firstShape.height;
|
||||
}
|
||||
|
||||
await context.sync();
|
||||
setStatusMessage(`Resized ${shapes.items.length - 1} shapes to match the first selected shape.`);
|
||||
setStatusType("success");
|
||||
|
||||
// Timeout is handled in App.tsx now
|
||||
// Update status message
|
||||
const statusMessage = generateStatusMessage(resizedCount, matchType);
|
||||
setStatusMessage(statusMessage);
|
||||
setStatusType(resizedCount > 0 ? "success" : "error");
|
||||
});
|
||||
} catch (error) {
|
||||
setStatusMessage(`Error: ${error.message}`);
|
||||
console.error("Error in matchSizeToFirstSelected:", getErrorMessage(error));
|
||||
setStatusMessage(`Error: ${getErrorMessage(error)}`);
|
||||
setStatusType("error");
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.buttonGroup}>
|
||||
<Button
|
||||
appearance="primary"
|
||||
className={styles.actionButton}
|
||||
onClick={matchSizeToFirstSelected}
|
||||
<ActionButton
|
||||
title="Match Sizes"
|
||||
icon={<ArrowFitInRegular />}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Match Sizes
|
||||
</Button>
|
||||
onClick={() => matchSizeToFirstSelected(SizeMatchType.Both)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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++) {
|
||||
const slide = slides.items[i];
|
||||
slides.items[i].load("layout");
|
||||
}
|
||||
|
||||
// Load slide properties to check layout
|
||||
slide.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];
|
||||
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")) {
|
||||
const isCompatibleLayout = SUPPORTED_LAYOUTS.some(layout =>
|
||||
layoutName.includes(layout)
|
||||
);
|
||||
|
||||
// Add progress bar segments to this slide
|
||||
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");
|
||||
// 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 (last blue segment) slightly more prominent
|
||||
// Make the current slide indicator 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
|
||||
}
|
||||
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");
|
||||
shapesToLoad.push({ slide, shapes });
|
||||
}
|
||||
|
||||
// Single sync to get all shapes
|
||||
await context.sync();
|
||||
|
||||
// Find shapes with our tag
|
||||
const progressBarShapes = [];
|
||||
// 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");
|
||||
allShapesWithNames.push({ slide, shape });
|
||||
}
|
||||
}
|
||||
|
||||
// Single sync to get all shape names
|
||||
await context.sync();
|
||||
|
||||
// Look for shapes with names starting with "progress_bar_"
|
||||
if (shape.name && shape.name.startsWith("progress_bar_")) {
|
||||
progressBarShapes.push(shape);
|
||||
}
|
||||
}
|
||||
// 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) {
|
||||
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.");
|
||||
|
||||
@@ -1,34 +1,40 @@
|
||||
/**
|
||||
* @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 convertToRoundImage = async () => {
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
await PowerPoint.run(async (context) => {
|
||||
// Get the selected shapes
|
||||
const shapes = context.presentation.getSelectedShapes();
|
||||
shapes.load("items");
|
||||
await context.sync();
|
||||
const { setStatusMessage, setStatusType } = useStatusContext();
|
||||
|
||||
/**
|
||||
* 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;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the first selected shape
|
||||
@@ -38,69 +44,128 @@ export const RoundImage: React.FC = () => {
|
||||
shape.load(["type"]);
|
||||
await context.sync();
|
||||
|
||||
// Ensure the shape is a picture
|
||||
if (shape.type !== PowerPoint.ShapeType.image) {
|
||||
// Ensure the shape is a picture using our type-safe utility
|
||||
if (!isPictureShape(shape)) {
|
||||
setStatusMessage("Please select an image.");
|
||||
setStatusType("warning");
|
||||
return;
|
||||
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
|
||||
shape.load(["width", "height", "left", "top", "id"]);
|
||||
imageShape.load(["width", "height", "left", "top", "id"]);
|
||||
await context.sync();
|
||||
|
||||
// Store current dimensions
|
||||
const width = shape.width;
|
||||
const height = shape.height;
|
||||
|
||||
const slide = context.presentation.getSelectedSlides().getItemAt(0);
|
||||
const maskShape = slide.shapes.addGeometricShape("Ellipse" as any);
|
||||
// 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;
|
||||
// Position the mask to match the image
|
||||
maskShape.left = imageShape.left;
|
||||
|
||||
shape.lineFormat.load(["weight"]);
|
||||
imageShape.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.top = imageShape.top;
|
||||
maskShape.width = imageShape.width;
|
||||
maskShape.height = imageShape.height;
|
||||
|
||||
// Style the mask
|
||||
maskShape.fill.setSolidColor(MASK_COLOR);
|
||||
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");
|
||||
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]);
|
||||
|
||||
slide.setSelectedShapes([shape.id, maskShape.id]);
|
||||
// Ensure we maintain the same size
|
||||
shape.width = width;
|
||||
shape.height = height;
|
||||
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
|
||||
const shapes = context.presentation.getSelectedShapes();
|
||||
shapes.load("items");
|
||||
await context.sync();
|
||||
|
||||
// Validate selection and get the image shape
|
||||
const imageShape = await validateShapeSelection(shapes, context);
|
||||
if (!imageShape) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the current slide using our type-safe utility
|
||||
const slide = getFirstSelectedSlide(context);
|
||||
|
||||
// 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) {
|
||||
setStatusMessage(`Error: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = getErrorMessage(error);
|
||||
setStatusMessage(`Error: ${errorMessage}`);
|
||||
setStatusType("error");
|
||||
console.error("Main error:", error);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
console.error("Round image error:", error);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import * as React from "react";
|
||||
import { Subtitle1, makeStyles, tokens } from "@fluentui/react-components";
|
||||
|
||||
interface SectionProps {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles({
|
||||
section: {
|
||||
marginBottom: "6px",
|
||||
padding: "12px",
|
||||
borderRadius: "8px",
|
||||
backgroundColor: tokens.colorNeutralBackground2,
|
||||
boxShadow: "0 1px 2px rgba(0, 0, 0, 0.06)",
|
||||
transition: "all 0.2s ease",
|
||||
":hover": {
|
||||
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.1)",
|
||||
},
|
||||
},
|
||||
sectionTitle: {
|
||||
marginBottom: "12px",
|
||||
fontWeight: tokens.fontWeightSemibold,
|
||||
color: tokens.colorBrandForeground1,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Reusable section component with consistent styling
|
||||
*/
|
||||
export const Section: React.FC<SectionProps> = ({ title, children }) => {
|
||||
const styles = useStyles();
|
||||
|
||||
return (
|
||||
<div className={styles.section}>
|
||||
<Subtitle1 block className={styles.sectionTitle}>
|
||||
{title}
|
||||
</Subtitle1>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Section;
|
||||
@@ -1,82 +1,153 @@
|
||||
/**
|
||||
* @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 {
|
||||
Button
|
||||
} from "@fluentui/react-components";
|
||||
import {
|
||||
ArrowSwapRegular
|
||||
} from "@fluentui/react-icons";
|
||||
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 {
|
||||
statusMessage, setStatusMessage,
|
||||
statusType, setStatusType,
|
||||
isProcessing, setIsProcessing
|
||||
} = useStatusContext();
|
||||
const { setStatusMessage, setStatusType } = useStatusContext();
|
||||
|
||||
const swapPositionsOfTwoSelectedObjects = async () => {
|
||||
setIsProcessing(true);
|
||||
/**
|
||||
* Validates that exactly two shapes are selected
|
||||
* @param shapes The collection of selected shapes
|
||||
* @returns True if validation passes, false otherwise
|
||||
*/
|
||||
const validateShapeSelection = (shapes: PowerPoint.ShapeScopedCollection): boolean => {
|
||||
if (shapes.items.length !== 2) {
|
||||
setStatusMessage("Please select exactly two shapes to swap their positions.");
|
||||
setStatusType("warning");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Loads position properties from the shapes
|
||||
* @param shape1 The first shape
|
||||
* @param shape2 The second shape
|
||||
* @param context The PowerPoint request context
|
||||
* @returns The loaded shapes
|
||||
*/
|
||||
const loadShapePositions = async (
|
||||
shape1: PowerPoint.Shape,
|
||||
shape2: PowerPoint.Shape,
|
||||
context: PowerPoint.RequestContext
|
||||
): Promise<[PowerPoint.Shape, PowerPoint.Shape]> => {
|
||||
// Load position properties for both shapes in a single batch
|
||||
shape1.load(POSITION_PROPERTIES);
|
||||
shape2.load(POSITION_PROPERTIES);
|
||||
await context.sync();
|
||||
return [shape1, shape2];
|
||||
};
|
||||
|
||||
/**
|
||||
* Swaps the positions of two shapes
|
||||
* @param shape1 The first shape
|
||||
* @param shape2 The second shape
|
||||
* @returns True if the swap was successful
|
||||
*/
|
||||
const swapPositions = (
|
||||
shape1: PowerPoint.Shape,
|
||||
shape2: PowerPoint.Shape
|
||||
): boolean => {
|
||||
try {
|
||||
// Store the position of the first shape
|
||||
const tempPosition: ShapePosition = {
|
||||
left: shape1.left,
|
||||
top: shape1.top
|
||||
};
|
||||
|
||||
// Swap positions
|
||||
shape1.left = shape2.left;
|
||||
shape1.top = shape2.top;
|
||||
shape2.left = tempPosition.left;
|
||||
shape2.top = tempPosition.top;
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("Error swapping positions:", getErrorMessage(err));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates an appropriate status message based on the results
|
||||
* @param success Whether the swap was successful
|
||||
* @returns The formatted status message
|
||||
*/
|
||||
const generateStatusMessage = (success: boolean): string => {
|
||||
return success
|
||||
? "Positions of the two shapes have been swapped successfully."
|
||||
: "Failed to swap positions. Please try again with different shapes.";
|
||||
};
|
||||
|
||||
/**
|
||||
* Main function to swap positions of two selected shapes
|
||||
*/
|
||||
const swapPositionsOfTwoSelectedObjects = async (): Promise<void> => {
|
||||
try {
|
||||
await PowerPoint.run(async (context) => {
|
||||
// Get the selected shapes
|
||||
const shapes = context.presentation.getSelectedShapes();
|
||||
shapes.load("items/count");
|
||||
shapes.load("items");
|
||||
await context.sync();
|
||||
|
||||
// Check if exactly two shapes are selected
|
||||
if (shapes.items.length !== 2) {
|
||||
setStatusMessage("Please select exactly two shapes to swap their positions.");
|
||||
setStatusType("warning");
|
||||
// Validate shape selection
|
||||
if (!validateShapeSelection(shapes)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
// Store the position of the first shape
|
||||
const tempLeft = shapeObj1.left;
|
||||
const tempTop = shapeObj1.top;
|
||||
const [shape1, shape2] = await loadShapePositions(
|
||||
shapes.items[0],
|
||||
shapes.items[1],
|
||||
context
|
||||
);
|
||||
|
||||
// Swap positions
|
||||
shapeObj1.left = shapeObj2.left;
|
||||
shapeObj1.top = shapeObj2.top;
|
||||
shapeObj2.left = tempLeft;
|
||||
shapeObj2.top = tempTop;
|
||||
const swapSuccess = swapPositions(shape1, shape2);
|
||||
|
||||
// Sync changes to PowerPoint
|
||||
await context.sync();
|
||||
|
||||
setStatusMessage("Positions of the two shapes have been swapped successfully.");
|
||||
setStatusType("success");
|
||||
// Update status message
|
||||
const statusMessage = generateStatusMessage(swapSuccess);
|
||||
setStatusMessage(statusMessage);
|
||||
setStatusType(swapSuccess ? "success" : "error");
|
||||
});
|
||||
} catch (error) {
|
||||
setStatusMessage(`Error: ${error.message}`);
|
||||
console.error("Error in swapPositionsOfTwoSelectedObjects:", getErrorMessage(error));
|
||||
setStatusMessage(`Error: ${getErrorMessage(error)}`);
|
||||
setStatusType("error");
|
||||
console.error("Swap positions error:", error);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.buttonGroup}>
|
||||
<Button
|
||||
appearance="primary"
|
||||
className={styles.actionButton}
|
||||
onClick={swapPositionsOfTwoSelectedObjects}
|
||||
<ActionButton
|
||||
title="Swap Positions"
|
||||
icon={<ArrowSwapRegular />}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Swap Positions
|
||||
</Button>
|
||||
onClick={swapPositionsOfTwoSelectedObjects}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+15
-3
@@ -19,9 +19,21 @@ Office.onReady(() => {
|
||||
);
|
||||
});
|
||||
|
||||
if ((module as any).hot) {
|
||||
(module as any).hot.accept("./components/App", () => {
|
||||
// Define proper module hot interface for webpack hot module replacement
|
||||
interface HotModule extends NodeModule {
|
||||
hot?: {
|
||||
accept(path: string, callback: () => void): void;
|
||||
};
|
||||
}
|
||||
|
||||
// Use the proper type for module with HMR
|
||||
if ((module as HotModule).hot) {
|
||||
(module as HotModule).hot?.accept("./components/App", () => {
|
||||
const NextApp = require("./components/App").default;
|
||||
root?.render(NextApp);
|
||||
root?.render(
|
||||
<FluentProvider theme={webLightTheme}>
|
||||
<NextApp title={title} />
|
||||
</FluentProvider>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Type definitions for the Office JS API
|
||||
*/
|
||||
|
||||
/**
|
||||
* Extended Error interface for Office/PowerPoint API errors
|
||||
*/
|
||||
export interface OfficeApiError extends Error {
|
||||
code?: string;
|
||||
debugInfo?: {
|
||||
code?: string;
|
||||
message?: string;
|
||||
errorLocation?: string;
|
||||
statement?: string;
|
||||
innerError?: any;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to safely extract error message from any error object
|
||||
*/
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-safe wrapper for PowerPoint API operations on shapes
|
||||
*/
|
||||
export function isPictureShape(shape: PowerPoint.Shape): boolean {
|
||||
return shape.type === PowerPoint.ShapeType.image;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-safe wrapper for working with PowerPoint slides
|
||||
*/
|
||||
export function getFirstSelectedSlide(context: PowerPoint.RequestContext): PowerPoint.Slide {
|
||||
return context.presentation.getSelectedSlides().getItemAt(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-safe utility for selecting shapes
|
||||
*/
|
||||
export function selectShapesById(slide: PowerPoint.Slide, shapeIds: string[]): void {
|
||||
slide.setSelectedShapes(shapeIds);
|
||||
}
|
||||
Reference in New Issue
Block a user