Skip to content

Add presenter notes feature#222

Open
see4tech wants to merge 65 commits intoClaperCo:devfrom
see4tech:claude/dreamy-easley
Open

Add presenter notes feature#222
see4tech wants to merge 65 commits intoClaperCo:devfrom
see4tech:claude/dreamy-easley

Conversation

@see4tech
Copy link
Copy Markdown

@see4tech see4tech commented Apr 6, 2026

Adds per-slide presenter notes to the event manager page. Notes are automatically extracted from PPTX speaker notes on upload (using SweetXml to parse notesSlide XML), stored in a new presenter_notes table, and displayed in an editable textarea in the manage view that auto-saves on blur. PDF and PPT uploads are unaffected.

aplicacionesitgpc and others added 30 commits April 6, 2026 17:21
Adds per-slide presenter notes to the event manager page. Notes are
automatically extracted from PPTX speaker notes on upload (using
SweetXml to parse notesSlide XML), stored in a new presenter_notes
table, and displayed in an editable textarea in the manage view that
auto-saves on blur. PDF and PPT uploads are unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the fixed-height notes textarea with a draggable Split layout
inside the interactions column. Notes start at 2/3 height and interactions
at 1/3, with a •••  gutter the presenter can drag to resize freely.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Switch the notes pane to a flex column layout so the textarea
stretches to fill all available height in the Split row pane,
rather than staying at a fixed intrinsic height.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the plain textarea with a Quill editor (Snow theme) supporting
bold, italic, underline, strikethrough, headings, font color/highlight,
ordered/unordered lists, and a clean-format button. Content is stored as
HTML. The editor auto-saves 800 ms after the user stops typing and updates
instantly when navigating slides via the PresenterNotes LiveView hook.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Move all Quill-managed DOM (toolbar + editor) inside a single
phx-update="ignore" wrapper so LiveView never patches it away.
Use Quill's modules.toolbar to point at the pre-rendered container.
Add explicit CMD/Ctrl keyboard bindings for bold, italic, underline,
and strikethrough. Add CSS overrides so the editor fills its flex pane.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace data-content attribute with a <script type="application/json">
  carrier element to avoid HTML-in-attribute encoding issues that caused
  the Unexpected token '&' JS syntax error
- Remove broken custom keyboard bindings (Quill 2.x has CMD+B/I/U built-in)
- Rewrite PPTX notes extractor to produce <p>-tagged HTML per paragraph
  instead of a flat space-joined string, preserving line breaks and
  paragraph structure when notes are imported from PPTX files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Root cause: save-note updated @current_note, re-rendering the <script>
carrier and triggering updated() while the user was still in the editor,
which reset Quill to the old (or empty) content.

Fix:
- Track slide position (data-position) in the hook; updated() only
  reloads the editor when the slide actually changes, never on a
  save-triggered re-render
- Remove @current_note update from save-note handler — content is
  already in the editor, pushing it back served no purpose

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Move save-note handle_event next to other handle_event clauses
- Replace deprecated <%# with <%!-- comment syntax in HEEx template
- Remove unused default argument values from jpg_upload/7 and success/6

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Root causes:
- Toolbar never showed because { container: emptyDiv } tells Quill to
  USE an existing toolbar, not CREATE one. Pass the options array instead
  so Quill builds the toolbar itself.
- Notes disappeared on slide change because the complex phx-update/script/
  updated() approach had race conditions with LiveView DOM patching.

New approach (canonical LiveView + third-party editor pattern):
- phx-update="ignore" on the entire hook element so LiveView never
  touches the Quill DOM (toolbar, editor, formatting) after mount
