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 = "
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 + "
#{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""" -