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
|
## Version History
|
||||||
|
|
||||||
|
- v1.1.0 - Performance improvements, code quality, and UI enhancements
|
||||||
- v1.0.0 - First stable release
|
- v1.0.0 - First stable release
|
||||||
- v0.1.0 - Initial release with core functionality
|
- v0.1.0 - Initial release with core functionality
|
||||||
+104
-7
@@ -12,6 +12,17 @@ Edison is a PowerPoint add-in built with:
|
|||||||
|
|
||||||
The application follows a component-based architecture where each tool is encapsulated in its own React component.
|
The application follows a component-based architecture where each tool is encapsulated in its own React component.
|
||||||
|
|
||||||
|
## UML Diagrams
|
||||||
|
|
||||||
|
To better understand the architecture and structure of the Edison PowerPoint Add-in, refer to the following UML diagrams:
|
||||||
|
|
||||||
|
- [UML Diagrams Index](powerpoint-toolbox-uml-index.md) - Overview and index of all UML diagrams
|
||||||
|
- [Component Relationships](powerpoint-toolbox-uml.md) - Shows the relationships between React components
|
||||||
|
- [Project Structure](powerpoint-toolbox-structure-uml.md) - Illustrates the file and directory organization
|
||||||
|
- [Data Flow](powerpoint-toolbox-dataflow-uml.md) - Demonstrates the flow of data and interactions
|
||||||
|
|
||||||
|
These diagrams provide visual representations of the application's architecture, component relationships, and data flow patterns, which can help new developers understand the codebase more quickly.
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -24,18 +35,24 @@ The application follows a component-based architecture where each tool is encaps
|
|||||||
│ │ └── commands.ts # Command implementations
|
│ │ └── commands.ts # Command implementations
|
||||||
│ └── taskpane/ # Main add-in UI
|
│ └── taskpane/ # Main add-in UI
|
||||||
│ ├── components/ # React components
|
│ ├── components/ # React components
|
||||||
|
│ │ ├── ActionButton.tsx # Reusable button with error handling
|
||||||
│ │ ├── AlignmentButtons.tsx
|
│ │ ├── AlignmentButtons.tsx
|
||||||
│ │ ├── App.tsx # Main application component
|
│ │ ├── App.tsx # Main application component
|
||||||
│ │ ├── ConfidentialButtons.tsx
|
│ │ ├── ConfidentialButtons.tsx
|
||||||
│ │ ├── DraftButtons.tsx
|
│ │ ├── DraftButtons.tsx
|
||||||
|
│ │ ├── GridGuidelineManager.tsx
|
||||||
│ │ ├── Header.tsx
|
│ │ ├── Header.tsx
|
||||||
│ │ ├── InsertTitles.tsx
|
│ │ ├── InsertTitles.tsx
|
||||||
│ │ ├── MatchProperties.tsx
|
│ │ ├── MatchProperties.tsx
|
||||||
│ │ ├── MatchSizes.tsx
|
│ │ ├── MatchSizes.tsx
|
||||||
│ │ ├── ProgressBarButtons.tsx
|
│ │ ├── ProgressBarButtons.tsx
|
||||||
│ │ ├── RoundImage.tsx
|
│ │ ├── RoundImage.tsx
|
||||||
|
│ │ ├── Section.tsx # Reusable section component
|
||||||
|
│ │ ├── SmartTextFormatter.tsx
|
||||||
│ │ ├── SwapPositions.tsx
|
│ │ ├── SwapPositions.tsx
|
||||||
│ │ └── commonStyles.tsx
|
│ │ └── commonStyles.tsx
|
||||||
|
│ ├── types/ # TypeScript type definitions
|
||||||
|
│ │ └── office-types.ts # Office API type utilities
|
||||||
│ ├── index.tsx # Entry point
|
│ ├── index.tsx # Entry point
|
||||||
│ └── taskpane.html # Main HTML container
|
│ └── taskpane.html # Main HTML container
|
||||||
├── babel.config.json # Babel configuration
|
├── babel.config.json # Babel configuration
|
||||||
@@ -92,6 +109,34 @@ npx office-addin-dev-certs install
|
|||||||
|
|
||||||
## Component Development Guidelines
|
## Component Development Guidelines
|
||||||
|
|
||||||
|
### Reusable Components
|
||||||
|
|
||||||
|
#### ActionButton
|
||||||
|
A reusable button component that handles error states, loading states, and consistent styling:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import ActionButton from "./ActionButton";
|
||||||
|
|
||||||
|
// In your component:
|
||||||
|
<ActionButton
|
||||||
|
title="Match Sizes"
|
||||||
|
icon={<ArrowFitInRegular />}
|
||||||
|
onClick={myAsyncFunction}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Section
|
||||||
|
A consistent container for organizing the UI:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import Section from "./Section";
|
||||||
|
|
||||||
|
// In your component:
|
||||||
|
<Section title="My Feature Group">
|
||||||
|
<MyFeatureComponent />
|
||||||
|
</Section>
|
||||||
|
```
|
||||||
|
|
||||||
### Creating a New Tool
|
### Creating a New Tool
|
||||||
|
|
||||||
1. Create a new component file in `src/taskpane/components/`
|
1. Create a new component file in `src/taskpane/components/`
|
||||||
@@ -142,6 +187,25 @@ export default MyNewTool;
|
|||||||
|
|
||||||
### Common PowerPoint.js Operations
|
### Common PowerPoint.js Operations
|
||||||
|
|
||||||
|
#### Type-Safe Utilities
|
||||||
|
The application provides type-safe utility functions in `src/taskpane/types/office-types.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Get error message safely from any error type
|
||||||
|
const errorMsg = getErrorMessage(error);
|
||||||
|
|
||||||
|
// Check if a shape is an image
|
||||||
|
if (isPictureShape(shape)) {
|
||||||
|
// It's a picture shape
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the first selected slide
|
||||||
|
const slide = getFirstSelectedSlide(context);
|
||||||
|
|
||||||
|
// Select shapes by ID
|
||||||
|
selectShapesById(slide, [shape1.id, shape2.id]);
|
||||||
|
```
|
||||||
|
|
||||||
#### Selecting Shapes
|
#### Selecting Shapes
|
||||||
```typescript
|
```typescript
|
||||||
const shapes = context.presentation.getSelectedShapes();
|
const shapes = context.presentation.getSelectedShapes();
|
||||||
@@ -217,10 +281,40 @@ Recommended testing approach:
|
|||||||
|
|
||||||
## Performance Considerations
|
## Performance Considerations
|
||||||
|
|
||||||
- Minimize `context.sync()` calls, especially in loops
|
### Optimizing context.sync() calls
|
||||||
- Batch operations when possible
|
|
||||||
|
A key performance optimization is to batch context.sync() calls:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// BAD: Calling context.sync() in a loop
|
||||||
|
for (let i = 0; i < shapes.items.length; i++) {
|
||||||
|
shapes.items[i].load("name");
|
||||||
|
await context.sync(); // Slow! One network roundtrip per shape
|
||||||
|
if (shapes.items[i].name === "Target") {
|
||||||
|
// Do something
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GOOD: Batching context.sync() calls
|
||||||
|
// Load all properties at once
|
||||||
|
for (let i = 0; i < shapes.items.length; i++) {
|
||||||
|
shapes.items[i].load("name");
|
||||||
|
}
|
||||||
|
await context.sync(); // Single network roundtrip for all shapes
|
||||||
|
|
||||||
|
// Process the data locally
|
||||||
|
for (let i = 0; i < shapes.items.length; i++) {
|
||||||
|
if (shapes.items[i].name === "Target") {
|
||||||
|
// Do something
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Additional performance tips:
|
||||||
- Use memoization for expensive calculations
|
- Use memoization for expensive calculations
|
||||||
- Ensure proper cleanup of event listeners
|
- Ensure proper cleanup of event listeners
|
||||||
|
- Use the ActionButton component for consistent loading states
|
||||||
|
- Consider using Web Workers for heavy computations
|
||||||
|
|
||||||
## Common Issues and Solutions
|
## Common Issues and Solutions
|
||||||
|
|
||||||
@@ -236,11 +330,14 @@ Recommended testing approach:
|
|||||||
|
|
||||||
## Best Practices
|
## Best Practices
|
||||||
|
|
||||||
1. **Error Handling**: Always use try/catch blocks with PowerPoint API calls
|
1. **Error Handling**: Always use try/catch blocks with PowerPoint API calls and use proper types (see `office-types.ts`)
|
||||||
2. **User Feedback**: Provide clear status messages for all operations
|
2. **User Feedback**: Provide clear status messages for all operations using the StatusContext
|
||||||
3. **Accessibility**: Ensure all UI elements have proper labels and keyboard access
|
3. **Accessibility**: Ensure all UI elements have proper ARIA attributes and support keyboard navigation
|
||||||
4. **Theme Support**: Use Fluent UI theme tokens for consistent theming
|
4. **Theme Support**: Use Fluent UI theme tokens for consistent theming
|
||||||
5. **Code Organization**: Keep component files focused on a single responsibility
|
5. **Code Organization**: Keep component files focused on a single responsibility
|
||||||
|
6. **Performance**: Batch context.sync() calls and avoid calling them inside loops
|
||||||
|
7. **Reusability**: Use custom hooks for common operations and shared components like ActionButton and Section
|
||||||
|
8. **Type Safety**: Avoid using "any" type and leverage TypeScript's static typing
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
@@ -254,4 +351,4 @@ Recommended testing approach:
|
|||||||
- [Office Add-ins Documentation](https://docs.microsoft.com/en-us/office/dev/add-ins/)
|
- [Office Add-ins Documentation](https://docs.microsoft.com/en-us/office/dev/add-ins/)
|
||||||
- [Office.js API Reference](https://docs.microsoft.com/en-us/javascript/api/overview/office)
|
- [Office.js API Reference](https://docs.microsoft.com/en-us/javascript/api/overview/office)
|
||||||
- [Fluent UI Documentation](https://developer.microsoft.com/en-us/fluentui)
|
- [Fluent UI Documentation](https://developer.microsoft.com/en-us/fluentui)
|
||||||
- [React Documentation](https://reactjs.org/docs/getting-started.html)
|
- [React Documentation](https://reactjs.org/docs/getting-started.html)
|
||||||
|
|||||||
+9
-1
@@ -212,4 +212,12 @@ If you encounter issues not covered in this guide, contact your IT department or
|
|||||||
|
|
||||||
## Version Information
|
## Version Information
|
||||||
|
|
||||||
This user guide is for Edison PowerPoint Add-in v1.0.0.
|
This user guide is for Edison PowerPoint Add-in v1.1.0.
|
||||||
|
|
||||||
|
### What's New in v1.1.0
|
||||||
|
|
||||||
|
* **Performance Improvements** - Faster operations especially when working with multiple shapes
|
||||||
|
* **Enhanced Stability** - Improved error handling and recovery
|
||||||
|
* **Better UI Experience** - More responsive interface with improved scrolling
|
||||||
|
* **Accessibility Enhancements** - Better support for screen readers and keyboard navigation
|
||||||
|
* **Code Quality** - Improved TypeScript typing for increased reliability
|
||||||
@@ -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"?>
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
<OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0" xmlns:ov="http://schemas.microsoft.com/office/taskpaneappversionoverrides" xsi:type="TaskPaneApp">
|
<OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0" xmlns:ov="http://schemas.microsoft.com/office/taskpaneappversionoverrides" xsi:type="TaskPaneApp">
|
||||||
<Id>be5c6f61-4bbf-4bd2-a8da-5031abf096a5</Id>
|
<Id>be5c6f61-4bbf-4bd2-a8da-5031abf096a5</Id>
|
||||||
<Version>1.0.0.0</Version>
|
<Version>1.1.0.0</Version>
|
||||||
<ProviderName>Contoso</ProviderName>
|
<ProviderName>Contoso</ProviderName>
|
||||||
<DefaultLocale>en-US</DefaultLocale>
|
<DefaultLocale>en-US</DefaultLocale>
|
||||||
<DisplayName DefaultValue="Edison"/>
|
<DisplayName DefaultValue="Edison"/>
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "edison-powerpoint-addin",
|
"name": "edison-powerpoint-addin",
|
||||||
"version": "0.0.1",
|
"version": "1.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "edison-powerpoint-addin",
|
"name": "edison-powerpoint-addin",
|
||||||
"version": "0.0.1",
|
"version": "1.1.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fluentui/react-components": "^9.55.1",
|
"@fluentui/react-components": "^9.55.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "edison-powerpoint-addin",
|
"name": "edison-powerpoint-addin",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/OfficeDev/Office-Addin-TaskPane-React.git"
|
"url": "https://github.com/OfficeDev/Office-Addin-TaskPane-React.git"
|
||||||
|
|||||||
@@ -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 * as React from "react";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -14,6 +21,35 @@ import {
|
|||||||
import { useStatusContext } from "./App";
|
import { useStatusContext } from "./App";
|
||||||
import { useCommonStyles } from "./commonStyles";
|
import { useCommonStyles } from "./commonStyles";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration constants for alignment positions
|
||||||
|
*/
|
||||||
|
const ALIGNMENT_CONFIG = {
|
||||||
|
// Horizontal alignment positions
|
||||||
|
HORIZONTAL: {
|
||||||
|
LEFT: 81.1,
|
||||||
|
CENTER: 480, // Center point of slide
|
||||||
|
RIGHT: 879.75
|
||||||
|
},
|
||||||
|
// Vertical alignment positions
|
||||||
|
VERTICAL: {
|
||||||
|
TOP: 136.75,
|
||||||
|
MIDDLE: 321, // Middle point of slide
|
||||||
|
BOTTOM: 505.25
|
||||||
|
},
|
||||||
|
// Warning message when no shapes are selected
|
||||||
|
NO_SELECTION_WARNING: "No shapes are selected. Please select shapes first."
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alignment types for horizontal and vertical alignment
|
||||||
|
*/
|
||||||
|
type HorizontalAlignmentType = 'left' | 'center' | 'right';
|
||||||
|
type VerticalAlignmentType = 'top' | 'middle' | 'bottom';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component-specific styles
|
||||||
|
*/
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
alignmentGrid: {
|
alignmentGrid: {
|
||||||
display: "grid",
|
display: "grid",
|
||||||
@@ -31,16 +67,41 @@ const useStyles = makeStyles({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AlignmentButtons component provides UI and functionality to align
|
||||||
|
* selected shapes in PowerPoint presentations.
|
||||||
|
*
|
||||||
|
* @returns React component
|
||||||
|
*/
|
||||||
export const AlignmentButtons: React.FC = () => {
|
export const AlignmentButtons: React.FC = () => {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
const commonStyles = useCommonStyles();
|
const commonStyles = useCommonStyles();
|
||||||
const {
|
const {
|
||||||
statusMessage, setStatusMessage,
|
setStatusMessage,
|
||||||
statusType, setStatusType,
|
setStatusType,
|
||||||
isProcessing, setIsProcessing
|
isProcessing,
|
||||||
|
setIsProcessing
|
||||||
} = useStatusContext();
|
} = useStatusContext();
|
||||||
|
|
||||||
const alignLeft = async () => {
|
/**
|
||||||
|
* Handles errors and updates the status message
|
||||||
|
*
|
||||||
|
* @param error - The error object
|
||||||
|
* @param operation - The operation being performed (for logging)
|
||||||
|
*/
|
||||||
|
const handleError = (error: any, operation: string): void => {
|
||||||
|
setStatusMessage(`Error: ${error.message || "Unknown error occurred"}`);
|
||||||
|
setStatusType("error");
|
||||||
|
console.error(`${operation} error:`, error);
|
||||||
|
setIsProcessing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic function to handle horizontal alignment of shapes
|
||||||
|
*
|
||||||
|
* @param alignmentType - The type of horizontal alignment to apply
|
||||||
|
*/
|
||||||
|
const handleHorizontalAlignment = async (alignmentType: HorizontalAlignmentType): Promise<void> => {
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
try {
|
try {
|
||||||
await PowerPoint.run(async (context) => {
|
await PowerPoint.run(async (context) => {
|
||||||
@@ -51,30 +112,45 @@ export const AlignmentButtons: React.FC = () => {
|
|||||||
|
|
||||||
// Check if shapes are selected
|
// Check if shapes are selected
|
||||||
if (shapes.items.length === 0) {
|
if (shapes.items.length === 0) {
|
||||||
setStatusMessage("No shapes are selected. Please select shapes first.");
|
setStatusMessage(ALIGNMENT_CONFIG.NO_SELECTION_WARNING);
|
||||||
setStatusType("warning");
|
setStatusType("warning");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loop through all select shapes and align them
|
// Set alignment for all shapes at once based on alignment type
|
||||||
for (let i = 0; i < shapes.items.length; i++) {
|
shapes.items.forEach(shape => {
|
||||||
shapes.items[i].left = 81.1;
|
switch (alignmentType) {
|
||||||
}
|
case 'left':
|
||||||
|
shape.left = ALIGNMENT_CONFIG.HORIZONTAL.LEFT;
|
||||||
|
break;
|
||||||
|
case 'center':
|
||||||
|
shape.left = ALIGNMENT_CONFIG.HORIZONTAL.CENTER - (shape.width / 2);
|
||||||
|
break;
|
||||||
|
case 'right':
|
||||||
|
shape.left = ALIGNMENT_CONFIG.HORIZONTAL.RIGHT - shape.width;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Single sync after all updates
|
||||||
await context.sync();
|
await context.sync();
|
||||||
|
|
||||||
setStatusMessage("Objects aligned to left.");
|
setStatusMessage(`Objects aligned to ${alignmentType}.`);
|
||||||
setStatusType("success");
|
setStatusType("success");
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatusMessage(`Error: ${error.message}`);
|
handleError(error, `Align ${alignmentType}`);
|
||||||
setStatusType("error");
|
|
||||||
console.error("Align left error:", error);
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const alignCenter = async () => {
|
/**
|
||||||
|
* Generic function to handle vertical alignment of shapes
|
||||||
|
*
|
||||||
|
* @param alignmentType - The type of vertical alignment to apply
|
||||||
|
*/
|
||||||
|
const handleVerticalAlignment = async (alignmentType: VerticalAlignmentType): Promise<void> => {
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
try {
|
try {
|
||||||
await PowerPoint.run(async (context) => {
|
await PowerPoint.run(async (context) => {
|
||||||
@@ -85,163 +161,79 @@ export const AlignmentButtons: React.FC = () => {
|
|||||||
|
|
||||||
// Check if shapes are selected
|
// Check if shapes are selected
|
||||||
if (shapes.items.length === 0) {
|
if (shapes.items.length === 0) {
|
||||||
setStatusMessage("No shapes are selected. Please select shapes first.");
|
setStatusMessage(ALIGNMENT_CONFIG.NO_SELECTION_WARNING);
|
||||||
setStatusType("warning");
|
setStatusType("warning");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loop through all select shapes and align them
|
// Set alignment for all shapes at once based on alignment type
|
||||||
for (let i = 0; i < shapes.items.length; i++) {
|
shapes.items.forEach(shape => {
|
||||||
shapes.items[i].left = 480 - (shapes.items[i].width / 2);
|
switch (alignmentType) {
|
||||||
}
|
case 'top':
|
||||||
|
shape.top = ALIGNMENT_CONFIG.VERTICAL.TOP;
|
||||||
|
break;
|
||||||
|
case 'middle':
|
||||||
|
shape.top = ALIGNMENT_CONFIG.VERTICAL.MIDDLE - (shape.height / 2);
|
||||||
|
break;
|
||||||
|
case 'bottom':
|
||||||
|
shape.top = ALIGNMENT_CONFIG.VERTICAL.BOTTOM - shape.height;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Single sync after all updates
|
||||||
await context.sync();
|
await context.sync();
|
||||||
|
|
||||||
setStatusMessage("Objects aligned to center.");
|
setStatusMessage(`Objects aligned to ${alignmentType}.`);
|
||||||
setStatusType("success");
|
setStatusType("success");
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatusMessage(`Error: ${error.message}`);
|
handleError(error, `Align ${alignmentType}`);
|
||||||
setStatusType("error");
|
|
||||||
console.error("Align center error:", error);
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const alignRight = async () => {
|
/**
|
||||||
setIsProcessing(true);
|
* Aligns selected shapes to the left edge of the slide
|
||||||
try {
|
*/
|
||||||
await PowerPoint.run(async (context) => {
|
const alignLeft = async (): Promise<void> => {
|
||||||
// Get the selected shapes
|
await handleHorizontalAlignment('left');
|
||||||
const shapes = context.presentation.getSelectedShapes();
|
|
||||||
shapes.load("items");
|
|
||||||
await context.sync();
|
|
||||||
|
|
||||||
// Check if shapes are selected
|
|
||||||
if (shapes.items.length === 0) {
|
|
||||||
setStatusMessage("No shapes are selected. Please select shapes first.");
|
|
||||||
setStatusType("warning");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Loop through all select shapes and align them
|
|
||||||
for (let i = 0; i < shapes.items.length; i++) {
|
|
||||||
shapes.items[i].left = 879.75 - shapes.items[i].width;
|
|
||||||
}
|
|
||||||
await context.sync();
|
|
||||||
|
|
||||||
setStatusMessage("Objects aligned to right.");
|
|
||||||
setStatusType("success");
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
setStatusMessage(`Error: ${error.message}`);
|
|
||||||
setStatusType("error");
|
|
||||||
console.error("Align right error:", error);
|
|
||||||
} finally {
|
|
||||||
setIsProcessing(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const alignTop = async () => {
|
/**
|
||||||
setIsProcessing(true);
|
* Aligns selected shapes to the horizontal center of the slide
|
||||||
try {
|
*/
|
||||||
await PowerPoint.run(async (context) => {
|
const alignCenter = async (): Promise<void> => {
|
||||||
// Get the selected shapes
|
await handleHorizontalAlignment('center');
|
||||||
const shapes = context.presentation.getSelectedShapes();
|
|
||||||
shapes.load("items");
|
|
||||||
await context.sync();
|
|
||||||
|
|
||||||
// Check if shapes are selected
|
|
||||||
if (shapes.items.length === 0) {
|
|
||||||
setStatusMessage("No shapes are selected. Please select shapes first.");
|
|
||||||
setStatusType("warning");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Loop through all select shapes and align them
|
|
||||||
for (let i = 0; i < shapes.items.length; i++) {
|
|
||||||
shapes.items[i].top = 136.75;
|
|
||||||
}
|
|
||||||
await context.sync();
|
|
||||||
|
|
||||||
setStatusMessage("Objects aligned to top.");
|
|
||||||
setStatusType("success");
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
setStatusMessage(`Error: ${error.message}`);
|
|
||||||
setStatusType("error");
|
|
||||||
console.error("Align top error:", error);
|
|
||||||
} finally {
|
|
||||||
setIsProcessing(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const alignMiddle = async () => {
|
/**
|
||||||
setIsProcessing(true);
|
* Aligns selected shapes to the right edge of the slide
|
||||||
try {
|
*/
|
||||||
await PowerPoint.run(async (context) => {
|
const alignRight = async (): Promise<void> => {
|
||||||
// Get the selected shapes
|
await handleHorizontalAlignment('right');
|
||||||
const shapes = context.presentation.getSelectedShapes();
|
|
||||||
shapes.load("items");
|
|
||||||
await context.sync();
|
|
||||||
|
|
||||||
// Check if shapes are selected
|
|
||||||
if (shapes.items.length === 0) {
|
|
||||||
setStatusMessage("No shapes are selected. Please select shapes first.");
|
|
||||||
setStatusType("warning");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Loop through all select shapes and align them
|
|
||||||
for (let i = 0; i < shapes.items.length; i++) {
|
|
||||||
shapes.items[i].top = 321 - (shapes.items[i].height / 2);
|
|
||||||
}
|
|
||||||
await context.sync();
|
|
||||||
|
|
||||||
setStatusMessage("Objects aligned to middle.");
|
|
||||||
setStatusType("success");
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
setStatusMessage(`Error: ${error.message}`);
|
|
||||||
setStatusType("error");
|
|
||||||
console.error("Align middle error:", error);
|
|
||||||
} finally {
|
|
||||||
setIsProcessing(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const alignBottom = async () => {
|
/**
|
||||||
setIsProcessing(true);
|
* Aligns selected shapes to the top edge of the slide
|
||||||
try {
|
*/
|
||||||
await PowerPoint.run(async (context) => {
|
const alignTop = async (): Promise<void> => {
|
||||||
// Get the selected shapes
|
await handleVerticalAlignment('top');
|
||||||
const shapes = context.presentation.getSelectedShapes();
|
};
|
||||||
shapes.load("items");
|
|
||||||
await context.sync();
|
|
||||||
|
|
||||||
// Check if shapes are selected
|
/**
|
||||||
if (shapes.items.length === 0) {
|
* Aligns selected shapes to the vertical middle of the slide
|
||||||
setStatusMessage("No shapes are selected. Please select shapes first.");
|
*/
|
||||||
setStatusType("warning");
|
const alignMiddle = async (): Promise<void> => {
|
||||||
return;
|
await handleVerticalAlignment('middle');
|
||||||
}
|
};
|
||||||
|
|
||||||
// Loop through all select shapes and align them
|
/**
|
||||||
for (let i = 0; i < shapes.items.length; i++) {
|
* Aligns selected shapes to the bottom edge of the slide
|
||||||
shapes.items[i].top = 505.25 - shapes.items[i].height;
|
*/
|
||||||
}
|
const alignBottom = async (): Promise<void> => {
|
||||||
await context.sync();
|
await handleVerticalAlignment('bottom');
|
||||||
|
|
||||||
setStatusMessage("Objects aligned to bottom.");
|
|
||||||
setStatusType("success");
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
setStatusMessage(`Error: ${error.message}`);
|
|
||||||
setStatusType("error");
|
|
||||||
console.error("Align bottom error:", error);
|
|
||||||
} finally {
|
|
||||||
setIsProcessing(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -312,4 +304,4 @@ export const AlignmentButtons: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AlignmentButtons;
|
export default AlignmentButtons;
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* @file App.tsx
|
||||||
|
* @description Main application component for the PowerPoint toolbox add-in.
|
||||||
|
* This component serves as the container for all tool components and provides
|
||||||
|
* a context for status messages and processing state that can be used by child components.
|
||||||
|
*/
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import MatchSizes from "./MatchSizes";
|
import MatchSizes from "./MatchSizes";
|
||||||
@@ -10,7 +17,8 @@ import DraftButtons from "./DraftButtons";
|
|||||||
import ProgressBarButtons from "./ProgressBarButtons";
|
import ProgressBarButtons from "./ProgressBarButtons";
|
||||||
import AlignmentButtons from "./AlignmentButtons";
|
import AlignmentButtons from "./AlignmentButtons";
|
||||||
import GridGuidelineManager from "./GridGuidelineManager";
|
import GridGuidelineManager from "./GridGuidelineManager";
|
||||||
import { makeStyles, Text, Subtitle1, tokens, Theme, Spinner } from "@fluentui/react-components";
|
import Section from "./Section";
|
||||||
|
import { makeStyles, Text, tokens, Theme, Spinner } from "@fluentui/react-components";
|
||||||
import { ShapeUnionRegular, SquareRegular, InfoRegular } from "@fluentui/react-icons";
|
import { ShapeUnionRegular, SquareRegular, InfoRegular } from "@fluentui/react-icons";
|
||||||
|
|
||||||
interface AppProps {
|
interface AppProps {
|
||||||
@@ -42,12 +50,15 @@ const useStyles = makeStyles({
|
|||||||
root: {
|
root: {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
height: "100%",
|
|
||||||
padding: "16px",
|
padding: "16px",
|
||||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
|
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
|
||||||
backgroundColor: tokens.colorNeutralBackground1,
|
backgroundColor: tokens.colorNeutralBackground1,
|
||||||
overflow: "auto",
|
overflow: "auto",
|
||||||
maxHeight: "100vh",
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
},
|
},
|
||||||
statusContainer: {
|
statusContainer: {
|
||||||
marginTop: "4px",
|
marginTop: "4px",
|
||||||
@@ -76,22 +87,6 @@ const useStyles = makeStyles({
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
section: {
|
|
||||||
marginBottom: "6px",
|
|
||||||
padding: "12px",
|
|
||||||
borderRadius: "8px",
|
|
||||||
backgroundColor: tokens.colorNeutralBackground2,
|
|
||||||
boxShadow: "0 1px 2px rgba(0, 0, 0, 0.06)",
|
|
||||||
transition: "all 0.2s ease",
|
|
||||||
":hover": {
|
|
||||||
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.1)",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
sectionTitle: {
|
|
||||||
marginBottom: "12px",
|
|
||||||
fontWeight: tokens.fontWeightSemibold,
|
|
||||||
color: tokens.colorBrandForeground1,
|
|
||||||
},
|
|
||||||
footer: {
|
footer: {
|
||||||
fontSize: "12px",
|
fontSize: "12px",
|
||||||
color: tokens.colorNeutralForeground3,
|
color: tokens.colorNeutralForeground3,
|
||||||
@@ -153,10 +148,7 @@ const App: React.FC<AppProps> = () => {
|
|||||||
setIsProcessing
|
setIsProcessing
|
||||||
}}>
|
}}>
|
||||||
<div className={styles.root} data-theme={theme}>
|
<div className={styles.root} data-theme={theme}>
|
||||||
<div className={styles.section}>
|
<Section title="Content">
|
||||||
<Subtitle1 block className={styles.sectionTitle}>
|
|
||||||
Content
|
|
||||||
</Subtitle1>
|
|
||||||
<MatchProperties />
|
<MatchProperties />
|
||||||
<div style={{ marginTop: "8px" }}></div>
|
<div style={{ marginTop: "8px" }}></div>
|
||||||
<MatchSizes />
|
<MatchSizes />
|
||||||
@@ -166,42 +158,27 @@ const App: React.FC<AppProps> = () => {
|
|||||||
<InsertTitles />
|
<InsertTitles />
|
||||||
<div style={{ marginTop: "8px" }}></div>
|
<div style={{ marginTop: "8px" }}></div>
|
||||||
<RoundImage />
|
<RoundImage />
|
||||||
</div>
|
</Section>
|
||||||
|
|
||||||
<div className={styles.section}>
|
<Section title="Progress Bar">
|
||||||
<Subtitle1 block className={styles.sectionTitle}>
|
|
||||||
Progress Bar
|
|
||||||
</Subtitle1>
|
|
||||||
<ProgressBarButtons />
|
<ProgressBarButtons />
|
||||||
</div>
|
</Section>
|
||||||
|
|
||||||
<div className={styles.section}>
|
<Section title="Confidential Marking">
|
||||||
<Subtitle1 block className={styles.sectionTitle}>
|
|
||||||
Confidential Marking
|
|
||||||
</Subtitle1>
|
|
||||||
<ConfidentialButtons />
|
<ConfidentialButtons />
|
||||||
</div>
|
</Section>
|
||||||
|
|
||||||
<div className={styles.section}>
|
<Section title="Draft Watermark">
|
||||||
<Subtitle1 block className={styles.sectionTitle}>
|
|
||||||
Draft Watermark
|
|
||||||
</Subtitle1>
|
|
||||||
<DraftButtons />
|
<DraftButtons />
|
||||||
</div>
|
</Section>
|
||||||
|
|
||||||
<div className={styles.section}>
|
<Section title="Alignment">
|
||||||
<Subtitle1 block className={styles.sectionTitle}>
|
|
||||||
Alignment
|
|
||||||
</Subtitle1>
|
|
||||||
<AlignmentButtons />
|
<AlignmentButtons />
|
||||||
</div>
|
</Section>
|
||||||
|
|
||||||
<div className={styles.section}>
|
<Section title="Layout">
|
||||||
<Subtitle1 block className={styles.sectionTitle}>
|
|
||||||
Layout
|
|
||||||
</Subtitle1>
|
|
||||||
<GridGuidelineManager />
|
<GridGuidelineManager />
|
||||||
</div>
|
</Section>
|
||||||
|
|
||||||
{/* Status message area at the bottom */}
|
{/* Status message area at the bottom */}
|
||||||
{isProcessing && (
|
{isProcessing && (
|
||||||
@@ -223,7 +200,7 @@ const App: React.FC<AppProps> = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={styles.footer}>
|
<div className={styles.footer}>
|
||||||
<Text size={100}>Edison • v1.0.0</Text>
|
<Text size={100}>Edison • v1.2.0-dev</Text>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</StatusContext.Provider>
|
</StatusContext.Provider>
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* @file ConfidentialButtons.tsx
|
||||||
|
* @description Component that provides functionality to add and remove confidential markings
|
||||||
|
* to PowerPoint slides. The markings appear as text at the bottom of each slide.
|
||||||
|
*/
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -10,6 +16,35 @@ import {
|
|||||||
import { useStatusContext } from "./App";
|
import { useStatusContext } from "./App";
|
||||||
import { useCommonStyles } from "./commonStyles";
|
import { useCommonStyles } from "./commonStyles";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration constants for confidential markings
|
||||||
|
*/
|
||||||
|
const CONFIDENTIAL_CONFIG = {
|
||||||
|
// Marking text that appears on slides
|
||||||
|
TEXT: "– Confidential –",
|
||||||
|
// Shape name used to identify confidential markings
|
||||||
|
SHAPE_NAME: "ConfidentialMarking",
|
||||||
|
// Font configuration
|
||||||
|
FONT: {
|
||||||
|
NAME: "Inter",
|
||||||
|
SIZE: 8,
|
||||||
|
COLOR: "#DA1335" // RGB(218, 19, 53)
|
||||||
|
},
|
||||||
|
// Default slide dimensions in points (if we can't get actual dimensions)
|
||||||
|
SLIDE: {
|
||||||
|
WIDTH: 960, // 10 inches * 72 points
|
||||||
|
HEIGHT: 540, // 7.5 inches * 72 points
|
||||||
|
},
|
||||||
|
// Text box configuration
|
||||||
|
TEXT_BOX: {
|
||||||
|
HEIGHT: 25,
|
||||||
|
POSITION_FROM_BOTTOM: 23
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component-specific styles
|
||||||
|
*/
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
buttonGrid: {
|
buttonGrid: {
|
||||||
display: "grid",
|
display: "grid",
|
||||||
@@ -27,220 +62,264 @@ const useStyles = makeStyles({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for slide processing results
|
||||||
|
*/
|
||||||
|
interface ProcessingResult {
|
||||||
|
processedSlides: number;
|
||||||
|
errorSlides: number;
|
||||||
|
affectedCount: number; // Either added or removed markings
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ConfidentialButtons component provides UI and functionality to add or remove
|
||||||
|
* confidential markings on PowerPoint slides.
|
||||||
|
*
|
||||||
|
* @returns React component
|
||||||
|
*/
|
||||||
export const ConfidentialButtons: React.FC = () => {
|
export const ConfidentialButtons: React.FC = () => {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
const commonStyles = useCommonStyles();
|
const commonStyles = useCommonStyles();
|
||||||
const {
|
const {
|
||||||
statusMessage, setStatusMessage,
|
setStatusMessage,
|
||||||
statusType, setStatusType,
|
setStatusType,
|
||||||
isProcessing, setIsProcessing
|
isProcessing,
|
||||||
|
setIsProcessing
|
||||||
} = useStatusContext();
|
} = useStatusContext();
|
||||||
|
|
||||||
const addConfidentialMarking = async () => {
|
/**
|
||||||
|
* Handles errors and updates the status message
|
||||||
|
*
|
||||||
|
* @param error - The error object
|
||||||
|
* @param operation - The operation being performed (for logging)
|
||||||
|
*/
|
||||||
|
const handleError = (error: any, operation: string): void => {
|
||||||
|
setStatusMessage(`Error: ${error.message || "Unknown error occurred"}`);
|
||||||
|
setStatusType("error");
|
||||||
|
console.error(`${operation} error:`, error);
|
||||||
|
setIsProcessing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the status message based on processing results
|
||||||
|
*
|
||||||
|
* @param result - The processing result object
|
||||||
|
* @param isAddOperation - Whether this was an add operation (true) or remove operation (false)
|
||||||
|
*/
|
||||||
|
const updateStatusFromResult = (result: ProcessingResult, isAddOperation: boolean): void => {
|
||||||
|
const { processedSlides, errorSlides, affectedCount } = result;
|
||||||
|
|
||||||
|
if (affectedCount > 0) {
|
||||||
|
const action = isAddOperation ? "Added" : "Removed";
|
||||||
|
setStatusMessage(`${action} confidential marking to ${affectedCount} slides.`);
|
||||||
|
setStatusType("success");
|
||||||
|
} else if (errorSlides > 0) {
|
||||||
|
setStatusMessage(`Failed to ${isAddOperation ? "add" : "remove"} markings. Errors on ${errorSlides} slides.`);
|
||||||
|
setStatusType("error");
|
||||||
|
} else {
|
||||||
|
if (isAddOperation) {
|
||||||
|
setStatusMessage("No slides found to add confidential marking.");
|
||||||
|
} else {
|
||||||
|
setStatusMessage("No confidential markings found to remove.");
|
||||||
|
}
|
||||||
|
setStatusType(isAddOperation ? "warning" : "info");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds confidential markings to all slides in the presentation
|
||||||
|
*/
|
||||||
|
const addConfidentialMarking = async (): Promise<void> => {
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
try {
|
try {
|
||||||
await PowerPoint.run(async (context) => {
|
await PowerPoint.run(async (context) => {
|
||||||
try {
|
// Get all slides in the presentation
|
||||||
// Get all slides in the presentation
|
const slides = context.presentation.slides;
|
||||||
const slides = context.presentation.slides;
|
slides.load("items");
|
||||||
slides.load("items");
|
await context.sync();
|
||||||
await context.sync();
|
|
||||||
|
if (slides.items.length === 0) {
|
||||||
// Get a reference slide to determine dimensions
|
setStatusMessage("No slides found in the presentation.");
|
||||||
// Since we can't access presentation width/height directly
|
setStatusType("warning");
|
||||||
if (slides.items.length === 0) {
|
return;
|
||||||
setStatusMessage("No slides found in the presentation.");
|
|
||||||
setStatusType("warning");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get dimensions from the first slide
|
|
||||||
const firstSlide = slides.items[0];
|
|
||||||
|
|
||||||
// Standard PowerPoint slide dimensions (in points)
|
|
||||||
// We'll use these as fallbacks and adjust if we can get actual dimensions
|
|
||||||
let slideWidth = 960; // Default slide width (10 inches * 72 points)
|
|
||||||
let slideHeight = 540; // Default slide height (7.5 inches * 72 points)
|
|
||||||
|
|
||||||
// Process counter
|
|
||||||
let processedSlides = 0;
|
|
||||||
let errorSlides = 0;
|
|
||||||
|
|
||||||
// Process each slide
|
|
||||||
for (let i = 0; i < slides.items.length; i++) {
|
|
||||||
try {
|
|
||||||
const slide = slides.items[i];
|
|
||||||
|
|
||||||
const positionFromTop = slideHeight - 23;
|
|
||||||
|
|
||||||
// Create the textbox - make sure it spans the full width of the slide
|
|
||||||
// This is important for proper centering
|
|
||||||
const textBox = slide.shapes.addTextBox("");
|
|
||||||
|
|
||||||
// Make it span most of the width of the slide (90% centered)
|
|
||||||
// This ensures we have room for the text to be centered
|
|
||||||
const textBoxWidth = slideWidth * 1; // 0.9
|
|
||||||
textBox.left = (slideWidth - textBoxWidth) / 2; // Center it horizontally
|
|
||||||
textBox.top = positionFromTop;
|
|
||||||
textBox.width = textBoxWidth;
|
|
||||||
textBox.height = 25; // Smaller height for footer text
|
|
||||||
|
|
||||||
await context.sync();
|
|
||||||
|
|
||||||
// Load textFrame to set text properties
|
|
||||||
textBox.load("textFrame");
|
|
||||||
await context.sync();
|
|
||||||
|
|
||||||
if (textBox.textFrame) {
|
|
||||||
// Load textRange to set text and properties
|
|
||||||
textBox.textFrame.load("textRange");
|
|
||||||
await context.sync();
|
|
||||||
|
|
||||||
// Need to load font and paragraphFormat
|
|
||||||
textBox.textFrame.textRange.load("font,paragraphFormat");
|
|
||||||
await context.sync();
|
|
||||||
|
|
||||||
// Set font properties
|
|
||||||
try {
|
|
||||||
// Ensure the font is loaded properly before setting properties
|
|
||||||
textBox.textFrame.textRange.font.load();
|
|
||||||
await context.sync();
|
|
||||||
|
|
||||||
// Set font properties exactly as in the VBA code
|
|
||||||
textBox.textFrame.textRange.font.name = "Inter";
|
|
||||||
textBox.textFrame.textRange.font.size = 8;
|
|
||||||
textBox.textFrame.textRange.paragraphFormat.horizontalAlignment = "Center"
|
|
||||||
|
|
||||||
// Set the color to RGB(218, 19, 53)
|
|
||||||
// Different APIs may need different color formats
|
|
||||||
textBox.textFrame.textRange.font.color = "#DA1335";
|
|
||||||
|
|
||||||
// Set the text
|
|
||||||
textBox.textFrame.textRange.text = "– Confidential –";
|
|
||||||
} catch (fontError) {
|
|
||||||
console.error("Error setting font properties:", fontError);
|
|
||||||
// Even if we can't set the font properties exactly, continue with default font
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add a name/tag to the shape for identification
|
|
||||||
textBox.name = "ConfidentialMarking";
|
|
||||||
|
|
||||||
await context.sync();
|
|
||||||
processedSlides++;
|
|
||||||
}
|
|
||||||
} catch (slideError) {
|
|
||||||
console.error(`Error processing slide ${i+1}:`, slideError);
|
|
||||||
errorSlides++;
|
|
||||||
// Continue to the next slide
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Report results
|
|
||||||
if (processedSlides > 0) {
|
|
||||||
setStatusMessage(`Added confidential marking to ${processedSlides} slides.`);
|
|
||||||
setStatusType("success");
|
|
||||||
} else if (errorSlides > 0) {
|
|
||||||
setStatusMessage(`Failed to add markings. Errors on ${errorSlides} slides.`);
|
|
||||||
setStatusType("error");
|
|
||||||
} else {
|
|
||||||
setStatusMessage("No slides found to add confidential marking.");
|
|
||||||
setStatusType("warning");
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (innerError) {
|
|
||||||
console.error("Inner error:", innerError);
|
|
||||||
throw innerError; // Re-throw to outer catch
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const result = await processAddMarkings(context, slides.items);
|
||||||
|
updateStatusFromResult(result, true);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatusMessage(`Error: ${error.message}`);
|
handleError(error, "Add confidential");
|
||||||
setStatusType("error");
|
|
||||||
console.error("Add confidential error:", error);
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeConfidentialMarking = async () => {
|
/**
|
||||||
|
* Processes all slides to add confidential markings
|
||||||
|
*
|
||||||
|
* @param context - The PowerPoint API context
|
||||||
|
* @param slides - Array of slides to process
|
||||||
|
* @returns Processing result with counts of processed and error slides
|
||||||
|
*/
|
||||||
|
const processAddMarkings = async (
|
||||||
|
context: PowerPoint.RequestContext,
|
||||||
|
slides: PowerPoint.Slide[]
|
||||||
|
): Promise<ProcessingResult> => {
|
||||||
|
const result: ProcessingResult = {
|
||||||
|
processedSlides: 0,
|
||||||
|
errorSlides: 0,
|
||||||
|
affectedCount: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
const { WIDTH, HEIGHT } = CONFIDENTIAL_CONFIG.SLIDE;
|
||||||
|
const slideWidth = WIDTH;
|
||||||
|
const slideHeight = HEIGHT;
|
||||||
|
|
||||||
|
// Process each slide
|
||||||
|
for (let i = 0; i < slides.length; i++) {
|
||||||
|
try {
|
||||||
|
const slide = slides[i];
|
||||||
|
const positionFromTop = slideHeight - CONFIDENTIAL_CONFIG.TEXT_BOX.POSITION_FROM_BOTTOM;
|
||||||
|
|
||||||
|
// Create the textbox spanning the full width of the slide
|
||||||
|
const textBox = slide.shapes.addTextBox("");
|
||||||
|
textBox.left = 0; // Start from left edge
|
||||||
|
textBox.top = positionFromTop;
|
||||||
|
textBox.width = slideWidth;
|
||||||
|
textBox.height = CONFIDENTIAL_CONFIG.TEXT_BOX.HEIGHT;
|
||||||
|
textBox.name = CONFIDENTIAL_CONFIG.SHAPE_NAME;
|
||||||
|
|
||||||
|
// Load textFrame and its properties in a single batch
|
||||||
|
textBox.load("textFrame");
|
||||||
|
await context.sync();
|
||||||
|
|
||||||
|
if (textBox.textFrame) {
|
||||||
|
// Load textRange and its properties
|
||||||
|
textBox.textFrame.load("textRange");
|
||||||
|
await context.sync();
|
||||||
|
|
||||||
|
// Set text and formatting in a single batch
|
||||||
|
textBox.textFrame.textRange.text = CONFIDENTIAL_CONFIG.TEXT;
|
||||||
|
textBox.textFrame.textRange.load("font,paragraphFormat");
|
||||||
|
await context.sync();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Apply font properties in a single batch
|
||||||
|
const font = textBox.textFrame.textRange.font;
|
||||||
|
font.name = CONFIDENTIAL_CONFIG.FONT.NAME;
|
||||||
|
font.size = CONFIDENTIAL_CONFIG.FONT.SIZE;
|
||||||
|
font.color = CONFIDENTIAL_CONFIG.FONT.COLOR;
|
||||||
|
textBox.textFrame.textRange.paragraphFormat.horizontalAlignment = "Center";
|
||||||
|
|
||||||
|
await context.sync();
|
||||||
|
result.affectedCount++;
|
||||||
|
} catch (fontError) {
|
||||||
|
console.error("Error setting font properties:", fontError);
|
||||||
|
// Continue with default font if custom font fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.processedSlides++;
|
||||||
|
} catch (slideError) {
|
||||||
|
console.error(`Error processing slide ${i+1}:`, slideError);
|
||||||
|
result.errorSlides++;
|
||||||
|
// Continue to the next slide
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes confidential markings from all slides in the presentation
|
||||||
|
*/
|
||||||
|
const removeConfidentialMarking = async (): Promise<void> => {
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
try {
|
try {
|
||||||
await PowerPoint.run(async (context) => {
|
await PowerPoint.run(async (context) => {
|
||||||
try {
|
// Get all slides in the presentation
|
||||||
// Get all slides in the presentation
|
const slides = context.presentation.slides;
|
||||||
const slides = context.presentation.slides;
|
slides.load("items");
|
||||||
slides.load("items");
|
await context.sync();
|
||||||
await context.sync();
|
|
||||||
|
if (slides.items.length === 0) {
|
||||||
if (slides.items.length === 0) {
|
setStatusMessage("No slides found in the presentation.");
|
||||||
setStatusMessage("No slides found in the presentation.");
|
setStatusType("warning");
|
||||||
setStatusType("warning");
|
return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process counter
|
|
||||||
let processedSlides = 0;
|
|
||||||
let errorSlides = 0;
|
|
||||||
let removedCount = 0;
|
|
||||||
|
|
||||||
// Process each slide
|
|
||||||
for (let i = 0; i < slides.items.length; i++) {
|
|
||||||
try {
|
|
||||||
const slide = slides.items[i];
|
|
||||||
|
|
||||||
// Load all shapes on the slide
|
|
||||||
slide.shapes.load("items");
|
|
||||||
await context.sync();
|
|
||||||
|
|
||||||
// Find shapes with name "ConfidentialMarking"
|
|
||||||
for (let j = 0; j < slide.shapes.items.length; j++) {
|
|
||||||
const shape = slide.shapes.items[j];
|
|
||||||
shape.load("name");
|
|
||||||
await context.sync();
|
|
||||||
|
|
||||||
if (shape.name === "ConfidentialMarking") {
|
|
||||||
// Delete the confidential marking shape
|
|
||||||
shape.delete();
|
|
||||||
removedCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await context.sync();
|
|
||||||
processedSlides++;
|
|
||||||
} catch (slideError) {
|
|
||||||
console.error(`Error processing slide ${i+1}:`, slideError);
|
|
||||||
errorSlides++;
|
|
||||||
// Continue to the next slide
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Report results
|
|
||||||
if (removedCount > 0) {
|
|
||||||
setStatusMessage(`Removed ${removedCount} confidential markings from ${processedSlides} slides.`);
|
|
||||||
setStatusType("success");
|
|
||||||
} else if (errorSlides > 0) {
|
|
||||||
setStatusMessage(`Failed to remove markings. Errors on ${errorSlides} slides.`);
|
|
||||||
setStatusType("error");
|
|
||||||
} else {
|
|
||||||
setStatusMessage("No confidential markings found to remove.");
|
|
||||||
setStatusType("info");
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (innerError) {
|
|
||||||
console.error("Inner error:", innerError);
|
|
||||||
throw innerError; // Re-throw to outer catch
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const result = await processRemoveMarkings(context, slides.items);
|
||||||
|
updateStatusFromResult(result, false);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatusMessage(`Error: ${error.message}`);
|
handleError(error, "Remove confidential");
|
||||||
setStatusType("error");
|
|
||||||
console.error("Remove confidential error:", error);
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes all slides to remove confidential markings
|
||||||
|
*
|
||||||
|
* @param context - The PowerPoint API context
|
||||||
|
* @param slides - Array of slides to process
|
||||||
|
* @returns Processing result with counts of processed and affected slides
|
||||||
|
*/
|
||||||
|
const processRemoveMarkings = async (
|
||||||
|
context: PowerPoint.RequestContext,
|
||||||
|
slides: PowerPoint.Slide[]
|
||||||
|
): Promise<ProcessingResult> => {
|
||||||
|
const result: ProcessingResult = {
|
||||||
|
processedSlides: 0,
|
||||||
|
errorSlides: 0,
|
||||||
|
affectedCount: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process each slide
|
||||||
|
for (let i = 0; i < slides.length; i++) {
|
||||||
|
try {
|
||||||
|
const slide = slides[i];
|
||||||
|
|
||||||
|
// Load all shapes on the slide
|
||||||
|
slide.shapes.load("items");
|
||||||
|
await context.sync();
|
||||||
|
|
||||||
|
// Collect shapes to delete
|
||||||
|
const shapesToDelete: PowerPoint.Shape[] = [];
|
||||||
|
|
||||||
|
// Find shapes with the confidential marking name
|
||||||
|
for (let j = 0; j < slide.shapes.items.length; j++) {
|
||||||
|
const shape = slide.shapes.items[j];
|
||||||
|
shape.load("name");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for all shape names to load
|
||||||
|
await context.sync();
|
||||||
|
|
||||||
|
// Now check names and collect shapes to delete
|
||||||
|
for (let j = 0; j < slide.shapes.items.length; j++) {
|
||||||
|
const shape = slide.shapes.items[j];
|
||||||
|
if (shape.name === CONFIDENTIAL_CONFIG.SHAPE_NAME) {
|
||||||
|
shapesToDelete.push(shape);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete all matching shapes in one batch
|
||||||
|
shapesToDelete.forEach(shape => shape.delete());
|
||||||
|
result.affectedCount += shapesToDelete.length;
|
||||||
|
|
||||||
|
await context.sync();
|
||||||
|
result.processedSlides++;
|
||||||
|
} catch (slideError) {
|
||||||
|
console.error(`Error processing slide ${i+1}:`, slideError);
|
||||||
|
result.errorSlides++;
|
||||||
|
// Continue to the next slide
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={commonStyles.container}>
|
<div className={commonStyles.container}>
|
||||||
<div className={styles.buttonGrid}>
|
<div className={styles.buttonGrid}>
|
||||||
@@ -269,4 +348,4 @@ export const ConfidentialButtons: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ConfidentialButtons;
|
export default ConfidentialButtons;
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* @file DraftButtons.tsx
|
||||||
|
* @description Component that provides functionality to add and remove draft watermarks
|
||||||
|
* on PowerPoint master slides. The watermarks appear as large, light-colored text
|
||||||
|
* across the slide background.
|
||||||
|
*/
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -10,6 +17,42 @@ import {
|
|||||||
import { useStatusContext } from "./App";
|
import { useStatusContext } from "./App";
|
||||||
import { useCommonStyles } from "./commonStyles";
|
import { useCommonStyles } from "./commonStyles";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration constants for draft watermarks
|
||||||
|
*/
|
||||||
|
const DRAFT_CONFIG = {
|
||||||
|
// Shape name used to identify draft watermarks
|
||||||
|
SHAPE_NAME: "DraftWatermark",
|
||||||
|
// Font configuration
|
||||||
|
FONT: {
|
||||||
|
NAME: "Inter",
|
||||||
|
SIZE: 54,
|
||||||
|
COLOR: "#FFE9E8" // Light pink RGB(255, 233, 232)
|
||||||
|
},
|
||||||
|
// Text box configuration
|
||||||
|
TEXT_BOX: {
|
||||||
|
LEFT: -330,
|
||||||
|
TOP: 32,
|
||||||
|
WIDTH: 2400,
|
||||||
|
HEIGHT: 540
|
||||||
|
},
|
||||||
|
// Watermark text (repeated pattern)
|
||||||
|
TEXT: [
|
||||||
|
"DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT",
|
||||||
|
"DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT",
|
||||||
|
"DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT",
|
||||||
|
"DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT",
|
||||||
|
"DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT",
|
||||||
|
"DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT",
|
||||||
|
"DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT",
|
||||||
|
"DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT",
|
||||||
|
"DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT"
|
||||||
|
].join("\n")
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component-specific styles
|
||||||
|
*/
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
buttonGrid: {
|
buttonGrid: {
|
||||||
display: "grid",
|
display: "grid",
|
||||||
@@ -27,16 +70,78 @@ const useStyles = makeStyles({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for master slide processing results
|
||||||
|
*/
|
||||||
|
interface ProcessingResult {
|
||||||
|
processedMasters: number;
|
||||||
|
errorMasters: number;
|
||||||
|
affectedCount: number; // Either added or removed watermarks
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DraftButtons component provides UI and functionality to add or remove
|
||||||
|
* draft watermarks on PowerPoint master slides.
|
||||||
|
*
|
||||||
|
* @returns React component
|
||||||
|
*/
|
||||||
export const DraftButtons: React.FC = () => {
|
export const DraftButtons: React.FC = () => {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
const commonStyles = useCommonStyles();
|
const commonStyles = useCommonStyles();
|
||||||
const {
|
const {
|
||||||
statusMessage, setStatusMessage,
|
setStatusMessage,
|
||||||
statusType, setStatusType,
|
setStatusType,
|
||||||
isProcessing, setIsProcessing
|
isProcessing,
|
||||||
|
setIsProcessing
|
||||||
} = useStatusContext();
|
} = useStatusContext();
|
||||||
|
|
||||||
const addDraftWatermark = async () => {
|
/**
|
||||||
|
* Handles errors and updates the status message
|
||||||
|
*
|
||||||
|
* @param error - The error object
|
||||||
|
* @param operation - The operation being performed (for logging)
|
||||||
|
*/
|
||||||
|
const handleError = (error: any, operation: string): void => {
|
||||||
|
setStatusMessage(`Error: ${error.message || "Unknown error occurred"}`);
|
||||||
|
setStatusType("error");
|
||||||
|
console.error(`${operation} error:`, error);
|
||||||
|
setIsProcessing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the status message based on processing results
|
||||||
|
*
|
||||||
|
* @param result - The processing result object
|
||||||
|
* @param isAddOperation - Whether this was an add operation (true) or remove operation (false)
|
||||||
|
*/
|
||||||
|
const updateStatusFromResult = (result: ProcessingResult, isAddOperation: boolean): void => {
|
||||||
|
const { processedMasters, errorMasters, affectedCount } = result;
|
||||||
|
|
||||||
|
if (affectedCount > 0) {
|
||||||
|
const action = isAddOperation ? "Added" : "Removed";
|
||||||
|
const pluralWatermark = affectedCount > 1 ? 's' : '';
|
||||||
|
const pluralMasters = processedMasters > 1 ? 's' : '';
|
||||||
|
|
||||||
|
setStatusMessage(`${action} draft watermark${pluralWatermark} to ${processedMasters} master slide${pluralMasters}.`);
|
||||||
|
setStatusType("success");
|
||||||
|
} else if (errorMasters > 0) {
|
||||||
|
const pluralMasters = errorMasters > 1 ? 's' : '';
|
||||||
|
setStatusMessage(`Failed to ${isAddOperation ? "add" : "remove"} watermarks. Errors on ${errorMasters} master slide${pluralMasters}.`);
|
||||||
|
setStatusType("error");
|
||||||
|
} else {
|
||||||
|
if (isAddOperation) {
|
||||||
|
setStatusMessage("No master slides found to add draft watermark.");
|
||||||
|
} else {
|
||||||
|
setStatusMessage("No draft watermark found to remove.");
|
||||||
|
}
|
||||||
|
setStatusType(isAddOperation ? "warning" : "info");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds draft watermarks to all master slides in the presentation
|
||||||
|
*/
|
||||||
|
const addDraftWatermark = async (): Promise<void> => {
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
try {
|
try {
|
||||||
await PowerPoint.run(async (context) => {
|
await PowerPoint.run(async (context) => {
|
||||||
@@ -51,187 +156,182 @@ export const DraftButtons: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process counter
|
const result = await processAddWatermarks(context, masters.items);
|
||||||
let processedMasters = 0;
|
updateStatusFromResult(result, true);
|
||||||
let errorMasters = 0;
|
|
||||||
|
|
||||||
// Process each slide master (usually there's just one)
|
|
||||||
for (let i = 0; i < masters.items.length; i++) {
|
|
||||||
try {
|
|
||||||
const master = masters.items[i];
|
|
||||||
|
|
||||||
// Create the textbox on the master slide
|
|
||||||
const textBox = master.shapes.addTextBox("");
|
|
||||||
|
|
||||||
// textBox.left = 0; // Center it horizontally
|
|
||||||
textBox.left = -330
|
|
||||||
textBox.top = 32;
|
|
||||||
// textBox.width = 960;
|
|
||||||
textBox.width = 2400;
|
|
||||||
textBox.height = 540;
|
|
||||||
|
|
||||||
await context.sync();
|
|
||||||
|
|
||||||
// Load textFrame to set text properties
|
|
||||||
textBox.load("textFrame");
|
|
||||||
await context.sync();
|
|
||||||
|
|
||||||
if (textBox.textFrame) {
|
|
||||||
// Load textRange to set text and properties
|
|
||||||
textBox.textFrame.load("textRange");
|
|
||||||
await context.sync();
|
|
||||||
|
|
||||||
// Need to load font and paragraphFormat
|
|
||||||
textBox.textFrame.textRange.load("font,paragraphFormat");
|
|
||||||
await context.sync();
|
|
||||||
|
|
||||||
// Set font properties
|
|
||||||
try {
|
|
||||||
// Ensure the font is loaded properly before setting properties
|
|
||||||
textBox.textFrame.textRange.font.load();
|
|
||||||
await context.sync();
|
|
||||||
|
|
||||||
// Set font properties exactly as in the VBA code
|
|
||||||
textBox.textFrame.textRange.font.name = "Inter";
|
|
||||||
// textBox.textFrame.textRange.font.size = 256;
|
|
||||||
textBox.textFrame.textRange.font.size = 54;
|
|
||||||
textBox.textFrame.textRange.font.bold = true;
|
|
||||||
textBox.textFrame.verticalAlignment = "MiddleCentered"
|
|
||||||
|
|
||||||
// Set the color to RGB(255, 233, 232)
|
|
||||||
// Different APIs may need different color formats
|
|
||||||
textBox.textFrame.textRange.font.color = "#FFE9E8";
|
|
||||||
|
|
||||||
// Set the text
|
|
||||||
textBox.textFrame.textRange.text =
|
|
||||||
"DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT\n" +
|
|
||||||
"DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT\n" +
|
|
||||||
"DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT\n" +
|
|
||||||
"DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT\n" +
|
|
||||||
"DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT\n" +
|
|
||||||
"DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT\n" +
|
|
||||||
"DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT\n" +
|
|
||||||
"DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT\n" +
|
|
||||||
"DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT DRAFT\n";
|
|
||||||
} catch (fontError) {
|
|
||||||
console.error("Error setting font properties:", fontError);
|
|
||||||
// Even if we can't set the font properties exactly, continue with default font
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add a name/tag to the shape for identification
|
|
||||||
textBox.name = "DraftWatermark";
|
|
||||||
|
|
||||||
await context.sync();
|
|
||||||
processedMasters++;
|
|
||||||
}
|
|
||||||
} catch (masterError) {
|
|
||||||
console.error(`Error processing master ${i+1}:`, masterError);
|
|
||||||
errorMasters++;
|
|
||||||
// Continue to the next master
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Report results
|
|
||||||
if (processedMasters > 0) {
|
|
||||||
setStatusMessage(`Added draft watermark to ${processedMasters} master slide${processedMasters > 1 ? 's' : ''}.`);
|
|
||||||
setStatusType("success");
|
|
||||||
} else if (errorMasters > 0) {
|
|
||||||
setStatusMessage(`Failed to add markings. Errors on ${errorMasters} master slide${errorMasters > 1 ? 's' : ''}.`);
|
|
||||||
setStatusType("error");
|
|
||||||
} else {
|
|
||||||
setStatusMessage("No master slides found to add draft watermark.");
|
|
||||||
setStatusType("warning");
|
|
||||||
}
|
|
||||||
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatusMessage(`Error: ${error.message}`);
|
handleError(error, "Add draft watermark");
|
||||||
setStatusType("error");
|
|
||||||
console.error("Add draft watermark error:", error);
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeDraftWatermark = async () => {
|
/**
|
||||||
|
* Processes all master slides to add draft watermarks
|
||||||
|
*
|
||||||
|
* @param context - The PowerPoint API context
|
||||||
|
* @param masters - Array of master slides to process
|
||||||
|
* @returns Processing result with counts of processed and error slides
|
||||||
|
*/
|
||||||
|
const processAddWatermarks = async (
|
||||||
|
context: PowerPoint.RequestContext,
|
||||||
|
masters: PowerPoint.SlideMaster[]
|
||||||
|
): Promise<ProcessingResult> => {
|
||||||
|
const result: ProcessingResult = {
|
||||||
|
processedMasters: 0,
|
||||||
|
errorMasters: 0,
|
||||||
|
affectedCount: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process each slide master (usually there's just one)
|
||||||
|
for (let i = 0; i < masters.length; i++) {
|
||||||
|
try {
|
||||||
|
const master = masters[i];
|
||||||
|
|
||||||
|
// Create the textbox on the master slide
|
||||||
|
const textBox = master.shapes.addTextBox("");
|
||||||
|
|
||||||
|
// Set position and size in a single batch
|
||||||
|
textBox.left = DRAFT_CONFIG.TEXT_BOX.LEFT;
|
||||||
|
textBox.top = DRAFT_CONFIG.TEXT_BOX.TOP;
|
||||||
|
textBox.width = DRAFT_CONFIG.TEXT_BOX.WIDTH;
|
||||||
|
textBox.height = DRAFT_CONFIG.TEXT_BOX.HEIGHT;
|
||||||
|
textBox.name = DRAFT_CONFIG.SHAPE_NAME;
|
||||||
|
|
||||||
|
// Load textFrame to set text properties
|
||||||
|
textBox.load("textFrame");
|
||||||
|
await context.sync();
|
||||||
|
|
||||||
|
if (textBox.textFrame) {
|
||||||
|
// Load textRange to set text and properties
|
||||||
|
textBox.textFrame.load("textRange");
|
||||||
|
await context.sync();
|
||||||
|
|
||||||
|
// Set text and load font properties in a single batch
|
||||||
|
textBox.textFrame.textRange.text = DRAFT_CONFIG.TEXT;
|
||||||
|
textBox.textFrame.textRange.load("font,paragraphFormat");
|
||||||
|
await context.sync();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Apply all font properties in a single batch
|
||||||
|
const font = textBox.textFrame.textRange.font;
|
||||||
|
font.name = DRAFT_CONFIG.FONT.NAME;
|
||||||
|
font.size = DRAFT_CONFIG.FONT.SIZE;
|
||||||
|
font.bold = true;
|
||||||
|
font.color = DRAFT_CONFIG.FONT.COLOR;
|
||||||
|
|
||||||
|
// Set alignment
|
||||||
|
textBox.textFrame.verticalAlignment = "MiddleCentered";
|
||||||
|
|
||||||
|
await context.sync();
|
||||||
|
result.affectedCount++;
|
||||||
|
} catch (fontError) {
|
||||||
|
console.error("Error setting font properties:", fontError);
|
||||||
|
// Continue with default font if custom font fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.processedMasters++;
|
||||||
|
} catch (masterError) {
|
||||||
|
console.error(`Error processing master ${i+1}:`, masterError);
|
||||||
|
result.errorMasters++;
|
||||||
|
// Continue to the next master
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes draft watermarks from all master slides in the presentation
|
||||||
|
*/
|
||||||
|
const removeDraftWatermark = async (): Promise<void> => {
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
try {
|
try {
|
||||||
await PowerPoint.run(async (context) => {
|
await PowerPoint.run(async (context) => {
|
||||||
try {
|
// Get the slide masters collection
|
||||||
// Get the slide masters collection
|
const masters = context.presentation.slideMasters;
|
||||||
const masters = context.presentation.slideMasters;
|
masters.load("items");
|
||||||
masters.load("items");
|
await context.sync();
|
||||||
await context.sync();
|
|
||||||
|
if (masters.items.length === 0) {
|
||||||
if (masters.items.length === 0) {
|
setStatusMessage("Could not access slide masters.");
|
||||||
setStatusMessage("Could not access slide masters.");
|
setStatusType("error");
|
||||||
setStatusType("error");
|
return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process counter
|
|
||||||
let processedMasters = 0;
|
|
||||||
let errorMasters = 0;
|
|
||||||
let removedCount = 0;
|
|
||||||
|
|
||||||
// Process each master slide
|
|
||||||
for (let i = 0; i < masters.items.length; i++) {
|
|
||||||
try {
|
|
||||||
const master = masters.items[i];
|
|
||||||
|
|
||||||
// Load all shapes on the master slide
|
|
||||||
master.shapes.load("items");
|
|
||||||
await context.sync();
|
|
||||||
|
|
||||||
// Find shapes with name "DraftWatermark"
|
|
||||||
for (let j = 0; j < master.shapes.items.length; j++) {
|
|
||||||
const shape = master.shapes.items[j];
|
|
||||||
shape.load("name");
|
|
||||||
await context.sync();
|
|
||||||
|
|
||||||
if (shape.name === "DraftWatermark") {
|
|
||||||
// Delete the draft watermark shape
|
|
||||||
shape.delete();
|
|
||||||
removedCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await context.sync();
|
|
||||||
processedMasters++;
|
|
||||||
} catch (masterError) {
|
|
||||||
console.error(`Error processing master slide ${i+1}:`, masterError);
|
|
||||||
errorMasters++;
|
|
||||||
// Continue to the next master slide
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Report results
|
|
||||||
if (removedCount > 0) {
|
|
||||||
setStatusMessage(`Removed ${removedCount} draft watermark${removedCount > 1 ? 's' : ''} from ${processedMasters} master slide${processedMasters > 1 ? 's' : ''}.`);
|
|
||||||
setStatusType("success");
|
|
||||||
} else if (errorMasters > 0) {
|
|
||||||
setStatusMessage(`Failed to remove draft watermarks. Errors on ${errorMasters} master slide${errorMasters > 1 ? 's' : ''}.`);
|
|
||||||
setStatusType("error");
|
|
||||||
} else {
|
|
||||||
setStatusMessage("No draft watermark found to remove.");
|
|
||||||
setStatusType("info");
|
|
||||||
}
|
|
||||||
} catch (innerError) {
|
|
||||||
console.error("Inner error:", innerError);
|
|
||||||
throw innerError; // Re-throw to outer catch
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const result = await processRemoveWatermarks(context, masters.items);
|
||||||
|
updateStatusFromResult(result, false);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatusMessage(`Error: ${error.message}`);
|
handleError(error, "Remove draft watermark");
|
||||||
setStatusType("error");
|
|
||||||
console.error("Remove draft watermark error:", error);
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes all master slides to remove draft watermarks
|
||||||
|
*
|
||||||
|
* @param context - The PowerPoint API context
|
||||||
|
* @param masters - Array of master slides to process
|
||||||
|
* @returns Processing result with counts of processed and affected slides
|
||||||
|
*/
|
||||||
|
const processRemoveWatermarks = async (
|
||||||
|
context: PowerPoint.RequestContext,
|
||||||
|
masters: PowerPoint.SlideMaster[]
|
||||||
|
): Promise<ProcessingResult> => {
|
||||||
|
const result: ProcessingResult = {
|
||||||
|
processedMasters: 0,
|
||||||
|
errorMasters: 0,
|
||||||
|
affectedCount: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process each master slide
|
||||||
|
for (let i = 0; i < masters.length; i++) {
|
||||||
|
try {
|
||||||
|
const master = masters[i];
|
||||||
|
|
||||||
|
// Load all shapes on the master slide
|
||||||
|
master.shapes.load("items");
|
||||||
|
await context.sync();
|
||||||
|
|
||||||
|
// Collect shapes to delete
|
||||||
|
const shapesToDelete: PowerPoint.Shape[] = [];
|
||||||
|
|
||||||
|
// Load all shape names in a single batch
|
||||||
|
for (let j = 0; j < master.shapes.items.length; j++) {
|
||||||
|
const shape = master.shapes.items[j];
|
||||||
|
shape.load("name");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for all shape names to load
|
||||||
|
await context.sync();
|
||||||
|
|
||||||
|
// Now check names and collect shapes to delete
|
||||||
|
for (let j = 0; j < master.shapes.items.length; j++) {
|
||||||
|
const shape = master.shapes.items[j];
|
||||||
|
if (shape.name === DRAFT_CONFIG.SHAPE_NAME) {
|
||||||
|
shapesToDelete.push(shape);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete all matching shapes in one batch
|
||||||
|
shapesToDelete.forEach(shape => shape.delete());
|
||||||
|
result.affectedCount += shapesToDelete.length;
|
||||||
|
|
||||||
|
await context.sync();
|
||||||
|
result.processedMasters++;
|
||||||
|
} catch (masterError) {
|
||||||
|
console.error(`Error processing master slide ${i+1}:`, masterError);
|
||||||
|
result.errorMasters++;
|
||||||
|
// Continue to the next master slide
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={commonStyles.container}>
|
<div className={commonStyles.container}>
|
||||||
<div className={styles.buttonGrid}>
|
<div className={styles.buttonGrid}>
|
||||||
@@ -260,4 +360,4 @@ export const DraftButtons: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default DraftButtons;
|
export default DraftButtons;
|
||||||
|
|||||||
@@ -1,27 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* GridGuidelineManager Component
|
||||||
|
*
|
||||||
|
* This component provides functionality to create and manage grids and guidelines
|
||||||
|
* on PowerPoint slides. It allows users to add customizable grids with adjustable
|
||||||
|
* spacing, opacity, and color, as well as add individual guidelines with custom
|
||||||
|
* positioning and color.
|
||||||
|
*
|
||||||
|
* @module GridGuidelineManager
|
||||||
|
*/
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
makeStyles,
|
makeStyles,
|
||||||
Label,
|
Label,
|
||||||
Slider,
|
|
||||||
SpinButton,
|
|
||||||
Divider,
|
|
||||||
ToggleButton,
|
ToggleButton,
|
||||||
tokens,
|
tokens
|
||||||
Input
|
|
||||||
} from "@fluentui/react-components";
|
} from "@fluentui/react-components";
|
||||||
import {
|
import {
|
||||||
GridRegular,
|
|
||||||
DismissRegular,
|
DismissRegular,
|
||||||
AddRegular,
|
AddRegular,
|
||||||
LineHorizontal3Regular,
|
LineHorizontal3Regular,
|
||||||
SplitVerticalRegular,
|
SplitVerticalRegular,
|
||||||
GridDotsRegular,
|
GridDotsRegular
|
||||||
ArrowResetRegular
|
|
||||||
} from "@fluentui/react-icons";
|
} from "@fluentui/react-icons";
|
||||||
import { useStatusContext } from "./App";
|
import { useStatusContext } from "./App";
|
||||||
import { useCommonStyles } from "./commonStyles";
|
import { useCommonStyles } from "./commonStyles";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard dimensions for PowerPoint slides in points
|
||||||
|
*/
|
||||||
|
const SLIDE_WIDTH = 960; // Width in points
|
||||||
|
const SLIDE_HEIGHT = 540; // Height in points
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Available colors for grids and guidelines
|
||||||
|
*/
|
||||||
|
type GridColor = "blue" | "red" | "yellow" | "green";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Direction options for guidelines
|
||||||
|
*/
|
||||||
|
type GuideDirection = "horizontal" | "vertical";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefixes used for naming shapes to identify them later
|
||||||
|
*/
|
||||||
|
const GRID_PREFIX = "edison_grid_";
|
||||||
|
const GUIDE_PREFIX = "edison_guide_";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Color values mapping for the available grid and guideline colors
|
||||||
|
*/
|
||||||
|
const colorValues = {
|
||||||
|
blue: "#4472C4",
|
||||||
|
red: "#C00000",
|
||||||
|
yellow: "#FFC000",
|
||||||
|
green: "#70AD47"
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for the ColorButton component
|
||||||
|
*/
|
||||||
|
interface ColorButtonProps {
|
||||||
|
/** The color this button represents */
|
||||||
|
color: GridColor;
|
||||||
|
/** The currently selected color */
|
||||||
|
selectedColor: GridColor;
|
||||||
|
/** Callback function when the color is clicked */
|
||||||
|
onClick: (color: GridColor) => void;
|
||||||
|
}
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
buttonGrid: {
|
buttonGrid: {
|
||||||
display: "grid",
|
display: "grid",
|
||||||
@@ -81,18 +130,55 @@ const useStyles = makeStyles({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
type GridColor = "blue" | "red" | "yellow" | "green";
|
/**
|
||||||
|
* ColorButton component - Renders a color selection button
|
||||||
const GRID_PREFIX = "edison_grid_";
|
*
|
||||||
const GUIDE_PREFIX = "edison_guide_";
|
* @param props - Component props
|
||||||
|
* @returns React component
|
||||||
const colorValues = {
|
*/
|
||||||
blue: "#4472C4",
|
const ColorButton: React.FC<ColorButtonProps> = ({ color, selectedColor, onClick }) => {
|
||||||
red: "#C00000",
|
const styles = useStyles();
|
||||||
yellow: "#FFC000",
|
return (
|
||||||
green: "#70AD47"
|
<Button
|
||||||
|
className={`${styles.colorButton} ${color === selectedColor ? styles.colorButtonSelected : ''}`}
|
||||||
|
style={{ backgroundColor: colorValues[color] }}
|
||||||
|
onClick={() => onClick(color)}
|
||||||
|
title={color.charAt(0).toUpperCase() + color.slice(1)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for handling numeric input fields
|
||||||
|
*
|
||||||
|
* @param initialValue - The initial value for the input
|
||||||
|
* @returns A tuple containing the current value and a change handler function
|
||||||
|
*/
|
||||||
|
const useNumericInputHandler = (
|
||||||
|
initialValue: number
|
||||||
|
): [number, (e: React.ChangeEvent<HTMLInputElement>) => void] => {
|
||||||
|
const [value, setValue] = React.useState(initialValue);
|
||||||
|
|
||||||
|
const handleChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const newValue = e.target.value;
|
||||||
|
const parsed = parseInt(newValue);
|
||||||
|
|
||||||
|
if (newValue === '') {
|
||||||
|
setValue(0);
|
||||||
|
} else if (!isNaN(parsed)) {
|
||||||
|
setValue(parsed);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return [value, handleChange];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GridGuidelineManager component - Provides UI and functionality for creating
|
||||||
|
* and managing grids and guidelines on PowerPoint slides
|
||||||
|
*
|
||||||
|
* @returns React component
|
||||||
|
*/
|
||||||
export const GridGuidelineManager: React.FC = () => {
|
export const GridGuidelineManager: React.FC = () => {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
const commonStyles = useCommonStyles();
|
const commonStyles = useCommonStyles();
|
||||||
@@ -103,17 +189,26 @@ export const GridGuidelineManager: React.FC = () => {
|
|||||||
} = useStatusContext();
|
} = useStatusContext();
|
||||||
|
|
||||||
// Grid state
|
// Grid state
|
||||||
const [gridSpacing, setGridSpacing] = React.useState(50);
|
const [gridSpacing, handleGridSpacingChange] = useNumericInputHandler(50);
|
||||||
const [gridColor, setGridColor] = React.useState<GridColor>("blue");
|
const [gridColor, setGridColor] = React.useState<GridColor>("blue");
|
||||||
const [gridOpacity, setGridOpacity] = React.useState(30);
|
const [gridOpacity, handleGridOpacityChange] = useNumericInputHandler(30);
|
||||||
|
|
||||||
// Guidelines state
|
// Guidelines state
|
||||||
const [guidePosition, setGuidePosition] = React.useState(240);
|
const [guidePosition, handleGuidePositionChange] = useNumericInputHandler(240);
|
||||||
const [guideDirection, setGuideDirection] = React.useState<"horizontal" | "vertical">("horizontal");
|
const [guideDirection, setGuideDirection] = React.useState<GuideDirection>("horizontal");
|
||||||
const [guideColor, setGuideColor] = React.useState<GridColor>("red");
|
const [guideColor, setGuideColor] = React.useState<GridColor>("red");
|
||||||
|
|
||||||
// Helper function to create a line shape
|
/**
|
||||||
const createLineShape = (
|
* Creates a line shape on the slide using a thin rectangle
|
||||||
|
*
|
||||||
|
* @param slide - The PowerPoint slide to add the shape to
|
||||||
|
* @param startX - Starting X coordinate
|
||||||
|
* @param startY - Starting Y coordinate
|
||||||
|
* @param endX - Ending X coordinate
|
||||||
|
* @param endY - Ending Y coordinate
|
||||||
|
* @returns The created shape object
|
||||||
|
*/
|
||||||
|
const createLineShape = React.useCallback((
|
||||||
slide: PowerPoint.Slide,
|
slide: PowerPoint.Slide,
|
||||||
startX: number,
|
startX: number,
|
||||||
startY: number,
|
startY: number,
|
||||||
@@ -138,9 +233,12 @@ export const GridGuidelineManager: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return line;
|
return line;
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const createGrid = async () => {
|
/**
|
||||||
|
* Creates a grid on the current slide based on the configured settings
|
||||||
|
*/
|
||||||
|
const createGrid = React.useCallback(async () => {
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
try {
|
try {
|
||||||
await PowerPoint.run(async (context) => {
|
await PowerPoint.run(async (context) => {
|
||||||
@@ -158,23 +256,19 @@ export const GridGuidelineManager: React.FC = () => {
|
|||||||
|
|
||||||
const currentSlide = selectedSlides.items[0];
|
const currentSlide = selectedSlides.items[0];
|
||||||
|
|
||||||
// Standard dimensions for PowerPoint slides
|
|
||||||
const slideWidth = 960; // Width in points
|
|
||||||
const slideHeight = 540; // Height in points
|
|
||||||
|
|
||||||
// Calculate number of lines needed
|
// Calculate number of lines needed
|
||||||
const numHorizontalLines = Math.floor(slideHeight / gridSpacing);
|
const numHorizontalLines = Math.floor(SLIDE_HEIGHT / gridSpacing);
|
||||||
const numVerticalLines = Math.floor(slideWidth / gridSpacing);
|
const numVerticalLines = Math.floor(SLIDE_WIDTH / gridSpacing);
|
||||||
|
|
||||||
// Create horizontal grid lines
|
// Create horizontal grid lines
|
||||||
for (let i = 1; i <= numHorizontalLines; i++) {
|
for (let i = 1; i <= numHorizontalLines; i++) {
|
||||||
const yPosition = i * gridSpacing;
|
const yPosition = i * gridSpacing;
|
||||||
|
|
||||||
// Skip if we're at the edge of the slide
|
// Skip if we're at the edge of the slide
|
||||||
if (yPosition >= slideHeight) continue;
|
if (yPosition >= SLIDE_HEIGHT) continue;
|
||||||
|
|
||||||
// Create horizontal line
|
// Create horizontal line
|
||||||
const line = createLineShape(currentSlide, 0, yPosition, slideWidth, yPosition);
|
const line = createLineShape(currentSlide, 0, yPosition, SLIDE_WIDTH, yPosition);
|
||||||
line.name = `${GRID_PREFIX}h_${i}`;
|
line.name = `${GRID_PREFIX}h_${i}`;
|
||||||
|
|
||||||
// Set line properties
|
// Set line properties
|
||||||
@@ -191,10 +285,10 @@ export const GridGuidelineManager: React.FC = () => {
|
|||||||
const xPosition = i * gridSpacing;
|
const xPosition = i * gridSpacing;
|
||||||
|
|
||||||
// Skip if we're at the edge of the slide
|
// Skip if we're at the edge of the slide
|
||||||
if (xPosition >= slideWidth) continue;
|
if (xPosition >= SLIDE_WIDTH) continue;
|
||||||
|
|
||||||
// Create vertical line
|
// Create vertical line
|
||||||
const line = createLineShape(currentSlide, xPosition, 0, xPosition, slideHeight);
|
const line = createLineShape(currentSlide, xPosition, 0, xPosition, SLIDE_HEIGHT);
|
||||||
line.name = `${GRID_PREFIX}v_${i}`;
|
line.name = `${GRID_PREFIX}v_${i}`;
|
||||||
|
|
||||||
// Set line properties
|
// Set line properties
|
||||||
@@ -216,9 +310,14 @@ export const GridGuidelineManager: React.FC = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
}
|
}
|
||||||
};
|
}, [createLineShape, gridColor, gridOpacity, gridSpacing, setIsProcessing, setStatusMessage, setStatusType]);
|
||||||
|
|
||||||
const removeGrid = async (showStatus = true) => {
|
/**
|
||||||
|
* Removes the grid from the current slide
|
||||||
|
*
|
||||||
|
* @param showStatus - Whether to show status messages (default: true)
|
||||||
|
*/
|
||||||
|
const removeGrid = React.useCallback(async (showStatus = true) => {
|
||||||
if (showStatus) {
|
if (showStatus) {
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
}
|
}
|
||||||
@@ -241,13 +340,17 @@ export const GridGuidelineManager: React.FC = () => {
|
|||||||
shapes.load("items");
|
shapes.load("items");
|
||||||
await context.sync();
|
await context.sync();
|
||||||
|
|
||||||
// Find shapes that are part of our grid
|
// Load all shape names at once
|
||||||
|
const allShapes = shapes.items;
|
||||||
|
for (let i = 0; i < allShapes.length; i++) {
|
||||||
|
allShapes[i].load("name");
|
||||||
|
}
|
||||||
|
await context.sync();
|
||||||
|
|
||||||
|
// Find shapes that are part of our grid and mark them for deletion
|
||||||
let removedCount = 0;
|
let removedCount = 0;
|
||||||
for (let i = 0; i < shapes.items.length; i++) {
|
for (let i = 0; i < allShapes.length; i++) {
|
||||||
const shape = shapes.items[i];
|
const shape = allShapes[i];
|
||||||
shape.load("name");
|
|
||||||
await context.sync();
|
|
||||||
|
|
||||||
// Check if this shape is part of our grid
|
// Check if this shape is part of our grid
|
||||||
if (shape.name && shape.name.startsWith(GRID_PREFIX)) {
|
if (shape.name && shape.name.startsWith(GRID_PREFIX)) {
|
||||||
shape.delete();
|
shape.delete();
|
||||||
@@ -255,6 +358,7 @@ export const GridGuidelineManager: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Single sync after all deletions
|
||||||
await context.sync();
|
await context.sync();
|
||||||
|
|
||||||
if (showStatus) {
|
if (showStatus) {
|
||||||
@@ -277,9 +381,12 @@ export const GridGuidelineManager: React.FC = () => {
|
|||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
}, [setIsProcessing, setStatusMessage, setStatusType]);
|
||||||
|
|
||||||
const addGuideline = async () => {
|
/**
|
||||||
|
* Adds a guideline to the current slide based on the configured settings
|
||||||
|
*/
|
||||||
|
const addGuideline = React.useCallback(async () => {
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
try {
|
try {
|
||||||
await PowerPoint.run(async (context) => {
|
await PowerPoint.run(async (context) => {
|
||||||
@@ -294,9 +401,6 @@ export const GridGuidelineManager: React.FC = () => {
|
|||||||
|
|
||||||
const currentSlide = selectedSlides.items[0];
|
const currentSlide = selectedSlides.items[0];
|
||||||
|
|
||||||
// Standard dimensions for PowerPoint slides
|
|
||||||
const slideWidth = 960; // Width in points
|
|
||||||
const slideHeight = 540; // Height in points
|
|
||||||
|
|
||||||
// Create a unique identifier for this guide
|
// Create a unique identifier for this guide
|
||||||
const timestamp = new Date().getTime();
|
const timestamp = new Date().getTime();
|
||||||
@@ -306,10 +410,10 @@ export const GridGuidelineManager: React.FC = () => {
|
|||||||
let line;
|
let line;
|
||||||
if (guideDirection === "horizontal") {
|
if (guideDirection === "horizontal") {
|
||||||
// Create horizontal guideline
|
// Create horizontal guideline
|
||||||
line = createLineShape(currentSlide, 0, guidePosition, slideWidth, guidePosition);
|
line = createLineShape(currentSlide, 0, guidePosition, SLIDE_WIDTH, guidePosition);
|
||||||
} else {
|
} else {
|
||||||
// Create vertical guideline
|
// Create vertical guideline
|
||||||
line = createLineShape(currentSlide, guidePosition, 0, guidePosition, slideHeight);
|
line = createLineShape(currentSlide, guidePosition, 0, guidePosition, SLIDE_HEIGHT);
|
||||||
}
|
}
|
||||||
|
|
||||||
line.name = guideId;
|
line.name = guideId;
|
||||||
@@ -331,9 +435,12 @@ export const GridGuidelineManager: React.FC = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
}
|
}
|
||||||
};
|
}, [createLineShape, guideColor, guideDirection, guidePosition, setIsProcessing, setStatusMessage, setStatusType]);
|
||||||
|
|
||||||
const removeGuidelines = async () => {
|
/**
|
||||||
|
* Removes all guidelines from the current slide
|
||||||
|
*/
|
||||||
|
const removeGuidelines = React.useCallback(async () => {
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
try {
|
try {
|
||||||
await PowerPoint.run(async (context) => {
|
await PowerPoint.run(async (context) => {
|
||||||
@@ -353,13 +460,17 @@ export const GridGuidelineManager: React.FC = () => {
|
|||||||
shapes.load("items");
|
shapes.load("items");
|
||||||
await context.sync();
|
await context.sync();
|
||||||
|
|
||||||
// Find shapes that are guidelines
|
// Load all shape names at once
|
||||||
|
const allShapes = shapes.items;
|
||||||
|
for (let i = 0; i < allShapes.length; i++) {
|
||||||
|
allShapes[i].load("name");
|
||||||
|
}
|
||||||
|
await context.sync();
|
||||||
|
|
||||||
|
// Find shapes that are guidelines and mark them for deletion
|
||||||
let removedCount = 0;
|
let removedCount = 0;
|
||||||
for (let i = 0; i < shapes.items.length; i++) {
|
for (let i = 0; i < allShapes.length; i++) {
|
||||||
const shape = shapes.items[i];
|
const shape = allShapes[i];
|
||||||
shape.load("name");
|
|
||||||
await context.sync();
|
|
||||||
|
|
||||||
// Check if this shape is a guideline
|
// Check if this shape is a guideline
|
||||||
if (shape.name && shape.name.startsWith(GUIDE_PREFIX)) {
|
if (shape.name && shape.name.startsWith(GUIDE_PREFIX)) {
|
||||||
shape.delete();
|
shape.delete();
|
||||||
@@ -367,6 +478,7 @@ export const GridGuidelineManager: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Single sync after all deletions
|
||||||
await context.sync();
|
await context.sync();
|
||||||
|
|
||||||
if (removedCount > 0) {
|
if (removedCount > 0) {
|
||||||
@@ -383,20 +495,7 @@ export const GridGuidelineManager: React.FC = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
}
|
}
|
||||||
};
|
}, [setIsProcessing, setStatusMessage, setStatusType]);
|
||||||
|
|
||||||
// Color picker button component
|
|
||||||
const ColorButton = ({ color, selectedColor, onClick }:
|
|
||||||
{ color: GridColor, selectedColor: GridColor, onClick: (color: GridColor) => void }) => {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
className={`${styles.colorButton} ${color === selectedColor ? styles.colorButtonSelected : ''}`}
|
|
||||||
style={{ backgroundColor: colorValues[color] }}
|
|
||||||
onClick={() => onClick(color)}
|
|
||||||
title={color.charAt(0).toUpperCase() + color.slice(1)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={commonStyles.container} style={{ maxHeight: "400px", overflowY: "auto" }}>
|
<div className={commonStyles.container} style={{ maxHeight: "400px", overflowY: "auto" }}>
|
||||||
@@ -415,16 +514,11 @@ export const GridGuidelineManager: React.FC = () => {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={gridSpacing}
|
value={gridSpacing}
|
||||||
onChange={(e) => {
|
onChange={handleGridSpacingChange}
|
||||||
const newValue = e.target.value;
|
|
||||||
const parsed = parseInt(newValue);
|
|
||||||
if (newValue === '') {
|
|
||||||
setGridSpacing(0);
|
|
||||||
} else if (!isNaN(parsed)) {
|
|
||||||
setGridSpacing(parsed);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||||
|
title="Grid Spacing"
|
||||||
|
placeholder="Grid Spacing"
|
||||||
|
aria-label="Grid Spacing"
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
padding: "5px 8px",
|
padding: "5px 8px",
|
||||||
@@ -442,16 +536,11 @@ export const GridGuidelineManager: React.FC = () => {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={gridOpacity}
|
value={gridOpacity}
|
||||||
onChange={(e) => {
|
onChange={handleGridOpacityChange}
|
||||||
const newValue = e.target.value;
|
|
||||||
const parsed = parseInt(newValue);
|
|
||||||
if (newValue === '') {
|
|
||||||
setGridOpacity(0);
|
|
||||||
} else if (!isNaN(parsed)) {
|
|
||||||
setGridOpacity(parsed);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||||
|
title="Grid Opacity"
|
||||||
|
placeholder="Grid Opacity"
|
||||||
|
aria-label="Grid Opacity"
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
padding: "5px 8px",
|
padding: "5px 8px",
|
||||||
@@ -473,7 +562,7 @@ export const GridGuidelineManager: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.buttonGrid} style={{ marginTop: "8px" }}>
|
<div className={styles.buttonGrid}>
|
||||||
<Button
|
<Button
|
||||||
appearance="primary"
|
appearance="primary"
|
||||||
className={styles.gridButton}
|
className={styles.gridButton}
|
||||||
@@ -506,32 +595,27 @@ export const GridGuidelineManager: React.FC = () => {
|
|||||||
Guidelines
|
Guidelines
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<div className={styles.controlRow}>
|
<div className={styles.controlRow}>
|
||||||
<Label className={styles.controlLabel}>Position</Label>
|
<Label className={styles.controlLabel}>Position</Label>
|
||||||
<div style={{ width: "80px", flex: "none" }}>
|
<div style={{ width: "80px", flex: "none" }}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={guidePosition}
|
value={guidePosition}
|
||||||
onChange={(e) => {
|
onChange={handleGuidePositionChange}
|
||||||
const newValue = e.target.value;
|
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||||
const parsed = parseInt(newValue);
|
title="Guide Position"
|
||||||
if (newValue === '') {
|
placeholder="Guide Position"
|
||||||
setGuidePosition(0);
|
aria-label="Guide Position"
|
||||||
} else if (!isNaN(parsed)) {
|
style={{
|
||||||
setGuidePosition(parsed);
|
width: "100%",
|
||||||
}
|
padding: "5px 8px",
|
||||||
}}
|
border: "1px solid #d1d1d1",
|
||||||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
borderRadius: "4px",
|
||||||
style={{
|
fontSize: "14px"
|
||||||
width: "100%",
|
}}
|
||||||
padding: "5px 8px",
|
/>
|
||||||
border: "1px solid #d1d1d1",
|
|
||||||
borderRadius: "4px",
|
|
||||||
fontSize: "14px"
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={styles.controlRow}>
|
<div className={styles.controlRow}>
|
||||||
<Label className={styles.controlLabel}>Type</Label>
|
<Label className={styles.controlLabel}>Type</Label>
|
||||||
@@ -596,4 +680,4 @@ export const GridGuidelineManager: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default GridGuidelineManager;
|
export default GridGuidelineManager;
|
||||||
|
|||||||
@@ -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,172 +1,434 @@
|
|||||||
|
/**
|
||||||
|
* @file InsertTitles.tsx
|
||||||
|
* @description Component that provides functionality to collect and insert slide titles
|
||||||
|
* from all slides in a PowerPoint presentation into a selected text box. This allows users
|
||||||
|
* to quickly generate a table of contents or agenda slide with all presentation titles.
|
||||||
|
*/
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import {
|
import { TextBulletListRegular } from "@fluentui/react-icons";
|
||||||
Button
|
|
||||||
} from "@fluentui/react-components";
|
|
||||||
import {
|
|
||||||
TextBulletListRegular
|
|
||||||
} from "@fluentui/react-icons";
|
|
||||||
import { useStatusContext } from "./App";
|
import { useStatusContext } from "./App";
|
||||||
import { useCommonStyles } from "./commonStyles";
|
import { useCommonStyles } from "./commonStyles";
|
||||||
|
import ActionButton from "./ActionButton";
|
||||||
|
import { getErrorMessage } from "../types/office-types";
|
||||||
|
|
||||||
|
// Configuration constants
|
||||||
|
const TITLE_POSITION_THRESHOLD = 95; // Pixels from top of slide to consider a shape as a title
|
||||||
|
const STANDARD_SLIDE_WIDTH = 720; // Standard PowerPoint slide width
|
||||||
|
|
||||||
|
// Title detection methods
|
||||||
|
type TitleDetectionMethod = "auto" | "position" | "first" | "name";
|
||||||
|
|
||||||
|
// Title collection options
|
||||||
|
interface TitleCollectionOptions {
|
||||||
|
includeSlideNumbers: boolean;
|
||||||
|
detectionMethod?: TitleDetectionMethod;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title collection result
|
||||||
|
interface TitleCollectionResult {
|
||||||
|
titleText: string;
|
||||||
|
titlesCollected: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title candidate with position and size information
|
||||||
|
interface TitleCandidate {
|
||||||
|
text: string;
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
fontSize?: number;
|
||||||
|
isBold?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export const InsertTitles: React.FC = () => {
|
export const InsertTitles: React.FC = () => {
|
||||||
const styles = useCommonStyles();
|
const styles = useCommonStyles();
|
||||||
const {
|
const { setStatusMessage, setStatusType } = useStatusContext();
|
||||||
statusMessage, setStatusMessage,
|
const [titleDetectionMethod, setTitleDetectionMethod] = React.useState<TitleDetectionMethod>("auto");
|
||||||
statusType, setStatusType,
|
const [includeSlideNumbers, setIncludeSlideNumbers] = React.useState<boolean>(false);
|
||||||
isProcessing, setIsProcessing
|
|
||||||
} = useStatusContext();
|
|
||||||
|
|
||||||
const collectAndInsertTitles = async () => {
|
/**
|
||||||
setIsProcessing(true);
|
* Validates that a text box is selected
|
||||||
|
* @param shapes The collection of selected shapes
|
||||||
|
* @param context The PowerPoint request context
|
||||||
|
* @returns The selected text box shape or null if validation fails
|
||||||
|
*/
|
||||||
|
const validateSelection = async (
|
||||||
|
shapes: PowerPoint.ShapeScopedCollection,
|
||||||
|
context: PowerPoint.RequestContext
|
||||||
|
): Promise<PowerPoint.Shape | null> => {
|
||||||
|
// Check if any shape is selected
|
||||||
|
if (shapes.items.length === 0) {
|
||||||
|
setStatusMessage("Please select a text box to insert slide titles.");
|
||||||
|
setStatusType("warning");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the first selected shape
|
||||||
|
const selectedShape = shapes.items[0];
|
||||||
|
|
||||||
|
// Check if the selected shape has a text frame
|
||||||
|
selectedShape.load("textFrame");
|
||||||
|
await context.sync();
|
||||||
|
|
||||||
|
if (!selectedShape.textFrame) {
|
||||||
|
setStatusMessage("Please select a text box to insert slide titles.");
|
||||||
|
setStatusType("warning");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return selectedShape;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the title shape on a slide using multiple heuristics
|
||||||
|
* @param shapes The collection of shapes on a slide
|
||||||
|
* @param context The PowerPoint request context
|
||||||
|
* @param slideNumber The slide number (for logging)
|
||||||
|
* @param method The title detection method to use
|
||||||
|
* @returns The title shape text or null if not found
|
||||||
|
*/
|
||||||
|
const findTitleShape = async (
|
||||||
|
shapes: PowerPoint.ShapeCollection,
|
||||||
|
context: PowerPoint.RequestContext,
|
||||||
|
slideNumber: number,
|
||||||
|
method: TitleDetectionMethod = "auto"
|
||||||
|
): Promise<{ text: string } | null> => {
|
||||||
try {
|
try {
|
||||||
await PowerPoint.run(async (context) => {
|
let titleCandidates: TitleCandidate[] = [];
|
||||||
try {
|
|
||||||
// Get the selected shape
|
// First pass: look for title shapes by name
|
||||||
const selectedShapes = context.presentation.getSelectedShapes();
|
// This is the most reliable method if available
|
||||||
selectedShapes.load("items");
|
if (method === "auto" || method === "name") {
|
||||||
await context.sync();
|
for (let j = 0; j < shapes.items.length; j++) {
|
||||||
|
const shape = shapes.items[j];
|
||||||
// Check if any shape is selected
|
|
||||||
if (selectedShapes.items.length === 0) {
|
|
||||||
setStatusMessage("Please select a text box to insert slide titles.");
|
|
||||||
setStatusType("warning");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the first selected shape
|
|
||||||
const selectedShape = selectedShapes.items[0];
|
|
||||||
|
|
||||||
// Check if the selected shape has a text frame
|
// Check if this shape has a name that indicates it's a title
|
||||||
selectedShape.load("textFrame");
|
shape.load("name,textFrame");
|
||||||
await context.sync();
|
await context.sync();
|
||||||
|
|
||||||
if (!selectedShape.textFrame) {
|
// Check if this might be a title shape by name
|
||||||
setStatusMessage("Please select a text box to insert slide titles.");
|
if (shape.name &&
|
||||||
setStatusType("warning");
|
(shape.name.toLowerCase().includes("title") ||
|
||||||
return;
|
shape.name.toLowerCase().includes("heading"))) {
|
||||||
}
|
|
||||||
|
if (shape.textFrame) {
|
||||||
// Get all slides in the presentation
|
shape.textFrame.load("textRange");
|
||||||
const slides = context.presentation.slides;
|
|
||||||
slides.load("items");
|
|
||||||
await context.sync();
|
|
||||||
|
|
||||||
// Collect all slide titles using a simple approach
|
|
||||||
let titleText = "";
|
|
||||||
let titlesCollected = 0;
|
|
||||||
|
|
||||||
// Process each slide
|
|
||||||
for (let i = 0; i < slides.items.length; i++) {
|
|
||||||
try {
|
|
||||||
const slide = slides.items[i];
|
|
||||||
|
|
||||||
// Load only necessary shape properties
|
|
||||||
slide.load("shapes");
|
|
||||||
await context.sync();
|
await context.sync();
|
||||||
|
|
||||||
// Get title text using a simplified approach
|
if (shape.textFrame.textRange) {
|
||||||
// This gets all the shapes and looks for one with text content positioned at the top
|
shape.textFrame.textRange.load("text");
|
||||||
const shapes = slide.shapes;
|
await context.sync();
|
||||||
shapes.load("items");
|
|
||||||
await context.sync();
|
const shapeText = shape.textFrame.textRange.text;
|
||||||
|
if (shapeText && shapeText.trim() !== "") {
|
||||||
// Look for shapes with text content
|
return { text: shapeText };
|
||||||
for (let j = 0; j < shapes.items.length; j++) {
|
|
||||||
try {
|
|
||||||
const shape = shapes.items[j];
|
|
||||||
|
|
||||||
// Only load textFrame property initially
|
|
||||||
shape.load("textFrame");
|
|
||||||
await context.sync();
|
|
||||||
|
|
||||||
// Only proceed with shapes that have text frames
|
|
||||||
if (shape.textFrame) {
|
|
||||||
// Load text range to see if there's content
|
|
||||||
shape.textFrame.load("textRange");
|
|
||||||
await context.sync();
|
|
||||||
|
|
||||||
if (shape.textFrame.textRange) {
|
|
||||||
shape.textFrame.textRange.load("text");
|
|
||||||
await context.sync();
|
|
||||||
|
|
||||||
const shapeText = shape.textFrame.textRange.text;
|
|
||||||
|
|
||||||
// Check if the shape has text and might be a title
|
|
||||||
if (shapeText && shapeText.trim() !== "") {
|
|
||||||
// Load position to see if it's at the top
|
|
||||||
shape.load("top");
|
|
||||||
await context.sync();
|
|
||||||
|
|
||||||
// Titles are usually at the top of the slide
|
|
||||||
// Since we can't directly identify a title placeholder,
|
|
||||||
// we're using position as a heuristic
|
|
||||||
if (shape.top < 150) {
|
|
||||||
// Add the title to our collection
|
|
||||||
// titleText += `Slide ${i+1}: ${shapeText}\n`;
|
|
||||||
titleText += `${shapeText}\n`;
|
|
||||||
titlesCollected++;
|
|
||||||
break; // Only use the first potential title shape on each slide
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (shapeError) {
|
|
||||||
console.error(`Error processing shape on slide ${i+1}:`, shapeError);
|
|
||||||
// Continue to the next shape
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (slideError) {
|
|
||||||
console.error(`Error processing slide ${i+1}:`, slideError);
|
|
||||||
// Continue to the next slide
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert the collected titles into the selected text frame
|
|
||||||
if (titleText) {
|
|
||||||
// Make sure we have a textRange on the selected shape
|
|
||||||
selectedShape.textFrame.load("textRange");
|
|
||||||
await context.sync();
|
|
||||||
|
|
||||||
selectedShape.textFrame.textRange.text = titleText;
|
|
||||||
await context.sync();
|
|
||||||
|
|
||||||
setStatusMessage(`Collected and inserted ${titlesCollected} slide titles.`);
|
|
||||||
setStatusType("success");
|
|
||||||
} else {
|
|
||||||
setStatusMessage("No slide titles found to insert.");
|
|
||||||
setStatusType("warning");
|
|
||||||
}
|
|
||||||
} catch (innerError) {
|
|
||||||
console.error("Inner error:", innerError);
|
|
||||||
throw innerError; // Re-throw to outer catch
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're only looking for title shapes by name and didn't find any, return null
|
||||||
|
if (method === "name") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: collect all text shapes based on the selected method
|
||||||
|
for (let j = 0; j < shapes.items.length; j++) {
|
||||||
|
const shape = shapes.items[j];
|
||||||
|
|
||||||
|
// Load basic properties for all shapes
|
||||||
|
shape.load("textFrame,top,left,width,height");
|
||||||
|
await context.sync();
|
||||||
|
|
||||||
|
if (shape.textFrame) {
|
||||||
|
shape.textFrame.load("textRange");
|
||||||
|
await context.sync();
|
||||||
|
|
||||||
|
if (shape.textFrame.textRange) {
|
||||||
|
shape.textFrame.textRange.load("text,font/size,font/bold");
|
||||||
|
await context.sync();
|
||||||
|
|
||||||
|
const shapeText = shape.textFrame.textRange.text;
|
||||||
|
|
||||||
|
if (shapeText && shapeText.trim() !== "") {
|
||||||
|
// For "first" method, return the first text shape we find
|
||||||
|
if (method === "first") {
|
||||||
|
return { text: shapeText };
|
||||||
|
}
|
||||||
|
|
||||||
|
// For position method or auto, check position
|
||||||
|
if (method === "position" || method === "auto") {
|
||||||
|
// Use position as a heuristic for identifying titles
|
||||||
|
if (shape.top < TITLE_POSITION_THRESHOLD) {
|
||||||
|
// For position-only method, return the first match
|
||||||
|
if (method === "position") {
|
||||||
|
return { text: shapeText };
|
||||||
|
}
|
||||||
|
|
||||||
|
// For auto method, add to candidates with priority score
|
||||||
|
const fontSize = shape.textFrame.textRange.font.size || 12;
|
||||||
|
const isBold = shape.textFrame.textRange.font.bold || false;
|
||||||
|
|
||||||
|
titleCandidates.push({
|
||||||
|
text: shapeText,
|
||||||
|
top: shape.top,
|
||||||
|
left: shape.left,
|
||||||
|
width: shape.width,
|
||||||
|
height: shape.height,
|
||||||
|
fontSize: fontSize,
|
||||||
|
isBold: isBold
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have candidates, sort them by position and size heuristics
|
||||||
|
if (titleCandidates.length > 0) {
|
||||||
|
// First sort by vertical position (top-most first)
|
||||||
|
titleCandidates.sort((a, b) => a.top - b.top);
|
||||||
|
|
||||||
|
// Then refine by other heuristics for the top candidates
|
||||||
|
// Only consider shapes that are close to the top
|
||||||
|
const topCandidates = titleCandidates.filter(
|
||||||
|
c => c.top <= titleCandidates[0].top + 50
|
||||||
|
);
|
||||||
|
|
||||||
|
if (topCandidates.length > 1) {
|
||||||
|
// Sort by a combination of factors:
|
||||||
|
// 1. How centered the shape is
|
||||||
|
// 2. Font size (larger is better)
|
||||||
|
// 3. Bold text is preferred
|
||||||
|
// 4. Width (wider is better for titles)
|
||||||
|
topCandidates.sort((a, b) => {
|
||||||
|
// Calculate how centered each shape is
|
||||||
|
const aCenterOffset = Math.abs((a.left + a.width/2) - (STANDARD_SLIDE_WIDTH/2));
|
||||||
|
const bCenterOffset = Math.abs((b.left + b.width/2) - (STANDARD_SLIDE_WIDTH/2));
|
||||||
|
|
||||||
|
// Calculate font score (size + bold bonus)
|
||||||
|
const aFontScore = (a.fontSize || 12) * (a.isBold ? 1.5 : 1);
|
||||||
|
const bFontScore = (b.fontSize || 12) * (b.isBold ? 1.5 : 1);
|
||||||
|
|
||||||
|
// Combine factors into a single score (lower is better)
|
||||||
|
// Weight factors by importance
|
||||||
|
const aScore = aCenterOffset * 2 - aFontScore * 5 - a.width * 0.5;
|
||||||
|
const bScore = bCenterOffset * 2 - bFontScore * 5 - b.width * 0.5;
|
||||||
|
|
||||||
|
return aScore - bScore;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the best candidate
|
||||||
|
return { text: topCandidates[0].text };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error finding title on slide ${slideNumber}:`, getErrorMessage(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collects titles from all slides in the presentation
|
||||||
|
* @param slides The collection of slides
|
||||||
|
* @param context The PowerPoint request context
|
||||||
|
* @param options Options for title collection
|
||||||
|
* @returns The collected titles and count
|
||||||
|
*/
|
||||||
|
const collectSlideTitles = async (
|
||||||
|
slides: PowerPoint.SlideCollection,
|
||||||
|
context: PowerPoint.RequestContext,
|
||||||
|
options: TitleCollectionOptions = {
|
||||||
|
includeSlideNumbers: false,
|
||||||
|
detectionMethod: "auto"
|
||||||
|
}
|
||||||
|
): Promise<TitleCollectionResult> => {
|
||||||
|
let titleText = "";
|
||||||
|
let titlesCollected = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Process each slide
|
||||||
|
for (let i = 0; i < slides.items.length; i++) {
|
||||||
|
const slide = slides.items[i];
|
||||||
|
|
||||||
|
// Load shapes collection
|
||||||
|
slide.load("shapes");
|
||||||
|
await context.sync();
|
||||||
|
|
||||||
|
const shapes = slide.shapes;
|
||||||
|
shapes.load("items");
|
||||||
|
await context.sync();
|
||||||
|
|
||||||
|
// Find the title shape on this slide
|
||||||
|
const titleShape = await findTitleShape(
|
||||||
|
shapes,
|
||||||
|
context,
|
||||||
|
i + 1,
|
||||||
|
options.detectionMethod || "auto"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (titleShape) {
|
||||||
|
// Format the title text based on options
|
||||||
|
const slideTitle = titleShape.text.trim();
|
||||||
|
const formattedTitle = options.includeSlideNumbers
|
||||||
|
? `Slide ${i + 1}: ${slideTitle}`
|
||||||
|
: slideTitle;
|
||||||
|
|
||||||
|
// Add to our collection
|
||||||
|
titleText += `${formattedTitle}\n`;
|
||||||
|
titlesCollected++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error collecting slide titles:", getErrorMessage(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { titleText, titlesCollected };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserts collected titles into a shape
|
||||||
|
* @param shape The shape to insert titles into
|
||||||
|
* @param titleText The collected title text
|
||||||
|
* @param context The PowerPoint request context
|
||||||
|
* @returns True if insertion was successful
|
||||||
|
*/
|
||||||
|
const insertTitlesIntoShape = async (
|
||||||
|
shape: PowerPoint.Shape,
|
||||||
|
titleText: string,
|
||||||
|
context: PowerPoint.RequestContext
|
||||||
|
): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
// Make sure we have a textRange on the selected shape
|
||||||
|
shape.textFrame.load("textRange");
|
||||||
|
await context.sync();
|
||||||
|
|
||||||
|
shape.textFrame.textRange.text = titleText;
|
||||||
|
await context.sync();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error inserting titles:", getErrorMessage(error));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates an appropriate status message based on the results
|
||||||
|
* @param titlesCollected The number of titles collected
|
||||||
|
* @param insertSuccess Whether insertion was successful
|
||||||
|
* @returns The formatted status message
|
||||||
|
*/
|
||||||
|
const generateStatusMessage = (
|
||||||
|
titlesCollected: number,
|
||||||
|
insertSuccess: boolean
|
||||||
|
): string => {
|
||||||
|
if (titlesCollected === 0) {
|
||||||
|
return "No slide titles found to insert.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!insertSuccess) {
|
||||||
|
return "Error inserting titles into the selected shape.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Collected and inserted ${titlesCollected} slide titles.`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main function to collect and insert slide titles
|
||||||
|
*/
|
||||||
|
const collectAndInsertTitles = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await PowerPoint.run(async (context) => {
|
||||||
|
// Get the selected shapes
|
||||||
|
const selectedShapes = context.presentation.getSelectedShapes();
|
||||||
|
selectedShapes.load("items");
|
||||||
|
await context.sync();
|
||||||
|
|
||||||
|
// Validate selection and get the target shape
|
||||||
|
const targetShape = await validateSelection(selectedShapes, context);
|
||||||
|
if (!targetShape) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all slides in the presentation
|
||||||
|
const slides = context.presentation.slides;
|
||||||
|
slides.load("items");
|
||||||
|
await context.sync();
|
||||||
|
|
||||||
|
// Collect titles from all slides
|
||||||
|
const { titleText, titlesCollected } = await collectSlideTitles(
|
||||||
|
slides,
|
||||||
|
context,
|
||||||
|
{
|
||||||
|
includeSlideNumbers: includeSlideNumbers,
|
||||||
|
detectionMethod: titleDetectionMethod
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Insert titles if any were found
|
||||||
|
let insertSuccess = false;
|
||||||
|
if (titleText) {
|
||||||
|
insertSuccess = await insertTitlesIntoShape(targetShape, titleText, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update status message
|
||||||
|
const statusMessage = generateStatusMessage(titlesCollected, insertSuccess);
|
||||||
|
setStatusMessage(statusMessage);
|
||||||
|
setStatusType(
|
||||||
|
titlesCollected === 0 ? "warning" :
|
||||||
|
insertSuccess ? "success" : "error"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatusMessage(`Error: ${error.message}`);
|
console.error("Error in collectAndInsertTitles:", getErrorMessage(error));
|
||||||
|
setStatusMessage(`Error: ${getErrorMessage(error)}`);
|
||||||
setStatusType("error");
|
setStatusType("error");
|
||||||
console.error("Collect titles error:", error);
|
|
||||||
} finally {
|
|
||||||
setIsProcessing(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.buttonGroup}>
|
<div className={styles.buttonGroup}>
|
||||||
<Button
|
<ActionButton
|
||||||
appearance="primary"
|
title="Insert Titles"
|
||||||
className={styles.actionButton}
|
|
||||||
onClick={collectAndInsertTitles}
|
|
||||||
icon={<TextBulletListRegular />}
|
icon={<TextBulletListRegular />}
|
||||||
disabled={isProcessing}
|
onClick={collectAndInsertTitles}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '10px', display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<select
|
||||||
|
value={titleDetectionMethod}
|
||||||
|
onChange={(e) => setTitleDetectionMethod(e.target.value as TitleDetectionMethod)}
|
||||||
|
style={{ width: '75%' }}
|
||||||
|
aria-label="Title detection method"
|
||||||
|
title="Select title detection method"
|
||||||
>
|
>
|
||||||
Insert Titles
|
<option value="auto">Auto-detect titles</option>
|
||||||
</Button>
|
<option value="name">Title shapes by name</option>
|
||||||
|
<option value="position">Position-based detection</option>
|
||||||
|
<option value="first">First text on slide</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '10px', display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<select
|
||||||
|
value={includeSlideNumbers ? "yes" : "no"}
|
||||||
|
onChange={(e) => setIncludeSlideNumbers(e.target.value === "yes")}
|
||||||
|
style={{ width: '75%' }}
|
||||||
|
aria-label="Include slide numbers"
|
||||||
|
title="Include slide numbers in output"
|
||||||
|
>
|
||||||
|
<option value="no">Exclude slide numbers</option>
|
||||||
|
<option value="yes">Include slide numbers</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default InsertTitles;
|
export default InsertTitles;
|
||||||
|
|||||||
@@ -1,225 +1,347 @@
|
|||||||
|
/**
|
||||||
|
* @file MatchProperties.tsx
|
||||||
|
* @description Component that provides functionality to match visual properties between shapes
|
||||||
|
* in PowerPoint presentations. This allows users to copy line, fill, and text properties from
|
||||||
|
* one shape to multiple other shapes, ensuring consistent styling across elements.
|
||||||
|
*/
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import {
|
import { ColorRegular } from "@fluentui/react-icons";
|
||||||
Button
|
|
||||||
} from "@fluentui/react-components";
|
|
||||||
import {
|
|
||||||
ColorRegular
|
|
||||||
} from "@fluentui/react-icons";
|
|
||||||
import { useStatusContext } from "./App";
|
import { useStatusContext } from "./App";
|
||||||
import { useCommonStyles } from "./commonStyles";
|
import { useCommonStyles } from "./commonStyles";
|
||||||
|
import ActionButton from "./ActionButton";
|
||||||
|
import { getErrorMessage } from "../types/office-types";
|
||||||
|
|
||||||
|
// Property types that can be matched
|
||||||
|
enum PropertyType {
|
||||||
|
Line = "line",
|
||||||
|
Fill = "fill",
|
||||||
|
Text = "text"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shape property loading configuration
|
||||||
|
const SHAPE_PROPERTIES = {
|
||||||
|
line: [
|
||||||
|
"lineFormat/weight",
|
||||||
|
"lineFormat/dashStyle",
|
||||||
|
"lineFormat/color"
|
||||||
|
],
|
||||||
|
fill: [
|
||||||
|
"fill/transparency",
|
||||||
|
"fill/foregroundColor"
|
||||||
|
],
|
||||||
|
text: [
|
||||||
|
"textFrame"
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
// Font property loading configuration
|
||||||
|
const FONT_PROPERTIES = [
|
||||||
|
"font/name",
|
||||||
|
"font/size",
|
||||||
|
"font/bold",
|
||||||
|
"font/italic",
|
||||||
|
"font/underline",
|
||||||
|
"font/color"
|
||||||
|
];
|
||||||
|
|
||||||
export const MatchProperties: React.FC = () => {
|
export const MatchProperties: React.FC = () => {
|
||||||
const styles = useCommonStyles();
|
const styles = useCommonStyles();
|
||||||
const {
|
const { setStatusMessage, setStatusType } = useStatusContext();
|
||||||
statusMessage, setStatusMessage,
|
|
||||||
statusType, setStatusType,
|
|
||||||
isProcessing, setIsProcessing
|
|
||||||
} = useStatusContext();
|
|
||||||
|
|
||||||
const matchPropertiesToFirstSelected = async () => {
|
/**
|
||||||
setIsProcessing(true);
|
* Validates that multiple shapes are selected
|
||||||
try {
|
* @param shapes The collection of selected shapes
|
||||||
await PowerPoint.run(async (context) => {
|
* @returns True if validation passes, false otherwise
|
||||||
// Get the selected shapes
|
*/
|
||||||
const shapes = context.presentation.getSelectedShapes();
|
const validateShapeSelection = (shapes: PowerPoint.ShapeScopedCollection): boolean => {
|
||||||
shapes.load("items");
|
if (shapes.items.length === 0) {
|
||||||
await context.sync();
|
setStatusMessage("No shapes are selected. Please select shapes first.");
|
||||||
|
setStatusType("warning");
|
||||||
// Check if shapes are selected
|
return false;
|
||||||
if (shapes.items.length === 0) {
|
|
||||||
setStatusMessage("No shapes are selected. Please select shapes first.");
|
|
||||||
setStatusType("warning");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if there is more than one shape selected
|
|
||||||
if (shapes.items.length === 1) {
|
|
||||||
setStatusMessage("Please select multiple shapes to copy properties.");
|
|
||||||
setStatusType("warning");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the first shape to use as template
|
|
||||||
const firstShape = shapes.items[0];
|
|
||||||
|
|
||||||
// Load all necessary properties from the first shape
|
|
||||||
firstShape.load([
|
|
||||||
"lineFormat/weight",
|
|
||||||
"lineFormat/dashStyle",
|
|
||||||
"lineFormat/color",
|
|
||||||
"fill/transparency",
|
|
||||||
"fill/foregroundColor",
|
|
||||||
"textFrame"
|
|
||||||
]);
|
|
||||||
|
|
||||||
await context.sync();
|
|
||||||
|
|
||||||
// Check if the shape has text and load text properties
|
|
||||||
let hasText = false;
|
|
||||||
if (firstShape.textFrame) {
|
|
||||||
firstShape.textFrame.load("textRange");
|
|
||||||
await context.sync();
|
|
||||||
|
|
||||||
if (firstShape.textFrame.textRange) {
|
|
||||||
firstShape.textFrame.textRange.load([
|
|
||||||
"font/name",
|
|
||||||
"font/size",
|
|
||||||
"font/bold",
|
|
||||||
"font/italic",
|
|
||||||
"font/underline",
|
|
||||||
"font/color"
|
|
||||||
]);
|
|
||||||
await context.sync();
|
|
||||||
hasText = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let successCount = 0;
|
|
||||||
let textStyleCount = 0;
|
|
||||||
|
|
||||||
// Loop through remaining shapes and apply properties
|
|
||||||
for (let i = 1; i < shapes.items.length; i++) {
|
|
||||||
try {
|
|
||||||
const targetShape = shapes.items[i];
|
|
||||||
let propertiesApplied = false;
|
|
||||||
|
|
||||||
// Apply line formatting properties
|
|
||||||
try {
|
|
||||||
// Line weight
|
|
||||||
if (firstShape.lineFormat.weight !== undefined) {
|
|
||||||
targetShape.lineFormat.weight = firstShape.lineFormat.weight;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Line style
|
|
||||||
if (firstShape.lineFormat.dashStyle !== undefined) {
|
|
||||||
targetShape.lineFormat.dashStyle = firstShape.lineFormat.dashStyle;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Line color
|
|
||||||
if (firstShape.lineFormat.color !== undefined) {
|
|
||||||
targetShape.lineFormat.color = firstShape.lineFormat.color;
|
|
||||||
}
|
|
||||||
|
|
||||||
await context.sync();
|
|
||||||
propertiesApplied = true;
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Error applying line format to shape ${i}:`, err);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply fill properties
|
|
||||||
try {
|
|
||||||
// Fill transparency
|
|
||||||
if (firstShape.fill.transparency !== undefined) {
|
|
||||||
targetShape.fill.transparency = firstShape.fill.transparency;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (firstShape.fill.foregroundColor !== undefined) {
|
|
||||||
targetShape.fill.foregroundColor = firstShape.fill.foregroundColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
await context.sync();
|
|
||||||
propertiesApplied = true;
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Error applying fill format to shape ${i}:`, err);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply text properties if the source has text
|
|
||||||
if (hasText) {
|
|
||||||
try {
|
|
||||||
// First check if target shape has text
|
|
||||||
targetShape.load("textFrame");
|
|
||||||
await context.sync();
|
|
||||||
|
|
||||||
if (targetShape.textFrame) {
|
|
||||||
targetShape.textFrame.load("textRange");
|
|
||||||
await context.sync();
|
|
||||||
|
|
||||||
if (targetShape.textFrame.textRange) {
|
|
||||||
const sourceFont = firstShape.textFrame.textRange.font;
|
|
||||||
const targetFont = targetShape.textFrame.textRange.font;
|
|
||||||
|
|
||||||
// Apply font properties
|
|
||||||
if (sourceFont.name !== undefined) {
|
|
||||||
targetFont.name = sourceFont.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sourceFont.size !== undefined) {
|
|
||||||
targetFont.size = sourceFont.size;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sourceFont.bold !== undefined) {
|
|
||||||
targetFont.bold = sourceFont.bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sourceFont.italic !== undefined) {
|
|
||||||
targetFont.italic = sourceFont.italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sourceFont.underline !== undefined) {
|
|
||||||
targetFont.underline = sourceFont.underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sourceFont.color !== undefined) {
|
|
||||||
targetFont.color = sourceFont.color;
|
|
||||||
}
|
|
||||||
|
|
||||||
await context.sync();
|
|
||||||
textStyleCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Error applying text format to shape ${i}:`, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (propertiesApplied) {
|
|
||||||
successCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Error updating shape ${i}:`, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Final status message based on what was applied
|
|
||||||
if (successCount > 0) {
|
|
||||||
let message = `Applied properties to ${successCount} shapes`;
|
|
||||||
if (textStyleCount > 0) {
|
|
||||||
message += ` (including text styling on ${textStyleCount})`;
|
|
||||||
}
|
|
||||||
setStatusMessage(message);
|
|
||||||
setStatusType("success");
|
|
||||||
} else {
|
|
||||||
setStatusMessage("Couldn't apply properties. Try selecting different shapes.");
|
|
||||||
setStatusType("error");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Timeout is handled in App.tsx now
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
setStatusMessage(`Error: ${error.message}`);
|
|
||||||
setStatusType("error");
|
|
||||||
console.error("Main error:", error);
|
|
||||||
} finally {
|
|
||||||
setIsProcessing(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shapes.items.length === 1) {
|
||||||
|
setStatusMessage("Please select multiple shapes to copy properties.");
|
||||||
|
setStatusType("warning");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads all necessary properties from the source shape
|
||||||
|
* @param sourceShape The shape to copy properties from
|
||||||
|
* @param context The PowerPoint request context
|
||||||
|
* @returns Object containing loaded property information
|
||||||
|
*/
|
||||||
|
const loadSourceProperties = async (
|
||||||
|
sourceShape: PowerPoint.Shape,
|
||||||
|
context: PowerPoint.RequestContext
|
||||||
|
): Promise<{ hasText: boolean }> => {
|
||||||
|
// Load basic shape properties
|
||||||
|
const propertiesToLoad = [
|
||||||
|
...SHAPE_PROPERTIES.line,
|
||||||
|
...SHAPE_PROPERTIES.fill,
|
||||||
|
...SHAPE_PROPERTIES.text
|
||||||
|
];
|
||||||
|
|
||||||
|
sourceShape.load(propertiesToLoad);
|
||||||
|
await context.sync();
|
||||||
|
|
||||||
|
// Check if the shape has text and load text properties if it does
|
||||||
|
let hasText = false;
|
||||||
|
if (sourceShape.textFrame) {
|
||||||
|
sourceShape.textFrame.load("textRange");
|
||||||
|
await context.sync();
|
||||||
|
|
||||||
|
if (sourceShape.textFrame.textRange) {
|
||||||
|
sourceShape.textFrame.textRange.load(FONT_PROPERTIES);
|
||||||
|
await context.sync();
|
||||||
|
hasText = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { hasText };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preloads text properties for all target shapes in batches
|
||||||
|
* @param shapes The collection of shapes
|
||||||
|
* @param hasText Whether the source shape has text
|
||||||
|
* @param context The PowerPoint request context
|
||||||
|
*/
|
||||||
|
const preloadTargetTextProperties = async (
|
||||||
|
shapes: PowerPoint.ShapeScopedCollection,
|
||||||
|
hasText: boolean,
|
||||||
|
context: PowerPoint.RequestContext
|
||||||
|
): Promise<void> => {
|
||||||
|
if (!hasText) return;
|
||||||
|
|
||||||
|
// Batch 1: Load textFrame for all target shapes
|
||||||
|
for (let i = 1; i < shapes.items.length; i++) {
|
||||||
|
shapes.items[i].load("textFrame");
|
||||||
|
}
|
||||||
|
await context.sync();
|
||||||
|
|
||||||
|
// Batch 2: For shapes that have textFrames, load their textRanges
|
||||||
|
for (let i = 1; i < shapes.items.length; i++) {
|
||||||
|
if (shapes.items[i].textFrame) {
|
||||||
|
shapes.items[i].textFrame.load("textRange");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await context.sync();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies line properties from source to target shape
|
||||||
|
* @param sourceShape The shape to copy properties from
|
||||||
|
* @param targetShape The shape to apply properties to
|
||||||
|
* @returns True if any properties were applied
|
||||||
|
*/
|
||||||
|
const applyLineProperties = (
|
||||||
|
sourceShape: PowerPoint.Shape,
|
||||||
|
targetShape: PowerPoint.Shape
|
||||||
|
): boolean => {
|
||||||
|
let propertiesApplied = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Line weight
|
||||||
|
if (sourceShape.lineFormat.weight !== undefined) {
|
||||||
|
targetShape.lineFormat.weight = sourceShape.lineFormat.weight;
|
||||||
|
propertiesApplied = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Line style
|
||||||
|
if (sourceShape.lineFormat.dashStyle !== undefined) {
|
||||||
|
targetShape.lineFormat.dashStyle = sourceShape.lineFormat.dashStyle;
|
||||||
|
propertiesApplied = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Line color
|
||||||
|
if (sourceShape.lineFormat.color !== undefined) {
|
||||||
|
targetShape.lineFormat.color = sourceShape.lineFormat.color;
|
||||||
|
propertiesApplied = true;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error applying line format:", getErrorMessage(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
return propertiesApplied;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies fill properties from source to target shape
|
||||||
|
* @param sourceShape The shape to copy properties from
|
||||||
|
* @param targetShape The shape to apply properties to
|
||||||
|
* @returns True if any properties were applied
|
||||||
|
*/
|
||||||
|
const applyFillProperties = (
|
||||||
|
sourceShape: PowerPoint.Shape,
|
||||||
|
targetShape: PowerPoint.Shape
|
||||||
|
): boolean => {
|
||||||
|
let propertiesApplied = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fill transparency
|
||||||
|
if (sourceShape.fill.transparency !== undefined) {
|
||||||
|
targetShape.fill.transparency = sourceShape.fill.transparency;
|
||||||
|
propertiesApplied = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill color
|
||||||
|
if (sourceShape.fill.foregroundColor !== undefined) {
|
||||||
|
targetShape.fill.foregroundColor = sourceShape.fill.foregroundColor;
|
||||||
|
propertiesApplied = true;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error applying fill format:", getErrorMessage(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
return propertiesApplied;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies text properties from source to target shape
|
||||||
|
* @param sourceShape The shape to copy properties from
|
||||||
|
* @param targetShape The shape to apply properties to
|
||||||
|
* @returns True if text properties were applied
|
||||||
|
*/
|
||||||
|
const applyTextProperties = (
|
||||||
|
sourceShape: PowerPoint.Shape,
|
||||||
|
targetShape: PowerPoint.Shape
|
||||||
|
): boolean => {
|
||||||
|
try {
|
||||||
|
if (sourceShape.textFrame?.textRange && targetShape.textFrame?.textRange) {
|
||||||
|
const sourceFont = sourceShape.textFrame.textRange.font;
|
||||||
|
const targetFont = targetShape.textFrame.textRange.font;
|
||||||
|
|
||||||
|
// Apply font properties in batch
|
||||||
|
if (sourceFont.name !== undefined) {
|
||||||
|
targetFont.name = sourceFont.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceFont.size !== undefined) {
|
||||||
|
targetFont.size = sourceFont.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceFont.bold !== undefined) {
|
||||||
|
targetFont.bold = sourceFont.bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceFont.italic !== undefined) {
|
||||||
|
targetFont.italic = sourceFont.italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceFont.underline !== undefined) {
|
||||||
|
targetFont.underline = sourceFont.underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceFont.color !== undefined) {
|
||||||
|
targetFont.color = sourceFont.color;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error applying text format:", getErrorMessage(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main function to match properties from the first selected shape to others
|
||||||
|
*/
|
||||||
|
const matchPropertiesToFirstSelected = async (): Promise<void> => {
|
||||||
|
await PowerPoint.run(async (context) => {
|
||||||
|
// Get the selected shapes
|
||||||
|
const shapes = context.presentation.getSelectedShapes();
|
||||||
|
shapes.load("items");
|
||||||
|
await context.sync();
|
||||||
|
|
||||||
|
// Validate shape selection
|
||||||
|
if (!validateShapeSelection(shapes)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the first shape to use as template
|
||||||
|
const sourceShape = shapes.items[0];
|
||||||
|
|
||||||
|
// Load all necessary properties from the source shape
|
||||||
|
const { hasText } = await loadSourceProperties(sourceShape, context);
|
||||||
|
|
||||||
|
// Preload text properties for target shapes if needed
|
||||||
|
await preloadTargetTextProperties(shapes, hasText, context);
|
||||||
|
|
||||||
|
// Apply properties to all target shapes
|
||||||
|
let successCount = 0;
|
||||||
|
let textStyleCount = 0;
|
||||||
|
|
||||||
|
for (let i = 1; i < shapes.items.length; i++) {
|
||||||
|
const targetShape = shapes.items[i];
|
||||||
|
let shapeUpdated = false;
|
||||||
|
|
||||||
|
// Apply different property types
|
||||||
|
const lineApplied = applyLineProperties(sourceShape, targetShape);
|
||||||
|
const fillApplied = applyFillProperties(sourceShape, targetShape);
|
||||||
|
|
||||||
|
// Apply text properties if available
|
||||||
|
if (hasText) {
|
||||||
|
const textApplied = applyTextProperties(sourceShape, targetShape);
|
||||||
|
if (textApplied) {
|
||||||
|
textStyleCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count successful shape updates
|
||||||
|
if (lineApplied || fillApplied) {
|
||||||
|
shapeUpdated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shapeUpdated) {
|
||||||
|
successCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single sync after all property changes
|
||||||
|
await context.sync();
|
||||||
|
|
||||||
|
// Generate appropriate status message
|
||||||
|
if (successCount > 0) {
|
||||||
|
const propertyTypes = [];
|
||||||
|
|
||||||
|
// Build a detailed status message
|
||||||
|
if (sourceShape.lineFormat) propertyTypes.push("line");
|
||||||
|
if (sourceShape.fill) propertyTypes.push("fill");
|
||||||
|
|
||||||
|
let message = `Applied ${propertyTypes.join(", ")} properties to ${successCount} shapes`;
|
||||||
|
if (textStyleCount > 0) {
|
||||||
|
message += ` (including text styling on ${textStyleCount})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatusMessage(message);
|
||||||
|
setStatusType("success");
|
||||||
|
} else {
|
||||||
|
setStatusMessage("Couldn't apply properties. Try selecting different shapes.");
|
||||||
|
setStatusType("error");
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.buttonGroup}>
|
<div className={styles.buttonGroup}>
|
||||||
<Button
|
<ActionButton
|
||||||
appearance="primary"
|
title="Match Properties"
|
||||||
className={styles.actionButton}
|
|
||||||
onClick={matchPropertiesToFirstSelected}
|
|
||||||
icon={<ColorRegular />}
|
icon={<ColorRegular />}
|
||||||
disabled={isProcessing}
|
onClick={matchPropertiesToFirstSelected}
|
||||||
>
|
/>
|
||||||
Match Properties
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default MatchProperties;
|
export default MatchProperties;
|
||||||
|
|||||||
@@ -1,29 +1,148 @@
|
|||||||
|
/**
|
||||||
|
* @file MatchSizes.tsx
|
||||||
|
* @description Component that provides functionality to match dimensions between shapes
|
||||||
|
* in PowerPoint presentations. This allows users to make multiple shapes the same width,
|
||||||
|
* height, or both dimensions, based on the first selected shape.
|
||||||
|
*/
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import {
|
import { ArrowFitInRegular, ArrowMaximizeRegular, ArrowMinimizeRegular } from "@fluentui/react-icons";
|
||||||
Button,
|
|
||||||
Text,
|
|
||||||
Tooltip,
|
|
||||||
InfoLabel,
|
|
||||||
Card
|
|
||||||
} from "@fluentui/react-components";
|
|
||||||
import {
|
|
||||||
ArrowFitInRegular,
|
|
||||||
ArrowSortDownRegular,
|
|
||||||
ArrowSortUpRegular
|
|
||||||
} from "@fluentui/react-icons";
|
|
||||||
import { useStatusContext } from "./App";
|
import { useStatusContext } from "./App";
|
||||||
import { useCommonStyles } from "./commonStyles";
|
import { useCommonStyles } from "./commonStyles";
|
||||||
|
import ActionButton from "./ActionButton";
|
||||||
|
import { getErrorMessage } from "../types/office-types";
|
||||||
|
|
||||||
|
// Size matching options
|
||||||
|
enum SizeMatchType {
|
||||||
|
Both = "both",
|
||||||
|
Width = "width",
|
||||||
|
Height = "height"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dimension properties to load and match
|
||||||
|
const DIMENSION_PROPERTIES = {
|
||||||
|
[SizeMatchType.Both]: ["width", "height"],
|
||||||
|
[SizeMatchType.Width]: ["width"],
|
||||||
|
[SizeMatchType.Height]: ["height"]
|
||||||
|
};
|
||||||
|
|
||||||
export const MatchSizes: React.FC = () => {
|
export const MatchSizes: React.FC = () => {
|
||||||
const styles = useCommonStyles();
|
const styles = useCommonStyles();
|
||||||
const {
|
const { setStatusMessage, setStatusType } = useStatusContext();
|
||||||
statusMessage, setStatusMessage,
|
|
||||||
statusType, setStatusType,
|
|
||||||
isProcessing, setIsProcessing
|
|
||||||
} = useStatusContext();
|
|
||||||
|
|
||||||
const matchSizeToFirstSelected = async () => {
|
/**
|
||||||
setIsProcessing(true);
|
* Validates that multiple shapes are selected
|
||||||
|
* @param shapes The collection of selected shapes
|
||||||
|
* @returns True if validation passes, false otherwise
|
||||||
|
*/
|
||||||
|
const validateShapeSelection = (shapes: PowerPoint.ShapeScopedCollection): boolean => {
|
||||||
|
if (shapes.items.length === 0) {
|
||||||
|
setStatusMessage("No shapes are selected. Please select shapes first.");
|
||||||
|
setStatusType("warning");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shapes.items.length === 1) {
|
||||||
|
setStatusMessage("Please select multiple shapes to resize.");
|
||||||
|
setStatusType("warning");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads dimension properties from the source shape
|
||||||
|
* @param sourceShape The shape to copy dimensions from
|
||||||
|
* @param context The PowerPoint request context
|
||||||
|
* @param matchType The type of dimension matching to perform
|
||||||
|
* @returns The loaded source shape
|
||||||
|
*/
|
||||||
|
const loadSourceShape = async (
|
||||||
|
sourceShape: PowerPoint.Shape,
|
||||||
|
context: PowerPoint.RequestContext,
|
||||||
|
matchType: SizeMatchType = SizeMatchType.Both
|
||||||
|
): Promise<PowerPoint.Shape> => {
|
||||||
|
// Load the appropriate dimension properties
|
||||||
|
sourceShape.load(DIMENSION_PROPERTIES[matchType]);
|
||||||
|
await context.sync();
|
||||||
|
return sourceShape;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies size changes to target shapes
|
||||||
|
* @param sourceShape The shape to copy dimensions from
|
||||||
|
* @param shapes The collection of shapes to resize
|
||||||
|
* @param matchType The type of dimension matching to perform
|
||||||
|
* @returns The number of shapes that were resized
|
||||||
|
*/
|
||||||
|
const applySizeChanges = (
|
||||||
|
sourceShape: PowerPoint.Shape,
|
||||||
|
shapes: PowerPoint.ShapeScopedCollection,
|
||||||
|
matchType: SizeMatchType = SizeMatchType.Both
|
||||||
|
): number => {
|
||||||
|
let resizedCount = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Apply dimensions to all target shapes
|
||||||
|
for (let i = 1; i < shapes.items.length; i++) {
|
||||||
|
const targetShape = shapes.items[i];
|
||||||
|
|
||||||
|
// Apply width if needed
|
||||||
|
if (matchType === SizeMatchType.Both || matchType === SizeMatchType.Width) {
|
||||||
|
targetShape.width = sourceShape.width;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply height if needed
|
||||||
|
if (matchType === SizeMatchType.Both || matchType === SizeMatchType.Height) {
|
||||||
|
targetShape.height = sourceShape.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
resizedCount++;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error applying size changes:", getErrorMessage(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
return resizedCount;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates an appropriate status message based on the results
|
||||||
|
* @param resizedCount The number of shapes that were resized
|
||||||
|
* @param matchType The type of dimension matching performed
|
||||||
|
* @returns The formatted status message
|
||||||
|
*/
|
||||||
|
const generateStatusMessage = (
|
||||||
|
resizedCount: number,
|
||||||
|
matchType: SizeMatchType = SizeMatchType.Both
|
||||||
|
): string => {
|
||||||
|
if (resizedCount === 0) {
|
||||||
|
return "No shapes were resized. Please try again with different shapes.";
|
||||||
|
}
|
||||||
|
|
||||||
|
let dimensionText = "";
|
||||||
|
switch (matchType) {
|
||||||
|
case SizeMatchType.Width:
|
||||||
|
dimensionText = "width";
|
||||||
|
break;
|
||||||
|
case SizeMatchType.Height:
|
||||||
|
dimensionText = "height";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
dimensionText = "dimensions";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Resized ${resizedCount} shapes to match the ${dimensionText} of the first selected shape.`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main function to match sizes from the first selected shape to others
|
||||||
|
* @param matchType The type of dimension matching to perform
|
||||||
|
*/
|
||||||
|
const matchSizeToFirstSelected = async (
|
||||||
|
matchType: SizeMatchType = SizeMatchType.Both
|
||||||
|
): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
await PowerPoint.run(async (context) => {
|
await PowerPoint.run(async (context) => {
|
||||||
// Get the selected shapes
|
// Get the selected shapes
|
||||||
@@ -31,62 +150,43 @@ export const MatchSizes: React.FC = () => {
|
|||||||
shapes.load("items");
|
shapes.load("items");
|
||||||
await context.sync();
|
await context.sync();
|
||||||
|
|
||||||
// Check if shapes are selected
|
// Validate shape selection
|
||||||
if (shapes.items.length === 0) {
|
if (!validateShapeSelection(shapes)) {
|
||||||
setStatusMessage("No shapes are selected. Please select shapes first.");
|
|
||||||
setStatusType("warning");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if there is more than one shape selected
|
// Get the first shape to use as template
|
||||||
if (shapes.items.length === 1) {
|
const sourceShape = await loadSourceShape(shapes.items[0], context, matchType);
|
||||||
setStatusMessage("Please select multiple shapes to resize.");
|
|
||||||
setStatusType("warning");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the first shape's dimensions
|
|
||||||
const firstShape = shapes.items[0];
|
|
||||||
firstShape.load("width,height");
|
|
||||||
await context.sync();
|
|
||||||
|
|
||||||
// Loop through the remaining shapes and resize them
|
|
||||||
for (let i = 1; i < shapes.items.length; i++) {
|
|
||||||
shapes.items[i].width = firstShape.width;
|
|
||||||
shapes.items[i].height = firstShape.height;
|
|
||||||
}
|
|
||||||
|
|
||||||
await context.sync();
|
|
||||||
setStatusMessage(`Resized ${shapes.items.length - 1} shapes to match the first selected shape.`);
|
|
||||||
setStatusType("success");
|
|
||||||
|
|
||||||
// Timeout is handled in App.tsx now
|
// Apply size changes to all target shapes
|
||||||
|
const resizedCount = applySizeChanges(sourceShape, shapes, matchType);
|
||||||
|
|
||||||
|
// Sync changes to PowerPoint
|
||||||
|
await context.sync();
|
||||||
|
|
||||||
|
// Update status message
|
||||||
|
const statusMessage = generateStatusMessage(resizedCount, matchType);
|
||||||
|
setStatusMessage(statusMessage);
|
||||||
|
setStatusType(resizedCount > 0 ? "success" : "error");
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatusMessage(`Error: ${error.message}`);
|
console.error("Error in matchSizeToFirstSelected:", getErrorMessage(error));
|
||||||
|
setStatusMessage(`Error: ${getErrorMessage(error)}`);
|
||||||
setStatusType("error");
|
setStatusType("error");
|
||||||
console.error(error);
|
|
||||||
} finally {
|
|
||||||
setIsProcessing(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.buttonGroup}>
|
<div className={styles.buttonGroup}>
|
||||||
<Button
|
<ActionButton
|
||||||
appearance="primary"
|
title="Match Sizes"
|
||||||
className={styles.actionButton}
|
icon={<ArrowFitInRegular />}
|
||||||
onClick={matchSizeToFirstSelected}
|
onClick={() => matchSizeToFirstSelected(SizeMatchType.Both)}
|
||||||
icon={<ArrowFitInRegular />}
|
/>
|
||||||
disabled={isProcessing}
|
|
||||||
>
|
|
||||||
Match Sizes
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default MatchSizes;
|
export default MatchSizes;
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* @file ProgressBarButtons.tsx
|
||||||
|
* @description Component that provides functionality to add and remove progress bars
|
||||||
|
* on PowerPoint slides. The progress bars visually indicate the current position within
|
||||||
|
* the presentation, with colored segments showing completed and upcoming slides.
|
||||||
|
*/
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -10,6 +17,28 @@ import {
|
|||||||
import { useStatusContext } from "./App";
|
import { useStatusContext } from "./App";
|
||||||
import { useCommonStyles } from "./commonStyles";
|
import { useCommonStyles } from "./commonStyles";
|
||||||
|
|
||||||
|
// Constants for progress bar configuration
|
||||||
|
const PROGRESS_BAR_CONFIG = {
|
||||||
|
// Standard PowerPoint slide dimensions in points
|
||||||
|
slideWidth: 960,
|
||||||
|
slideHeight: 540,
|
||||||
|
// Progress bar appearance
|
||||||
|
barHeight: 1,
|
||||||
|
distanceFromBottom: 28,
|
||||||
|
overlap: 0.5, // Small overlap to ensure no gaps between segments
|
||||||
|
// Colors
|
||||||
|
completedColor: "#205170", // Blue for progressed segments (RGB 32, 81, 112)
|
||||||
|
pendingColor: "#F2F2F2" // Grey for segments not yet progressed (RGB 242, 242, 242)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Layouts that should receive progress bars
|
||||||
|
const SUPPORTED_LAYOUTS = [
|
||||||
|
"Title",
|
||||||
|
"Content",
|
||||||
|
"Two Content",
|
||||||
|
"Blank"
|
||||||
|
];
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
buttonGrid: {
|
buttonGrid: {
|
||||||
display: "grid",
|
display: "grid",
|
||||||
@@ -36,108 +65,96 @@ export const ProgressBarButtons: React.FC = () => {
|
|||||||
isProcessing, setIsProcessing
|
isProcessing, setIsProcessing
|
||||||
} = useStatusContext();
|
} = useStatusContext();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a progress bar to the bottom of each slide in the presentation
|
||||||
|
* The progress bar shows the current position within the presentation
|
||||||
|
* with colored segments indicating progress
|
||||||
|
*/
|
||||||
const addProgressBar = async () => {
|
const addProgressBar = async () => {
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
|
const startTime = performance.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await PowerPoint.run(async (context) => {
|
await PowerPoint.run(async (context) => {
|
||||||
// Get all slides
|
// Get all slides at once
|
||||||
const presentation = context.presentation;
|
const presentation = context.presentation;
|
||||||
const slides = presentation.slides;
|
const slides = presentation.slides;
|
||||||
slides.load("items");
|
slides.load("items");
|
||||||
|
|
||||||
// Need to load the first slide to get access to dimensions
|
// Single sync to get all slides
|
||||||
await context.sync();
|
await context.sync();
|
||||||
|
|
||||||
if (slides.items.length === 0) {
|
if (slides.items.length === 0) {
|
||||||
throw new Error("No slides in the presentation");
|
throw new Error("No slides in the presentation");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get dimensions from first slide
|
// Calculate dimensions based on slide count
|
||||||
const firstSlide = slides.items[0];
|
const { slideWidth, slideHeight, barHeight, distanceFromBottom, overlap } = PROGRESS_BAR_CONFIG;
|
||||||
|
|
||||||
// We'll create our own width and height based on standard values
|
|
||||||
// since direct access to slide dimensions is tricky in Office.js
|
|
||||||
const slideWidth = 960; // Standard PowerPoint slide width in points
|
|
||||||
const slideHeight = 540; // Standard PowerPoint slide height in points
|
|
||||||
|
|
||||||
// Define progress bar properties
|
|
||||||
const progressBarHeight = 1; // Height in points
|
|
||||||
const segmentGap = 0; // Gap between segments
|
|
||||||
const startY = slideHeight - 28; // 28 points from bottom
|
|
||||||
|
|
||||||
// Load slides first to get count
|
|
||||||
await context.sync();
|
|
||||||
|
|
||||||
const slideCount = slides.items.length;
|
const slideCount = slides.items.length;
|
||||||
const segmentWidth = slideWidth / slideCount;
|
const segmentWidth = slideWidth / slideCount;
|
||||||
|
const startY = slideHeight - distanceFromBottom;
|
||||||
|
|
||||||
// Process each slide
|
// Load all slide layouts at once to reduce API calls
|
||||||
|
for (let i = 0; i < slideCount; i++) {
|
||||||
|
slides.items[i].load("layout");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single sync to get all layouts
|
||||||
|
await context.sync();
|
||||||
|
|
||||||
|
// Process each slide without unnecessary syncs
|
||||||
for (let i = 0; i < slideCount; i++) {
|
for (let i = 0; i < slideCount; i++) {
|
||||||
const slide = slides.items[i];
|
const slide = slides.items[i];
|
||||||
|
|
||||||
// Load slide properties to check layout
|
|
||||||
slide.load("layout");
|
|
||||||
await context.sync();
|
|
||||||
|
|
||||||
const layoutName = slide.layout.name;
|
const layoutName = slide.layout.name;
|
||||||
|
|
||||||
// Check if this is a layout we want to add the progress bar to
|
// Check if this is a layout we want to add the progress bar to
|
||||||
// Note: Office.js might not have exact same layout names as VBA,
|
const isCompatibleLayout = SUPPORTED_LAYOUTS.some(layout =>
|
||||||
// so we're checking for common layouts
|
layoutName.includes(layout)
|
||||||
if (layoutName.includes("Title") ||
|
);
|
||||||
layoutName.includes("Content") ||
|
|
||||||
layoutName.includes("Two Content") ||
|
if (isCompatibleLayout) {
|
||||||
layoutName.includes("Blank")) {
|
// Create all segments for this slide in a batch
|
||||||
|
|
||||||
// Add progress bar segments to this slide
|
|
||||||
for (let j = 0; j < slideCount; j++) {
|
for (let j = 0; j < slideCount; j++) {
|
||||||
// Create line for this segment with a small overlap to prevent gaps
|
// Calculate segment position and dimensions
|
||||||
const overlap = 0.5; // Small overlap to ensure no gaps between segments
|
|
||||||
const startX = j * segmentWidth;
|
const startX = j * segmentWidth;
|
||||||
const endX = (j + 1) * segmentWidth;
|
const endX = (j + 1) * segmentWidth;
|
||||||
|
|
||||||
// Create a rectangle with minimal height to simulate a line
|
// Create a rectangle with minimal height to simulate a line
|
||||||
// since proper line creation is challenging with the Office JS API
|
const segment = slide.shapes.addGeometricShape("Rectangle");
|
||||||
const line = slide.shapes.addGeometricShape("Rectangle");
|
|
||||||
|
|
||||||
// Set the rectangle's position to look like a line
|
// Set the rectangle's position to look like a line
|
||||||
// Adding a tiny bit of overlap to ensure no gaps
|
segment.left = startX - (j > 0 ? overlap : 0);
|
||||||
line.left = startX - (j > 0 ? overlap : 0); // Overlap with previous segment except for first
|
segment.top = startY;
|
||||||
line.top = startY;
|
segment.width = (endX - startX) + (j > 0 ? overlap : 0) + (j < slideCount - 1 ? overlap : 0);
|
||||||
line.width = (endX - startX) + (j > 0 ? overlap : 0) + (j < slideCount - 1 ? overlap : 0);
|
segment.height = barHeight;
|
||||||
line.height = progressBarHeight; // Very small height
|
|
||||||
|
|
||||||
// Set fill color based on progress instead of line color
|
// Set fill color based on progress
|
||||||
// We're using a rectangle, so fill is more appropriate than line
|
const isCompleted = j <= i;
|
||||||
if (j <= i) {
|
segment.fill.setSolidColor(isCompleted ?
|
||||||
// Blue for progressed segments (RGB 32, 81, 112)
|
PROGRESS_BAR_CONFIG.completedColor :
|
||||||
line.fill.setSolidColor("#205170");
|
PROGRESS_BAR_CONFIG.pendingColor
|
||||||
|
);
|
||||||
// Make the current slide indicator (last blue segment) slightly more prominent
|
|
||||||
if (j == i) {
|
// Make the current slide indicator slightly more prominent
|
||||||
// Make it wider and taller for emphasis
|
if (j == i) {
|
||||||
line.width += 1; // Add extra width
|
segment.width += 1;
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Grey for segments not yet progressed (RGB 242, 242, 242)
|
|
||||||
line.fill.setSolidColor("#F2F2F2");
|
|
||||||
|
|
||||||
// Make the current slide indicator (last blue segment) slightly more prominent
|
|
||||||
if (j == i) {
|
|
||||||
// Make it wider and taller for emphasis
|
|
||||||
line.width += 1; // Add extra width
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the border/outline entirely
|
// Remove the border/outline entirely
|
||||||
line.lineFormat.visible = false;
|
segment.lineFormat.visible = false;
|
||||||
|
|
||||||
// Since tags API might not be fully supported, use shape name as identifier
|
// Use shape name as identifier for later removal
|
||||||
line.name = "progress_bar_" + j;
|
segment.name = "progress_bar_" + j;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Single final sync to commit all changes
|
||||||
|
await context.sync();
|
||||||
|
|
||||||
|
const endTime = performance.now();
|
||||||
|
console.log(`Progress bar creation took ${endTime - startTime}ms`);
|
||||||
|
|
||||||
setStatusMessage("Added progress bar to slides.");
|
setStatusMessage("Added progress bar to slides.");
|
||||||
setStatusType("success");
|
setStatusType("success");
|
||||||
});
|
});
|
||||||
@@ -150,53 +167,68 @@ export const ProgressBarButtons: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes all progress bar elements from all slides in the presentation
|
||||||
|
* Identifies progress bar elements by their name prefix "progress_bar_"
|
||||||
|
*/
|
||||||
const removeProgressBar = async () => {
|
const removeProgressBar = async () => {
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
|
const startTime = performance.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await PowerPoint.run(async (context) => {
|
await PowerPoint.run(async (context) => {
|
||||||
// Get all slides
|
// Get all slides at once
|
||||||
const presentation = context.presentation;
|
const presentation = context.presentation;
|
||||||
const slides = presentation.slides;
|
const slides = presentation.slides;
|
||||||
slides.load("items");
|
slides.load("items");
|
||||||
await context.sync();
|
await context.sync();
|
||||||
|
|
||||||
let removedCount = 0;
|
let removedCount = 0;
|
||||||
|
const shapesToLoad = [];
|
||||||
|
|
||||||
// Process each slide
|
// First, load all shapes from all slides in a batch
|
||||||
for (let i = 0; i < slides.items.length; i++) {
|
for (let i = 0; i < slides.items.length; i++) {
|
||||||
const slide = slides.items[i];
|
const slide = slides.items[i];
|
||||||
|
|
||||||
// Load all shapes in this slide
|
|
||||||
const shapes = slide.shapes;
|
const shapes = slide.shapes;
|
||||||
shapes.load("items");
|
shapes.load("items");
|
||||||
await context.sync();
|
shapesToLoad.push({ slide, shapes });
|
||||||
|
}
|
||||||
// Find shapes with our tag
|
|
||||||
const progressBarShapes = [];
|
// Single sync to get all shapes
|
||||||
|
await context.sync();
|
||||||
|
|
||||||
|
// Now load all shape names in a batch
|
||||||
|
const allShapesWithNames = [];
|
||||||
|
|
||||||
|
for (const { slide, shapes } of shapesToLoad) {
|
||||||
for (let j = 0; j < shapes.items.length; j++) {
|
for (let j = 0; j < shapes.items.length; j++) {
|
||||||
const shape = shapes.items[j];
|
const shape = shapes.items[j];
|
||||||
// Check if this shape name indicates it's a progress bar
|
|
||||||
shape.load("name");
|
shape.load("name");
|
||||||
await context.sync();
|
allShapesWithNames.push({ slide, shape });
|
||||||
|
|
||||||
// Look for shapes with names starting with "progress_bar_"
|
|
||||||
if (shape.name && shape.name.startsWith("progress_bar_")) {
|
|
||||||
progressBarShapes.push(shape);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete all progress bar shapes
|
|
||||||
for (const shape of progressBarShapes) {
|
|
||||||
shape.delete();
|
|
||||||
removedCount++;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Single sync to get all shape names
|
||||||
await context.sync();
|
await context.sync();
|
||||||
|
|
||||||
|
// Filter shapes that are progress bars and delete them
|
||||||
|
const progressBarShapes = allShapesWithNames.filter(
|
||||||
|
({ shape }) => shape.name && shape.name.startsWith("progress_bar_")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete all progress bar shapes
|
||||||
|
for (const { shape } of progressBarShapes) {
|
||||||
|
shape.delete();
|
||||||
|
removedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single final sync to commit all deletions
|
||||||
|
await context.sync();
|
||||||
|
|
||||||
|
const endTime = performance.now();
|
||||||
|
console.log(`Progress bar removal took ${endTime - startTime}ms`);
|
||||||
|
|
||||||
if (removedCount > 0) {
|
if (removedCount > 0) {
|
||||||
// setStatusMessage(`Removed ${removedCount} progress bar elements from slides.`);
|
|
||||||
// Added progress bar to slides.
|
|
||||||
setStatusMessage(`Removed progress bar from slides.`);
|
setStatusMessage(`Removed progress bar from slides.`);
|
||||||
} else {
|
} else {
|
||||||
setStatusMessage("No progress bar found to remove.");
|
setStatusMessage("No progress bar found to remove.");
|
||||||
@@ -240,4 +272,4 @@ export const ProgressBarButtons: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ProgressBarButtons;
|
export default ProgressBarButtons;
|
||||||
|
|||||||
@@ -1,23 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* @file RoundImage.tsx
|
||||||
|
* @description Component that provides functionality to create circular or rounded images
|
||||||
|
* in PowerPoint presentations. This tool creates a mask shape that can be used with
|
||||||
|
* PowerPoint's built-in shape intersection feature to crop images into a circular shape.
|
||||||
|
*/
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import {
|
import { CircleRegular } from "@fluentui/react-icons";
|
||||||
Button
|
|
||||||
} from "@fluentui/react-components";
|
|
||||||
import {
|
|
||||||
CircleRegular
|
|
||||||
} from "@fluentui/react-icons";
|
|
||||||
import { useStatusContext } from "./App";
|
import { useStatusContext } from "./App";
|
||||||
import { useCommonStyles } from "./commonStyles";
|
import { useCommonStyles } from "./commonStyles";
|
||||||
|
import ActionButton from "./ActionButton";
|
||||||
|
import { getErrorMessage, isPictureShape, getFirstSelectedSlide, selectShapesById } from "../types/office-types";
|
||||||
|
|
||||||
|
// Configuration constants
|
||||||
|
const MASK_COLOR = "red";
|
||||||
|
const SUCCESS_MESSAGE = "Created mask shape. Please select both the image and the oval, then use the 'Shape Format > Merge Shapes > Intersect' command in PowerPoint.";
|
||||||
|
|
||||||
export const RoundImage: React.FC = () => {
|
export const RoundImage: React.FC = () => {
|
||||||
const styles = useCommonStyles();
|
const styles = useCommonStyles();
|
||||||
const {
|
const { setStatusMessage, setStatusType } = useStatusContext();
|
||||||
statusMessage, setStatusMessage,
|
|
||||||
statusType, setStatusType,
|
|
||||||
isProcessing, setIsProcessing
|
|
||||||
} = useStatusContext();
|
|
||||||
|
|
||||||
const convertToRoundImage = async () => {
|
/**
|
||||||
setIsProcessing(true);
|
* Validates that an image shape is selected
|
||||||
|
* @param shapes The collection of selected shapes
|
||||||
|
* @param context The PowerPoint request context
|
||||||
|
* @returns The selected image shape or null if validation fails
|
||||||
|
*/
|
||||||
|
const validateShapeSelection = async (
|
||||||
|
shapes: PowerPoint.ShapeScopedCollection,
|
||||||
|
context: PowerPoint.RequestContext
|
||||||
|
): Promise<PowerPoint.Shape | null> => {
|
||||||
|
// Check if any shape is selected
|
||||||
|
if (shapes.items.length === 0) {
|
||||||
|
setStatusMessage("No shapes are selected. Please select an image.");
|
||||||
|
setStatusType("warning");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the first selected shape
|
||||||
|
const shape = shapes.items[0];
|
||||||
|
|
||||||
|
// Load essential properties
|
||||||
|
shape.load(["type"]);
|
||||||
|
await context.sync();
|
||||||
|
|
||||||
|
// Ensure the shape is a picture using our type-safe utility
|
||||||
|
if (!isPictureShape(shape)) {
|
||||||
|
setStatusMessage("Please select an image.");
|
||||||
|
setStatusType("warning");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return shape;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an elliptical mask shape for the image
|
||||||
|
* @param slide The slide to add the mask to
|
||||||
|
* @param imageShape The image shape to mask
|
||||||
|
* @param context The PowerPoint request context
|
||||||
|
* @returns The created mask shape
|
||||||
|
*/
|
||||||
|
const createMaskShape = async (
|
||||||
|
slide: PowerPoint.Slide,
|
||||||
|
imageShape: PowerPoint.Shape,
|
||||||
|
context: PowerPoint.RequestContext
|
||||||
|
): Promise<PowerPoint.Shape> => {
|
||||||
|
// Load current dimensions to maintain aspect ratio
|
||||||
|
imageShape.load(["width", "height", "left", "top", "id"]);
|
||||||
|
await context.sync();
|
||||||
|
|
||||||
|
// Create elliptical mask with proper type
|
||||||
|
const maskShape = slide.shapes.addGeometricShape(PowerPoint.GeometricShapeType.ellipse);
|
||||||
|
|
||||||
|
maskShape.load(["width", "height", "left", "top", "id"]);
|
||||||
|
await context.sync();
|
||||||
|
|
||||||
|
// Position the mask to match the image
|
||||||
|
maskShape.left = imageShape.left;
|
||||||
|
|
||||||
|
imageShape.lineFormat.load(["weight"]);
|
||||||
|
await context.sync();
|
||||||
|
|
||||||
|
maskShape.top = imageShape.top;
|
||||||
|
maskShape.width = imageShape.width;
|
||||||
|
maskShape.height = imageShape.height;
|
||||||
|
|
||||||
|
// Style the mask
|
||||||
|
maskShape.fill.setSolidColor(MASK_COLOR);
|
||||||
|
maskShape.lineFormat.visible = false;
|
||||||
|
|
||||||
|
return maskShape;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the mask to the image and selects both shapes
|
||||||
|
* @param slide The slide containing the shapes
|
||||||
|
* @param imageShape The image shape to mask
|
||||||
|
* @param maskShape The mask shape
|
||||||
|
* @param context The PowerPoint request context
|
||||||
|
*/
|
||||||
|
const applyMaskToImage = async (
|
||||||
|
slide: PowerPoint.Slide,
|
||||||
|
imageShape: PowerPoint.Shape,
|
||||||
|
maskShape: PowerPoint.Shape,
|
||||||
|
context: PowerPoint.RequestContext
|
||||||
|
): Promise<void> => {
|
||||||
|
// Store original dimensions to maintain after selection
|
||||||
|
const width = imageShape.width;
|
||||||
|
const height = imageShape.height;
|
||||||
|
|
||||||
|
// Select both shapes for the user to apply the intersection
|
||||||
|
selectShapesById(slide, [imageShape.id, maskShape.id]);
|
||||||
|
|
||||||
|
// Ensure we maintain the same size
|
||||||
|
imageShape.width = width;
|
||||||
|
imageShape.height = height;
|
||||||
|
|
||||||
|
await context.sync();
|
||||||
|
|
||||||
|
// Update status message with instructions
|
||||||
|
setStatusMessage(SUCCESS_MESSAGE);
|
||||||
|
setStatusType("warning");
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main function to convert an image to a round image
|
||||||
|
*/
|
||||||
|
const convertToRoundImage = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
await PowerPoint.run(async (context) => {
|
await PowerPoint.run(async (context) => {
|
||||||
// Get the selected shapes
|
// Get the selected shapes
|
||||||
@@ -25,85 +135,40 @@ export const RoundImage: React.FC = () => {
|
|||||||
shapes.load("items");
|
shapes.load("items");
|
||||||
await context.sync();
|
await context.sync();
|
||||||
|
|
||||||
if (shapes.items.length === 0) {
|
// Validate selection and get the image shape
|
||||||
setStatusMessage("No shapes are selected. Please select an image.");
|
const imageShape = await validateShapeSelection(shapes, context);
|
||||||
setStatusType("warning");
|
if (!imageShape) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the first selected shape
|
// Get the current slide using our type-safe utility
|
||||||
const shape = shapes.items[0];
|
const slide = getFirstSelectedSlide(context);
|
||||||
|
|
||||||
// Load essential properties
|
// Create the mask shape
|
||||||
shape.load(["type"]);
|
const maskShape = await createMaskShape(slide, imageShape, context);
|
||||||
await context.sync();
|
|
||||||
|
|
||||||
// Ensure the shape is a picture
|
|
||||||
if (shape.type !== PowerPoint.ShapeType.image) {
|
|
||||||
setStatusMessage("Please select an image.");
|
|
||||||
setStatusType("warning");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load current dimensions to maintain aspect ratio
|
// Apply the mask and select both shapes
|
||||||
shape.load(["width", "height", "left", "top", "id"]);
|
await applyMaskToImage(slide, imageShape, maskShape, context);
|
||||||
await context.sync();
|
|
||||||
|
|
||||||
// Store current dimensions
|
|
||||||
const width = shape.width;
|
|
||||||
const height = shape.height;
|
|
||||||
|
|
||||||
const slide = context.presentation.getSelectedSlides().getItemAt(0);
|
|
||||||
const maskShape = slide.shapes.addGeometricShape("Ellipse" as any);
|
|
||||||
|
|
||||||
maskShape.load(["width", "height", "left", "top", "id"]);
|
|
||||||
await context.sync();
|
|
||||||
|
|
||||||
maskShape.left = shape.left;
|
|
||||||
|
|
||||||
shape.lineFormat.load(["weight"]);
|
|
||||||
await context.sync();
|
|
||||||
|
|
||||||
maskShape.top = shape.top; // + shape.lineFormat.weight;
|
|
||||||
maskShape.width = shape.width;
|
|
||||||
maskShape.height = shape.height;
|
|
||||||
maskShape.fill.setSolidColor("red");
|
|
||||||
maskShape.lineFormat.visible = false;
|
|
||||||
|
|
||||||
setStatusMessage("Created mask shape. Please select both the image and the oval, then use the 'Shape Forma > Merge Shapes > Intersect' command in PowerPoint.");
|
|
||||||
setStatusType("warning");
|
|
||||||
|
|
||||||
slide.setSelectedShapes([shape.id, maskShape.id]);
|
|
||||||
// Ensure we maintain the same size
|
|
||||||
shape.width = width;
|
|
||||||
shape.height = height;
|
|
||||||
|
|
||||||
await context.sync();
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
setStatusMessage(`Error: ${error.message}`);
|
const errorMessage = getErrorMessage(error);
|
||||||
|
setStatusMessage(`Error: ${errorMessage}`);
|
||||||
setStatusType("error");
|
setStatusType("error");
|
||||||
console.error("Main error:", error);
|
console.error("Round image error:", error);
|
||||||
} finally {
|
|
||||||
setIsProcessing(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.buttonGroup}>
|
<div className={styles.buttonGroup}>
|
||||||
<Button
|
<ActionButton
|
||||||
appearance="primary"
|
title="Round Image"
|
||||||
className={styles.actionButton}
|
|
||||||
onClick={convertToRoundImage}
|
|
||||||
icon={<CircleRegular />}
|
icon={<CircleRegular />}
|
||||||
disabled={isProcessing}
|
onClick={convertToRoundImage}
|
||||||
>
|
/>
|
||||||
Round Image
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default RoundImage;
|
export default RoundImage;
|
||||||
|
|||||||
@@ -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,85 +1,156 @@
|
|||||||
|
/**
|
||||||
|
* @file SwapPositions.tsx
|
||||||
|
* @description Component that provides functionality to swap the positions of two selected shapes
|
||||||
|
* in PowerPoint presentations. This allows users to quickly exchange the locations of two objects
|
||||||
|
* while maintaining their individual properties and content.
|
||||||
|
*/
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import {
|
import { ArrowSwapRegular } from "@fluentui/react-icons";
|
||||||
Button
|
|
||||||
} from "@fluentui/react-components";
|
|
||||||
import {
|
|
||||||
ArrowSwapRegular
|
|
||||||
} from "@fluentui/react-icons";
|
|
||||||
import { useStatusContext } from "./App";
|
import { useStatusContext } from "./App";
|
||||||
import { useCommonStyles } from "./commonStyles";
|
import { useCommonStyles } from "./commonStyles";
|
||||||
|
import ActionButton from "./ActionButton";
|
||||||
|
import { getErrorMessage } from "../types/office-types";
|
||||||
|
|
||||||
|
// Position properties to load and swap
|
||||||
|
const POSITION_PROPERTIES = ["left", "top"];
|
||||||
|
|
||||||
|
// Shape position data interface
|
||||||
|
interface ShapePosition {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
}
|
||||||
|
|
||||||
export const SwapPositions: React.FC = () => {
|
export const SwapPositions: React.FC = () => {
|
||||||
const styles = useCommonStyles();
|
const styles = useCommonStyles();
|
||||||
const {
|
const { setStatusMessage, setStatusType } = useStatusContext();
|
||||||
statusMessage, setStatusMessage,
|
|
||||||
statusType, setStatusType,
|
|
||||||
isProcessing, setIsProcessing
|
|
||||||
} = useStatusContext();
|
|
||||||
|
|
||||||
const swapPositionsOfTwoSelectedObjects = async () => {
|
/**
|
||||||
setIsProcessing(true);
|
* Validates that exactly two shapes are selected
|
||||||
|
* @param shapes The collection of selected shapes
|
||||||
|
* @returns True if validation passes, false otherwise
|
||||||
|
*/
|
||||||
|
const validateShapeSelection = (shapes: PowerPoint.ShapeScopedCollection): boolean => {
|
||||||
|
if (shapes.items.length !== 2) {
|
||||||
|
setStatusMessage("Please select exactly two shapes to swap their positions.");
|
||||||
|
setStatusType("warning");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads position properties from the shapes
|
||||||
|
* @param shape1 The first shape
|
||||||
|
* @param shape2 The second shape
|
||||||
|
* @param context The PowerPoint request context
|
||||||
|
* @returns The loaded shapes
|
||||||
|
*/
|
||||||
|
const loadShapePositions = async (
|
||||||
|
shape1: PowerPoint.Shape,
|
||||||
|
shape2: PowerPoint.Shape,
|
||||||
|
context: PowerPoint.RequestContext
|
||||||
|
): Promise<[PowerPoint.Shape, PowerPoint.Shape]> => {
|
||||||
|
// Load position properties for both shapes in a single batch
|
||||||
|
shape1.load(POSITION_PROPERTIES);
|
||||||
|
shape2.load(POSITION_PROPERTIES);
|
||||||
|
await context.sync();
|
||||||
|
return [shape1, shape2];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Swaps the positions of two shapes
|
||||||
|
* @param shape1 The first shape
|
||||||
|
* @param shape2 The second shape
|
||||||
|
* @returns True if the swap was successful
|
||||||
|
*/
|
||||||
|
const swapPositions = (
|
||||||
|
shape1: PowerPoint.Shape,
|
||||||
|
shape2: PowerPoint.Shape
|
||||||
|
): boolean => {
|
||||||
|
try {
|
||||||
|
// Store the position of the first shape
|
||||||
|
const tempPosition: ShapePosition = {
|
||||||
|
left: shape1.left,
|
||||||
|
top: shape1.top
|
||||||
|
};
|
||||||
|
|
||||||
|
// Swap positions
|
||||||
|
shape1.left = shape2.left;
|
||||||
|
shape1.top = shape2.top;
|
||||||
|
shape2.left = tempPosition.left;
|
||||||
|
shape2.top = tempPosition.top;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error swapping positions:", getErrorMessage(err));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates an appropriate status message based on the results
|
||||||
|
* @param success Whether the swap was successful
|
||||||
|
* @returns The formatted status message
|
||||||
|
*/
|
||||||
|
const generateStatusMessage = (success: boolean): string => {
|
||||||
|
return success
|
||||||
|
? "Positions of the two shapes have been swapped successfully."
|
||||||
|
: "Failed to swap positions. Please try again with different shapes.";
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main function to swap positions of two selected shapes
|
||||||
|
*/
|
||||||
|
const swapPositionsOfTwoSelectedObjects = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
await PowerPoint.run(async (context) => {
|
await PowerPoint.run(async (context) => {
|
||||||
// Get the selected shapes
|
// Get the selected shapes
|
||||||
const shapes = context.presentation.getSelectedShapes();
|
const shapes = context.presentation.getSelectedShapes();
|
||||||
shapes.load("items/count");
|
shapes.load("items");
|
||||||
await context.sync();
|
await context.sync();
|
||||||
|
|
||||||
// Check if exactly two shapes are selected
|
// Validate shape selection
|
||||||
if (shapes.items.length !== 2) {
|
if (!validateShapeSelection(shapes)) {
|
||||||
setStatusMessage("Please select exactly two shapes to swap their positions.");
|
|
||||||
setStatusType("warning");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the two shapes
|
// Get the two shapes
|
||||||
const shapeObj1 = shapes.items[0];
|
const [shape1, shape2] = await loadShapePositions(
|
||||||
const shapeObj2 = shapes.items[1];
|
shapes.items[0],
|
||||||
|
shapes.items[1],
|
||||||
// Load position properties
|
context
|
||||||
shapeObj1.load("left,top");
|
);
|
||||||
shapeObj2.load("left,top");
|
|
||||||
await context.sync();
|
|
||||||
|
|
||||||
// Store the position of the first shape
|
|
||||||
const tempLeft = shapeObj1.left;
|
|
||||||
const tempTop = shapeObj1.top;
|
|
||||||
|
|
||||||
// Swap positions
|
// Swap positions
|
||||||
shapeObj1.left = shapeObj2.left;
|
const swapSuccess = swapPositions(shape1, shape2);
|
||||||
shapeObj1.top = shapeObj2.top;
|
|
||||||
shapeObj2.left = tempLeft;
|
|
||||||
shapeObj2.top = tempTop;
|
|
||||||
|
|
||||||
|
// Sync changes to PowerPoint
|
||||||
await context.sync();
|
await context.sync();
|
||||||
|
|
||||||
setStatusMessage("Positions of the two shapes have been swapped successfully.");
|
// Update status message
|
||||||
setStatusType("success");
|
const statusMessage = generateStatusMessage(swapSuccess);
|
||||||
|
setStatusMessage(statusMessage);
|
||||||
|
setStatusType(swapSuccess ? "success" : "error");
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatusMessage(`Error: ${error.message}`);
|
console.error("Error in swapPositionsOfTwoSelectedObjects:", getErrorMessage(error));
|
||||||
|
setStatusMessage(`Error: ${getErrorMessage(error)}`);
|
||||||
setStatusType("error");
|
setStatusType("error");
|
||||||
console.error("Swap positions error:", error);
|
|
||||||
} finally {
|
|
||||||
setIsProcessing(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.buttonGroup}>
|
<div className={styles.buttonGroup}>
|
||||||
<Button
|
<ActionButton
|
||||||
appearance="primary"
|
title="Swap Positions"
|
||||||
className={styles.actionButton}
|
|
||||||
onClick={swapPositionsOfTwoSelectedObjects}
|
|
||||||
icon={<ArrowSwapRegular />}
|
icon={<ArrowSwapRegular />}
|
||||||
disabled={isProcessing}
|
onClick={swapPositionsOfTwoSelectedObjects}
|
||||||
>
|
/>
|
||||||
Swap Positions
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SwapPositions;
|
export default SwapPositions;
|
||||||
|
|||||||
+15
-3
@@ -19,9 +19,21 @@ Office.onReady(() => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
if ((module as any).hot) {
|
// Define proper module hot interface for webpack hot module replacement
|
||||||
(module as any).hot.accept("./components/App", () => {
|
interface HotModule extends NodeModule {
|
||||||
|
hot?: {
|
||||||
|
accept(path: string, callback: () => void): void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the proper type for module with HMR
|
||||||
|
if ((module as HotModule).hot) {
|
||||||
|
(module as HotModule).hot?.accept("./components/App", () => {
|
||||||
const NextApp = require("./components/App").default;
|
const NextApp = require("./components/App").default;
|
||||||
root?.render(NextApp);
|
root?.render(
|
||||||
|
<FluentProvider theme={webLightTheme}>
|
||||||
|
<NextApp title={title} />
|
||||||
|
</FluentProvider>
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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