- data-initial-content for the first render (read once in mounted())
- push_event("load-note") from the server for slide navigation
- pushEvent("save-note") from the client for auto-save
- No updated() callback, no <script> carrier, no data-position tracking

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The "instructions to join" screen now appears as a left panel alongside
the presentation slides instead of covering the entire screen. QR code
sizing adapts to the panel width.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Reduced panel from w-2/5 to w-1/5, scaled down text sizes, and made
slides fill the full viewport height with object-contain scaling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add CSS rules targeting tiny-slider wrapper elements to ensure they
all fill 100vh. Slide images now use width/height 100% with
object-fit: contain so they scale proportionally with viewport size.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Use JS to calculate the correct wrapper height from the slide image's
natural aspect ratio and available width, capped at viewport height.
Both the join panel and slide inherit this height so they stay
proportional. Recalculates on resize, join-screen toggle, and
chat-visible toggle.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Calculate wrapper height from the slide's aspect ratio at full
available width, so the join panel doesn't reduce slide height.
QR code now caps at 40% of panel height to avoid overflow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The wrapper height now accounts for the join panel's 20% width so the
slide and panel heights match exactly. Chat toggle recalculates with
a delay to let the grid animation settle.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
fitSlideArea() now reads slidesDiv.clientWidth directly, which is
correct regardless of chat/join panel combination. All callers use
requestAnimationFrame or setTimeout to ensure measurements happen
after layout settles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
All panels (join, slides, chat) now fill the full viewport height.
Slide images use object-fit:contain within 100vh to preserve aspect
ratio. Removed fitSlideArea() JS — pure CSS handles everything.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…atio

