aboutsummaryrefslogtreecommitdiffstats
path: root/apps
diff options
context:
space:
mode:
authorGiuseppe <Giuseppe06@gmail.com>2024-12-21 14:09:30 +0100
committerGitHub <noreply@github.com>2024-12-21 15:09:30 +0200
commit40c5262a9c2fcb8c89d7c464ccfdc3016f933eb3 (patch)
tree108852741fc06c28b851563738f499cdaccdf6bd /apps
parent16f2ce378b121dcbea31708630def8ff95e90f14 (diff)
downloadkarakeep-40c5262a9c2fcb8c89d7c464ccfdc3016f933eb3.tar.zst
feature: WYSIWYG markdown for notes. Fixes #701 (#715)
* #701 Improve note support : WYSIWYG markdown First implementation with a wysiwyg markdown editor. Update: - Add Lexical markdown editor - consistent rendering between card and preview - removed edit modal, replaced by preview with save action - simple markdown shortcut: underline, bold, italic etc... * #701 Improve note support : WYSIWYG markdown improved performance to not rerender all note card when one is updated * Use markdown shortcuts * Remove the alignment actions * Drop history buttons * Fix code and highlighting buttons * Remove the unneeded update markdown plugin * Remove underline support as it's not markdown native * - added ListPlugin because if absent, there's a bug where you can't escape a list with enter + enter - added codeblock plugin - added prose dark:prose-invert prose-p:m-0 like you said (there's room for improvement I think, don't took the time too deep dive in) and removed theme - Added a switch to show raw markdown - Added back the react markdown for card (SSR) * delete theme.ts * add theme back for code element to be more like prism theme from markdown-readonly * move the new editor back to the edit menu * move the bookmark markdown component into dashboard/bookmark * move the tooltip into its own component * move save button to toolbar * Better raw markdown --------- Co-authored-by: Giuseppe Lapenta <giuseppe.lapenta@enovacom.com> Co-authored-by: Mohamed Bassem <me@mbassem.com>
Diffstat (limited to 'apps')
-rw-r--r--apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx43
-rw-r--r--apps/web/components/dashboard/bookmarks/BookmarkedTextEditor.tsx67
-rw-r--r--apps/web/components/dashboard/bookmarks/TextCard.tsx11
-rw-r--r--apps/web/components/dashboard/preview/TextContentSection.tsx7
-rw-r--r--apps/web/components/ui/markdown/markdown-editor.tsx124
-rw-r--r--apps/web/components/ui/markdown/markdown-readonly.tsx (renamed from apps/web/components/ui/markdown-component.tsx)118
-rw-r--r--apps/web/components/ui/markdown/plugins/toolbar-plugin.tsx290
-rw-r--r--apps/web/components/ui/markdown/theme.ts35
-rw-r--r--apps/web/components/ui/switch.tsx28
-rw-r--r--apps/web/lib/i18n/locales/de/translation.json15
-rw-r--r--apps/web/lib/i18n/locales/en/translation.json50
-rw-r--r--apps/web/lib/i18n/locales/fr/translation.json15
-rw-r--r--apps/web/package.json7
13 files changed, 684 insertions, 126 deletions
diff --git a/apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx b/apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx
new file mode 100644
index 00000000..74eb0868
--- /dev/null
+++ b/apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx
@@ -0,0 +1,43 @@
+import MarkdownEditor from "@/components/ui/markdown/markdown-editor";
+import { MarkdownReadonly } from "@/components/ui/markdown/markdown-readonly";
+import { toast } from "@/components/ui/use-toast";
+
+import type { ZBookmarkTypeText } from "@hoarder/shared/types/bookmarks";
+import { useUpdateBookmarkText } from "@hoarder/shared-react/hooks/bookmarks";
+
+export function BookmarkMarkdownComponent({
+ children: bookmark,
+ readOnly = true,
+}: {
+ children: ZBookmarkTypeText;
+ readOnly?: boolean;
+}) {
+ const { mutate: updateBookmarkMutator, isPending } = useUpdateBookmarkText({
+ onSuccess: () => {
+ toast({
+ description: "Note updated!",
+ });
+ },
+ onError: () => {
+ toast({ description: "Something went wrong", variant: "destructive" });
+ },
+ });
+
+ const onSave = (text: string) => {
+ updateBookmarkMutator({
+ bookmarkId: bookmark.id,
+ text,
+ });
+ };
+ return (
+ <div className="h-full overflow-hidden">
+ {readOnly ? (
+ <MarkdownReadonly>{bookmark.content.text}</MarkdownReadonly>
+ ) : (
+ <MarkdownEditor onSave={onSave} isSaving={isPending}>
+ {bookmark.content.text}
+ </MarkdownEditor>
+ )}
+ </div>
+ );
+}
diff --git a/apps/web/components/dashboard/bookmarks/BookmarkedTextEditor.tsx b/apps/web/components/dashboard/bookmarks/BookmarkedTextEditor.tsx
index e0434943..b2c27c7e 100644
--- a/apps/web/components/dashboard/bookmarks/BookmarkedTextEditor.tsx
+++ b/apps/web/components/dashboard/bookmarks/BookmarkedTextEditor.tsx
@@ -1,20 +1,12 @@
-import { useState } from "react";
-import { ActionButton } from "@/components/ui/action-button";
-import { Button } from "@/components/ui/button";
+import { BookmarkMarkdownComponent } from "@/components/dashboard/bookmarks/BookmarkMarkdownComponent";
import {
Dialog,
- DialogClose,
DialogContent,
- DialogDescription,
- DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
-import { Textarea } from "@/components/ui/textarea";
-import { toast } from "@/components/ui/use-toast";
-import { useUpdateBookmarkText } from "@hoarder/shared-react/hooks/bookmarks";
-import { BookmarkTypes, ZBookmark } from "@hoarder/shared/types/bookmarks";
+import { ZBookmark, ZBookmarkTypeText } from "@hoarder/shared/types/bookmarks";
export function BookmarkedTextEditor({
bookmark,
@@ -26,55 +18,20 @@ export function BookmarkedTextEditor({
setOpen: (open: boolean) => void;
}) {
const isNewBookmark = bookmark === undefined;
- const [noteText, setNoteText] = useState(
- bookmark && bookmark.content.type == BookmarkTypes.TEXT
- ? bookmark.content.text
- : "",
- );
-
- const { mutate: updateBookmarkMutator, isPending } = useUpdateBookmarkText({
- onSuccess: () => {
- toast({
- description: "Note updated!",
- });
- setOpen(false);
- },
- onError: () => {
- toast({ description: "Something went wrong", variant: "destructive" });
- },
- });
-
- const onSave = () => {
- updateBookmarkMutator({
- bookmarkId: bookmark.id,
- text: noteText,
- });
- };
return (
<Dialog open={open} onOpenChange={setOpen}>
- <DialogContent>
- <DialogHeader>
- <DialogTitle>{isNewBookmark ? "New Note" : "Edit Note"}</DialogTitle>
- <DialogDescription>
- Write your note with markdown support
- </DialogDescription>
+ <DialogContent className="max-w-[80%]">
+ <DialogHeader className="flex">
+ <DialogTitle className="w-fit">
+ {isNewBookmark ? "New Note" : "Edit Note"}
+ </DialogTitle>
</DialogHeader>
- <Textarea
- value={noteText}
- onChange={(e) => setNoteText(e.target.value)}
- className="h-52 grow"
- />
- <DialogFooter className="flex-shrink gap-1 sm:justify-end">
- <DialogClose asChild>
- <Button type="button" variant="secondary">
- Close
- </Button>
- </DialogClose>
- <ActionButton type="button" loading={isPending} onClick={onSave}>
- Save
- </ActionButton>
- </DialogFooter>
+ <div className="h-[80vh]">
+ <BookmarkMarkdownComponent readOnly={false}>
+ {bookmark as ZBookmarkTypeText}
+ </BookmarkMarkdownComponent>
+ </div>
</DialogContent>
</Dialog>
);
diff --git a/apps/web/components/dashboard/bookmarks/TextCard.tsx b/apps/web/components/dashboard/bookmarks/TextCard.tsx
index 14a4f905..9d168910 100644
--- a/apps/web/components/dashboard/bookmarks/TextCard.tsx
+++ b/apps/web/components/dashboard/bookmarks/TextCard.tsx
@@ -2,7 +2,7 @@
import Image from "next/image";
import Link from "next/link";
-import { MarkdownComponent } from "@/components/ui/markdown-component";
+import { BookmarkMarkdownComponent } from "@/components/dashboard/bookmarks/BookmarkMarkdownComponent";
import { bookmarkLayoutSwitch } from "@/lib/userLocalSettings/bookmarksLayout";
import { cn } from "@/lib/utils";
@@ -20,15 +20,16 @@ export default function TextCard({
bookmark: ZBookmarkTypeText;
className?: string;
}) {
- const bookmarkedText = bookmark.content;
-
const banner = bookmark.assets.find((a) => a.assetType == "bannerImage");
-
return (
<>
<BookmarkLayoutAdaptingCard
title={bookmark.title}
- content={<MarkdownComponent>{bookmarkedText.text}</MarkdownComponent>}
+ content={
+ <BookmarkMarkdownComponent readOnly={true}>
+ {bookmark}
+ </BookmarkMarkdownComponent>
+ }
footer={
getSourceUrl(bookmark) && (
<FooterLinkURL url={getSourceUrl(bookmark)} />
diff --git a/apps/web/components/dashboard/preview/TextContentSection.tsx b/apps/web/components/dashboard/preview/TextContentSection.tsx
index 327436c6..a58bc717 100644
--- a/apps/web/components/dashboard/preview/TextContentSection.tsx
+++ b/apps/web/components/dashboard/preview/TextContentSection.tsx
@@ -1,7 +1,8 @@
import Image from "next/image";
-import { MarkdownComponent } from "@/components/ui/markdown-component";
+import { BookmarkMarkdownComponent } from "@/components/dashboard/bookmarks/BookmarkMarkdownComponent";
import { ScrollArea } from "@radix-ui/react-scroll-area";
+import type { ZBookmarkTypeText } from "@hoarder/shared/types/bookmarks";
import { getAssetUrl } from "@hoarder/shared-react/utils/assetUtils";
import { BookmarkTypes, ZBookmark } from "@hoarder/shared/types/bookmarks";
@@ -27,7 +28,9 @@ export function TextContentSection({ bookmark }: { bookmark: ZBookmark }) {
/>
</div>
)}
- <MarkdownComponent>{bookmark.content.text}</MarkdownComponent>
+ <BookmarkMarkdownComponent>
+ {bookmark as ZBookmarkTypeText}
+ </BookmarkMarkdownComponent>
</ScrollArea>
);
}
diff --git a/apps/web/components/ui/markdown/markdown-editor.tsx b/apps/web/components/ui/markdown/markdown-editor.tsx
new file mode 100644
index 00000000..85f0c878
--- /dev/null
+++ b/apps/web/components/ui/markdown/markdown-editor.tsx
@@ -0,0 +1,124 @@
+import { memo, useMemo, useState } from "react";
+import ToolbarPlugin from "@/components/ui/markdown/plugins/toolbar-plugin";
+import { MarkdownEditorTheme } from "@/components/ui/markdown/theme";
+import {
+ CodeHighlightNode,
+ CodeNode,
+ registerCodeHighlighting,
+} from "@lexical/code";
+import { LinkNode } from "@lexical/link";
+import { ListItemNode, ListNode } from "@lexical/list";
+import {
+ $convertFromMarkdownString,
+ $convertToMarkdownString,
+ TRANSFORMERS,
+} from "@lexical/markdown";
+import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin";
+import {
+ InitialConfigType,
+ LexicalComposer,
+} from "@lexical/react/LexicalComposer";
+import { ContentEditable } from "@lexical/react/LexicalContentEditable";
+import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary";
+import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
+import { HorizontalRuleNode } from "@lexical/react/LexicalHorizontalRuleNode";
+import { ListPlugin } from "@lexical/react/LexicalListPlugin";
+import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin";
+import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";
+import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin";
+import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
+import { TabIndentationPlugin } from "@lexical/react/LexicalTabIndentationPlugin";
+import { HeadingNode, QuoteNode } from "@lexical/rich-text";
+import { $getRoot, EditorState, LexicalEditor } from "lexical";
+
+function onError(error: Error) {
+ console.error(error);
+}
+
+const EDITOR_NODES = [
+ HeadingNode,
+ ListNode,
+ ListItemNode,
+ QuoteNode,
+ LinkNode,
+ CodeNode,
+ HorizontalRuleNode,
+ CodeHighlightNode,
+];
+
+interface MarkdownEditorProps {
+ children: string;
+ onSave?: (markdown: string) => void;
+ isSaving?: boolean;
+}
+
+const MarkdownEditor = memo(
+ ({ children: initialMarkdown, onSave, isSaving }: MarkdownEditorProps) => {
+ const [isRawMarkdownMode, setIsRawMarkdownMode] = useState(false);
+ const [rawMarkdown, setRawMarkdown] = useState(initialMarkdown);
+
+ const initialConfig: InitialConfigType = useMemo(
+ () => ({
+ namespace: "editor",
+ onError,
+ theme: MarkdownEditorTheme,
+ nodes: EDITOR_NODES,
+ editorState: (editor: LexicalEditor) => {
+ registerCodeHighlighting(editor);
+ $convertFromMarkdownString(initialMarkdown, TRANSFORMERS);
+ },
+ }),
+ [initialMarkdown],
+ );
+
+ const handleOnChange = (editorState: EditorState) => {
+ editorState.read(() => {
+ let markdownString;
+ if (isRawMarkdownMode) {
+ markdownString = $getRoot()?.getFirstChild()?.getTextContent() ?? "";
+ } else {
+ markdownString = $convertToMarkdownString(TRANSFORMERS);
+ }
+ setRawMarkdown(markdownString);
+ });
+ };
+
+ return (
+ <LexicalComposer initialConfig={initialConfig}>
+ <div className="flex h-full flex-col justify-stretch">
+ <ToolbarPlugin
+ isRawMarkdownMode={isRawMarkdownMode}
+ setIsRawMarkdownMode={setIsRawMarkdownMode}
+ onSave={onSave && (() => onSave(rawMarkdown))}
+ isSaving={!!isSaving}
+ />
+ {isRawMarkdownMode ? (
+ <PlainTextPlugin
+ contentEditable={
+ <ContentEditable className="h-full w-full min-w-full overflow-auto p-2" />
+ }
+ ErrorBoundary={LexicalErrorBoundary}
+ />
+ ) : (
+ <RichTextPlugin
+ contentEditable={
+ <ContentEditable className="prose h-full w-full min-w-full overflow-auto p-2 dark:prose-invert prose-p:m-0" />
+ }
+ ErrorBoundary={LexicalErrorBoundary}
+ />
+ )}
+ </div>
+ <HistoryPlugin />
+ <AutoFocusPlugin />
+ <TabIndentationPlugin />
+ <MarkdownShortcutPlugin transformers={TRANSFORMERS} />
+ <OnChangePlugin onChange={handleOnChange} />
+ <ListPlugin />
+ </LexicalComposer>
+ );
+ },
+);
+// needed for linter because of memo
+MarkdownEditor.displayName = "MarkdownEditor";
+
+export default MarkdownEditor;
diff --git a/apps/web/components/ui/markdown-component.tsx b/apps/web/components/ui/markdown/markdown-readonly.tsx
index d3c832ac..29077480 100644
--- a/apps/web/components/ui/markdown-component.tsx
+++ b/apps/web/components/ui/markdown/markdown-readonly.tsx
@@ -1,61 +1,57 @@
-import React from "react";
-import CopyBtn from "@/components/ui/copy-button";
-import { cn } from "@/lib/utils";
-import Markdown from "react-markdown";
-import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
-import { dracula } from "react-syntax-highlighter/dist/cjs/styles/prism";
-import remarkBreaks from "remark-breaks";
-import remarkGfm from "remark-gfm";
-
-function PreWithCopyBtn({ className, ...props }: React.ComponentProps<"pre">) {
- const ref = React.useRef<HTMLPreElement>(null);
- return (
- <span className="group relative">
- <CopyBtn
- className="absolute right-1 top-1 m-1 hidden text-white group-hover:block"
- getStringToCopy={() => {
- return ref.current?.textContent ?? "";
- }}
- />
- <pre ref={ref} className={cn(className, "")} {...props} />
- </span>
- );
-}
-
-export function MarkdownComponent({
- children: markdown,
-}: {
- children: string;
-}) {
- return (
- <Markdown
- remarkPlugins={[remarkGfm, remarkBreaks]}
- className="prose dark:prose-invert"
- components={{
- pre({ ...props }) {
- return <PreWithCopyBtn {...props} />;
- },
- code({ className, children, ...props }) {
- const match = /language-(\w+)/.exec(className ?? "");
- return match ? (
- // @ts-expect-error -- Refs are not compatible for some reason
- <SyntaxHighlighter
- PreTag="div"
- language={match[1]}
- {...props}
- style={dracula}
- >
- {String(children).replace(/\n$/, "")}
- </SyntaxHighlighter>
- ) : (
- <code className={className} {...props}>
- {children}
- </code>
- );
- },
- }}
- >
- {markdown}
- </Markdown>
- );
-}
+import React from "react";
+import CopyBtn from "@/components/ui/copy-button";
+import { cn } from "@/lib/utils";
+import Markdown from "react-markdown";
+import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
+import { dracula } from "react-syntax-highlighter/dist/cjs/styles/prism";
+import remarkBreaks from "remark-breaks";
+import remarkGfm from "remark-gfm";
+
+function PreWithCopyBtn({ className, ...props }: React.ComponentProps<"pre">) {
+ const ref = React.useRef<HTMLPreElement>(null);
+ return (
+ <span className="group relative">
+ <CopyBtn
+ className="absolute right-1 top-1 m-1 hidden text-white group-hover:block"
+ getStringToCopy={() => {
+ return ref.current?.textContent ?? "";
+ }}
+ />
+ <pre ref={ref} className={cn(className, "")} {...props} />
+ </span>
+ );
+}
+
+export function MarkdownReadonly({ children: markdown }: { children: string }) {
+ return (
+ <Markdown
+ remarkPlugins={[remarkGfm, remarkBreaks]}
+ className="prose dark:prose-invert"
+ components={{
+ pre({ ...props }) {
+ return <PreWithCopyBtn {...props} />;
+ },
+ code({ className, children, ...props }) {
+ const match = /language-(\w+)/.exec(className ?? "");
+ return match ? (
+ // @ts-expect-error -- Refs are not compatible for some reason
+ <SyntaxHighlighter
+ PreTag="div"
+ language={match[1]}
+ {...props}
+ style={dracula}
+ >
+ {String(children).replace(/\n$/, "")}
+ </SyntaxHighlighter>
+ ) : (
+ <code className={className} {...props}>
+ {children}
+ </code>
+ );
+ },
+ }}
+ >
+ {markdown}
+ </Markdown>
+ );
+}
diff --git a/apps/web/components/ui/markdown/plugins/toolbar-plugin.tsx b/apps/web/components/ui/markdown/plugins/toolbar-plugin.tsx
new file mode 100644
index 00000000..28e265e2
--- /dev/null
+++ b/apps/web/components/ui/markdown/plugins/toolbar-plugin.tsx
@@ -0,0 +1,290 @@
+import { useCallback, useEffect, useState } from "react";
+import { Button } from "@/components/ui/button";
+import { useTranslation } from "@/lib/i18n/client";
+import {
+ $convertFromMarkdownString,
+ $convertToMarkdownString,
+ TRANSFORMERS,
+} from "@lexical/markdown";
+import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
+import { mergeRegister } from "@lexical/utils";
+import {
+ $createParagraphNode,
+ $createTextNode,
+ $getRoot,
+ $getSelection,
+ $isRangeSelection,
+ FORMAT_TEXT_COMMAND,
+ LexicalCommand,
+ SELECTION_CHANGE_COMMAND,
+ TextFormatType,
+} from "lexical";
+import {
+ Bold,
+ Code,
+ Highlighter,
+ Italic,
+ LucideIcon,
+ Save,
+ Strikethrough,
+} from "lucide-react";
+
+import { ActionButton } from "../../action-button";
+import InfoTooltip from "../../info-tooltip";
+import { Label } from "../../label";
+import { Switch } from "../../switch";
+
+const LowPriority = 1;
+
+function MarkdownToolTip() {
+ const { t } = useTranslation();
+ return (
+ <InfoTooltip size={15}>
+ <table className="w-full table-auto text-left text-sm">
+ <thead>
+ <tr>
+ <th>{t("editor.text_toolbar.markdown_shortcuts.label")}</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr className="border-b">
+ <td className="py-2">
+ {t("editor.text_toolbar.markdown_shortcuts.heading.label")}
+ </td>
+ <td className="py-2">
+ {t("editor.text_toolbar.markdown_shortcuts.heading.example")}
+ </td>
+ </tr>
+ <tr className="border-b">
+ <td className="py-2">
+ {t("editor.text_toolbar.markdown_shortcuts.bold.label")}
+ </td>
+ <td className="py-2">
+ {t("editor.text_toolbar.markdown_shortcuts.bold.example")}
+ </td>
+ </tr>
+ <tr className="border-b">
+ <td className="py-2">
+ {t("editor.text_toolbar.markdown_shortcuts.italic.label")}
+ </td>
+ <td className="py-2">
+ {t("editor.text_toolbar.markdown_shortcuts.italic.example")}
+ </td>
+ </tr>
+ <tr className="border-b">
+ <td className="py-2">
+ {t("editor.text_toolbar.markdown_shortcuts.blockquote.label")}
+ </td>
+ <td className="py-2">
+ {t("editor.text_toolbar.markdown_shortcuts.blockquote.example")}
+ </td>
+ </tr>
+ <tr className="border-b">
+ <td className="py-2">
+ {t("editor.text_toolbar.markdown_shortcuts.ordered_list.label")}
+ </td>
+ <td className="py-2">
+ {t("editor.text_toolbar.markdown_shortcuts.ordered_list.example")}
+ </td>
+ </tr>
+ <tr className="border-b">
+ <td className="py-2">
+ {t("editor.text_toolbar.markdown_shortcuts.unordered_list.label")}
+ </td>
+ <td className="py-2">
+ {t(
+ "editor.text_toolbar.markdown_shortcuts.unordered_list.example",
+ )}
+ </td>
+ </tr>
+ <tr className="border-b">
+ <td className="py-2">
+ {t("editor.text_toolbar.markdown_shortcuts.inline_code.label")}
+ </td>
+ <td className="py-2">
+ {t("editor.text_toolbar.markdown_shortcuts.inline_code.example")}
+ </td>
+ </tr>
+ <tr className="border-b">
+ <td className="py-2">
+ {t("editor.text_toolbar.markdown_shortcuts.block_code.label")}
+ </td>
+ <td className="py-2">
+ {t("editor.text_toolbar.markdown_shortcuts.block_code.example")}
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </InfoTooltip>
+ );
+}
+
+export default function ToolbarPlugin({
+ isRawMarkdownMode = false,
+ setIsRawMarkdownMode,
+ onSave,
+ isSaving,
+}: {
+ isRawMarkdownMode: boolean;
+ setIsRawMarkdownMode: (value: boolean) => void;
+ onSave?: () => void;
+ isSaving: boolean;
+}) {
+ const { t } = useTranslation();
+ const [editor] = useLexicalComposerContext();
+ const [editorToolbarState, setEditorToolbarState] = useState<{
+ isBold: boolean;
+ isItalic: boolean;
+ isStrikethrough: boolean;
+ isHighlight: boolean;
+ isCode: boolean;
+ }>({
+ isBold: false,
+ isItalic: false,
+ isStrikethrough: false,
+ isHighlight: false,
+ isCode: false,
+ });
+
+ const $updateToolbar = useCallback(() => {
+ const selection = $getSelection();
+ if ($isRangeSelection(selection)) {
+ setEditorToolbarState({
+ isBold: selection.hasFormat("bold"),
+ isItalic: selection.hasFormat("italic"),
+ isStrikethrough: selection.hasFormat("strikethrough"),
+ isHighlight: selection.hasFormat("highlight"),
+ isCode: selection.hasFormat("code"),
+ });
+ }
+ }, []);
+
+ useEffect(() => {
+ return mergeRegister(
+ editor.registerUpdateListener(({ editorState }) => {
+ editorState.read(() => {
+ $updateToolbar();
+ });
+ }),
+ editor.registerCommand(
+ SELECTION_CHANGE_COMMAND,
+ (_payload, _newEditor) => {
+ $updateToolbar();
+ return false;
+ },
+ LowPriority,
+ ),
+ );
+ }, [editor, $updateToolbar]);
+
+ const formatButtons: {
+ command: LexicalCommand<TextFormatType>;
+ format: TextFormatType;
+ isActive?: boolean;
+ icon: LucideIcon;
+ label: string;
+ }[] = [
+ {
+ command: FORMAT_TEXT_COMMAND,
+ format: "bold",
+ icon: Bold,
+ isActive: editorToolbarState.isBold,
+ label: t("editor.text_toolbar.bold"),
+ },
+ {
+ command: FORMAT_TEXT_COMMAND,
+ format: "italic",
+ icon: Italic,
+ isActive: editorToolbarState.isItalic,
+ label: t("editor.text_toolbar.italic"),
+ },
+ {
+ command: FORMAT_TEXT_COMMAND,
+ format: "strikethrough",
+ icon: Strikethrough,
+ isActive: editorToolbarState.isStrikethrough,
+ label: t("editor.text_toolbar.strikethrough"),
+ },
+ {
+ command: FORMAT_TEXT_COMMAND,
+ format: "code",
+ icon: Code,
+ isActive: editorToolbarState.isCode,
+ label: t("editor.text_toolbar.code"),
+ },
+ {
+ command: FORMAT_TEXT_COMMAND,
+ format: "highlight",
+ icon: Highlighter,
+ isActive: editorToolbarState.isHighlight,
+ label: t("editor.text_toolbar.highlight"),
+ },
+ ];
+
+ const handleRawMarkdownToggle = useCallback(() => {
+ editor.update(() => {
+ console.log(isRawMarkdownMode);
+ const root = $getRoot();
+ const firstChild = root.getFirstChild();
+ if (isRawMarkdownMode) {
+ if (firstChild) {
+ $convertFromMarkdownString(firstChild.getTextContent(), TRANSFORMERS);
+ }
+ setIsRawMarkdownMode(false);
+ } else {
+ const markdown = $convertToMarkdownString(TRANSFORMERS);
+ const pNode = $createParagraphNode();
+ pNode.append($createTextNode(markdown));
+ root.clear().append(pNode);
+ setIsRawMarkdownMode(true);
+ }
+ });
+ }, [editor, isRawMarkdownMode]);
+
+ return (
+ <div className="mb-1 flex items-center justify-between rounded-t-lg p-1">
+ <div className="flex">
+ {formatButtons.map(
+ ({ command, format, icon: Icon, isActive, label }) => (
+ <Button
+ key={format}
+ disabled={isRawMarkdownMode}
+ size={"sm"}
+ onClick={() => {
+ editor.dispatchCommand(command, format);
+ }}
+ variant={isActive ? "default" : "ghost"}
+ aria-label={label}
+ >
+ <Icon className="h-4 w-4" />
+ </Button>
+ ),
+ )}
+ </div>
+ <div className="flex items-center gap-2">
+ <div className="flex items-center space-x-2">
+ <Switch
+ id="editor-raw-markdown"
+ onCheckedChange={handleRawMarkdownToggle}
+ checked={isRawMarkdownMode}
+ />
+ <Label htmlFor="editor-raw-markdown">Raw Markdown</Label>
+ </div>
+ {onSave && (
+ <ActionButton
+ loading={isSaving}
+ className="flex items-center gap-2"
+ size={"sm"}
+ onClick={() => {
+ onSave?.();
+ }}
+ >
+ <Save className="size-4" />
+ Save
+ </ActionButton>
+ )}
+ <MarkdownToolTip />
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/components/ui/markdown/theme.ts b/apps/web/components/ui/markdown/theme.ts
new file mode 100644
index 00000000..ff088e32
--- /dev/null
+++ b/apps/web/components/ui/markdown/theme.ts
@@ -0,0 +1,35 @@
+export const MarkdownEditorTheme = {
+ code: "bg-[#282A36] text-[#F8F8F2] font-mono block px-4 py-2 my-2 text-sm overflow-x-auto relative rounded-md shadow-sm",
+ codeHighlight: {
+ atrule: "text-[#8BE9FD]",
+ attr: "text-[#8BE9FD]",
+ boolean: "text-[#FF79C6]",
+ builtin: "text-[#50FA7B]",
+ cdata: "text-[#6272A4]",
+ char: "text-[#50FA7B]",
+ class: "text-[#FF79C6]",
+ "class-name": "text-[#FF79C6]",
+ comment: "text-[#6272A4]",
+ constant: "text-[#FF79C6]",
+ deleted: "text-[#FF5555]",
+ doctype: "text-[#6272A4]",
+ entity: "text-[#FFB86C]",
+ function: "text-[#50FA7B]",
+ important: "text-[#F1FA8C]",
+ inserted: "text-[#50FA7B]",
+ keyword: "text-[#FF79C6]",
+ namespace: "text-[#F1FA8C]",
+ number: "text-[#BD93F9]",
+ operator: "text-[#FFB86C]",
+ prolog: "text-[#6272A4]",
+ property: "text-[#FFB86C]",
+ punctuation: "text-[#F8F8F2]",
+ regex: "text-[#FF5555]",
+ selector: "text-[#50FA7B]",
+ string: "text-[#F1FA8C]",
+ symbol: "text-[#FF79C6]",
+ tag: "text-[#FF79C6]",
+ url: "text-[#8BE9FD]",
+ variable: "text-[#F1FA8C]",
+ },
+};
diff --git a/apps/web/components/ui/switch.tsx b/apps/web/components/ui/switch.tsx
new file mode 100644
index 00000000..2438dc86
--- /dev/null
+++ b/apps/web/components/ui/switch.tsx
@@ -0,0 +1,28 @@
+"use client";
+
+import * as React from "react";
+import { cn } from "@/lib/utils";
+import * as SwitchPrimitives from "@radix-ui/react-switch";
+
+const Switch = React.forwardRef<
+ React.ElementRef<typeof SwitchPrimitives.Root>,
+ React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
+>(({ className, ...props }, ref) => (
+ <SwitchPrimitives.Root
+ className={cn(
+ "peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
+ className,
+ )}
+ {...props}
+ ref={ref}
+ >
+ <SwitchPrimitives.Thumb
+ className={cn(
+ "pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
+ )}
+ />
+ </SwitchPrimitives.Root>
+));
+Switch.displayName = SwitchPrimitives.Root.displayName;
+
+export { Switch };
diff --git a/apps/web/lib/i18n/locales/de/translation.json b/apps/web/lib/i18n/locales/de/translation.json
index 40a167ad..d744dc97 100644
--- a/apps/web/lib/i18n/locales/de/translation.json
+++ b/apps/web/lib/i18n/locales/de/translation.json
@@ -180,7 +180,20 @@
"import_as_separate_bookmarks": "Als separate Lesezeichen importieren",
"placeholder": "Fügen Sie einen Link oder ein Bild ein, schreiben Sie eine Notiz oder ziehen Sie ein Bild hierher ...",
"new_item": "NEUER EINTRAG",
- "disabled_submissions": "Einsendungen sind deaktiviert"
+ "disabled_submissions": "Einsendungen sind deaktiviert",
+ "text_toolbar": {
+ "undo": "Rückgängig",
+ "redo": "Wiederholen",
+ "bold": "Fett",
+ "italic": "Kursiv",
+ "underline": "Unterstrichen",
+ "strikethrough": "Durchgestrichen",
+ "code": "Code",
+ "highlight": "Hervorheben",
+ "align_left": "Linksbündig",
+ "align_center": "Zentriert",
+ "align_right": "Rechtsbündig"
+ }
},
"toasts": {
"bookmarks": {
diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json
index 9f12487f..a871489a 100644
--- a/apps/web/lib/i18n/locales/en/translation.json
+++ b/apps/web/lib/i18n/locales/en/translation.json
@@ -187,7 +187,55 @@
"import_as_separate_bookmarks": "Import as separate Bookmarks",
"placeholder": "Paste a link or an image, write a note or drag and drop an image in here ...",
"new_item": "NEW ITEM",
- "disabled_submissions": "Submissions are disabled"
+ "disabled_submissions": "Submissions are disabled",
+ "text_toolbar": {
+ "undo": "Undo",
+ "redo": "Redo",
+ "bold": "Bold",
+ "italic": "Italic",
+ "underline": "Underline",
+ "strikethrough": "Strikethrough",
+ "code": "Code",
+ "highlight": "Highlight",
+ "align_left": "Left Align",
+ "align_center": "Center Align",
+ "align_right": "Right Align",
+ "markdown_shortcuts": {
+ "label": "Markdown shortcuts",
+ "heading": {
+ "label": "Heading",
+ "example": "# H1, ## H2, ### H3"
+ },
+ "bold": {
+ "label": "Bold",
+ "example": "**text** or CTRL+b"
+ },
+ "italic": {
+ "label": "Italic",
+ "example": "*Italic* or _Italic_ or CTRL+i"
+ },
+ "blockquote": {
+ "label": "Blockquote",
+ "example": "> Blockquote"
+ },
+ "ordered_list": {
+ "label": "Ordered List",
+ "example": "1. List item"
+ },
+ "unordered_list": {
+ "label": "Unordered List",
+ "example": "- List item"
+ },
+ "inline_code": {
+ "label": "Inline Code",
+ "example": "`Code`"
+ },
+ "block_code": {
+ "label": "Block Code",
+ "example": "``` + space"
+ }
+ }
+ }
},
"toasts": {
"bookmarks": {
diff --git a/apps/web/lib/i18n/locales/fr/translation.json b/apps/web/lib/i18n/locales/fr/translation.json
index d369ad44..142e9148 100644
--- a/apps/web/lib/i18n/locales/fr/translation.json
+++ b/apps/web/lib/i18n/locales/fr/translation.json
@@ -180,7 +180,20 @@
"import_as_separate_bookmarks": "Importer comme favoris séparés",
"placeholder": "Collez un lien ou une image, écrivez une note ou glissez-déposez une image ici ...",
"new_item": "NOUVEL ÉLÉMENT",
- "disabled_submissions": "Les soumissions sont désactivées"
+ "disabled_submissions": "Les soumissions sont désactivées",
+ "text_toolbar": {
+ "undo": "Annuler",
+ "redo": "Rétablir",
+ "bold": "Gras",
+ "italic": "Italique",
+ "underline": "Souligné",
+ "strikethrough": "Barré",
+ "code": "Code",
+ "highlight": "Surligner",
+ "align_left": "Aligner à gauche",
+ "align_center": "Aligner au centre",
+ "align_right": "Aligner à droite"
+ }
},
"toasts": {
"bookmarks": {
diff --git a/apps/web/package.json b/apps/web/package.json
index 3affe516..7edae7a8 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -23,6 +23,11 @@
"@hoarder/shared-react": "workspace:^0.1.0",
"@hoarder/trpc": "workspace:^0.1.0",
"@hookform/resolvers": "^3.3.4",
+ "@lexical/list": "^0.20.2",
+ "@lexical/markdown": "^0.20.2",
+ "@lexical/plain-text": "^0.20.2",
+ "@lexical/react": "^0.20.2",
+ "@lexical/rich-text": "^0.20.2",
"@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
@@ -33,6 +38,7 @@
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slot": "^1.0.2",
+ "@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-toggle": "^1.0.3",
@@ -53,6 +59,7 @@
"fastest-levenshtein": "^1.0.16",
"i18next": "^23.16.5",
"i18next-resources-to-backend": "^1.2.1",
+ "lexical": "^0.20.2",
"lucide-react": "^0.330.0",
"next": "14.2.13",
"next-auth": "^4.24.5",