From 40c5262a9c2fcb8c89d7c464ccfdc3016f933eb3 Mon Sep 17 00:00:00 2001
From: Giuseppe <Giuseppe06@gmail.com>
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 <giuseppe.lapenta@enovacom.com>
Co-authored-by: Mohamed Bassem <me@mbassem.com>
---
 .../ui/markdown/plugins/toolbar-plugin.tsx         | 290 +++++++++++++++++++++
 1 file changed, 290 insertions(+)
 create mode 100644 apps/web/components/ui/markdown/plugins/toolbar-plugin.tsx

(limited to 'apps/web/components/ui/markdown/plugins/toolbar-plugin.tsx')

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>
+  );
+}
-- 
cgit v1.2.3-70-g09d2

