From 3d6e7e42af75cf6d1d4f4eaa2f1791a6566b317a Mon Sep 17 00:00:00 2001 From: Kylie Meli Date: Fri, 13 Jun 2025 16:08:49 -0400 Subject: [PATCH 01/16] who knows --- docs/contribute/index.md | 18 + .../Assets/image-carousel.ts | 346 ++++++++++++++++++ src/Elastic.Documentation.Site/Assets/main.ts | 2 + .../Assets/markdown/image-carousel.css | 106 ++++++ .../Assets/styles.css | 1 + .../Myst/Directives/DirectiveBlockParser.cs | 3 + .../Myst/Directives/DirectiveHtmlRenderer.cs | 35 +- .../Myst/Directives/ImageCarouselBlock.cs | 174 +++++++++ .../Slices/Directives/ImageCarousel.cshtml | 73 ++++ .../Slices/Directives/_ViewModels.cs | 8 + 10 files changed, 763 insertions(+), 3 deletions(-) create mode 100644 src/Elastic.Documentation.Site/Assets/image-carousel.ts create mode 100644 src/Elastic.Documentation.Site/Assets/markdown/image-carousel.css create mode 100644 src/Elastic.Markdown/Myst/Directives/ImageCarouselBlock.cs create mode 100644 src/Elastic.Markdown/Slices/Directives/ImageCarousel.cshtml diff --git a/docs/contribute/index.md b/docs/contribute/index.md index 0af35e1c1..b4edf05d7 100644 --- a/docs/contribute/index.md +++ b/docs/contribute/index.md @@ -8,6 +8,24 @@ Welcome, **contributor**! Whether you're a technical writer or you've only edited Elastic docs once or twice, you're a valued contributor. Every word matters! +:::{carousel} +:id: nested-carousel-example +:controls: true +:indicators: true +:::{image} https://epr.elastic.co/package/abnormal_security/1.8.0/img/abnormal_security-mailbox_not_analyzed_overview.png +:alt: First image description +:title: First image title +::: +:::{image} https://epr.elastic.co/package/abnormal_security/1.8.0/img/abnormal_security-ai_security_mailbox_overview.png +:alt: Second image description +:title: Second image title +::: +:::{image} https://epr.elastic.co/package/abnormal_security/1.8.0/img/abnormal_security-audit_overview.png +:alt: Third image description +:title: Third image title +::: +::: + ## Contribute to the docs [#contribute] The version of the docs you want to contribute to determines the tool and syntax you must use to update the docs. diff --git a/src/Elastic.Documentation.Site/Assets/image-carousel.ts b/src/Elastic.Documentation.Site/Assets/image-carousel.ts new file mode 100644 index 000000000..bf7b20c48 --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/image-carousel.ts @@ -0,0 +1,346 @@ +class ImageCarousel { + private container: HTMLElement; + private slides: HTMLElement[]; + private indicators: HTMLElement[]; + private prevButton: HTMLElement | null; + private nextButton: HTMLElement | null; + private currentIndex: number = 0; + private touchStartX: number = 0; + private touchEndX: number = 0; + + constructor(containerId: string) { + this.container = document.getElementById(containerId)!; + if (!this.container) { + console.warn(`Carousel container with ID "${containerId}" not found`); + return; + } + + this.slides = Array.from(this.container.querySelectorAll('.carousel-slide')); + this.indicators = Array.from(this.container.querySelectorAll('.carousel-indicator')); + this.prevButton = this.container.querySelector('.carousel-prev'); + this.nextButton = this.container.querySelector('.carousel-next'); + + // Force initialization - make all slides inactive first + this.slides.forEach(slide => { + slide.setAttribute('data-active', 'false'); + slide.style.display = 'none'; + slide.style.opacity = '0'; + }); + + // Then make the first slide active + if (this.slides.length > 0) { + this.slides[0].setAttribute('data-active', 'true'); + this.slides[0].style.display = 'block'; + this.slides[0].style.opacity = '1'; + + // Also initialize indicators + if (this.indicators.length > 0) { + this.indicators.forEach(indicator => { + indicator.setAttribute('data-active', 'false'); + }); + this.indicators[0].setAttribute('data-active', 'true'); + } + } + + this.init(); + } + + private init(): void { + // Set up event listeners for controls + if (this.prevButton) { + this.prevButton.addEventListener('click', () => this.prevSlide()); + } + + if (this.nextButton) { + this.nextButton.addEventListener('click', () => this.nextSlide()); + } + + // Set up indicators + this.indicators.forEach((indicator, index) => { + indicator.addEventListener('click', () => this.goToSlide(index)); + }); + + // Set up keyboard navigation + document.addEventListener('keydown', (e) => { + if (!this.isInViewport()) return; + + if (e.key === 'ArrowLeft') { + this.prevSlide(); + } else if (e.key === 'ArrowRight') { + this.nextSlide(); + } + }); + + // Set up touch events for mobile + this.container.addEventListener('touchstart', (e) => { + this.touchStartX = e.changedTouches[0].screenX; + }); + + this.container.addEventListener('touchend', (e) => { + this.touchEndX = e.changedTouches[0].screenX; + this.handleSwipe(); + }); + } + + private prevSlide(): void { + const newIndex = (this.currentIndex - 1 + this.slides.length) % this.slides.length; + this.goToSlide(newIndex); + } + + private nextSlide(): void { + const newIndex = (this.currentIndex + 1) % this.slides.length; + this.goToSlide(newIndex); + } + + private goToSlide(index: number): void { + // Hide current slide + this.slides[this.currentIndex].setAttribute('data-active', 'false'); + this.slides[this.currentIndex].style.display = 'none'; + this.slides[this.currentIndex].style.opacity = '0'; + + // Show new slide + this.slides[index].setAttribute('data-active', 'true'); + this.slides[index].style.display = 'block'; + this.slides[index].style.opacity = '1'; + + // Update indicators + if (this.indicators.length > 0) { + this.indicators[this.currentIndex].setAttribute('data-active', 'false'); + this.indicators[index].setAttribute('data-active', 'true'); + } + + this.currentIndex = index; + } + + private handleSwipe(): void { + const threshold = 50; + const diff = this.touchStartX - this.touchEndX; + + if (Math.abs(diff) < threshold) return; + + if (diff > 0) { + // Swipe left - next slide + this.nextSlide(); + } else { + // Swipe right - previous slide + this.prevSlide(); + } + } + + private isInViewport(): boolean { + const rect = this.container.getBoundingClientRect(); + return ( + rect.top >= 0 && + rect.left >= 0 && + rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && + rect.right <= (window.innerWidth || document.documentElement.clientWidth) + ); + } +} + +// Export function to initialize carousels +export function initImageCarousel(): void { + + // Find all carousel containers + const carousels = document.querySelectorAll('.carousel-container'); + + // Process each carousel + carousels.forEach((carousel) => { + const id = carousel.id; + if (!id) return; + + // Get the existing track + let track = carousel.querySelector('.carousel-track'); + if (!track) { + track = document.createElement('div'); + track.className = 'carousel-track'; + carousel.appendChild(track); + } + + // Clean up any existing slides - this prevents duplicates + const existingSlides = Array.from(track.querySelectorAll('.carousel-slide')); + + // Find all image links that might be related to this carousel + const section = findSectionForCarousel(carousel); + if (!section) return; + + // First, collect all images we want in the carousel + const allImageLinks = Array.from(section.querySelectorAll('a[href*="epr.elastic.co"]')); + + // Track URLs to prevent duplicates + const processedUrls = new Set(); + + // Process the existing slides first + existingSlides.forEach(slide => { + const imageRef = slide.querySelector('a.carousel-image-reference'); + if (imageRef && imageRef instanceof HTMLAnchorElement) { + processedUrls.add(imageRef.href); + } + }); + + // Find standalone images (not already in carousel slides) + const standaloneImages = allImageLinks.filter(img => { + if (processedUrls.has(img.href)) { + return false; // Skip if already processed + } + + // Don't count images already in carousel slides + const isInCarousel = img.closest('.carousel-slide') !== null; + if (isInCarousel) { + processedUrls.add(img.href); + return false; + } + + processedUrls.add(img.href); + return true; + }); + + // Add the standalone images to the carousel + let slideIndex = existingSlides.length; + standaloneImages.forEach((imgLink) => { + // Find container to hide + const imgContainer = findClosestContainer(imgLink, carousel); + + // Create a new slide + const slide = document.createElement('div'); + slide.className = 'carousel-slide'; + slide.setAttribute('data-index', slideIndex.toString()); + if (slideIndex === 0 && existingSlides.length === 0) { + slide.setAttribute('data-active', 'true'); + } + + // Create a proper carousel image reference wrapper + const imageRef = document.createElement('a'); + imageRef.className = 'carousel-image-reference'; + imageRef.href = imgLink.href; + imageRef.target = '_blank'; + + // Clone the image + const img = imgLink.querySelector('img'); + if (img) { + imageRef.appendChild(img.cloneNode(true)); + } + + slide.appendChild(imageRef); + track.appendChild(slide); + + // Hide the original container properly + if (imgContainer) { + try { + // Find the parent element that might be a paragraph or div containing the image + let parent = imgContainer; + let maxAttempts = 3; // Don't go too far up the tree + + while (maxAttempts > 0 && parent && parent !== document.body) { + // If this is one of these elements, hide it + if (parent.tagName === 'P' || + (parent.tagName === 'DIV' && !parent.classList.contains('carousel-container'))) { + parent.style.display = 'none'; + break; + } + parent = parent.parentElement; + maxAttempts--; + } + + // If we couldn't find a suitable parent, just hide the container itself + if (maxAttempts === 0) { + imgContainer.style.display = 'none'; + } + } catch (e) { + console.error('Failed to hide original image:', e); + } + } + + slideIndex++; + }); + + // Only set up controls if we have multiple slides + const totalSlides = track.querySelectorAll('.carousel-slide').length; + if (totalSlides > 1) { + // Add controls if they don't exist + if (!carousel.querySelector('.carousel-prev')) { + const prevButton = document.createElement('button'); + prevButton.type = 'button'; + prevButton.className = 'carousel-control carousel-prev'; + prevButton.setAttribute('aria-label', 'Previous slide'); + prevButton.innerHTML = ''; + carousel.appendChild(prevButton); + } + + if (!carousel.querySelector('.carousel-next')) { + const nextButton = document.createElement('button'); + nextButton.type = 'button'; + nextButton.className = 'carousel-control carousel-next'; + nextButton.setAttribute('aria-label', 'Next slide'); + nextButton.innerHTML = ''; + carousel.appendChild(nextButton); + } + + // Add or update indicators + let indicators = carousel.querySelector('.carousel-indicators'); + if (!indicators) { + indicators = document.createElement('div'); + indicators.className = 'carousel-indicators'; + carousel.appendChild(indicators); + } else { + indicators.innerHTML = ''; // Clear existing indicators + } + + for (let i = 0; i < totalSlides; i++) { + const indicator = document.createElement('button'); + indicator.type = 'button'; + indicator.className = 'carousel-indicator'; + indicator.setAttribute('data-index', i.toString()); + if (i === 0) { + indicator.setAttribute('data-active', 'true'); + } + indicator.setAttribute('aria-label', `Go to slide ${i+1}`); + indicators.appendChild(indicator); + } + } + + // Initialize this carousel + new ImageCarousel(id); + }); +} + +// Helper to find a suitable container for an image +function findClosestContainer(element: Element, carousel: Element): Element | null { + let current = element; + while (current && !current.contains(carousel) && current !== document.body) { + // Stop at these elements + if (current.tagName === 'P' || + current.tagName === 'DIV' || + current.classList.contains('carousel-container')) { + return current; + } + current = current.parentElement!; + } + return element; +} + +// Helper to find the section containing a carousel +function findSectionForCarousel(carousel: Element): Element | null { + // Look for containing section, article, or main element + let section = carousel.closest('section, article, main, div.markdown-content'); + if (!section) { + // Fallback to parent element + section = carousel.parentElement; + } + return section; +} + +// Make function available globally +declare global { + interface Window { + initImageCarousel: typeof initImageCarousel; + } +} + +// Assign the function to the global window object +window.initImageCarousel = initImageCarousel; + +// Initialize all carousels when DOM is loaded +document.addEventListener('DOMContentLoaded', () => { + initImageCarousel(); +}); \ No newline at end of file diff --git a/src/Elastic.Documentation.Site/Assets/main.ts b/src/Elastic.Documentation.Site/Assets/main.ts index f14b3f989..839d8130c 100644 --- a/src/Elastic.Documentation.Site/Assets/main.ts +++ b/src/Elastic.Documentation.Site/Assets/main.ts @@ -1,6 +1,7 @@ import { initCopyButton } from './copybutton' import { initDismissibleBanner } from './dismissible-banner' import { initHighlight } from './hljs' +import { initImageCarousel } from './image-carousel' import { openDetailsWithAnchor } from './open-details-with-anchor' import { initNav } from './pages-nav' import { initSmoothScroll } from './smooth-scroll' @@ -24,6 +25,7 @@ document.addEventListener('htmx:load', function () { initSmoothScroll() openDetailsWithAnchor() initDismissibleBanner() + initImageCarousel(); }) // Don't remove style tags because they are used by the elastic global nav. diff --git a/src/Elastic.Documentation.Site/Assets/markdown/image-carousel.css b/src/Elastic.Documentation.Site/Assets/markdown/image-carousel.css new file mode 100644 index 000000000..4bf6af36f --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/markdown/image-carousel.css @@ -0,0 +1,106 @@ +.carousel-container { + position: relative; + width: 100%; + margin: 2rem 0; + overflow: hidden; +} + +.carousel-track { + width: 100%; + position: relative; + min-height: 200px; /* Ensure container has height even when slides are absolute */ +} + +.carousel-slide { + width: 100%; + position: absolute; + display: none; + opacity: 0; + transition: opacity 0.3s ease; +} + +.carousel-slide[data-active="true"] { + position: relative; + display: block; + opacity: 1; + z-index: 2; +} + +.carousel-image-reference { + display: block; +} + +.carousel-image-reference img { + width: 100%; + height: auto; + display: block; +} + +.carousel-control { + position: absolute; + top: 50%; + transform: translateY(-50%); + background-color: rgba(0, 0, 0, 0.5); + border: none; + color: white; + font-size: 1.5rem; + width: 40px; + height: 40px; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.3s; + z-index: 5; +} + +.carousel-control:hover { + background-color: rgba(0, 0, 0, 0.7); +} + +.carousel-prev { + left: 10px; +} + +.carousel-next { + right: 10px; +} + +.carousel-indicators { + position: absolute; + bottom: 10px; + left: 50%; + transform: translateX(-50%); + display: flex; + gap: 8px; + z-index: 5; +} + +.carousel-indicator { + width: 12px; + height: 12px; + border-radius: 50%; + background-color: rgba(0, 0, 0, 0.3); + border: none; + cursor: pointer; + padding: 0; + transition: background-color 0.3s; +} + +.carousel-indicator[data-active="true"] { + background-color: black; +} + +@media (max-width: 768px) { + .carousel-control { + width: 30px; + height: 30px; + font-size: 1.2rem; + } + + .carousel-indicator { + width: 10px; + height: 10px; + } +} \ No newline at end of file diff --git a/src/Elastic.Documentation.Site/Assets/styles.css b/src/Elastic.Documentation.Site/Assets/styles.css index 80da74e43..63e05c015 100644 --- a/src/Elastic.Documentation.Site/Assets/styles.css +++ b/src/Elastic.Documentation.Site/Assets/styles.css @@ -13,6 +13,7 @@ @import './markdown/table.css'; @import './markdown/definition-list.css'; @import './markdown/images.css'; +@import './markdown/image-carousel.css'; @import './modal.css'; @import './archive.css'; @import './markdown/stepper.css'; diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs index b2a3cf803..86a909882 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveBlockParser.cs @@ -86,6 +86,9 @@ protected override DirectiveBlock CreateFencedBlock(BlockProcessor processor) if (info.IndexOf("{image}") > 0) return new ImageBlock(this, context); + if (info.IndexOf("{carousel}") > 0) + return new ImageCarouselBlock(this, context); + if (info.IndexOf("{figure}") > 0) return new FigureBlock(this, context); diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs index 6721ac15a..ecc25bc42 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs @@ -38,6 +38,9 @@ protected override void Write(HtmlRenderer renderer, DirectiveBlock directiveBlo case ImageBlock imageBlock: WriteImage(renderer, imageBlock); return; + case ImageCarouselBlock carouselBlock: + WriteImageCarousel(renderer, carouselBlock); + return; case DropdownBlock dropdownBlock: WriteDropdown(renderer, dropdownBlock); return; @@ -94,7 +97,7 @@ private static void WriteImage(HtmlRenderer renderer, ImageBlock block) Alt = block.Alt ?? string.Empty, Title = block.Title, Height = block.Height, - Scale = block.Scale, + Scale = block.Scale ?? string.Empty, Target = block.Target, Width = block.Width, Screenshot = block.Screenshot, @@ -103,6 +106,32 @@ private static void WriteImage(HtmlRenderer renderer, ImageBlock block) RenderRazorSlice(slice, renderer); } + private static void WriteImageCarousel(HtmlRenderer renderer, ImageCarouselBlock block) + { + var slice = ImageCarousel.Create(new ImageCarouselViewModel + { + DirectiveBlock = block, + Id = block.Id ?? Guid.NewGuid().ToString(), + Images = block.Images.Select(img => new ImageViewModel + { + DirectiveBlock = img, + Label = img.Label, + Align = img.Align ?? string.Empty, + Alt = img.Alt ?? string.Empty, + Title = img.Title, + Height = img.Height, + Width = img.Width, + Scale = img.Scale ?? string.Empty, + Screenshot = img.Screenshot, + Target = img.Target, + ImageUrl = img.ImageUrl + }).ToList(), + ShowControls = block.ShowControls, + ShowIndicators = block.ShowIndicators + }); + RenderRazorSlice(slice, renderer); + } + private static void WriteStepperBlock(HtmlRenderer renderer, StepperBlock block) { var slice = Stepper.Create(new StepperViewModel { DirectiveBlock = block }); @@ -130,11 +159,11 @@ private static void WriteFigure(HtmlRenderer renderer, ImageBlock block) { DirectiveBlock = block, Label = block.Label, - Align = block.Align, + Align = block.Align ?? string.Empty, Alt = block.Alt ?? string.Empty, Title = block.Title, Height = block.Height, - Scale = block.Scale, + Scale = block.Scale ?? string.Empty, Target = block.Target, Width = block.Width, Screenshot = block.Screenshot, diff --git a/src/Elastic.Markdown/Myst/Directives/ImageCarouselBlock.cs b/src/Elastic.Markdown/Myst/Directives/ImageCarouselBlock.cs new file mode 100644 index 000000000..c077e7382 --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/ImageCarouselBlock.cs @@ -0,0 +1,174 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System; +using System.Collections.Generic; +using System.Linq; +using Elastic.Markdown.Diagnostics; +using Elastic.Markdown.Myst; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace Elastic.Markdown.Myst.Directives; + +public class ImageCarouselBlock : DirectiveBlock +{ + private readonly DirectiveBlockParser _parser; + + // Inner class for YAML deserialization + private sealed class CarouselImage + { + [YamlMember(Alias = "url")] + public string? Url { get; set; } + + [YamlMember(Alias = "alt")] + public string? Alt { get; set; } + + [YamlMember(Alias = "title")] + public string? Title { get; set; } + + [YamlMember(Alias = "height")] + public string? Height { get; set; } + + [YamlMember(Alias = "width")] + public string? Width { get; set; } + } + + public List Images { get; } = []; + public string? Id { get; set; } + public bool? ShowControls { get; set; } + public bool? ShowIndicators { get; set; } + +#pragma warning disable IDE0290 // Use primary constructor + public ImageCarouselBlock(DirectiveBlockParser parser, ParserContext context) + : base(parser, context) => _parser = parser; +#pragma warning restore IDE0290 // Use primary constructor + + public override string Directive => "carousel"; + + public override void FinalizeAndValidate(ParserContext context) + { + // Parse options + Id = Prop("id"); + ShowControls = TryPropBool("controls"); + ShowIndicators = TryPropBool("indicators"); + + // Check for child image blocks first (nested directive approach) + var childImageBlocks = new List(); + + // Log all children for debugging + var childCount = 0; + Console.WriteLine($"DEBUG Carousel {Id}: Examining all child blocks"); + foreach (var block in this) + { + childCount++; + Console.WriteLine($"DEBUG Carousel {Id}: Child {childCount} is of type {block.GetType().Name}"); + + if (block is ImageBlock imageBlock) + { + childImageBlocks.Add(imageBlock); + Console.WriteLine($"DEBUG Carousel {Id}: - Added ImageBlock with URL: {imageBlock.Arguments}"); + } + } + + Console.WriteLine($"DEBUG Carousel {Id}: Total children count: {childCount}"); + Console.WriteLine($"DEBUG Carousel {Id}: Found {childImageBlocks.Count} child image blocks"); + + if (childImageBlocks.Count > 0) + { + // Process child image blocks + foreach (var imageBlock in childImageBlocks) + { + Console.WriteLine($"DEBUG Carousel {Id}: Processing ImageBlock with URL: {imageBlock.Arguments}"); + // Process the ImageBlock + imageBlock.FinalizeAndValidate(context); + + // Add to Images list so they're available for rendering + Images.Add(imageBlock); + } + + // Remove the blocks from the parent since they've been processed + // This prevents them from being rendered twice + foreach (var block in childImageBlocks) + { + _ = this.Remove(block); // Use discard operator to ignore the return value + } + + return; // Exit early as we've processed nested directives + } + + // Parse images array as fallback for backward compatibility + var imagesYaml = Prop("images"); + Console.WriteLine($"DEBUG Carousel {Id}: Found images array: {imagesYaml}"); + if (string.IsNullOrEmpty(imagesYaml)) + { + this.EmitError("carousel directive requires either nested image directives or an :images: property"); + return; + } + + try + { + // Create a deserializer to process the YAML using the static builder + var deserializer = new StaticDeserializerBuilder(new DocsBuilderYamlStaticContext()) + .WithNamingConvention(HyphenatedNamingConvention.Instance) + .Build(); + + // Parse YAML images array + var carouselImages = deserializer.Deserialize>(imagesYaml); + + // Create ImageBlock instances from the parsed YAML + foreach (var img in carouselImages) + { + if (string.IsNullOrEmpty(img.Url)) + { + this.EmitError("Each image in a carousel must have a URL"); + continue; + } + + // Create a new ImageBlock for each entry + var imageBlock = new ImageBlock(_parser, context) + { + Arguments = img.Url + }; + + // Set properties if provided + if (!string.IsNullOrEmpty(img.Alt)) + imageBlock.AddProperty("alt", img.Alt); + + if (!string.IsNullOrEmpty(img.Title)) + imageBlock.AddProperty("title", img.Title); + + if (!string.IsNullOrEmpty(img.Height)) + imageBlock.AddProperty("height", img.Height); + + if (!string.IsNullOrEmpty(img.Width)) + imageBlock.AddProperty("width", img.Width); + + // Process the ImageBlock + imageBlock.FinalizeAndValidate(context); + + // Add to our carousel's images list + Images.Add(imageBlock); + } + } + catch (Exception ex) + { + this.EmitError($"Failed to parse images: {ex.Message}"); + } + + // Validate we have at least one image + if (Images.Count == 0) + { + this.EmitError("carousel directive requires at least one image"); + } + } + + private int? TryPropInt(string name) + { + var value = Prop(name); + if (string.IsNullOrEmpty(value)) + return null; + return int.TryParse(value, out var result) ? result : null; + } +} diff --git a/src/Elastic.Markdown/Slices/Directives/ImageCarousel.cshtml b/src/Elastic.Markdown/Slices/Directives/ImageCarousel.cshtml new file mode 100644 index 000000000..c8a5f0ba6 --- /dev/null +++ b/src/Elastic.Markdown/Slices/Directives/ImageCarousel.cshtml @@ -0,0 +1,73 @@ +@inherits RazorSlice + + + + + \ No newline at end of file diff --git a/src/Elastic.Markdown/Slices/Directives/_ViewModels.cs b/src/Elastic.Markdown/Slices/Directives/_ViewModels.cs index 995d0b71a..ef5a556ed 100644 --- a/src/Elastic.Markdown/Slices/Directives/_ViewModels.cs +++ b/src/Elastic.Markdown/Slices/Directives/_ViewModels.cs @@ -132,3 +132,11 @@ public class StepViewModel : DirectiveViewModel public required string Title { get; init; } public required string Anchor { get; init; } } + +public class ImageCarouselViewModel : DirectiveViewModel +{ + public required List Images { get; init; } + public required string Id { get; init; } + public bool? ShowControls { get; init; } + public bool? ShowIndicators { get; init; } +} From aadbe93a411a9dc6f8142e32210bfd352317a9ea Mon Sep 17 00:00:00 2001 From: Kylie Meli Date: Fri, 13 Jun 2025 16:20:48 -0400 Subject: [PATCH 02/16] closing tags fix --- docs/contribute/index.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/contribute/index.md b/docs/contribute/index.md index b4edf05d7..9e664649d 100644 --- a/docs/contribute/index.md +++ b/docs/contribute/index.md @@ -8,7 +8,7 @@ Welcome, **contributor**! Whether you're a technical writer or you've only edited Elastic docs once or twice, you're a valued contributor. Every word matters! -:::{carousel} +::::{carousel} :id: nested-carousel-example :controls: true :indicators: true @@ -24,7 +24,7 @@ Whether you're a technical writer or you've only edited Elastic docs once or twi :alt: Third image description :title: Third image title ::: -::: +:::: ## Contribute to the docs [#contribute] From b79ed01f3d3007cbd7a76465375ff70bde6c0349 Mon Sep 17 00:00:00 2001 From: Kylie Meli Date: Fri, 13 Jun 2025 16:39:11 -0400 Subject: [PATCH 03/16] simplify carousel block code --- .../Myst/Directives/ImageCarouselBlock.cs | 137 +----------------- 1 file changed, 3 insertions(+), 134 deletions(-) diff --git a/src/Elastic.Markdown/Myst/Directives/ImageCarouselBlock.cs b/src/Elastic.Markdown/Myst/Directives/ImageCarouselBlock.cs index c077e7382..15902152a 100644 --- a/src/Elastic.Markdown/Myst/Directives/ImageCarouselBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/ImageCarouselBlock.cs @@ -7,44 +7,16 @@ using System.Linq; using Elastic.Markdown.Diagnostics; using Elastic.Markdown.Myst; -using YamlDotNet.Serialization; -using YamlDotNet.Serialization.NamingConventions; namespace Elastic.Markdown.Myst.Directives; -public class ImageCarouselBlock : DirectiveBlock +public class ImageCarouselBlock(DirectiveBlockParser parser, ParserContext context) : DirectiveBlock(parser, context) { - private readonly DirectiveBlockParser _parser; - - // Inner class for YAML deserialization - private sealed class CarouselImage - { - [YamlMember(Alias = "url")] - public string? Url { get; set; } - - [YamlMember(Alias = "alt")] - public string? Alt { get; set; } - - [YamlMember(Alias = "title")] - public string? Title { get; set; } - - [YamlMember(Alias = "height")] - public string? Height { get; set; } - - [YamlMember(Alias = "width")] - public string? Width { get; set; } - } - public List Images { get; } = []; public string? Id { get; set; } public bool? ShowControls { get; set; } public bool? ShowIndicators { get; set; } -#pragma warning disable IDE0290 // Use primary constructor - public ImageCarouselBlock(DirectiveBlockParser parser, ParserContext context) - : base(parser, context) => _parser = parser; -#pragma warning restore IDE0290 // Use primary constructor - public override string Directive => "carousel"; public override void FinalizeAndValidate(ParserContext context) @@ -54,121 +26,18 @@ public override void FinalizeAndValidate(ParserContext context) ShowControls = TryPropBool("controls"); ShowIndicators = TryPropBool("indicators"); - // Check for child image blocks first (nested directive approach) - var childImageBlocks = new List(); - - // Log all children for debugging - var childCount = 0; - Console.WriteLine($"DEBUG Carousel {Id}: Examining all child blocks"); + // Process child image blocks directly foreach (var block in this) { - childCount++; - Console.WriteLine($"DEBUG Carousel {Id}: Child {childCount} is of type {block.GetType().Name}"); - if (block is ImageBlock imageBlock) { - childImageBlocks.Add(imageBlock); - Console.WriteLine($"DEBUG Carousel {Id}: - Added ImageBlock with URL: {imageBlock.Arguments}"); - } - } - - Console.WriteLine($"DEBUG Carousel {Id}: Total children count: {childCount}"); - Console.WriteLine($"DEBUG Carousel {Id}: Found {childImageBlocks.Count} child image blocks"); - - if (childImageBlocks.Count > 0) - { - // Process child image blocks - foreach (var imageBlock in childImageBlocks) - { - Console.WriteLine($"DEBUG Carousel {Id}: Processing ImageBlock with URL: {imageBlock.Arguments}"); - // Process the ImageBlock - imageBlock.FinalizeAndValidate(context); - - // Add to Images list so they're available for rendering Images.Add(imageBlock); } - - // Remove the blocks from the parent since they've been processed - // This prevents them from being rendered twice - foreach (var block in childImageBlocks) - { - _ = this.Remove(block); // Use discard operator to ignore the return value - } - - return; // Exit early as we've processed nested directives } - // Parse images array as fallback for backward compatibility - var imagesYaml = Prop("images"); - Console.WriteLine($"DEBUG Carousel {Id}: Found images array: {imagesYaml}"); - if (string.IsNullOrEmpty(imagesYaml)) - { - this.EmitError("carousel directive requires either nested image directives or an :images: property"); - return; - } - - try - { - // Create a deserializer to process the YAML using the static builder - var deserializer = new StaticDeserializerBuilder(new DocsBuilderYamlStaticContext()) - .WithNamingConvention(HyphenatedNamingConvention.Instance) - .Build(); - - // Parse YAML images array - var carouselImages = deserializer.Deserialize>(imagesYaml); - - // Create ImageBlock instances from the parsed YAML - foreach (var img in carouselImages) - { - if (string.IsNullOrEmpty(img.Url)) - { - this.EmitError("Each image in a carousel must have a URL"); - continue; - } - - // Create a new ImageBlock for each entry - var imageBlock = new ImageBlock(_parser, context) - { - Arguments = img.Url - }; - - // Set properties if provided - if (!string.IsNullOrEmpty(img.Alt)) - imageBlock.AddProperty("alt", img.Alt); - - if (!string.IsNullOrEmpty(img.Title)) - imageBlock.AddProperty("title", img.Title); - - if (!string.IsNullOrEmpty(img.Height)) - imageBlock.AddProperty("height", img.Height); - - if (!string.IsNullOrEmpty(img.Width)) - imageBlock.AddProperty("width", img.Width); - - // Process the ImageBlock - imageBlock.FinalizeAndValidate(context); - - // Add to our carousel's images list - Images.Add(imageBlock); - } - } - catch (Exception ex) - { - this.EmitError($"Failed to parse images: {ex.Message}"); - } - - // Validate we have at least one image if (Images.Count == 0) { - this.EmitError("carousel directive requires at least one image"); + this.EmitError("carousel directive requires nested image directives"); } } - - private int? TryPropInt(string name) - { - var value = Prop(name); - if (string.IsNullOrEmpty(value)) - return null; - return int.TryParse(value, out var result) ? result : null; - } } From 8bfa39d02091645f546eedb5514849a2fcf7a42e Mon Sep 17 00:00:00 2001 From: Kylie Meli Date: Fri, 13 Jun 2025 16:48:04 -0400 Subject: [PATCH 04/16] more cleanup --- .../Assets/image-carousel.ts | 116 +++++++----------- .../Slices/Directives/ImageCarousel.cshtml | 35 ------ 2 files changed, 44 insertions(+), 107 deletions(-) diff --git a/src/Elastic.Documentation.Site/Assets/image-carousel.ts b/src/Elastic.Documentation.Site/Assets/image-carousel.ts index bf7b20c48..e199e3df5 100644 --- a/src/Elastic.Documentation.Site/Assets/image-carousel.ts +++ b/src/Elastic.Documentation.Site/Assets/image-carousel.ts @@ -20,62 +20,55 @@ class ImageCarousel { this.prevButton = this.container.querySelector('.carousel-prev'); this.nextButton = this.container.querySelector('.carousel-next'); - // Force initialization - make all slides inactive first - this.slides.forEach(slide => { - slide.setAttribute('data-active', 'false'); - slide.style.display = 'none'; - slide.style.opacity = '0'; + this.initializeSlides(); + this.setupEventListeners(); + } + + private initializeSlides(): void { + // Initialize all slides as inactive + this.slides.forEach((slide, index) => { + this.setSlideState(slide, index === 0); + }); + + // Initialize indicators + this.indicators.forEach((indicator, index) => { + this.setIndicatorState(indicator, index === 0); }); - - // Then make the first slide active - if (this.slides.length > 0) { - this.slides[0].setAttribute('data-active', 'true'); - this.slides[0].style.display = 'block'; - this.slides[0].style.opacity = '1'; - - // Also initialize indicators - if (this.indicators.length > 0) { - this.indicators.forEach(indicator => { - indicator.setAttribute('data-active', 'false'); - }); - this.indicators[0].setAttribute('data-active', 'true'); - } - } - - this.init(); } - - private init(): void { - // Set up event listeners for controls - if (this.prevButton) { - this.prevButton.addEventListener('click', () => this.prevSlide()); - } - - if (this.nextButton) { - this.nextButton.addEventListener('click', () => this.nextSlide()); - } - - // Set up indicators + + private setSlideState(slide: HTMLElement, isActive: boolean): void { + slide.setAttribute('data-active', isActive.toString()); + slide.style.display = isActive ? 'block' : 'none'; + slide.style.opacity = isActive ? '1' : '0'; + } + + private setIndicatorState(indicator: HTMLElement, isActive: boolean): void { + indicator.setAttribute('data-active', isActive.toString()); + } + + private setupEventListeners(): void { + // Navigation controls + this.prevButton?.addEventListener('click', () => this.prevSlide()); + this.nextButton?.addEventListener('click', () => this.nextSlide()); + + // Indicators this.indicators.forEach((indicator, index) => { indicator.addEventListener('click', () => this.goToSlide(index)); }); - - // Set up keyboard navigation + + // Keyboard navigation document.addEventListener('keydown', (e) => { if (!this.isInViewport()) return; - if (e.key === 'ArrowLeft') { - this.prevSlide(); - } else if (e.key === 'ArrowRight') { - this.nextSlide(); - } + if (e.key === 'ArrowLeft') this.prevSlide(); + else if (e.key === 'ArrowRight') this.nextSlide(); }); - - // Set up touch events for mobile + + // Touch events this.container.addEventListener('touchstart', (e) => { this.touchStartX = e.changedTouches[0].screenX; }); - + this.container.addEventListener('touchend', (e) => { this.touchEndX = e.changedTouches[0].screenX; this.handleSwipe(); @@ -93,20 +86,14 @@ class ImageCarousel { } private goToSlide(index: number): void { - // Hide current slide - this.slides[this.currentIndex].setAttribute('data-active', 'false'); - this.slides[this.currentIndex].style.display = 'none'; - this.slides[this.currentIndex].style.opacity = '0'; - - // Show new slide - this.slides[index].setAttribute('data-active', 'true'); - this.slides[index].style.display = 'block'; - this.slides[index].style.opacity = '1'; + // Update slides + this.setSlideState(this.slides[this.currentIndex], false); + this.setSlideState(this.slides[index], true); // Update indicators if (this.indicators.length > 0) { - this.indicators[this.currentIndex].setAttribute('data-active', 'false'); - this.indicators[index].setAttribute('data-active', 'true'); + this.setIndicatorState(this.indicators[this.currentIndex], false); + this.setIndicatorState(this.indicators[index], true); } this.currentIndex = index; @@ -118,13 +105,8 @@ class ImageCarousel { if (Math.abs(diff) < threshold) return; - if (diff > 0) { - // Swipe left - next slide - this.nextSlide(); - } else { - // Swipe right - previous slide - this.prevSlide(); - } + if (diff > 0) this.nextSlide(); + else this.prevSlide(); } private isInViewport(): boolean { @@ -330,16 +312,6 @@ function findSectionForCarousel(carousel: Element): Element | null { return section; } -// Make function available globally -declare global { - interface Window { - initImageCarousel: typeof initImageCarousel; - } -} - -// Assign the function to the global window object -window.initImageCarousel = initImageCarousel; - // Initialize all carousels when DOM is loaded document.addEventListener('DOMContentLoaded', () => { initImageCarousel(); diff --git a/src/Elastic.Markdown/Slices/Directives/ImageCarousel.cshtml b/src/Elastic.Markdown/Slices/Directives/ImageCarousel.cshtml index c8a5f0ba6..896d39c27 100644 --- a/src/Elastic.Markdown/Slices/Directives/ImageCarousel.cshtml +++ b/src/Elastic.Markdown/Slices/Directives/ImageCarousel.cshtml @@ -36,38 +36,3 @@ } - - - - \ No newline at end of file From e5b5ca28553812dc5bb369f437326802e4a212be Mon Sep 17 00:00:00 2001 From: Kylie Meli Date: Wed, 18 Jun 2025 15:54:26 -0400 Subject: [PATCH 05/16] adding fixed height setting and removing sample carousel addition --- docs/contribute/index.md | 18 ---------- .../Assets/markdown/image-carousel.css | 35 ++++++++++++++++++- .../Myst/Directives/DirectiveHtmlRenderer.cs | 3 +- .../Myst/Directives/ImageCarouselBlock.cs | 2 ++ .../Slices/Directives/ImageCarousel.cshtml | 8 +++-- .../Slices/Directives/_ViewModels.cs | 1 + 6 files changed, 45 insertions(+), 22 deletions(-) diff --git a/docs/contribute/index.md b/docs/contribute/index.md index 9e664649d..0af35e1c1 100644 --- a/docs/contribute/index.md +++ b/docs/contribute/index.md @@ -8,24 +8,6 @@ Welcome, **contributor**! Whether you're a technical writer or you've only edited Elastic docs once or twice, you're a valued contributor. Every word matters! -::::{carousel} -:id: nested-carousel-example -:controls: true -:indicators: true -:::{image} https://epr.elastic.co/package/abnormal_security/1.8.0/img/abnormal_security-mailbox_not_analyzed_overview.png -:alt: First image description -:title: First image title -::: -:::{image} https://epr.elastic.co/package/abnormal_security/1.8.0/img/abnormal_security-ai_security_mailbox_overview.png -:alt: Second image description -:title: Second image title -::: -:::{image} https://epr.elastic.co/package/abnormal_security/1.8.0/img/abnormal_security-audit_overview.png -:alt: Third image description -:title: Third image title -::: -:::: - ## Contribute to the docs [#contribute] The version of the docs you want to contribute to determines the tool and syntax you must use to update the docs. diff --git a/src/Elastic.Documentation.Site/Assets/markdown/image-carousel.css b/src/Elastic.Documentation.Site/Assets/markdown/image-carousel.css index 4bf6af36f..9dbc3894d 100644 --- a/src/Elastic.Documentation.Site/Assets/markdown/image-carousel.css +++ b/src/Elastic.Documentation.Site/Assets/markdown/image-carousel.css @@ -8,7 +8,7 @@ .carousel-track { width: 100%; position: relative; - min-height: 200px; /* Ensure container has height even when slides are absolute */ + min-height: 200px; } .carousel-slide { @@ -92,6 +92,39 @@ background-color: black; } +/* Fixed height carousel styles */ +.carousel-container[data-fixed-height] .carousel-track { + min-height: auto; + overflow: hidden; +} + +.carousel-container[data-fixed-height] .carousel-slide { + height: 100%; + top: 0; + left: 0; +} + +.carousel-container[data-fixed-height] .carousel-slide[data-active="true"] { + position: relative; + height: 100%; + top: auto; + left: auto; +} + +.carousel-container[data-fixed-height] .carousel-image-reference { + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.carousel-container[data-fixed-height] .carousel-image-reference img { + width: auto; + height: 100%; + max-width: 100%; + object-fit: contain; + object-position: center; +} @media (max-width: 768px) { .carousel-control { width: 30px; diff --git a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs index ecc25bc42..757ed665c 100644 --- a/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs +++ b/src/Elastic.Markdown/Myst/Directives/DirectiveHtmlRenderer.cs @@ -127,7 +127,8 @@ private static void WriteImageCarousel(HtmlRenderer renderer, ImageCarouselBlock ImageUrl = img.ImageUrl }).ToList(), ShowControls = block.ShowControls, - ShowIndicators = block.ShowIndicators + ShowIndicators = block.ShowIndicators, + FixedHeight = block.FixedHeight }); RenderRazorSlice(slice, renderer); } diff --git a/src/Elastic.Markdown/Myst/Directives/ImageCarouselBlock.cs b/src/Elastic.Markdown/Myst/Directives/ImageCarouselBlock.cs index 15902152a..d2fa544c4 100644 --- a/src/Elastic.Markdown/Myst/Directives/ImageCarouselBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/ImageCarouselBlock.cs @@ -16,6 +16,7 @@ public class ImageCarouselBlock(DirectiveBlockParser parser, ParserContext conte public string? Id { get; set; } public bool? ShowControls { get; set; } public bool? ShowIndicators { get; set; } + public string? FixedHeight { get; set; } public override string Directive => "carousel"; @@ -25,6 +26,7 @@ public override void FinalizeAndValidate(ParserContext context) Id = Prop("id"); ShowControls = TryPropBool("controls"); ShowIndicators = TryPropBool("indicators"); + FixedHeight = Prop("fixed-height"); // Process child image blocks directly foreach (var block in this) diff --git a/src/Elastic.Markdown/Slices/Directives/ImageCarousel.cshtml b/src/Elastic.Markdown/Slices/Directives/ImageCarousel.cshtml index 896d39c27..1ebd89517 100644 --- a/src/Elastic.Markdown/Slices/Directives/ImageCarousel.cshtml +++ b/src/Elastic.Markdown/Slices/Directives/ImageCarousel.cshtml @@ -1,6 +1,10 @@ @inherits RazorSlice -