tcmd: Accordion and popover md elements

This commit is contained in:
Emmaline Autumn 2024-02-28 21:41:12 -07:00
parent 2e9bfa1557
commit ff0a4280e2
22 changed files with 729 additions and 68 deletions

5
actions/temp.ts Normal file
View File

@ -0,0 +1,5 @@
"use server";
import { readFile } from "fs/promises";
export const readMD = async () => await readFile("./test.md", "utf-8");

View File

@ -8,7 +8,6 @@
} }
body { body {
@apply dark:bg-mixed-100 bg-primary-600 @apply dark:bg-mixed-100 bg-primary-600
/* background: linear-gradient(47deg, black, #620072, #120072); */
} }
input { input {
@apply py-2 px-4 rounded-full dark:bg-mixed-200 dark:bg-mixed-600 placeholder:text-dark-500 @apply py-2 px-4 rounded-full dark:bg-mixed-200 dark:bg-mixed-600 placeholder:text-dark-500
@ -38,4 +37,11 @@
.btn-secondary { .btn-secondary {
@apply text-primary-500 py-4 px-6 font-bold text-lg @apply text-primary-500 py-4 px-6 font-bold text-lg
} }
.p {
@apply py-1
}
.poppable {
@apply card bg-mixed-300 p-2 rounded-lg transition-opacity data-[visible=true]:z-10 data-[visible=true]:opacity-100 data-[visible=false]:opacity-0 -z-10 max-w-[400px] absolute
}
} }

View File

@ -23,7 +23,7 @@ export default function RootLayout({
return ( return (
<html lang="en"> <html lang="en">
<body className={inter.className + " flex min-h-[100vh]"}> <body className={inter.className + " flex min-h-[100vh]"}>
<nav className="h-[100vh] p-8 rounded-r-3xl dark:bg-mixed-300 bg-primary-400 w-max shadow-2xl"> <nav className="h-[100vh] fixed top-0 left-0 bottom-0 p-8 rounded-r-3xl dark:bg-mixed-300 bg-primary-400 w-max shadow-2xl">
<h1 className="text-lg font-bold pb-6 border-b dark:border-dark-500 border-primary-600"> <h1 className="text-lg font-bold pb-6 border-b dark:border-dark-500 border-primary-600">
Tabletop Commander Tabletop Commander
</h1> </h1>
@ -42,9 +42,10 @@ export default function RootLayout({
</li> </li>
</ul> </ul>
</nav> </nav>
<main className="p-8 w-full"> <main className="p-8 w-full ml-64">
{children} {children}
</main> </main>
<div id="root-portal"></div>
</body> </body>
</html> </html>
); );

View File

@ -1,3 +1,4 @@
import { readMD } from "@/actions/temp";
import { TCMD } from "@/components/tcmd"; import { TCMD } from "@/components/tcmd";
import { import {
BookOpenIcon, BookOpenIcon,
@ -16,7 +17,8 @@ export default function Home() {
<h2 className="strapline">Tabletop Commander</h2> <h2 className="strapline">Tabletop Commander</h2>
<h1 className="text-5xl font-bold">How does it work?</h1> <h1 className="text-5xl font-bold">How does it work?</h1>
</section> </section>
<section className="w-full my-6"> {
/* <section className="w-full my-6">
<div className="card"> <div className="card">
<p> <p>
Tabletop Commander (TC) is a rules-and-tools app for tabletop games Tabletop Commander (TC) is a rules-and-tools app for tabletop games
@ -135,13 +137,11 @@ export default function Home() {
will be done. If this makes it to production, tell Emma she forgot to will be done. If this makes it to production, tell Emma she forgot to
turn the home page into magic turn the home page into magic
</cite> </cite>
</section> </section> */
}
<Suspense> <Suspense>
<TCMD <TCMD
body={(async () => { body={readMD()}
"use server";
return await readFile("./test.md", "utf-8");
})()}
/> />
</Suspense> </Suspense>
</> </>

View File

@ -1,5 +1,7 @@
"use client"; "use client";
import { Accordion, AccordionContent } from "@/lib/accordion";
import { Poppable } from "@/lib/poppables/components/poppable";
import { createElements } from "@/lib/tcmd"; import { createElements } from "@/lib/tcmd";
import Link from "next/link"; import Link from "next/link";
import React, { FC, Fragment, ReactNode, use, useMemo } from "react"; import React, { FC, Fragment, ReactNode, use, useMemo } from "react";
@ -12,7 +14,9 @@ export const TCMD: FC<{ body: Promise<string> }> = ({ body }) => {
// <pre>{JSON.stringify(elements,null,2)}</pre> // <pre>{JSON.stringify(elements,null,2)}</pre>
// </div> // </div>
<div> <div>
{elements.map(renderBlock)} {elements.map((e, i) => (
<Fragment key={"tcmd-block" + i}>{renderBlock(e)}</Fragment>
))}
</div> </div>
); );
}; };
@ -20,7 +24,9 @@ export const TCMD: FC<{ body: Promise<string> }> = ({ body }) => {
const renderBlock = (block: BlockChildren): ReactNode => { const renderBlock = (block: BlockChildren): ReactNode => {
switch (block.type) { switch (block.type) {
case "block": case "block":
return block.children.map(renderBlock); return block.children.map((e, i) => (
<Fragment key={"tcmd-block" + i}>{renderBlock(e)}</Fragment>
));
case "grid": case "grid":
return ( return (
<div <div
@ -30,14 +36,36 @@ const renderBlock = (block: BlockChildren): ReactNode => {
className="grid grid-cols-dynamic gap-x-8 gap-y-6 mb-6" className="grid grid-cols-dynamic gap-x-8 gap-y-6 mb-6"
> >
{block.children.map((c, i) => ( {block.children.map((c, i) => (
<Fragment key={"block-grid" + c.type + i}> <div key={"block-grid" + c.type + i}>
{renderBlock(c)} {renderBlock(c)}
</Fragment> </div>
))} ))}
</div> </div>
); );
case "card": case "card":
return <div className="card">{block.children.map(renderBlock)}</div>; return (
<div className="card">
{block.children.map((e, i) => (
<Fragment key={"card-block" + i + e.type}>
{renderBlock(e)}
</Fragment>
))}
</div>
);
case "accordion":
return (
<Accordion
title={block.metadata.title || "Expand"}
>
<AccordionContent>
{block.children.map((e, i) => (
<Fragment key={"accordion" + e.type + "i"}>
{renderBlock(e)}
</Fragment>
))}
</AccordionContent>
</Accordion>
);
default: default:
return ( return (
renderParagraph(block as ParagraphToken) renderParagraph(block as ParagraphToken)
@ -48,7 +76,15 @@ const renderBlock = (block: BlockChildren): ReactNode => {
const renderParagraph = (p: ParagraphToken) => { const renderParagraph = (p: ParagraphToken) => {
switch (p.type) { switch (p.type) {
case "p": case "p":
return <p>{p.content.map(renderToken)}</p>; return (
<div className="p">
{p.content.map((e, i) => (
<Fragment key={"p-paragraph" + i + e.type}>
{renderToken(e)}
</Fragment>
))}
</div>
);
case "code": case "code":
return ( return (
<pre className="whitespace-pre-wrap"> <pre className="whitespace-pre-wrap">
@ -57,9 +93,9 @@ const renderParagraph = (p: ParagraphToken) => {
); );
default: default:
return ( return (
<p className="bg-red-600 text-white"> <div className="p bg-red-600 text-white">
Block or paragraph missing implementation: {p.type} Block or paragraph missing implementation: {p.type}
</p> </div>
); );
} }
}; };
@ -68,45 +104,49 @@ const renderToken = (t: Token) => {
switch (t.type) { switch (t.type) {
case "h1": case "h1":
return ( return (
<p <div
id={t.raw.toLowerCase().replace(/[^a-z\s]/ig, "").trim().replaceAll( id={t.raw.toLowerCase().replace(/[^a-z\s]/ig, "").trim().replaceAll(
" ", " ",
"-", "-",
)} )}
className="font-bold text-2xl" className="font-bold text-2xl p"
> >
{renderInlineToken(t.line)} {renderInlineToken(t.line)}
</p> </div>
); );
case "h2": case "h2":
return ( return (
<p <div
id={t.raw.toLowerCase().replace(/[^a-z\s]/ig, "").trim().replaceAll( id={t.raw.toLowerCase().replace(/[^a-z\s]/ig, "").trim().replaceAll(
" ", " ",
"-", "-",
)} )}
className="font-bold text-xl" className="font-bold text-xl p"
> >
{renderInlineToken(t.line)} {renderInlineToken(t.line)}
</p> </div>
); );
case "h3": case "h3":
return ( return (
<p <div
id={t.raw.toLowerCase().replace(/[^a-z\s]/ig, "").trim().replaceAll( id={t.raw.toLowerCase().replace(/[^a-z\s]/ig, "").trim().replaceAll(
" ", " ",
"-", "-",
)} )}
className="font-bold text-lg" className="font-bold text-lg p"
> >
{renderInlineToken(t.line)} {renderInlineToken(t.line)}
</p> </div>
); );
case "p": case "p":
return ( return (
<p> <div className="p">
{t.lines.map(renderToken)} {t.lines.map((e, i) => (
</p> <Fragment key={"p-line" + i + e.type}>
{renderInlineToken(e.line)}
</Fragment>
))}
</div>
); );
case "text": case "text":
return ( return (
@ -121,10 +161,10 @@ const renderToken = (t: Token) => {
return <li className="list-disc ml-8">{renderInlineToken(t.line)}</li>; return <li className="list-disc ml-8">{renderInlineToken(t.line)}</li>;
default: default:
return ( return (
<p className="text-white bg-red-500"> <div className="text-white bg-red-500 p">
Missing implementation for tcMD element `{(t as { type: string }) Missing implementation for tcMD element `{(t as { type: string })
.type}` .type}`
</p> </div>
); );
} }
}; };
@ -132,27 +172,44 @@ const renderToken = (t: Token) => {
const renderInlineToken = (l: Line) => { const renderInlineToken = (l: Line) => {
if (typeof l === "string") return l; if (typeof l === "string") return l;
return l.map((token) => { return l.map((token) => (
switch (token.type) { <Fragment key={"inline_token" + token.content}>
case "text": {(() => {
return <span>{token.content}</span>; switch (token.type) {
case "bold": case "text":
return <span className="font-bold">{token.content}</span>; return <span>{token.content}</span>;
case "anchor": case "bold":
return ( return <span className="font-bold">{token.content}</span>;
<Link className="text-primary-600" href={token.data.href}> case "anchor":
{token.content} return (
</Link> <Link className="text-primary-600" href={token.data.href}>
); {token.content}
case "image": </Link>
// eslint-disable-next-line @next/next/no-img-element );
return <img src={token.data.src} alt={token.content} />; case "image":
default: // eslint-disable-next-line @next/next/no-img-element
return ( return <img src={token.data.src} alt={token.content} />;
<span className="bg-red-500"> case "popover":
Inline element not implemented: {token.type} return (
</span> <Poppable
); content={token.data.popover}
} preferredAlign="centered"
}); preferredEdge="bottom"
className="cursor-pointer"
>
<span className="border-b border-dotted border-white">
{token.content}
</span>
</Poppable>
);
default:
return (
<span className="bg-red-500">
Inline element not implemented: {token.type}
</span>
);
}
})()}
</Fragment>
));
}; };

12
hooks/useDebounce.ts Normal file
View File

@ -0,0 +1,12 @@
import { useEffect, useState } from 'react';
export const useDebounce = (value: any, delay: number) => {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(handler);
}, [value, delay]);
return debouncedValue;
};

40
hooks/useInput.ts Normal file
View File

@ -0,0 +1,40 @@
import { useState, ChangeEvent } from 'react';
type InputHookConfig = {
disallowSpaces?: boolean;
spaceReplacer?: string;
}
export const useInput = <T extends string | number>(initialValue: T, config?: InputHookConfig) => {
const [value, setValue] = useState<T>(initialValue);
return {
value,
setValue,
reset: () => setValue(initialValue),
bind: {
value: value,
onChange: (event: ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const changed: string | number = typeof initialValue === 'number' ? parseInt(event.target.value) : config?.disallowSpaces ? event.target.value.replace(' ', config.spaceReplacer || '_') : event.target.value;
setValue(changed as T);
}
}
};
};
export const useCheckbox = (initial: boolean) => {
const [value, setValue] = useState(initial);
return {
value,
setValue,
reset: () => setValue(initial),
bind: {
checked: value,
onClick: () => {
setValue(v => !v);
},
readOnly: true
}
};
};

126
hooks/useObjectState.ts Normal file
View File

@ -0,0 +1,126 @@
import React, { ChangeEvent, useCallback, useState } from "react";
import { InputBinder } from "../types/inputBinder";
type ObjectStateHookConfig = {
disallowSpaces?: boolean;
spaceReplacer?: string;
};
export const useObjectState = <T extends object>(initial: T) => {
const [state, setState] = useState<T>(initial || {} as T);
const bindProperty = useCallback(
<K extends keyof T>(property: K, config?: ObjectStateHookConfig) => ({
value: state[property] ?? "",
name: property,
onChange: (
event: ChangeEvent<
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
>,
) =>
setState((value) => (
{
...value,
[event.target.name]: (
(typeof value[property] === "number")
? Number(event.target.value) || 0
: config?.disallowSpaces
? event.target.value.replace(" ", config.spaceReplacer || "_")
: event.target.value
),
}
)),
}),
[state],
);
const bindPropertyCheck = useCallback(<K extends keyof T>(property: K) => ({
checked: !!state[property],
name: property,
onChange: (event: ChangeEvent<HTMLInputElement>) =>
setState((value) => ({
...value,
[event.target.name]: (event.target.checked),
})),
readOnly: true,
}), [state]);
const update = useCallback(
(updates: Partial<T>) => setState((s) => ({ ...s, ...updates })),
[],
);
const reset = useCallback(() => {
setState(initial);
}, [initial]);
return {
bindProperty,
bindPropertyCheck,
update,
state,
setState,
reset,
};
};
export const useObjectStateWrapper = <T extends object>(
state: T,
setState: React.Dispatch<React.SetStateAction<T>>,
) => {
const bindProperty = useCallback(
<K extends keyof T>(
property: K,
config?: ObjectStateHookConfig,
): InputBinder => ({
value: state[property]?.toString() ?? "",
name: property.toString(),
onChange: (
event: ChangeEvent<
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
>,
) =>
setState((value) => (
{
...value,
[event.target.name]: (
(typeof value[property] === "number")
? Number(event.target.value) || 0
: config?.disallowSpaces
? event.target.value.replace(" ", config.spaceReplacer || "_")
: event.target.value
),
}
)),
}),
[setState, state],
);
const bindPropertyCheck = useCallback(<K extends keyof T>(property: K) => ({
checked: !!state[property],
name: property,
onChange: (event: ChangeEvent<HTMLInputElement>) =>
setState((value) => ({
...value,
[event.target.name]: (event.target.checked),
})),
readOnly: true,
}), [setState, state]);
const update = useCallback(
(updates: Partial<T> | ((arg: T) => Partial<T>)) =>
setState((s) => ({
...s,
...(typeof updates === "function" ? updates(s) : updates),
})),
[setState],
);
return {
bindProperty,
bindPropertyCheck,
update,
state,
setState,
};
};

10
hooks/useQueryParams.ts Normal file
View File

@ -0,0 +1,10 @@
import { useEffect, useRef } from 'react'
import { QueryParams } from '../utils/queryParams'
export const useQueryParams = (options?: {clean: boolean}) => {
const queryParams = useRef(new QueryParams(options))
useEffect(() => {
console.log(queryParams.current.get('test'))
}, [queryParams.current])
}

19
hooks/useRefCallback.ts Normal file
View File

@ -0,0 +1,19 @@
import { ReactNode, useCallback, useRef } from 'react'
export const useRefCallback = <T = ReactNode>() : [T | null, (arg: T) => void] => {
const ref = useRef<T | null>(null);
const setRef = useCallback((val: T) => {
console.log(val);
if (ref.current) {
// does something?
}
if (val) {
// also does something?
}
ref.current = val
}, [])
return [ref.current, setRef]
}

80
lib/accordion/index.tsx Normal file
View File

@ -0,0 +1,80 @@
import { FC, PropsWithChildren, ReactNode, useCallback, useState } from "react";
interface IProps {
expandOnHover?: boolean;
expanded?: boolean;
title?: ReactNode;
}
export const Accordion: FC<PropsWithChildren<IProps>> = (
{ children, expandOnHover, expanded, title },
) => {
const [open, setOpen] = useState(false);
return (
<div
data-expanded={open || expanded}
data-expandonhover={expandOnHover}
className={(expandOnHover ? "group/hover" : "group/controlled") +
" group"}
onClick={() => !title && !expandOnHover && setOpen(!open)}
>
{!!title && (
<div
className="flex justify-between cursor-pointer"
onClick={() => !expandOnHover && setOpen(!open)}
>
{title}
<div
className={`
group-hover/hover:-rotate-180
group-data-[expanded]:-rotate-180
transition-transform
duration-500
grid
rounded-full
h-min
mr-2
mt-1
scale-y-50
`}
>
<span className="block w-2 h-2 rotate-45 border-r-2 border-b-2 place-self-center">
</span>
<span className="block w-2 h-2 rotate-45 border-r-2 border-b-2 place-self-center">
</span>
</div>
</div>
)}
{children}
</div>
);
};
export const AccordionContent: FC<PropsWithChildren> = ({ children }) => {
const [height, setHeight] = useState(0);
const updateRef = useCallback((node: HTMLDivElement | null) => {
if (node) {
setHeight(node.clientHeight);
} else {
setHeight(0);
}
}, []);
const Child = () => (
<div className="absolute bottom-0 w-full" ref={updateRef}>
{children}
</div>
);
return (
<div className="relative overflow-hidden">
{<Child />}
<span
style={{ ["--v-height" as never]: height + "px" }}
className="w-0 block h-0 group-hover/hover:h-variable group-data-[expanded]/controlled:h-variable transition-all duration-700"
/>
</div>
);
};

View File

@ -0,0 +1,170 @@
import {
Dispatch,
FC,
PropsWithChildren,
SetStateAction,
useCallback,
useEffect,
useState,
} from "react";
import { bulkRound } from "../../utils/bulkRound";
import { clamp } from "../../utils/clamp";
import { Portal } from "../../portal/components";
type edge = "top" | "bottom" | "left" | "right";
type alignment = edge | "centered";
interface IProps {
preferredEdge: edge;
preferredAlign: alignment;
relativeElement: HTMLElement;
spacing?: number;
isClosing: boolean;
isClosed: boolean;
setHover: Dispatch<SetStateAction<boolean>>;
}
type position = { top: number; left: number; width?: number };
export const PoppableContent: FC<PropsWithChildren<IProps>> = (
{
preferredAlign,
preferredEdge,
children,
relativeElement,
spacing = 10,
setHover,
isClosing,
isClosed,
},
) => {
const [popRef, setPopRef] = useState<HTMLDivElement>();
const updateRef = useCallback((node: HTMLDivElement) => {
if (!node) return;
setPopRef(node);
}, []);
const getAlignment = useCallback(
(
relX: number,
relY: number,
relWidth: number,
relHeight: number,
popWidth: number,
popHeight: number,
edge: edge,
align: alignment,
): position => {
const pos = {
top: relY,
left: relX,
};
switch (align) {
case "centered":
pos.top = relY + (relHeight / 2) - (popHeight / 2);
pos.left = relX + (relWidth / 2) - (popWidth / 2);
break;
case "top":
pos.top = relY;
break;
case "bottom":
pos.top = relY + relHeight - popHeight;
break;
case "left":
pos.left = relX;
break;
case "right":
pos.left = relX + relWidth - popWidth;
break;
}
return pos;
},
[],
);
const getPosition = useCallback(
(popWidth: number, popHeight: number, edge: edge, align: alignment) => {
const rel = relativeElement.getBoundingClientRect();
const [relX, relY, relWidth, relHeight] = bulkRound(
rel.x,
rel.y,
rel.width,
rel.height,
);
const pos: position = { top: 100, left: 100 };
const alignment = getAlignment(
relX,
relY,
relWidth,
relHeight,
popWidth,
popHeight,
edge,
align,
);
switch (edge) {
case "top":
pos.top = relY - popHeight - spacing +
document.documentElement.scrollTop;
pos.left = alignment.left;
break;
case "bottom":
pos.top = relY + relHeight + spacing +
document.documentElement.scrollTop;
pos.left = alignment.left;
break;
case "left":
pos.left = relX - popWidth - spacing;
pos.top = alignment.top + document.documentElement.scrollTop;
break;
case "right":
pos.left = relX + relWidth + spacing;
pos.top = alignment.top + document.documentElement.scrollTop;
break;
}
return pos;
},
[getAlignment, relativeElement, spacing],
);
const getClampedPosition = useCallback(() => {
if (!popRef) return { opacity: 0 };
const pop = popRef.getBoundingClientRect();
const [popWidth, popHeight] = bulkRound(pop.width, pop.height);
const pos = getPosition(popWidth, popHeight, preferredEdge, preferredAlign);
const { innerHeight, innerWidth } = window;
pos.top = ["left", "right"].includes(preferredEdge)
? clamp(pos.top, spacing, innerHeight - popHeight - spacing)
: pos.top;
pos.left = ["top", "bottom"].includes(preferredEdge)
? clamp(pos.left, spacing, innerWidth - popWidth - spacing)
: pos.left;
return pos;
}, [popRef, getPosition, preferredEdge, preferredAlign, spacing]);
return (
<Portal>
<div
ref={updateRef}
style={getClampedPosition()}
data-fading={isClosing}
data-visible={!isClosing}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
// className="absolute w-[400px] border"
className="poppable"
>
{children}
</div>
</Portal>
);
};

View File

@ -0,0 +1,59 @@
import {
FC,
PropsWithChildren,
ReactNode,
useCallback,
useEffect,
useState,
} from "react";
import { PoppableContent } from "./poppable-content";
import { useDebounce } from "../../../hooks/useDebounce";
interface IProps {
content: ReactNode;
className?: string;
preferredEdge: "top" | "bottom" | "left" | "right";
preferredAlign: "centered" | "top" | "bottom" | "left" | "right";
spacing?: number;
}
export const Poppable: FC<PropsWithChildren<IProps>> = (
{ className, content, children, preferredEdge, preferredAlign, spacing },
) => {
const [isHovered, setIsHovered] = useState(false);
const closing = useDebounce(!isHovered, 1000);
const closed = useDebounce(closing, 300);
const [ref, setRef] = useState<HTMLElement>();
const updateRef = useCallback((node: HTMLElement) => {
if (!node) return;
setRef(node);
}, []);
return (
<>
<span
ref={updateRef}
className={className}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{children}
</span>
{!!ref && (
<PoppableContent
preferredAlign={preferredAlign}
preferredEdge={preferredEdge}
spacing={spacing}
isClosing={closing}
isClosed={closed}
relativeElement={ref}
setHover={setIsHovered}
>
{content}
</PoppableContent>
)}
</>
);
};

View File

@ -0,0 +1,27 @@
import { FC, PropsWithChildren, useEffect, useState } from "react";
import { createPortal } from "react-dom";
interface IProps {
className?: string;
el?: string;
}
export const Portal: FC<PropsWithChildren<IProps>> = (
{ children, className = "root-portal", el = "div" },
) => {
const [container] = useState(() => {
// This will be executed only on the initial render
// https://reactjs.org/docs/hooks-reference.html#lazy-initial-state
return document.getElementById("root-portal")!;
});
useEffect(() => {
container.classList.add(className);
document.body.appendChild(container);
return () => {
document.body.removeChild(container);
};
}, [className, container]);
return createPortal(children, container);
};

View File

@ -21,7 +21,6 @@ const tokenize = (body: string) => {
let openBT = blockTokens.findLast((bt) => !bt.closed); let openBT = blockTokens.findLast((bt) => !bt.closed);
if (block) { if (block) {
if (typeof block === "string") { if (typeof block === "string") {
console.log(block);
if (openBT) { if (openBT) {
openBT.closed = true; openBT.closed = true;
} }

View File

@ -24,7 +24,7 @@ export const inlineTokens: {
}, },
}, },
{ {
rx: /(?<!\!)\[(.*?)\]\((.*?)\)/g, rx: /(?<![\!\?|^])\[(.*?)\]\((.*?)\)/g,
create(content, start, end, tokens) { create(content, start, end, tokens) {
const [_, label, href] = content; const [_, label, href] = content;
tokens.push({ tokens.push({
@ -60,4 +60,22 @@ export const inlineTokens: {
return l; return l;
}, },
}, },
{
rx: /\^\[(.*?)\]\((.*?)\)/g,
create(content, start, end, tokens) {
const [_, text, popover] = content;
tokens.push({
content: text,
end,
start,
type: "popover",
data: {
popover,
},
});
},
replace(l) {
return l;
},
},
]; ];

View File

@ -41,4 +41,17 @@ const blockTokens: {
}; };
}, },
}, },
{
rx: /\[accordion\s?([a-z\s]*)\]/,
closeRx: /\[\/accordion\]/,
create(line) {
const title = line.match(this.rx)?.at(1);
return {
type: "accordion",
metadata: { title },
children: [],
closed: false,
};
},
},
]; ];

1
lib/utils/bulkRound.ts Normal file
View File

@ -0,0 +1 @@
export const bulkRound = (...args: number[]): number[] => args.map(n => Math.round(n));

2
lib/utils/clamp.ts Normal file
View File

@ -0,0 +1,2 @@
export const clamp = (value: number, min: number, max: number) =>
Math.max(Math.min(value, max), min)

View File

@ -4,8 +4,10 @@ const config: Config = {
content: [ content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}", "./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}", "./components/**/*.{js,ts,jsx,tsx,mdx}",
"./lib/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}", "./app/**/*.{js,ts,jsx,tsx,mdx}",
], ],
theme: { theme: {
extend: { extend: {
backgroundImage: { backgroundImage: {
@ -42,6 +44,9 @@ const config: Config = {
gridTemplateColumns: { gridTemplateColumns: {
dynamic: "repeat(var(--grid-cols), minmax(0,1fr))", dynamic: "repeat(var(--grid-cols), minmax(0,1fr))",
}, },
height: {
variable: "var(--v-height)",
},
}, },
}, },
plugins: [], plugins: [],

31
test.md
View File

@ -4,16 +4,17 @@
[[ [[
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor Lorem ^[ipsum](This is a popover test) dolor sit amet, consectetur adipiscing
incididunt ut labore et dolore magna aliqua. Sollicitudin tempor id eu nisl nunc elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
mi ipsum faucibus vitae. Lobortis elementum nibh tellus molestie nunc. Purus non Sollicitudin tempor id eu nisl nunc mi ipsum faucibus vitae. Lobortis elementum
enim praesent elementum facilisis leo vel. Orci nulla pellentesque dignissim nibh tellus molestie nunc. Purus non enim praesent elementum facilisis leo vel.
enim sit amet venenatis. Eu feugiat pretium nibh ipsum. Gravida dictum fusce ut Orci nulla pellentesque dignissim enim sit amet venenatis. Eu feugiat pretium
placerat orci nulla pellentesque. Tincidunt vitae semper quis lectus nulla at nibh ipsum. Gravida dictum fusce ut placerat orci nulla pellentesque. Tincidunt
volutpat diam ut. Proin sed libero enim sed faucibus turpis in eu mi. Dui sapien vitae semper quis lectus nulla at volutpat diam ut. Proin sed libero enim sed
eget mi proin sed libero enim sed faucibus. Felis donec et odio pellentesque faucibus turpis in eu mi. Dui sapien eget mi proin sed libero enim sed faucibus.
diam volutpat commodo sed egestas. Massa tincidunt dui ut ornare lectus sit amet Felis donec et odio pellentesque diam volutpat commodo sed egestas. Massa
est placerat. Auctor urna nunc id cursus metus aliquam eleifend. tincidunt dui ut ornare lectus sit amet est placerat. Auctor urna nunc id cursus
metus aliquam eleifend.
- Lorem ipsum dolor sit amet, - Lorem ipsum dolor sit amet,
- consectetur adipiscing elit, bananana banana ban anana anaba bananananana - consectetur adipiscing elit, bananana banana ban anana anaba bananananana
@ -74,6 +75,16 @@ const blockTokens: {
[[ [[
[accordion this is the title]
this is the test of a single accordion
[/accordion]
]]
[[
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
incididunt ut labore et dolore magna aliqua. Sollicitudin tempor id eu nisl nunc incididunt ut labore et dolore magna aliqua. Sollicitudin tempor id eu nisl nunc
mi ipsum faucibus vitae. Lobortis elementum nibh tellus molestie nunc. Purus non mi ipsum faucibus vitae. Lobortis elementum nibh tellus molestie nunc. Purus non

4
types.d.ts vendored
View File

@ -1,5 +1,5 @@
type InlineToken = { type InlineToken = {
type: "text" | "bold" | "anchor" | "image"; type: "text" | "bold" | "anchor" | "image" | "popover";
content: string; content: string;
data?: any; data?: any;
}; };
@ -42,7 +42,7 @@ type MultilineCfg = {
// -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-
type BlockToken = { type BlockToken = {
type: "block" | "grid" | "card"; type: "block" | "grid" | "card" | "accordion";
metadata: any; metadata: any;
children: BlockChildren[]; children: BlockChildren[];
parent?: string; parent?: string;