Improve editor and no longer crash if markdawn Line has bad parameters

This commit is contained in:
DrMint 2023-04-08 16:34:27 +02:00
parent b6882cd1e5
commit d68e238b00
3 changed files with 221 additions and 48 deletions

View File

@ -1,8 +1,10 @@
// eslint-disable-next-line import/named
import { Placement } from "tippy.js";
import { Button } from "./Button"; import { Button } from "./Button";
import { ToolTip } from "components/ToolTip"; import { ToolTip } from "components/ToolTip";
import { cJoin } from "helpers/className"; import { cJoin } from "helpers/className";
import { ConditionalWrapper, Wrapper } from "helpers/component"; import { ConditionalWrapper, Wrapper } from "helpers/component";
import { isDefinedAndNotEmpty } from "helpers/asserts"; import { isDefined } from "helpers/asserts";
/* /*
* *
@ -12,7 +14,8 @@ import { isDefinedAndNotEmpty } from "helpers/asserts";
interface Props { interface Props {
className?: string; className?: string;
buttonsProps: (Parameters<typeof Button>[0] & { buttonsProps: (Parameters<typeof Button>[0] & {
tooltip?: string | null | undefined; tooltip?: React.ReactNode | null | undefined;
tooltipPlacement?: Placement;
})[]; })[];
} }
@ -23,9 +26,9 @@ export const ButtonGroup = ({ buttonsProps, className }: Props): JSX.Element =>
{buttonsProps.map((buttonProps, index) => ( {buttonsProps.map((buttonProps, index) => (
<ConditionalWrapper <ConditionalWrapper
key={index} key={index}
isWrapping={isDefinedAndNotEmpty(buttonProps.tooltip)} isWrapping={isDefined(buttonProps.tooltip)}
wrapper={ToolTipWrapper} wrapper={ToolTipWrapper}
wrapperProps={{ text: buttonProps.tooltip ?? "" }}> wrapperProps={{ text: buttonProps.tooltip ?? "", placement: buttonProps.tooltipPlacement }}>
<Button <Button
{...buttonProps} {...buttonProps}
className={ className={
@ -47,11 +50,12 @@ export const ButtonGroup = ({ buttonsProps, className }: Props): JSX.Element =>
*/ */
interface ToolTipWrapperProps { interface ToolTipWrapperProps {
text: string; text: React.ReactNode;
placement?: Placement;
} }
const ToolTipWrapper = ({ text, children }: ToolTipWrapperProps & Wrapper) => ( const ToolTipWrapper = ({ text, children, placement }: ToolTipWrapperProps & Wrapper) => (
<ToolTip content={text}> <ToolTip content={text} placement={placement}>
<>{children}</> <>{children}</>
</ToolTip> </ToolTip>
); );

View File

