From 743976d180239cac6812e414ae986c7a88f0d6e7 Mon Sep 17 00:00:00 2001 From: codetorso Date: Mon, 10 Jun 2024 04:10:57 +0530 Subject: [PATCH] Add Memories, Canvas, Editor --- apps/web/app/(canvas)/canvas/layout.tsx | 4 +- apps/web/app/(canvas)/canvas/page.tsx | 8 +- apps/web/app/(dash)/dynamicisland.tsx | 6 +- apps/web/app/(dash)/layout.tsx | 2 +- apps/web/app/(dash)/memories/page.tsx | 94 +++++++ apps/web/app/(dash)/menu.tsx | 8 +- apps/web/app/(editor)/api/generate/route.ts | 141 +++++++++++ apps/web/app/(editor)/api/upload/route.ts | 26 ++ .../app/(editor)/components/aigenerate.tsx | 144 +++++++++++ .../web/app/(editor)/components/extensions.ts | 141 +++++++++++ .../app/(editor)/components/image-upload.ts | 60 +++++ .../components/selectors/bgcolor-selector.tsx | 107 ++++++++ .../components/selectors/color-selector.tsx | 111 +++++++++ .../components/selectors/link-selector.tsx | 95 +++++++ .../components/selectors/node-selector.tsx | 126 ++++++++++ .../components/selectors/text-buttons.tsx | 63 +++++ .../app/(editor)/components/slash-command.tsx | 163 ++++++++++++ .../generative/ai-completion-command.tsx | 66 +++++ .../generative/ai-selector-commands.tsx | 76 ++++++ .../tailwind/generative/ai-selector.tsx | 111 +++++++++ .../components/tailwind/ui/button.tsx | 47 ++++ .../components/tailwind/ui/command.tsx | 134 ++++++++++ .../components/tailwind/ui/icons/asksvg.tsx | 12 + .../tailwind/ui/icons/autocompletesvg.tsx | 12 + .../tailwind/ui/icons/crazy-spinner.tsx | 11 + .../tailwind/ui/icons/font-default.tsx | 15 ++ .../tailwind/ui/icons/font-mono.tsx | 22 ++ .../tailwind/ui/icons/font-serif.tsx | 23 ++ .../components/tailwind/ui/icons/index.tsx | 3 + .../tailwind/ui/icons/loading-circle.tsx | 20 ++ .../components/tailwind/ui/icons/magic.tsx | 8 + .../tailwind/ui/icons/rewritesvg.tsx | 11 + .../tailwind/ui/icons/translatesvg.tsx | 12 + .../components/tailwind/ui/popover.tsx | 31 +++ .../components/tailwind/ui/scroll-area.tsx | 40 +++ .../components/tailwind/ui/separator.tsx | 21 ++ apps/web/app/(editor)/editor.tsx | 145 +++++++++++ apps/web/app/(editor)/editor/page.tsx | 8 + apps/web/app/(editor)/layout.tsx | 12 + apps/web/app/(editor)/lib/content.ts | 231 ++++++++++++++++++ .../web/app/(editor)/lib/use-local-storage.ts | 27 ++ apps/web/app/(editor)/styles/globals.css | 169 +++++++++++++ apps/web/app/(editor)/styles/prosemirror.css | 203 +++++++++++++++ apps/web/package.json | 5 +- apps/web/public/landing-ui.svg | 4 +- package.json | 1 + packages/ui/icons/index.ts | 6 + packages/ui/icons/nextarrow.svg | 3 + packages/ui/icons/search.svg | 3 + packages/ui/icons/url.svg | 4 + 50 files changed, 2781 insertions(+), 14 deletions(-) create mode 100644 apps/web/app/(dash)/memories/page.tsx create mode 100644 apps/web/app/(editor)/api/generate/route.ts create mode 100644 apps/web/app/(editor)/api/upload/route.ts create mode 100644 apps/web/app/(editor)/components/aigenerate.tsx create mode 100644 apps/web/app/(editor)/components/extensions.ts create mode 100644 apps/web/app/(editor)/components/image-upload.ts create mode 100644 apps/web/app/(editor)/components/selectors/bgcolor-selector.tsx create mode 100644 apps/web/app/(editor)/components/selectors/color-selector.tsx create mode 100644 apps/web/app/(editor)/components/selectors/link-selector.tsx create mode 100644 apps/web/app/(editor)/components/selectors/node-selector.tsx create mode 100644 apps/web/app/(editor)/components/selectors/text-buttons.tsx create mode 100644 apps/web/app/(editor)/components/slash-command.tsx create mode 100644 apps/web/app/(editor)/components/tailwind/generative/ai-completion-command.tsx create mode 100644 apps/web/app/(editor)/components/tailwind/generative/ai-selector-commands.tsx create mode 100644 apps/web/app/(editor)/components/tailwind/generative/ai-selector.tsx create mode 100644 apps/web/app/(editor)/components/tailwind/ui/button.tsx create mode 100644 apps/web/app/(editor)/components/tailwind/ui/command.tsx create mode 100644 apps/web/app/(editor)/components/tailwind/ui/icons/asksvg.tsx create mode 100644 apps/web/app/(editor)/components/tailwind/ui/icons/autocompletesvg.tsx create mode 100644 apps/web/app/(editor)/components/tailwind/ui/icons/crazy-spinner.tsx create mode 100644 apps/web/app/(editor)/components/tailwind/ui/icons/font-default.tsx create mode 100644 apps/web/app/(editor)/components/tailwind/ui/icons/font-mono.tsx create mode 100644 apps/web/app/(editor)/components/tailwind/ui/icons/font-serif.tsx create mode 100644 apps/web/app/(editor)/components/tailwind/ui/icons/index.tsx create mode 100644 apps/web/app/(editor)/components/tailwind/ui/icons/loading-circle.tsx create mode 100644 apps/web/app/(editor)/components/tailwind/ui/icons/magic.tsx create mode 100644 apps/web/app/(editor)/components/tailwind/ui/icons/rewritesvg.tsx create mode 100644 apps/web/app/(editor)/components/tailwind/ui/icons/translatesvg.tsx create mode 100644 apps/web/app/(editor)/components/tailwind/ui/popover.tsx create mode 100644 apps/web/app/(editor)/components/tailwind/ui/scroll-area.tsx create mode 100644 apps/web/app/(editor)/components/tailwind/ui/separator.tsx create mode 100644 apps/web/app/(editor)/editor.tsx create mode 100644 apps/web/app/(editor)/editor/page.tsx create mode 100644 apps/web/app/(editor)/layout.tsx create mode 100644 apps/web/app/(editor)/lib/content.ts create mode 100644 apps/web/app/(editor)/lib/use-local-storage.ts create mode 100644 apps/web/app/(editor)/styles/globals.css create mode 100644 apps/web/app/(editor)/styles/prosemirror.css create mode 100644 packages/ui/icons/nextarrow.svg create mode 100644 packages/ui/icons/search.svg create mode 100644 packages/ui/icons/url.svg diff --git a/apps/web/app/(canvas)/canvas/layout.tsx b/apps/web/app/(canvas)/canvas/layout.tsx index 494b52b9..9bc3b6d7 100644 --- a/apps/web/app/(canvas)/canvas/layout.tsx +++ b/apps/web/app/(canvas)/canvas/layout.tsx @@ -1,4 +1,4 @@ -import "../canvasStyles.css" +import "../canvasStyles.css"; export default function RootLayout({ children, @@ -6,7 +6,7 @@ export default function RootLayout({ children: React.ReactNode; }) { return ( -
+
{children}
); diff --git a/apps/web/app/(canvas)/canvas/page.tsx b/apps/web/app/(canvas)/canvas/page.tsx index b1d3adb5..86c283ea 100644 --- a/apps/web/app/(canvas)/canvas/page.tsx +++ b/apps/web/app/(canvas)/canvas/page.tsx @@ -5,11 +5,13 @@ import Canvas from "../canvas"; import React, { useState } from "react"; // import ReactTextareaAutosize from "react-textarea-autosize"; import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; -import { DragSvg, +import { + DragSvg, SettingsSvg, LinkSvg, ThreeDBlock, - TextLoadingSvg, } from "../svg"; + TextLoadingSvg, +} from "../svg"; function page() { const [value, setValue] = useState(""); @@ -32,7 +34,7 @@ function page() { }} value={value} // rows={1} - className="w-full resize-none rounded-xl bg-[#171B1F] px-3 py-4 text-xl text-[#989EA4] outline-none focus:outline-none sm:max-h-52" + className="w-full resize-none rounded-xl bg-[#151515] px-3 py-4 text-xl text-[#989EA4] outline-none focus:outline-none sm:max-h-52" />
diff --git a/apps/web/app/(dash)/dynamicisland.tsx b/apps/web/app/(dash)/dynamicisland.tsx index a30ec8cf..6c911f3e 100644 --- a/apps/web/app/(dash)/dynamicisland.tsx +++ b/apps/web/app/(dash)/dynamicisland.tsx @@ -15,7 +15,6 @@ export function DynamicIsland() { const [visible, setVisible] = useState(true); useMotionValueEvent(scrollYProgress, "change", (current) => { - // Check if current is not undefined and is a number if (typeof current === "number") { let direction = current! - scrollYProgress.getPrevious()!; @@ -63,9 +62,12 @@ function DynamicIslandContent() { {show ? (
setshow(!show)} - className="bg-[#1F2428] p-2 rounded-3xl" + className="bg-[#1F2428] px-3 w-[2.23rem] overflow-hidden hover:w-[8.7rem] whitespace-nowrap py-2 rounded-3xl transition-[width]" > +
Add icon + Add Content +
) : (
diff --git a/apps/web/app/(dash)/layout.tsx b/apps/web/app/(dash)/layout.tsx index dffa27fa..85f8476e 100644 --- a/apps/web/app/(dash)/layout.tsx +++ b/apps/web/app/(dash)/layout.tsx @@ -11,7 +11,7 @@ async function Layout({ children }: { children: React.ReactNode }) { } return ( -
+
diff --git a/apps/web/app/(dash)/memories/page.tsx b/apps/web/app/(dash)/memories/page.tsx new file mode 100644 index 00000000..bc2fcd53 --- /dev/null +++ b/apps/web/app/(dash)/memories/page.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { NextIcon, SearchIcon, UrlIcon } from "@repo/ui/icons"; +import Image from "next/image"; +import React, { useState } from "react"; + +function page() { + const [filter, setFilter] = useState("All") + const setFilterfn = (i:string) => setFilter(i) + + const [search, setSearch] = useState("") + return ( +
+

+ My Memories +

+ +
+
+ + Search icon +
+ + + +
+
+
Spaces
+ + + +
+ +
+
Pages
+ + + +
+
+ ); +} + +function TabComponent({title, description}: {title:string, description:string}){ + return ( +
+
+
+ {title.slice(0,2).toUpperCase()} +
+
+
+
{title}
+
{description}
+
+
+ Search icon +
+
+ ) +} + +function LinkComponent({title, url}: {title:string, url:string}){ + return ( +
+
+
+ Url icon +
+
+
+
{title}
+
{url}
+
+
+ ) +} + +const FilterMethods = ["All", "Spaces", "Pages", "Notes"] +function Filters({setFilter, filter}:{setFilter: (i:string)=> void, filter: string}){ + return ( +
+ {FilterMethods.map((i)=> { + return
setFilter(i)} className={`transition px-6 py-2 rounded-xl ${i === filter ? "bg-[#21303D] text-[#369DFD]" : "text-[#B3BCC5] bg-[#1F2428] hover:bg-[#1f262d] hover:text-[#76a3cc]"}`}>{i}
+ })} +
+ ) +} + +export default page; diff --git a/apps/web/app/(dash)/menu.tsx b/apps/web/app/(dash)/menu.tsx index fad4a036..89f17a11 100644 --- a/apps/web/app/(dash)/menu.tsx +++ b/apps/web/app/(dash)/menu.tsx @@ -1,13 +1,14 @@ import React from "react"; import Image from "next/image"; import { MemoriesIcon, ExploreIcon, HistoryIcon } from "@repo/ui/icons"; +import Link from "next/link"; function Menu() { const menuItems = [ { icon: MemoriesIcon, text: "Memories", - url: "/", + url: "/memories", }, { icon: ExploreIcon, @@ -26,7 +27,8 @@ function Menu() {
{menuItems.map((item) => ( -
@@ -38,7 +40,7 @@ function Menu() {

{item.text}

-
+ ))}
diff --git a/apps/web/app/(editor)/api/generate/route.ts b/apps/web/app/(editor)/api/generate/route.ts new file mode 100644 index 00000000..ecd9effb --- /dev/null +++ b/apps/web/app/(editor)/api/generate/route.ts @@ -0,0 +1,141 @@ +import { Ratelimit } from "@upstash/ratelimit"; +import { kv } from "@vercel/kv"; +import { OpenAIStream, StreamingTextResponse } from "ai"; +import OpenAI from "openai"; +import type { ChatCompletionMessageParam } from "openai/resources/index.mjs"; +import { match } from "ts-pattern"; + +// Create an OpenAI API client (that's edge friendly!) + +// IMPORTANT! Set the runtime to edge: https://vercel.com/docs/functions/edge-functions/edge-runtime +export const runtime = "edge"; + +export async function POST(req: Request): Promise { + const openai = new OpenAI({ + apiKey: process.env.OPENAI_API_KEY, + baseURL: process.env.OPENAI_BASE_URL || "https://api.openai.com/v1", + }); + // Check if the OPENAI_API_KEY is set, if not return 400 + if (!process.env.OPENAI_API_KEY || process.env.OPENAI_API_KEY === "") { + return new Response("Missing OPENAI_API_KEY - make sure to add it to your .env file.", { + status: 400, + }); + } + if (process.env.KV_REST_API_URL && process.env.KV_REST_API_TOKEN) { + const ip = req.headers.get("x-forwarded-for"); + const ratelimit = new Ratelimit({ + redis: kv, + limiter: Ratelimit.slidingWindow(50, "1 d"), + }); + + const { success, limit, reset, remaining } = await ratelimit.limit(`novel_ratelimit_${ip}`); + + if (!success) { + return new Response("You have reached your request limit for the day.", { + status: 429, + headers: { + "X-RateLimit-Limit": limit.toString(), + "X-RateLimit-Remaining": remaining.toString(), + "X-RateLimit-Reset": reset.toString(), + }, + }); + } + } + + const { prompt, option, command } = await req.json(); + const messages = match(option) + .with("continue", () => [ + { + role: "system", + content: + "You are an AI writing assistant that continues existing text based on context from prior text. " + + "Give more weight/priority to the later characters than the beginning ones. " + + "Limit your response to no more than 200 characters, but make sure to construct complete sentences." + + "Use Markdown formatting when appropriate.", + }, + { + role: "user", + content: prompt, + }, + ]) + .with("improve", () => [ + { + role: "system", + content: + "You are an AI writing assistant that improves existing text. " + + "Limit your response to no more than 200 characters, but make sure to construct complete sentences." + + "Use Markdown formatting when appropriate.", + }, + { + role: "user", + content: `The existing text is: ${prompt}`, + }, + ]) + .with("shorter", () => [ + { + role: "system", + content: + "You are an AI writing assistant that shortens existing text. " + "Use Markdown formatting when appropriate.", + }, + { + role: "user", + content: `The existing text is: ${prompt}`, + }, + ]) + .with("longer", () => [ + { + role: "system", + content: + "You are an AI writing assistant that lengthens existing text. " + + "Use Markdown formatting when appropriate.", + }, + { + role: "user", + content: `The existing text is: ${prompt}`, + }, + ]) + .with("fix", () => [ + { + role: "system", + content: + "You are an AI writing assistant that fixes grammar and spelling errors in existing text. " + + "Limit your response to no more than 200 characters, but make sure to construct complete sentences." + + "Use Markdown formatting when appropriate.", + }, + { + role: "user", + content: `The existing text is: ${prompt}`, + }, + ]) + .with("zap", () => [ + { + role: "system", + content: + "You area an AI writing assistant that generates text based on a prompt. " + + "You take an input from the user and a command for manipulating the text" + + "Use Markdown formatting when appropriate.", + }, + { + role: "user", + content: `For this text: ${prompt}. You have to respect the command: ${command}`, + }, + ]) + .run() as ChatCompletionMessageParam[]; + + const response = await openai.chat.completions.create({ + model: "gpt-3.5-turbo", + stream: true, + messages, + temperature: 0.7, + top_p: 1, + frequency_penalty: 0, + presence_penalty: 0, + n: 1, + }); + + // Convert the response into a friendly text-stream + const stream = OpenAIStream(response); + + // Respond with the stream + return new StreamingTextResponse(stream); +} diff --git a/apps/web/app/(editor)/api/upload/route.ts b/apps/web/app/(editor)/api/upload/route.ts new file mode 100644 index 00000000..ce7f1513 --- /dev/null +++ b/apps/web/app/(editor)/api/upload/route.ts @@ -0,0 +1,26 @@ +import { put } from "@vercel/blob"; +import { NextResponse } from "next/server"; + +export const runtime = "edge"; + +export async function POST(req: Request) { + if (!process.env.BLOB_READ_WRITE_TOKEN) { + return new Response("Missing BLOB_READ_WRITE_TOKEN. Don't forget to add that to your .env file.", { + status: 401, + }); + } + + const file = req.body || ""; + const filename = req.headers.get("x-vercel-filename") || "file.txt"; + const contentType = req.headers.get("content-type") || "text/plain"; + const fileType = `.${contentType.split("/")[1]}`; + + // construct final filename based on content-type if not provided + const finalName = filename.includes(fileType) ? filename : `${filename}${fileType}`; + const blob = await put(finalName, file, { + contentType, + access: "public", + }); + + return NextResponse.json(blob); +} diff --git a/apps/web/app/(editor)/components/aigenerate.tsx b/apps/web/app/(editor)/components/aigenerate.tsx new file mode 100644 index 00000000..91c97584 --- /dev/null +++ b/apps/web/app/(editor)/components/aigenerate.tsx @@ -0,0 +1,144 @@ +import React, { useEffect, useState } from 'react' +import Magic from './tailwind/ui/icons/magic' +import CrazySpinner from './tailwind/ui/icons/crazy-spinner' +import Asksvg from './tailwind/ui/icons/asksvg' +import Rewritesvg from './tailwind/ui/icons/rewritesvg' +import Translatesvg from './tailwind/ui/icons/translatesvg' +import Autocompletesvg from './tailwind/ui/icons/autocompletesvg' +import { motion, AnimatePresence } from "framer-motion" +import { GoogleGenerativeAI } from "@google/generative-ai"; +import type { Editor } from '@tiptap/core' +import { removeAIHighlight } from 'novel/extensions' +import { useEditor } from 'novel' + + +function Aigenerate() { + const [visible, setVisible] = useState(false) + const [generating, setGenerating] = useState(false) + + const { editor } = useEditor(); + + useEffect(()=> { + if (!visible) { + setGenerating(visible) + } else{ + removeAIHighlight(editor); + editor.chain().unsetHighlight().run(); + } + + }, [visible]) + + const setGeneratingfn = (v: boolean) => setGenerating(v) + + return ( +
+ { + setVisible(!visible); + }} + 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.56rem]"}`}> + + { + (visible && generating) ? ( + <>Generating + ) : visible ? ( + <>Press Commands + ) : ( + <>Ask AI + ) + } + + + + +
+ ) +} + +export default Aigenerate + +const options = [ +
Translate
, +
Change Tone
, +
Ask Gemini
, +
Auto Complete
+]; + +function ToolBar({editor, setGeneratingfn}: {editor:Editor, setGeneratingfn: (v: boolean) => void}) { + const [index, setIndex] = useState(0); + + return ( +
+ {options.map((item, idx) => ( +
setIndex(idx)} + > + + {index === idx && ( + AigenerateContent({idx, editor,setGeneratingfn })} + className="absolute inset-0 block h-full w-full rounded-xl bg-[#33393D]" + layoutId="hoverBackground" + initial={{ opacity: 0 }} + animate={{ + opacity: 1, + transition: { duration: 0.15 }, + }} + exit={{ + opacity: 0, + transition: { duration: 0.15, delay: 0.2 }, + }} + /> + )} + + {item} +
+ ))} +
+ ); +} + +async function AigenerateContent({idx, editor, setGeneratingfn}:{idx:number, editor:Editor, setGeneratingfn: (v:boolean) => void}){ + setGeneratingfn(true); + const from = editor.view.state.selection.from + const to = editor.view.state.selection.to + editor.chain().setTextSelection({from, to}).run(); + editor.commands.setAIHighlight({color: "#0538A2"}) + const query = editor.state.doc.textBetween(from, to, " $$$ ") + console.log("hello there!", from, to) + setGeneratingfn(false); + + // const genAI = new GoogleGenerativeAI("AIzaSyDGwJCP9SH5gryyvh65LJ6xTZ0SOdNvzyY"); + // const model = genAI.getGenerativeModel({ model: "gemini-pro"}); + + + + // const result = (await model.generateContent(`${ty}, ${query}`)).response.text(); + + // .insertContentAt( + // { + // from: from, + // to: to, + // }, + // result, + // ) + // .run(); +} 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..0cf93a40 --- /dev/null +++ b/apps/web/app/(editor)/components/image-upload.ts @@ -0,0 +1,60 @@ +import { createImageUpload } from "novel/plugins"; +import { toast } from "sonner"; + +const onUpload = (file: File) => { + const promise = fetch("/api/upload", { + method: "POST", + headers: { + "content-type": file?.type || "application/octet-stream", + "x-vercel-filename": file?.name || "image.png", + }, + body: file, + }); + + return new Promise((resolve, reject) => { + toast.promise( + promise.then(async (res) => { + // Successfully uploaded image + if (res.status === 200) { + const { url } = (await res.json()) as { url: string }; + // preload the image + const image = new Image(); + image.src = url; + image.onload = () => { + resolve(url); + }; + // No blob store configured + } else if (res.status === 401) { + resolve(file); + throw new Error("`BLOB_READ_WRITE_TOKEN` environment variable not found, reading image locally instead."); + // Unknown error + } 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 ( + + + + + + +
+
Background
+ {HIGHLIGHT_COLORS.map(({ name, color }) => ( + { + 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]" + > +
+
+ A +
+ {name} +
+ {editor.isActive("highlight", { color }) && } +
+ ))} +
+
+
+ ); +}; 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 ( + + + + + + +
+
Color
+ {TEXT_COLORS.map(({ name, color }) => ( + { + 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" + > +
+
+ A +
+ {name} +
+
+ ))} +
+
+
+ ); +}; 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(null); + const { editor } = useEditor(); + + // Autofocus on input by default + useEffect(() => { + inputRef.current?.focus(); + }); + if (!editor) return null; + + return ( + + + + + +
{ + 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 " + > + + {editor.getAttributes("link").href ? ( + + ) : ( + + )} +
+
+
+ ); +}; 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["editor"]) => void; + isActive: (editor: ReturnType["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 ( + + + + + + {items.map((item) => ( + { + item.command(editor); + onOpenChange(false); + }} + className="flex cursor-pointer items-center justify-between rounded-sm px-2 py-1 text-sm hover:bg-accent" + > +
+
+ +
+ {item.name} +
+ {activeItem.name === item.name && } +
+ ))} +
+
+ ); +}; 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 ( +
+ {items.map((item) => ( + { + item.command(editor); + }} + > + + + ))} +
+ ); +}; 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: , + 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: , + 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: , + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).toggleTaskList().run(); + }, + }, + { + title: "Heading 1", + description: "Big section heading.", + searchTerms: ["title", "big", "large"], + icon: , + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run(); + }, + }, + { + title: "Heading 2", + description: "Medium section heading.", + searchTerms: ["subtitle", "medium"], + icon: , + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run(); + }, + }, + { + title: "Heading 3", + description: "Small section heading.", + searchTerms: ["subtitle", "small"], + icon: , + 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: , + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).toggleBulletList().run(); + }, + }, + { + title: "Numbered List", + description: "Create a list with numbering.", + searchTerms: ["ordered"], + icon: , + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).toggleOrderedList().run(); + }, + }, + { + title: "Quote", + description: "Capture a quote.", + searchTerms: ["blockquote"], + icon: , + command: ({ editor, range }) => + editor.chain().focus().deleteRange(range).toggleNode("paragraph", "paragraph").toggleBlockquote().run(), + }, + { + title: "Code", + description: "Capture a code snippet.", + searchTerms: ["codeblock"], + icon: , + command: ({ editor, range }) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(), + }, + // { + // title: "Image", + // description: "Upload an image from your computer.", + // searchTerms: ["photo", "picture", "media"], + // icon: , + // 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: , + // 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/tailwind/generative/ai-completion-command.tsx b/apps/web/app/(editor)/components/tailwind/generative/ai-completion-command.tsx new file mode 100644 index 00000000..7d37b74a --- /dev/null +++ b/apps/web/app/(editor)/components/tailwind/generative/ai-completion-command.tsx @@ -0,0 +1,66 @@ +import { CommandGroup, CommandItem, CommandSeparator } from "../ui/command"; +import { useEditor } from "novel"; +import { Check, TextQuote, TrashIcon } from "lucide-react"; + +const AICompletionCommands = ({ + completion, + onDiscard, +}: { + completion: string; + onDiscard: () => void; +}) => { + const { editor } = useEditor(); + return ( + <> + + { + const selection = editor.view.state.selection; + + editor + .chain() + .focus() + .insertContentAt( + { + from: selection.from, + to: selection.to, + }, + completion, + ) + .run(); + }} + > + + Replace selection + + { + const selection = editor.view.state.selection; + editor + .chain() + .focus() + .insertContentAt(selection.to + 1, completion) + .run(); + }} + > + + Insert below + + + + + + + + Discard + + + + ); +}; + +export default AICompletionCommands; diff --git a/apps/web/app/(editor)/components/tailwind/generative/ai-selector-commands.tsx b/apps/web/app/(editor)/components/tailwind/generative/ai-selector-commands.tsx new file mode 100644 index 00000000..dad106b0 --- /dev/null +++ b/apps/web/app/(editor)/components/tailwind/generative/ai-selector-commands.tsx @@ -0,0 +1,76 @@ +import { ArrowDownWideNarrow, CheckCheck, RefreshCcwDot, StepForward, WrapText } from "lucide-react"; +import { useEditor } from "novel"; +import { getPrevText } from "novel/utils"; +import { CommandGroup, CommandItem, CommandSeparator } from "../ui/command"; + +const options = [ + { + value: "improve", + label: "Improve writing", + icon: RefreshCcwDot, + }, + + { + value: "fix", + label: "Fix grammar", + icon: CheckCheck, + }, + { + value: "shorter", + label: "Make shorter", + icon: ArrowDownWideNarrow, + }, + { + value: "longer", + label: "Make longer", + icon: WrapText, + }, +]; + +interface AISelectorCommandsProps { + onSelect: (value: string, option: string) => void; +} + +const AISelectorCommands = ({ onSelect }: AISelectorCommandsProps) => { + const { editor } = useEditor(); + + return ( + <> + + {options.map((option) => ( + { + const slice = editor.state.selection.content(); + const text = editor.storage.markdown.serializer.serialize(slice.content); + onSelect(text, value); + }} + className="flex gap-2 px-4" + key={option.value} + value={option.value} + > + + {option.label} + + ))} + + + + { + const pos = editor.state.selection.from; + + const text = getPrevText(editor, pos); + onSelect(text, "continue"); + }} + value="continue" + className="gap-2 px-4" + > + + Continue writing + + + + ); +}; + +export default AISelectorCommands; diff --git a/apps/web/app/(editor)/components/tailwind/generative/ai-selector.tsx b/apps/web/app/(editor)/components/tailwind/generative/ai-selector.tsx new file mode 100644 index 00000000..88ca1681 --- /dev/null +++ b/apps/web/app/(editor)/components/tailwind/generative/ai-selector.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { Command, CommandInput } from "@/components/tailwind/ui/command"; + +import { useCompletion } from "ai/react"; +import { ArrowUp } from "lucide-react"; +import { useEditor } from "novel"; +import { addAIHighlight } from "novel/extensions"; +import { useState } from "react"; +import Markdown from "react-markdown"; +import { toast } from "sonner"; +import { Button } from "../ui/button"; +import CrazySpinner from "../ui/icons/crazy-spinner"; +import Magic from "../ui/icons/magic"; +import { ScrollArea } from "../ui/scroll-area"; +import AICompletionCommands from "./ai-completion-command"; +import AISelectorCommands from "./ai-selector-commands"; +//TODO: I think it makes more sense to create a custom Tiptap extension for this functionality https://tiptap.dev/docs/editor/ai/introduction + +interface AISelectorProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function AISelector({ onOpenChange }: AISelectorProps) { + const { editor } = useEditor(); + const [inputValue, setInputValue] = useState(""); + + const { completion, complete, isLoading } = useCompletion({ + // id: "novel", + api: "/api/generate", + onResponse: (response) => { + if (response.status === 429) { + toast.error("You have reached your request limit for the day."); + return; + } + }, + onError: (e) => { + toast.error(e.message); + }, + }); + + const hasCompletion = completion.length > 0; + + return ( + + {hasCompletion && ( +
+ +
+ {completion} +
+
+
+ )} + + {isLoading && ( +
+ + AI is thinking +
+ +
+
+ )} + {!isLoading && ( + <> +
+ addAIHighlight(editor)} + /> + +
+ {hasCompletion ? ( + { + editor.chain().unsetHighlight().focus().run(); + onOpenChange(false); + }} + completion={completion} + /> + ) : ( + complete(value, { body: { option } })} /> + )} + + )} +
+ ); +} diff --git a/apps/web/app/(editor)/components/tailwind/ui/button.tsx b/apps/web/app/(editor)/components/tailwind/ui/button.tsx new file mode 100644 index 00000000..9995a5e8 --- /dev/null +++ b/apps/web/app/(editor)/components/tailwind/ui/button.tsx @@ -0,0 +1,47 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@repo/ui/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ; + }, +); +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/apps/web/app/(editor)/components/tailwind/ui/command.tsx b/apps/web/app/(editor)/components/tailwind/ui/command.tsx new file mode 100644 index 00000000..751bbc53 --- /dev/null +++ b/apps/web/app/(editor)/components/tailwind/ui/command.tsx @@ -0,0 +1,134 @@ +"use client"; + +import * as React from "react"; +import type { DialogProps } from "@radix-ui/react-dialog"; +import { Command as CommandPrimitive } from "cmdk"; + +import { cn } from "@repo/ui/lib/utils"; +import { Dialog, DialogContent } from "@repo/ui/shadcn/dialog"; +import Magic from "./icons/magic"; + +const Command = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Command.displayName = CommandPrimitive.displayName; + +interface CommandDialogProps extends DialogProps {} + +const CommandDialog = ({ children, ...props }: CommandDialogProps) => { + return ( + + + + {children} + + + + ); +}; + +const CommandInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ + +
+)); + +CommandInput.displayName = CommandPrimitive.Input.displayName; + +const CommandList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandList.displayName = CommandPrimitive.List.displayName; + +const CommandEmpty = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ); + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName; + +const CommandGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandGroup.displayName = CommandPrimitive.Group.displayName; + +const CommandSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +CommandSeparator.displayName = CommandPrimitive.Separator.displayName; + +const CommandItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandItem.displayName = CommandPrimitive.Item.displayName; + +const CommandShortcut = ({ className, ...props }: React.HTMLAttributes) => { + return ; +}; +CommandShortcut.displayName = "CommandShortcut"; + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +}; diff --git a/apps/web/app/(editor)/components/tailwind/ui/icons/asksvg.tsx b/apps/web/app/(editor)/components/tailwind/ui/icons/asksvg.tsx new file mode 100644 index 00000000..aa38fe08 --- /dev/null +++ b/apps/web/app/(editor)/components/tailwind/ui/icons/asksvg.tsx @@ -0,0 +1,12 @@ +import React from 'react' + +function Asksvg() { + return ( + + + + + ) +} + +export default Asksvg \ No newline at end of file diff --git a/apps/web/app/(editor)/components/tailwind/ui/icons/autocompletesvg.tsx b/apps/web/app/(editor)/components/tailwind/ui/icons/autocompletesvg.tsx new file mode 100644 index 00000000..c433fcad --- /dev/null +++ b/apps/web/app/(editor)/components/tailwind/ui/icons/autocompletesvg.tsx @@ -0,0 +1,12 @@ +import React from 'react' + +function Autocompletesvg() { + return ( + + + + + ) +} + +export default Autocompletesvg \ No newline at end of file diff --git a/apps/web/app/(editor)/components/tailwind/ui/icons/crazy-spinner.tsx b/apps/web/app/(editor)/components/tailwind/ui/icons/crazy-spinner.tsx new file mode 100644 index 00000000..2e95deee --- /dev/null +++ b/apps/web/app/(editor)/components/tailwind/ui/icons/crazy-spinner.tsx @@ -0,0 +1,11 @@ +const CrazySpinner = () => { + return ( +
+
+
+
+
+ ); +}; + +export default CrazySpinner; diff --git a/apps/web/app/(editor)/components/tailwind/ui/icons/font-default.tsx b/apps/web/app/(editor)/components/tailwind/ui/icons/font-default.tsx new file mode 100644 index 00000000..bf29e4b5 --- /dev/null +++ b/apps/web/app/(editor)/components/tailwind/ui/icons/font-default.tsx @@ -0,0 +1,15 @@ +export default function FontDefault({ className }: { className?: string }) { + return ( + + Font Default Icon + + + + ); +} diff --git a/apps/web/app/(editor)/components/tailwind/ui/icons/font-mono.tsx b/apps/web/app/(editor)/components/tailwind/ui/icons/font-mono.tsx new file mode 100644 index 00000000..6e7b301b --- /dev/null +++ b/apps/web/app/(editor)/components/tailwind/ui/icons/font-mono.tsx @@ -0,0 +1,22 @@ +export default function FontMono({ className }: { className?: string }) { + return ( + + Font Mono Icon + + + + ); +} diff --git a/apps/web/app/(editor)/components/tailwind/ui/icons/font-serif.tsx b/apps/web/app/(editor)/components/tailwind/ui/icons/font-serif.tsx new file mode 100644 index 00000000..92225a36 --- /dev/null +++ b/apps/web/app/(editor)/components/tailwind/ui/icons/font-serif.tsx @@ -0,0 +1,23 @@ +export default function FontSerif({ className }: { className?: string }) { + return ( + + Font Serif Icon + + + + + ); +} diff --git a/apps/web/app/(editor)/components/tailwind/ui/icons/index.tsx b/apps/web/app/(editor)/components/tailwind/ui/icons/index.tsx new file mode 100644 index 00000000..5b284462 --- /dev/null +++ b/apps/web/app/(editor)/components/tailwind/ui/icons/index.tsx @@ -0,0 +1,3 @@ +export { default as FontDefault } from "./font-default"; +export { default as FontSerif } from "./font-serif"; +export { default as FontMono } from "./font-mono"; diff --git a/apps/web/app/(editor)/components/tailwind/ui/icons/loading-circle.tsx b/apps/web/app/(editor)/components/tailwind/ui/icons/loading-circle.tsx new file mode 100644 index 00000000..2a638117 --- /dev/null +++ b/apps/web/app/(editor)/components/tailwind/ui/icons/loading-circle.tsx @@ -0,0 +1,20 @@ +export default function LoadingCircle({ dimensions }: { dimensions?: string }) { + return ( + + ); +} diff --git a/apps/web/app/(editor)/components/tailwind/ui/icons/magic.tsx b/apps/web/app/(editor)/components/tailwind/ui/icons/magic.tsx new file mode 100644 index 00000000..04dce39e --- /dev/null +++ b/apps/web/app/(editor)/components/tailwind/ui/icons/magic.tsx @@ -0,0 +1,8 @@ +export default function Magic({ className }: { className: string }) { + return ( + + + + + ); +} diff --git a/apps/web/app/(editor)/components/tailwind/ui/icons/rewritesvg.tsx b/apps/web/app/(editor)/components/tailwind/ui/icons/rewritesvg.tsx new file mode 100644 index 00000000..fad9eb90 --- /dev/null +++ b/apps/web/app/(editor)/components/tailwind/ui/icons/rewritesvg.tsx @@ -0,0 +1,11 @@ +import React from 'react' + +function Rewritesvg() { + return ( + + + + ) +} + +export default Rewritesvg \ No newline at end of file diff --git a/apps/web/app/(editor)/components/tailwind/ui/icons/translatesvg.tsx b/apps/web/app/(editor)/components/tailwind/ui/icons/translatesvg.tsx new file mode 100644 index 00000000..cde82da6 --- /dev/null +++ b/apps/web/app/(editor)/components/tailwind/ui/icons/translatesvg.tsx @@ -0,0 +1,12 @@ +import React from 'react' + +function Translatesvg() { + return ( + + + + + ) +} + +export default Translatesvg \ No newline at end of file diff --git a/apps/web/app/(editor)/components/tailwind/ui/popover.tsx b/apps/web/app/(editor)/components/tailwind/ui/popover.tsx new file mode 100644 index 00000000..0bf7e696 --- /dev/null +++ b/apps/web/app/(editor)/components/tailwind/ui/popover.tsx @@ -0,0 +1,31 @@ +"use client"; + +import * as React from "react"; +import * as PopoverPrimitive from "@radix-ui/react-popover"; + +import { cn } from "@repo/ui/lib/utils"; + +const Popover = PopoverPrimitive.Root; + +const PopoverTrigger = PopoverPrimitive.Trigger; + +const PopoverContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + + + +)); +PopoverContent.displayName = PopoverPrimitive.Content.displayName; + +export { Popover, PopoverTrigger, PopoverContent }; diff --git a/apps/web/app/(editor)/components/tailwind/ui/scroll-area.tsx b/apps/web/app/(editor)/components/tailwind/ui/scroll-area.tsx new file mode 100644 index 00000000..eb6c1f59 --- /dev/null +++ b/apps/web/app/(editor)/components/tailwind/ui/scroll-area.tsx @@ -0,0 +1,40 @@ +"use client"; + +import * as React from "react"; +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; + +import { cn } from "@repo/ui/lib/utils"; + +const ScrollArea = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + {children} + + + +)); +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; + +const ScrollBar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "vertical", ...props }, ref) => ( + + + +)); +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; + +export { ScrollArea, ScrollBar }; diff --git a/apps/web/app/(editor)/components/tailwind/ui/separator.tsx b/apps/web/app/(editor)/components/tailwind/ui/separator.tsx new file mode 100644 index 00000000..d956f2f0 --- /dev/null +++ b/apps/web/app/(editor)/components/tailwind/ui/separator.tsx @@ -0,0 +1,21 @@ +"use client"; + +import * as React from "react"; +import * as SeparatorPrimitive from "@radix-ui/react-separator"; +import { cn } from "@repo/ui/lib/utils"; + +const Separator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => ( + +)); +Separator.displayName = SeparatorPrimitive.Root.displayName; + +export { Separator }; diff --git a/apps/web/app/(editor)/editor.tsx b/apps/web/app/(editor)/editor.tsx new file mode 100644 index 00000000..140207e0 --- /dev/null +++ b/apps/web/app/(editor)/editor.tsx @@ -0,0 +1,145 @@ +"use client"; +import { defaultEditorContent } from "./lib/content"; +import { + EditorCommand, + EditorCommandEmpty, + EditorCommandItem, + EditorCommandList, + EditorContent, + type EditorInstance, + EditorRoot, + type JSONContent, + EditorBubble, +} from "novel"; +import { ImageResizer, handleCommandNavigation } from "novel/extensions"; +import { useEffect, useState } from "react"; +import { useDebouncedCallback } from "use-debounce"; +import { defaultExtensions } from "./components/extensions"; +import { ColorSelector } from "./components/selectors/color-selector"; +import { LinkSelector } from "./components/selectors/link-selector"; +import { NodeSelector } from "./components/selectors/node-selector"; +import { Separator } from "./components/tailwind/ui/separator"; +import { TextButtons } from "./components/selectors/text-buttons"; +import { BgColorSelector } from "./components/selectors/bgcolor-selector"; + +import { handleImageDrop, handleImagePaste } from "novel/plugins"; +import { uploadFn } from "./components/image-upload"; +import { slashCommand, suggestionItems } from "./components/slash-command"; +import Aigenerate from "./components/aigenerate"; + +const hljs = require('highlight.js'); + +const extensions = [...defaultExtensions, slashCommand]; + +const TailwindAdvancedEditor = () => { + const [initialContent, setInitialContent] = useState(null); + const [saveStatus, setSaveStatus] = useState("Saved"); + const [charsCount, setCharsCount] = useState(); + + const [openNode, setOpenNode] = useState(false); + const [openColor, setOpenColor] = useState(false); + const [openBgColor, setOpenBgColor] = useState(false); + const [openLink, setOpenLink] = useState(false); + const [openAI, setOpenAI] = useState(false); + + //Apply Codeblock Highlighting on the HTML from editor.getHTML() + const highlightCodeblocks = (content: string) => { + const doc = new DOMParser().parseFromString(content, 'text/html'); + doc.querySelectorAll('pre code').forEach((el) => { + // @ts-ignore + // https://highlightjs.readthedocs.io/en/latest/api.html?highlight=highlightElement#highlightelement + hljs.highlightElement(el); + }); + return new XMLSerializer().serializeToString(doc); + }; + + const debouncedUpdates = useDebouncedCallback(async (editor: EditorInstance) => { + 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); + + useEffect(() => { + const content = window.localStorage.getItem("novel-content"); + if (content) setInitialContent(JSON.parse(content)); + else setInitialContent(defaultEditorContent); + }, []); + + if (!initialContent) return null; + + return ( +
+
+
Untitled - {saveStatus}
+
+ + handleCommandNavigation(event), + }, + handlePaste: (view, event) => handleImagePaste(view, event, uploadFn), + handleDrop: (view, event, _slice, moved) => 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", + }, + }} + onUpdate={({ editor }) => { + debouncedUpdates(editor); + setSaveStatus("Unsaved"); + + }} + slotAfter={} + > + + + No results + + {suggestionItems.map((item) => ( + 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} + > +
+ {item.icon} +
+
+

