Skip to content

Commit

Permalink
feature: WYSIWYG markdown for notes. Fixes #701 (#715)
Browse files Browse the repository at this point in the history
* #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>
  • Loading branch information
3 people authored Dec 21, 2024
1 parent 16f2ce3 commit 40c5262
Show file tree
Hide file tree
Showing 14 changed files with 1,177 additions and 129 deletions.
Original file line number Diff line number Diff line change
@@ -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>
);
}
67 changes: 12 additions & 55 deletions apps/web/components/dashboard/bookmarks/BookmarkedTextEditor.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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>
);
Expand Down
11 changes: 6 additions & 5 deletions apps/web/components/dashboard/bookmarks/TextCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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)} />
Expand Down
7 changes: 5 additions & 2 deletions apps/web/components/dashboard/preview/TextContentSection.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -27,7 +28,9 @@ export function TextContentSection({ bookmark }: { bookmark: ZBookmark }) {
/>
</div>
)}
<MarkdownComponent>{bookmark.content.text}</MarkdownComponent>
<BookmarkMarkdownComponent>
{bookmark as ZBookmarkTypeText}
</BookmarkMarkdownComponent>
</ScrollArea>
);
}
124 changes: 124 additions & 0 deletions apps/web/components/ui/markdown/markdown-editor.tsx
Original file line number Diff line number Diff line change
@@ -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;
Loading

0 comments on commit 40c5262

Please sign in to comment.