Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/shape-text-creates-txbody.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@office-kit/pptx": patch
---

fix: setShapeText / appendShapeText now add text to a shape that has no text body

Previously, setting text on a shape authored without one (e.g. `addSlideShape`
called without `text`) threw `shape "…" has no <p:txBody>`. PowerPoint always
gives an autoshape a text body so you can click in and type, so these functions
now create the body on demand and populate it, matching that behavior. Picture /
table shapes still throw, since they are not text-bearing.
1 change: 1 addition & 0 deletions site/src/lib/components/SiteHeader.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
{ path: '/docs/getting-started', label: 'Docs' },
{ path: '/repl', label: 'REPL' },
{ path: '/playground', label: 'Playground' },
{ path: '/editor', label: 'Editor' },
{ path: '/api', label: 'API' },
{ path: 'https://github.com/office-kit/pptx', label: 'GitHub', external: true },
];
Expand Down
124 changes: 124 additions & 0 deletions site/src/lib/editor/EditorApp.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
<script lang="ts">
import './ui/tokens.css';
import { EditorController } from './core/controller.svelte.ts';
import { setEditor } from './core/context.ts';
import TopBar from './ui/TopBar.svelte';
import Ribbon from './ribbon/Ribbon.svelte';
import SlideNavigator from './ui/SlideNavigator.svelte';
import SlideCanvas from './canvas/SlideCanvas.svelte';
import PropertiesPanel from './panels/PropertiesPanel.svelte';
import StatusBar from './ui/StatusBar.svelte';
import CommandPalette from './ui/CommandPalette.svelte';
import CommandDialog from './ui/CommandDialog.svelte';
import ContextMenu from './ui/ContextMenu.svelte';
import ToastStack from './ui/ToastStack.svelte';

const editor = new EditorController();
setEditor(editor);
const doc = editor.doc;

const NUDGE = 18288; // 0.02in in EMU
const NUDGE_BIG = 182880; // 0.2in

function onKeydown(e: KeyboardEvent) {
const mod = e.ctrlKey || e.metaKey;
const target = e.target as HTMLElement;
const typing =
target?.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target?.tagName ?? '');

if (mod && e.key.toLowerCase() === 'k') {
e.preventDefault();
editor.togglePalette();
return;
}
if (typing) return;

const hasShapes = doc.selection.kind === 'shape' || doc.selection.kind === 'cell';

if (mod && e.key.toLowerCase() === 'z' && !e.shiftKey) {
e.preventDefault();
doc.undo();
} else if (mod && (e.key.toLowerCase() === 'y' || (e.key.toLowerCase() === 'z' && e.shiftKey))) {
e.preventDefault();
doc.redo();
} else if (mod && e.key.toLowerCase() === 'a') {
e.preventDefault();
editor.selectAllShapes();
} else if (mod && e.key.toLowerCase() === 'd') {
e.preventDefault();
editor.duplicateSelection();
} else if (mod && e.key.toLowerCase() === 'c') {
editor.copySelection();
} else if (mod && e.key.toLowerCase() === 'x') {
editor.cutSelection();
} else if (mod && e.key.toLowerCase() === 'v') {
editor.paste();
} else if (mod && e.key === '=') {
e.preventDefault();
editor.zoomIn();
} else if (mod && e.key === '-') {
e.preventDefault();
editor.zoomOut();
} else if (mod && e.key === '0') {
e.preventDefault();
editor.zoomFit();
} else if ((e.key === 'Delete' || e.key === 'Backspace') && hasShapes) {
e.preventDefault();
editor.deleteSelection();
} else if (e.key.startsWith('Arrow') && hasShapes) {
e.preventDefault();
const d = e.shiftKey ? NUDGE_BIG : NUDGE;
if (e.key === 'ArrowLeft') editor.nudge(-d, 0);
else if (e.key === 'ArrowRight') editor.nudge(d, 0);
else if (e.key === 'ArrowUp') editor.nudge(0, -d);
else if (e.key === 'ArrowDown') editor.nudge(0, d);
} else if (e.key === 'Escape') {
if (editor.contextMenu) editor.closeContextMenu();
else if (editor.paletteOpen) editor.togglePalette(false);
else if (editor.activeDialog) editor.closeDialog();
else doc.clearShapeSelection();
}
}
</script>

<svelte:window on:keydown={onKeydown} />

<div class="ok-editor ok-shell">
<TopBar />
<Ribbon />
<div class="ok-body">
<SlideNavigator />
<SlideCanvas />
<PropertiesPanel />
</div>
<StatusBar />

