import { localized, msg } from "@lit/localize"; import SlButton from "@shoelace-style/shoelace/dist/components/button/button.component.js"; import SlIcon from "@shoelace-style/shoelace/dist/components/icon/icon.component.js"; import SlRadioGroup from "@shoelace-style/shoelace/dist/components/radio-group/radio-group.component.js"; import SlRadio from "@shoelace-style/shoelace/dist/components/radio/radio.component.js"; import SlTabGroup from "@shoelace-style/shoelace/dist/components/tab-group/tab-group.component.js"; import SlTabPanel from "@shoelace-style/shoelace/dist/components/tab-panel/tab-panel.component.js"; import SlTab from "@shoelace-style/shoelace/dist/components/tab/tab.component.js"; import "@shoelace-style/shoelace/dist/themes/light.css"; import { LitElementWw } from "@webwriter/lit"; import ExclamationCircleIcon from "bootstrap-icons/icons/exclamation-circle.svg"; import EyeSlashIcon from "bootstrap-icons/icons/eye-slash.svg"; import { css, html, nothing, PropertyValues } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { createRef, ref } from "lit/directives/ref.js"; import LOCALIZE from "../localization/generated"; import { QuizContainer, QuizEvent } from "./quiz/quiz-container.component"; import { TimelineContainer } from "./timeline/timeline-container.component"; import type { WebWriterTimelineEventWidget } from "./timeline/webwriter-timeline-event.widget"; import { TimelineDate } from "./util/timeline-date"; /** * Displays a timeline with events and a quiz based on those events. * * As children, it should only contain `` elements in order * to function properly. Any other children may lead to unexpected behavior. */ @localized() @customElement("webwriter-timeline") export class WebWriterTimelineWidget extends LitElementWw { protected localize = LOCALIZE; /** @internal */ static scopedElements = { "sl-button": SlButton, "sl-icon": SlIcon, "sl-radio-group": SlRadioGroup, "sl-radio": SlRadio, "sl-tab-group": SlTabGroup, "sl-tab-panel": SlTabPanel, "sl-tab": SlTab, "timeline-container": TimelineContainer, "quiz-container": QuizContainer, }; static styles = css` :host { display: block; width: 100%; } sl-icon { margin-left: var(--sl-spacing-x-small); } :host(:not([contenteditable="true"]):not([contenteditable=""])) aside { display: none; } sl-tab-panel::part(base) { padding: 0; } .hide { display: none; } .timeline-empty { display: flex; gap: var(--sl-spacing-small); align-items: center; } `; private get isInEditView() { return this.contentEditable === "true" || this.contentEditable === ""; } private tabGroupRef = createRef(); /** * Which panels are enabled for the reader: * - "timeline": only the timeline panel * - "quiz": only the quiz panel * - "timeline+quiz": both panels with tabs */ @property({ type: String, reflect: true, attribute: "panels" }) accessor enabledPanels: "timeline" | "quiz" | "timeline+quiz" = "timeline+quiz"; @state() private accessor eventsForQuiz: QuizEvent[] | null = null; private updateEventsForQuiz() { this.eventsForQuiz = Array.from(this.children) .filter((child) => { if (!(child instanceof HTMLElement) || child.tagName !== "WEBWRITER-TIMELINE-EVENT") return false; const event = child as WebWriterTimelineEventWidget; const titleElement = event.querySelector("webwriter-timeline-event-title"); return event.date && titleElement && titleElement.textContent.trim() !== ""; }) .map((event: WebWriterTimelineEventWidget) => ({ id: event.id, titleHtml: event.querySelector("webwriter-timeline-event-title").innerHTML.trim(), date: event.date, endDate: event.endDate, })); } private addEvent(event: CustomEvent) { event.stopPropagation(); const newEvent = document.createElement("webwriter-timeline-event"); this.appendChild(newEvent); } private dateChanged(event: Event) { event.stopPropagation(); // When the date of an event changes, we need to reorder the events. const target = event.target as WebWriterTimelineEventWidget; let oldPos = Array.from(this.children).indexOf(target); let inserted = false; for (const child of this.children) { if ("date" in child && child.date) { // Move the target before the first event with a date greater than the target's date // (i.e. at the end of all events with a date less than or equal to the target's date) if (target.date.compare(child.date as TimelineDate) < 0) { this.insertBefore(target, child); inserted = true; break; } } else { // Once we reach an event without a date, we can insert the target before it // as all previous events have a date less than or equal to the target's date this.insertBefore(target, child); inserted = true; break; } } // If no event was found with a date greater than the target's date, append it to the end if (!inserted) this.appendChild(target); if (oldPos !== Array.from(this.children).indexOf(target)) { // For some reason, the element instance actually changes during reordering, // so we need to use a timeout and search for the updated instance to trigger the animation. setTimeout(() => { const updatedTarget = Array.from(this.children).find( (child) => child.id === target.id, ) as WebWriterTimelineEventWidget; updatedTarget?.showMovedAnimation(); }, 0); } } protected firstUpdated(_changedProperties: PropertyValues): void { // It is not entirely clear why this is necessary, but without this, // no tab is selected when the widget is first rendered in export mode. this.tabGroupRef.value?.updateComplete.then(() => this.tabGroupRef.value?.show("timeline")); } private Options() { // Shoelace's radio group only syncs the value to the checked state of the radio buttons if is // defined inside the global customElements registry, see // https://github.com/shoelace-style/shoelace/blob/v2.20.1/src/components/radio-group/radio-group.component.ts#L238 // Since we are using scoped elements, we need to manually set the checked state of the radio buttons const PanelRadioOption = (value: string, label: string) => html`${label}`; return html``; } private Quiz() { return html``; } private Timeline() { return html` this.updateEventsForQuiz()} > `; } private PanelIcon(panelName: string) { if (!this.enabledPanels.includes(panelName)) { return html``; } else { return nothing; } } private TabGroup() { if (!this.isInEditView) { // During reader view, display a warning if there are no events instead of showing a empty timeline or quiz, // as this is likely a mistake by the author. if (this.eventsForQuiz?.length === 0) { return html`
${msg("Timeline is empty.")}
`; } if (this.enabledPanels === "timeline") { return this.Timeline(); } else if (this.enabledPanels === "quiz") { // We still need to mount the slot to get a list of events for the quiz return html` this.updateEventsForQuiz()}>${this.Quiz()}`; } } return html` { if (event.detail.name === "quiz") this.updateEventsForQuiz(); }} > ${msg("Timeline")}${this.PanelIcon("timeline")} ${msg("Quiz")}${this.PanelIcon("quiz")} ${this.Timeline()} ${this.Quiz()} `; } render() { return html`${this.Options()}${this.TabGroup()}`; } }