Skip to content

Commit

Permalink
feat: implement basic markdown editor
Browse files Browse the repository at this point in the history
Currently onlu supports bold, italic, link and code blocks, including keyboard shortcuts.

Also has a preview mode for rendered markdown.
  • Loading branch information
sunaurus committed Apr 14, 2024
1 parent 8136ba5 commit 5ae0737
Show file tree
Hide file tree
Showing 6 changed files with 276 additions and 21 deletions.
10 changes: 5 additions & 5 deletions src/app/(ui)/form/TextArea.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
import { DetailedHTMLProps, InputHTMLAttributes } from "react";
import classNames from "classnames";

export type Props = DetailedHTMLProps<
export type TextAreaProps = DetailedHTMLProps<
InputHTMLAttributes<HTMLTextAreaElement>,
HTMLTextAreaElement
>;

export const TextArea = ({ className, ...rest }: Props) => {
export const TextArea = ({ className, ...rest }: TextAreaProps) => {
return (
<textarea
className={classNames(
`autofill:bg-primary-700 focus:border-primary-500 focus:ring-primary-500 block
w-full rounded border border-neutral-600 bg-neutral-700 p-2 text-sm text-white
placeholder-neutral-400 focus:outline-none`,
`block w-full rounded border border-neutral-600 bg-neutral-700 p-2 text-sm
text-white placeholder-neutral-400 autofill:bg-primary-700
focus:border-primary-500 focus:outline-none focus:ring-primary-500`,
className,
)}
{...rest}
Expand Down
252 changes: 252 additions & 0 deletions src/app/(ui)/markdown/MarkdownTextArea.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
"use client";

import { TextArea, TextAreaProps } from "@/app/(ui)/form/TextArea";
import {
ChangeEvent,
KeyboardEvent,
ReactNode,
useEffect,
useState,
} from "react";
import classNames from "classnames";
import { CodeBracketIcon, LinkIcon } from "@heroicons/react/16/solid";
import { Prose } from "@/app/(ui)/markdown/Prose";
import { Markdown } from "@/app/(ui)/markdown/Markdown";

export const MarkdownTextArea = (
props: TextAreaProps & {
readonly textAreaClassName?: string;
readonly id: string;
},
) => {
const { defaultValue, textAreaClassName, className, ...passThroughProps } =
props;

const [value, setValue] = useState(
typeof defaultValue === "string" ? defaultValue : "",
);

const [previewActive, setPreviewActive] = useState(false);

const [selection, setSelection] = useState<{
start: number;
end: number;
} | null>(null);

useEffect(() => {
if (selection) {
const textarea = document.getElementById(
props.id,
)! as HTMLTextAreaElement;

textarea.focus();
textarea.setSelectionRange(selection.start, selection.end);
}
}, [selection]);

const onChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
setValue(e.currentTarget.value);
};

const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.metaKey || e.ctrlKey) {
switch (e.key) {
case "k": {
e.preventDefault();

makeSelectionLink();
break;
}
case "b": {
e.preventDefault();
makeSelectionBold();
break;
}
case "i": {
e.preventDefault();
makeSelectionItalic();
break;
}
case "e": {
e.preventDefault();
makeSelectionCode();
break;
}
}
}
};

const makeSelectionLink = () => {
surroundSelection("[", "](<enter url>)");
};

const makeSelectionBold = () => {
surroundSelection("**");
};

const makeSelectionItalic = () => {
surroundSelection("*");
};

const makeSelectionCode = () => {
const textarea = document.getElementById(props.id)! as HTMLTextAreaElement;
const selectionStart = textarea.selectionStart;
const selectionEnd = textarea.selectionEnd;
const selectedText = value.substring(selectionStart, selectionEnd);

if (selectedText.split(/\r*\n/).length > 1) {
surroundSelection("```\n", "\n```");
} else {
surroundSelection("`");
}
};

const surroundSelection = (before: string, optionalAfter?: string) => {
const after = optionalAfter ?? before;

const textarea = document.getElementById(props.id)! as HTMLTextAreaElement;
const selectionStart = textarea.selectionStart;
const selectionEnd = textarea.selectionEnd;

const isAlreadySurrounded =
value.substring(selectionStart - before.length, selectionStart) ===
before &&
value.substring(selectionEnd, selectionEnd + after.length) === after;

if (isAlreadySurrounded) {
setValue((prev) => {
const selectedText = value.substring(selectionStart, selectionEnd);

return `${prev.substring(
0,
selectionStart - before.length,
)}${selectedText}${prev.substring(selectionEnd + after.length)}`;
});
setSelection({
start: selectionStart - before.length,
end: selectionEnd - after.length,
});
} else {
setValue((prev) => {
const selectedText = value.substring(selectionStart, selectionEnd);

return `${prev.substring(
0,
selectionStart,
)}${before}${selectedText}${after}${prev.substring(selectionEnd)}`;
});

setSelection({
start: selectionStart + before.length,
end: selectionEnd + before.length,
});
}
};

return (
<div className={classNames("", className)}>
<div
className={`flex items-center justify-end gap-1.5 rounded-t border border-b-0
border-neutral-600 bg-neutral-800 p-0.5 px-4`}
>
<ToolbarButton
className={classNames("text-sm", {
"bg-neutral-500 font-bold": !previewActive,
})}
onClick={() => setPreviewActive(false)}
title={"Write"}
>
{"Write"}
</ToolbarButton>
<ToolbarButton
className={classNames("mr-auto text-sm", {
"bg-neutral-500 font-bold": previewActive,
})}
onClick={() => setPreviewActive(true)}
title={"Preview"}
>
{"Preview"}
</ToolbarButton>
<ToolbarButton
className={"font-bold"}
disabled={previewActive}
onClick={makeSelectionBold}
title={"Bold"}
>
{"B"}
</ToolbarButton>
<ToolbarButton
className={"italic"}
disabled={previewActive}
onClick={makeSelectionItalic}
title={"Italic"}
>
{"i"}
</ToolbarButton>
<ToolbarButton
className={"font-bold"}
disabled={previewActive}
onClick={makeSelectionLink}
title={"Link"}
>
<LinkIcon className={"h-4"} />
</ToolbarButton>
<ToolbarButton
className={"font-bold"}
disabled={previewActive}
onClick={makeSelectionCode}
title={"Code"}
>
<CodeBracketIcon className={"h-4"} />
</ToolbarButton>
</div>
{!previewActive && (
<TextArea
{...passThroughProps}
className={classNames(
"mt-0 rounded-none rounded-b pt-0",
textAreaClassName,
)}
onChange={onChange}
onKeyDown={onKeyDown}
value={value}
/>
)}
{previewActive && (
<div
className={classNames(
"rounded-b border border-neutral-600 p-4",
props.textAreaClassName,
)}
>
<Markdown content={value} />
</div>
)}
</div>
);
};

