diff --git a/README.md b/README.md index 666685e..68e707f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +# Inke + @@ -10,8 +12,6 @@ status - -# Inke Inke is a Notion-style WYSIWYG editor with AI-powered autocompletions. @@ -54,18 +54,18 @@ export default function App() { The `Editor` is a React component that takes in the following props: -| Prop | Type | Description | Default | -| --------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | -| `completionApi` | `string` | The API route to use for the OpenAI completion API. | `/api/generate` | -| `className` | `string` | Editor container classname. | `"relative min-h-[500px] w-full max-w-screen-lg border-stone-200 bg-white sm:mb-[calc(20vh)] sm:rounded-lg sm:border sm:shadow-lg"` | -| `defaultValue` | `JSONContent` or `string` | The default value to use for the editor. | [`defaultEditorContent`](https://github.com/yesmore/inke/blob/main/packages/core/src/ui/editor/default-content.tsx) | +| Prop | Type | Description | Default | +| --------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| `completionApi` | `string` | The API route to use for the OpenAI completion API. | `/api/generate` | +| `className` | `string` | Editor container classname. | `"relative min-h-[500px] w-full max-w-screen-lg border-stone-200 bg-white sm:mb-[calc(20vh)] sm:rounded-lg sm:border sm:shadow-lg"` | +| `defaultValue` | `JSONContent` or `string` | The default value to use for the editor. | [`defaultEditorContent`](https://github.com/yesmore/inke/blob/main/packages/core/src/ui/editor/default-content.tsx) | | `extensions` | `Extension[]` | A list of extensions to use for the editor, in addition to the [default Novel extensions](https://github.com/yesmore/inke/blob/main/packages/core/src/ui/editor/extensions/index.tsx). | `[]` | | `editorProps` | `EditorProps` | Props to pass to the underlying Tiptap editor, in addition to the [default Novel editor props](https://github.com/yesmore/inke/blob/main/packages/core/src/ui/editor/props.ts). | `{}` | -| `onUpdate` | `(editor?: Editor) => void` | A callback function that is called whenever the editor is updated. | `() => {}` | -| `onDebouncedUpdate` | `(editor?: Editor) => void` | A callback function that is called whenever the editor is updated, but only after the defined debounce duration. | `() => {}` | -| `debounceDuration` | `number` | The duration (in milliseconds) to debounce the `onDebouncedUpdate` callback. | `750` | -| `storageKey` | `string` | The key to use for storing the editor's value in local storage. | `novel__content` | -| `disableLocalStorage` | `boolean` | Enabling this option will prevent read/write content from/to local storage. | `false` | +| `onUpdate` | `(editor?: Editor) => void` | A callback function that is called whenever the editor is updated. | `() => {}` | +| `onDebouncedUpdate` | `(editor?: Editor) => void` | A callback function that is called whenever the editor is updated, but only after the defined debounce duration. | `() => {}` | +| `debounceDuration` | `number` | The duration (in milliseconds) to debounce the `onDebouncedUpdate` callback. | `750` | +| `storageKey` | `string` | The key to use for storing the editor's value in local storage. | `novel__content` | +| `disableLocalStorage` | `boolean` | Enabling this option will prevent read/write content from/to local storage. | `false` | > **Note**: Make sure to define an API endpoint that matches the `completionApi` prop (default is `/api/generate`). This is needed for the AI autocompletions to work. Here's an example: https://github.com/yesmore/inke/blob/main/apps/web/app/api/generate/route.ts diff --git a/packages/core/README.md b/packages/core/README.md index e69de29..68e707f 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -0,0 +1,95 @@ +# Inke + + + + + + + + + License + + + status + + +Inke is a Notion-style WYSIWYG editor with AI-powered autocompletions. + +See live demo: [inke-web](https://inke.app) + +Inke is a Notion-style WYSIWYG editor with AI-powered autocompletions. + +# Install Inke + +```bash +npm install inkejs +``` + +## Setting Up Locally + +To set up Inke locally, you'll need to clone the repository and set up the following environment variables: + +- `OPENAI_API_KEY` – your OpenAI API key (you can get one [here](https://platform.openai.com/account/api-keys)) +- `BLOB_READ_WRITE_TOKEN` – your Vercel Blob read/write token (currently [still in beta](https://vercel.com/docs/storage/vercel-blob/quickstart#quickstart), but feel free to [sign up on this form](https://vercel.fyi/blob-beta) for access) + +If you've deployed this to Vercel, you can also use [`vc env pull`](https://vercel.com/docs/cli/env#exporting-development-environment-variables) to pull the environment variables from your Vercel project. + +To run the app locally, you can run the following commands: + +```bash +pnpm i +pnpm build +pnpm dev +``` + +Then, you can use it in your code like this: + +```jsx +import { Editor } from "inkejs"; + +export default function App() { + return ; +} +``` + +The `Editor` is a React component that takes in the following props: + +| Prop | Type | Description | Default | +| --------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| `completionApi` | `string` | The API route to use for the OpenAI completion API. | `/api/generate` | +| `className` | `string` | Editor container classname. | `"relative min-h-[500px] w-full max-w-screen-lg border-stone-200 bg-white sm:mb-[calc(20vh)] sm:rounded-lg sm:border sm:shadow-lg"` | +| `defaultValue` | `JSONContent` or `string` | The default value to use for the editor. | [`defaultEditorContent`](https://github.com/yesmore/inke/blob/main/packages/core/src/ui/editor/default-content.tsx) | +| `extensions` | `Extension[]` | A list of extensions to use for the editor, in addition to the [default Novel extensions](https://github.com/yesmore/inke/blob/main/packages/core/src/ui/editor/extensions/index.tsx). | `[]` | +| `editorProps` | `EditorProps` | Props to pass to the underlying Tiptap editor, in addition to the [default Novel editor props](https://github.com/yesmore/inke/blob/main/packages/core/src/ui/editor/props.ts). | `{}` | +| `onUpdate` | `(editor?: Editor) => void` | A callback function that is called whenever the editor is updated. | `() => {}` | +| `onDebouncedUpdate` | `(editor?: Editor) => void` | A callback function that is called whenever the editor is updated, but only after the defined debounce duration. | `() => {}` | +| `debounceDuration` | `number` | The duration (in milliseconds) to debounce the `onDebouncedUpdate` callback. | `750` | +| `storageKey` | `string` | The key to use for storing the editor's value in local storage. | `novel__content` | +| `disableLocalStorage` | `boolean` | Enabling this option will prevent read/write content from/to local storage. | `false` | + +> **Note**: Make sure to define an API endpoint that matches the `completionApi` prop (default is `/api/generate`). This is needed for the AI autocompletions to work. Here's an example: https://github.com/yesmore/inke/blob/main/apps/web/app/api/generate/route.ts + +## Deploy Your Own + +You can deploy your own version of Novel to Vercel with one click: + +[![Deploy with Vercel](https://vercel.com/button)]() + +## Tech Stack + +Inke is built on the following stack: + +- [Next.js](https://nextjs.org/) – framework +- [Tiptap](https://tiptap.dev/) – text editor +- [OpenAI](https://openai.com/) - AI completions +- [Vercel AI SDK](https://sdk.vercel.ai/docs) – AI library +- [Vercel](https://vercel.com) – deployments +- [TailwindCSS](https://tailwindcss.com/) – styles + +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=yesmore/inke&type=Date)](https://star-history.com/#yesmore/inke&Date) + +## License + +[Apache-2.0](./LICENSE) © [yesmore](https://github.com/yesmore) diff --git a/packages/core/package.json b/packages/core/package.json index 62b20e0..0238c0d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,16 @@ { - "name": "inke", - "version": "0.1.4", - "description": "Notion-style WYSIWYG editor with AI-powered autocompletions", + "name": "inkejs", + "version": "0.0.5", + "author": "yesmore", + "description": "A Notion-style WYSIWYG editor with AI-powered autocompletions. Built with Tiptap, OpenAI, and Vercel AI SDK.", + "keywords": [ + "editor", + "markdown", + "openai", + "ai", + "nextjs", + "react" + ], "license": "Apache-2.0", "main": "./dist/index.js", "module": "./dist/index.mjs", @@ -85,7 +94,6 @@ "tsup": "^7.2.0", "typescript": "^4.9.4" }, - "author": "Steven Tey ", "homepage": "https://inke.app", "repository": { "type": "git", @@ -93,13 +101,5 @@ }, "bugs": { "url": "https://github.com/yesmore/inke/issues" - }, - "keywords": [ - "ai", - "inke", - "editor", - "markdown", - "nextjs", - "react" - ] + } } diff --git a/packages/core/src/lib/utils.ts b/packages/core/src/lib/utils.ts index ee6dc04..7b32c6c 100644 --- a/packages/core/src/lib/utils.ts +++ b/packages/core/src/lib/utils.ts @@ -24,3 +24,11 @@ export function getUrlFromString(str: string) { return null; } } + +export function isImageLink(link: string): boolean { + if (!isValidUrl(link)) return false; + const imageExtensions = [".jpg", ".jpeg", ".png", "webp", ".gif", ".bmp"]; + const fileExtension = link.substring(link.lastIndexOf(".")).toLowerCase(); + + return imageExtensions.includes(fileExtension); +} diff --git a/packages/core/src/ui/editor/bubble-menu/ai-selectors/edit/ai-edit-selector.tsx b/packages/core/src/ui/editor/bubble-menu/ai-selectors/edit/ai-edit-selector.tsx index 92799f1..830738a 100644 --- a/packages/core/src/ui/editor/bubble-menu/ai-selectors/edit/ai-edit-selector.tsx +++ b/packages/core/src/ui/editor/bubble-menu/ai-selectors/edit/ai-edit-selector.tsx @@ -10,8 +10,6 @@ import { ListPlus, PartyPopper, PauseCircle, - Pipette, - Repeat, Scissors, Wand, } from "lucide-react"; diff --git a/packages/core/src/ui/editor/bubble-menu/link-selector.tsx b/packages/core/src/ui/editor/bubble-menu/link-selector.tsx index a45e7a4..90d34d5 100644 --- a/packages/core/src/ui/editor/bubble-menu/link-selector.tsx +++ b/packages/core/src/ui/editor/bubble-menu/link-selector.tsx @@ -28,8 +28,7 @@ export const LinkSelector: FC = ({ className="novel-flex novel-h-full novel-items-center novel-space-x-2 novel-px-3 novel-py-1.5 novel-text-sm novel-font-medium novel-text-stone-600 hover:novel-bg-stone-100 active:novel-bg-stone-200" onClick={() => { setIsOpen(!isOpen); - }} - > + }}>