{item.title}

+

{item.description}

+
+
+ ))} +
+
+ + + + + + + + + + + + +
+
+
+ ); +}; + +export default TailwindAdvancedEditor; 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 ( +
+ +
+ ); +} 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 ( +
+ {children} +
+ ); +} 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 \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/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 = ( + 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..0b9378f3 --- /dev/null +++ b/apps/web/app/(editor)/styles/globals.css @@ -0,0 +1,169 @@ +@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-thumb, ::-webkit-scrollbar-thumb:hover, ::-webkit-scrollbar-track { + background: transparent; +} + 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 diff --git a/apps/web/package.json b/apps/web/package.json index 16a59cb1..0f25e203 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -18,12 +18,15 @@ "@radix-ui/react-popover": "^1.0.7", "@repo/ui": "*", "cmdk": "^1.0.0", + "lowlight": "^3.1.0", "million": "^3.1.6", "next": "^14.1.1", + "novel": "^0.4.2", "nuqs": "^1.17.4", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-resizable-panels": "^2.0.19" + "react-resizable-panels": "^2.0.19", + "use-debounce": "^10.0.1" }, "devDependencies": { "@next/eslint-plugin-next": "^14.1.1", diff --git a/apps/web/public/landing-ui.svg b/apps/web/public/landing-ui.svg index 43e64a70..2d398618 100644 --- a/apps/web/public/landing-ui.svg +++ b/apps/web/public/landing-ui.svg @@ -1,5 +1,5 @@ - + @@ -54,7 +54,7 @@ - + diff --git a/package.json b/package.json index fd2341b3..1fc76d17 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-progress": "^1.0.3", "@radix-ui/react-scroll-area": "^1.0.5", + "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-toast": "^1.1.5", diff --git a/packages/ui/icons/index.ts b/packages/ui/icons/index.ts index e2331ee6..7788c20f 100644 --- a/packages/ui/icons/index.ts +++ b/packages/ui/icons/index.ts @@ -5,6 +5,9 @@ import ExploreIcon from "./explore.svg"; import MemoriesIcon from "./memories.svg"; import ArrowRightIcon from "./arrowright.svg"; import SelectIcon from "./select.svg"; +import SearchIcon from "./search.svg"; +import NextIcon from "./nextarrow.svg"; +import UrlIcon from "./url.svg"; export { AddIcon, @@ -14,4 +17,7 @@ export { MemoriesIcon, ArrowRightIcon, SelectIcon, + SearchIcon, + NextIcon, + UrlIcon }; diff --git a/packages/ui/icons/nextarrow.svg b/packages/ui/icons/nextarrow.svg new file mode 100644 index 00000000..b0f4a5fe --- /dev/null +++ b/packages/ui/icons/nextarrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/icons/search.svg b/packages/ui/icons/search.svg new file mode 100644 index 00000000..af8b6664 --- /dev/null +++ b/packages/ui/icons/search.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/icons/url.svg b/packages/ui/icons/url.svg new file mode 100644 index 00000000..65084459 --- /dev/null +++ b/packages/ui/icons/url.svg @@ -0,0 +1,4 @@ + + + +