Share a presentation link that opens directly in presentation mode #97
Labels
No labels
prio_critical
prio_low
type_bug
type_contact
type_issue
type_lead
type_question
type_story
type_task
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
lhumina_code/hero_whiteboard#97
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?
Improvement
The board page (issues #82 share modal) lets users share two roles: viewer (read-only) and editor. There's no way to share a link that opens the recipient's tab directly into presentation mode -- presenter clicks the share link, sends it to attendees, attendees see slides immediately, no edit chrome, no need to find the Present button.
Miro / FigJam / Figma all support "Share for presentation" links.
Expected behavior
home.html) gains a third row labeledPresentation linkwith a read-only URL and a Copy button. The board.html in-board Share button (aroundtemplates/web/board.html:17) gets the same row in its share dialog if there is one (current implementation callsshareReadonly()-- check whether there's a modal there).presentation(orviewer-present-- pick one and document it). The server'sshare.createalready accepts aroleparam./s/<token>route (crates/hero_whiteboard_ui/src/routes.rs::web_shared_board) recognizes the new role and serves the board page with a query parameter?present=1(or a body flag rendered into the template) so the client knows to auto-start presentation mode.?present=1is in the URL, callsWhiteboardFrames.startPresentation()after the board has loaded. The toolbar / minimap / property panel are still hidden via the existingbody.wb-presentingmachinery.?present=1flag is purely a UX hint that auto-enters present mode. If the underlying share role isviewer, the user already can't write.Affected files (expected)
crates/hero_whiteboard_ui/src/routes.rs::web_shared_board-- when the share role ispresentation(or when an extra?present=1query param is present), passpresent_mode: trueinto the template; serve the read-only board template (the same one used forviewerrole today).crates/hero_whiteboard_ui/templates/web/board.htmland/orboard_view.html-- on DOMContentLoaded, if the host injected apresent_modeflag, callWhiteboardFrames.startPresentation()once the board's objects have loaded.crates/hero_whiteboard_ui/templates/web/home.html::openShareModal-- add a third row to the share-board modal:Presentation linkwith a Copy button. On first open, lazily create ashare.createwithrole: 'presentation'(or reuseviewer+ the?present=1query). Cache the URL like the existing view/edit URLs.No SDK / openrpc edits if we choose the
viewerrole +?present=1query approach (zero server-side change). If we add a newpresentationrole, the openrpc spec gets one more enum value but no behavior change.Recommended:
viewerrole +?present=1query param. Smaller surface, no DB / openrpc changes.Acceptance criteria
Presentation linkwith a Copy button./s/<viewer_token>?present=1URL.Copied!toast.routes.rs,board.html(and/orboard_view.html), andhome.html.cargo check / clippy / fmt --check / testclean.Notes
?present=1query param is read in JS vianew URLSearchParams(location.search).get('present') === '1'. Defer the start until afterloadBoards/loadObjectsfinishes, otherwise the canvas tries to fit the frame before the frame even exists. The simplest pattern is a singlesetTimeout(start, 0)afterWhiteboardApp.initresolves.Spec — Issue #97: Share a presentation link that opens directly in presentation mode
Objective
Add a third "Presentation link" row to both share dialogs. The link is the existing viewer share URL with
?present=1appended. On page load, if?present=1is present in the URL, auto-startWhiteboardFrames.startPresentation()after the board's objects have loaded.Approach
Per the issue body's recommendation: reuse the existing viewer share role + a
?present=1query param. No server / SDK / openrpc / DB changes.Confirmed in code
home.html::openShareModal::renderRows(lines 730-748) renders the existing two-row markup; the view URL is computed aswindow.location.origin + WB_BASE + '/s/' + viewerShare.token(line 767). The localcopyLink(url)helper (line 782, fully wired post-#99 with execCommand fallback + red error toast) is reusable.ui-helpers.js::shareReadonly(line 17) builds the in-board share dialog with the same view-URL math; it usesWhiteboardApp.copyToClipboard(post-#99) for Copy buttons.app.js::loadBoard(line 192) is the post-load hook: at its end, all objects/connectors/comments have rendered. AsetTimeout(..., 0)afterhandleUrlGroupNavigation()(line 268) is the safe spot to callWhiteboardFrames.startPresentation().routes.rs::web_shared_board(line 356) does not need changes — it already serves the read-only template forviewerrole and the editor template foreditorrole; the JS readslocation.searchdirectly and Axum/Askama don't strip query strings.WhiteboardFrames.startPresentationalready exists on the public API (used bytogglePresentation). For zero-frame boards it callswindow.showNoFramesNotice()and returns — no special-casing needed here.Files to Modify
crates/hero_whiteboard_ui/static/web/js/whiteboard/app.js— auto-start hook at the end ofloadBoard().crates/hero_whiteboard_ui/templates/web/home.html— add a third row inrenderRows.crates/hero_whiteboard_ui/static/web/js/whiteboard/ui-helpers.js— add a third row inshareReadonly.Files to Leave Alone
routes.rs,share.rshandlers,openrpc.json, all server crates, SDK — no changes.frames.js—startPresentationalready does the right thing.board.htmlchrome / CSS —body.wb-presentingalready hides the toolbar/properties/etc.Step-by-Step Plan
Step 1 — Auto-start hook in
app.jsFile:
crates/hero_whiteboard_ui/static/web/js/whiteboard/app.jsAt the end of
loadBoard(), afterhandleUrlGroupNavigation():The
setTimeout(fn, 0)defers until the current microtask queue drains so Konva's batchDraw and any outstanding redraws settle beforefocusFramereads stage dimensions. Thetry/catchkeeps the readonly polling path safe in browsers withoutURLSearchParams(none in our target set, but defensive).Dependencies: none.
Step 2 — Add presentation-link row to the home-page share modal
File:
crates/hero_whiteboard_ui/templates/web/home.htmlIn
renderRows(viewUrl, editUrl)(lines 730-748), after the existing Edit link block, append a third block. The presentation URL isviewUrl + '?present=1':The cache (
shareUrlsCache[boardId] = { viewUrl, editUrl }) doesn't need a third field — the present URL is derived fromviewUrlon render.Dependencies: none.
Step 3 — Add presentation-link row to the in-board share dialog
File:
crates/hero_whiteboard_ui/static/web/js/whiteboard/ui-helpers.jsIn
shareReadonly()(line 17), insert a third labeled block after the Edit link block (line 45). ComputepresentUrl = viewUrl + '?present=1'once near the existing URL declarations (lines 29-30):And in the dialog HTML, append:
Place this between the Edit link block and the Close button.
Dependencies: none.
Parallelism
Steps 1, 2, 3 touch different files and are independent.
Acceptance Criteria
Presentation linkwith a Copy button./s/<viewer_token>?present=1URL.Copied!toast.app.js,home.html, andui-helpers.js.cargo check / clippy / fmt --check / testclean.Notes
?present=1is still in the URL. This matches the issue's "Don't auto-restart presentation if the user exits" requirement —WhiteboardFrames.startPresentationis fired exactly once fromloadBoardandstopPresentationdoesn't trigger any auto-restart.?present=1query string survives the Forgejo / Axum / browser routing path becauseweb_shared_boardis/s/{token}(path param only); query strings are passed through to the rendered template. The JS readslocation.searchclient-side.?present=1appended — there's no role gating in the auto-start hook. Audience-mode protection still relies on theviewerrole for write-blocking; the?present=1flag is purely UX.viewUrlused inhome.html/ui-helpers.jsis constructed without an existing query string, so?present=1is always the first parameter — no need to handle existing?cases.Test Results
The Askama home.html template re-compiled cleanly via cargo check.
Implementation Summary
Three files changed, +21/-0. No server / SDK / openrpc / DB changes.
crates/hero_whiteboard_ui/static/web/js/whiteboard/app.jsAt the end of
loadBoard(), afterhandleUrlGroupNavigation():The
setTimeout(fn, 0)defers until the current task settles so Konva's batchDraw and any outstanding redraws finish beforefocusFramereads stage dimensions. Thetry/catchis a defensive guard.crates/hero_whiteboard_ui/templates/web/home.htmlIn
openShareModal::renderRows, appended a third row labeled "Presentation link" after the existing Edit link row. The URL isviewUrl + '?present=1'. The Copy button reuses the localcopyLink(url)helper (post-#99 has the execCommand fallback + red error toast for non-secure contexts).crates/hero_whiteboard_ui/static/web/js/whiteboard/ui-helpers.jsIn
shareReadonly(), computedvar presentUrl = viewUrl + '?present=1';and added a third labeled block in the dialog markup between the Edit link and Close button. The Copy button usesWhiteboardApp.copyToClipboard.Verification
cargo fmt --all -- --check: cleancargo check --workspace: clean (Askama re-compiledhome.htmlsuccessfully)node --check app.js: cleannode --check ui-helpers.js: cleanManual smoke test
?present=1is still in the URL.Notes / scope
loadBoardruns once, thesetTimeout(start, 0)fires once, andstopPresentationdoesn't trigger any auto-restart.?present=1appended; there's no role gating in the auto-start hook (audience-mode write-blocking still relies on the underlyingviewerrole).viewUrlbuilt in both share dialogs is constructed without an existing query string, so?present=1is always the first parameter.Follow-up
Two gaps fixed in
templates/web/board.html(+32/-0):1. Inline
shareReadonlywas missed in the previous commitThe toolbar Share button on the board page calls an inline
shareReadonlydefined inboard.htmlitself (not the duplicate inui-helpers.jsthat the previous commit patched). The inline dialog now also includes the Presentation link row, matching the home-page modal and theui-helpers.jsversion.2. Share button while presenting
Added a Share button to the
#presentation-controlsbar between the divider and the Exit button:Click handler in the existing
bindPresentationIIFE:share.list; falls back toshare.createif none exists.location.origin + WB_BASE + '/s/' + token + '?present=1'.WhiteboardApp.copyToClipboard(the existing helper with the execCommand fallback + green/red toast).The presenter now never has to leave presentation mode to grab the link. Audience opens the URL → board loads in viewer mode → presentation auto-starts.
Verification
cargo fmt --all -- --check: cleancargo check --workspace: clean (Askama re-compiledboard.html)Manual smoke test
share.createRPCs (the URL is cached for the session).Follow-up — fix viewer-mode chrome and live slide sync
Two new bugs found while testing the presentation share link, both fixed in this commit (+140/-0):
Bug 1 — Viewer template lacked all presentation chrome
The shared viewer link served
board_view.html, which had zero presentation chrome: nowb-presentingCSS, no#pres-spotlight, no#presentation-controls, nobindPresentationscript. So when?present=1triggeredWhiteboardFrames.startPresentation(), the canvas zoomed to the first frame but:pointer-events: nonerule).Fix: added the same presentation
<style>block (navbar/zoom/minimap hide +pointer-events:none+ spotlight + control bar) and the<div id="pres-spotlight">/<div id="presentation-controls">markup toboard_view.html. The viewer's control bar shows only Counter and Exit — Prev/Next belong to the presenter (audience follows). Added a smallbindPresentationscript that wires the Exit button, the spotlight position listener viaWhiteboardFrames.setOnChange, and an Esc-to-exit keydown handler.Bug 2 — Slide changes didn't propagate to other windows
When the presenter clicked Next/Prev, the audience's window stayed on the original slide. There was no broadcast hook —
frames.jsupdated the local index without telling other clients.Fix: added
_broadcastSlide()toframes.js, called fromnextFrameandprevFrameafter the index change. It sends{type: 'presentation.slide', data: {index}}via the existingWhiteboardSync._wsSend. NewapplyRemoteSlide(index)handles incoming messages: it gates onpresentationMode === true, refreshes the frame list, clamps the index, and re-runsfocusFrameunder an_applyingSyncflag so the receiver's own broadcast is suppressed (no echo loop).sync.jsroutes the new message type:The existing per-board WebSocket fan-out + the
msg.sender === localUserIdecho-skip inhandleWsMessagemean the presenter's own broadcast doesn't loop back to themselves.Files changed
crates/hero_whiteboard_ui/templates/web/board_view.html(+108): presentation CSS, spotlight, control bar (Counter + Exit only),bindPresentationscript, Esc handler.crates/hero_whiteboard_ui/static/web/js/whiteboard/frames.js(+28):_applyingSync,_broadcastSlide,applyRemoteSlide. Wired intonextFrame/prevFrame. ExportedapplyRemoteSlide.crates/hero_whiteboard_ui/static/web/js/whiteboard/sync.js(+4):presentation.slidebranch inhandleWsMessage.Verification
cargo fmt --all -- --check: cleancargo check --workspace: clean (Askama re-compiledboard_view.html)node --checkonframes.jsandsync.js: cleanManual smoke test
sender === localUserIdskip).Notes / scope
?present=1, and Esc lets them exit independently. If we ever want force-follow on start/stop (presenter starts → everyone starts), that's a small extension on top of_broadcastSlide._applyingSyncguard prevents echo even if a receiver's local code path were to invokenextFrame/prevFrameprogrammatically while applying a remote slide — current code doesn't, but defense in depth.getFrames()is called insideapplyRemoteSlideso the receiver's localframesarray is up-to-date even if frames were added/removed since the laststartPresentation.board.htmlandboard_view.htmlis acceptable for now; if the chrome gets more complex, refactor into an Askama partial.Follow-up — audience joins on the presenter's current slide
When a new audience opens the presentation link mid-presentation, they used to land on slide 1 regardless of where the presenter was. Now they snap to the presenter's current slide on join.
Mechanism
frames.jsexports a newannounceCurrentSlide()that broadcasts the localcurrentFrameIndexover the existingpresentation.slideWebSocket message — but only if the local client is in presentation mode. Internally it just calls the existing_broadcastSlide().sync.js'sjoinhandler now callsWhiteboardFrames.announceCurrentSlide()after the existing presence update. So whenever any client receives a join, anyone currently presenting tells the new joiner where they are. Idempotent: receivers whose index already matches no-op.loadBoardand auto-start presentation,applyRemoteSlidecaches the index in_pendingRemoteSlide. At the end ofstartPresentation, the pending index is applied (no flicker since this happens inside the same task as the initialfocusFrame). If no pending slide is cached,startPresentationitself broadcasts our own arrival so an existing presenter can rebroadcast back if needed._broadcastSlideis only called bynextFrame/prevFrame(both gated bypresentationMode) and byannounceCurrentSlide/startPresentation(also gated). Audience'sboard_view.htmldoesn't have Prev/Next buttons.Files changed
crates/hero_whiteboard_ui/static/web/js/whiteboard/frames.js(+24/-1)_pendingRemoteSlidecache andannounceCurrentSlidepublic export.applyRemoteSlidecaches index when not in presentation mode instead of dropping it.startPresentationapplies any pending slide after the initial focus, otherwise broadcasts to advertise our presence.crates/hero_whiteboard_ui/static/web/js/whiteboard/sync.js(+5)joinhandler callsWhiteboardFrames.announceCurrentSlide()after presence update.Verification
cargo fmt --all -- --check: cleancargo check --workspace: cleannode --check frames.js,sync.js: cleanManual smoke test
Notes
startPresentationuntil we hear from a presenter, which adds startup latency and an unclear timeout policy. The current snap is one frame in practice.presentation.slideto arrive wins. No conflict resolution beyond that._pendingRemoteSlideis cleared afterstartPresentationconsumes it; subsequentapplyRemoteSlidecalls go through the normal path.