07b0232726
- 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>
604 lines
18 KiB
TypeScript
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; |