diff options
| author | Dhravya Shah <[email protected]> | 2024-06-18 17:58:46 -0500 |
|---|---|---|
| committer | Dhravya Shah <[email protected]> | 2024-06-18 17:58:46 -0500 |
| commit | f4bb71e8f7e07bb2e919b7f222d5acb2905eb8f2 (patch) | |
| tree | 7310dc521ef3559055bbe71f50c3861be2fa0503 /apps/web/app/(editor) | |
| parent | darkmode by default - so that the colors don't f up on lightmode devices (diff) | |
| parent | Create Embeddings for Canvas (diff) | |
| download | supermemory-default-darkmode.tar.xz supermemory-default-darkmode.zip | |
Diffstat (limited to 'apps/web/app/(editor)')
27 files changed, 2026 insertions, 0 deletions
diff --git a/apps/web/app/(editor)/ai.md b/apps/web/app/(editor)/ai.md new file mode 100644 index 00000000..1528a7bd --- /dev/null +++ b/apps/web/app/(editor)/ai.md @@ -0,0 +1,43 @@ +## to access the editor +``` +import { useEditor } from "novel"; +const editor = useEditor() +``` + +## to get previous text +``` +import { getPrevText } from "novel/utils"; +const pos = editor.state.selection.from; +const text = getPrevText(editor, pos); +``` + +## selected content into markdown format +``` +const slice = editor.state.selection.content(); +const text = editor.storage.markdown.serializer.serialize(slice.content); +``` + +## replace Selection +``` +const selection = editor.view.state.selection; +editor.chain().focus() + .insertContentAt( + { + from: selection.from, + to: selection.to, + }, + completion, + ) + .run(); +``` + + +## to insert after +``` +const selection = editor.view.state.selection; +editor + .chain() + .focus() + .insertContentAt(selection.to + 1, completion) + .run(); +``` diff --git a/apps/web/app/(editor)/components/aigenerate.tsx b/apps/web/app/(editor)/components/aigenerate.tsx new file mode 100644 index 00000000..f27fd50f --- /dev/null +++ b/apps/web/app/(editor)/components/aigenerate.tsx @@ -0,0 +1,169 @@ +import React, { useEffect, useRef, useState } from "react"; +import Magic from "./ui/magic"; +import CrazySpinner from "./ui/crazy-spinner"; +import Asksvg from "./ui/asksvg"; +import Rewritesvg from "./ui/rewritesvg"; +import Translatesvg from "./ui/translatesvg"; +import Autocompletesvg from "./ui/autocompletesvg"; +import { motion, AnimatePresence } from "framer-motion"; +import type { Editor } from "@tiptap/core"; +import { useEditor } from "novel"; + +function Aigenerate() { + const [visible, setVisible] = useState(false); + const [generating, setGenerating] = useState(false); + + const { editor } = useEditor(); + const setGeneratingfn = (v: boolean) => setGenerating(v); + + return ( + <div className="z-[60] bg-[#171B1F] fixed left-0 bottom-0 w-screen flex justify-center pt-4 pb-6"> + <motion.div + animate={{ + y: visible ? "30%" : 0, + }} + onClick={() => { + setVisible(!visible); + if (visible) editor?.commands.unsetAIHighlight(); + }} + className={`select-none relative z-[70] rounded-3xl text-[#369DFD] bg-[#21303D] px-4 py-3 text-sm flex gap-2 items-center font-medium whitespace-nowrap overflow-hidden transition-[width] w-[6.25rem] ${visible && "w-[10.55rem]"}`} + > + <Magic className="h-4 w-4 shrink-0 translate-y-[5%]" /> + {visible && generating ? ( + <> + Generating <CrazySpinner /> + </> + ) : visible ? ( + <>Press Commands</> + ) : ( + <>Ask AI</> + )} + </motion.div> + <motion.div + initial={{ + opacity: 0, + y: 20, + }} + animate={{ + y: visible ? "-60%" : 20, + opacity: visible ? 1 : 0, + }} + whileHover={{ scale: 1.05 }} + transition={{ + duration: 0.2, + }} + className="absolute z-50 top-0" + > + <ToolBar setGeneratingfn={setGeneratingfn} editor={editor} /> + <div className="h-8 w-18rem bg-blue-600 blur-[16rem]" /> + </motion.div> + </div> + ); +} + +export default Aigenerate; + +const options = [ + <><Translatesvg />Translate</>, + <><Rewritesvg />Change Tone</>, + <><Asksvg />Ask Gemini</>, + <><Autocompletesvg />Auto Complete</> +]; + +function ToolBar({ + editor, + setGeneratingfn, +}: { + editor: Editor; + setGeneratingfn: (v: boolean) => void; +}) { + const [index, setIndex] = useState(0); + + return ( + <div + className={ + "select-none flex gap-6 bg-[#1F2428] active:scale-[.98] transition rounded-3xl px-1 py-1 text-sm font-medium" + } + > + {options.map((item, idx) => ( + <div + key={idx} + className="relative block h-full w-full px-3 py-2 text-[#989EA4]" + onMouseEnter={() => setIndex(idx)} + > + <AnimatePresence> + {index === idx && ( + <motion.span + onClick={() => + AigenerateContent({ idx, editor, setGeneratingfn }) + } + className="absolute select-none inset-0 block h-full w-full rounded-xl bg-background-light" + layoutId="hoverBackground" + initial={{ opacity: 0 }} + animate={{ + opacity: 1, + transition: { duration: 0.15 }, + }} + exit={{ + opacity: 0, + transition: { duration: 0.15, delay: 0.2 }, + }} + /> + )} + </AnimatePresence> + <div className="select-none flex items-center whitespace-nowrap gap-3 relative z-[60] pointer-events-none"> + {item} + </div> + </div> + ))} + </div> + ); +} + +async function AigenerateContent({ + idx, + editor, + setGeneratingfn, +}: { + idx: number; + editor: Editor; + setGeneratingfn: (v: boolean) => void; +}) { + setGeneratingfn(true); + + const { from, to } = editor.view.state.selection; + + const slice = editor.state.selection.content(); + const text = editor.storage.markdown.serializer.serialize(slice.content); + + const request = [ + "Translate to hindi written in english, do not write anything else", + "change tone, improve the way be more formal", + "ask, answer the question", + "continue this, maximum 30 characters, do not repeat just continue don't use ... to denote start", + ] + + const res = await fetch("/api/editorai", { + method: "POST", + body: JSON.stringify({ + context: text, + request: request[idx], + }), + }) + const {completion}: {completion: string} = await res.json(); + console.log(completion) + + if (idx === 0 || idx === 1){ + const selectionLength = completion.length + from + editor.chain().focus() + .insertContentAt({from, to}, completion).setTextSelection({from, to: selectionLength}) + .run(); + } else { + const selectionLength = completion.length + to + 1 + editor.chain().focus() + .insertContentAt(to+1, completion).setTextSelection({from, to: selectionLength}) + .run(); + } + + setGeneratingfn(false); +} diff --git a/apps/web/app/(editor)/components/editorcommands.tsx b/apps/web/app/(editor)/components/editorcommands.tsx new file mode 100644 index 00000000..8e80df46 --- /dev/null +++ b/apps/web/app/(editor)/components/editorcommands.tsx @@ -0,0 +1,85 @@ +import React, { useState } from "react"; +import { + EditorBubble, + EditorCommand, + EditorCommandEmpty, + EditorCommandItem, + EditorCommandList, +} from "novel"; +import { suggestionItems } from "./slash-command"; +import { Separator } from "@repo/ui/shadcn/separator"; +import { NodeSelector } from "./selectors/node-selector"; +import { LinkSelector } from "./selectors/link-selector"; +import { TextButtons } from "./selectors/text-buttons"; +import { ColorSelector } from "./selectors/color-selector"; +import { BgColorSelector } from "./selectors/bgcolor-selector"; + +function EditorCommands() { + return ( + <> + <SlashCommand /> + <PopupMenu /> + </> + ); +} + +function SlashCommand() { + return ( + <EditorCommand className="z-50 h-auto max-h-[330px] min-w-[20rem] overflow-y-auto rounded-lg bg-[#1F2428] shadow-md transition-all"> + <EditorCommandEmpty className="px-4 text-lg text-muted-foreground"> + No results + </EditorCommandEmpty> + <EditorCommandList> + {suggestionItems.map((item) => ( + <EditorCommandItem + value={item.title} + onCommand={(val) => item.command(val)} + className="flex w-full items-center space-x-4 rounded-md px-4 py-3 text-left text-sm hover:bg-accent aria-selected:bg-[#21303D] group/command" + key={item.title} + > + <div className="flex h-11 w-11 items-center justify-center rounded-md bg-[#2D343A] group-aria-selected/command:bg-[#369DFD33] stroke-[#989EA4] group-aria-selected/command:stroke-[#369DFD]"> + {item.icon} + </div> + <div> + <p className="font-medium text-[#FFFFFF] group-aria-selected/command:text-[#369DFD]"> + {item.title} + </p> + <p className="text-xs text-muted-foreground group-aria-selected/command:text-[#369DFDB2]"> + {item.description} + </p> + </div> + </EditorCommandItem> + ))} + </EditorCommandList> + </EditorCommand> + ); +} + +function PopupMenu() { + const [openNode, setOpenNode] = useState(false); + const [openColor, setOpenColor] = useState(false); + const [openBgColor, setOpenBgColor] = useState(false); + const [openLink, setOpenLink] = useState(false); + const [openMenu, setOpenMenu] = useState(false); + return ( + <EditorBubble + tippyOptions={{ + placement: openMenu ? "bottom-start" : "top", + }} + className="flex w-fit max-w-[90vw] overflow-hidden bg-[#1F2428] text-white rounded " + > + <Separator orientation="vertical" /> + <NodeSelector open={openNode} onOpenChange={setOpenNode} /> + <Separator orientation="vertical" /> + <LinkSelector open={openLink} onOpenChange={setOpenLink} /> + <Separator orientation="vertical" /> + <TextButtons /> + <Separator orientation="vertical" /> + <ColorSelector open={openColor} onOpenChange={setOpenColor} /> + <Separator orientation="vertical" /> + <BgColorSelector open={openBgColor} onOpenChange={setOpenBgColor} /> + </EditorBubble> + ); +} + +export default EditorCommands; diff --git a/apps/web/app/(editor)/components/extensions.ts b/apps/web/app/(editor)/components/extensions.ts new file mode 100644 index 00000000..0c581154 --- /dev/null +++ b/apps/web/app/(editor)/components/extensions.ts @@ -0,0 +1,141 @@ +import { + AIHighlight, + CharacterCount, + CodeBlockLowlight, + GlobalDragHandle, + HorizontalRule, + Placeholder, + StarterKit, + TaskItem, + TaskList, + TiptapImage, + TiptapLink, + UpdatedImage, + Youtube, +} from "novel/extensions"; +import { UploadImagesPlugin } from "novel/plugins"; + +import { cx } from "class-variance-authority"; +import { common, createLowlight } from "lowlight"; + +//TODO I am using cx here to get tailwind autocomplete working, idk if someone else can write a regex to just capture the class key in objects +const aiHighlight = AIHighlight; +//You can overwrite the placeholder with your own configuration +const placeholder = Placeholder; +const tiptapLink = TiptapLink.configure({ + HTMLAttributes: { + class: cx( + "text-muted-foreground underline underline-offset-[3px] hover:text-primary transition-colors cursor-pointer", + ), + }, +}); + +const tiptapImage = TiptapImage.extend({ + addProseMirrorPlugins() { + return [ + UploadImagesPlugin({ + imageClass: cx("opacity-40 rounded-lg border border-stone-200"), + }), + ]; + }, +}).configure({ + allowBase64: true, + HTMLAttributes: { + class: cx("rounded-lg border border-muted"), + }, +}); + +const updatedImage = UpdatedImage.configure({ + HTMLAttributes: { + class: cx("rounded-lg border border-muted"), + }, +}); + +const taskList = TaskList.configure({ + HTMLAttributes: { + class: cx("not-prose pl-2 "), + }, +}); +const taskItem = TaskItem.configure({ + HTMLAttributes: { + class: cx("flex gap-2 items-start my-4"), + }, + nested: true, +}); + +const horizontalRule = HorizontalRule.configure({ + HTMLAttributes: { + class: cx("mt-4 mb-6 border-t border-muted-foreground"), + }, +}); + +const starterKit = StarterKit.configure({ + bulletList: { + HTMLAttributes: { + class: cx("list-disc list-outside leading-3 -mt-2"), + }, + }, + orderedList: { + HTMLAttributes: { + class: cx("list-decimal list-outside leading-3 -mt-2"), + }, + }, + listItem: { + HTMLAttributes: { + class: cx("leading-normal -mb-2"), + }, + }, + blockquote: { + HTMLAttributes: { + class: cx("border-l-4 border-primary"), + }, + }, + codeBlock: { + HTMLAttributes: { + class: cx("rounded-md bg-muted text-muted-foreground border p-5 font-mono font-medium"), + }, + }, + code: { + HTMLAttributes: { + class: cx("rounded-md bg-muted px-1.5 py-1 font-mono font-medium"), + spellcheck: "false", + }, + }, + horizontalRule: false, + dropcursor: { + color: "#DBEAFE", + width: 4, + }, + gapcursor: false, +}); + +const codeBlockLowlight = CodeBlockLowlight.configure({ + // configure lowlight: common / all / use highlightJS in case there is a need to specify certain language grammars only + // common: covers 37 language grammars which should be good enough in most cases + lowlight: createLowlight(common), +}); + +const youtube = Youtube.configure({ + HTMLAttributes: { + class: cx("rounded-lg border border-muted"), + }, + inline: false, +}); + +const characterCount = CharacterCount.configure(); + +export const defaultExtensions = [ + starterKit, + placeholder, + tiptapLink, + tiptapImage, + updatedImage, + taskList, + taskItem, + horizontalRule, + aiHighlight, + codeBlockLowlight, + youtube, + characterCount, + GlobalDragHandle, +]; diff --git a/apps/web/app/(editor)/components/image-upload.ts b/apps/web/app/(editor)/components/image-upload.ts new file mode 100644 index 00000000..d10be168 --- /dev/null +++ b/apps/web/app/(editor)/components/image-upload.ts @@ -0,0 +1,50 @@ +import { createImageUpload } from "novel/plugins"; +import { toast } from "sonner"; + +const onUpload = (file: File) => { + //Endpoint: to upload the image + const promise = fetch("", { + method: "POST", + body: file, + }); + + return new Promise((resolve, reject) => { + toast.promise( + promise.then(async (res) => { + if (res.status === 200) { + const { url } = (await res.json()) as { url: string }; + const image = new Image(); + image.src = url; + image.onload = () => { + resolve(url); + }; + } else { + throw new Error("Error uploading image. Please try again."); + } + }), + { + loading: "Uploading image...", + success: "Image uploaded successfully.", + error: (e) => { + reject(e); + return e.message; + }, + }, + ); + }); +}; + +export const uploadFn = createImageUpload({ + onUpload, + validateFn: (file) => { + if (!file.type.includes("image/")) { + toast.error("File type not supported."); + return false; + } + if (file.size / 1024 / 1024 > 20) { + toast.error("File size too big (max 20MB)."); + return false; + } + return true; + }, +}); diff --git a/apps/web/app/(editor)/components/selectors/bgcolor-selector.tsx b/apps/web/app/(editor)/components/selectors/bgcolor-selector.tsx new file mode 100644 index 00000000..77da0f03 --- /dev/null +++ b/apps/web/app/(editor)/components/selectors/bgcolor-selector.tsx @@ -0,0 +1,107 @@ +import { Check, ChevronDown } from "lucide-react"; +import { EditorBubbleItem, useEditor } from "novel"; + +import { Button } from "@repo/ui/shadcn/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@repo/ui/shadcn/popover"; +export interface BubbleColorMenuItem { + name: string; + color: string; +} + +const HIGHLIGHT_COLORS: BubbleColorMenuItem[] = [ + { + name: "Default", + color: "var(--novel-highlight-default)", + }, + { + name: "Purple", + color: "var(--novel-highlight-purple)", + }, + { + name: "Red", + color: "var(--novel-highlight-red)", + }, + { + name: "Yellow", + color: "var(--novel-highlight-yellow)", + }, + { + name: "Blue", + color: "var(--novel-highlight-blue)", + }, + { + name: "Green", + color: "var(--novel-highlight-green)", + }, + { + name: "Orange", + color: "var(--novel-highlight-orange)", + }, + { + name: "Pink", + color: "var(--novel-highlight-pink)", + }, + { + name: "Gray", + color: "var(--novel-highlight-gray)", + }, +]; + +interface ColorSelectorProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export const BgColorSelector = ({ open, onOpenChange }: ColorSelectorProps) => { + const { editor } = useEditor(); + + if (!editor) return null; + + const activeHighlightItem = HIGHLIGHT_COLORS.find(({ color }) => editor.isActive("highlight", { color })); + + return ( + <Popover modal={true} open={open} onOpenChange={onOpenChange}> + <PopoverTrigger asChild> + <Button size="sm" className="gap-2 rounded-none" variant="ghost"> + <span + className="rounded-sm px-1" + style={{ + backgroundColor: activeHighlightItem?.color, + }} + > + A + </span> + <ChevronDown className="h-4 w-4" /> + </Button> + </PopoverTrigger> + + <PopoverContent + sideOffset={5} + className="my-1 border-none bg-[#1F2428] flex max-h-80 w-48 flex-col overflow-hidden overflow-y-auto rounded border p-1 shadow-xl " + align="start" + > + <div> + <div className="my-1 px-2 text-sm font-semibold text-muted-foreground">Background</div> + {HIGHLIGHT_COLORS.map(({ name, color }) => ( + <EditorBubbleItem + key={name} + onSelect={() => { + editor.commands.unsetHighlight(); + name !== "Default" && editor.commands.setHighlight({ color }); + }} + className="flex cursor-pointer items-center justify-between px-2 py-1 text-sm hover:bg-[#21303D]" + > + <div className="flex items-center gap-2"> + <div className="rounded-sm px-2 py-px font-medium" style={{ backgroundColor: color }}> + A + </div> + <span>{name}</span> + </div> + {editor.isActive("highlight", { color }) && <Check className="h-4 w-4" />} + </EditorBubbleItem> + ))} + </div> + </PopoverContent> + </Popover> + ); +}; diff --git a/apps/web/app/(editor)/components/selectors/color-selector.tsx b/apps/web/app/(editor)/components/selectors/color-selector.tsx new file mode 100644 index 00000000..557c4255 --- /dev/null +++ b/apps/web/app/(editor)/components/selectors/color-selector.tsx @@ -0,0 +1,111 @@ +import { Check, ChevronDown } from "lucide-react"; +import { EditorBubbleItem, useEditor } from "novel"; + +import { Button } from "@repo/ui/shadcn/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@repo/ui/shadcn/popover"; +export interface BubbleColorMenuItem { + name: string; + color: string; +} + +const TEXT_COLORS: BubbleColorMenuItem[] = [ + { + name: "Default", + color: "var(--novel-black)", + }, + { + name: "Purple", + color: "#9333EA", + }, + { + name: "Red", + color: "#E00000", + }, + { + name: "Yellow", + color: "#EAB308", + }, + { + name: "Blue", + color: "#2563EB", + }, + { + name: "Green", + color: "#008A00", + }, + { + name: "Orange", + color: "#FFA500", + }, + { + name: "Pink", + color: "#BA4081", + }, + { + name: "Gray", + color: "#A8A29E", + }, +]; + + +interface ColorSelectorProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export const ColorSelector = ({ open, onOpenChange }: ColorSelectorProps) => { + const { editor } = useEditor(); + + if (!editor) return null; + const activeColorItem = TEXT_COLORS.find(({ color }) => editor.isActive("textStyle", { color })); + + return ( + <Popover modal={true} open={open} onOpenChange={onOpenChange}> + <PopoverTrigger asChild> + <Button size="sm" className="gap-2 rounded-none" variant="ghost"> + <span + className="rounded-sm px-1" + style={{ + color: activeColorItem?.color + }} + > + A + </span> + <ChevronDown className="h-4 w-4" /> + </Button> + </PopoverTrigger> + + <PopoverContent + sideOffset={5} + className="my-1 border-none bg-[#1F2428] flex max-h-80 w-48 flex-col overflow-hidden overflow-y-auto rounded border p-1 shadow-xl " + align="start" + > + <div className="flex flex-col"> + <div className="my-1 px-2 text-sm font-semibold text-muted-foreground">Color</div> + {TEXT_COLORS.map(({ name, color }) => ( + <EditorBubbleItem + key={name} + onSelect={() => { + editor.commands.unsetColor(); + name !== "Default" && + editor + .chain() + .focus() + .setColor(color || "") + .run(); + }} + className="flex cursor-pointer items-center justify-between px-2 py-1 text-sm hover:bg-accent" + > + <div className="flex items-center gap-2"> + <div className="rounded-sm px-2 py-px font-medium" style={{ color }}> + A + </div> + <span>{name}</span> + </div> + </EditorBubbleItem> + ))} + </div> + </PopoverContent> + </Popover> + ); +}; diff --git a/apps/web/app/(editor)/components/selectors/link-selector.tsx b/apps/web/app/(editor)/components/selectors/link-selector.tsx new file mode 100644 index 00000000..3dc28266 --- /dev/null +++ b/apps/web/app/(editor)/components/selectors/link-selector.tsx @@ -0,0 +1,95 @@ +import { Button } from "@repo/ui/shadcn/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@repo/ui/shadcn/popover"; +import { cn } from "@repo/ui/lib/utils"; +import { Check, Trash } from "lucide-react"; +import { useEditor } from "novel"; +import { useEffect, useRef } from "react"; + +export function isValidUrl(url: string) { + try { + new URL(url); + return true; + } catch (_e) { + return false; + } +} +export function getUrlFromString(str: string) { + if (isValidUrl(str)) return str; + try { + if (str.includes(".") && !str.includes(" ")) { + return new URL(`https://${str}`).toString(); + } + } catch (_e) { + return null; + } +} +interface LinkSelectorProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export const LinkSelector = ({ open, onOpenChange }: LinkSelectorProps) => { + const inputRef = useRef<HTMLInputElement>(null); + const { editor } = useEditor(); + + // Autofocus on input by default + useEffect(() => { + inputRef.current?.focus(); + }); + if (!editor) return null; + + return ( + <Popover modal={true} open={open} onOpenChange={onOpenChange}> + <PopoverTrigger asChild> + <Button size="sm" variant="ghost" className="gap-2 rounded-none border-none"> + <p className="text-base">↗</p> + <p + className={cn("underline decoration-stone-400 underline-offset-4", { + "text-blue-500": editor.isActive("link"), + })} + > + Link + </p> + </Button> + </PopoverTrigger> + <PopoverContent align="start" className="w-60 p-0" sideOffset={10}> + <form + onSubmit={(e) => { + const target = e.currentTarget as HTMLFormElement; + e.preventDefault(); + const input = target[0] as HTMLInputElement; + const url = getUrlFromString(input.value); + url && editor.chain().focus().setLink({ href: url }).run(); + }} + className="flex p-1 " + > + <input + ref={inputRef} + type="text" + placeholder="Paste a link" + className="flex-1 bg-background p-1 text-sm outline-none" + defaultValue={editor.getAttributes("link").href || ""} + /> + {editor.getAttributes("link").href ? ( + <Button + size="icon" + variant="outline" + type="button" + className="flex h-8 items-center rounded-sm p-1 text-red-600 transition-all hover:bg-red-100 dark:hover:bg-red-800" + onClick={() => { + editor.chain().focus().unsetLink().run(); + inputRef.current.value = ""; + }} + > + <Trash className="h-4 w-4" /> + </Button> + ) : ( + <Button size="icon" className="h-8"> + <Check className="h-4 w-4" /> + </Button> + )} + </form> + </PopoverContent> + </Popover> + ); +}; diff --git a/apps/web/app/(editor)/components/selectors/node-selector.tsx b/apps/web/app/(editor)/components/selectors/node-selector.tsx new file mode 100644 index 00000000..c6092b68 --- /dev/null +++ b/apps/web/app/(editor)/components/selectors/node-selector.tsx @@ -0,0 +1,126 @@ +import { + Check, + CheckSquare, + ChevronDown, + Code, + Heading1, + Heading2, + Heading3, + ListOrdered, + type LucideIcon, + TextIcon, + TextQuote, +} from "lucide-react"; +import { EditorBubbleItem, useEditor } from "novel"; + +import { Button } from "@repo/ui/shadcn/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@repo/ui/shadcn/popover"; + +export type SelectorItem = { + name: string; + icon: LucideIcon; + command: (editor: ReturnType<typeof useEditor>["editor"]) => void; + isActive: (editor: ReturnType<typeof useEditor>["editor"]) => boolean; +}; + +const items: SelectorItem[] = [ + { + name: "Text", + icon: TextIcon, + command: (editor) => editor.chain().focus().clearNodes().run(), + // I feel like there has to be a more efficient way to do this – feel free to PR if you know how! + isActive: (editor) => + editor.isActive("paragraph") && !editor.isActive("bulletList") && !editor.isActive("orderedList"), + }, + { + name: "Heading 1", + icon: Heading1, + command: (editor) => editor.chain().focus().clearNodes().toggleHeading({ level: 1 }).run(), + isActive: (editor) => editor.isActive("heading", { level: 1 }), + }, + { + name: "Heading 2", + icon: Heading2, + command: (editor) => editor.chain().focus().clearNodes().toggleHeading({ level: 2 }).run(), + isActive: (editor) => editor.isActive("heading", { level: 2 }), + }, + { + name: "Heading 3", + icon: Heading3, + command: (editor) => editor.chain().focus().clearNodes().toggleHeading({ level: 3 }).run(), + isActive: (editor) => editor.isActive("heading", { level: 3 }), + }, + { + name: "To-do List", + icon: CheckSquare, + command: (editor) => editor.chain().focus().clearNodes().toggleTaskList().run(), + isActive: (editor) => editor.isActive("taskItem"), + }, + { + name: "Bullet List", + icon: ListOrdered, + command: (editor) => editor.chain().focus().clearNodes().toggleBulletList().run(), + isActive: (editor) => editor.isActive("bulletList"), + }, + { + name: "Numbered List", + icon: ListOrdered, + command: (editor) => editor.chain().focus().clearNodes().toggleOrderedList().run(), + isActive: (editor) => editor.isActive("orderedList"), + }, + { + name: "Quote", + icon: TextQuote, + command: (editor) => editor.chain().focus().clearNodes().toggleBlockquote().run(), + isActive: (editor) => editor.isActive("blockquote"), + }, + { + name: "Code", + icon: Code, + command: (editor) => editor.chain().focus().clearNodes().toggleCodeBlock().run(), + isActive: (editor) => editor.isActive("codeBlock"), + }, +]; +interface NodeSelectorProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export const NodeSelector = ({ open, onOpenChange }: NodeSelectorProps) => { + const { editor } = useEditor(); + if (!editor) return null; + const activeItem = items.filter((item) => item.isActive(editor)).pop() ?? { + name: "Multiple", + }; + + return ( + <Popover modal={true} open={open} onOpenChange={onOpenChange}> + <PopoverTrigger asChild className="gap-2 rounded-none border-none hover:bg-accent focus:ring-0"> + <Button size="sm" variant="ghost" className="gap-2"> + <span className="whitespace-nowrap text-sm">{activeItem.name}</span> + <ChevronDown className="h-4 w-4" /> + </Button> + </PopoverTrigger> + <PopoverContent sideOffset={5} align="start" className="w-48 p-1 border-none bg-[#1F2428]"> + {items.map((item) => ( + <EditorBubbleItem + key={item.name} + onSelect={(editor) => { + item.command(editor); + onOpenChange(false); + }} + className="flex cursor-pointer items-center justify-between rounded-sm px-2 py-1 text-sm hover:bg-accent" + > + <div className="flex items-center space-x-2"> + <div className="rounded-sm p-1"> + <item.icon className="h-3 w-3" /> + </div> + <span>{item.name}</span> + </div> + {activeItem.name === item.name && <Check className="h-4 w-4" />} + </EditorBubbleItem> + ))} + </PopoverContent> + </Popover> + ); +}; diff --git a/apps/web/app/(editor)/components/selectors/text-buttons.tsx b/apps/web/app/(editor)/components/selectors/text-buttons.tsx new file mode 100644 index 00000000..f75d7b3c --- /dev/null +++ b/apps/web/app/(editor)/components/selectors/text-buttons.tsx @@ -0,0 +1,63 @@ +import { Button } from "@repo/ui/shadcn/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@repo/ui/shadcn/popover"; +import { BoldIcon, CodeIcon, ItalicIcon, StrikethroughIcon, UnderlineIcon } from "lucide-react"; +import { EditorBubbleItem, useEditor } from "novel"; +import type { SelectorItem } from "./node-selector"; +import { cn } from "@repo/ui/lib/utils"; + +export const TextButtons = () => { + const { editor } = useEditor(); + if (!editor) return null; + const items: SelectorItem[] = [ + { + name: "bold", + isActive: (editor) => editor.isActive("bold"), + command: (editor) => editor.chain().focus().toggleBold().run(), + icon: BoldIcon, + }, + { + name: "italic", + isActive: (editor) => editor.isActive("italic"), + command: (editor) => editor.chain().focus().toggleItalic().run(), + icon: ItalicIcon, + }, + { + name: "underline", + isActive: (editor) => editor.isActive("underline"), + command: (editor) => editor.chain().focus().toggleUnderline().run(), + icon: UnderlineIcon, + }, + { + name: "strike", + isActive: (editor) => editor.isActive("strike"), + command: (editor) => editor.chain().focus().toggleStrike().run(), + icon: StrikethroughIcon, + }, + { + name: "code", + isActive: (editor) => editor.isActive("code"), + command: (editor) => editor.chain().focus().toggleCode().run(), + icon: CodeIcon, + }, + ]; + return ( + <div className="flex"> + {items.map((item) => ( + <EditorBubbleItem + key={item.name} + onSelect={(editor) => { + item.command(editor); + }} + > + <Button size="sm" className="rounded-none" variant="ghost"> + <item.icon + className={cn("h-4 w-4", { + "text-blue-500": item.isActive(editor), + })} + /> + </Button> + </EditorBubbleItem> + ))} + </div> + ); +}; diff --git a/apps/web/app/(editor)/components/slash-command.tsx b/apps/web/app/(editor)/components/slash-command.tsx new file mode 100644 index 00000000..1bfb1690 --- /dev/null +++ b/apps/web/app/(editor)/components/slash-command.tsx @@ -0,0 +1,163 @@ +import { + CheckSquare, + Code, + Heading1, + Heading2, + Heading3, + ImageIcon, + List, + ListOrdered, + MessageSquarePlus, + Text, + TextQuote, + Youtube +} from "lucide-react"; +import { createSuggestionItems } from "novel/extensions"; +import { Command, renderItems } from "novel/extensions"; +import { uploadFn } from "./image-upload"; + +export const suggestionItems = createSuggestionItems([ + { + title: "Send Feedback", + description: "Let us know how we can improve.", + icon: <MessageSquarePlus stroke="inherit" size={18} />, + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).run(); + window.open("/feedback", "_blank"); + }, + }, + { + title: "Text", + description: "Just start typing with plain text.", + searchTerms: ["p", "paragraph"], + icon: <Text stroke="inherit" size={18} />, + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).toggleNode("paragraph", "paragraph").run(); + }, + }, + { + title: "To-do List", + description: "Track tasks with a to-do list.", + searchTerms: ["todo", "task", "list", "check", "checkbox"], + icon: <CheckSquare stroke="inherit" size={18} />, + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).toggleTaskList().run(); + }, + }, + { + title: "Heading 1", + description: "Big section heading.", + searchTerms: ["title", "big", "large"], + icon: <Heading1 stroke="inherit" size={18} />, + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run(); + }, + }, + { + title: "Heading 2", + description: "Medium section heading.", + searchTerms: ["subtitle", "medium"], + icon: <Heading2 stroke="inherit" size={18} />, + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run(); + }, + }, + { + title: "Heading 3", + description: "Small section heading.", + searchTerms: ["subtitle", "small"], + icon: <Heading3 stroke="inherit" size={18} />, + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run(); + }, + }, + { + title: "Bullet List", + description: "Create a simple bullet list.", + searchTerms: ["unordered", "point"], + icon: <List stroke="inherit" size={18} />, + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).toggleBulletList().run(); + }, + }, + { + title: "Numbered List", + description: "Create a list with numbering.", + searchTerms: ["ordered"], + icon: <ListOrdered stroke="inherit" size={18} />, + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).toggleOrderedList().run(); + }, + }, + { + title: "Quote", + description: "Capture a quote.", + searchTerms: ["blockquote"], + icon: <TextQuote stroke="inherit" size={18} />, + command: ({ editor, range }) => + editor.chain().focus().deleteRange(range).toggleNode("paragraph", "paragraph").toggleBlockquote().run(), + }, + { + title: "Code", + description: "Capture a code snippet.", + searchTerms: ["codeblock"], + icon: <Code stroke="inherit" size={18} />, + command: ({ editor, range }) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(), + }, + // { + // title: "Image", + // description: "Upload an image from your computer.", + // searchTerms: ["photo", "picture", "media"], + // icon: <ImageIcon stroke="inherit" size={18} />, + // command: ({ editor, range }) => { + // editor.chain().focus().deleteRange(range).run(); + // // upload image + // const input = document.createElement("input"); + // input.type = "file"; + // input.accept = "image/*"; + // input.onchange = async () => { + // if (input.files?.length) { + // const file = input.files[0]; + // const pos = editor.view.state.selection.from; + // uploadFn(file, editor.view, pos); + // } + // }; + // input.click(); + // }, + // }, + // { + // title: "Youtube", + // description: "Embed a Youtube video.", + // searchTerms: ["video", "youtube", "embed"], + // icon: <Youtube stroke="inherit" size={18} />, + // command: ({ editor, range }) => { + // const videoLink = prompt("Please enter Youtube Video Link"); + // //From https://regexr.com/3dj5t + // const ytregex = new RegExp( + // /^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w\-]+\?v=|embed\/|v\/)?)([\w\-]+)(\S+)?$/, + // ); + + // if (ytregex.test(videoLink)) { + // editor + // .chain() + // .focus() + // .deleteRange(range) + // .setYoutubeVideo({ + // src: videoLink, + // }) + // .run(); + // } else { + // if (videoLink !== null) { + // alert("Please enter a correct Youtube Video Link"); + // } + // } + // }, + // }, +]); + +export const slashCommand = Command.configure({ + suggestion: { + items: () => suggestionItems, + render: renderItems, + }, +}); diff --git a/apps/web/app/(editor)/components/topbar.tsx b/apps/web/app/(editor)/components/topbar.tsx new file mode 100644 index 00000000..49b5179c --- /dev/null +++ b/apps/web/app/(editor)/components/topbar.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { + AnimatePresence, + useMotionValueEvent, + useScroll, + motion, +} from "framer-motion"; +import React, { useState } from "react"; + +function Topbar({ + charsCount, + saveStatus, +}: { + charsCount: number | undefined; + saveStatus: string; +}) { + const [visible, setVisible] = useState(true); + + const { scrollYProgress } = useScroll(); + useMotionValueEvent(scrollYProgress, "change", (current) => { + if (typeof current === "number") { + let direction = current! - scrollYProgress.getPrevious()!; + + if (direction < 0 || direction === 1) { + setVisible(true); + } else { + setVisible(false); + } + } + }); + return ( + <div className="fixed left-0 top-0 z-10"> + <AnimatePresence mode="wait"> + <motion.div + initial={{ + opacity: 1, + y: -150, + }} + animate={{ + y: visible ? 0 : -150, + opacity: visible ? 1 : 0, + }} + transition={{ + duration: 0.2, + }} + className="flex flex-col items-center" + > + <div className="gap-2 w-screen flex bg-[#171B1F] justify-center items-center pt-6 pb-4"> + <div className="rounded-lg bg-[#21303D] px-2 py-1 text-sm text-muted-foreground"> + Untitled + </div> + <div className="rounded-lg bg-[#21303D] px-2 py-1 text-sm text-muted-foreground"> + {saveStatus} + </div> + {charsCount && ( + <div className="rounded-lg bg-[#21303D] px-2 py-1 text-sm text-muted-foreground"> + {`${charsCount} words`} + </div> + )} + </div> + </motion.div> + </AnimatePresence> + </div> + ); +} + +export default Topbar; diff --git a/apps/web/app/(editor)/components/ui/asksvg.tsx b/apps/web/app/(editor)/components/ui/asksvg.tsx new file mode 100644 index 00000000..aa38fe08 --- /dev/null +++ b/apps/web/app/(editor)/components/ui/asksvg.tsx @@ -0,0 +1,12 @@ +import React from 'react' + +function Asksvg() { + return ( + <svg width="15" height="16" viewBox="0 0 15 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M5.90925 4.63925C6.7875 3.8705 8.2125 3.8705 9.09075 4.63925C9.96975 5.408 9.96975 6.6545 9.09075 7.42325C8.9385 7.5575 8.76825 7.66775 8.58825 7.75475C8.0295 8.0255 7.50075 8.504 7.50075 9.125V9.6875M14.25 8C14.25 8.88642 14.0754 9.76417 13.7362 10.5831C13.397 11.4021 12.8998 12.1462 12.273 12.773C11.6462 13.3998 10.9021 13.897 10.0831 14.2362C9.26417 14.5754 8.38642 14.75 7.5 14.75C6.61358 14.75 5.73583 14.5754 4.91689 14.2362C4.09794 13.897 3.35382 13.3998 2.72703 12.773C2.10023 12.1462 1.60303 11.4021 1.26381 10.5831C0.924594 9.76417 0.75 8.88642 0.75 8C0.75 6.20979 1.46116 4.4929 2.72703 3.22703C3.9929 1.96116 5.70979 1.25 7.5 1.25C9.29021 1.25 11.0071 1.96116 12.273 3.22703C13.5388 4.4929 14.25 6.20979 14.25 8ZM7.5 11.9375H7.506V11.9435H7.5V11.9375Z" stroke="#989EA4" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> +</svg> + + ) +} + +export default Asksvg
\ No newline at end of file diff --git a/apps/web/app/(editor)/components/ui/autocompletesvg.tsx b/apps/web/app/(editor)/components/ui/autocompletesvg.tsx new file mode 100644 index 00000000..c433fcad --- /dev/null +++ b/apps/web/app/(editor)/components/ui/autocompletesvg.tsx @@ -0,0 +1,12 @@ +import React from 'react' + +function Autocompletesvg() { + return ( + <svg width="15" height="10" viewBox="0 0 15 10" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M1.3125 1.0625H13.6875M1.3125 5H13.6875M1.3125 8.9375H7.5" stroke="#989EA4" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> +</svg> + + ) +} + +export default Autocompletesvg
\ No newline at end of file diff --git a/apps/web/app/(editor)/components/ui/crazy-spinner.tsx b/apps/web/app/(editor)/components/ui/crazy-spinner.tsx new file mode 100644 index 00000000..2e95deee --- /dev/null +++ b/apps/web/app/(editor)/components/ui/crazy-spinner.tsx @@ -0,0 +1,11 @@ +const CrazySpinner = () => { + return ( + <div className="flex justify-center items-center gap-1.5"> + <div className="h-1.5 w-1.5 animate-ping rounded-full bg-[#369DFD] [animation-delay:-0.4s]" /> + <div className="h-1.5 w-1.5 animate-ping rounded-full bg-[#369DFD] [animation-delay:-0.2s]" /> + <div className="h-1.5 w-1.5 animate-ping rounded-full bg-[#369DFD]" /> + </div> + ); +}; + +export default CrazySpinner; diff --git a/apps/web/app/(editor)/components/ui/magic.tsx b/apps/web/app/(editor)/components/ui/magic.tsx new file mode 100644 index 00000000..04dce39e --- /dev/null +++ b/apps/web/app/(editor)/components/ui/magic.tsx @@ -0,0 +1,8 @@ +export default function Magic({ className }: { className: string }) { + return ( +<svg width="18" height="19" className={className} viewBox="0 0 18 19" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M6.49998 3.25C6.63578 3.25003 6.76788 3.29429 6.87629 3.37608C6.98469 3.45788 7.06351 3.57275 7.10081 3.70333L7.77831 6.075C7.92418 6.58576 8.19785 7.05092 8.57345 7.42652C8.94906 7.80213 9.41421 8.07579 9.92498 8.22167L12.2966 8.89917C12.4271 8.93655 12.5419 9.0154 12.6236 9.1238C12.7053 9.2322 12.7495 9.36425 12.7495 9.5C12.7495 9.63574 12.7053 9.7678 12.6236 9.8762C12.5419 9.9846 12.4271 10.0634 12.2966 10.1008L9.92498 10.7783C9.41421 10.9242 8.94906 11.1979 8.57345 11.5735C8.19785 11.9491 7.92418 12.4142 7.77831 12.925L7.10081 15.2967C7.06343 15.4272 6.98458 15.5419 6.87618 15.6236C6.76778 15.7054 6.63572 15.7495 6.49998 15.7495C6.36423 15.7495 6.23218 15.7054 6.12378 15.6236C6.01538 15.5419 5.93653 15.4272 5.89914 15.2967L5.22164 12.925C5.07577 12.4142 4.80211 11.9491 4.4265 11.5735C4.05089 11.1979 3.58574 10.9242 3.07498 10.7783L0.70331 10.1008C0.572814 10.0634 0.458036 9.9846 0.376329 9.8762C0.294622 9.7678 0.250427 9.63574 0.250427 9.5C0.250427 9.36425 0.294622 9.2322 0.376329 9.1238C0.458036 9.0154 0.572814 8.93655 0.70331 8.89917L3.07498 8.22167C3.58574 8.07579 4.05089 7.80213 4.4265 7.42652C4.80211 7.05092 5.07577 6.58576 5.22164 6.075L5.89914 3.70333C5.93644 3.57275 6.01526 3.45788 6.12367 3.37608C6.23208 3.29429 6.36417 3.25003 6.49998 3.25ZM14 0.75C14.1394 0.749922 14.2749 0.79647 14.3848 0.882239C14.4947 0.968007 14.5728 1.08807 14.6066 1.22333L14.8216 2.08667C15.0183 2.87 15.63 3.48167 16.4133 3.67833L17.2766 3.89333C17.4122 3.9269 17.5325 4.00488 17.6186 4.11483C17.7046 4.22479 17.7514 4.36038 17.7514 4.5C17.7514 4.63962 17.7046 4.77521 17.6186 4.88517C17.5325 4.99512 17.4122 5.0731 17.2766 5.10667L16.4133 5.32167C15.63 5.51833 15.0183 6.13 14.8216 6.91333L14.6066 7.77667C14.5731 7.91219 14.4951 8.03257 14.3851 8.11861C14.2752 8.20465 14.1396 8.2514 14 8.2514C13.8604 8.2514 13.7248 8.20465 13.6148 8.11861C13.5049 8.03257 13.4269 7.91219 13.3933 7.77667L13.1783 6.91333C13.0822 6.52869 12.8833 6.17741 12.6029 5.89706C12.3226 5.61671 11.9713 5.41782 11.5866 5.32167L10.7233 5.10667C10.5878 5.0731 10.4674 4.99512 10.3814 4.88517C10.2953 4.77521 10.2486 4.63962 10.2486 4.5C10.2486 4.36038 10.2953 4.22479 10.3814 4.11483C10.4674 4.00488 10.5878 3.9269 10.7233 3.89333L11.5866 3.67833C11.9713 3.58218 12.3226 3.38329 12.6029 3.10294C12.8833 2.82258 13.0822 2.47131 13.1783 2.08667L13.3933 1.22333C13.4271 1.08807 13.5052 0.968007 13.6152 0.882239C13.7251 0.79647 13.8605 0.749922 14 0.75ZM12.75 12C12.8812 11.9999 13.0092 12.0412 13.1157 12.1179C13.2222 12.1946 13.3018 12.303 13.3433 12.4275L13.6716 13.4133C13.7966 13.7858 14.0883 14.0792 14.4616 14.2033L15.4475 14.5325C15.5716 14.5742 15.6795 14.6538 15.756 14.7601C15.8324 14.8664 15.8736 14.9941 15.8736 15.125C15.8736 15.2559 15.8324 15.3836 15.756 15.4899C15.6795 15.5962 15.5716 15.6758 15.4475 15.7175L14.4616 16.0467C14.0891 16.1717 13.7958 16.4633 13.6716 16.8367L13.3425 17.8225C13.3008 17.9466 13.2212 18.0545 13.1149 18.131C13.0086 18.2075 12.8809 18.2486 12.75 18.2486C12.619 18.2486 12.4914 18.2075 12.3851 18.131C12.2788 18.0545 12.1992 17.9466 12.1575 17.8225L11.8283 16.8367C11.7669 16.6527 11.6636 16.4856 11.5265 16.3485C11.3894 16.2114 11.2222 16.1081 11.0383 16.0467L10.0525 15.7175C9.92834 15.6758 9.82043 15.5962 9.74398 15.4899C9.66752 15.3836 9.6264 15.2559 9.6264 15.125C9.6264 14.9941 9.66752 14.8664 9.74398 14.7601C9.82043 14.6538 9.92834 14.5742 10.0525 14.5325L11.0383 14.2033C11.4108 14.0783 11.7041 13.7867 11.8283 13.4133L12.1575 12.4275C12.1989 12.3031 12.2784 12.1949 12.3848 12.1182C12.4911 12.0414 12.6189 12.0001 12.75 12Z" fill="#369DFD"/> +</svg> + + ); +} diff --git a/apps/web/app/(editor)/components/ui/rewritesvg.tsx b/apps/web/app/(editor)/components/ui/rewritesvg.tsx new file mode 100644 index 00000000..fad9eb90 --- /dev/null +++ b/apps/web/app/(editor)/components/ui/rewritesvg.tsx @@ -0,0 +1,11 @@ +import React from 'react' + +function Rewritesvg() { + return ( + <svg width="17" height="14" viewBox="0 0 17 14" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M11.5172 5.01108H15.2612L12.8755 2.62383C12.1074 1.85573 11.1506 1.30337 10.1014 1.02228C9.05214 0.74119 7.94738 0.741272 6.89818 1.02252C5.84897 1.30377 4.8923 1.85627 4.12433 2.62448C3.35635 3.39269 2.80415 4.34954 2.52323 5.39883M1.73873 12.7331V8.98908M1.73873 8.98908H5.48273M1.73873 8.98908L4.12373 11.3763C4.89181 12.1444 5.84857 12.6968 6.89782 12.9779C7.94706 13.259 9.05182 13.2589 10.101 12.9776C11.1502 12.6964 12.1069 12.1439 12.8749 11.3757C13.6428 10.6075 14.1951 9.65062 14.476 8.60133M15.2612 1.26708V5.00958" stroke="#989EA4" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> +</svg> + ) +} + +export default Rewritesvg
\ No newline at end of file diff --git a/apps/web/app/(editor)/components/ui/translatesvg.tsx b/apps/web/app/(editor)/components/ui/translatesvg.tsx new file mode 100644 index 00000000..cde82da6 --- /dev/null +++ b/apps/web/app/(editor)/components/ui/translatesvg.tsx @@ -0,0 +1,12 @@ +import React from 'react' + +function Translatesvg() { + return ( + <svg width="15" height="16" viewBox="0 0 15 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M6.375 14.75L10.3125 6.3125L14.25 14.75M7.5 12.5H13.125M0.75 3.21575C2.2428 3.02999 3.74569 2.93706 5.25 2.9375M5.25 2.9375C6.09 2.9375 6.92475 2.966 7.7505 3.023M5.25 2.9375V1.25M7.7505 3.023C6.882 6.9935 4.2675 10.31 0.75 12.1265M7.7505 3.023C8.4225 3.06875 9.08925 3.13325 9.75 3.21575M6.30825 9.587C5.07822 8.33647 4.10335 6.85849 3.438 5.2355" stroke="#989EA4" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> +</svg> + + ) +} + +export default Translatesvg
\ No newline at end of file diff --git a/apps/web/app/(editor)/editor.tsx b/apps/web/app/(editor)/editor.tsx new file mode 100644 index 00000000..5b4a60ce --- /dev/null +++ b/apps/web/app/(editor)/editor.tsx @@ -0,0 +1,54 @@ +"use client"; +import { defaultEditorContent } from "./lib/content"; +import { EditorContent, EditorRoot, type JSONContent } from "novel"; +import { ImageResizer } from "novel/extensions"; +import { useEffect, useState } from "react"; +import { defaultExtensions } from "./components/extensions"; + +import { slashCommand } from "./components/slash-command"; +import { Updates } from "./lib/debouncedsave"; +import { editorProps } from "./lib/editorprops"; +import EditorCommands from "./components/editorcommands"; +import Aigenerate from "./components/aigenerate"; +import { useMotionValueEvent, useScroll } from "framer-motion"; +import Topbar from "./components/topbar"; + +const Editor = () => { + const [initialContent, setInitialContent] = useState<null | JSONContent>( + null + ); + const [saveStatus, setSaveStatus] = useState("Saved"); + const [charsCount, setCharsCount] = useState(); + const [visible, setVisible] = useState(true); + + useEffect(() => { + const content = window.localStorage.getItem("novel-content"); + if (content) setInitialContent(JSON.parse(content)); + else setInitialContent(defaultEditorContent); + }, []); + + if (!initialContent) return null; + + return ( + <div className="relative w-full max-w-screen-xl"> + <Topbar charsCount={charsCount} saveStatus={saveStatus} /> + <EditorRoot> + <EditorContent + initialContent={initialContent} + extensions={[...defaultExtensions, slashCommand]} + className="min-h-[55vh] mt-[8vh] w-full max-w-screen-xl bg-[#171B1F] mb-[40vh]" + editorProps={editorProps} + onUpdate={({ editor }) => { + Updates({ editor, setCharsCount, setSaveStatus }); + }} + slotAfter={<ImageResizer />} + > + <EditorCommands /> + <Aigenerate /> + </EditorContent> + </EditorRoot> + </div> + ); +}; + +export default Editor; diff --git a/apps/web/app/(editor)/editor/page.tsx b/apps/web/app/(editor)/editor/page.tsx new file mode 100644 index 00000000..d0298065 --- /dev/null +++ b/apps/web/app/(editor)/editor/page.tsx @@ -0,0 +1,8 @@ +import TailwindAdvancedEditor from "../editor"; +export default function Page() { + return ( + <div className="flex min-h-screen flex-col items-center bg-[#171B1F]"> + <TailwindAdvancedEditor /> + </div> + ); +} diff --git a/apps/web/app/(editor)/layout.tsx b/apps/web/app/(editor)/layout.tsx new file mode 100644 index 00000000..1bf97715 --- /dev/null +++ b/apps/web/app/(editor)/layout.tsx @@ -0,0 +1,12 @@ +import "./styles/prosemirror.css"; +import "./styles/globals.css" +import type { ReactNode } from "react"; + + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + <div className="dark"> + {children} + </div> + ); +} diff --git a/apps/web/app/(editor)/lib/content.ts b/apps/web/app/(editor)/lib/content.ts new file mode 100644 index 00000000..6464cfa1 --- /dev/null +++ b/apps/web/app/(editor)/lib/content.ts @@ -0,0 +1,231 @@ +export const defaultEditorContent = { + type: "doc", + content: [ + { + type: "heading", + attrs: { level: 2 }, + content: [{ type: "text", text: "Introducing Novel" }], + }, + { + type: "paragraph", + content: [ + { + type: "text", + marks: [ + { + type: "link", + attrs: { + href: "https://github.com/steven-tey/novel", + target: "_blank", + }, + }, + ], + text: "Novel", + }, + { + type: "text", + text: " is a Notion-style WYSIWYG editor with AI-powered autocompletion. Built with ", + }, + { + type: "text", + marks: [ + { + type: "link", + attrs: { + href: "https://tiptap.dev/", + target: "_blank", + }, + }, + ], + text: "Tiptap", + }, + { type: "text", text: " + " }, + { + type: "text", + marks: [ + { + type: "link", + attrs: { + href: "https://sdk.vercel.ai/docs", + target: "_blank", + }, + }, + ], + text: "Vercel AI SDK", + }, + { type: "text", text: "." }, + ], + }, + { + type: "heading", + attrs: { level: 3 }, + content: [{ type: "text", text: "Installation" }], + }, + { + type: "codeBlock", + attrs: { language: null }, + content: [{ type: "text", text: "npm i novel" }], + }, + { + type: "heading", + attrs: { level: 3 }, + content: [{ type: "text", text: "Usage" }], + }, + { + type: "codeBlock", + attrs: { language: null }, + content: [ + { + type: "text", + text: 'import { Editor } from "novel";\n\nexport default function App() {\n return (\n <Editor />\n )\n}', + }, + ], + }, + { + type: "heading", + attrs: { level: 3 }, + content: [{ type: "text", text: "Features" }], + }, + { + type: "orderedList", + attrs: { tight: true, start: 1 }, + content: [ + { + type: "listItem", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Slash menu & bubble menu" }], + }, + ], + }, + { + type: "listItem", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "AI autocomplete (type " }, + { type: "text", marks: [{ type: "code" }], text: "++" }, + { + type: "text", + text: " to activate, or select from slash menu)", + }, + ], + }, + ], + }, + { + type: "listItem", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "Image uploads (drag & drop / copy & paste, or select from slash menu) ", + }, + ], + }, + ], + }, + ], + }, + { + type: "image", + attrs: { + src: "https://public.blob.vercel-storage.com/pJrjXbdONOnAeZAZ/banner-2wQk82qTwyVgvlhTW21GIkWgqPGD2C.png", + alt: "banner.png", + title: "banner.png", + width: null, + height: null, + }, + }, + { type: "horizontalRule" }, + { + type: "heading", + attrs: { level: 3 }, + content: [{ type: "text", text: "Learn more" }], + }, + { + type: "taskList", + content: [ + { + type: "taskItem", + attrs: { checked: false }, + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "Star us on " }, + { + type: "text", + marks: [ + { + type: "link", + attrs: { + href: "https://github.com/steven-tey/novel", + target: "_blank", + }, + }, + ], + text: "GitHub", + }, + ], + }, + ], + }, + { + type: "taskItem", + attrs: { checked: false }, + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "Install the " }, + { + type: "text", + marks: [ + { + type: "link", + attrs: { + href: "https://www.npmjs.com/package/novel", + target: "_blank", + }, + }, + ], + text: "NPM package", + }, + ], + }, + ], + }, + { + type: "taskItem", + attrs: { checked: false }, + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + marks: [ + { + type: "link", + attrs: { + href: "https://vercel.com/templates/next.js/novel", + target: "_blank", + }, + }, + ], + text: "Deploy your own", + }, + { type: "text", text: " to Vercel" }, + ], + }, + ], + }, + ], + }, + ], +}; diff --git a/apps/web/app/(editor)/lib/debouncedsave.ts b/apps/web/app/(editor)/lib/debouncedsave.ts new file mode 100644 index 00000000..6490c6c4 --- /dev/null +++ b/apps/web/app/(editor)/lib/debouncedsave.ts @@ -0,0 +1,20 @@ +import hljs from 'highlight.js' +import { debounce } from 'tldraw'; +import { useDebouncedCallback } from "use-debounce"; + +export const Updates = debounce(({editor, setCharsCount, setSaveStatus})=> { + const json = editor.getJSON(); + setCharsCount(editor.storage.characterCount.words()); + window.localStorage.setItem("html-content", highlightCodeblocks(editor.getHTML())); + window.localStorage.setItem("novel-content", JSON.stringify(json)); + window.localStorage.setItem("markdown", editor.storage.markdown.getMarkdown()); + setSaveStatus("Saved"); +}, 500) + +export const highlightCodeblocks = (content: string) => { + const doc = new DOMParser().parseFromString(content, 'text/html'); + doc.querySelectorAll('pre code').forEach((el) => { + hljs.highlightElement(el); + }); + return new XMLSerializer().serializeToString(doc); +};
\ No newline at end of file diff --git a/apps/web/app/(editor)/lib/editorprops.ts b/apps/web/app/(editor)/lib/editorprops.ts new file mode 100644 index 00000000..00d89264 --- /dev/null +++ b/apps/web/app/(editor)/lib/editorprops.ts @@ -0,0 +1,16 @@ +import { handleCommandNavigation } from "novel/extensions"; +import { handleImageDrop, handleImagePaste } from "novel/plugins"; +import { uploadFn } from "../components/image-upload"; +import { EditorView } from "prosemirror-view"; + +export const editorProps = { + handleDOMEvents: { + keydown: (_view: EditorView, event: KeyboardEvent) => handleCommandNavigation(event), + }, + handlePaste: (view: EditorView, event: ClipboardEvent) => handleImagePaste(view, event, uploadFn), + handleDrop: (view: EditorView, event: DragEvent, slice, moved:boolean) => handleImageDrop(view, event, moved, uploadFn), + attributes: { + class: + "prose prose-lg dark:prose-invert prose-headings:font-title font-default focus:outline-none max-w-full", + }, +}
\ No newline at end of file diff --git a/apps/web/app/(editor)/lib/use-local-storage.ts b/apps/web/app/(editor)/lib/use-local-storage.ts new file mode 100644 index 00000000..5f2ebeb9 --- /dev/null +++ b/apps/web/app/(editor)/lib/use-local-storage.ts @@ -0,0 +1,27 @@ +import { useEffect, useState } from "react"; + +const useLocalStorage = <T>( + key: string, + initialValue: T, + // eslint-disable-next-line no-unused-vars +): [T, (value: T) => void] => { + const [storedValue, setStoredValue] = useState(initialValue); + + useEffect(() => { + // Retrieve from localStorage + const item = window.localStorage.getItem(key); + if (item) { + setStoredValue(JSON.parse(item)); + } + }, [key]); + + const setValue = (value: T) => { + // Save state + setStoredValue(value); + // Save to localStorage + window.localStorage.setItem(key, JSON.stringify(value)); + }; + return [storedValue, setValue]; +}; + +export default useLocalStorage; diff --git a/apps/web/app/(editor)/styles/globals.css b/apps/web/app/(editor)/styles/globals.css new file mode 100644 index 00000000..336e2dae --- /dev/null +++ b/apps/web/app/(editor)/styles/globals.css @@ -0,0 +1,168 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; + + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 222.2 84% 4.9%; + + --radius: 0.5rem; + + --novel-highlight-default: #ffffff; + --novel-highlight-purple: #f6f3f8; + --novel-highlight-red: #fdebeb; + --novel-highlight-yellow: #fbf4a2; + --novel-highlight-blue: #c1ecf9; + --novel-highlight-green: #acf79f; + --novel-highlight-orange: #faebdd; + --novel-highlight-pink: #faf1f5; + --novel-highlight-gray: #f1f1ef; + } + + .dark { + --background: #171B1F; + --foreground: 210 40% 98%; + + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + + --primary: 210 40% 98%; + --primary-foreground: 222.2 47.4% 11.2%; + + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 212.7 26.8% 83.9%; + + --novel-highlight-default: #000000; + --novel-highlight-purple: #3f2c4b; + --novel-highlight-red: #5c1a1a; + --novel-highlight-yellow: #5c4b1a; + --novel-highlight-blue: #1a3d5c; + --novel-highlight-green: #1a5c20; + --novel-highlight-orange: #5c3a1a; + --novel-highlight-pink: #5c1a3a; + --novel-highlight-gray: #3a3a3a; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} + + +pre { + background: #0d0d0d; + border-radius: 0.5rem; + color: #fff; + font-family: "JetBrainsMono", monospace; + padding: 0.75rem 1rem; + + code { + background: none; + color: inherit; + font-size: 0.8rem; + padding: 0; + } + + .hljs-comment, + .hljs-quote { + color: #616161; + } + + .hljs-variable, + .hljs-template-variable, + .hljs-attribute, + .hljs-tag, + .hljs-name, + .hljs-regexp, + .hljs-link, + .hljs-name, + .hljs-selector-id, + .hljs-selector-class { + color: #f98181; + } + + .hljs-number, + .hljs-meta, + .hljs-built_in, + .hljs-builtin-name, + .hljs-literal, + .hljs-type, + .hljs-params { + color: #fbbc88; + } + + .hljs-string, + .hljs-symbol, + .hljs-bullet { + color: #b9f18d; + } + + .hljs-title, + .hljs-section { + color: #faf594; + } + + .hljs-keyword, + .hljs-selector-tag { + color: #70cff8; + } + + .hljs-emphasis { + font-style: italic; + } + + .hljs-strong { + font-weight: 700; + } +} +::-webkit-scrollbar{ + width: 0; +} + diff --git a/apps/web/app/(editor)/styles/prosemirror.css b/apps/web/app/(editor)/styles/prosemirror.css new file mode 100644 index 00000000..7298c98b --- /dev/null +++ b/apps/web/app/(editor)/styles/prosemirror.css @@ -0,0 +1,203 @@ +.ProseMirror { + @apply p-12 px-8 sm:px-12; +} + +.ProseMirror .is-editor-empty:first-child::before { + content: attr(data-placeholder); + float: left; + color: hsl(var(--muted-foreground)); + pointer-events: none; + height: 0; +} +.ProseMirror .is-empty::before { + content: attr(data-placeholder); + float: left; + color: hsl(var(--muted-foreground)); + pointer-events: none; + height: 0; +} + +/* Custom image styles */ + +.ProseMirror img { + transition: filter 0.1s ease-in-out; + + &:hover { + cursor: pointer; + filter: brightness(90%); + } + + &.ProseMirror-selectednode { + outline: 3px solid #5abbf7; + filter: brightness(90%); + } +} + +.img-placeholder { + position: relative; + + &:before { + content: ""; + box-sizing: border-box; + position: absolute; + top: 50%; + left: 50%; + width: 36px; + height: 36px; + border-radius: 50%; + border: 3px solid var(--novel-stone-200); + border-top-color: var(--novel-stone-800); + animation: spinning 0.6s linear infinite; + } +} + +@keyframes spinning { + to { + transform: rotate(360deg); + } +} + +/* Custom TODO list checkboxes – shoutout to this awesome tutorial: https://moderncss.dev/pure-css-custom-checkbox-style/ */ + +ul[data-type="taskList"] li > label { + margin-right: 0.2rem; + user-select: none; +} + +@media screen and (max-width: 768px) { + ul[data-type="taskList"] li > label { + margin-right: 0.5rem; + } +} + +ul[data-type="taskList"] li > label input[type="checkbox"] { + -webkit-appearance: none; + appearance: none; + background-color: hsl(var(--background)); + margin: 0; + cursor: pointer; + width: 1.2em; + height: 1.2em; + position: relative; + top: 5px; + border: 2px solid hsl(var(--border)); + margin-right: 0.3rem; + display: grid; + place-content: center; + + &:hover { + background-color: hsl(var(--accent)); + } + + &:active { + background-color: hsl(var(--accent)); + } + + &::before { + content: ""; + width: 0.65em; + height: 0.65em; + transform: scale(0); + transition: 120ms transform ease-in-out; + box-shadow: inset 1em 1em; + transform-origin: center; + clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%); + } + + &:checked::before { + transform: scale(1); + } +} + +ul[data-type="taskList"] li[data-checked="true"] > div > p { + color: var(--muted-foreground); + text-decoration: line-through; + text-decoration-thickness: 2px; +} + +/* Overwrite tippy-box original max-width */ + +.tippy-box { + max-width: 400px !important; +} + +.ProseMirror:not(.dragging) .ProseMirror-selectednode { + outline: none !important; + background-color: var(--novel-highlight-blue); + transition: background-color 0.2s; + box-shadow: none; +} + +.drag-handle { + position: fixed; + opacity: 1; + transition: opacity ease-in 0.2s; + border-radius: 0.25rem; + + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10' style='fill: rgba(0, 0, 0, 0.5)'%3E%3Cpath d='M3,2 C2.44771525,2 2,1.55228475 2,1 C2,0.44771525 2.44771525,0 3,0 C3.55228475,0 4,0.44771525 4,1 C4,1.55228475 3.55228475,2 3,2 Z M3,6 C2.44771525,6 2,5.55228475 2,5 C2,4.44771525 2.44771525,4 3,4 C3.55228475,4 4,4.44771525 4,5 C4,5.55228475 3.55228475,6 3,6 Z M3,10 C2.44771525,10 2,9.55228475 2,9 C2,8.44771525 2.44771525,8 3,8 C3.55228475,8 4,8.44771525 4,9 C4,9.55228475 3.55228475,10 3,10 Z M7,2 C6.44771525,2 6,1.55228475 6,1 C6,0.44771525 6.44771525,0 7,0 C7.55228475,0 8,0.44771525 8,1 C8,1.55228475 7.55228475,2 7,2 Z M7,6 C6.44771525,6 6,5.55228475 6,5 C6,4.44771525 6.44771525,4 7,4 C7.55228475,4 8,4.44771525 8,5 C8,5.55228475 7.55228475,6 7,6 Z M7,10 C6.44771525,10 6,9.55228475 6,9 C6,8.44771525 6.44771525,8 7,8 C7.55228475,8 8,8.44771525 8,9 C8,9.55228475 7.55228475,10 7,10 Z'%3E%3C/path%3E%3C/svg%3E"); + background-size: calc(0.5em + 0.375rem) calc(0.5em + 0.375rem); + background-repeat: no-repeat; + background-position: center; + width: 1.2rem; + height: 1.5rem; + z-index: 50; + cursor: grab; + + &:hover { + background-color: var(--novel-stone-100); + transition: background-color 0.2s; + } + + &:active { + background-color: var(--novel-stone-200); + transition: background-color 0.2s; + cursor: grabbing; + } + + &.hide { + opacity: 0; + pointer-events: none; + } + + @media screen and (max-width: 600px) { + display: none; + pointer-events: none; + } +} + +.dark .drag-handle { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10' style='fill: rgba(255, 255, 255, 0.5)'%3E%3Cpath d='M3,2 C2.44771525,2 2,1.55228475 2,1 C2,0.44771525 2.44771525,0 3,0 C3.55228475,0 4,0.44771525 4,1 C4,1.55228475 3.55228475,2 3,2 Z M3,6 C2.44771525,6 2,5.55228475 2,5 C2,4.44771525 2.44771525,4 3,4 C3.55228475,4 4,4.44771525 4,5 C4,5.55228475 3.55228475,6 3,6 Z M3,10 C2.44771525,10 2,9.55228475 2,9 C2,8.44771525 2.44771525,8 3,8 C3.55228475,8 4,8.44771525 4,9 C4,9.55228475 3.55228475,10 3,10 Z M7,2 C6.44771525,2 6,1.55228475 6,1 C6,0.44771525 6.44771525,0 7,0 C7.55228475,0 8,0.44771525 8,1 C8,1.55228475 7.55228475,2 7,2 Z M7,6 C6.44771525,6 6,5.55228475 6,5 C6,4.44771525 6.44771525,4 7,4 C7.55228475,4 8,4.44771525 8,5 C8,5.55228475 7.55228475,6 7,6 Z M7,10 C6.44771525,10 6,9.55228475 6,9 C6,8.44771525 6.44771525,8 7,8 C7.55228475,8 8,8.44771525 8,9 C8,9.55228475 7.55228475,10 7,10 Z'%3E%3C/path%3E%3C/svg%3E"); +} + +/* Custom Youtube Video CSS */ +iframe { + border: 8px solid #ffd00027; + border-radius: 4px; + min-width: 200px; + min-height: 200px; + display: block; + outline: 0px solid transparent; +} + +div[data-youtube-video] > iframe { + cursor: move; + aspect-ratio: 16 / 9; + width: 100%; +} + +.ProseMirror-selectednode iframe { + transition: outline 0.15s; + outline: 6px solid #fbbf24; +} + +@media only screen and (max-width: 480px) { + div[data-youtube-video] > iframe { + max-height: 50px; + } +} + +@media only screen and (max-width: 720px) { + div[data-youtube-video] > iframe { + max-height: 100px; + } +}
\ No newline at end of file |