react-pptx-editor
v0.1.0
Published
A standalone React presentation editor — slides, text, shapes and images on a scalable canvas, with drag/resize/rotate, snapping, undo/redo, JSON persistence and PPTX import/export.
Maintainers
Readme
react-pptx-editor
A standalone React presentation editor: slides, text, shapes and images on a scalable canvas, with drag/resize/rotate, snapping, undo/redo, JSON persistence and PPTX import/export.
No backend, no authentication, no AI. You give it a presentation object and it gives you edits back.
npm install react-pptx-editorReact 18.2+ or 19 is a peer dependency.
The package is ESM-only — there is no CommonJS build. Any bundler used with
React 18/19 handles ESM, and in Node it needs "type": "module" or a dynamic
import(). The entry is also safe to import under SSR: nothing at module
scope touches window, document or DOMParser, so importing it from a
Next.js or Remix server render will not throw. The browser-only work is inside
the calls that need it — see Limitations.
Usage
import { useState } from 'react';
import { PresentationEditor, createEmptyPresentation, type Presentation } from 'react-pptx-editor';
import 'react-pptx-editor/styles.css';
export function App() {
const [doc, setDoc] = useState<Presentation>(() => createEmptyPresentation());
return <PresentationEditor value={doc} onChange={setDoc} />;
}The stylesheet is a separate entry point and is never injected automatically — import it once, wherever you import your other CSS.
value is a synchronising prop rather than a strictly-controlled one: the
editor owns its internal state and reloads only when value's identity changes
to something it did not itself emit. That keeps drag commits off your render
path. For uncontrolled use, pass defaultValue instead.
Props
<PresentationEditor
value={doc} // or defaultValue
onChange={setDoc} // fires per committed mutation, not per frame
editorRef={ref} // imperative handle, see below
theme={{ accent: '#7C3AED' }}
labels={{ addSlide: 'Neue Folie' }}
assets={{ upload: uploadToS3 }}
onError={(e) => toast(e.message)}
toolbarActions={<MySaveButton />} // extra controls on the right of the toolbar
showSlideList={false} // also showToolbar, showToolStrip, showElementToolbar
/>Imperative access
const editorRef = useRef<Editor | null>(null);
<PresentationEditor defaultValue={doc} editorRef={editorRef} />;
editorRef.current?.addSlide();
editorRef.current?.undo();
const json = editorRef.current?.serialize(true);Composing your own chrome
Every part is exported separately.
import { EditorProvider, SlideCanvas, SlideList, ElementToolbar, useEditor } from 'react-pptx-editor';
<EditorProvider defaultValue={doc} onChange={save}>
<MyToolbar /> {/* useEditor() gives the full command surface */}
<SlideList />
<SlideCanvas />
<ElementToolbar />
</EditorProvider>Hooks: useEditor, useEditorState, usePresentation, useSlides,
useSlide, useCurrentSlide, useCurrentSlideId, useSlideSize,
useSelectedIds, useSelectedElements, useSingleSelection, useHistory,
useZoom, useTool, useIsDirty.
useKeyboardShortcuts(editor, options) is exported for custom chrome. Pass
scope — a ref to your editor's root element — if more than one editor can be
on the page; without it the shortcuts are global, which is correct for a single
editor. <PresentationEditor /> passes its own root automatically.
const rootRef = useRef<HTMLDivElement>(null);
useKeyboardShortcuts(editor, { scope: rootRef });Headless
createEditor() has no React dependency and runs in Node with no DOM at all —
useful for server-side validation, batch edits or generating decks. There is a
test suite (tests/node.test.ts) that runs in a DOM-free environment to keep it
that way.
import { createEditor, createTextElement } from 'react-pptx-editor';
const editor = createEditor({ initial: json });
editor.addSlide();
editor.addElement(createTextElement({ text: 'Hello' }, { x: 100, y: 100, width: 800, height: 120 }));
editor.transaction('layout', () => { // one undo step for the whole batch
editor.align(ids, 'center-x');
editor.distribute(ids, 'y');
});
editor.undo();
const out = editor.serialize(true);Capabilities
| Area | Supported | |---|---| | Slides | add, delete, duplicate, reorder (drag & drop), background colour | | Elements | text, shape (rectangle / circle / triangle / star), image | | Transform | drag, resize from 8 handles, rotate — all rotation-aware | | Constraints | Shift = axis-lock / aspect-lock / 15° rotation snap, Alt = resize from centre | | Snapping | element edges and centres, plus slide edges and centres, with live guides | | Selection | click, Shift-click, marquee, Ctrl+A; multi-element drag | | Layering | bring to front / forward / backward / send to back | | Align | left / centre / right / top / middle / bottom, distribute H and V | | Clipboard | copy, cut, paste (cross-slide), duplicate | | History | undo / redo, capped, transactional, with burst coalescing | | Text | inline editing, font, size, colour, weight, style, decoration, alignment | | View | fit-to-container plus manual zoom (10%–800%) | | Playback | fullscreen presenter with keyboard and swipe navigation | | Interchange | JSON, PPTX export, PPTX import |
Keyboard
| | |
|---|---|
| Ctrl/⌘ Z · Ctrl/⌘ Shift Z · Ctrl/⌘ Y | undo · redo · redo |
| Ctrl/⌘ C · X · V · D | copy · cut · paste · duplicate |
| Ctrl/⌘ A | select all on the current slide |
| Delete · Backspace | delete selection |
| Escape | clear selection, or cancel the gesture in flight |
| Arrows · Shift+arrows | nudge 1 · nudge 10 |
| Ctrl/⌘ ] · [ (+Shift) | forward · backward (front · back) |
| Ctrl/⌘ + · - · 0 | zoom in · out · reset |
Shortcuts and image paste are bound to window, because the canvas and the
chrome around it are not focusable. With two or more editors mounted, only
the one whose root last received a pointer or focus event responds — clicking
outside all of them leaves none active. A single editor is always active, so
nothing changes for the common case.
The document model
Plain JSON — no classes, no cycles, no framework objects.
Presentation {
version: number // schema version, for migrations
id: string
title: string
width: number // logical slide size, default 1920x1080
height: number
theme: { backgroundColor, textColor, primaryColor, fontFamily }
slides: Slide[]
}
Slide {
id: string
background: string // any CSS background value
elements: SlideElement[]
metadata?: Record<string, unknown> // yours; preserved, never read
}
SlideElement = TextElement | ImageElement | ShapeElement
// id, x, y, width, height — top-left origin, slide units
// rotation — degrees clockwise about the centre
// zIndex — paint order (dense, 0..n-1)
// locked? — rendered but not selectable
// content — type-specific
// metadata?SlideElement is a discriminated union on type, so content narrows without
a cast:
if (element.type === 'text') element.content.fontSize;
if (element.type === 'shape') element.content.fillColor;Elements are a flat array per slide — there is no grouping or nesting.
JSON persistence
The editor never talks to a backend. It hands you the document; where it goes is your decision.
<PresentationEditor
value={doc}
onChange={(next) => {
setDoc(next);
debouncedSave(next); // localStorage, IndexedDB, REST, Supabase, S3…
}}
/>Helpers:
serializePresentation(doc, pretty?) // → string
parsePresentation(json) // → Presentation
normalizePresentation(unknown) // never throws; repairs and fills defaults
validatePresentation(unknown) // → ValidationIssue[] (report without loading)
clonePresentation(doc)normalizePresentation is the load-time boundary: it fills defaults, drops
unknown element types, repairs duplicate IDs, clamps invalid numbers and
guarantees at least one slide. Anything can be handed to it safely.
Assets
Image upload belongs to your application. Leave assets undefined and images
become data: URIs — fine for a demo, bad for real documents. Provide a
handler and you get URLs instead.
const assets: AssetHandler = {
upload: async (file) => {
const { url, width, height } = await uploadToCdn(file);
return { url, width, height }; // width/height set the initial aspect ratio
},
resolve: (url) => signUrl(url), // rewrite at render time
fetch: (url) => authedFetch(url), // used by PPTX export; override for CORS
accept: ['image/png', 'image/jpeg'],
};Without fetch, cross-origin images that fail CORS are silently absent from
exported decks.
PPTX
import { exportToPptx, importPptx, downloadBlob } from 'react-pptx-editor';
const blob = await exportToPptx(doc, { assets, onWarning: console.warn });
downloadBlob(blob, `${doc.title}.pptx`);
const imported = await importPptx(await file.arrayBuffer(), { title: file.name });
editorRef.current?.load(imported);pptxgenjs and jszip are dynamically imported, so an app that never touches
PPTX does not download them — your bundler puts them in separate lazy chunks.
Import reads slide dimensions, theme colours, placeholder geometry inherited from the layout and master, text runs with font/size/weight/colour/ alignment, pictures (embedded as data URIs) and simple autoshapes. It does not read groups, tables, charts, SmartArt, gradients, transitions or animations. The deck's own aspect ratio is preserved.
Export writes text, shapes, images and slide backgrounds with position, size, rotation and opacity. Text formatting is per-element, matching the model.
Both are browser APIs. importPptx needs DOMParser and throws a named error
if none is available; exportToPptx decodes images through <canvas>, and in a
DOM-less environment it reports each one through onWarning and writes the deck
without them.
Limitations
- No grouping. Elements are flat; multi-selection is a transient ID list.
- No rich text. Formatting is per-element; mixed styling inside one text box is not representable, and PPTX import collapses a shape's runs to the first styled one.
- Text boxes do not auto-size to their content.
- PPTX round-trip is lossy in both directions.
- Slide thumbnails render live DOM. Fine to roughly 50 slides; beyond that they want memoising or rasterising.
- No accessible keyboard path for reaching an element without a pointer. Shortcuts work; discovery does not.
- No PNG or PDF export.
- The editing surface is browser-only.
createEditor(), the model helpers and the whole package entry import and run in Node; rendering the components and both PPTX directions require DOM APIs. - ESM-only. No CommonJS build is published.
Known advisory
npm audit reports a high-severity DoS advisory in image-size, a transitive
dependency of pptxgenjs. It does not affect browser consumers: pptxgenjs
maps image-size to false in its browser field, so the code is stubbed out
and never reaches a browser bundle (verified against the built output). There is
no upstream fix available yet — [email protected] is the latest release, and the
advisory covers all published image-size versions.
Development
npm install
npm run dev # playground at http://localhost:5173
npm test # 128 tests
npm run typecheck
npm run build # playground bundle → dist-playground/
npm run build:lib # library bundle → dist/
npm run smoke # import dist/index.js in Node — guards SSR safetyCI runs all of the above plus npm pack --dry-run on Node 20 and 22. The
toolchain needs Node 20.19+; the published package's own floor is Node 18.
The playground under src/playground/ consumes the editor source directly and
is the primary manual test environment. The library lives under src/editor/,
with src/editor/index.ts as its intentional public boundary — anything not
exported there (gesture internals, the selection frame, geometry helpers, the
store instance) is implementation and may change without a major version.
See docs/ARCHITECTURE.md for the internals.
License
MIT
