@web-ppt/editor 0.5.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/CHANGELOG.md +274 -0
  2. package/LICENSE +21 -0
  3. package/README.md +473 -0
  4. package/dist/adapter-format-painter.d.ts +15 -0
  5. package/dist/adapter-link.d.ts +3 -0
  6. package/dist/adapter-options.d.ts +6 -0
  7. package/dist/adapter-recovery.d.ts +14 -0
  8. package/dist/adapter-selection-pane.d.ts +22 -0
  9. package/dist/adapter-text-search.d.ts +28 -0
  10. package/dist/animation-preview.d.ts +26 -0
  11. package/dist/dom-identity.d.ts +17 -0
  12. package/dist/dom-patch.d.ts +9 -0
  13. package/dist/editor-keyboard.d.ts +13 -0
  14. package/dist/editor.js +6332 -0
  15. package/dist/element-clipboard.d.ts +23 -0
  16. package/dist/format-painter-types.d.ts +32 -0
  17. package/dist/format-painter-view.d.ts +17 -0
  18. package/dist/format-painter.d.ts +21 -0
  19. package/dist/framework-adapter-binding.d.ts +4 -0
  20. package/dist/framework-adapter-state.d.ts +9 -0
  21. package/dist/framework-adapter-types.d.ts +114 -0
  22. package/dist/framework-adapter.d.ts +2 -0
  23. package/dist/image-crop-gesture.d.ts +40 -0
  24. package/dist/image-insertion.d.ts +46 -0
  25. package/dist/index.d.ts +28 -0
  26. package/dist/indexeddb-recovery-store.d.ts +30 -0
  27. package/dist/keyboard-context.d.ts +8 -0
  28. package/dist/keyboard-delete.d.ts +7 -0
  29. package/dist/keyboard-history.d.ts +7 -0
  30. package/dist/keyboard-layer.d.ts +7 -0
  31. package/dist/keyboard-nudge.d.ts +10 -0
  32. package/dist/keyboard-owner.d.ts +5 -0
  33. package/dist/marquee-gesture.d.ts +31 -0
  34. package/dist/move-gesture.d.ts +34 -0
  35. package/dist/placeholder-overlay.d.ts +3 -0
  36. package/dist/pointer-gesture.d.ts +37 -0
  37. package/dist/recovery-session.d.ts +24 -0
  38. package/dist/recovery-store.d.ts +64 -0
  39. package/dist/resize-geometry.d.ts +19 -0
  40. package/dist/resize-gesture.d.ts +30 -0
  41. package/dist/rotation-geometry.d.ts +9 -0
  42. package/dist/rotation-gesture.d.ts +33 -0
  43. package/dist/selection-body-properties.d.ts +3 -0
  44. package/dist/selection-combine.d.ts +10 -0
  45. package/dist/selection-handles.d.ts +3 -0
  46. package/dist/selection-hit.d.ts +8 -0
  47. package/dist/selection-overlay.d.ts +13 -0
  48. package/dist/selection-pane-types.d.ts +18 -0
  49. package/dist/selection-pane.d.ts +3 -0
  50. package/dist/session-state.d.ts +20 -0
  51. package/dist/session.d.ts +24 -0
  52. package/dist/slide-dom-renderer.d.ts +22 -0
  53. package/dist/slide-dom-update.d.ts +13 -0
  54. package/dist/slide-editor-commands.d.ts +76 -0
  55. package/dist/slide-editor-dom.d.ts +2 -0
  56. package/dist/slide-editor-events.d.ts +20 -0
  57. package/dist/slide-editor-keyboard-events.d.ts +39 -0
  58. package/dist/slide-editor-types.d.ts +90 -0
  59. package/dist/slide-editor.d.ts +3 -0
  60. package/dist/slide-link-controller.d.ts +26 -0
  61. package/dist/slide-pointer-controller.d.ts +43 -0
  62. package/dist/snap-guides.d.ts +3 -0
  63. package/dist/snap.d.ts +60 -0
  64. package/dist/source-fingerprint.d.ts +9 -0
  65. package/dist/table-insertion.d.ts +7 -0
  66. package/dist/text-autofit.d.ts +18 -0
  67. package/dist/text-clipboard-controller.d.ts +32 -0
  68. package/dist/text-clipboard.d.ts +7 -0
  69. package/dist/text-dom.d.ts +30 -0
  70. package/dist/text-editor-context.d.ts +13 -0
  71. package/dist/text-editor-target.d.ts +10 -0
  72. package/dist/text-editor-types.d.ts +30 -0
  73. package/dist/text-editor-view.d.ts +4 -0
  74. package/dist/text-editor.d.ts +51 -0
  75. package/dist/text-input-plan.d.ts +20 -0
  76. package/dist/text-search-highlight.d.ts +18 -0
  77. package/dist/text-search-index.d.ts +11 -0
  78. package/dist/text-search-types.d.ts +41 -0
  79. package/dist/text-search-view.d.ts +33 -0
  80. package/dist/text-search.d.ts +46 -0
  81. package/dist/transform-frame.d.ts +14 -0
  82. package/dist/transition-preview.d.ts +24 -0
  83. package/package.json +54 -0
