/** Module imports */
import { html, css, PropertyValueMap, PropertyValues } from "lit";
import { LitElementWw } from "@webwriter/lit";
import {
customElement,
property,
queryAll,
queryAssignedElements,
state,
} from "lit/decorators.js";
import { msg } from "@lit/localize";
import SlButton from "@shoelace-style/shoelace/dist/components/button/button.component.js";
import SlIconButton from "@shoelace-style/shoelace/dist/components/icon-button/icon-button.component.js";
import LOCALIZE from "../../localization/generated";
import "@shoelace-style/shoelace/dist/themes/light.css";
import fullscreenIcon from "bootstrap-icons/icons/fullscreen.svg";
import fullscreenExitIcon from "bootstrap-icons/icons/fullscreen-exit.svg";
import plusSquareIcon from "bootstrap-icons/icons/plus-square.svg";
import minusSquareIcon from "bootstrap-icons/icons/dash-square.svg";
import chevronLeftIcon from "bootstrap-icons/icons/chevron-left.svg";
import chevronRightIcon from "bootstrap-icons/icons/chevron-right.svg";
import { WebwriterSlide } from "./webwriter-slide";
import IconRemove from "bootstrap-icons/icons/x-circle.svg";
import IconAdd from "bootstrap-icons/icons/plus-circle.svg";
import IconDuplicate from "bootstrap-icons/icons/copy.svg";
import {
SlChangeEvent,
SlOption,
SlSelect,
SlTooltip,
} from "@shoelace-style/shoelace";
import { snapdom } from "@zumer/snapdom";
import { slides_styles } from "../styles/styles";
/**
* Container for displaying a slideshow of content sequentially.
*
* @slot default - Slide elements to be displayed (should be `webwriter-slide` components only).
*/
@customElement("webwriter-slides")
export class WebwriterSlides extends LitElementWw {
protected localize = LOCALIZE;
constructor() {
super();
this.addEventListener("fullscreenchange", () => this.requestUpdate());
document.addEventListener(
"selectionchange",
(e) => {
const selectedSlideIndex = this.slides?.findIndex((slide) =>
document.getSelection().containsNode(slide, true)
);
if (selectedSlideIndex !== -1) {
this.changeSlide(selectedSlideIndex);
this.requestUpdate();
}
},
{ passive: true }
);
}
private _boundKeyHandler!: (e: KeyboardEvent) => void;
connectedCallback() {
super.connectedCallback?.();
// Register keydown listener for slide navigation
this._boundKeyHandler = this._handleKeyDown.bind(this);
window.addEventListener("keydown", this._boundKeyHandler);
}
disconnectedCallback() {
super.disconnectedCallback?.();
// Cleanup keydown listener on disconnect
window.removeEventListener("keydown", this._boundKeyHandler);
}
/**
* Handles keyboard navigation for the slideshow.
* ArrowRight advances to the next slide, ArrowLeft goes back.
* Only possible in preview mode.
*/
_handleKeyDown(e: KeyboardEvent) {
if (this.hasAttribute("contenteditable")) return;
switch (e.key) {
case "ArrowRight":
this.handleNextSlideClick(e);
break;
case "ArrowLeft":
this.handleNextSlideClick(e, true);
break;
}
}
protected firstUpdated(): void {
this.requestUpdate();
}
protected static scopedElements = {
"sl-button": SlButton,
"sl-icon-button": SlIconButton,
"sl-tooltip": SlTooltip,
"sl-select": SlSelect,
"sl-option": SlOption,
};
/** Index of the currently active slide. */
@property({ attribute: false, state: true })
accessor activeSlideIndex = 0;
/** The active slide element based on the activeSlideIndex. */
get activeSlide(): WebwriterSlide {
return this.slides[this.activeSlideIndex];
}
private draggingIndex: number | null = null;
/** Index of the slide currently being dragged over (for drag-and-drop functionality). */
private lastDraggedOver = -1;
static styles = slides_styles;
/** Whether the slideshow is currently displayed in fullscreen mode. */
protected get isFullscreen() {
return this.ownerDocument.fullscreenElement === this;
}
/** Icon source URL depending on fullscreen state (enter/exit). */
protected get iconSrc() {
return this.isFullscreen ? fullscreenExitIcon : fullscreenIcon;
}
/**
* All `webwriter-slide` elements.
* Represents the individual slides in the slideshow.
*/
@queryAssignedElements()
protected accessor slides: WebwriterSlide[];
/**
* Defines the type of view for the slideshow.
* - "slides": Show content as sequential slides.
* - "tabs": Show content using tabs.
*/
@property({ type: String, attribute: true, reflect: true })
public accessor type: "tabs" | "slides" = "slides";
/** Add a new empty slide element. Optionally insert after given index. */
addSlide(index?: number) {
const slide = this.ownerDocument.createElement(
"webwriter-slide"
) as WebwriterSlide;
const p = this.ownerDocument.createElement("p");
slide.appendChild(p);
if (index !== undefined && index >= 0 && index < this.slides.length) {
const refSlide = this.slides[index];
refSlide.insertAdjacentElement("afterend", slide);
} else {
this.appendChild(slide);
}
this.changeSlide(this.slides.indexOf(slide));
// place cursor at the start of the new slide
const selection = document.getSelection();
if (selection) {
selection.setBaseAndExtent(p, 0, p, 0);
}
}
/** Duplicate an existing slide at given index. */
duplicateSlide(index: number) {
const original = this.slides[index];
if (!original) return;
const clone = original.cloneNode(true) as WebwriterSlide;
original.insertAdjacentElement("afterend", clone);
this.changeSlide(this.slides.indexOf(clone));
}
/** Remove the currently active slide element. */
removeActiveSlide() {
this.removeSlide(this.activeSlideIndex);
}
/** Remove the currently active slide element. */
removeSlide(slideIndex: number) {
this.slides[slideIndex].remove();
if (this.activeSlideIndex > this.slides.length - 1) {
this.changeSlide(this.slides.length - 1);
}
this.requestUpdate();
}
/** Activate the next slide element. */
nextSlide(backwards = false, step = 1) {
const i = this.activeSlideIndex;
const n = this.slides?.length - 1;
this.changeSlide(
backwards ? Math.max(0, i - step) : Math.min(n, i + step)
);
}
/**
* Lifecycle method called whenever the component is updated.
* Updates each slide's `active` property based on the current activeSlideIndex.
*/
updated(changed: any) {
super.updated(changed);
this.slides?.forEach(
(slide, i) => (slide.active = i === this.activeSlideIndex)
);
}
/** False if slideshow is on the last slide. */
get hasNextSlide(): boolean {
return this.activeSlideIndex < this.slides?.length - 1;
}
/** False if slideshow is on the first slide. */
get hasPreviousSlide(): boolean {
return this.activeSlideIndex > 0;
}
/**
* Handles navigation to the next or previous slide based on user input.
* - Shift key: jump by the total number of slides.
* - Ctrl key: jump by 10 slides.
* - Otherwise: move by one slide.
*
* @param e - The triggering mouse or keyboard event.
* @param backwards - Whether to navigate backward (default is false).
*/
protected handleNextSlideClick(
e: MouseEvent | KeyboardEvent,
backwards = false
) {
if (e.shiftKey) {
this.nextSlide(backwards, this.slides.length);
} else if (e.ctrlKey) {
this.nextSlide(backwards, 10);
} else {
this.nextSlide(backwards);
}
}
/**
* Changes the active slide to the specified index.
* Waits for rendering to finish, scrolls the active slide into view,
* and generates a thumbnail preview using snapdom.
*
* @param index - The index of the slide to activate.
*/
protected changeSlide = async (index: number) => {
this.activeSlideIndex = index;
const tmpSlideIndex = this.activeSlideIndex;
// Wait briefly to ensure slide rendering completes
await new Promise((resolve) => setTimeout(resolve, 100));
// Scroll the active slide/tab into view smoothly
const active = this.renderRoot.querySelector(".active");
if (active) {
active.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "nearest",
});
}
// Only proceed if slide hasn’t changed in the meantime
if (this.activeSlideIndex === tmpSlideIndex) {
const result = await snapdom(this.slides[tmpSlideIndex], {
width: 240,
height: 140,
});
const img = await result.toPng();
this.slides[tmpSlideIndex].thumbnail = img.src;
this.requestUpdate();
}
};
/**
* Starts dragging a slide element.
* Adds the 'dragging' CSS class and sets drag data.
*
* @param e - The dragstart event.
* @param index - Index of the slide being dragged.
*/
private onDragStart(e: DragEvent, index: number) {
this.draggingIndex = index;
(e.currentTarget as HTMLElement).classList.add("dragging");
e.dataTransfer?.setData("text/plain", index.toString());
e.dataTransfer!.effectAllowed = "move";
}
/**
* Ends dragging a slide.
* Moves the dragged slide to its new position if it was moved,
* updates the active slide index, and cleans up drag state.
*
* @param e - The dragend event.
*/
private onDragEnd(e: DragEvent) {
const draggingIdx = this.draggingIndex;
if (draggingIdx === null || draggingIdx === this.lastDraggedOver)
return;
const draggedSlide = this.slides[draggingIdx];
const targetSlide = this.slides[this.lastDraggedOver];
if (draggedSlide && targetSlide && draggedSlide !== targetSlide) {
if (draggingIdx < this.lastDraggedOver) {
// Insert dragged slide after the target slide
targetSlide.insertAdjacentElement("afterend", draggedSlide);
} else {
// Insert dragged slide before the target slide
targetSlide.insertAdjacentElement("beforebegin", draggedSlide);
}
this.activeSlideIndex = this.lastDraggedOver;
this.requestUpdate();
}
(e.currentTarget as HTMLElement).classList.remove("dragging");
this.draggingIndex = null;
this.lastDraggedOver = -1;
}
/**
* Handles dragging over a slide element.
* Prevents default to allow dropping and records the slide index being hovered.
*
* @param e - The dragover event.
* @param index - Index of the slide being dragged over.
*/
private onDragOver(e: DragEvent, index: number) {
e.preventDefault();
this.lastDraggedOver = index;
}
/**
* Renders the slideshow component UI including:
* - Slide navigation controls (next, previous, duplicate, add, remove).
* - View type selector when contenteditable (tabs or slides).
* - Tabs view with draggable slide tabs and controls.
* - Slides view with thumbnails, draggable slides, and controls.
* - The default slot where slide content is displayed.
*
* The controls adapt depending on whether the component is editable.
*/
render() {
const slideButtons = (index: number) => html`