feat: add support for collapsible callouts

This commit is contained in:
Oliver Booth 2024-05-04 13:11:49 +01:00
parent 29ed46eb9e
commit 7ede8b13fa
Signed by: oliverbooth
GPG Key ID: E60B570D1B7557B5
6 changed files with 135 additions and 7 deletions

View File

@ -17,6 +17,12 @@ internal sealed class CalloutBlock : QuoteBlock
Type = type;
}
/// <summary>
/// Gets or sets a value indicating whether this callout is foldable.
/// </summary>
/// <value><see langword="true" /> if this callout is foldable; otherwise, <see langword="false" />.</value>
public bool Foldable { get; set; }
/// <summary>
/// Gets or sets the title of the callout.
/// </summary>

View File

@ -16,10 +16,6 @@ internal sealed class CalloutInlineParser : InlineParser
private static readonly MethodInfo ReplaceParentContainerMethod =
typeof(InlineProcessor).GetMethod("ReplaceParentContainer", BindingFlags.Instance | BindingFlags.NonPublic)!;
// but we can at least make it a bit nicer to access
private static readonly Action<InlineProcessor, ContainerBlock, ContainerBlock> ReplaceParentContainer =
(processor, before, after) => ReplaceParentContainerMethod.Invoke(processor, [before, after]);
/// <summary>
/// Initializes a new instance of the <see cref="CalloutInlineParser" /> class.
/// </summary>
@ -70,10 +66,19 @@ internal sealed class CalloutInlineParser : InlineParser
current = slice.NextChar(); // skip ]
start = slice.Start;
ReadTitle(current, ref slice, out StringSlice title, start, out end);
bool fold = false;
if (current == '-')
{
fold = true;
current = slice.NextChar(); // skip -
start = slice.Start;
}
ReadTitle(current, ref slice, out StringSlice title, out end);
var callout = new CalloutBlock(type)
{
Foldable = fold,
Span = quoteBlock.Span,
TrailingWhitespaceTrivia = new StringSlice(slice.Text, start, end),
Line = quoteBlock.Line,
@ -86,7 +91,7 @@ internal sealed class CalloutInlineParser : InlineParser
return true;
}
private static void ReadTitle(char startChar, ref StringSlice slice, out StringSlice title, int start, out int end)
private static void ReadTitle(char startChar, ref StringSlice slice, out StringSlice title, out int end)
{
using Utf16ValueStringBuilder builder = ZString.CreateStringBuilder();

View File

@ -67,7 +67,13 @@ internal sealed class CalloutRenderer : HtmlObjectRenderer<CalloutBlock>
var typeString = type.ToString().ToLowerInvariant();
renderer.Write($"<div class=\"callout\" data-callout=\"{typeString}\">");
renderer.Write($"<div class=\"callout\" data-callout=\"{typeString}\"");
if (block.Foldable)
{
renderer.Write(" data-callout-fold=\"true\"");
}
renderer.Write('>');
renderer.Write("<div class=\"callout-title\"><i data-lucide=\"");
renderer.Write(lucideClass);
renderer.Write("\"></i> ");
@ -75,9 +81,16 @@ internal sealed class CalloutRenderer : HtmlObjectRenderer<CalloutBlock>
string calloutTitle = title.Length == 0 ? typeString.Humanize(LetterCasing.Sentence) : title;
WriteTitle(renderer, pipeline, calloutTitle);
if (block.Foldable)
{
renderer.Write("<span class=\"callout-fold\"><i data-lucide=\"chevron-down\"></i></span>");
}
renderer.WriteLine("</div>");
renderer.Write("<div class=\"callout-content\">");
renderer.WriteChildren(block);
renderer.WriteLine("</div>");
renderer.WriteLine("</div>");
renderer.EnsureLine();
}

View File

@ -29,6 +29,45 @@ $callout-fg-grey: #9e9e9e;
margin-bottom: 0;
}
&.collapsible {
.callout-fold {
transform: rotate(180deg);
transition: transform 500ms;
margin-left: 0.5em;
svg {
transform: rotate(180deg);
transition: transform 500ms;
}
}
.callout-title {
cursor: pointer;
transition: margin-bottom 500ms;
}
.callout-content {
opacity: 1;
max-height: 500px;
transition: opacity 500ms, max-height 500ms;
}
&.collapsed {
.callout-title {
margin-bottom: 0;
}
.callout-fold svg {
transform: rotate(0deg);
}
.callout-content {
opacity: 0;
max-height: 0;
}
}
}
&[data-callout="note"] {
background-color: $callout-bg-blue;

63
src/ts/Callout.ts Normal file
View File

@ -0,0 +1,63 @@
class Callout {
private readonly _callout: HTMLElement;
private readonly _title: HTMLElement;
private readonly _content: HTMLElement;
private _foldEnabled: boolean;
constructor(element: HTMLElement) {
this._callout = element;
this._title = element.querySelector(".callout-title");
this._content = element.querySelector(".callout-content");
}
public static foldAll(element?: HTMLElement): void {
element = element || document.body;
this.findAll(element).forEach(c => c.fold());
}
public static findAll(element?: HTMLElement): Array<Callout> {
element = element || document.body;
return Array.from(element.querySelectorAll("div.callout")).map(c => {
return new Callout(c as HTMLElement);
});
}
public get content(): HTMLElement {
return this._content;
}
public get element(): HTMLElement {
return this._callout;
}
public get isFoldable(): boolean {
const fold: string = this._callout.dataset.calloutFold;
return fold !== null && fold !== undefined;
}
public get title(): HTMLElement {
return this._title;
}
public fold(): void {
if (this._foldEnabled || !this.isFoldable) {
return;
}
const callout: HTMLElement = this._callout;
if (callout === null) {
console.error("Callout element for ", this, " is null!");
return;
}
callout.classList.add("collapsible", "collapsed");
this._title.addEventListener("click", () => {
callout.classList.toggle("collapsed");
});
this._foldEnabled = true;
}
}
export default Callout;

View File

@ -3,12 +3,14 @@ import UI from "./UI";
import Input from "./Input";
import Author from "./Author";
import BlogPost from "./BlogPost";
import Callout from "./Callout";
declare const Handlebars: any;
declare const Prism: any;
declare const lucide: any;
(() => {
Callout.foldAll();
lucide.createIcons();
Prism.languages.extend('markup', {});