diff --git a/assets/css/app.css b/assets/css/app.css index b3d3e4d8..e2bf5b6f 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -1,5 +1,19 @@ @import 'air-datepicker/air-datepicker.css'; @import 'animate.css/animate.min.css'; +@import 'quill/dist/quill.snow.css'; + +/* Presenter notes – make the Quill container fill its flex/grid pane */ +#presenter-notes-editor .ql-toolbar { + flex-shrink: 0; + border-left: none; + border-right: none; +} +#presenter-notes-editor .ql-container { + flex: 1; + min-height: 0; + overflow-y: auto; + font-size: 0.875rem; +} @import 'tailwindcss'; @import './theme.css' layer(theme); diff --git a/assets/js/app.js b/assets/js/app.js index fdf61dd0..b4e73003 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -1,5 +1,6 @@ // Include phoenix_html to handle method=PUT/DELETE in forms and buttons. import "phoenix_html"; +import PresenterNotes from "./presenter_notes"; // Establish Phoenix Socket and LiveView configuration. import { Socket, Presence } from "phoenix"; import { LiveSocket } from "phoenix_live_view"; @@ -568,17 +569,24 @@ Hooks.QRCode = { "/e/" + this.el.dataset.code : window.location.href; - this.el.style.width = document.documentElement.clientWidth * 0.27 + "px"; - this.el.style.height = document.documentElement.clientWidth * 0.27 + "px"; + + var qrSize; + if (this.el.dataset.panel) { + // Top banner mode: fixed small QR code + qrSize = 80; + } else if (this.el.dataset.dynamic) { + qrSize = document.documentElement.clientWidth * 0.25; + } else { + qrSize = 240; + } + + this.el.style.width = (qrSize * 1.08) + "px"; + this.el.style.height = (qrSize * 1.08) + "px"; if (this.qrCode == null) { this.qrCode = new QRCodeStyling({ - width: this.el.dataset.dynamic - ? document.documentElement.clientWidth * 0.25 - : 240, - height: this.el.dataset.dynamic - ? document.documentElement.clientWidth * 0.25 - : 240, + width: qrSize, + height: qrSize, margin: 0, data: url, cornersSquareOptions: { @@ -600,12 +608,8 @@ Hooks.QRCode = { this.qrCode.append(this.el); } else { this.qrCode.update({ - width: this.el.dataset.dynamic - ? document.documentElement.clientWidth * 0.25 - : 240, - height: this.el.dataset.dynamic - ? document.documentElement.clientWidth * 0.25 - : 240, + width: qrSize, + height: qrSize, }); } }, @@ -682,6 +686,103 @@ Hooks.CSVDownloader = { } }; +Hooks.PresenterNotes = PresenterNotes; + +Hooks.SortableSlides = { + beforeUpdate() { + // Preserve scroll position before LiveView patches the DOM + this._scrollLeft = this.el.scrollLeft; + }, + updated() { + // Restore scroll position after LiveView patches the DOM + if (this._scrollLeft !== undefined) { + this.el.scrollLeft = this._scrollLeft; + } + // Re-bind drag events + this.mounted(); + }, + mounted() { + this.dragSrcIdx = null; + const container = this.el; + + container.ondragstart = (e) => { + const item = e.target.closest("[data-slide-index]"); + if (!item) return; + this.dragSrcIdx = parseInt(item.dataset.slideIndex); + item.style.opacity = "0.3"; + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", item.dataset.slideIndex); + }; + + container.ondragover = (e) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + // Highlight drop target + const target = e.target.closest("[data-slide-index]"); + container.querySelectorAll("[data-slide-index]").forEach(el => { + el.style.borderLeft = ""; + el.style.borderRight = ""; + }); + if (target && parseInt(target.dataset.slideIndex) !== this.dragSrcIdx) { + const items = Array.from(container.querySelectorAll("[data-slide-index]")); + const srcPos = items.findIndex(el => parseInt(el.dataset.slideIndex) === this.dragSrcIdx); + const tgtPos = items.indexOf(target); + if (tgtPos > srcPos) { + target.style.borderRight = "3px solid #7c3aed"; + } else { + target.style.borderLeft = "3px solid #7c3aed"; + } + } + }; + + container.ondragleave = (e) => { + const target = e.target.closest("[data-slide-index]"); + if (target) { + target.style.borderLeft = ""; + target.style.borderRight = ""; + } + }; + + container.ondragend = (e) => { + // Clear all visual indicators + container.querySelectorAll("[data-slide-index]").forEach(el => { + el.style.opacity = ""; + el.style.borderLeft = ""; + el.style.borderRight = ""; + }); + this.dragSrcIdx = null; + }; + + container.ondrop = (e) => { + e.preventDefault(); + const target = e.target.closest("[data-slide-index]"); + if (!target || this.dragSrcIdx === null) return; + const targetIdx = parseInt(target.dataset.slideIndex); + if (targetIdx === this.dragSrcIdx) return; + + // Build new order: take current order, move dragSrc to target position + const items = Array.from(container.querySelectorAll("[data-slide-index]")); + const currentOrder = items.map(el => parseInt(el.dataset.slideIndex)); + const fromPos = currentOrder.indexOf(this.dragSrcIdx); + const toPos = currentOrder.indexOf(targetIdx); + + // Remove from old position, insert at new position + currentOrder.splice(fromPos, 1); + currentOrder.splice(toPos, 0, this.dragSrcIdx); + + // Clear visuals + container.querySelectorAll("[data-slide-index]").forEach(el => { + el.style.opacity = ""; + el.style.borderLeft = ""; + el.style.borderRight = ""; + }); + + this.dragSrcIdx = null; + this.pushEvent("reorder-slides", { order: currentOrder }); + }; + } +}; + // Merge our custom hooks with the existing hooks Object.assign(Hooks, CustomHooks); diff --git a/assets/js/manager.js b/assets/js/manager.js index 0060bcce..43f20bf6 100644 --- a/assets/js/manager.js +++ b/assets/js/manager.js @@ -17,10 +17,11 @@ export class Manager { setTimeout( () => { const slidesLayout = document.getElementById("slides-layout"); + if (!slidesLayout) return; const layoutWidth = slidesLayout.clientWidth; - const elementWidth = el.children[0].scrollWidth; + const elementWidth = el.offsetWidth; const scrollPosition = - el.children[0].offsetLeft - layoutWidth / 2 + elementWidth / 2; + el.offsetLeft - layoutWidth / 2 + elementWidth / 2; slidesLayout.scrollTo({ left: scrollPosition, @@ -32,18 +33,23 @@ export class Manager { }); window.addEventListener("keydown", (e) => { - if ((e.target.tagName || "").toLowerCase() != "input") { - - switch (e.key) { - case "ArrowLeft": - e.preventDefault(); - this.prevPage(); - break; - case "ArrowRight": - e.preventDefault(); - this.nextPage(); - break; - } + const tag = (e.target.tagName || "").toLowerCase(); + // Don't navigate slides when focus is in an input, textarea, + // contenteditable element (e.g. Quill presenter notes), or select. + if (tag === "input" || tag === "textarea" || tag === "select" || + e.target.isContentEditable || (e.target.closest && e.target.closest(".ql-editor"))) { + return; + } + + switch (e.key) { + case "ArrowLeft": + e.preventDefault(); + this.prevPage(); + break; + case "ArrowRight": + e.preventDefault(); + this.nextPage(); + break; } }); @@ -140,22 +146,29 @@ export class Manager { } update() { + const prevPage = this.currentPage; this.currentPage = parseInt(this.context.el.dataset.currentPage); - var el = document.getElementById("slide-preview-" + this.currentPage); - - if (el) { - setTimeout(() => { - const slidesLayout = document.getElementById("slides-layout"); - const layoutWidth = slidesLayout.clientWidth; - const elementWidth = el.children[0].scrollWidth; - const scrollPosition = - el.children[0].offsetLeft - layoutWidth / 2 + elementWidth / 2; - - slidesLayout.scrollTo({ - left: scrollPosition, - behavior: "smooth", - }); - }, 50); + + // Only scroll thumbnails when the slide actually changed, + // not on every LiveView re-render (e.g. opening a modal). + if (this.currentPage !== prevPage) { + var el = document.getElementById("slide-preview-" + this.currentPage); + + if (el) { + setTimeout(() => { + const slidesLayout = document.getElementById("slides-layout"); + if (!slidesLayout) return; + const layoutWidth = slidesLayout.clientWidth; + const elementWidth = el.offsetWidth; + const scrollPosition = + el.offsetLeft - layoutWidth / 2 + elementWidth / 2; + + slidesLayout.scrollTo({ + left: scrollPosition, + behavior: "smooth", + }); + }, 50); + } } this.initPreview(); diff --git a/assets/js/presenter.js b/assets/js/presenter.js index 53b77046..3d12e88f 100644 --- a/assets/js/presenter.js +++ b/assets/js/presenter.js @@ -24,10 +24,15 @@ export class Presenter { nav: false, }); + // Size slide container from actual image aspect ratio + this.fitSlideHeight(); + if (refresh) { return; } + window.addEventListener("resize", () => this.fitSlideHeight()); + this.context.handleEvent("page", (data) => { //set current page if (this.currentPage == data.current_page) { @@ -35,8 +40,8 @@ export class Presenter { } this.currentPage = parseInt(data.current_page); - this.slider.goTo(data.current_page); - + this._lastPageEventAt = Date.now(); + if (this.slider) this.slider.goTo(data.current_page); }); this.context.handleEvent("chat-visible", (data) => { @@ -90,21 +95,17 @@ export class Presenter { }); this.context.handleEvent("join-screen-visible", (data) => { + const joinScreen = document.getElementById("joinScreen"); + if (!joinScreen) return; if (data.value) { - document - .getElementById("joinScreen") - .classList.remove("animate__animated", "animate__fadeOut"); - document - .getElementById("joinScreen") - .classList.add("animate__animated", "animate__fadeIn"); + joinScreen.classList.remove("hidden"); + joinScreen.classList.add("flex"); } else { - document - .getElementById("joinScreen") - .classList.remove("animate__animated", "animate__fadeIn"); - document - .getElementById("joinScreen") - .classList.add("animate__animated", "animate__fadeOut"); + joinScreen.classList.remove("flex"); + joinScreen.classList.add("hidden"); } + // Recalculate slide height after join banner toggle + requestAnimationFrame(() => this.fitSlideHeight()); }); window.addEventListener("keyup", (e) => { @@ -132,38 +133,64 @@ export class Presenter { }); window.addEventListener("storage", (e) => { - console.log(e) if (e.key == "slide-position") { - console.log("settings new value " + Date.now()) - this.currentPage = parseInt(e.newValue); - this.slider.goTo(e.newValue); - + // Skip if the server "page" event already handled this change recently + if (this._lastPageEventAt && (Date.now() - this._lastPageEventAt) < 500) { + return; + } + const newPage = parseInt(e.newValue); + if (this.currentPage === newPage) return; + this.currentPage = newPage; + if (this.slider) this.slider.goTo(newPage); } }) } update() { + // Always read updated values from the DOM before reinitializing. + // LiveView DOM-patching can corrupt tns's internal wrapper elements, + // so we must rebuild the slider on every update (not just goTo). + this.currentPage = parseInt(this.context.el.dataset.currentPage); + this.maxPage = parseInt(this.context.el.dataset.maxPage); + this.hash = this.context.el.dataset.hash; this.init(true); } + fitSlideHeight() { + const slideContent = document.getElementById("slide-content"); + if (!slideContent) return; + + const img = document.querySelector("#slider .tns-item img"); + if (!img) return; + + const apply = () => { + if (!img.naturalWidth || !img.naturalHeight) return; + const ratio = img.naturalHeight / img.naturalWidth; + const width = slideContent.clientWidth; + const joinScreen = document.getElementById("joinScreen"); + const bannerH = joinScreen && !joinScreen.classList.contains("hidden") + ? joinScreen.offsetHeight : 0; + const maxH = window.innerHeight - bannerH; + const height = Math.min(width * ratio, maxH); + slideContent.style.height = height + "px"; + }; + + if (img.complete && img.naturalWidth) { + apply(); + } else { + img.addEventListener("load", apply, { once: true }); + } + } + fullscreen() { - var docEl = document.getElementById("presenter"); - - try { - docEl - .webkitRequestFullscreen() - .then(function () {}) - .catch(function (error) {}); - } catch (e) { - docEl - .requestFullscreen() - .then(function () {}) - .catch(function (error) {}); - - docEl - .mozRequestFullScreen() - .then(function () {}) - .catch(function (error) {}); + var docEl = document.documentElement; + + if (docEl.requestFullscreen) { + docEl.requestFullscreen(); + } else if (docEl.webkitRequestFullscreen) { + docEl.webkitRequestFullscreen(); + } else if (docEl.mozRequestFullScreen) { + docEl.mozRequestFullScreen(); } } } diff --git a/assets/js/presenter_notes.js b/assets/js/presenter_notes.js new file mode 100644 index 00000000..ca8054e3 --- /dev/null +++ b/assets/js/presenter_notes.js @@ -0,0 +1,56 @@ +import Quill from "quill"; + +const TOOLBAR_OPTIONS = [ + [{ header: [1, 2, 3, false] }], + ["bold", "italic", "underline", "strike"], + [{ color: [] }, { background: [] }], + [{ list: "ordered" }, { list: "bullet" }], + ["clean"], +]; + +const EMPTY_HTML = "