JS now computes both width and height of the wrapper so the slide
fills it with zero wasted space. The join panel width is set
proportionally (20% of wrapper). The wrapper is centered with
margin:auto. Everything scales proportionally when the window resizes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Remove all JS fitSlideArea() sizing. Use CSS-only approach: grid uses
items-stretch + h-screen so all children fill viewport height. Slide
image uses max-width/max-height 100% with object-fit:contain. Chat,
join panel, and slides all fill the full viewport height naturally.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Restore original slide layout (grid with chat + slides). Join screen
is now an absolute-positioned overlay on the left 25% of the slide
area with semi-transparent white background and shadow. This avoids
all the proportional sizing issues of the side-by-side approach.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Join info displayed as a horizontal bar at the top with QR code on
the left and text on the right. Slides area uses flex-1 to fill
the remaining viewport height below the banner. Grid uses
items-stretch + h-screen so all columns fill the full viewport.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The join banner no longer stretches with the viewport. QR code is
a fixed 80px, banner max-height is 120px with flex-grow-0.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Reverted grid and slide structure to match the original codebase
exactly. Join screen is now an absolute-positioned banner at the top
of the slide area that floats over the slide without affecting layout.
Removed URL text from the banner — only shows QR code and event code.
Removed all custom tns CSS overrides.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Join screen is a flex-shrink-0 banner in normal document flow above
the slide — no overlay. Slide area uses flex-1 to fill remaining
height. Grid uses items-stretch so chat column matches the slides
column height. CSS propagates height through all tns wrappers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replaces the high-contrast black background with light gray (#f3f4f6)
so empty space around slides blends with typical white/light slide
backgrounds, matching Google Slides' presenter mode approach.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Remove #slide-content from CSS height chain so flex-1 works
  correctly and the slide centers vertically (not stuck at top)
- Change chat panel background from black to gray-100 to match
  the slide area background
- Add location=no,menubar=no,toolbar=no to presenter popup window

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The previous fullscreen code targeted the #presenter div and had
broken try/catch logic that silently swallowed errors. Now uses
document.documentElement for true browser fullscreen which hides
the address bar. Also reverts ineffective window.open params.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add justify-center to #slides container to vertically center
  the slide content instead of pinning it to the top
- Restore #slide-content in the CSS height chain so tns wrappers
  get proper height context for vertical centering
- Add the full join URL to the join info banner (QR + URL + code)
- Auto-enter fullscreen on first click to hide browser URL bar

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Safari shows the toolbar in fullscreen regardless — this is a
browser setting (View > Always Show Toolbar in Full Screen),
not controllable from JavaScript.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Use JS to calculate slide container height from the actual image
aspect ratio. Use CSS grid content-center to vertically center
the entire layout (join banner + slide + chat) in the viewport.
Chat column stretches to match via items-stretch. Recalculates
on window resize and join banner toggle.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
aplicacionesitgpc and others added 30 commits April 7, 2026 15:21
- Use fully qualified Claper.Presentations.PresentationState in
  reorder_slides/2 (alias is defined after the function)
- Fix '+' button: wrap slides strip and button in a flex container
  so the button appears at the end of the strip, not overlapping
  slides. Slides scroll in flex-1, button is flex-shrink-0.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The previous change broke the grid-rows-[0.3fr_10px_1fr] layout
by removing the 'contents' class from slide buttons. Restored
'contents' so images are direct flex children of #slides-layout,
preserving original thumbnail sizing. The '+' button sits in a
flex wrapper alongside the scroll container, occupying the first
grid row correctly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add overflow-hidden and min-h-0 to wrapper div so it respects the 0.3fr
grid row height, and max-h-full to images to constrain them within bounds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The raw <a data-phx-link="patch"> was not triggering LiveView navigation.
Replaced with the proper <.link patch={...}> component.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The live_patch link wasn't triggering. Replaced with a phx-click event
handler that directly assigns the slide creation state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Move add-slide button out of thumbnail area into interaction modal
  (option-5 "Slide image") using phx-click instead of link navigation
- Always insert new slides at the end (remove position selector)
- Remove phx-update="ignore" from slides container so LiveView can
  update thumbnails after reorder/insert
- Fix drag-and-drop: use div wrappers instead of button+contents,
  add updated() callback to re-bind events after DOM patches
- Remove push_navigate from save-slide and reorder-slides handlers
  to avoid full page reloads that reset the UI
- Replace &times; with × to avoid HTML entity parse issues

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The add-slide button is now a visible dashed-border + icon at the end
of the slide thumbnails, inside the same container (no phx-update=ignore
so phx-click works).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Phoenix removes uploaded temp files after consume_uploaded_entries
returns. Copy to a stable temp path first so insert_slide can read it.
Also close modal on all error paths and add error logging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The previous implementation only worked with local filesystem storage.
When running with S3, local files don't exist (deleted after upload),
causing File.cp! to fail with :enoent.

Refactored to use storage-agnostic helpers:
- copy_slide_to_new_hash: uploads a local file to the correct storage
- copy_slide_between_hashes: copies between storage locations (S3 copy
  or local File.cp)
- clear_slide_hash: removes old hash directory/prefix from storage

Also fixed Range warning when appending at end (file_insert_index > length).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Logs storage mode, paths, directory existence, and file listing to
help identify why File.cp fails with :enoent.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…e-render

The JS hook was manually reordering DOM elements and then LiveView also
re-rendered, causing duplicate/misplaced thumbnails.

Now the hook only calculates the new order array (splice-based) and
pushes it to the server. LiveView handles the DOM update.

Added visual feedback: purple border indicator shows where the slide
will be inserted during drag. Cleaned up all visual states on dragend.

Also removed debug logging from insert_slide.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- New delete_slide/2 in Presentations context: copies files to new hash
  skipping the deleted slide, removes interactions at that position,
  shifts remaining positions down, fixes presentation state
- Event handler in manage.ex with confirmation dialog
- Red X button appears on thumbnail hover (hidden for last slide)
- Supports both local and S3 storage via existing helpers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The guard `when pf.length > 1` doesn't work reliably with struct field
access. Changed to pattern match `%PresentationFile{length: length}`
and guard on the bound variable instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
LiveView DOM patches reset the scroll position of the slides container.
Added beforeUpdate/updated hooks on SortableSlides to save and restore
scrollLeft across patches.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Reorder and delete now rename files within the same directory instead
of copying all files to a new hash. This is instant on local filesystem
and avoids forcing the browser to re-download all slide images.

- reorder_slides: two-pass rename (to temp names, then final positions)
- delete_slide: remove file, shift subsequent files down by 1
- No hash change means same URLs, so added ?v= cache buster using
  updated_at timestamp on thumbnail images
- Removed push_event("page-manage") from reorder handler to prevent
  unwanted scroll reset

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Replace File.rename! with File.rename + :ok pattern match (Elixir
  has no bang version of File.rename)
- Fix scroll calculation: use el.offsetLeft/offsetWidth instead of
  el.children[0] which returns 0 due to position:relative on parent

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The in-place rename approach corrupted files when it failed mid-way
(File.rename! doesn't exist in Elixir, causing partial renames to
tmp_ prefixes). Reverted to the same copy-to-new-hash pattern used
by insert_slide, which is safe: old files are only deleted on success,
new hash is cleaned up on failure.

Removed broken delete_slide_file and reorder_slide_files helpers.
Removed ?v= cache buster (new hash already busts browser cache).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The join info panel was inside the slide area as a flex child, causing
it to overlap with poll/quiz interaction overlays. Moved it to an
absolute-positioned floating panel in the top-left corner of the
presenter with z-50 (above z-30 interaction overlays). Compact design
with semi-transparent background and blur.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The embed wrapper was constrained to lg:w-1/3 (1/3 of screen width),
making custom iframes like LLM UIs unusable. Changed to full-width
with top-16/bottom-0 to fill the available viewport below the header.
Propagated h-full and flex-col through the component chain so the
iframe gets maximum space.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The #slides and #slide-content divs had no explicit height, so the
iframe (which uses height:100%) collapsed. Added h-full to #slides
and flex-1 min-h-0 to #slide-content so the embed fills the grid cell.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add scoped iframe CSS (width/height 100%) to EmbedIframeComponent so it
applies in both views, add h-full to extended-embed and slider-wrapper
to complete the height propagation chain.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Three issues caused the "crazy" slide movement:
1. update() recreated tns slider without destroying the old instance,
   leaving ghost DOM elements and event listeners
2. update() never read the new currentPage from DOM data attributes,
   so the new slider was created at the stale position then goTo'd
3. Both storage and page events fired goTo() for the same navigation,
   causing redundant transitions

Now update() checks if only the page changed (same hash/maxPage) and
uses goTo() directly instead of rebuilding. When structural changes
occur (slides added/removed), old slider is destroyed before rebuild.
Storage event is suppressed when page event already handled it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The keydown handler only checked for <input> tags, but the Quill rich
text editor uses a contenteditable <div>. Now also skips textarea,
select, and any contenteditable element so arrow keys work normally
inside the notes editor.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The presenter window dispatches synthetic KeyboardEvent to window.opener,
where e.target is the window object (not a DOM element). Calling
e.target.closest() on window throws because closest is an Element method.
Add a guard check before calling closest().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
LiveView DOM patching corrupts tiny-slider's internal wrapper elements,
making slider.goTo() fail silently. Restore always-rebuild behavior on
update() but now properly destroying the old slider first and reading
updated data attributes from the DOM before reinitializing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
tns.destroy() strips internal DOM wrappers, and reinitializing on
LiveView-patched DOM fails. Revert to original behavior of simply
overwriting the slider reference while keeping the fix that reads
updated data attributes before rebuilding.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Manager update() runs on every LiveView re-render, including when
opening modals. It was unconditionally scrolling the thumbnail strip,
causing a visible jump-and-scroll-back. Now only scrolls when the
page position actually changed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The duplicate_event function copied polls, forms, embeds, and quizzes
but skipped presenter notes. Add duplicate_presenter_notes step to
the Ecto.Multi chain that copies all notes from the original
presentation file to the new one.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants