2022-07-23 22:56:48 +02:00

573 lines
18 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Markdown from "markdown-to-jsx";
import { useRouter } from "next/router";
import React, { Fragment, useMemo } from "react";
import ReactDOMServer from "react-dom/server";
import { HorizontalLine } from "components/HorizontalLine";
import { Img } from "components/Img";
import { InsetBox } from "components/InsetBox";
import { useAppLayout } from "contexts/AppLayoutContext";
import { AppStaticProps } from "graphql/getAppStaticProps";
import { cJoin } from "helpers/className";
import { slugify } from "helpers/formatters";
import { getAssetURL, ImageQuality } from "helpers/img";
import { isDefined, isDefinedAndNotEmpty, isUndefined } from "helpers/others";
import { useLightBox } from "hooks/useLightBox";
import { AnchorShare } from "components/AnchorShare";
/*
* ╭─────────────╮
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
*/
interface MarkdawnProps {
className?: string;
text: string;
}
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
export const Markdawn = ({
className,
text: rawText,
}: MarkdawnProps): JSX.Element => {
const { playerName } = useAppLayout();
const router = useRouter();
const [openLightBox, LightBox] = useLightBox();
/* eslint-disable no-irregular-whitespace */
const text = useMemo(
() => `${preprocessMarkDawn(rawText, playerName)}
`,
[playerName, rawText]
);
/* eslint-enable no-irregular-whitespace */
if (isUndefined(text) || text === "") {
return <></>;
}
return (
<>
<LightBox />
<Markdown
className={cJoin("formatted", className)}
options={{
slugify: slugify,
overrides: {
a: {
component: (compProps: {
href: string;
children: React.ReactNode;
}) => {
if (
compProps.href.startsWith("/") ||
compProps.href.startsWith("#")
) {
return (
<a onClick={async () => router.push(compProps.href)}>
{compProps.children}
</a>
);
}
return (
<a href={compProps.href} target="_blank" rel="noreferrer">
{compProps.children}
</a>
);
},
},
h1: {
component: (compProps: {
id: string;
style: React.CSSProperties;
children: React.ReactNode;
}) => (
<h1 id={compProps.id} style={compProps.style}>
{compProps.children}
<AnchorShare id={compProps.id} />
</h1>
),
},
h2: {
component: (compProps: {
id: string;
style: React.CSSProperties;
children: React.ReactNode;
}) => (
<h2 id={compProps.id} style={compProps.style}>
{compProps.children}
<AnchorShare id={compProps.id} />
</h2>
),
},
h3: {
component: (compProps: {
id: string;
style: React.CSSProperties;
children: React.ReactNode;
}) => (
<h3 id={compProps.id} style={compProps.style}>
{compProps.children}
<AnchorShare id={compProps.id} />
</h3>
),
},
h4: {
component: (compProps: {
id: string;
style: React.CSSProperties;
children: React.ReactNode;
}) => (
<h4 id={compProps.id} style={compProps.style}>
{compProps.children}
<AnchorShare id={compProps.id} />
</h4>
),
},
h5: {
component: (compProps: {
id: string;
style: React.CSSProperties;
children: React.ReactNode;
}) => (
<h5 id={compProps.id} style={compProps.style}>
{compProps.children}
<AnchorShare id={compProps.id} />
</h5>
),
},
h6: {
component: (compProps: {
id: string;
style: React.CSSProperties;
children: React.ReactNode;
}) => (
<h6 id={compProps.id} style={compProps.style}>
{compProps.children}
<AnchorShare id={compProps.id} />
</h6>
),
},
SceneBreak: {
component: (compProps: { id: string }) => (
<div
id={compProps.id}
className={"mt-16 mb-20 h-0 text-center text-3xl text-dark"}
>
* * *
</div>
),
},
IntraLink: {
component: (compProps: {
children: React.ReactNode;
target?: string;
page?: string;
}) => {
const slug = isDefinedAndNotEmpty(compProps.target)
? slugify(compProps.target)
: slugify(compProps.children?.toString());
return (
<a
onClick={async () =>
router.replace(`${compProps.page ?? ""}#${slug}`)
}
>
{compProps.children}
</a>
);
},
},
Transcript: {
component: (compProps) => (
<div className="grid grid-cols-[auto_1fr] gap-x-6 gap-y-2 mobile:grid-cols-1">
{compProps.children}
</div>
),
},
Line: {
component: (compProps) => (
<>
<strong className="!my-0 text-dark/60 mobile:!-mb-4">
<Markdawn text={compProps.name} />
</strong>
<p className="whitespace-pre-line">{compProps.children}</p>
</>
),
},
InsetBox: {
component: (compProps) => (
<InsetBox className="my-12">{compProps.children}</InsetBox>
),
},
li: {
component: (compProps: { children: React.ReactNode }) => (
<li
className={
isDefined(compProps.children) &&
ReactDOMServer.renderToStaticMarkup(
<>{compProps.children}</>
).length > 100
? "my-4"
: ""
}
>
{compProps.children}
</li>
),
},
Highlight: {
component: (compProps: { children: React.ReactNode }) => (
<mark>{compProps.children}</mark>
),
},
footer: {
component: (compProps: { children: React.ReactNode }) => (
<>
<HorizontalLine />
<div className="grid gap-8">{compProps.children}</div>
</>
),
},
blockquote: {
component: (compProps: {
children: React.ReactNode;
cite?: string;
}) => (
<blockquote>
{isDefinedAndNotEmpty(compProps.cite) ? (
<>
&ldquo;{compProps.children}&rdquo;
<cite> {compProps.cite}</cite>
</>
) : (
compProps.children
)}
</blockquote>
),
},
img: {
component: (compProps: {
alt: string;
src: string;
width?: number;
height?: number;
caption?: string;
name?: string;
}) => (
<div
className="mt-8 mb-12 grid cursor-pointer place-content-center"
onClick={() => {
openLightBox([
compProps.src.startsWith("/uploads/")
? getAssetURL(compProps.src, ImageQuality.Large)
: compProps.src,
]);
}}
>
<Img
image={
compProps.src.startsWith("/uploads/")
? getAssetURL(compProps.src, ImageQuality.Small)
: compProps.src
}
quality={ImageQuality.Medium}
className="drop-shadow-shade-lg"
></Img>
</div>
),
},
},
}}
>
{text}
</Markdown>
</>
);
};
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
interface TableOfContentsProps {
text: string;
title?: string;
langui: AppStaticProps["langui"];
}
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
export const TableOfContents = ({
text,
title,
langui,
}: TableOfContentsProps): JSX.Element => {
const router = useRouter();
const toc = useMemo(
() => getTocFromMarkdawn(preprocessMarkDawn(text), title),
[text, title]
);
return (
<>
<h3 className="text-xl">{langui.table_of_contents}</h3>
<div className="max-w-[14.5rem] text-left">
<p className="relative my-2 overflow-x-hidden text-ellipsis whitespace-nowrap text-left">
<a onClick={async () => router.replace(`#${toc.slug}`)}>
{<abbr title={toc.title}>{toc.title}</abbr>}
</a>
</p>
<TocLevel tocchildren={toc.children} parentNumbering="" />
</div>
</>
);
};
/*
* ╭──────────────────────╮
* ───────────────────────────────────╯ PRIVATE COMPONENTS ╰──────────────────────────────────────
*/
interface TocInterface {
title: string;
slug: string;
children: TocInterface[];
}
interface LevelProps {
tocchildren: TocInterface[];
parentNumbering: string;
}
const TocLevel = ({
tocchildren,
parentNumbering,
}: LevelProps): JSX.Element => {
const router = useRouter();
return (
<ol className="pl-4 text-left">
{tocchildren.map((child, childIndex) => (
<Fragment key={child.slug}>
<li className="my-2 w-full overflow-x-hidden text-ellipsis whitespace-nowrap">
<span className="text-dark">{`${parentNumbering}${
childIndex + 1
}.`}</span>{" "}
<a onClick={async () => router.replace(`#${child.slug}`)}>
{<abbr title={child.title}>{child.title}</abbr>}
</a>
</li>
<TocLevel
tocchildren={child.children}
parentNumbering={`${parentNumbering}${childIndex + 1}.`}
/>
</Fragment>
))}
</ol>
);
};
/*
* ╭──────────────────────╮
* ───────────────────────────────────╯ PRIVATE COMPONENTS ╰──────────────────────────────────────
*/
enum HeaderLevels {
H1 = 1,
H2 = 2,
H3 = 3,
H4 = 4,
H5 = 5,
H6 = 6,
}
const preprocessMarkDawn = (text: string, playerName = ""): string => {
if (!text) return "";
const processedPlayerName = playerName
.replaceAll("_", "\\_")
.replaceAll("*", "\\*");
let preprocessed = text
.replaceAll("--", "—")
.replaceAll(
"@player",
isDefinedAndNotEmpty(processedPlayerName)
? processedPlayerName
: "(player)"
);
let scenebreakIndex = 0;
const visitedSlugs: string[] = [];
preprocessed = preprocessed
.split("\n")
.map((line) => {
if (line === "* * *" || line === "---") {
scenebreakIndex++;
return `<SceneBreak id="scene-break-${scenebreakIndex}">`;
}
if (line.startsWith("# ")) {
return markdawnHeadersParser(HeaderLevels.H1, line, visitedSlugs);
}
if (line.startsWith("## ")) {
return markdawnHeadersParser(HeaderLevels.H2, line, visitedSlugs);
}
if (line.startsWith("### ")) {
return markdawnHeadersParser(HeaderLevels.H3, line, visitedSlugs);
}
if (line.startsWith("#### ")) {
return markdawnHeadersParser(HeaderLevels.H4, line, visitedSlugs);
}
if (line.startsWith("##### ")) {
return markdawnHeadersParser(HeaderLevels.H5, line, visitedSlugs);
}
if (line.startsWith("###### ")) {
return markdawnHeadersParser(HeaderLevels.H6, line, visitedSlugs);
}
return line;
})
.join("\n");
return preprocessed;
};
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
const markdawnHeadersParser = (
headerLevel: HeaderLevels,
line: string,
visitedSlugs: string[]
): string => {
const lineText = line.slice(headerLevel + 1);
const slug = slugify(lineText);
let newSlug = slug;
let index = 2;
while (visitedSlugs.includes(newSlug)) {
newSlug = `${slug}-${index}`;
index++;
}
visitedSlugs.push(newSlug);
return `<h${headerLevel} id="${newSlug}">${lineText}</h${headerLevel}>`;
};
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
const getTocFromMarkdawn = (text: string, title?: string): TocInterface => {
const toc: TocInterface = {
title: title ?? "Return to top",
slug: slugify(title),
children: [],
};
let h2 = -1;
let h3 = -1;
let h4 = -1;
let h5 = -1;
let scenebreak = 0;
let scenebreakIndex = 0;
const getTitle = (line: string): string =>
line.slice(line.indexOf(`">`) + 2, line.indexOf("</"));
const getSlug = (line: string): string =>
line.slice(line.indexOf(`id="`) + 4, line.indexOf(`">`));
text.split("\n").map((line) => {
if (line.startsWith("<h1 id=")) {
toc.title = getTitle(line);
toc.slug = getSlug(line);
} else if (line.startsWith("<h2 id=")) {
toc.children.push({
title: getTitle(line),
slug: getSlug(line),
children: [],
});
h2++;
h3 = -1;
h4 = -1;
h5 = -1;
scenebreak = 0;
} else if (h2 >= 0 && line.startsWith("<h3 id=")) {
toc.children[h2].children.push({
title: getTitle(line),
slug: getSlug(line),
children: [],
});
h3++;
h4 = -1;
h5 = -1;
scenebreak = 0;
} else if (h3 >= 0 && line.startsWith("<h4 id=")) {
toc.children[h2].children[h3].children.push({
title: getTitle(line),
slug: getSlug(line),
children: [],
});
h4++;
h5 = -1;
scenebreak = 0;
} else if (h4 >= 0 && line.startsWith("<h5 id=")) {
toc.children[h2].children[h3].children[h4].children.push({
title: getTitle(line),
slug: getSlug(line),
children: [],
});
h5++;
scenebreak = 0;
} else if (h5 >= 0 && line.startsWith("<h6 id=")) {
toc.children[h2].children[h3].children[h4].children[h5].children.push({
title: getTitle(line),
slug: getSlug(line),
children: [],
});
} else if (line.startsWith(`<SceneBreak`)) {
scenebreak++;
scenebreakIndex++;
const child = {
title: `Scene break ${scenebreak}`,
slug: slugify(`scene-break-${scenebreakIndex}`),
children: [],
};
if (h5 >= 0) {
toc.children[h2].children[h3].children[h4].children[h5].children.push(
child
);
} else if (h4 >= 0) {
toc.children[h2].children[h3].children[h4].children.push(child);
} else if (h3 >= 0) {
toc.children[h2].children[h3].children.push(child);
} else if (h2 >= 0) {
toc.children[h2].children.push(child);
} else {
toc.children.push(child);
}
}
});
return toc;
};