Skip to content

Commit

Permalink
hoarder-app#701 Improve note support : WYSIWYG markdown
Browse files Browse the repository at this point in the history
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...
  • Loading branch information
Giuseppe Lapenta committed Dec 4, 2024
1 parent 5a49691 commit 08c27a7
Show file tree
Hide file tree
Showing 12 changed files with 813 additions and 158 deletions.
16 changes: 0 additions & 16 deletions apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
"use client";

import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
Expand All @@ -17,7 +16,6 @@ import {
List,
ListX,
MoreHorizontal,
Pencil,
RotateCw,
Tags,
Trash2,
Expand All @@ -36,7 +34,6 @@ import { useRemoveBookmarkFromList } from "@hoarder/shared-react/hooks//lists";
import { useBookmarkGridContext } from "@hoarder/shared-react/hooks/bookmark-grid-context";
import { BookmarkTypes } from "@hoarder/shared/types/bookmarks";

import { BookmarkedTextEditor } from "./BookmarkedTextEditor";
import { ArchivedActionIcon, FavouritedActionIcon } from "./icons";
import { useManageListsModal } from "./ManageListsModal";
import { useTagModel } from "./TagModal";
Expand All @@ -53,8 +50,6 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) {
const { setOpen: setManageListsModalOpen, content: manageListsModal } =
useManageListsModal(bookmark.id);

const [isTextEditorOpen, setTextEditorOpen] = useState(false);

const { listId } = useBookmarkGridContext() ?? {};

const onError = () => {
Expand Down Expand Up @@ -112,11 +107,6 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) {
<>
{tagModal}
{manageListsModal}
<BookmarkedTextEditor
bookmark={bookmark}
open={isTextEditorOpen}
setOpen={setTextEditorOpen}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
Expand All @@ -127,12 +117,6 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-fit">
{bookmark.content.type === BookmarkTypes.TEXT && (
<DropdownMenuItem onClick={() => setTextEditorOpen(true)}>
<Pencil className="mr-2 size-4" />
<span>Edit</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
disabled={demoMode}
onClick={() =>
Expand Down
81 changes: 0 additions & 81 deletions apps/web/components/dashboard/bookmarks/BookmarkedTextEditor.tsx

This file was deleted.

7 changes: 3 additions & 4 deletions apps/web/components/dashboard/bookmarks/TextCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,14 @@ 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={
<MarkdownComponent readOnly={true}>{bookmark}</MarkdownComponent>
}
footer={
getSourceUrl(bookmark) && (
<FooterLinkURL url={getSourceUrl(bookmark)} />
Expand Down
5 changes: 4 additions & 1 deletion apps/web/components/dashboard/preview/TextContentSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Image from "next/image";
import { MarkdownComponent } from "@/components/ui/markdown-component";
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>
<MarkdownComponent readOnly={false}>
{bookmark as ZBookmarkTypeText}
</MarkdownComponent>
</ScrollArea>
);
}
104 changes: 51 additions & 53 deletions apps/web/components/ui/markdown-component.tsx
Original file line number Diff line number Diff line change
@@ -1,61 +1,59 @@
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";
import React, { useState } from "react";
import { ActionButton } from "@/components/ui/action-button";
import MarkdownEditor from "@/components/ui/markdown/markdown-editor";
import { toast } from "@/components/ui/use-toast";

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>
);
}
import type { ZBookmarkTypeText } from "@hoarder/shared/types/bookmarks";
import { useUpdateBookmarkText } from "@hoarder/shared-react/hooks/bookmarks";

export function MarkdownComponent({
children: markdown,
children: bookmark,
readOnly = true,
}: {
children: string;
children: ZBookmarkTypeText;
readOnly?: boolean;
}) {
const [noteText, setNoteText] = useState(bookmark.content.text);

const { mutate: updateBookmarkMutator, isPending } = useUpdateBookmarkText({
onSuccess: () => {
toast({
description: "Note updated!",
});
},
onError: () => {
toast({ description: "Something went wrong", variant: "destructive" });
},
});

const onSave = () => {
updateBookmarkMutator({
bookmarkId: bookmark.id,
text: noteText,
});
};
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>
<>
<MarkdownEditor
readonly={readOnly}
onChangeMarkdown={(value: string) => {
setNoteText(value);
}}
>
{bookmark.content.text}
</MarkdownEditor>
{!readOnly && (
<div className="absolute bottom-2 right-2">
<ActionButton
type="button"
loading={isPending}
onClick={onSave}
disabled={isPending}
>
Save
</ActionButton>
</div>
)}
</>
);
}
103 changes: 103 additions & 0 deletions apps/web/components/ui/markdown/markdown-editor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import React, { useEffect, useState } from "react";
import ToolbarPlugin from "@/components/ui/markdown/plugins/tollbar-plugin";
import { UpdateMarkdownPlugin } from "@/components/ui/markdown/plugins/update-markdown-editor-plugin";
import { MarkdownEditorTheme } from "@/components/ui/markdown/theme/theme";
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 { 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 { EditorState } from "lexical";

function onError(error: Error) {
console.error(error);
}

const EDITOR_NODES = [HeadingNode, ListNode, ListItemNode, QuoteNode, LinkNode];

interface MarkdownEditorProps {
children: string;
onChangeMarkdown?: (markdown: string) => void;
readonly?: boolean;
}

const MarkdownEditor = ({
children: initialMarkdown,
onChangeMarkdown,
readonly = false,
}: MarkdownEditorProps) => {
const [markdown, setMarkdown] = useState<string | null>(null);

useEffect(() => {
setMarkdown(initialMarkdown);
}, [initialMarkdown]);

if (markdown === null) {
return <div>Loading...</div>;
}

const initialConfig: InitialConfigType = {
namespace: "editor",
onError,
editable: !readonly,
theme: MarkdownEditorTheme,
nodes: EDITOR_NODES,
editorState: () => $convertFromMarkdownString(markdown, TRANSFORMERS),
};

return (
<LexicalComposer initialConfig={initialConfig}>
{readonly ? (
<PlainTextPlugin
contentEditable={
<ContentEditable className="h-full w-full content-center" />
}
ErrorBoundary={LexicalErrorBoundary}
></PlainTextPlugin>
) : (
<>
<div className="flex h-full flex-col justify-stretch">
<ToolbarPlugin></ToolbarPlugin>
<RichTextPlugin
contentEditable={<ContentEditable className="h-full" />}
ErrorBoundary={LexicalErrorBoundary}
/>
</div>
</>
)}
{!readonly && (
<>
<HistoryPlugin />
<AutoFocusPlugin />
<TabIndentationPlugin />
<OnChangePlugin
onChange={(editorState: EditorState) => {
editorState.read(() => {
const markdownString = $convertToMarkdownString();
if (onChangeMarkdown) onChangeMarkdown(markdownString);
});
}}
/>
</>
)}
<UpdateMarkdownPlugin markdown={markdown} />
</LexicalComposer>
);
};

export default MarkdownEditor;
Loading

0 comments on commit 08c27a7

Please sign in to comment.