Files
powerpoint-toolbox/src/taskpane/components/GridGuidelineManager.tsx
T
schihei 07b0232726 Optimize performance by batching context.sync() calls
- GridGuidelineManager: Load all shape names upfront and apply operations in batches
- AlignmentButtons: Replaced for loops with forEach and reduced sync calls
- MatchProperties: Reorganized code to batch load operations and property assignments

This optimization significantly reduces round-trips between JavaScript and the Office application,
improving performance and responsiveness of the add-in.

🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-03-14 23:56:54 +01:00

604 lines
18 KiB
TypeScript

import * as React from "react";
import {
Button,
makeStyles,
Label,
Slider,
SpinButton,
Divider,
ToggleButton,
tokens,
Input
} from "@fluentui/react-components";
import {
GridRegular,
DismissRegular,
AddRegular,
LineHorizontal3Regular,
SplitVerticalRegular,
GridDotsRegular,
ArrowResetRegular
} from "@fluentui/react-icons";
import { useStatusContext } from "./App";
import { useCommonStyles } from "./commonStyles";
const useStyles = makeStyles({
buttonGrid: {
display: "grid",
gridTemplateColumns: "repeat(2, 1fr)",
gap: "8px",
width: "100%",
marginBottom: "8px"
},
gridButton: {
width: "100%",
minWidth: 0,
padding: "8px 4px",
"& span": {
justifyContent: "center"
}
},
controlRow: {
display: "flex",
alignItems: "center",
gap: "8px",
marginBottom: "8px"
},
controlLabel: {
minWidth: "80px",
marginBottom: "0"
},
controlInput: {
flex: 1,
"& input[type=number]::-webkit-inner-spin-button, & input[type=number]::-webkit-outer-spin-button": {
"-webkit-appearance": "none",
margin: 0
},
"& input[type=number]": {
"-moz-appearance": "textfield"
}
},
guidesContainer: {
marginTop: "16px"
},
guideButtonGrid: {
display: "grid",
gridTemplateColumns: "repeat(2, 1fr)",
gap: "8px",
width: "100%",
marginTop: "8px"
},
colorButton: {
width: "24px",
height: "24px",
minWidth: "24px",
padding: "0",
border: "1px solid " + tokens.colorNeutralStroke1,
marginLeft: "8px"
},
colorButtonSelected: {
border: "2px solid " + tokens.colorBrandForeground1
}
});
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"
};
export const GridGuidelineManager: React.FC = () => {
const styles = useStyles();
const commonStyles = useCommonStyles();
const {
statusMessage, setStatusMessage,
statusType, setStatusType,
isProcessing, setIsProcessing
} = useStatusContext();
// Grid state
const [gridSpacing, setGridSpacing] = React.useState(50);
const [gridColor, setGridColor] = React.useState<GridColor>("blue");
const [gridOpacity, setGridOpacity] = React.useState(30);
// Guidelines state
const [guidePosition, setGuidePosition] = React.useState(240);
const [guideDirection, setGuideDirection] = React.useState<"horizontal" | "vertical">("horizontal");
const [guideColor, setGuideColor] = React.useState<GridColor>("red");
// Helper function to create a line shape
const createLineShape = (
slide: PowerPoint.Slide,
startX: number,
startY: number,
endX: number,
endY: number
) => {
// Create a line using alternative approach with a rectangle shape
const line = slide.shapes.addGeometricShape("Rectangle");
if (startX === endX) {
// Vertical line
line.left = startX;
line.top = startY;
line.height = endY - startY;
line.width = 1; // Make it 1pt wide
} else {
// Horizontal line
line.left = startX;
line.top = startY;
line.width = endX - startX;
line.height = 1; // Make it 1pt tall
}
return line;
};
const createGrid = async () => {
setIsProcessing(true);
try {
await PowerPoint.run(async (context) => {
// Remove any existing grid first
await removeGrid(false);
// Get the current slide using the selected slides collection
const selectedSlides = context.presentation.getSelectedSlides();
selectedSlides.load("items");
await context.sync();
if (selectedSlides.items.length === 0) {
throw new Error("No slide selected");
}
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);
// 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;
// Create horizontal line
const line = createLineShape(currentSlide, 0, yPosition, slideWidth, yPosition);
line.name = `${GRID_PREFIX}h_${i}`;
// Set line properties
line.fill.setSolidColor(colorValues[gridColor]);
line.lineFormat.weight = 0.75; // Thin line
line.lineFormat.visible = false; // No outline, just fill
// Set transparency (opacity is inverse of transparency)
line.fill.transparency = (100 - gridOpacity) / 100;
}
// Create vertical grid lines
for (let i = 1; i <= numVerticalLines; i++) {
const xPosition = i * gridSpacing;
// Skip if we're at the edge of the slide
if (xPosition >= slideWidth) continue;
// Create vertical line
const line = createLineShape(currentSlide, xPosition, 0, xPosition, slideHeight);
line.name = `${GRID_PREFIX}v_${i}`;
// Set line properties
line.fill.setSolidColor(colorValues[gridColor]);
line.lineFormat.weight = 0.75; // Thin line
line.lineFormat.visible = false; // No outline, just fill
// Set transparency (opacity is inverse of transparency)
line.fill.transparency = (100 - gridOpacity) / 100;
}
setStatusMessage("Grid created on current slide");
setStatusType("success");
});
} catch (error) {
setStatusMessage(`Error: ${error.message}`);
setStatusType("error");
console.error("Create grid error:", error);
} finally {
setIsProcessing(false);
}
};
const removeGrid = async (showStatus = true) => {
if (showStatus) {
setIsProcessing(true);
}
try {
await PowerPoint.run(async (context) => {
// Get the current slide using the selected slides collection
const selectedSlides = context.presentation.getSelectedSlides();
selectedSlides.load("items");
await context.sync();
if (selectedSlides.items.length === 0) {
throw new Error("No slide selected");
}
const currentSlide = selectedSlides.items[0];
// Load all shapes on this slide
const shapes = currentSlide.shapes;
shapes.load("items");
await context.sync();
// Load all shape names at once
const allShapes = shapes.items;
for (let i = 0; i < allShapes.length; i++) {
allShapes[i].load("name");
}
await context.sync();
// Find shapes that are part of our grid and mark them for deletion
let removedCount = 0;
for (let i = 0; i < allShapes.length; i++) {
const shape = allShapes[i];
// Check if this shape is part of our grid
if (shape.name && shape.name.startsWith(GRID_PREFIX)) {
shape.delete();
removedCount++;
}
}
// Single sync after all deletions
await context.sync();
if (showStatus) {
if (removedCount > 0) {
setStatusMessage(`Removed grid from slide`);
} else {
setStatusMessage("No grid found to remove");
}
setStatusType("success");
}
});
} catch (error) {
if (showStatus) {
setStatusMessage(`Error: ${error.message}`);
setStatusType("error");
}
console.error("Remove grid error:", error);
} finally {
if (showStatus) {
setIsProcessing(false);
}
}
};
const addGuideline = async () => {
setIsProcessing(true);
try {
await PowerPoint.run(async (context) => {
// Get the current slide using the selected slides collection
const selectedSlides = context.presentation.getSelectedSlides();
selectedSlides.load("items");
await context.sync();
if (selectedSlides.items.length === 0) {
throw new Error("No slide selected");
}
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();
const guideId = `${GUIDE_PREFIX}${guideDirection}_${timestamp}`;
// Add the guide line
let line;
if (guideDirection === "horizontal") {
// Create horizontal guideline
line = createLineShape(currentSlide, 0, guidePosition, slideWidth, guidePosition);
} else {
// Create vertical guideline
line = createLineShape(currentSlide, guidePosition, 0, guidePosition, slideHeight);
}
line.name = guideId;
// Set line properties
line.fill.setSolidColor(colorValues[guideColor]);
line.lineFormat.visible = false; // No outline, just fill
// We want solid lines for guidelines
line.fill.transparency = 0;
setStatusMessage(`Added ${guideDirection} guideline`);
setStatusType("success");
});
} catch (error) {
setStatusMessage(`Error: ${error.message}`);
setStatusType("error");
console.error("Add guideline error:", error);
} finally {
setIsProcessing(false);
}
};
const removeGuidelines = async () => {
setIsProcessing(true);
try {
await PowerPoint.run(async (context) => {
// Get the current slide using the selected slides collection
const selectedSlides = context.presentation.getSelectedSlides();
selectedSlides.load("items");
await context.sync();
if (selectedSlides.items.length === 0) {
throw new Error("No slide selected");
}
const currentSlide = selectedSlides.items[0];
// Load all shapes on this slide
const shapes = currentSlide.shapes;
shapes.load("items");
await context.sync();
// Find shapes that are guidelines
let removedCount = 0;
for (let i = 0; i < shapes.items.length; i++) {
const shape = shapes.items[i];
shape.load("name");
await context.sync();
// Check if this shape is a guideline
if (shape.name && shape.name.startsWith(GUIDE_PREFIX)) {
shape.delete();
removedCount++;
}
}
await context.sync();
if (removedCount > 0) {
setStatusMessage(`Removed ${removedCount} guidelines`);
} else {
setStatusMessage("No guidelines found to remove");
}
setStatusType("success");
});
} catch (error) {
setStatusMessage(`Error: ${error.message}`);
setStatusType("error");
console.error("Remove guidelines error:", error);
} 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)}
/>
);
};
return (
<div className={commonStyles.container} style={{ maxHeight: "400px", overflowY: "auto" }}>
{/* Grid Controls */}
<Label style={{
fontWeight: "600",
marginBottom: "12px",
fontSize: "16px"
}}>
Grid
</Label>
<div className={styles.controlRow}>
<Label className={styles.controlLabel}>Spacing</Label>
<div style={{ width: "80px", flex: "none" }}>
<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);
}
}}
onClick={(e) => (e.target as HTMLInputElement).select()}
style={{
width: "100%",
padding: "5px 8px",
border: "1px solid #d1d1d1",
borderRadius: "4px",
fontSize: "14px"
}}
/>
</div>
</div>
<div className={styles.controlRow}>
<Label className={styles.controlLabel}>Opacity</Label>
<div style={{ width: "80px", flex: "none" }}>
<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);
}
}}
onClick={(e) => (e.target as HTMLInputElement).select()}
style={{
width: "100%",
padding: "5px 8px",
border: "1px solid #d1d1d1",
borderRadius: "4px",
fontSize: "14px"
}}
/>
</div>
</div>
<div className={styles.controlRow}>
<Label className={styles.controlLabel}>Color</Label>
<div style={{ display: "flex" }}>
<ColorButton color="blue" selectedColor={gridColor} onClick={setGridColor} />
<ColorButton color="red" selectedColor={gridColor} onClick={setGridColor} />
<ColorButton color="yellow" selectedColor={gridColor} onClick={setGridColor} />
<ColorButton color="green" selectedColor={gridColor} onClick={setGridColor} />
</div>
</div>
<div className={styles.buttonGrid} style={{ marginTop: "8px" }}>
<Button
appearance="primary"
className={styles.gridButton}
onClick={createGrid}
icon={<GridDotsRegular />}
disabled={isProcessing}
title="Create Grid"
>
Add
</Button>
<Button
appearance="primary"
className={styles.gridButton}
onClick={() => removeGrid(true)}
icon={<DismissRegular />}
disabled={isProcessing}
title="Remove Grid"
>
Remove
</Button>
</div>
{/* Guidelines Section */}
<div className={styles.guidesContainer}>
<Label style={{
fontWeight: "600",
marginBottom: "12px",
fontSize: "16px"
}}>
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>
<div className={styles.controlRow}>
<Label className={styles.controlLabel}>Type</Label>
<div style={{ display: "flex", gap: "8px" }}>
<ToggleButton
size="small"
appearance="outline"
checked={guideDirection === "horizontal"}
onClick={() => setGuideDirection("horizontal")}
icon={<LineHorizontal3Regular />}
style={{ minWidth: "40px", padding: "2px 8px" }}
>
Horizontal
</ToggleButton>
<ToggleButton
size="small"
appearance="outline"
checked={guideDirection === "vertical"}
onClick={() => setGuideDirection("vertical")}
icon={<SplitVerticalRegular />}
style={{ minWidth: "40px", padding: "2px 8px" }}
>
Vertical
</ToggleButton>
</div>
</div>
<div className={styles.controlRow}>
<Label className={styles.controlLabel}>Color</Label>
<div style={{ display: "flex" }}>
<ColorButton color="blue" selectedColor={guideColor} onClick={setGuideColor} />
<ColorButton color="red" selectedColor={guideColor} onClick={setGuideColor} />
<ColorButton color="yellow" selectedColor={guideColor} onClick={setGuideColor} />
<ColorButton color="green" selectedColor={guideColor} onClick={setGuideColor} />
</div>
</div>
<div className={styles.guideButtonGrid}>
<Button
appearance="primary"
className={styles.gridButton}
onClick={addGuideline}
icon={<AddRegular />}
disabled={isProcessing}
title="Add Guideline"
>
Add
</Button>
<Button
appearance="primary"
className={styles.gridButton}
onClick={removeGuidelines}
icon={<DismissRegular />}
disabled={isProcessing}
title="Remove All Guidelines"
>
Remove
</Button>
</div>
</div>
</div>
);
};
export default GridGuidelineManager;