= ({ { "novel-text-blue-500": editor.isActive("link"), } - )} - > + )}> Link

@@ -51,8 +49,7 @@ export const LinkSelector: FC = ({ url && editor.chain().focus().setLink({ href: url }).run(); setIsOpen(false); }} - className="novel-fixed novel-top-full novel-z-[99999] novel-mt-1 novel-flex novel-w-60 novel-overflow-hidden novel-rounded novel-border novel-border-stone-200 novel-bg-white novel-p-1 novel-shadow-xl novel-animate-in novel-fade-in novel-slide-in-from-top-1" - > + className="novel-fixed novel-top-full novel-z-[99999] novel-mt-1 novel-flex novel-w-60 novel-overflow-hidden novel-rounded novel-border novel-border-stone-200 novel-bg-white novel-p-1 novel-shadow-xl novel-animate-in novel-fade-in novel-slide-in-from-top-1"> = ({ onClick={() => { editor.chain().focus().unsetLink().run(); setIsOpen(false); - }} - > + }}> ) : ( diff --git a/packages/core/src/ui/editor/extensions/slash-command.tsx b/packages/core/src/ui/editor/extensions/slash-command.tsx index 3ea919b..fe7f426 100644 --- a/packages/core/src/ui/editor/extensions/slash-command.tsx +++ b/packages/core/src/ui/editor/extensions/slash-command.tsx @@ -26,6 +26,7 @@ import { CheckSquare, Table2, PauseCircle, + FileImage, } from "lucide-react"; import { LoadingCircle } from "@/ui/icons"; import { toast } from "sonner"; @@ -35,6 +36,7 @@ import { getPrevText } from "@/lib/editor"; import { startImageUpload } from "@/ui/editor/plugins/upload-images"; import { NovelContext } from "../provider"; import { Youtube } from "lucide-react"; +import { isImageLink } from "@/lib/utils"; interface CommandItemProps { title: string; @@ -211,10 +213,10 @@ const getSuggestionItems = ({ }, }, { - title: "Image", + title: "Local image", description: "Upload an image from your computer.", - searchTerms: ["photo", "picture", "media"], - icon: , + searchTerms: ["photo", "picture", "media", "img"], + icon: , command: ({ editor, range }: CommandProps) => { editor.chain().focus().deleteRange(range).run(); // upload image @@ -231,6 +233,25 @@ const getSuggestionItems = ({ input.click(); }, }, + { + title: "Remote image", + description: "Render an image from url.", + searchTerms: ["photo", "picture", "media", "img"], + icon: , + command: ({ editor, range }: CommandProps) => { + const url = prompt("Enter image url"); + if (url && isImageLink(url)) { + editor + .chain() + .focus() + .deleteRange(range) + .setImage({ + src: url, + }) + .run(); + } + }, + }, { title: "Youtube video", description: "Play the Youtube video you filled out.", diff --git a/packages/core/src/ui/editor/plugins/upload-images.tsx b/packages/core/src/ui/editor/plugins/upload-images.tsx index 2249b44..582f2f2 100644 --- a/packages/core/src/ui/editor/plugins/upload-images.tsx +++ b/packages/core/src/ui/editor/plugins/upload-images.tsx @@ -2,8 +2,6 @@ import { BlobResult } from "@vercel/blob"; import { toast } from "sonner"; import { EditorState, Plugin, PluginKey } from "@tiptap/pm/state"; import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view"; -import { NovelContext } from "../provider"; -import { useContext } from "react"; const uploadKey = new PluginKey("upload-image"); @@ -26,7 +24,7 @@ const UploadImagesPlugin = () => const image = document.createElement("img"); image.setAttribute( "class", - "opacity-40 rounded-lg border border-stone-200" + "opacity-40 rounded-md border border-stone-200" ); image.src = src; placeholder.appendChild(image); @@ -62,8 +60,8 @@ export function startImageUpload(file: File, view: EditorView, pos: number) { if (!file.type.includes("image/")) { toast.error("File type not supported."); return; - } else if (file.size / 1024 / 1024 > 5) { - toast.error(`File size too big (max ${15}MB).`); + } else if (file.size / 1024 / 1024 > 1) { + toast.error(`File size too big (max ${1}MB).`); return; } @@ -111,7 +109,6 @@ export function startImageUpload(file: File, view: EditorView, pos: number) { } export const handleImageUpload = (file: File) => { - // upload to Vercel Blob return new Promise((resolve) => { toast.promise( fetch("/api/upload", { @@ -125,6 +122,7 @@ export const handleImageUpload = (file: File) => { // Successfully uploaded image if (res.status === 200) { const { url } = (await res.json()) as BlobResult; + // preload the image let image = new Image(); image.src = url; @@ -134,7 +132,6 @@ export const handleImageUpload = (file: File) => { // 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." );