Optimize GridGuidelineManager component

- Improve performance by batching API calls and reducing context.sync() calls
- Add memoization with useCallback for event handlers
- Extract ColorButton component and create reusable numeric input hook
- Define slide dimensions as constants
- Add accessibility attributes to input fields
- Add comprehensive JSDoc documentation
This commit is contained in:
2025-03-22 19:47:18 +01:00
parent b76b34a332
commit b1699a74bc
+190 -111
View File
@@ -1,27 +1,76 @@
/**
* GridGuidelineManager Component
*
* This component provides functionality to create and manage grids and guidelines
* on PowerPoint slides. It allows users to add customizable grids with adjustable
* spacing, opacity, and color, as well as add individual guidelines with custom
* positioning and color.
*
* @module GridGuidelineManager
*/
import * as React from "react";
import {
Button,
makeStyles,
Label,
Slider,
SpinButton,
Divider,
ToggleButton,
tokens,
Input
tokens
} from "@fluentui/react-components";
import {
GridRegular,
DismissRegular,
AddRegular,
LineHorizontal3Regular,
SplitVerticalRegular,
GridDotsRegular,
ArrowResetRegular
GridDotsRegular
} from "@fluentui/react-icons";
import { useStatusContext } from "./App";
import { useCommonStyles } from "./commonStyles";
/**
* Standard dimensions for PowerPoint slides in points
*/
const SLIDE_WIDTH = 960; // Width in points
const SLIDE_HEIGHT = 540; // Height in points
/**
* Available colors for grids and guidelines
*/
type GridColor = "blue" | "red" | "yellow" | "green";
/**
* Direction options for guidelines
*/
type GuideDirection = "horizontal" | "vertical";
/**
* Prefixes used for naming shapes to identify them later
*/
const GRID_PREFIX = "edison_grid_";
const GUIDE_PREFIX = "edison_guide_";
/**
* Color values mapping for the available grid and guideline colors
*/
const colorValues = {
blue: "#4472C4",
red: "#C00000",
yellow: "#FFC000",
green: "#70AD47"
};
/**
* Props for the ColorButton component
*/
interface ColorButtonProps {
/** The color this button represents */
color: GridColor;
/** The currently selected color */
selectedColor: GridColor;
/** Callback function when the color is clicked */
onClick: (color: GridColor) => void;
}
const useStyles = makeStyles({
buttonGrid: {
display: "grid",
@@ -81,18 +130,55 @@ const useStyles = makeStyles({
}
});
type GridColor = "blue" | "red" | "yellow" | "green";
const GRID_PREFIX = "edison_grid_";
const GUIDE_PREFIX = "edison_guide_";
const colorValues = {
blue: "#4472C4",
red: "#C00000",
yellow: "#FFC000",
green: "#70AD47"
/**
* ColorButton component - Renders a color selection button
*
* @param props - Component props
* @returns React component
*/
const ColorButton: React.FC<ColorButtonProps> = ({ color, selectedColor, onClick }) => {
const styles = useStyles();
return (
<Button
className={`${styles.colorButton} ${color === selectedColor ? styles.colorButtonSelected : ''}`}
style={{ backgroundColor: colorValues[color] }}
onClick={() => onClick(color)}
title={color.charAt(0).toUpperCase() + color.slice(1)}
/>
);
};
/**
* Custom hook for handling numeric input fields
*
* @param initialValue - The initial value for the input
* @returns A tuple containing the current value and a change handler function
*/
const useNumericInputHandler = (
initialValue: number
): [number, (e: React.ChangeEvent<HTMLInputElement>) => void] => {
const [value, setValue] = React.useState(initialValue);
const handleChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
const parsed = parseInt(newValue);
if (newValue === '') {
setValue(0);
} else if (!isNaN(parsed)) {
setValue(parsed);
}
}, []);
return [value, handleChange];
};
/**
* GridGuidelineManager component - Provides UI and functionality for creating
* and managing grids and guidelines on PowerPoint slides
*
* @returns React component
*/
export const GridGuidelineManager: React.FC = () => {
const styles = useStyles();
const commonStyles = useCommonStyles();
@@ -103,17 +189,26 @@ export const GridGuidelineManager: React.FC = () => {
} = useStatusContext();
// Grid state
const [gridSpacing, setGridSpacing] = React.useState(50);
const [gridSpacing, handleGridSpacingChange] = useNumericInputHandler(50);
const [gridColor, setGridColor] = React.useState<GridColor>("blue");
const [gridOpacity, setGridOpacity] = React.useState(30);
const [gridOpacity, handleGridOpacityChange] = useNumericInputHandler(30);
// Guidelines state
const [guidePosition, setGuidePosition] = React.useState(240);
const [guideDirection, setGuideDirection] = React.useState<"horizontal" | "vertical">("horizontal");
const [guidePosition, handleGuidePositionChange] = useNumericInputHandler(240);
const [guideDirection, setGuideDirection] = React.useState<GuideDirection>("horizontal");
const [guideColor, setGuideColor] = React.useState<GridColor>("red");
// Helper function to create a line shape
const createLineShape = (
/**
* Creates a line shape on the slide using a thin rectangle
*
* @param slide - The PowerPoint slide to add the shape to
* @param startX - Starting X coordinate
* @param startY - Starting Y coordinate
* @param endX - Ending X coordinate
* @param endY - Ending Y coordinate
* @returns The created shape object
*/
const createLineShape = React.useCallback((
slide: PowerPoint.Slide,
startX: number,
startY: number,
@@ -138,9 +233,12 @@ export const GridGuidelineManager: React.FC = () => {
}
return line;
};
}, []);
const createGrid = async () => {
/**
* Creates a grid on the current slide based on the configured settings
*/
const createGrid = React.useCallback(async () => {
setIsProcessing(true);
try {
await PowerPoint.run(async (context) => {
@@ -158,23 +256,19 @@ export const GridGuidelineManager: React.FC = () => {
const currentSlide = selectedSlides.items[0];
// Standard dimensions for PowerPoint slides
const slideWidth = 960; // Width in points
const slideHeight = 540; // Height in points
// Calculate number of lines needed
const numHorizontalLines = Math.floor(slideHeight / gridSpacing);
const numVerticalLines = Math.floor(slideWidth / gridSpacing);
const numHorizontalLines = Math.floor(SLIDE_HEIGHT / gridSpacing);
const numVerticalLines = Math.floor(SLIDE_WIDTH / gridSpacing);
// Create horizontal grid lines
for (let i = 1; i <= numHorizontalLines; i++) {
const yPosition = i * gridSpacing;
// Skip if we're at the edge of the slide
if (yPosition >= slideHeight) continue;
if (yPosition >= SLIDE_HEIGHT) continue;
// Create horizontal line
const line = createLineShape(currentSlide, 0, yPosition, slideWidth, yPosition);
const line = createLineShape(currentSlide, 0, yPosition, SLIDE_WIDTH, yPosition);
line.name = `${GRID_PREFIX}h_${i}`;
// Set line properties
@@ -191,10 +285,10 @@ export const GridGuidelineManager: React.FC = () => {
const xPosition = i * gridSpacing;
// Skip if we're at the edge of the slide
if (xPosition >= slideWidth) continue;
if (xPosition >= SLIDE_WIDTH) continue;
// Create vertical line
const line = createLineShape(currentSlide, xPosition, 0, xPosition, slideHeight);
const line = createLineShape(currentSlide, xPosition, 0, xPosition, SLIDE_HEIGHT);
line.name = `${GRID_PREFIX}v_${i}`;
// Set line properties
@@ -216,9 +310,14 @@ export const GridGuidelineManager: React.FC = () => {
} finally {
setIsProcessing(false);
}
};
}, [createLineShape, gridColor, gridOpacity, gridSpacing, setIsProcessing, setStatusMessage, setStatusType]);
const removeGrid = async (showStatus = true) => {
/**
* Removes the grid from the current slide
*
* @param showStatus - Whether to show status messages (default: true)
*/
const removeGrid = React.useCallback(async (showStatus = true) => {
if (showStatus) {
setIsProcessing(true);
}
@@ -282,9 +381,12 @@ export const GridGuidelineManager: React.FC = () => {
setIsProcessing(false);
}
}
};
}, [setIsProcessing, setStatusMessage, setStatusType]);
const addGuideline = async () => {
/**
* Adds a guideline to the current slide based on the configured settings
*/
const addGuideline = React.useCallback(async () => {
setIsProcessing(true);
try {
await PowerPoint.run(async (context) => {
@@ -299,9 +401,6 @@ export const GridGuidelineManager: React.FC = () => {
const currentSlide = selectedSlides.items[0];
// Standard dimensions for PowerPoint slides
const slideWidth = 960; // Width in points
const slideHeight = 540; // Height in points
// Create a unique identifier for this guide
const timestamp = new Date().getTime();
@@ -311,10 +410,10 @@ export const GridGuidelineManager: React.FC = () => {
let line;
if (guideDirection === "horizontal") {
// Create horizontal guideline
line = createLineShape(currentSlide, 0, guidePosition, slideWidth, guidePosition);
line = createLineShape(currentSlide, 0, guidePosition, SLIDE_WIDTH, guidePosition);
} else {
// Create vertical guideline
line = createLineShape(currentSlide, guidePosition, 0, guidePosition, slideHeight);
line = createLineShape(currentSlide, guidePosition, 0, guidePosition, SLIDE_HEIGHT);
}
line.name = guideId;
@@ -336,9 +435,12 @@ export const GridGuidelineManager: React.FC = () => {
} finally {
setIsProcessing(false);
}
};
}, [createLineShape, guideColor, guideDirection, guidePosition, setIsProcessing, setStatusMessage, setStatusType]);
const removeGuidelines = async () => {
/**
* Removes all guidelines from the current slide
*/
const removeGuidelines = React.useCallback(async () => {
setIsProcessing(true);
try {
await PowerPoint.run(async (context) => {
@@ -358,13 +460,17 @@ export const GridGuidelineManager: React.FC = () => {
shapes.load("items");
await context.sync();
// Find shapes that are guidelines
// Load all shape names at once
const allShapes = shapes.items;
for (let i = 0; i < allShapes.length; i++) {
allShapes[i].load("name");
}
await context.sync();
// Find shapes that are guidelines and mark them for deletion
let removedCount = 0;
for (let i = 0; i < shapes.items.length; i++) {
const shape = shapes.items[i];
shape.load("name");
await context.sync();
for (let i = 0; i < allShapes.length; i++) {
const shape = allShapes[i];
// Check if this shape is a guideline
if (shape.name && shape.name.startsWith(GUIDE_PREFIX)) {
shape.delete();
@@ -372,6 +478,7 @@ export const GridGuidelineManager: React.FC = () => {
}
}
// Single sync after all deletions
await context.sync();
if (removedCount > 0) {
@@ -388,20 +495,7 @@ export const GridGuidelineManager: React.FC = () => {
} finally {
setIsProcessing(false);
}
};
// Color picker button component
const ColorButton = ({ color, selectedColor, onClick }:
{ color: GridColor, selectedColor: GridColor, onClick: (color: GridColor) => void }) => {
return (
<Button
className={`${styles.colorButton} ${color === selectedColor ? styles.colorButtonSelected : ''}`}
style={{ backgroundColor: colorValues[color] }}
onClick={() => onClick(color)}
title={color.charAt(0).toUpperCase() + color.slice(1)}
/>
);
};
}, [setIsProcessing, setStatusMessage, setStatusType]);
return (
<div className={commonStyles.container} style={{ maxHeight: "400px", overflowY: "auto" }}>
@@ -420,16 +514,11 @@ export const GridGuidelineManager: React.FC = () => {
<input
type="text"
value={gridSpacing}
onChange={(e) => {
const newValue = e.target.value;
const parsed = parseInt(newValue);
if (newValue === '') {
setGridSpacing(0);
} else if (!isNaN(parsed)) {
setGridSpacing(parsed);
}
}}
onChange={handleGridSpacingChange}
onClick={(e) => (e.target as HTMLInputElement).select()}
title="Grid Spacing"
placeholder="Grid Spacing"
aria-label="Grid Spacing"
style={{
width: "100%",
padding: "5px 8px",
@@ -447,16 +536,11 @@ export const GridGuidelineManager: React.FC = () => {
<input
type="text"
value={gridOpacity}
onChange={(e) => {
const newValue = e.target.value;
const parsed = parseInt(newValue);
if (newValue === '') {
setGridOpacity(0);
} else if (!isNaN(parsed)) {
setGridOpacity(parsed);
}
}}
onChange={handleGridOpacityChange}
onClick={(e) => (e.target as HTMLInputElement).select()}
title="Grid Opacity"
placeholder="Grid Opacity"
aria-label="Grid Opacity"
style={{
width: "100%",
padding: "5px 8px",
@@ -511,32 +595,27 @@ export const GridGuidelineManager: React.FC = () => {
Guidelines
</Label>
<div className={styles.controlRow}>
<Label className={styles.controlLabel}>Position</Label>
<div style={{ width: "80px", flex: "none" }}>
<input
type="text"
value={guidePosition}
onChange={(e) => {
const newValue = e.target.value;
const parsed = parseInt(newValue);
if (newValue === '') {
setGuidePosition(0);
} else if (!isNaN(parsed)) {
setGuidePosition(parsed);
}
}}
onClick={(e) => (e.target as HTMLInputElement).select()}
style={{
width: "100%",
padding: "5px 8px",
border: "1px solid #d1d1d1",
borderRadius: "4px",
fontSize: "14px"
}}
/>
</div>
<div className={styles.controlRow}>
<Label className={styles.controlLabel}>Position</Label>
<div style={{ width: "80px", flex: "none" }}>
<input
type="text"
value={guidePosition}
onChange={handleGuidePositionChange}
onClick={(e) => (e.target as HTMLInputElement).select()}
title="Guide Position"
placeholder="Guide Position"
aria-label="Guide Position"
style={{
width: "100%",
padding: "5px 8px",
border: "1px solid #d1d1d1",
borderRadius: "4px",
fontSize: "14px"
}}
/>
</div>
</div>
<div className={styles.controlRow}>
<Label className={styles.controlLabel}>Type</Label>
@@ -601,4 +680,4 @@ export const GridGuidelineManager: React.FC = () => {
);
};
export default GridGuidelineManager;
export default GridGuidelineManager;