feat(editor): WYSIWYG HTML slide editing — click-to-edit, drag, resize #96
Labels
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
lhumina_code/hero_slides#96
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Now that slides are rendered as HTML (not static images), the preview iframe gives us full DOM access. This opens up direct visual editing that was previously impossible.
What to implement
Phase 1 — Core editing (highest leverage)
Inject editing script into iframe on
?edit=trueWhen serving the slide HTML in edit mode, append a
<script>block that runs inside the iframe DOM. This avoids coordinate translation and gives full element access. Strip the script before saving HTML back to disk.Click-to-select
Click any element in the preview → highlight with a selection box overlay. Clicking away deselects.
Double-click to edit text
Double-click any text element → set
contentEditable = true, user types inline. Blur → save.slide.saveHtmlRPCNew RPC method that writes the modified
iframe.contentDocument.documentElement.outerHTMLback todeck/output/<slide>.html. Once a slide has manual HTML edits, mark it as "manually edited" — skip AI regeneration unless explicitly requested. This avoids the hard markdown↔HTML sync problem entirely.Undo/redo stack
Before each mutation, push
outerHTMLsnapshot. Ctrl+Z pops and re-injects. Keep last 20 states. ~20 lines once save path exists.Phase 2 — Move & resize
Drag to move (abs-positioned elements only)
The AI generates most elements with
position:absolute+ explicitleft/top. Mousedown → track delta → mutatestyle.left/style.top. Skip flow-layout elements — dragging those breaks layout unpredictably.Resize handles
8 handles (corners + edges) as overlay divs on the selection box. Drag → mutate
width/height(andleft/topfor top/left handles).Phase 3 — Properties panel
When an element is selected, show a floating or sidebar panel:
Direct DOM
stylemutation, same save path.What NOT to do
position:absoluteelements.Feasibility
slide.saveHtmlRPCPhase 1 alone eliminates the "regenerate the whole slide to fix a typo" problem.
Technical approach
Same-origin iframe →
iframe.contentDocumentis fully accessible from parent JS. Inject edit overlay via?edit=truequery param handled in the slide HTML route. No cross-origin, no CSP issues since we control the server.Related branch:
development_pptx_mockupThis work builds on the HTML-based PPTX export work done on that branch (the switch from image to HTML rendering is what makes this possible).
Implementation Spec for Issue #96
Objective
Add WYSIWYG (click-to-edit, drag, resize, properties-panel) editing of slides directly inside the existing same-origin preview iframe on the slide editor page (
crates/hero_slides_admin). Edits mutate the liveiframe.contentDocumentDOM in JS, then persist the cleaneddocumentElement.outerHTMLback todeck/output/<slide>.htmlvia a newslide.saveHtmlRPC. Once a slide has manual HTML edits it is marked "manually edited" so AI HTML regeneration skips it unless forced.Requirements
?edit=true) appends an editor<script>(+overlay CSS) that runs inside the iframe DOM. The script is stripped before the HTML is written to disk.contentEditable=true, inline typing; blur saves the snapshot.slide.saveHtmlRPC writes the editedouterHTMLtooutput/<slide>.htmland sets a per-slidemanually_editedmarker inmetadata.toml.outerHTMLsnapshots; Ctrl+Z / Ctrl+Shift+Z).position:absoluteelements (mutatestyle.left/style.top).width/height(+left/topfor top/left handles).stylemutation, same save path.deck_generate_html/slide_generate_htmlskip the slide unlessforce=true.Files to Modify / Create
Backend — Rust
crates/hero_slides_admin/src/routes.rs— Inserve_slide_html, read the?edit=truequery param; when set, inject the editor script + CSS before</body>. The route already resolves the deck path and serves the file.crates/hero_slides_lib/src/hashing.rs— Addpub manually_edited: bool(with#[serde(default)]) toSlideMetaEntry. Addslide_mark_manually_edited(deck_dir, slide_name)andslide_is_manually_edited(deck_dir, slide_name) -> bool(via existingload_metadata/save_metadata).crates/hero_slides_lib/src/deck.rs— Addslide_save_html(deck_path, slide_name, html) -> Result<PathBuf>(writesoutput/<slide>.html, then marks manually edited). Indeck_generate_htmlandslide_generate_html, skip whenmanually_editedis true and!force(add aforceparam toslide_generate_html).crates/hero_slides_lib/src/lib.rs— Re-exportslide_save_html,slide_mark_manually_edited,slide_is_manually_edited.crates/hero_slides_server/src/rpc/mod.rs— Register"slide.saveHtml" => handle_slide_save_html(...)in the dispatch match (byslide.saveContent). Implementhandle_slide_save_html(modeled onhandle_slide_save_content). Add"slide.saveHtml"tois_deck_mutationso the deck-changed SSE fires.crates/hero_slides_server/openrpc.json— Add aslide.saveHtmlmethod entry (params:collection,deck,slide,html; result{ saved: bool }).Frontend — JS/HTML/CSS
crates/hero_slides_admin/static/js/slide_edit.js— Parent-side controller: load preview with?edit=true, relay save torpc('slide.saveHtml', …). Integration points:slideHtmlUrl(),loadPreview(),rpc()helper.crates/hero_slides_admin/static/css/slide_edit.css— Properties-panel + toolbar styling on the parent page.crates/hero_slides_admin/templates/slide_edit.html— "Edit layout" toggle button + properties-panel markup.crates/hero_slides_admin/static/js/slide_wysiwyg_inject.js— The script injected into the iframe (selection box + 8 handles, click/dblclick/drag/resize, undo stack,postMessageto parent). Embedded into the served HTML byserve_slide_html.Implementation Plan
Phase 1 — Core editing
Step 1.1 —
manually_editedmarker in the data modelFiles:
hero_slides_lib/src/hashing.rs,hero_slides_lib/src/lib.rs.Add
#[serde(default)] pub manually_edited: booltoSlideMetaEntry; addslide_mark_manually_edited/slide_is_manually_edited; re-export both.Dependencies: none.
Step 1.2 —
slide_save_htmllib function + skip-on-generateFiles:
hero_slides_lib/src/deck.rs,hero_slides_lib/src/lib.rs.Add
slide_save_html(writeoutput/<slide>.html, then mark); indeck_generate_htmlskip manually-edited slides unlessforce; inslide_generate_htmladd aforceparam and early-return when manually edited and not forced; re-exportslide_save_html.Dependencies: 1.1.
Step 1.3 —
slide.saveHtmlRPC + openrpc.jsonFiles:
hero_slides_server/src/rpc/mod.rs,hero_slides_server/openrpc.json.Implement
handle_slide_save_html(copyhandle_slide_save_contentshape, callslide_save_htmlinspawn_blocking); add match arm; add tois_deck_mutation; add method toopenrpc.json.Dependencies: 1.2.
Step 1.4 —
?edit=trueinjection in the HTML routeFiles:
hero_slides_admin/src/routes.rs.serve_slide_htmlaccepts aQuery<HashMap>; whenedit=true, splice the injected block before</body>; non-edit requests unchanged.Dependencies: pairs with 1.5.
Step 1.5 — In-iframe editor script: select + edit text + undo
Files: new
hero_slides_admin/static/js/slide_wysiwyg_inject.js,hero_slides_admin/src/routes.rs.Selection box overlay, click-to-select, click-away deselect, double-click
contentEditable, blur→snapshot, undo/redo (cap 20), Ctrl+Z/Ctrl+Shift+Z. On save,postMessage{type:'hero:wysiwyg-save', html: cleanedOuterHTML}with injected nodes (taggeddata-hero-wysiwyg) +contentEditablestripped.Dependencies: 1.4.
Step 1.6 — Parent-side controller wiring
Files:
hero_slides_admin/static/js/slide_edit.js,templates/slide_edit.html,static/css/slide_edit.css."Edit layout" toggle, load iframe with
&edit=true, listen forhero:wysiwyg-save→rpc('slide.saveHtml', …)→ reload staleness.Dependencies: 1.3, 1.5.
Phase 2 — Move & resize
Step 2.1 — Drag-to-move (abs-positioned only)
Files:
slide_wysiwyg_inject.js.On mousedown over a selected
position:absolute/fixedelement, track delta, mutatestyle.left/style.top; skip flow-layout; undo snapshot on drag start.Dependencies: 1.5.
Step 2.2 — 8 resize handles
Files:
slide_wysiwyg_inject.js.8 handle divs on the selection box; per-handle resize math mutating
width/height(+left/topfor top/left); undo snapshot on resize start.Dependencies: 1.5, 2.1.
Phase 3 — Properties panel
Step 3.1 — Properties panel UI + binding
Files:
templates/slide_edit.html,static/js/slide_edit.js,static/css/slide_edit.css,slide_wysiwyg_inject.js.On selection, inject script posts the element's computed style to the parent; panel inputs (font-size, color, font-weight, text-align, x, y, w, h, hidden) post changes back; inject script applies to
element.style+ undo snapshot; persist via thehero:wysiwyg-savepath.Dependencies: 1.6, 2.2.
Acceptance Criteria
.../slides/{s}/html?edit=truereturns HTML with the editor script injected; without?edit=trueit returns the raw file unchanged.slide.saveHtmlwrites the editedouterHTMLtooutput/<slide>.htmlwith injected script/overlay/contentEditablestripped.metadata.tomlentry hasmanually_edited = true.deck_generate_html/slide_generate_htmlskip a manually-edited slide unlessforce=true.outerHTML; undo stack capped at 20.position:absoluteelements drag; flow-layout elements do not.slide.saveHtmlappears inopenrpc.json.cargo buildsucceeds forhero_slides_lib,hero_slides_server,hero_slides_admin.Notes
web.rs/templates/web.html. The slide route isserve_slide_htmlincrates/hero_slides_admin/src/routes.rs; the preview iframe (preview-iframe) is intemplates/slide_edit.html, driven bystatic/js/slide_edit.js(loadPreview). RPC dispatch is a manualmatchinhero_slides_server/src/rpc/mod.rs(handle_request) — no auto-registry; the public schema lives inopenrpc.json. The browser calls/rpcon the admin server, which proxies over a Unix socket to the backend.metadata.toml+SlideMetaEntry(mirrors the existinghiddenflag /slide_set_hidden).#[serde(default)]keeps old files parseable. Saving markdown (slide.saveContent) does NOT clear the flag (out of scope here).btn-regenerate-force→regenerate(force)→slide.generateHtmlAsync→--force) already exists; the lib-level skip just needs to honorforce.data-hero-wysiwyg) rather than server-side regex.slide_edit.htmlalready uses amessagelistener withhero:theme-ready; use a distincthero:wysiwyg-*namespace.browser_pptx.rs(the HTML→PPTX exporter) is unrelated to this feature and will not be touched.