Editing
Move, resize, and edit text in presentations.
Editing requires two things: loading the presentation with readOnly={false}, and placing Presentation.Selection inside Presentation.Slide.
<Presentation.Root file={file} readOnly={false}>
<Presentation.Viewport autoFit>
<Presentation.Slide>
<Presentation.Selection />
</Presentation.Slide>
</Presentation.Viewport>
</Presentation.Root>Features
- Move shapes by dragging them
- Resize shapes by dragging their handles (hold Shift on a corner handle to lock the aspect ratio)
- Edit text inline: single-click a text box, or double-click or start typing on any shape
- Multi-select with Shift/Ctrl/Cmd+click, Ctrl/Cmd+A, or by dragging a marquee on the empty canvas
- Delete selected shapes with Delete or Backspace
- Nudge with the arrow keys (1 px, or 10 px while holding Shift)
- Undo and redo with Ctrl/Cmd+Z and Ctrl/Cmd+Shift+Z (or Ctrl/Cmd+Y)
Undo and redo shortcuts are disabled by default to avoid conflicts with the surrounding app. Enable them with undoRedoShortcuts on Presentation.Selection:
<Presentation.Selection undoRedoShortcuts />When enabled, they are bound on the document rather than the overlay, so they keep working when undo switches slides or when focus moves to a thumbnail after a keyboard reorder. Only keystrokes aimed at the presentation act on the deck: the boundary is Presentation.Root when there is one, otherwise the overlay itself. Text fields are always left alone.
For full control, leave undoRedoShortcuts disabled and call store.undo() and store.redo().
Edit callbacks
Each edit callback receives (status, error?) for undo/redo (where status is "success" | "empty") and (nodeId, error?) for node events. Check error to show a toast or log failures:
<Presentation.Selection
onUndo={(status) => {
if (status === "empty") toast.error("Nothing to undo");
}}
onRedo={(status, error) => {
if (error) toast.error(error instanceof Error ? error.message : "Redo failed");
else if (status === "empty") toast.error("Nothing to redo");
}}
onNodeTransform={(nodeId, error) => {
if (error) toast.error("Could not move shape");
}}
onNodeDelete={(nodeId, error) => {
if (error) toast.error("Could not delete shape");
}}
onTextChange={(nodeId, error) => {
if (error) toast.error("Text edit failed");
}}
/>Programmatic edits
Use store.edit() to apply changes from outside the UI (for example, from a toolbar button). Edit operations have full undo/redo support.
import { useCreatePresentationStore } from "@diceui/pptx";
const store = useCreatePresentationStore();
// Change a shape's fill color
await store.edit({
type: "setSolidFill",
slideId,
nodeId,
color: "#ef4444",
});
// Move a shape
await store.edit({
type: "setNodeTransform",
slideId,
nodeId,
x: 100,
y: 200,
width: 400,
height: 300,
});
// Edit text
await store.edit({
type: "setTextBody",
slideId,
nodeId,
paragraphs: [
{
runs: [{ text: "Hello world" }],
},
],
});Batch operations
Group multiple edits into a single undoable step with "batch":
await store.edit({
type: "batch",
ops: [
{ type: "deleteNode", slideId, nodeId: "2" },
{ type: "deleteNode", slideId, nodeId: "3" },
],
});Saving
Call store.save() to get the edited presentation as a Uint8Array, then trigger a download:
async function onSave() {
const bytes = await store.save();
const blob = new Blob([bytes], {
type: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "edited.pptx";
a.click();
URL.revokeObjectURL(url);
}The saved file round-trips all untouched parts byte-for-byte. Only slides that were edited are re-serialized.
Undo / redo stack
The store maintains a linear undo/redo history:
store.canUndo(); // true if there is something to undo
store.canRedo(); // true if there is something to redo
store.undo(); // reverts the last edit; navigates to the affected slide
store.redo(); // re-applies the last undone editCross-slide edits navigate to the affected slide automatically on undo/redo.
Reordering slides
Moving a slide is an edit like any other, so it is undoable and included in isDirty. The thumbnail strip deliberately ships no drag-and-drop: reordering is left to whatever sortable library you already use, and Presentation.ThumbnailItem is built to be wrapped by one. It forwards ref, merges arbitrary props and event handlers, and lets your style win over its internals so transforms animate normally.
Commit the drop as a moveSlide edit:
async function onDragEnd({ active, over }: DragEndEvent) {
if (!over || active.id === over.id) return;
const toIndex = slideIds.indexOf(String(over.id));
await store.edit({ type: "moveSlide", slideId: String(active.id), toIndex });
}A few things are worth knowing when wiring this up:
- dnd-kit's
attributessetrole="button"andtabIndex={0}. Both would override what the list needs, replacing theoptionrole and putting every thumbnail in the tab order instead of the single roving tab stop. Drop those two keys and spread the rest. - Give the pointer sensor a small activation distance (
{ distance: 4 }), or a plain click starts a drag instead of selecting the slide. store.edit()is async, so hold the dropped order in local state until it settles; otherwise the strip snaps back to the old order for a frame.- Clamp the drag with
restrictToFirstScrollableAncestor. The strip scrolls vertically, and a transformed child still counts toward its scroll container's overflow, so an unclamped drag past the last thumbnail growsscrollHeight, which lets auto-scroll run, which grows the transform again — the list scrolls for as long as you hold the pointer at the edge. - Give a drag overlay's thumbnail the
decorativeprop. The overlay is a second copy of a slide that is still in the list, and without it the copy takes the original's place in the roving focus map, unregisters it on drop, and the deck is announced with a duplicate option.
<DragOverlay>
{draggedId ? (
<Presentation.ThumbnailItem decorative slideId={draggedId}>
<Presentation.ThumbnailItemNumber />
<Presentation.ThumbnailItemPreview />
</Presentation.ThumbnailItem>
) : null}
</DragOverlay>Render the overlay inside Presentation.ThumbnailList so the copy can reach the list context it needs to paint a real miniature. It is fixed-positioned, so the strip's overflow will not clip it.
The list also owns ↑/↓/Home/End for roving focus, so a keyboard drag sensor bound to those keys will fight it. Modifier combinations (Alt/Option+↑, for example) pass through untouched.
The demo at the top of this page wires all of that up with dnd-kit, though nothing in the approach is specific to it. Here it is in full:
"use client";
import * as React from "react";
import type { PresentationStore } from "@diceui/pptx";
import { useCreatePresentationStore, useHistory, usePresentation } from "@diceui/pptx";
import {
DndContext,
type DragEndEvent,
DragOverlay,
type DragStartEvent,
type DropAnimation,
PointerSensor,
closestCenter,
defaultDropAnimation,
defaultDropAnimationSideEffects,
useSensor,
useSensors,
} from "@dnd-kit/core";
import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers";
import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Button } from "@pptx/ui/components/button";
import {
Presentation,
PresentationContent,
PresentationError,
PresentationLoading,
PresentationProvider,
PresentationSelection,
PresentationSlide,
PresentationThumbnailItem,
PresentationThumbnailItemNumber,
PresentationThumbnailItemPreview,
PresentationThumbnailList,
PresentationViewport,
} from "@pptx/ui/components/presentation";
import { Tooltip, TooltipContent, TooltipTrigger } from "@pptx/ui/components/tooltip";
import { Redo2Icon, Undo2Icon } from "lucide-react";
import { PresentationZoomSelect } from "@/components/presentation-zoom-select";
import { DEMO_DECK_PATH } from "@/lib/constants";
/**
* Keeps the source item visible during the drop animation.
*
* The default side effect sets its opacity to `0`, delaying the thumbnail
* item's focus ring from reappearing until roughly 250 ms after pointer release.
*/
const DROP_ANIMATION: DropAnimation = {
...defaultDropAnimation,
sideEffects: defaultDropAnimationSideEffects({ styles: { active: {} } }),
};
export function PresentationEditingDemo() {
const store = useCreatePresentationStore();
React.useEffect(() => {
fetch(DEMO_DECK_PATH)
.then((res) => {
// fetch resolves on 404, so an unchecked body would reach the parser as
// an error page rather than a deck.
if (!res.ok) throw new Error(`${DEMO_DECK_PATH}: ${res.status}.`);
return res.arrayBuffer();
})
// Editing and reordering both need the source package retained.
.then((buffer) => store.load(buffer, { readOnly: false }))
.catch(() => {
// Fail silently to avoid blocking the main thread.
});
// oxlint-disable-next-line react-hooks/exhaustive-deps -- store is a stable ref, intentionally omitted from deps
}, []);
return (
<div className="not-prose flex h-100 flex-col overflow-hidden rounded-lg border">
<PresentationProvider store={store}>
<PresentationToolbar />
<Presentation className="min-h-0 flex-1">
<SortableThumbnailList store={store} />
<PresentationContent>
<PresentationLoading />
<PresentationError />
<PresentationViewport autoFit autoFitPadding={10}>
<PresentationSlide>
<PresentationSelection undoRedoShortcuts />
</PresentationSlide>
</PresentationViewport>
</PresentationContent>
</Presentation>
</PresentationProvider>
</div>
);
}
function PresentationToolbar() {
const { status } = usePresentation();
const { canUndo, canRedo, undo, redo } = useHistory();
function run(action: () => Promise<unknown>) {
void action().catch((error) => console.error("[demo] action failed:", error));
}
return (
<div className="flex items-center gap-2 border-b px-3 py-2">
<span className="text-sm text-muted-foreground">
{status === "ready"
? "Drag a shape to move it, or drag a thumbnail to reorder the deck"
: "Loading sample deck…"}
</span>
<Tooltip>
<TooltipTrigger
render={
<Button
aria-label="Undo"
variant="ghost"
size="icon-sm"
className="ml-auto"
disabled={!canUndo}
focusableWhenDisabled
onClick={() => undo()}
>
<Undo2Icon />
</Button>
}
/>
<TooltipContent>Undo</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
aria-label="Redo"
variant="ghost"
size="icon-sm"
disabled={!canRedo}
focusableWhenDisabled
onClick={() => run(() => redo())}
>
<Redo2Icon />
</Button>
}
/>
<TooltipContent>Redo</TooltipContent>
</Tooltip>
<PresentationZoomSelect />
</div>
);
}
function SortableThumbnailList({ store }: { store: PresentationStore }) {
const { presentation } = usePresentation();
const slideIds = presentation?.slides.map((slide) => slide.id) ?? [];
/**
* Order to paint while a drop is being committed. `store.edit()` is async, so
* without this the strip would snap back to the old order for a frame between
* the pointer release and the edit landing.
*/
const [pendingIds, setPendingIds] = React.useState<string[] | null>(null);
const orderedIds = pendingIds ?? slideIds;
/** Slide under the pointer, mirrored into the drag overlay. */
const [draggedId, setDraggedId] = React.useState<string | null>(null);
// Pointer only: the list owns ArrowUp/ArrowDown for roving focus, so a
// keyboard drag sensor bound to the same keys would fight it.
const sensors = useSensors(
// A small threshold keeps a plain click selecting the slide instead of
// starting a drag.
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
);
async function onDragEnd(event: DragEndEvent) {
const { active, over } = event;
setDraggedId(null);
if (!over || active.id === over.id) return;
const slideId = String(active.id);
const toIndex = orderedIds.indexOf(String(over.id));
if (toIndex === -1) return;
const fromIndex = orderedIds.indexOf(slideId);
const next = [...orderedIds];
next.splice(fromIndex, 1);
next.splice(toIndex, 0, slideId);
setPendingIds(next);
try {
await store.edit({ type: "moveSlide", slideId, toIndex });
} finally {
// The store is the source of truth again once the edit settles.
setPendingIds(null);
}
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
// A transformed child still counts toward its scroll container's overflow,
// so an unclamped drag past the last thumbnail grows scrollHeight, which
// lets auto-scroll run, which grows the transform again: the strip scrolls
// forever. Clamping the drag to the scroll port breaks that loop.
modifiers={[restrictToVerticalAxis, restrictToFirstScrollableAncestor]}
onDragStart={({ active }: DragStartEvent) => setDraggedId(String(active.id))}
onDragCancel={() => setDraggedId(null)}
onDragEnd={onDragEnd}
>
<SortableContext items={orderedIds} strategy={verticalListSortingStrategy}>
<PresentationThumbnailList>
{() => (
<>
{orderedIds.map((slideId) => (
<SortableItem key={slideId} slideId={slideId} />
))}
{/*
* Inside the list so the floating copy can read the list context
* it needs to paint a real miniature. It is fixed-positioned, so
* the strip's overflow does not clip it.
*/}
<DragOverlay dropAnimation={DROP_ANIMATION}>
{draggedId ? (
<PresentationThumbnailItem
decorative
slideId={draggedId}
className="h-full cursor-grabbing bg-background shadow-lg"
>
<PresentationThumbnailItemNumber />
<PresentationThumbnailItemPreview />
</PresentationThumbnailItem>
) : null}
</DragOverlay>
</>
)}
</PresentationThumbnailList>
</SortableContext>
</DndContext>
);
}
function SortableItem({ slideId }: { slideId: string }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: slideId,
});
// dnd-kit sets role="button" and tabIndex={0}; both would override what the
// list needs, replacing the `option` role and putting every thumbnail in the
// tab order instead of the one roving tab stop.
const { role: _role, tabIndex: _tabIndex, ...dragAttributes } = attributes;
return (
<PresentationThumbnailItem
slideId={slideId}
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
// The overlay carries the thumbnail during the drag, so what stays in the
// list is just the slot it will land in.
className={isDragging ? "opacity-30" : undefined}
onSelect={(event) => {
// Pressing a thumbnail focuses it, and focus navigates. Suppress that
// while dragging so the deck does not jump mid-gesture.
if (isDragging) event.preventDefault();
}}
{...dragAttributes}
{...listeners}
>
<PresentationThumbnailItemNumber />
<PresentationThumbnailItemPreview />
</PresentationThumbnailItem>
);
}
Unsaved changes
store.isDirty() reports whether the presentation has changed since it was last saved. If the user undoes those changes and returns to the saved state, it becomes false again avoiding unnecessary unsaved changes warnings.
window.addEventListener("beforeunload", (event) => {
if (store.isDirty()) event.preventDefault();
});In the component tree, use useHistory:
function Toolbar() {
const store = usePresentationStore();
const { canUndo, canRedo, isDirty, undo, redo } = useHistory();
return (
<>
<button disabled={!canUndo} onClick={undo}>
Undo
</button>
<button disabled={!canRedo} onClick={() => void redo()}>
Redo
</button>
<button onClick={() => void store.save()}>{isDirty ? "Save" : "Saved"}</button>
</>
);
}Outside the tree, the same values come through onHistoryChange:
<Presentation.Root
file={file}
readOnly={false}
onHistoryChange={({ canUndo, canRedo, isDirty }) => {
setHasUnsavedChanges(isDirty);
}}
/>