"; + +export default { + mounted() { + const editorEl = this.el.querySelector("#quill-editor-target"); + const placeholder = this.el.dataset.placeholder || ""; + + // Let Quill CREATE its own toolbar from the options array. + // The entire hook element has phx-update="ignore", so LiveView + // will never touch the toolbar or editor DOM. + this.quill = new Quill(editorEl, { + theme: "snow", + placeholder, + modules: { toolbar: TOOLBAR_OPTIONS }, + }); + + // Load initial content (set once via data attribute on first render). + try { + const initial = JSON.parse(this.el.dataset.initialContent || '""'); + if (initial) { + this.quill.clipboard.dangerouslyPasteHTML(initial); + } + } catch (_e) { /* ignore parse errors */ } + + // Server pushes new content when the presenter navigates slides. + this.handleEvent("load-note", ({ content }) => { + this.quill.clipboard.dangerouslyPasteHTML(content || ""); + }); + + // Auto-save: debounce 800 ms after the user stops typing. + let timer; + this.quill.on("text-change", (_delta, _old, source) => { + if (source !== "user") return; + clearTimeout(timer); + timer = setTimeout(() => { + const html = this.el.querySelector(".ql-editor")?.innerHTML ?? EMPTY_HTML; + const content = html === EMPTY_HTML ? "" : html; + this.pushEvent("save-note", { content }); + }, 800); + }); + }, + + destroyed() { + this.quill = null; + }, +}; diff --git a/assets/package.json b/assets/package.json index 3fe714f6..58343f78 100644 --- a/assets/package.json +++ b/assets/package.json @@ -21,6 +21,7 @@ "phoenix": "file:../deps/phoenix", "phoenix_html": "file:../deps/phoenix_html", "phoenix_live_view": "file:../deps/phoenix_live_view", + "quill": "^2.0.3", "qr-code-styling": "^1.6.0-rc.1", "split-grid": "^1.0.11", "split.js": "^1.6.5", diff --git a/lib/claper/events.ex b/lib/claper/events.ex index 45ad2e66..38374332 100644 --- a/lib/claper/events.ex +++ b/lib/claper/events.ex @@ -585,6 +585,9 @@ defmodule Claper.Events do |> Ecto.Multi.run(:forms, fn _repo, changes -> duplicate_forms(original, changes) end) |> Ecto.Multi.run(:embeds, fn _repo, changes -> duplicate_embeds(original, changes) end) |> Ecto.Multi.run(:quizzes, fn _repo, changes -> duplicate_quizzes(original, changes) end) + |> Ecto.Multi.run(:presenter_notes, fn _repo, changes -> + duplicate_presenter_notes(original, changes) + end) case Repo.transaction(multi) do {:ok, %{event: event}} -> {:ok, event} @@ -751,6 +754,32 @@ defmodule Claper.Events do |> Map.put(:response_count, 0) end + defp duplicate_presenter_notes(original, changes) do + notes = + Repo.all( + from n in Presentations.PresenterNote, + where: n.presentation_file_id == ^original.presentation_file.id + ) + + new_notes = + for note <- notes do + attrs = %{ + slide_position: note.slide_position, + content: note.content, + presentation_file_id: changes.presentation_file.id + } + + {:ok, new_note} = + %Presentations.PresenterNote{} + |> Presentations.PresenterNote.changeset(attrs) + |> Repo.insert() + + new_note + end + + {:ok, new_notes} + end + @doc """ Deletes a event. diff --git a/lib/claper/presentations.ex b/lib/claper/presentations.ex index c323ea1d..12b335ec 100644 --- a/lib/claper/presentations.ex +++ b/lib/claper/presentations.ex @@ -7,6 +7,7 @@ defmodule Claper.Presentations do alias Claper.Repo alias Claper.Presentations.PresentationFile + alias Claper.Presentations.PresenterNote @doc """ Gets a single presentation_files. @@ -130,6 +131,360 @@ defmodule Claper.Presentations do |> Repo.update() end + @doc """ + Inserts a slide image at the given 0-based position. + Renumbers existing files, updates length, and shifts interaction positions. + Returns {:ok, updated_presentation_file} or {:error, reason}. + """ + def insert_slide(%PresentationFile{} = pf, insert_position, image_path) do + new_hash = "#{:erlang.phash2("#{pf.hash}-#{System.system_time(:second)}")}" + file_insert_index = insert_position + 1 + + try do + # Copy existing slides and insert the new one + copy_slide_to_new_hash(image_path, new_hash, file_insert_index) + + if file_insert_index > 1 do + for i <- 1..(file_insert_index - 1) do + copy_slide_between_hashes(pf.hash, i, new_hash, i) + end + end + + if file_insert_index <= pf.length do + for i <- file_insert_index..pf.length do + copy_slide_between_hashes(pf.hash, i, new_hash, i + 1) + end + end + + multi = + Ecto.Multi.new() + |> Ecto.Multi.update( + :presentation_file, + PresentationFile.changeset(pf, %{hash: new_hash, length: pf.length + 1}) + ) + |> Ecto.Multi.run(:shift_polls, fn _repo, _changes -> + shift_positions(Claper.Polls.Poll, :position, pf.id, insert_position) + end) + |> Ecto.Multi.run(:shift_forms, fn _repo, _changes -> + shift_positions(Claper.Forms.Form, :position, pf.id, insert_position) + end) + |> Ecto.Multi.run(:shift_embeds, fn _repo, _changes -> + shift_positions(Claper.Embeds.Embed, :position, pf.id, insert_position) + end) + |> Ecto.Multi.run(:shift_quizzes, fn _repo, _changes -> + shift_positions(Claper.Quizzes.Quiz, :position, pf.id, insert_position) + end) + |> Ecto.Multi.run(:shift_notes, fn _repo, _changes -> + shift_positions(PresenterNote, :slide_position, pf.id, insert_position) + end) + + case Repo.transaction(multi) do + {:ok, %{presentation_file: updated_pf}} -> + clear_slide_hash(pf.hash) + {:ok, updated_pf} + + {:error, _step, changeset, _changes} -> + clear_slide_hash(new_hash) + {:error, changeset} + end + rescue + e -> + clear_slide_hash(new_hash) + {:error, e} + end + end + + defp shift_positions(schema, field, presentation_file_id, insert_position) do + from(s in schema, + where: + s.presentation_file_id == ^presentation_file_id and field(s, ^field) >= ^insert_position + ) + |> Repo.update_all(inc: [{field, 1}]) + + {:ok, :shifted} + end + + @doc """ + Deletes a slide at the given 0-based position. + Copies remaining files to a new hash, updates length, shifts interaction positions down, + and deletes any interactions that were on the removed slide. + """ + def delete_slide(%PresentationFile{length: length} = pf, delete_position) when length > 1 do + new_hash = "#{:erlang.phash2("#{pf.hash}-#{System.system_time(:second)}")}" + file_delete_index = delete_position + 1 + + try do + # Copy files before the deleted slide + if file_delete_index > 1 do + for i <- 1..(file_delete_index - 1) do + copy_slide_between_hashes(pf.hash, i, new_hash, i) + end + end + + # Copy files after the deleted slide, shifted down by 1 + if file_delete_index < pf.length do + for i <- (file_delete_index + 1)..pf.length do + copy_slide_between_hashes(pf.hash, i, new_hash, i - 1) + end + end + + multi = + Ecto.Multi.new() + |> Ecto.Multi.update( + :presentation_file, + PresentationFile.changeset(pf, %{hash: new_hash, length: pf.length - 1}) + ) + |> Ecto.Multi.run(:delete_polls, fn _repo, _changes -> + delete_at_position(Claper.Polls.Poll, :position, pf.id, delete_position) + end) + |> Ecto.Multi.run(:delete_forms, fn _repo, _changes -> + delete_at_position(Claper.Forms.Form, :position, pf.id, delete_position) + end) + |> Ecto.Multi.run(:delete_embeds, fn _repo, _changes -> + delete_at_position(Claper.Embeds.Embed, :position, pf.id, delete_position) + end) + |> Ecto.Multi.run(:delete_quizzes, fn _repo, _changes -> + delete_at_position(Claper.Quizzes.Quiz, :position, pf.id, delete_position) + end) + |> Ecto.Multi.run(:delete_notes, fn _repo, _changes -> + delete_at_position(PresenterNote, :slide_position, pf.id, delete_position) + end) + |> Ecto.Multi.run(:unshift_polls, fn _repo, _changes -> + unshift_positions(Claper.Polls.Poll, :position, pf.id, delete_position) + end) + |> Ecto.Multi.run(:unshift_forms, fn _repo, _changes -> + unshift_positions(Claper.Forms.Form, :position, pf.id, delete_position) + end) + |> Ecto.Multi.run(:unshift_embeds, fn _repo, _changes -> + unshift_positions(Claper.Embeds.Embed, :position, pf.id, delete_position) + end) + |> Ecto.Multi.run(:unshift_quizzes, fn _repo, _changes -> + unshift_positions(Claper.Quizzes.Quiz, :position, pf.id, delete_position) + end) + |> Ecto.Multi.run(:unshift_notes, fn _repo, _changes -> + unshift_positions(PresenterNote, :slide_position, pf.id, delete_position) + end) + |> Ecto.Multi.run(:fix_state, fn _repo, _changes -> + state = Repo.get_by(Claper.Presentations.PresentationState, presentation_file_id: pf.id) + + cond do + is_nil(state) -> + {:ok, nil} + + state.position == delete_position && pf.length > 1 -> + new_pos = max(0, delete_position - 1) + + state + |> Claper.Presentations.PresentationState.changeset(%{position: new_pos}) + |> Repo.update() + + state.position > delete_position -> + state + |> Claper.Presentations.PresentationState.changeset(%{position: state.position - 1}) + |> Repo.update() + + true -> + {:ok, state} + end + end) + + case Repo.transaction(multi) do + {:ok, %{presentation_file: updated_pf}} -> + clear_slide_hash(pf.hash) + {:ok, updated_pf} + + {:error, _step, changeset, _changes} -> + clear_slide_hash(new_hash) + {:error, changeset} + end + rescue + e -> + clear_slide_hash(new_hash) + {:error, e} + end + end + + def delete_slide(_pf, _position), do: {:error, :cannot_delete_last_slide} + + defp delete_at_position(schema, field, presentation_file_id, position) do + from(s in schema, + where: s.presentation_file_id == ^presentation_file_id and field(s, ^field) == ^position + ) + |> Repo.delete_all() + + {:ok, :deleted} + end + + defp unshift_positions(schema, field, presentation_file_id, deleted_position) do + from(s in schema, + where: + s.presentation_file_id == ^presentation_file_id and field(s, ^field) > ^deleted_position + ) + |> Repo.update_all(inc: [{field, -1}]) + + {:ok, :shifted} + end + + @doc """ + Reorders slides according to the given order list. + `new_order` is a list of 0-based old indices in the desired new order. + E.g., [2, 0, 1] means: new slide 0 = old slide 2, new slide 1 = old slide 0, new slide 2 = old slide 1. + """ + def reorder_slides(%PresentationFile{} = pf, new_order) when is_list(new_order) do + expected = Enum.sort(0..(pf.length - 1) |> Enum.to_list()) + + if Enum.sort(new_order) != expected do + {:error, :invalid_order} + else + new_hash = "#{:erlang.phash2("#{pf.hash}-#{System.system_time(:second)}")}" + + position_map = + new_order + |> Enum.with_index() + |> Map.new() + + try do + for {old_idx, new_idx} <- position_map do + copy_slide_between_hashes(pf.hash, old_idx + 1, new_hash, new_idx + 1) + end + + multi = + Ecto.Multi.new() + |> Ecto.Multi.update( + :presentation_file, + PresentationFile.changeset(pf, %{hash: new_hash}) + ) + |> Ecto.Multi.run(:remap_polls, fn _repo, _changes -> + remap_positions(Claper.Polls.Poll, :position, pf.id, position_map) + end) + |> Ecto.Multi.run(:remap_forms, fn _repo, _changes -> + remap_positions(Claper.Forms.Form, :position, pf.id, position_map) + end) + |> Ecto.Multi.run(:remap_embeds, fn _repo, _changes -> + remap_positions(Claper.Embeds.Embed, :position, pf.id, position_map) + end) + |> Ecto.Multi.run(:remap_quizzes, fn _repo, _changes -> + remap_positions(Claper.Quizzes.Quiz, :position, pf.id, position_map) + end) + |> Ecto.Multi.run(:remap_notes, fn _repo, _changes -> + remap_positions(PresenterNote, :slide_position, pf.id, position_map) + end) + |> Ecto.Multi.run(:remap_state, fn _repo, _changes -> + state = + Repo.get_by(Claper.Presentations.PresentationState, presentation_file_id: pf.id) + + if state && Map.has_key?(position_map, state.position) do + state + |> Claper.Presentations.PresentationState.changeset(%{ + position: position_map[state.position] + }) + |> Repo.update() + else + {:ok, state} + end + end) + + case Repo.transaction(multi) do + {:ok, %{presentation_file: updated_pf}} -> + clear_slide_hash(pf.hash) + {:ok, updated_pf} + + {:error, _step, changeset, _changes} -> + clear_slide_hash(new_hash) + {:error, changeset} + end + rescue + e -> + clear_slide_hash(new_hash) + {:error, e} + end + end + end + + defp remap_positions(schema, field, presentation_file_id, position_map) do + Enum.each(position_map, fn {old_pos, new_pos} -> + from(s in schema, + where: s.presentation_file_id == ^presentation_file_id and field(s, ^field) == ^old_pos + ) + |> Repo.update_all(set: [{field, -(new_pos + 1)}]) + end) + + Enum.each(0..(map_size(position_map) - 1), fn new_pos -> + neg_val = -(new_pos + 1) + + from(s in schema, + where: s.presentation_file_id == ^presentation_file_id and field(s, ^field) == ^neg_val + ) + |> Repo.update_all(set: [{field, new_pos}]) + end) + + {:ok, :remapped} + end + + # Storage-agnostic helpers for slide file operations + + defp presentation_storage do + Application.get_env(:claper, :presentations) |> Keyword.get(:storage) + end + + defp s3_bucket do + Application.get_env(:claper, :presentations) |> Keyword.get(:s3_bucket) + end + + defp copy_slide_to_new_hash(local_image_path, new_hash, dest_index) do + case presentation_storage() do + "local" -> + storage_dir = Application.get_env(:claper, :storage_dir, "priv/static") + new_dir = Path.join([storage_dir, "uploads", new_hash]) + File.mkdir_p!(new_dir) + File.cp!(local_image_path, Path.join(new_dir, "#{dest_index}.jpg")) + + "s3" -> + local_image_path + |> ExAws.S3.Upload.stream_file() + |> ExAws.S3.upload(s3_bucket(), "presentations/#{new_hash}/#{dest_index}.jpg", + acl: "public-read" + ) + |> ExAws.request!() + end + end + + defp copy_slide_between_hashes(old_hash, old_index, new_hash, new_index) do + case presentation_storage() do + "local" -> + storage_dir = Application.get_env(:claper, :storage_dir, "priv/static") + old_path = Path.join([storage_dir, "uploads", old_hash, "#{old_index}.jpg"]) + new_dir = Path.join([storage_dir, "uploads", new_hash]) + File.mkdir_p!(new_dir) + File.cp!(old_path, Path.join(new_dir, "#{new_index}.jpg")) + + "s3" -> + ExAws.S3.put_object_copy( + s3_bucket(), + "presentations/#{new_hash}/#{new_index}.jpg", + s3_bucket(), + "presentations/#{old_hash}/#{old_index}.jpg" + ) + |> ExAws.request!() + end + end + + defp clear_slide_hash(hash) do + case presentation_storage() do + "local" -> + storage_dir = Application.get_env(:claper, :storage_dir, "priv/static") + File.rm_rf!(Path.join([storage_dir, "uploads", hash])) + + "s3" -> + stream = + ExAws.S3.list_objects(s3_bucket(), prefix: "presentations/#{hash}") + |> ExAws.stream!() + |> Stream.map(& &1.key) + + ExAws.S3.delete_all_objects(s3_bucket(), stream) |> ExAws.request() + end + end + def subscribe(presentation_file_id) do Phoenix.PubSub.subscribe(Claper.PubSub, "presentation:#{presentation_file_id}") end @@ -173,6 +528,38 @@ defmodule Claper.Presentations do |> broadcast(:state_updated) end + @doc """ + Returns the content of the presenter note for a given slide position, + or an empty string if no note exists. + """ + def get_note_at_position(presentation_file_id, position) do + case Repo.get_by(PresenterNote, + presentation_file_id: presentation_file_id, + slide_position: position + ) do + nil -> "" + note -> note.content || "" + end + end + + @doc """ + Inserts or updates the presenter note for a given slide position. + """ + def upsert_note(presentation_file_id, position, content) do + now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) + + %PresenterNote{} + |> PresenterNote.changeset(%{ + presentation_file_id: presentation_file_id, + slide_position: position, + content: content + }) + |> Repo.insert( + on_conflict: [set: [content: content, updated_at: now]], + conflict_target: [:presentation_file_id, :slide_position] + ) + end + defp broadcast({:error, _reason} = error, _state), do: error defp broadcast({:ok, state}, event) do diff --git a/lib/claper/presentations/presenter_note.ex b/lib/claper/presentations/presenter_note.ex new file mode 100644 index 00000000..2ea68c1b --- /dev/null +++ b/lib/claper/presentations/presenter_note.ex @@ -0,0 +1,20 @@ +defmodule Claper.Presentations.PresenterNote do + use Ecto.Schema + import Ecto.Changeset + + schema "presenter_notes" do + field :slide_position, :integer + field :content, :string, default: "" + + belongs_to :presentation_file, Claper.Presentations.PresentationFile + + timestamps() + end + + @doc false + def changeset(note, attrs) do + note + |> cast(attrs, [:slide_position, :content, :presentation_file_id]) + |> validate_required([:slide_position, :presentation_file_id]) + end +end diff --git a/lib/claper/tasks/converter.ex b/lib/claper/tasks/converter.ex index f70bc2c7..765e3d54 100644 --- a/lib/claper/tasks/converter.ex +++ b/lib/claper/tasks/converter.ex @@ -39,7 +39,7 @@ defmodule Claper.Tasks.Converter do file_to_pdf(ext_atom, path, file) |> pdf_to_jpg(path, presentation, user_id) - |> jpg_upload(hash, path, presentation, user_id, is_copy) + |> jpg_upload(hash, path, presentation, user_id, is_copy, ext_atom) end @doc """ @@ -116,7 +116,7 @@ defmodule Claper.Tasks.Converter do failure(presentation, path, user_id) end - defp jpg_upload(%Result{status: 0}, hash, path, presentation, user_id, is_copy) do + defp jpg_upload(%Result{status: 0}, hash, path, presentation, user_id, is_copy, ext_atom) do files = Path.wildcard("#{path}/*.jpg") # assign new hash to avoid cache issues @@ -154,26 +154,100 @@ defmodule Claper.Tasks.Converter do clear(presentation.hash) end - success(presentation, path, new_hash, length(files), user_id) + success(presentation, path, new_hash, length(files), user_id, ext_atom) end - defp jpg_upload(_result, _hash, path, presentation, user_id, _is_copy) do + defp jpg_upload(_result, _hash, path, presentation, user_id, _is_copy, _ext_atom) do failure(presentation, path, user_id) end - defp success(presentation, path, hash, length, user_id) do + defp success(presentation, path, hash, length, user_id, ext_atom) do with {:ok, presentation} <- Claper.Presentations.update_presentation_file(presentation, %{ "hash" => "#{hash}", "length" => length, "status" => "done" }) do + # For local storage the directory was already renamed to the new hash, + # so original.pptx lives under the new hash path. + # For S3 storage the original directory (path) is still intact at this point. + pptx_path = + if get_presentation_storage() == "local" do + Path.join([get_presentation_storage_dir(), "uploads", "#{hash}", "original.pptx"]) + else + "#{path}/original.pptx" + end + + extract_and_save_notes(pptx_path, ext_atom, presentation.id, length) + if get_presentation_storage() != "local", do: File.rm_rf!(path) Events.broadcast_user_events(user_id, {:presentation_file_process_done, presentation}) end end + defp extract_and_save_notes(pptx_path, :pptx, presentation_file_id, slide_count) do + case :zip.unzip(String.to_charlist(pptx_path), [:memory]) do + {:ok, files} -> + for i <- 1..slide_count do + filename = String.to_charlist("ppt/notesSlides/notesSlide#{i}.xml") + + case List.keyfind(files, filename, 0) do + {_, content} -> + html = extract_notes_html(content) + + if html != "" do + Claper.Presentations.upsert_note(presentation_file_id, i - 1, html) + end + + nil -> + :ok + end + end + + _ -> + :ok + end + end + + defp extract_and_save_notes(_path, _ext, _presentation_file_id, _slide_count), do: :ok + + # Extracts the notes body from a notesSlide XML and converts it to + # an HTML string suitable for Quill. Each PPTX paragraph becomes a + #

