-
-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: implement basic markdown editor
Currently onlu supports bold, italic, link and code blocks, including keyboard shortcuts. Also has a preview mode for rendered markdown.
- Loading branch information
Showing
6 changed files
with
276 additions
and
21 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters