aboutsummaryrefslogtreecommitdiffstats
path: root/apps/web/components/ui/markdown/plugins
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/web/components/ui/markdown/plugins
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/web/components/ui/markdown/plugins')
-rw-r--r--apps/web/components/ui/markdown/plugins/toolbar-plugin.tsx290
1 files changed, 290 insertions, 0 deletions
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>
+ );
+}