From 40c5262a9c2fcb8c89d7c464ccfdc3016f933eb3 Mon Sep 17 00:00:00 2001 From: Giuseppe Date: Sat, 21 Dec 2024 14:09:30 +0100 Subject: 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 Co-authored-by: Mohamed Bassem --- apps/web/components/ui/markdown-component.tsx | 61 ----- .../web/components/ui/markdown/markdown-editor.tsx | 124 +++++++++ .../components/ui/markdown/markdown-readonly.tsx | 57 ++++ .../ui/markdown/plugins/toolbar-plugin.tsx | 290 +++++++++++++++++++++ apps/web/components/ui/markdown/theme.ts | 35 +++ apps/web/components/ui/switch.tsx | 28 ++ 6 files changed, 534 insertions(+), 61 deletions(-) delete mode 100644 apps/web/components/ui/markdown-component.tsx create mode 100644 apps/web/components/ui/markdown/markdown-editor.tsx create mode 100644 apps/web/components/ui/markdown/markdown-readonly.tsx create mode 100644 apps/web/components/ui/markdown/plugins/toolbar-plugin.tsx create mode 100644 apps/web/components/ui/markdown/theme.ts create mode 100644 apps/web/components/ui/switch.tsx (limited to 'apps/web/components/ui') diff --git a/apps/web/components/ui/markdown-component.tsx b/apps/web/components/ui/markdown-component.tsx deleted file mode 100644 index d3c832ac..00000000 --- a/apps/web/components/ui/markdown-component.tsx +++ /dev/null @@ -1,61 +0,0 @@ -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(null); - return ( - - { - return ref.current?.textContent ?? ""; - }} - /> -
-    
-  );
-}
-
-export function MarkdownComponent({
-  children: markdown,
-}: {
-  children: string;
-}) {
-  return (
-    ;
-        },
-        code({ className, children, ...props }) {
-          const match = /language-(\w+)/.exec(className ?? "");
-          return match ? (
-            // @ts-expect-error -- Refs are not compatible for some reason
-            
-              {String(children).replace(/\n$/, "")}
-            
-          ) : (
-            
-              {children}
-            
-          );
-        },
-      }}
-    >
-      {markdown}
-    
-  );
-}
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 (
+      
+        
+ onSave(rawMarkdown))} + isSaving={!!isSaving} + /> + {isRawMarkdownMode ? ( + + } + ErrorBoundary={LexicalErrorBoundary} + /> + ) : ( + + } + ErrorBoundary={LexicalErrorBoundary} + /> + )} +
+ + + + + + +
+ ); + }, +); +// needed for linter because of memo +MarkdownEditor.displayName = "MarkdownEditor"; + +export default MarkdownEditor; diff --git a/apps/web/components/ui/markdown/markdown-readonly.tsx b/apps/web/components/ui/markdown/markdown-readonly.tsx new file mode 100644 index 00000000..29077480 --- /dev/null +++ b/apps/web/components/ui/markdown/markdown-readonly.tsx @@ -0,0 +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(null); + return ( + + { + return ref.current?.textContent ?? ""; + }} + /> +
+    
+  );
+}
+
+export function MarkdownReadonly({ children: markdown }: { children: string }) {
+  return (
+    ;
+        },
+        code({ className, children, ...props }) {
+          const match = /language-(\w+)/.exec(className ?? "");
+          return match ? (
+            // @ts-expect-error -- Refs are not compatible for some reason
+            
+              {String(children).replace(/\n$/, "")}
+            
+          ) : (
+            
+              {children}
+            
+          );
+        },
+      }}
+    >
+      {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 (
+    
+      
+        
+          
+            
+          
+        
+        
+          
+            
+            
+          
+          
+            
+            
+          
+          
+            
+            
+          
+          
+            
+            
+          
+          
+            
+            
+          
+          
+            
+            
+          
+          
+            
+            
+          
+          
+            
+            
+          
+        
+      
{t("editor.text_toolbar.markdown_shortcuts.label")}
+ {t("editor.text_toolbar.markdown_shortcuts.heading.label")} + + {t("editor.text_toolbar.markdown_shortcuts.heading.example")} +
+ {t("editor.text_toolbar.markdown_shortcuts.bold.label")} + + {t("editor.text_toolbar.markdown_shortcuts.bold.example")} +
+ {t("editor.text_toolbar.markdown_shortcuts.italic.label")} + + {t("editor.text_toolbar.markdown_shortcuts.italic.example")} +
+ {t("editor.text_toolbar.markdown_shortcuts.blockquote.label")} + + {t("editor.text_toolbar.markdown_shortcuts.blockquote.example")} +
+ {t("editor.text_toolbar.markdown_shortcuts.ordered_list.label")} + + {t("editor.text_toolbar.markdown_shortcuts.ordered_list.example")} +
+ {t("editor.text_toolbar.markdown_shortcuts.unordered_list.label")} + + {t( + "editor.text_toolbar.markdown_shortcuts.unordered_list.example", + )} +
+ {t("editor.text_toolbar.markdown_shortcuts.inline_code.label")} + + {t("editor.text_toolbar.markdown_shortcuts.inline_code.example")} +
+ {t("editor.text_toolbar.markdown_shortcuts.block_code.label")} + + {t("editor.text_toolbar.markdown_shortcuts.block_code.example")} +
+
+ ); +} + +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; + 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 ( +
+
+ {formatButtons.map( + ({ command, format, icon: Icon, isActive, label }) => ( + + ), + )} +
+
+
+ + +
+ {onSave && ( + { + onSave?.(); + }} + > + + Save + + )} + +
+
+ ); +} 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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +Switch.displayName = SwitchPrimitives.Root.displayName; + +export { Switch }; -- cgit v1.2.3-70-g09d2