const ToolbarButton = (props: {
readonly className?: string;
readonly title: string;
onClick(): void;
readonly children: ReactNode;
readonly disabled?: boolean;
}) => {
return (
<button
className={classNames(
"rounded bg-neutral-800 p-1 hover:brightness-125",
props.className,
)}
disabled={props.disabled}
onClick={(e) => {
e.preventDefault();
props.onClick();
}}
title={props.title}
>
{props.children}
</button>
);
};
9 changes: 5 additions & 4 deletions src/app/comment/CommentEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
"use client";

import { TextArea } from "@/app/(ui)/form/TextArea";
import { SubmitButton } from "@/app/(ui)/button/SubmitButton";
import { Button } from "@/app/(ui)/button/Button";
import {
Expand All @@ -9,6 +8,7 @@ import {
} from "@/app/comment/commentActions";
import classNames from "classnames";
import { CommentView } from "lemmy-js-client";
import { MarkdownTextArea } from "@/app/(ui)/markdown/MarkdownTextArea";

type BaseProps = {
className?: string;
Expand Down Expand Up @@ -46,13 +46,14 @@ export const CommentEditor = (props: Props) => {
newComment && props.onSubmit && props.onSubmit(commentView);
}}
>
<TextArea
className={"mb-2 mt-4 h-32"}
<MarkdownTextArea
className={"mb-2 mt-4"}
defaultValue={isNewComment(props) ? undefined : props.initialContent}
id={"content"}
name={"content"}
required={true}
></TextArea>
textAreaClassName={"h-32"}
/>
<div className={"flex justify-end gap-2"}>
{props.onCancel && (
<Button onClick={props.onCancel} size={"xs"}>
Expand Down
13 changes: 7 additions & 6 deletions src/app/create_post/PostEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { Input } from "@/app/(ui)/form/Input";
import { SubmitButton } from "@/app/(ui)/button/SubmitButton";
import { ButtonLink } from "@/app/(ui)/button/ButtonLink";
import classNames from "classnames";
import { TextArea } from "@/app/(ui)/form/TextArea";
import { createPostAction, editPostAction } from "@/app/post/postActions";
import { MarkdownTextArea } from "@/app/(ui)/markdown/MarkdownTextArea";

export const PostEditor = (props: {
readonly existingPostView?: PostView;
Expand Down Expand Up @@ -65,14 +65,15 @@ export const PostEditor = (props: {
{"Body"}
</label>

<TextArea
className={classNames("mt-2 min-h-32", {
"min-h-96": bodyLineCount > 5 && bodyLineCount < 15,
"min-h-[600px]": bodyLineCount >= 15,
})}
<MarkdownTextArea
className={classNames("mt-2")}
defaultValue={props.existingPostView?.post.body}
id={"body"}
name={"body"}
textAreaClassName={classNames("min-h-32", {
"min-h-96": bodyLineCount > 5 && bodyLineCount < 15,
"min-h-[600px]": bodyLineCount >= 15,
})}
/>
</div>
<input
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import { PersonView } from "lemmy-js-client";
import { SubmitButton } from "@/app/(ui)/button/SubmitButton";
import { ButtonLink } from "@/app/(ui)/button/ButtonLink";
import classNames from "classnames";
import { TextArea } from "@/app/(ui)/form/TextArea";
import { createPrivateMessageAction } from "@/app/create_private_message/privateMessageActions";
import { UserLink } from "@/app/u/UserLink";
import { MarkdownTextArea } from "@/app/(ui)/markdown/MarkdownTextArea";

export const PrivateMessageEditor = (props: {
readonly recipientPersonView: PersonView;
Expand All @@ -31,10 +31,11 @@ export const PrivateMessageEditor = (props: {
{"Message"}
</label>

<TextArea
className={classNames("mt-2 min-h-32")}
<MarkdownTextArea
className={classNames("mt-2")}
id={"content"}
name={"content"}
textAreaClassName={classNames("min-h-32")}
/>
</div>
<input
Expand Down
6 changes: 3 additions & 3 deletions src/app/settings/SettingsInputWithLabel.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import classNames from "classnames";
import { Input } from "@/app/(ui)/form/Input";
import { Select } from "@/app/(ui)/form/Select";
import { TextArea } from "@/app/(ui)/form/TextArea";
import { MarkdownTextArea } from "@/app/(ui)/markdown/MarkdownTextArea";

type InputWithLabelBaseProps = {
inputId: string;
Expand Down Expand Up @@ -83,13 +83,13 @@ export const SettingsInputWithLabel = (
</Select>
)}
{props.type === "textarea" && (
<TextArea
className={"min-h-72"}
<MarkdownTextArea
defaultValue={props.defaultValue}
disabled={props.disabled}
id={props.inputId}
name={props.inputId}
placeholder={props.placeholder}
textAreaClassName={"min-h-72"}
/>
)}
</div>
Expand Down

0 comments on commit 5ae0737

Please sign in to comment.