diff options
Diffstat (limited to 'apps')
| -rw-r--r-- | apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx | 43 | ||||
| -rw-r--r-- | apps/web/components/dashboard/bookmarks/BookmarkedTextEditor.tsx | 67 | ||||
| -rw-r--r-- | apps/web/components/dashboard/bookmarks/TextCard.tsx | 11 | ||||
| -rw-r--r-- | apps/web/components/dashboard/preview/TextContentSection.tsx | 7 | ||||
| -rw-r--r-- | apps/web/components/ui/markdown/markdown-editor.tsx | 124 | ||||
| -rw-r--r-- | apps/web/components/ui/markdown/markdown-readonly.tsx (renamed from apps/web/components/ui/markdown-component.tsx) | 118 | ||||
| -rw-r--r-- | apps/web/components/ui/markdown/plugins/toolbar-plugin.tsx | 290 | ||||
| -rw-r--r-- | apps/web/components/ui/markdown/theme.ts | 35 | ||||
| -rw-r--r-- | apps/web/components/ui/switch.tsx | 28 | ||||
| -rw-r--r-- | apps/web/lib/i18n/locales/de/translation.json | 15 | ||||
| -rw-r--r-- | apps/web/lib/i18n/locales/en/translation.json | 50 | ||||
| -rw-r--r-- | apps/web/lib/i18n/locales/fr/translation.json | 15 | ||||
| -rw-r--r-- | apps/web/package.json | 7 |
13 files changed, 684 insertions, 126 deletions
diff --git a/apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx b/apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx new file mode 100644 index 00000000..74eb0868 --- /dev/null +++ b/apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx @@ -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>
+ );
+}
diff --git a/apps/web/components/dashboard/bookmarks/BookmarkedTextEditor.tsx b/apps/web/components/dashboard/bookmarks/BookmarkedTextEditor.tsx index e0434943..b2c27c7e 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkedTextEditor.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkedTextEditor.tsx @@ -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, @@ -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> ); diff --git a/apps/web/components/dashboard/bookmarks/TextCard.tsx b/apps/web/components/dashboard/bookmarks/TextCard.tsx index 14a4f905..9d168910 100644 --- a/apps/web/components/dashboard/bookmarks/TextCard.tsx +++ b/apps/web/components/dashboard/bookmarks/TextCard.tsx @@ -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"; @@ -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)} /> diff --git a/apps/web/components/dashboard/preview/TextContentSection.tsx b/apps/web/components/dashboard/preview/TextContentSection.tsx index 327436c6..a58bc717 100644 --- a/apps/web/components/dashboard/preview/TextContentSection.tsx +++ b/apps/web/components/dashboard/preview/TextContentSection.tsx @@ -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"; @@ -27,7 +28,9 @@ export function TextContentSection({ bookmark }: { bookmark: ZBookmark }) { /> </div> )} - <MarkdownComponent>{bookmark.content.text}</MarkdownComponent> + <BookmarkMarkdownComponent> + {bookmark as ZBookmarkTypeText} + </BookmarkMarkdownComponent> </ScrollArea> ); } diff --git a/apps/web/components/ui/markdown/markdown-editor.tsx b/apps/web/components/ui/markdown/markdown-editor.tsx new file mode 100644 index 00000000..85f0c878 --- /dev/null +++ b/apps/web/components/ui/markdown/markdown-editor.tsx @@ -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; diff --git a/apps/web/components/ui/markdown-component.tsx b/apps/web/components/ui/markdown/markdown-readonly.tsx index d3c832ac..29077480 100644 --- a/apps/web/components/ui/markdown-component.tsx +++ b/apps/web/components/ui/markdown/markdown-readonly.tsx @@ -1,61 +1,57 @@ -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";
-
-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>
- );
-}
-
-export function MarkdownComponent({
- children: markdown,
-}: {
- children: string;
-}) {
- 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>
- );
-}
+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"; + +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> + ); +} + +export function MarkdownReadonly({ children: markdown }: { children: string }) { + 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> + ); +} 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> + ); +} diff --git a/apps/web/components/ui/markdown/theme.ts b/apps/web/components/ui/markdown/theme.ts new file mode 100644 index 00000000..ff088e32 --- /dev/null +++ b/apps/web/components/ui/markdown/theme.ts @@ -0,0 +1,35 @@ +export const MarkdownEditorTheme = { + code: "bg-[#282A36] text-[#F8F8F2] font-mono block px-4 py-2 my-2 text-sm overflow-x-auto relative rounded-md shadow-sm", + codeHighlight: { + atrule: "text-[#8BE9FD]", + attr: "text-[#8BE9FD]", + boolean: "text-[#FF79C6]", + builtin: "text-[#50FA7B]", + cdata: "text-[#6272A4]", + char: "text-[#50FA7B]", + class: "text-[#FF79C6]", + "class-name": "text-[#FF79C6]", + comment: "text-[#6272A4]", + constant: "text-[#FF79C6]", + deleted: "text-[#FF5555]", + doctype: "text-[#6272A4]", + entity: "text-[#FFB86C]", + function: "text-[#50FA7B]", + important: "text-[#F1FA8C]", + inserted: "text-[#50FA7B]", + keyword: "text-[#FF79C6]", + namespace: "text-[#F1FA8C]", + number: "text-[#BD93F9]", + operator: "text-[#FFB86C]", + prolog: "text-[#6272A4]", + property: "text-[#FFB86C]", + punctuation: "text-[#F8F8F2]", + regex: "text-[#FF5555]", + selector: "text-[#50FA7B]", + string: "text-[#F1FA8C]", + symbol: "text-[#FF79C6]", + tag: "text-[#FF79C6]", + url: "text-[#8BE9FD]", + variable: "text-[#F1FA8C]", + }, +}; diff --git a/apps/web/components/ui/switch.tsx b/apps/web/components/ui/switch.tsx new file mode 100644 index 00000000..2438dc86 --- /dev/null +++ b/apps/web/components/ui/switch.tsx @@ -0,0 +1,28 @@ +"use client"; + +import * as React from "react"; +import { cn } from "@/lib/utils"; +import * as SwitchPrimitives from "@radix-ui/react-switch"; + +const Switch = React.forwardRef< + React.ElementRef<typeof SwitchPrimitives.Root>, + React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> +>(({ className, ...props }, ref) => ( + <SwitchPrimitives.Root + className={cn( + "peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input", + className, + )} + {...props} + ref={ref} + > + <SwitchPrimitives.Thumb + className={cn( + "pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0", + )} + /> + </SwitchPrimitives.Root> +)); +Switch.displayName = SwitchPrimitives.Root.displayName; + +export { Switch }; diff --git a/apps/web/lib/i18n/locales/de/translation.json b/apps/web/lib/i18n/locales/de/translation.json index 40a167ad..d744dc97 100644 --- a/apps/web/lib/i18n/locales/de/translation.json +++ b/apps/web/lib/i18n/locales/de/translation.json @@ -180,7 +180,20 @@ "import_as_separate_bookmarks": "Als separate Lesezeichen importieren", "placeholder": "Fügen Sie einen Link oder ein Bild ein, schreiben Sie eine Notiz oder ziehen Sie ein Bild hierher ...", "new_item": "NEUER EINTRAG", - "disabled_submissions": "Einsendungen sind deaktiviert" + "disabled_submissions": "Einsendungen sind deaktiviert", + "text_toolbar": { + "undo": "Rückgängig", + "redo": "Wiederholen", + "bold": "Fett", + "italic": "Kursiv", + "underline": "Unterstrichen", + "strikethrough": "Durchgestrichen", + "code": "Code", + "highlight": "Hervorheben", + "align_left": "Linksbündig", + "align_center": "Zentriert", + "align_right": "Rechtsbündig" + } }, "toasts": { "bookmarks": { diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json index 9f12487f..a871489a 100644 --- a/apps/web/lib/i18n/locales/en/translation.json +++ b/apps/web/lib/i18n/locales/en/translation.json @@ -187,7 +187,55 @@ "import_as_separate_bookmarks": "Import as separate Bookmarks", "placeholder": "Paste a link or an image, write a note or drag and drop an image in here ...", "new_item": "NEW ITEM", - "disabled_submissions": "Submissions are disabled" + "disabled_submissions": "Submissions are disabled", + "text_toolbar": { + "undo": "Undo", + "redo": "Redo", + "bold": "Bold", + "italic": "Italic", + "underline": "Underline", + "strikethrough": "Strikethrough", + "code": "Code", + "highlight": "Highlight", + "align_left": "Left Align", + "align_center": "Center Align", + "align_right": "Right Align", + "markdown_shortcuts": { + "label": "Markdown shortcuts", + "heading": { + "label": "Heading", + "example": "# H1, ## H2, ### H3" + }, + "bold": { + "label": "Bold", + "example": "**text** or CTRL+b" + }, + "italic": { + "label": "Italic", + "example": "*Italic* or _Italic_ or CTRL+i" + }, + "blockquote": { + "label": "Blockquote", + "example": "> Blockquote" + }, + "ordered_list": { + "label": "Ordered List", + "example": "1. List item" + }, + "unordered_list": { + "label": "Unordered List", + "example": "- List item" + }, + "inline_code": { + "label": "Inline Code", + "example": "`Code`" + }, + "block_code": { + "label": "Block Code", + "example": "``` + space" + } + } + } }, "toasts": { "bookmarks": { diff --git a/apps/web/lib/i18n/locales/fr/translation.json b/apps/web/lib/i18n/locales/fr/translation.json index d369ad44..142e9148 100644 --- a/apps/web/lib/i18n/locales/fr/translation.json +++ b/apps/web/lib/i18n/locales/fr/translation.json @@ -180,7 +180,20 @@ "import_as_separate_bookmarks": "Importer comme favoris séparés", "placeholder": "Collez un lien ou une image, écrivez une note ou glissez-déposez une image ici ...", "new_item": "NOUVEL ÉLÉMENT", - "disabled_submissions": "Les soumissions sont désactivées" + "disabled_submissions": "Les soumissions sont désactivées", + "text_toolbar": { + "undo": "Annuler", + "redo": "Rétablir", + "bold": "Gras", + "italic": "Italique", + "underline": "Souligné", + "strikethrough": "Barré", + "code": "Code", + "highlight": "Surligner", + "align_left": "Aligner à gauche", + "align_center": "Aligner au centre", + "align_right": "Aligner à droite" + } }, "toasts": { "bookmarks": { diff --git a/apps/web/package.json b/apps/web/package.json index 3affe516..7edae7a8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -23,6 +23,11 @@ "@hoarder/shared-react": "workspace:^0.1.0", "@hoarder/trpc": "workspace:^0.1.0", "@hookform/resolvers": "^3.3.4", + "@lexical/list": "^0.20.2", + "@lexical/markdown": "^0.20.2", + "@lexical/plain-text": "^0.20.2", + "@lexical/react": "^0.20.2", + "@lexical/rich-text": "^0.20.2", "@radix-ui/react-collapsible": "^1.0.3", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", @@ -33,6 +38,7 @@ "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-toast": "^1.1.5", "@radix-ui/react-toggle": "^1.0.3", @@ -53,6 +59,7 @@ "fastest-levenshtein": "^1.0.16", "i18next": "^23.16.5", "i18next-resources-to-backend": "^1.2.1", + "lexical": "^0.20.2", "lucide-react": "^0.330.0", "next": "14.2.13", "next-auth": "^4.24.5", |