{#if editor.paletteOpen}
<CommandPalette />
{/if}
{#if editor.activeDialog}
<CommandDialog id={editor.activeDialog} />
{/if}
{#if editor.contextMenu}
<ContextMenu />
{/if}
<ToastStack />
</div>

<style>
.ok-shell {
position: fixed;
inset: 0;
z-index: 50;
display: grid;
grid-template-rows: auto auto 1fr auto;
background: var(--ok-bg);
overflow: hidden;
}
.ok-body {
display: grid;
grid-template-columns: var(--ok-nav-w) 1fr var(--ok-panel-w);
min-height: 0;
overflow: hidden;
}
</style>
104 changes: 104 additions & 0 deletions site/src/lib/editor/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# @office-kit/pptx — Editor

A PowerPoint-style editing UI built **entirely on the `@office-kit/pptx` public
API**, in Svelte 5 + SvelteKit. It lives at the `/editor` route.

The design goal is _MS Office-like operation covering every pptx expression the
library can author_ — and, crucially, a **mechanism that guarantees that
coverage** rather than leaving it to diligence.

## The coverage guarantee (why this can't silently miss a feature)

The library exposes ~440 public functions. The ones a UI must surface as an
**operation** are the _mutating_ (state-changing) exports — every `add*`,
`set*`, `clear*`, `remove*`, `insert*`, … There are **147** of them today.

That set is the coverage target, and it is enforced end-to-end:

1. **`manifest/generate.mjs`** reads the library source, enumerates the mutating
exports by verb prefix, parses each signature into an operand + parameter
schema, and writes **`manifest/capabilities.generated.json`** — 147 entries.
2. **`core/registry.ts`** turns _every_ manifest entry into a runnable Command
that dispatches to the real library function by name (`pptx[id](operand,
…args)`). No stubs: a command is bound to an actual callable or it fails.
3. **`test/editor-capability-coverage.test.ts`** (in the library's own vitest
suite) independently re-derives the mutating-export set from the compiled
library and asserts it equals the manifest exactly, and that every id is a
real callable. If someone adds a new `setX` authoring function, **`pnpm
test` fails** until it is manifested — and therefore wired into the editor.
4. **`test/editor-command-smoke.test.ts`** drives the registry end-to-end
(author a shape → fill → move → save → reload) to prove the wiring executes,
not just type-checks.

So implementation effort can never quietly drop a capability: the gate is the
same `pnpm test` that guards the library.

## How a capability reaches the user

Every capability is reachable by at least one path, in increasing ergonomics:

- **Command palette** (`Ctrl/Cmd+K`) — searchable list of all 147, always
available. The guaranteed floor.
- **Properties panel** — auto-generated from the manifest: given the current
selection it lists _every_ capability that can act on it, grouped by category.
Exhaustive by construction.
- **Ribbon** (`ribbon/config.ts`) — a PowerPoint-style tab/group layout over the
common commands, with contextual tabs (Shape Format, Table) that appear with
the matching selection. Ergonomics for the common path, not the coverage
surface.
- **Direct manipulation** (`canvas/SlideCanvas.svelte`) — the shape moves for
real on every frame (`applyLive` re-renders the slide via the preview renderer,
~6ms; the whole gesture is one undo step committed on release), with:
- **smart-guide snapping** (`canvas/snapping.ts`) — edges/centres snap to other
shapes and the slide, drawing pink guide lines;
- **multi-select** via marquee (rubber-band on empty canvas) and Shift-click,
with group move;
- handles to resize, a top handle to rotate (Shift = 15° steps), double-click
to edit text;
- **keyboard**: arrow-nudge (Shift = coarse), Ctrl+D duplicate, Ctrl+C/X/V
copy·cut·paste, Ctrl+A select-all, Delete, Ctrl+±/0 zoom;
- **zoom / fit** (auto-fit to the viewport, manual zoom in the status bar);
- a **right-click context menu** (`ui/ContextMenu.svelte`).

These all funnel through the controller's actions and the same undoable
command path, so direct manipulation and the ribbon never diverge.

## Argument collection

Commands that need arguments open a dialog (`ui/CommandDialog.svelte`) built from
the parameter schema by `ui/ParamField.svelte`, which renders a control per
kind (string / number / EMU-with-units / color / boolean / enum) and **recurses
into nested object and array schemas** (see the Gradient / Transition dialogs).
Capabilities whose options are enriched with a schema get a field-based form;
the rest fall back to a structured-JSON editor, so the long tail stays usable
while remaining reachable. Enriched schemas come from two places, merged in
`manifest/overrides.ts` (hand wins per id):

- **`manifest/overrides.generated.ts`** — schemas produced by a one-off pass
that read the library's option types (exact enum members, nested object/array
fields for `TextFormat`, `TableCellBorders`, `ArrowOptions`, …). Rebuild with
`manifest/build-generated-overrides.mjs`, which **validates that each
override's top-level parameter names match the generated capability** — a
mismatch would make the registry pass the wrong positional args, so it is
rejected rather than emitted.
- **hand entries in `overrides.ts`** — flagship dialogs (gradient, shadow,
transition, …) and label/ribbon tuning.

## State & undo

`core/document.svelte.ts` holds the live `PresentationData` in `$state.raw` and
drives re-render with a `version` counter (the library mutates its object graph
in place; deep-proxying fights that). Undo/redo snapshots by **serializing to
`.pptx` bytes** (`savePresentation`/`loadPresentation`) — the library's
guaranteed round-trip — because the model's real state hangs off a symbol-keyed
`OpcPackage` that `structuredClone` silently corrupts. Edits stay synchronous;
snapshots are taken asynchronously (one per discrete gesture).

## Regenerating the manifest

```
node site/src/lib/editor/manifest/generate.mjs
```

Run this whenever the library's authoring surface changes; the coverage test
tells you when it is needed.
Loading
Loading