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
+189 -110
View File
@@ -1,27 +1,76 @@
/**
* GridGuidelineManager Component
*
* This component provides functionality to create and manage grids and guidelines
* on PowerPoint slides. It allows users to add customizable grids with adjustable
* spacing, opacity, and color, as well as add individual guidelines with custom
* positioning and color.
*
* @module GridGuidelineManager
*/
import * as React from "react"; import * as React from "react";
import { import {
Button, Button,
makeStyles, makeStyles,
Label, Label,
Slider,
SpinButton,
Divider,
ToggleButton, ToggleButton,
tokens, tokens
Input
} from "@fluentui/react-components"; } from "@fluentui/react-components";
import { import {
GridRegular,
DismissRegular, DismissRegular,
AddRegular, AddRegular,
LineHorizontal3Regular, LineHorizontal3Regular,
SplitVerticalRegular, SplitVerticalRegular,
GridDotsRegular, GridDotsRegular
ArrowResetRegular
} from "@fluentui/react-icons"; } from "@fluentui/react-icons";
import { useStatusContext } from "./App"; import { useStatusContext } from "./App";
import { useCommonStyles } from "./commonStyles"; import { useCommonStyles } from "./commonStyles";
/**
* Standard dimensions for PowerPoint slides in points
*/
const SLIDE_WIDTH = 960; // Width in points
const SLIDE_HEIGHT = 540; // Height in points
/**
* Available colors for grids and guidelines
*/
type GridColor = "blue" | "red" | "yellow" | "green";
/**
* Direction options for guidelines
*/
type GuideDirection = "horizontal" | "vertical";
/**
* Prefixes used for naming shapes to identify them later
*/
const GRID_PREFIX = "edison_grid_";
const GUIDE_PREFIX = "edison_guide_";
/**
* Color values mapping for the available grid and guideline colors
*/
const colorValues = {
blue: "#4472C4",
red: "#C00000",
yellow: "#FFC000",
green: "#70AD47"
};
/**
* Props for the ColorButton component
*/
interface ColorButtonProps {
/** The color this button represents */
color: GridColor;
/** The currently selected color */
selectedColor: GridColor;
/** Callback function when the color is clicked */
onClick: (color: GridColor) => void;
}
const useStyles = makeStyles({ const useStyles = makeStyles({
buttonGrid: { buttonGrid: {
display: "grid", display: "grid",
@@ -81,18 +130,55 @@ const useStyles = makeStyles({
} }
}); });
type GridColor = "blue" | "red" | "yellow" | "green"; /**
* ColorButton component - Renders a color selection button
const GRID_PREFIX = "edison_grid_"; *
const GUIDE_PREFIX = "edison_guide_"; * @param props - Component props
* @returns React component
const colorValues = { */
blue: "#4472C4", const ColorButton: React.FC<ColorButtonProps> = ({ color, selectedColor, onClick }) => {
red: "#C00000", const styles = useStyles();
yellow: "#FFC000", return (
green: "#70AD47" <Button
className={`${styles.colorButton} ${color === selectedColor ? styles.colorButtonSelected : ''}`}
style={{ backgroundColor: colorValues[color] }}
onClick={() => onClick(color)}
title={color.charAt(0).toUpperCase() + color.slice(1)}
/>
);
}; };
/**
* Custom hook for handling numeric input fields
*
* @param initialValue - The initial value for the input
* @returns A tuple containing the current value and a change handler function
*/
const useNumericInputHandler = (
initialValue: number
): [number, (e: React.ChangeEvent<HTMLInputElement>) => void] => {
const [value, setValue] = React.useState(initialValue);
const handleChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
const parsed = parseInt(newValue);
if (newValue === '') {
setValue(0);
} else if (!isNaN(parsed)) {
setValue(parsed);
}
}, []);
return [value, handleChange];
};
/**
* GridGuidelineManager component - Provides UI and functionality for creating
* and managing grids and guidelines on PowerPoint slides
*
* @returns React component
*/
export const GridGuidelineManager: React.FC = () => { export const GridGuidelineManager: React.FC = () => {
const styles = useStyles(); const styles = useStyles();
const commonStyles = useCommonStyles(); const commonStyles = useCommonStyles();
@@ -103,17 +189,26 @@ export const GridGuidelineManager: React.FC = () => {
} = useStatusContext(); } = useStatusContext();
// Grid state // Grid state
const [gridSpacing, setGridSpacing] = React.useState(50); const [gridSpacing, handleGridSpacingChange] = useNumericInputHandler(50);
const [gridColor, setGridColor] = React.useState<GridColor>("blue"); const [gridColor, setGridColor] = React.useState<GridColor>("blue");
const [gridOpacity, setGridOpacity] = React.useState(30); const [gridOpacity, handleGridOpacityChange] = useNumericInputHandler(30);
// Guidelines state // Guidelines state
const [guidePosition, setGuidePosition] = React.useState(240); const [guidePosition, handleGuidePositionChange] = useNumericInputHandler(240);
const [guideDirection, setGuideDirection] = React.useState<"horizontal" | "vertical">("horizontal"); const [guideDirection, setGuideDirection] = React.useState<GuideDirection>("horizontal");
const [guideColor, setGuideColor] = React.useState<GridColor>("red"); const [guideColor, setGuideColor] = React.useState<GridColor>("red");
// Helper function to create a line shape /**
const createLineShape = ( * Creates a line shape on the slide using a thin rectangle
*
* @param slide - The PowerPoint slide to add the shape to
* @param startX - Starting X coordinate
* @param startY - Starting Y coordinate
* @param endX - Ending X coordinate
* @param endY - Ending Y coordinate
* @returns The created shape object
*/
const createLineShape = React.useCallback((
slide: PowerPoint.Slide, slide: PowerPoint.Slide,
startX: number, startX: number,
startY: number, startY: number,
@@ -138,9 +233,12 @@ export const GridGuidelineManager: React.FC = () => {
} }
return line; return line;
}; }, []);
const createGrid = async () => { /**
* Creates a grid on the current slide based on the configured settings
*/
const createGrid = React.useCallback(async () => {
setIsProcessing(true); setIsProcessing(true);
try { try {
await PowerPoint.run(async (context) => { await PowerPoint.run(async (context) => {
@@ -158,23 +256,19 @@ export const GridGuidelineManager: React.FC = () => {
const currentSlide = selectedSlides.items[0]; const currentSlide = selectedSlides.items[0];
// Standard dimensions for PowerPoint slides
const slideWidth = 960; // Width in points
const slideHeight = 540; // Height in points
// Calculate number of lines needed // Calculate number of lines needed
const numHorizontalLines = Math.floor(slideHeight / gridSpacing); const numHorizontalLines = Math.floor(SLIDE_HEIGHT / gridSpacing);
const numVerticalLines = Math.floor(slideWidth / gridSpacing); const numVerticalLines = Math.floor(SLIDE_WIDTH / gridSpacing);
// Create horizontal grid lines // Create horizontal grid lines
for (let i = 1; i <= numHorizontalLines; i++) { for (let i = 1; i <= numHorizontalLines; i++) {
const yPosition = i * gridSpacing; const yPosition = i * gridSpacing;
// Skip if we're at the edge of the slide // Skip if we're at the edge of the slide
if (yPosition >= slideHeight) continue; if (yPosition >= SLIDE_HEIGHT) continue;
// Create horizontal line // Create horizontal line
const line = createLineShape(currentSlide, 0, yPosition, slideWidth, yPosition); const line = createLineShape(currentSlide, 0, yPosition, SLIDE_WIDTH, yPosition);
line.name = `${GRID_PREFIX}h_${i}`; line.name = `${GRID_PREFIX}h_${i}`;
// Set line properties // Set line properties
@@ -191,10 +285,10 @@ export const GridGuidelineManager: React.FC = () => {
const xPosition = i * gridSpacing; const xPosition = i * gridSpacing;
// Skip if we're at the edge of the slide // Skip if we're at the edge of the slide
if (xPosition >= slideWidth) continue; if (xPosition >= SLIDE_WIDTH) continue;
// Create vertical line // Create vertical line
const line = createLineShape(currentSlide, xPosition, 0, xPosition, slideHeight); const line = createLineShape(currentSlide, xPosition, 0, xPosition, SLIDE_HEIGHT);
line.name = `${GRID_PREFIX}v_${i}`; line.name = `${GRID_PREFIX}v_${i}`;
// Set line properties // Set line properties
@@ -216,9 +310,14 @@ export const GridGuidelineManager: React.FC = () => {
} finally { } finally {
setIsProcessing(false); setIsProcessing(false);
} }
}; }, [createLineShape, gridColor, gridOpacity, gridSpacing, setIsProcessing, setStatusMessage, setStatusType]);
const removeGrid = async (showStatus = true) => { /**
* Removes the grid from the current slide
*
* @param showStatus - Whether to show status messages (default: true)
*/
const removeGrid = React.useCallback(async (showStatus = true) => {
if (showStatus) { if (showStatus) {
setIsProcessing(true); setIsProcessing(true);
} }
@@ -282,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) => {
@@ -299,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();
@@ -311,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;
@@ -336,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) => {
@@ -358,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
let removedCount = 0; const allShapes = shapes.items;
for (let i = 0; i < shapes.items.length; i++) { for (let i = 0; i < allShapes.length; i++) {
const shape = shapes.items[i]; allShapes[i].load("name");
shape.load("name"); }
await context.sync(); await context.sync();
// Find shapes that are guidelines and mark them for deletion
let removedCount = 0;
for (let i = 0; i < allShapes.length; i++) {
const shape = allShapes[i];
// Check if this shape is a guideline // 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();
@@ -372,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) {
@@ -388,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" }}>
@@ -420,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",
@@ -447,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",
@@ -511,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>