element, preserving line breaks and paragraph structure. + defp extract_notes_html(xml_content) do + import SweetXml + + paragraphs = + xpath( + xml_content, + ~x"//*[local-name()='sp'][.//*[local-name()='ph'][@type='body']]//*[local-name()='txBody']/*[local-name()='p']"l + ) + + html = + Enum.map_join(paragraphs, "", fn para -> + runs = xpath(para, ~x".//*[local-name()='t']/text()"ls) + text = Enum.join(runs, "") + + if String.trim(text) == "" do + "


" + else + "

#{escape_html(text)}

" + end + end) + + if html == "", do: "", else: html + rescue + _ -> "" + end + + defp escape_html(text) do + text + |> String.replace("&", "&") + |> String.replace("<", "<") + |> String.replace(">", ">") + end + defp failure(presentation, path, user_id) do with {:ok, presentation} <- Claper.Presentations.update_presentation_file(presentation, %{ diff --git a/lib/claper_web/live/event_live/embed_component.ex b/lib/claper_web/live/event_live/embed_component.ex index e7c0c547..21997316 100644 --- a/lib/claper_web/live/event_live/embed_component.ex +++ b/lib/claper_web/live/event_live/embed_component.ex @@ -4,16 +4,12 @@ defmodule ClaperWeb.EventLive.EmbedComponent do @impl true def render(assigns) do ~H""" -
+