@ -1,6 +1,7 @@
import Markdown from "markdown-to-jsx"; import Markdown from "markdown-to-jsx";
import React, { Fragment, MouseEventHandler, useMemo } from "react"; import React, { Fragment, MouseEventHandler, useMemo } from "react";
import ReactDOMServer from "react-dom/server"; import ReactDOMServer from "react-dom/server";
import { z } from "zod";
import { HorizontalLine } from "components/HorizontalLine"; import { HorizontalLine } from "components/HorizontalLine";
import { Img } from "components/Img"; import { Img } from "components/Img";
import { InsetBox } from "components/Containers/InsetBox"; import { InsetBox } from "components/Containers/InsetBox";
@ -35,7 +36,7 @@ export const Markdawn = ({ className, text: rawText }: MarkdawnProps): JSX.Eleme
const { showLightBox } = useAtomGetter(atoms.lightBox); const { showLightBox } = useAtomGetter(atoms.lightBox);
/* eslint-disable no-irregular-whitespace */ /* eslint-disable no-irregular-whitespace */
const text = `${preprocessMarkDawn(rawText, playerName)} const text = `${preprocessMarkDawn(rawText, playerName)}\n
`; `;
/* eslint-enable no-irregular-whitespace */ /* eslint-enable no-irregular-whitespace */
@ -117,15 +118,30 @@ export const Markdawn = ({ className, text: rawText }: MarkdawnProps): JSX.Eleme
}, },
Line: { Line: {
component: (compProps) => ( component: (compProps) => {
const schema = z.object({ name: z.string(), children: z.any() });
if (!schema.safeParse(compProps).success) {
return (
<MarkdawnError
message={`Error while parsing a <Line/> tag. Here is the correct usage:
<Line name="John">Hello!</Line>`}
/>
);
}
const safeProps: z.infer<typeof schema> = compProps;
return (
<> <>
<strong <strong
className={cJoin("!my-0 text-dark/60", cIf(!isContentPanelAtLeastLg, "!-mb-4"))}> className={cJoin(
<Markdawn text={compProps.name} /> "!my-0 text-dark/60",
cIf(!isContentPanelAtLeastLg, "!-mb-4")
)}>
<Markdawn text={safeProps.name} />
</strong> </strong>
<p className="whitespace-pre-line">{compProps.children}</p> <p className="whitespace-pre-line">{safeProps.children}</p>
</> </>
), );
},
}, },
InsetBox: { InsetBox: {
@ -215,6 +231,21 @@ export const Markdawn = ({ className, text: rawText }: MarkdawnProps): JSX.Eleme
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ // ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
interface MarkdawnErrorProps {
message: string;
}
const MarkdawnError = ({ message }: MarkdawnErrorProps): JSX.Element => (
<div
className="flex place-items-center gap-4 whitespace-pre-line rounded-md
bg-[red]/10 px-4 text-[red]">
<Ico icon="error" isFilled={false} />
<p>{message}</p>
</div>
);
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
interface TableOfContentsProps { interface TableOfContentsProps {
toc: TocInterface; toc: TocInterface;
onContentClicked?: MouseEventHandler<HTMLAnchorElement>; onContentClicked?: MouseEventHandler<HTMLAnchorElement>;

View File

@ -12,6 +12,7 @@ import { getOpenGraph } from "helpers/openGraph";
import { getFormat } from "helpers/i18n"; import { getFormat } from "helpers/i18n";
import { isDefined } from "helpers/asserts"; import { isDefined } from "helpers/asserts";
import { atomPairing, useAtomPair } from "helpers/atoms"; import { atomPairing, useAtomPair } from "helpers/atoms";
import { ButtonGroup } from "components/Inputs/ButtonGroup";
/* /*
* *
@ -39,23 +40,23 @@ const Editor = (props: Props): JSX.Element => {
value: string, value: string,
selectionStart: number, selectionStart: number,
selectedEnd: number selectedEnd: number
) => { prependLength: number; transformedValue: string } ) => { prependLength: number; selectionLength?: number; transformedValue: string }
) => { ) => {
if (textAreaRef.current) { if (textAreaRef.current) {
const { value, selectionStart, selectionEnd } = textAreaRef.current; const { value, selectionStart, selectionEnd } = textAreaRef.current;
const { prependLength, transformedValue } = transformation( const {
value, prependLength,
selectionStart, transformedValue,
selectionEnd selectionLength = selectionEnd - selectionStart,
); } = transformation(value, selectionStart, selectionEnd);
textAreaRef.current.value = transformedValue; textAreaRef.current.value = transformedValue;
setMarkdown(textAreaRef.current.value); setMarkdown(textAreaRef.current.value);
textAreaRef.current.focus(); textAreaRef.current.focus();
textAreaRef.current.selectionStart = selectionStart + prependLength; textAreaRef.current.selectionStart = selectionStart + prependLength;
textAreaRef.current.selectionEnd = selectionEnd + prependLength; textAreaRef.current.selectionEnd = selectionStart + selectionLength + prependLength;
} }
}, },
[setMarkdown] [setMarkdown]
@ -92,13 +93,13 @@ const Editor = (props: Props): JSX.Element => {
); );
const unwrap = useCallback( const unwrap = useCallback(
(wrapper: string) => { (openingWrapper: string, closingWrapper: string) => {
transformationWrapper((value, selectionStart, selectionEnd) => { transformationWrapper((value, selectionStart, selectionEnd) => {
let newValue = ""; let newValue = "";
newValue += value.slice(0, selectionStart - wrapper.length); newValue += value.slice(0, selectionStart - openingWrapper.length);
newValue += value.slice(selectionStart, selectionEnd); newValue += value.slice(selectionStart, selectionEnd);
newValue += value.slice(wrapper.length + selectionEnd); newValue += value.slice(closingWrapper.length + selectionEnd);
return { prependLength: -wrapper.length, transformedValue: newValue }; return { prependLength: -openingWrapper.length, transformedValue: newValue };
}); });
}, },
[transformationWrapper] [transformationWrapper]
@ -109,11 +110,16 @@ const Editor = (props: Props): JSX.Element => {
if (textAreaRef.current) { if (textAreaRef.current) {
const { value, selectionStart, selectionEnd } = textAreaRef.current; const { value, selectionStart, selectionEnd } = textAreaRef.current;
const openingWrapper =
properties && Object.keys(properties).length === 0 ? `<${wrapper}>` : wrapper;
const closingWrapper =
properties && Object.keys(properties).length === 0 ? `</${wrapper}>` : wrapper;
if ( if (
value.slice(selectionStart - wrapper.length, selectionStart) === wrapper && value.slice(selectionStart - openingWrapper.length, selectionStart) === openingWrapper &&
value.slice(selectionEnd, selectionEnd + wrapper.length) === wrapper value.slice(selectionEnd, selectionEnd + closingWrapper.length) === closingWrapper
) { ) {
unwrap(wrapper); unwrap(openingWrapper, closingWrapper);
} else { } else {
wrap(wrapper, properties, addInnerNewLines); wrap(wrapper, properties, addInnerNewLines);
} }
@ -124,20 +130,77 @@ const Editor = (props: Props): JSX.Element => {
const preline = useCallback( const preline = useCallback(
(prepend: string) => { (prepend: string) => {
transformationWrapper((value, selectionStart) => { transformationWrapper((value, selectionStart, selectionEnd) => {
const lastNewLine = value.slice(0, selectionStart).lastIndexOf("\n") + 1; const lastNewLine = value.slice(0, selectionStart).lastIndexOf("\n") + 1;
const nextNewLine = value.slice(selectionEnd).indexOf("\n") + selectionEnd;
const lines = value.slice(lastNewLine, nextNewLine).split("\n");
const processedLines = lines.map((line) => `${prepend}${line}`);
let newValue = ""; let newValue = "";
newValue += value.slice(0, lastNewLine); newValue += value.slice(0, lastNewLine);
newValue += prepend; newValue += processedLines.join("\n");
newValue += value.slice(lastNewLine); newValue += value.slice(nextNewLine);
return { prependLength: prepend.length, transformedValue: newValue }; return {
prependLength: prepend.length,
selectionLength: selectionEnd - selectionStart + (lines.length - 1) * prepend.length,
transformedValue: newValue,
};
}); });
}, },
[transformationWrapper] [transformationWrapper]
); );
const unpreline = useCallback(
(prepend: string) => {
transformationWrapper((value, selectionStart, selectionEnd) => {
const lastNewLine = value.slice(0, selectionStart).lastIndexOf("\n") + 1;
const nextNewLine = value.slice(selectionEnd).indexOf("\n") + selectionEnd;
const lines = value.slice(lastNewLine, nextNewLine).split("\n");
const processedLines = lines.map((line) =>
line.startsWith(prepend) ? line.slice(prepend.length) : line
);
let newValue = "";
newValue += value.slice(0, lastNewLine);
newValue += processedLines.join("\n");
newValue += value.slice(nextNewLine);
return {
prependLength: -prepend.length,
selectionLength: selectionEnd - selectionStart + (lines.length - 1) * -prepend.length,
transformedValue: newValue,
};
});
},
[transformationWrapper]
);
const togglePreline = useCallback(
(prepend: string) => {
if (!textAreaRef.current) {
return;
}
const { value, selectionStart, selectionEnd } = textAreaRef.current;
const lastNewLine = value.slice(0, selectionStart).lastIndexOf("\n") + 1;
const nextNewLine = value.slice(selectionEnd).indexOf("\n") + selectionEnd;
const lines = value.slice(lastNewLine, nextNewLine).split("\n");
if (lines.every((line) => line.startsWith(prepend))) {
unpreline(prepend);
} else {
preline(prepend);
}
},
[preline, unpreline]
);
const insert = useCallback( const insert = useCallback(
(prepend: string) => { (prepend: string) => {
transformationWrapper((value, selectionStart) => { transformationWrapper((value, selectionStart) => {
@ -208,23 +271,48 @@ const Editor = (props: Props): JSX.Element => {
content={ content={
<div className="grid gap-2"> <div className="grid gap-2">
<h3 className="text-lg">Headers</h3> <h3 className="text-lg">Headers</h3>
<Button onClick={() => preline("# ")} text={"H1"} /> <Button onClick={() => togglePreline("# ")} text={"H1"} />
<Button onClick={() => preline("## ")} text={"H2"} /> <Button onClick={() => togglePreline("## ")} text={"H2"} />
<Button onClick={() => preline("### ")} text={"H3"} /> <Button onClick={() => togglePreline("### ")} text={"H3"} />
<Button onClick={() => preline("#### ")} text={"H4"} /> <Button onClick={() => togglePreline("#### ")} text={"H4"} />
<Button onClick={() => preline("##### ")} text={"H5"} /> <Button onClick={() => togglePreline("##### ")} text={"H5"} />
<Button onClick={() => preline("###### ")} text={"H6"} /> <Button onClick={() => togglePreline("###### ")} text={"H6"} />
</div> </div>
}> }>
<Button icon="title" /> <Button icon="title" />
</ToolTip> </ToolTip>
<ToolTip placement="bottom" content={<h3 className="text-lg">Toggle Bold</h3>}> <ButtonGroup
<Button onClick={() => toggleWrap("**")} icon="format_bold" /> buttonsProps={[
</ToolTip> {
onClick: () => toggleWrap("**"),
tooltip: <h3 className="text-lg">Toggle Bold</h3>,
tooltipPlacement: "bottom",
icon: "format_bold",
},
{
onClick: () => toggleWrap("_"),
tooltip: <h3 className="text-lg">Toggle Italic</h3>,
tooltipPlacement: "bottom",
icon: "format_italic",
},
{
onClick: () => toggleWrap("u", {}),
tooltip: <h3 className="text-lg">Toggle Underline</h3>,
tooltipPlacement: "bottom",
icon: "format_underlined",
},
{
onClick: () => toggleWrap("~~"),
tooltip: <h3 className="text-lg">Toggle Strikethrough</h3>,
tooltipPlacement: "bottom",
icon: "strikethrough_s",
},
]}
/>
<ToolTip placement="bottom" content={<h3 className="text-lg">Toggle Italic</h3>}> <ToolTip placement="bottom" content={<h3 className="text-lg">Highlight</h3>}>
<Button onClick={() => toggleWrap("_")} icon="format_italic" /> <Button onClick={() => toggleWrap("==")} icon="format_ink_highlighter" />
</ToolTip> </ToolTip>
<ToolTip <ToolTip
@ -241,6 +329,40 @@ const Editor = (props: Props): JSX.Element => {
<Button onClick={() => toggleWrap("`")} icon="code" /> <Button onClick={() => toggleWrap("`")} icon="code" />
</ToolTip> </ToolTip>
<ButtonGroup
buttonsProps={[
{
onClick: () => togglePreline("- "),
icon: "format_list_bulleted",
tooltip: <h3 className="text-lg">Bulleted List</h3>,
tooltipPlacement: "bottom",
},
{
onClick: () => togglePreline("1. "),
icon: "format_list_numbered",
tooltip: <h3 className="text-lg">Numbered List</h3>,
tooltipPlacement: "bottom",
},
]}
/>
<ButtonGroup
buttonsProps={[
{
onClick: () => preline("\t"),
icon: "format_indent_increase",
tooltip: <h3 className="text-lg">Increase Indent</h3>,
tooltipPlacement: "bottom",
},
{
onClick: () => unpreline("\t"),
icon: "format_indent_decrease",
tooltip: <h3 className="text-lg">Decrease Indent</h3>,
tooltipPlacement: "bottom",
},
]}
/>
<ToolTip <ToolTip
placement="bottom" placement="bottom"
content={ content={
@ -299,12 +421,28 @@ const Editor = (props: Props): JSX.Element => {
<Button icon="record_voice_over" /> <Button icon="record_voice_over" />
</ToolTip> </ToolTip>
<ToolTip placement="bottom" content={<h3 className="text-lg">Inset box</h3>}> <ToolTip
<Button onClick={() => wrap("InsetBox", {}, true)} icon="check_box_outline_blank" /> placement="bottom"
content={
<>
<h3 className="text-lg">Layouts</h3>
<div className="grid gap-2">
<Button
onClick={() => wrap("InsetBox", {}, true)}
icon="check_box_outline_blank"
text="InsetBox"
/>
<Button onClick={() => togglePreline("> ")} icon="format_quote" text="Blockquote" />
</div>
</>
}>
<Button icon="dashboard" />
</ToolTip> </ToolTip>
<ToolTip placement="bottom" content={<h3 className="text-lg">Scene break</h3>}> <ToolTip placement="bottom" content={<h3 className="text-lg">Scene break</h3>}>
<Button onClick={() => insert("\n* * *\n")} icon="more_horiz" /> <Button onClick={() => insert("\n* * *\n")} icon="more_horiz" />
</ToolTip> </ToolTip>
<ToolTip <ToolTip
content={ content={
<div className="flex flex-col place-items-center gap-2"> <div className="flex flex-col place-items-center gap-2">