Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#701 Improve note support : WYSIWYG markdown #715

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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