package/README.md ADDED
@@ -0,0 +1,473 @@
1
+ # @web-ppt/editor
2
+
3
+ **English** · [简体中文](https://github.com/unStone/web-ppt/blob/master/packages/editor/README-zh-CN.md)
4
+
5
+ The framework-agnostic browser DOM layer for [Web-PPT](https://github.com/unStone/web-ppt). It opens a
6
+ source file once, owns its parsed resources and headless editing model, and mounts one or more high-fidelity
7
+ slide views without uploading the file.
8
+
9
+ ```bash
10
+ npm i @web-ppt/core @web-ppt/edit-core @web-ppt/viewer-core @web-ppt/editor
11
+ ```
12
+
13
+ ```ts
14
+ import { openEditor } from '@web-ppt/editor';
15
+
16
+ const session = await openEditor(file);
17
+ const view = session.mount(container, {
18
+ mode: 'edit', zoom: 1, textMode: 'auto', snapping: true,
19
+ snapMargins: { left: 24, right: 24, top: 24, bottom: 24 }, // optional slide px
20
+ });
21
+ const selectionPane = session.mountSelectionPane(paneContainer, {
22
+ mode: 'edit', slideId: view.slideId,
23
+ });
24
+
25
+ const slideId = session.editor.doc.slideOrder[0];
26
+ const elementId = session.editor.doc.slides[slideId].children[0];
27
+ session.editor.exec({ type: 'SetXfrm', id: elementId, x: 120 });
28
+ session.editor.exec({ type: 'AlignElements', ids: [elementId], edge: 'center' });
29
+ session.editor.exec({
30
+ type: 'AddShape', slideId, preset: 'roundRect',
31
+ rect: { x: 360, y: 180, w: 280, h: 160 },
32
+ });
33
+ const layoutId = session.editor.doc.layoutOrder[0];
34
+ const added = session.editor.exec({ type: 'AddSlide', layoutId, at: { after: slideId } });
35
+ view.setSlide([...added.createdSlides][0]);
36
+ session.editor.exec({ type: 'MoveSlide', id: view.slideId, at: { after: null } });
37
+ const duplicated = session.editor.exec({ type: 'DuplicateSlide', id: view.slideId });
38
+ view.setSlide([...duplicated.createdSlides][0]);
39
+
40
+ await view.insertImage(imageFile, { rect: { x: 420, y: 180, w: 320, h: 220 } });
41
+ // Or from a toolbar click: const imageId = await view.chooseImage();
42
+ const tableId = view.insertTable(3, 4); // rows, columns; replaces a selected empty content placeholder
43
+
44
+ view.setMode('view'); // same static preview DOM; interaction layers are hidden
45
+ view.setMode('edit');
46
+ view.setSlide(slideId);
47
+ view.setZoom(1.5);
48
+ view.setSnapping(false); // can be changed without remounting
49
+ selectionPane.setSlide(slideId);
50
+
51
+ notesTextarea.value = view.queryNotes().value;
52
+ notesTextarea.addEventListener('input', () => view.setNotes(notesTextarea.value));
53
+ // View mode can still queryNotes(), but setNotes() returns false without mutating the shared model.
54
+
55
+ const transition = view.queryTransition();
56
+ await view.previewTransition(); // preview the effective value in view or edit mode
57
+ await view.previewTransition({ type: 'push', dir: 'r', durationMs: 500 }); // preview before commit
58
+ view.setTransition({ type: 'morph', durationMs: 900, morphBy: 'byWord' });
59
+ view.setTransition(null); // restore the parsed source; { type: 'none' } explicitly disables it
60
+
61
+ const animations = view.queryAnimations(); // targets are stable ElementIds, never OOXML spids
62
+ // When sourceReadonly is true, offer an explicit whole-timeline replacement instead of partial editing.
63
+ await view.previewAnimations(); // plays the complete effective timeline in view/edit mode
64
+ await view.previewAnimations([{ // validate and audition a draft without changing history
65
+ target: targetId, kind: 'entrance', effect: 'fly', dir: 'r', trigger: 'click',
66
+ delayMs: 0, durationMs: 500,
67
+ }]);
68
+ view.setAnimations(animations.value); // edit mode only; [] removes the timeline
69
+ view.setAnimations(null); // restore the untouched parsed source
70
+
71
+ const bytes = await session.editor.save();
72
+ view.destroy(); // destroys only this mounted view
73
+ selectionPane.destroy();
74
+ session.dispose(); // destroys remaining views and releases ZIP bytes / blob URLs
75
+ ```
76
+
77
+ ## Autosave and crash recovery
78
+
79
+ Storage is opt-in. Once `recovery` is provided, the source is read once, identified by a full-byte SHA-256, and
80
+ parsed from those same bytes. Transactions are appended in asynchronous batches, so canvas commits never wait for
81
+ IndexedDB.
82
+
83
+ ```ts
84
+ import { createIndexedDbRecoveryStore, openEditor } from '@web-ppt/editor';
85
+
86
+ const recoveryStore = createIndexedDbRecoveryStore({ namespace: 'my-editor' });
87
+ const session = await openEditor(file, {
88
+ recovery: {
89
+ store: recoveryStore,
90
+ decide: async (candidate) => showRecoveryDialog({
91
+ changedAt: new Date(candidate.updatedAt),
92
+ actions: ['restore', 'discard', 'cancel'],
93
+ }),
94
+ onError: (error) => showAutosaveWarning(error),
95
+ },
96
+ });
97
+
98
+ await session.recovery?.flush(); // await queued frames before a critical navigation
99
+ session.dispose();
100
+ await recoveryStore.close(); // share one store across sessions; close it at app shutdown
101
+ ```
102
+
103
+ Equal bytes across `File`, `Blob`, `ArrayBuffer`, and `Uint8Array` resolve to one journal; equal names or sizes do
104
+ not. `restore` reuses the stored `idPrefix` before any DOM is visible, while `discard` atomically replaces the old
105
+ journal with an empty, new-generation reservation. Late writes from an older tab are rejected instead of reviving
106
+ discarded edits. `cancel` neither parses nor mounts nor changes the journal. A clean tail is reset without prompting.
107
+ Edits made after a save retain the complete chain from the original source.
108
+
109
+ Defaults compact more than 64 chunks back to 32 and retain at most 20 journals, 16MB, or 30 days. `stats()`,
110
+ `cleanup()`, and factory options expose those controls. Compaction regroups patch frames unchanged and coalesces only
111
+ consecutive metadata-only frames, preserving the final selection/savepoint state. Append/quota failures never roll back committed edits; they surface through
112
+ `session.recovery.error`, `flush()` rejection, and `onError` without an unhandled rejection.
113
+
114
+ A custom `RecoveryStore` implements `load`, atomic `reset`, atomic `append`, and `remove`. It must persist and compare
115
+ the supplied `epoch`; this is the generation guard that prevents a stale session from appending after discard. Its
116
+ `reset` must also reject before commit when `request.signal` is aborted, so a superseded open cannot overwrite the
117
+ latest reservation.
118
+
119
+ You may still supply `idPrefix + recoveryFrames` from `@web-ppt/edit-core` for custom persistence, but not together
120
+ with `recovery`. Framework adapters use O(1) reference identity for both stores and manual logs rather than scanning
121
+ them on every render.
122
+
123
+ ## Framework adapter contract
124
+
125
+ `createWebPptAdapter()` is the lifecycle boundary used by `@web-ppt/react` and `@web-ppt/vue`; Svelte, Web
126
+ Components, and other hosts can bind it directly. It is safe to import during SSR and in a Worker because no DOM or
127
+ IndexedDB global is touched until a session or store is explicitly created; `attach()` is the first DOM boundary.
128
+
129
+ ```ts
130
+ import { applyWebPptAdapterBinding, createWebPptAdapter } from '@web-ppt/editor';
131
+
132
+ const adapter = createWebPptAdapter({ onError: console.error });
133
+ adapter.attach(container);
134
+ adapter.attachSelectionPane(paneContainer);
135
+ await applyWebPptAdapterBinding(adapter, {
136
+ source: file, mode: 'edit', slideId, zoom: 1,
137
+ onViewChange: (state) => routeTo(state.slideId),
138
+ });
139
+ const bytes = await adapter.save();
140
+ adapter.dispose();
141
+ ```
142
+
143
+ The adapter owns sessions opened from `source` and atomically releases them on replacement or disposal. To share
144
+ an existing session across several adapters, pass `{ session, sessionOwnership: 'external' }`; each adapter then
145
+ destroys only its view and the caller remains responsible for `session.dispose()`. Concurrent stale opens dispose
146
+ their late result. `snapshot` and `subscribe()` expose idle/opening/recovering/ready/error state, progress, the
147
+ recovery candidate, session, view, mode, stable slide id, and zoom without introducing a second presentation model.
148
+ When persistence is enabled, `onRecovery(candidate)` returns `restore`, `discard`, or `cancel`; React and Vue pass
149
+ through this same callback.
150
+
151
+ ### Session format painter
152
+
153
+ The format painter is one session controller shared by every mounted view and framework adapter. Select exactly one
154
+ shape/image/group or a text range, then start a one-shot or continuous painter:
155
+
156
+ ```ts
157
+ view.startFormatPainter(); // exits after the first successful target
158
+ view.startFormatPainter({ continuous: true }); // keeps the stable source across slides/views
159
+ session.formatPainter.cancel();
160
+ ```
161
+
162
+ `Escape`, switching any view to `view` mode, deleting the source, or disposing the session cancels it. A target
163
+ click applies one atomic `ApplyFormat` command before selecting that target; incompatible targets dispatch
164
+ `webpptformaterror` and retain the active source. Active roots expose `data-format-painter="single|continuous"`
165
+ and `data-format-painter-source`, so products can supply their own cursor and toolbar styling without owning state.
166
+ `adapter.startFormatPainter()` / `cancelFormatPainter()` map to the same controller, while
167
+ `snapshot.formatPainter` exposes `active`, `mode`, `source`, and `readonly` to React, Vue, Svelte, and Web Components.
168
+
169
+ ### Session find and replace
170
+
171
+ Every mounted view and adapter also consumes one lazy `session.textSearch` controller. View mode can search,
172
+ navigate, and highlight; edit mode additionally enables replacement. The first non-empty query builds per-page
173
+ results, later document transactions evict only dirty or removed pages, and page moves only reorder cached results.
174
+
175
+ ```ts
176
+ session.textSearch.open({
177
+ mode: 'replace', query: 'draft', replacement: 'final',
178
+ scope: { kind: 'document' }, matchCase: false, wholeWord: true,
179
+ });
180
+ session.textSearch.next();
181
+ session.textSearch.replaceCurrent();
182
+
183
+ adapter.openTextSearch({ mode: 'find', query: 'owner' });
184
+ adapter.setTextSearchReplacement('team');
185
+ adapter.replaceAllText(); // false/0 in view mode; snapshot.textSearch.canReplace explains why
186
+ ```
187
+
188
+ Focused views use `Ctrl/Cmd+F`, `Ctrl/Cmd+H`, `Enter`, `Shift+Enter`, and `Escape`. Only the view receiving a
189
+ cross-page navigation event switches slides; other views keep their local page. Roots expose
190
+ `data-text-search-*`, matched elements expose `data-ppt-search-matches` / `data-ppt-search-current`, and the
191
+ current text range exposes `data-ppt-search-current-range`. Exact range boxes live in an overlay rather than a
192
+ fourth render layer. Products own the search bar and ARIA controls. The Chromium contract measures 200-page
193
+ initial index / cold query p95 at 1.8 / 0.5ms and 60-element navigation / replacement feedback p95 at 0.1 / 0.9ms.
194
+
195
+ `mountSelectionPane()` is the single accessible DOM implementation for the object catalog. It presents the current
196
+ slide in topmost-first tree order, keeps duplicate OOXML names distinct through stable element ids, and supports
197
+ group navigation, rename, lock, and session-only hide. Arrow keys, Home/End, Left/Right, Enter, Space, and F2 work
198
+ without a pointer. Lock and hide inherit through groups; showing a child removes its own `visibility` declaration
199
+ instead of writing `visible`, so a hidden ancestor cannot be bypassed. Geometry and text edits retain existing row
200
+ DOM identities; a 60-object lock/unlock round trip measures 0.4ms p95 in the Chromium contract.
201
+
202
+ Every host maps the same four operations; no rendering or editor state is reimplemented:
203
+
204
+ | Host | Create / update / destroy mapping |
205
+ |---|---|
206
+ | React | effect creates adapter → props call `applyWebPptAdapterBinding` → cleanup calls `dispose` |
207
+ | Vue | `onMounted` creates → reactive effect applies binding → `onBeforeUnmount` disposes |
208
+ | Svelte | `onMount` creates and returns `dispose`; `$effect` applies the controlled binding |
209
+ | Web Component | `connectedCallback` creates/attaches; attributes apply binding; `disconnectedCallback` disposes |
210
+
211
+ For a reconnectable custom element, create a fresh adapter on every `connectedCallback` rather than retaining a
212
+ disposed controller. Container mounting remains `adapter.attach(this)`, so Shadow DOM is optional and product CSS
213
+ or toolbars stay outside the contract.
214
+
215
+ `SlideEditor` owns three stacked layers: the existing SVG preview, an SVG interaction overlay, and an HTML
216
+ text overlay. A headless `Editor` transaction replaces only the dirty element's markup and defs partition;
217
+ unchanged sibling DOM nodes keep their identity. The view falls back to one full render only when more than eight
218
+ partitions and over 30% of the slide's top-level elements are dirty, avoiding ratio distortion on small slides.
219
+ Stable `data-edit-id` values are assigned to top-level and nested group nodes, so DOM hit testing never depends
220
+ on part-local OOXML ids.
221
+
222
+ In edit mode, pointer selection uses the browser's native SVG hit testing. A click inside a group selects its
223
+ outermost group; double-click enters one group level and `Escape` leaves one level. `Alt`+click cycles through
224
+ overlapping candidates in `elementsFromPoint` z-order. Locked, user-hidden, and non-editable branches are
225
+ skipped. View mode does not intercept pointer events or mutate the shared headless selection. Selection changes
226
+ replace only the interaction overlay, leaving the static preview DOM untouched.
227
+
228
+ `Shift`, `Ctrl`, or macOS `Command`+click toggles a direct selectable child in the current slide or entered
229
+ group. Combining the selection modifier with `Alt` still reaches an unselected object behind the current stack.
230
+ A modified marquee previews and commits the symmetric difference between the valid prior selection and fully
231
+ contained objects; a modified blank click preserves the selection. Results follow paint order, create no history,
232
+ and never rebuild the static preview.
233
+
234
+ When an edit view has focus, arrow keys nudge in slide space by `1px`; `Shift`+arrow nudges by `10px`.
235
+ Multi-selections and elements inside rotated, flipped, non-uniformly scaled groups receive the same world-space
236
+ delta. Auto-repeat from one physical hold is one undo unit, while a press after key-up starts another. A locked,
237
+ hidden, non-editable, or off-page member rejects the whole operation. View mode, active pointer gestures, and
238
+ form/contenteditable controls in either regular or Shadow DOM keep ownership of their arrow keys.
239
+
240
+ `Tab` and `Shift`+`Tab` cycle forward or backward through the direct selectable children of the current slide
241
+ or entered group, in paint order and with wrap-around. A selection owned by another mounted page starts at this
242
+ view's first or last candidate. The traversal changes selection only: it creates no history entry and preserves
243
+ the static preview DOM. Form and contenteditable controls, including those in Shadow DOM, keep native Tab focus.
244
+
245
+ After a table cell is opened in the same text editor, `Tab` and `Shift`+`Tab` move only between visible merge-start
246
+ cells. `Tab` in the final cell runs one `InsertRow` transaction and enters the first cell of the new blank row.
247
+ Table style, frame height, static preview, selection box, and caret update together without losing focus. View mode
248
+ never emits the structural command.
249
+
250
+ `Ctrl/Cmd+Z` undoes; `Ctrl/Cmd+Shift+Z` or `Ctrl/Cmd+Y` redoes. If the restored selection belongs to another
251
+ slide, only the edit view that received the shortcut reveals that slide; other shared views stay put. Active
252
+ pointer previews and regular or Shadow DOM text controls retain keyboard ownership, while single-element history
253
+ still replaces only that element's DOM partition.
254
+
255
+ `Delete` or `Backspace` removes the current element selection as one undo unit. Groups are recursive; frame-only
256
+ objects such as charts, SmartArt, and OLE lose their outer frame without garbage-collecting potentially shared
257
+ relationships or media. A populated placeholder clears its text on the first deletion and removes its frame on
258
+ the second. Delete and undo remove or reinsert only the affected markup/defs partitions in stable z-order, so
259
+ untouched sibling DOM identities survive. Form controls, contenteditable or Shadow DOM text, and active pointer
260
+ gestures keep native keyboard ownership.
261
+
262
+ `Ctrl/Cmd+]` moves forward, `Ctrl/Cmd+Shift+]` brings to front, `Ctrl/Cmd+[` moves backward, and
263
+ `Ctrl/Cmd+Shift+[` sends to back. A multi-selection moves as one undo unit without reversing its internal order;
264
+ group children and frame-only objects use the same semantics, and boundary operations create no empty history.
265
+ Views move existing markup partitions in place, preserving defs, hyperlink wrappers, untouched siblings, and
266
+ node identities in every shared view.
267
+
268
+ Focused edit views support native `Ctrl/Cmd+C`, `X`, and `V` through synchronous `ClipboardEvent` handling.
269
+ The view writes `application/x-web-ppt-elements+json` and `text/plain`, never calls permission-gated
270
+ `navigator.clipboard`, and pastes multiple element trees as one undo unit. `Ctrl/Cmd+D` duplicates the current
271
+ selection by 10 slide pixels without changing the system clipboard. View mode, active pointer gestures,
272
+ text/table selections, and form/contenteditable descendants retain browser ownership. Small pastes insert only
273
+ new markup/defs partitions; large batches may use the existing bounded full-slide fallback.
274
+
275
+ Links keep PowerPoint's edit/view distinction. Edit-mode clicks only select; `Ctrl/Cmd+Enter` or
276
+ `view.followLink()` follows the current single element/text link. In view mode, internal links are focusable with
277
+ `Tab`, activate with `Enter`, and route through the view's stable `SlideId` state; safe external links open with
278
+ `noopener,noreferrer`. A framework can intercept both through one callback and return `true` when its router handled
279
+ the target:
280
+
281
+ ```ts
282
+ const view = session.mount(container, {
283
+ mode: 'edit',
284
+ onLinkFollow(target, context) {
285
+ if (target.kind === 'slide') {
286
+ appRouter.openSlide(target.slideId);
287
+ return true;
288
+ }
289
+ },
290
+ });
291
+ view.followLink({ kind: 'external', href: 'https://example.com/docs' });
292
+ ```
293
+
294
+ The callback receives only `LinkTarget` (`external` or stable `slide`), never a raw page index or OOXML action.
295
+ View mode installs no edit pointer/keyboard/clipboard listeners; mode switches and `destroy()` release their
296
+ listeners symmetrically, so multiple React/Vue/Svelte/Web Component/vanilla views keep local navigation state.
297
+
298
+ Double-clicking an editable shape opens the HTML text layer with native selection and IME composition. Selected
299
+ text responds to `Ctrl/Cmd+B`, `I`, and `U` as one undo unit; the equivalent `beforeinput` format events are also
300
+ handled. At a collapsed caret these shortcuts update view-local pending typing style, and the next insertion plus
301
+ its format commit as one history unit without creating a zero-width model run. The live DOM range is published as
302
+ `session.editor.selection`; non-collapsed ranges can use headless `SetRunProps` / `queryRunProps` directly. A mounted
303
+ toolbar uses the view seam below so collapsed-caret typing style stays with the owning input view. Every mounted
304
+ view refreshes while the active browser range is preserved. Switching to view mode closes the input layer and keeps
305
+ the high-fidelity static preview.
306
+
307
+ ```ts
308
+ const unregister = view.registerTextUi(toolbarElement);
309
+ boldButton.addEventListener('pointerdown', (event) => {
310
+ event.preventDefault(); // keep native selection/focus stable
311
+ const state = view.queryRunProps();
312
+ if (state) view.setRunProps({ b: state.b.mixed || state.b.value !== true });
313
+ });
314
+ centerButton.addEventListener('pointerdown', (event) => {
315
+ event.preventDefault();
316
+ const state = view.queryParaProps();
317
+ if (state) view.setParaProps({ align: 'center' });
318
+ });
319
+ // unregister() when this toolbar no longer belongs to the view.
320
+ ```
321
+
322
+ Paragraph controls use the same live DOM Range and remain framework-neutral. `setParaProps` formats every touched
323
+ paragraph—including the current paragraph at a collapsed caret—in one undo unit, then restores the browser Range.
324
+ `queryParaProps` returns per-property mixed state for alignment, effective line height, spacing, margin, and indent.
325
+ The command refreshes all views sharing the session; an external toolbar registered with `registerTextUi` does not
326
+ steal focus or accidentally close text editing.
327
+
328
+ Text-mode `Ctrl/Cmd+C`, `X`, and `V` use the same synchronous browser clipboard events as native editors. Copy and
329
+ cut publish sanitized `text/plain` plus `text/html`; default paste keeps only font, size, bold, italic, underline,
330
+ and strike-through, while `Ctrl/Cmd+Shift+V` ignores HTML. Blocks become PPT paragraphs and `<br>` remains a hard
331
+ line break. External HTML is parsed in a detached tree and never injected into the live editor; scripts, style
332
+ sheets, link targets, image sources, hidden metadata, and unsupported CSS are discarded. If sanitized HTML text
333
+ does not equal `text/plain`, formatting is dropped instead of guessing offsets. A paste or cut is one undo unit.
334
+ Image-only paste on the canvas routes through the same `AddImage` command and creates one undo unit. Text/table
335
+ selections still retain their native paste ownership.
336
+
337
+ Product toolbars stay outside the base DOM package. Their six alignment actions call the headless
338
+ `AlignElements` command directly; the mounted view synchronously patches only elements that actually moved and
339
+ refreshes the interaction frame. This keeps the same integration surface for React, Vue, Web Components, and
340
+ vanilla applications without putting a framework runtime into the editor package.
341
+
342
+ Fill and outline controls use the same external-toolbar seam. The editor entry re-exports
343
+ `queryElementFill`, `queryElementStroke`, and `SHAPE_PATTERN_PRESETS`, so an adapter can derive mixed/effective
344
+ state and submit JSON commands without reading SVG or importing editor internals. Every mounted edit and view
345
+ surface updates the target markup/defs partition; unchanged siblings and the page SVG keep their DOM identity.
346
+
347
+ ```ts
348
+ import { openEditor, queryElementFill } from '@web-ppt/editor';
349
+
350
+ const state = queryElementFill(session.editor.doc, selectedIds);
351
+ session.editor.exec({
352
+ type: 'SetFill', id: selectedIds[0],
353
+ fill: { type: 'solid', color: state.mixed ? '#2563EB' : '#0EA5E9' },
354
+ });
355
+ session.editor.exec({
356
+ type: 'SetStroke', id: selectedIds[0],
357
+ stroke: { color: '#0F172A', width: 2, dash: null },
358
+ });
359
+ ```
360
+
361
+ Page-sidebars use the re-exported `querySlideBackground` / `querySlideHidden` functions and submit
362
+ `SetBackground` / `SetHidden` through the same headless editor. Multiple mounted edit/view surfaces showing the
363
+ target page receive the new background synchronously; surfaces on other pages retain their SVG identity.
364
+ For local files, call `await view.setBackgroundImage(file, options)` or `view.chooseBackgroundImage()`; use
365
+ `view.setBackgroundCrop(cropOrNull)` for an existing or inherited image background. These methods are available to
366
+ React, Vue, Web Component, and vanilla toolbars without exposing OPC relationships or media hashes.
367
+
368
+ Shape palettes use the same framework-neutral seam: call `session.editor.exec({ type: 'AddShape', ... })`.
369
+ Every mounted view inserts the new SVG partition synchronously, the edit view shows its selection frame, and a
370
+ double-click opens the existing text editor. View mode exposes no creation gesture; product code decides when to
371
+ offer the command without importing DOM internals.
372
+
373
+ Image buttons can call `view.chooseImage()` for the built-in local file input or `view.insertImage(file, options)`
374
+ when a React, Vue, Web Component, or vanilla toolbar already owns a `File`/`Blob`. PNG, JPEG, GIF, and WebP are
375
+ recognized from their bytes rather than the filename or browser MIME. Files stay local; the default 5MB limit
376
+ keeps the insertion inside the standard 8MB undo budget and can be changed explicitly. While bytes are read the
377
+ view exposes `aria-busy="true"`; failures reject the promise and dispatch `webpptimageerror`. Double-clicking an
378
+ empty picture placeholder uses the same path and replaces that placeholder plus the image in one undo unit.
379
+
380
+ Table pickers call synchronous `view.insertTable(rows, cols, options?)`. An explicit `rect` is used as-is; without
381
+ one, the view replaces the selected empty content placeholder or computes a centered size from the grid. The result
382
+ is a native DrawingML table whose cells open in the existing text editor and whose final-cell `Tab` uses the existing
383
+ append-row path. The method returns the new stable element id, while view mode rejects creation.
384
+
385
+ Page navigators use the equivalent `AddSlide` seam shown above. The headless result identifies the new page;
386
+ each mounted edit or view surface switches with its existing `setSlide` method, so a toolbar never mutates DOM
387
+ or scans for generated ids. An edit surface draws empty layout placeholders only in its interaction layer and
388
+ double-click opens the existing text editor; view surfaces and exported/saved output contain no helper UI.
389
+
390
+ Reordering uses the adjacent `MoveSlide { id, at: { after } }` seam. Subscribe to `movedSlides`, then read the
391
+ final `session.editor.doc.slideOrder`; mounted view/edit canvases keep their stable `slideId` and only derived
392
+ page-number or relative-link partitions are refreshed. Framework adapters never need a shadow page-order model.
393
+
394
+ Deletion uses `session.editor.exec({ type: 'RemoveSlide', id })`. All mounted view/edit canvases showing that page
395
+ close active input and switch to the public `removedSlideFallbacks` target; canvases on surviving pages keep their
396
+ stable `slideId` and SVG root. A React, Vue, Svelte, Web Component, or vanilla navigator can consume the same map.
397
+ Disable the command when `session.editor.doc.slideOrder.length === 1`.
398
+
399
+ Duplication uses `DuplicateSlide { id }` and reports the new stable page through `createdSlides`. Existing canvases
400
+ stay on their current pages and preserve active input; a React/Vue/Svelte/Web Component adapter can switch or mount
401
+ another canvas explicitly with that returned id. The copy is inserted after its source; compose `MoveSlide` when the
402
+ user chooses another destination.
403
+
404
+ The interaction SVG draws one exact oriented bounding box for a single selection and the world-space AABB union
405
+ for a multi-selection. It adds eight resize handles and one rotation handle; their stroke and size stay constant
406
+ in screen pixels at every view zoom. Rotated/flipped elements and nested groups use the same transform order as
407
+ the core renderer.
408
+
409
+ Custom adapters can use the same pure coordinate seam without mounting another view:
410
+
411
+ ```ts
412
+ import {
413
+ elementFrameToSlidePoint, screenToSlidePoint,
414
+ slideToElementFramePoint, slideToElementParentPoint,
415
+ } from '@web-ppt/editor';
416
+
417
+ const slidePoint = screenToSlidePoint(
418
+ { x: event.clientX, y: event.clientY },
419
+ { left: canvasRect.left, top: canvasRect.top, zoom: view.zoom },
420
+ );
421
+ const localPoint = slideToElementFramePoint(session.editor.doc, elementId, slidePoint);
422
+ const parentPoint = slideToElementParentPoint(session.editor.doc, elementId, slidePoint);
423
+ const origin = elementFrameToSlidePoint(session.editor.doc, elementId, { x: 0, y: 0 });
424
+ ```
425
+
426
+ These functions are DOM-free and include every ancestor group's rotation, flip, child offset, and child scale.
427
+ Use `localPoint` for geometry and `parentPoint` for `x` / `y` movement.
428
+
429
+ Dragging an element selects and moves it in one gesture; dragging any member of a multi-selection preserves and
430
+ moves the whole selection. A 3-screen-pixel threshold separates clicks from moves. The view captures the primary
431
+ pointer, coalesces preview updates with `requestAnimationFrame`, and translates temporary SVG wrappers plus the
432
+ interaction overlay—no model, defs, or static element identity changes during the gesture. Pointer-up removes the
433
+ ghosts before committing one `SetXfrm` transaction. `Escape`, pointer cancellation/loss, page or mode changes,
434
+ and view destruction restore the original DOM without history. Nested rotated/flipped groups are converted into
435
+ each element's parent coordinate space.
436
+
437
+ Movement snaps within a six-screen-pixel threshold to the slide center/edges and direct siblings' edges/centers;
438
+ equal gaps are shown as paired bidirectional spacing arrows. Optional `snapMargins` add four host-defined slide
439
+ guides without guessing a document margin. Candidates are resolved independently per axis in stable priority
440
+ order, so overlapping choices do not jitter with element traversal order. Hold `Ctrl` to disable snapping during
441
+ the current gesture, or use `snapping: false` / `view.setSnapping(false)` for the whole view. Guides exist only in
442
+ the interaction SVG and every cancellation path removes them without changing the model.
443
+
444
+ The eight resize handles have a 4-screen-pixel outward hit margin. Corners resize both axes, edge handles resize
445
+ one axis, `Shift` preserves aspect ratio, and `Alt` keeps the center fixed; modifiers can be pressed or released
446
+ during the gesture. Crossing the opposite anchor normalizes positive dimensions, swaps the active handle without
447
+ a visual jump, and toggles `flipH` / `flipV`. Single rotated/flipped and nested elements keep the opposite anchor
448
+ fixed in their parent space; multi-selections resize from their shared world-space AABB. Preview frames reuse the
449
+ same pointer-capture/rAF lifecycle as movement and touch only temporary wrappers plus the interaction overlay.
450
+ Pointer-up commits every selected root in one undo unit.
451
+
452
+ The rotation handle has the same four-screen-pixel hit margin. A single selection converts pointer angles into
453
+ the element's parent space through its own flip and every rotated/flipped ancestor; a multi-selection rotates all
454
+ selected roots around the shared AABB center. Angles accumulate continuously across ±180°, `Shift` can snap to
455
+ 15° at any point in the gesture, and a live value appears beside a single selection. Preview still touches only
456
+ ghost wrappers; pointer-up writes OOXML's 1/60000-degree value in one transaction, while every cancellation path
457
+ commits nothing.
458
+
459
+ `textMode: 'auto'` is the default. It reuses `viewer-core`'s runtime probe and switches affected Safari/iOS
460
+ engines to native SVG text when they fail to scale `foreignObject`. The out-of-SVG `contenteditable` then consumes
461
+ the same absolute engine line boxes from core. Explicit `html` and `svg` modes are also available. Soft wraps stay
462
+ out of the model while hard breaks, empty paragraphs, RTL, vertical text, columns, and math remain editable at
463
+ source UTF-16 positions. Full-slide renders, incremental patches, and the active editor use one text mode.
464
+
465
+ One session can mount several views (for example, the main canvas and a thumbnail). Destroying a view never
466
+ releases shared resources; disposing the session destroys every remaining view and is idempotent. React, Vue,
467
+ Svelte, Web Components, and plain DOM adapters all use the same `openEditor` / `mount` seam—none of their
468
+ runtimes are dependencies of this package.
469
+
470
+ The published entry measures 40.61 KB gzip. `@web-ppt/core`, `@web-ppt/edit-core`, and
471
+ `@web-ppt/viewer-core` are peer dependencies.
472
+
473
+ MIT
@@ -0,0 +1,15 @@
1
+ import type { EditorSession } from './session';
2
+ import type { EditorMode } from './slide-editor-types';
3
+ import type { WebPptFormatPainterState } from './framework-adapter-types';
4
+ import type { FormatPainterStartOptions } from './format-painter-types';
5
+ /** adapter 只桥接会话控制器;状态机仍由 EditorSession 独占。 */
6
+ export declare class AdapterFormatPainterBinding {
7
+ private session;
8
+ private unsubscribe;
9
+ private previous;
10
+ bind(session: EditorSession, changed: () => void): void;
11
+ release(): void;
12
+ start(options: FormatPainterStartOptions): boolean;
13
+ cancel(): void;
14
+ state(ready: boolean, mode: EditorMode): WebPptFormatPainterState;
15
+ }
@@ -0,0 +1,3 @@
1
+ import type { LinkTarget } from '@web-ppt/edit-core';
2
+ import type { LinkFollowContext, LinkFollowHandler } from './slide-editor-types';
3
+ export declare function followAdapterLink(target: LinkTarget, context: LinkFollowContext, handler: LinkFollowHandler | undefined, setSlide: (slideId: string) => void, report: (error: unknown) => void): boolean | void;
@@ -0,0 +1,6 @@
1
+ import type { OpenEditorOptions } from './session';
2
+ import type { SlideEditorOptions } from './slide-editor-types';
3
+ import type { WebPptViewOptions } from './framework-adapter-types';
4
+ export declare function openOptionsKey(options: OpenEditorOptions | undefined): string;
5
+ export declare function validateViewOptions(options: WebPptViewOptions): void;
6
+ export declare function sameMargins(left: SlideEditorOptions['snapMargins'], right: SlideEditorOptions['snapMargins']): boolean;
@@ -0,0 +1,14 @@
1
+ import type { OpenEditorOptions } from './session';
2
+ import type { RecoveryCandidate, RecoveryDecisionHandler } from './recovery-store';
3
+ interface AdapterRecoveryHooks {
4
+ active(): boolean;
5
+ errorActive(): boolean;
6
+ decision(): RecoveryDecisionHandler | undefined;
7
+ recovering(candidate: RecoveryCandidate): void;
8
+ opening(): void;
9
+ errorHandler(): ((error: unknown) => void) | undefined;
10
+ error(error: unknown): void;
11
+ }
12
+ /** 只编排宿主决策;指纹、日志和回放仍由 openEditor 独占。 */
13
+ export declare function bindAdapterRecovery(options: OpenEditorOptions | undefined, signal: AbortSignal, hooks: AdapterRecoveryHooks): OpenEditorOptions | undefined;
14
+ export {};
@@ -0,0 +1,22 @@
1
+ import type { SlideId } from '@web-ppt/edit-core';
2
+ import type { EditorSession } from './session';
3
+ import type { SelectionPane } from './selection-pane-types';
4
+ import type { EditorMode } from './slide-editor-types';
5
+ interface PaneViewState {
6
+ readonly mode?: EditorMode;
7
+ readonly slideId?: SlideId;
8
+ }
9
+ /** adapter 的第二个 DOM 资源独立收口,文件替换时可先完整挂载再原子切换所有权。 */
10
+ export declare class AdapterSelectionPaneBinding {
11
+ private currentContainer;
12
+ private currentPane;
13
+ get pane(): SelectionPane | null;
14
+ attach(container: HTMLElement | null, session: EditorSession | null, view: PaneViewState, fallbackSlide: SlideId | null, onError: (error: unknown) => void): void;
15
+ prepare(session: EditorSession, view: PaneViewState, fallbackSlide: SlideId | null, onError: (error: unknown) => void): SelectionPane | null;
16
+ commit(next: SelectionPane | null): void;
17
+ sync(session: EditorSession | null, view: PaneViewState): void;
18
+ release(): void;
19
+ dispose(): void;
20
+ private mount;
21
+ }
22
+ export {};
@@ -0,0 +1,28 @@
1
+ import type { TextSearchMatch } from '@web-ppt/edit-core';
2
+ import type { EditorSession } from './session';
3
+ import type { EditorMode } from './slide-editor-types';
4
+ import type { TextSearchOpenOptions, TextSearchOptions } from './text-search-types';
5
+ import type { WebPptTextSearchState } from './framework-adapter-types';
6
+ /** adapter 仅转发会话搜索;React/Vue/Svelte 不应各自维护第二份状态机。 */
7
+ export declare class AdapterTextSearchBinding {
8
+ private readonly reveal;
9
+ private session;
10
+ private unsubscribe;
11
+ private source;
12
+ private previous;
13
+ constructor(reveal: (match: TextSearchMatch) => void);
14
+ bind(session: EditorSession, changed: () => void): void;
15
+ release(): void;
16
+ open(options: TextSearchOpenOptions): void;
17
+ close(): void;
18
+ setQuery(query: string): void;
19
+ setReplacement(replacement: string): void;
20
+ setOptions(options: Partial<TextSearchOptions>): void;
21
+ next(): TextSearchMatch | null;
22
+ previousMatch(): TextSearchMatch | null;
23
+ replaceCurrent(): boolean;
24
+ replaceAll(): number;
25
+ state(ready: boolean, mode: EditorMode): WebPptTextSearchState;
26
+ private navigate;
27
+ private revealCurrent;
28
+ }
@@ -0,0 +1,26 @@
1
+ import type { AnimStep } from '@web-ppt/core';
2
+ interface AnimationPreviewOptions {
3
+ readonly layer: HTMLElement;
4
+ readonly chrome?: readonly (HTMLElement | SVGElement)[];
5
+ current(): readonly AnimStep[] | undefined;
6
+ destroyed(): boolean;
7
+ }
8
+ /** 一键预览自动跑完整条时间线,但只拥有自己创建的 WAAPI 与临时内联样式。 */
9
+ export declare class AnimationPreviewController {
10
+ private readonly options;
11
+ private generation;
12
+ private playback;
13
+ private styles;
14
+ private chromeVisibility;
15
+ constructor(options: AnimationPreviewOptions);
16
+ preview(input?: readonly AnimStep[]): Promise<boolean>;
17
+ cancel(): void;
18
+ private releasePreview;
19
+ private releasePlayback;
20
+ private captureStyles;
21
+ private restoreStyles;
22
+ private applyVisibility;
23
+ private hideChrome;
24
+ private restoreChrome;
25
+ }
26
+ export {};
@@ -0,0 +1,17 @@
1
+ import type { EditDoc, ElementId, SlideId } from '@web-ppt/edit-core';
2
+ export declare function elementIds(doc: EditDoc, roots: readonly ElementId[]): ElementId[];
3
+ export declare function bindElementIdentities(root: ParentNode, doc: EditDoc, roots: readonly ElementId[]): void;
4
+ export declare function bindSlideIdentities(root: ParentNode, doc: EditDoc, slideId: SlideId): void;
5
+ export declare function findElementPartition(root: ParentNode, id: ElementId): SVGElement | null;
6
+ /**
7
+ * visibility 会被后代的 visible 覆盖;显示元素时必须删除声明,让祖先隐藏继续继承。
8
+ */
9
+ export declare function syncElementVisibility(root: ParentNode, doc: EditDoc, id: ElementId): boolean;
10
+ export declare function syncElementTreeVisibility(root: ParentNode, doc: EditDoc, id: ElementId): void;
11
+ export declare function syncSlideVisibility(root: ParentNode, doc: EditDoc, slideId: SlideId): void;
12
+ export declare function touchedElementPartitions(doc: EditDoc, slideId: SlideId, touched: ReadonlySet<ElementId>): {
13
+ ids: ElementId[];
14
+ topLevelCount: number;
15
+ };
16
+ /** 百分比在小页面上会把一次局部修改误判成整页重绘;只有真实批量提交才考虑换整页。 */
17
+ export declare function shouldRenderWholeSlide(partitionCount: number, topLevelCount: number, slideTopLevelCount: number): boolean;
@@ -0,0 +1,9 @@
1
+ import type { Editor, ElementId } from '@web-ppt/edit-core';
2
+ /** markup 与它引用的 defs 在同一同步提交中换代,浏览器没有机会绘制半个状态。 */
3
+ export declare function patchElement(staticLayer: HTMLElement, editor: Editor, id: ElementId, idPrefix: string, textMode: 'html' | 'svg'): boolean;
4
+ /** 删除结构节点时只移除其 markup/defs 分区,未触碰兄弟必须保留 DOM 身份。 */
5
+ export declare function removeElementPartition(staticLayer: HTMLElement, id: ElementId): boolean;
6
+ /** 撤销删除时以模型兄弟 z 序寻找稳定锚点;空父容器交给整页回退。 */
7
+ export declare function insertElementPartition(staticLayer: HTMLElement, editor: Editor, id: ElementId, idPrefix: string, textMode: 'html' | 'svg'): boolean;
8
+ /** 按模型目标序倒序移动受影响分区;锚点标记保证末元素不会越过非元素装饰节点。 */
9
+ export declare function reorderElementPartitions(staticLayer: HTMLElement, editor: Editor, ids: ReadonlySet<ElementId>): boolean;
@@ -0,0 +1,13 @@
1
+ import type { KeyboardControllerOptions } from './keyboard-context';
2
+ /** 画布键盘路由只组合命令,元素变换与选择范围仍留在各自模块。 */
3
+ export declare class EditorKeyboardController {
4
+ private readonly options;
5
+ private readonly history;
6
+ private readonly deletion;
7
+ private readonly layer;
8
+ private readonly nudge;
9
+ constructor(options: KeyboardControllerOptions);
10
+ keyDown(event: KeyboardEvent): boolean;
11
+ keyUp(event: KeyboardEvent): boolean;
12
+ breakSequence(): void;
13
+ }