1
0
Code Issues Pull Requests Packages Projects Releases Wiki Activity GitHub Gitee

feat: suppot remote image url

This commit is contained in:
songjunxi 2023-10-24 11:25:48 +08:00
parent 116e116332
commit 2b456637e9
8 changed files with 160 additions and 45 deletions

View File

@ -1,3 +1,5 @@
# Inke
<a href="https://www.npmjs.org/package/inkejs" target='_blank'> <a href="https://www.npmjs.org/package/inkejs" target='_blank'>
<img src="https://img.shields.io/npm/v/inkejs"> <img src="https://img.shields.io/npm/v/inkejs">
</a> </a>
@ -10,8 +12,6 @@
<a href="https://inke.app"> <a href="https://inke.app">
<img src="https://badgen.net/https/inke.app/api/status" alt="status"/> <img src="https://badgen.net/https/inke.app/api/status" alt="status"/>
</a> </a>
# Inke
Inke is a Notion-style WYSIWYG editor with AI-powered autocompletions. 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: The `Editor` is a React component that takes in the following props:
| Prop | Type | Description | Default | | Prop | Type | Description | Default |
| --------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | | --------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `completionApi` | `string` | The API route to use for the OpenAI completion API. | `/api/generate` | | `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"` | | `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) | | `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). | `[]` | | `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). | `{}` | | `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. | `() => {}` | | `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. | `() => {}` | | `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` | | `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` | | `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` | | `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 > **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

View File

@ -0,0 +1,95 @@
# Inke
<a href="https://www.npmjs.org/package/inkejs" target='_blank'>
<img src="https://img.shields.io/npm/v/inkejs">
</a>
<a href="https://npmcharts.com/compare/inkejs?minimal=true" target='_blank'>
<img src="https://img.shields.io/npm/dt/inkejs.svg">
</a>
<a href="https://github.com/yesmore/inke/blob/master/LICENSE">
<img src="https://img.shields.io/github/license/yesmore/inke?label=license&logo=github&color=f80&logoColor=fff" alt="License" />
</a>
<a href="https://inke.app">
<img src="https://badgen.net/https/inke.app/api/status" alt="status"/>
</a>
Inke is a Notion-style WYSIWYG editor with AI-powered autocompletions.
See live demo: [inke-web](https://inke.app)
<img alt="Inke is a Notion-style WYSIWYG editor with AI-powered autocompletions." src="https://inke.app/desktop.png">
# 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 <Editor />;
}
```
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)

View File

@ -1,7 +1,16 @@
{ {
"name": "inke", "name": "inkejs",
"version": "0.1.4", "version": "0.0.5",
"description": "Notion-style WYSIWYG editor with AI-powered autocompletions", "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", "license": "Apache-2.0",
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.mjs", "module": "./dist/index.mjs",
@ -85,7 +94,6 @@
"tsup": "^7.2.0", "tsup": "^7.2.0",
"typescript": "^4.9.4" "typescript": "^4.9.4"
}, },
"author": "Steven Tey <stevensteel97@gmail.com>",
"homepage": "https://inke.app", "homepage": "https://inke.app",
"repository": { "repository": {
"type": "git", "type": "git",
@ -93,13 +101,5 @@
}, },
"bugs": { "bugs": {
"url": "https://github.com/yesmore/inke/issues" "url": "https://github.com/yesmore/inke/issues"
}, }
"keywords": [
"ai",
"inke",
"editor",
"markdown",
"nextjs",
"react"
]
} }

View File

@ -24,3 +24,11 @@ export function getUrlFromString(str: string) {
return null; 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);
}

View File

@ -10,8 +10,6 @@ import {
ListPlus, ListPlus,
PartyPopper, PartyPopper,
PauseCircle, PauseCircle,
Pipette,
Repeat,
Scissors, Scissors,
Wand, Wand,
} from "lucide-react"; } from "lucide-react";

View File

@ -28,8 +28,7 @@ export const LinkSelector: FC<LinkSelectorProps> = ({
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" 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={() => { onClick={() => {
setIsOpen(!isOpen); setIsOpen(!isOpen);
}} }}>
>
<p className="novel-text-base"></p> <p className="novel-text-base"></p>
<p <p
className={cn( className={cn(
@ -37,8 +36,7 @@ export const LinkSelector: FC<LinkSelectorProps> = ({
{ {
"novel-text-blue-500": editor.isActive("link"), "novel-text-blue-500": editor.isActive("link"),
} }
)} )}>
>
Link Link
</p> </p>
</button> </button>
@ -51,8 +49,7 @@ export const LinkSelector: FC<LinkSelectorProps> = ({
url && editor.chain().focus().setLink({ href: url }).run(); url && editor.chain().focus().setLink({ href: url }).run();
setIsOpen(false); 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">
>
<input <input
ref={inputRef} ref={inputRef}
type="text" type="text"
@ -67,8 +64,7 @@ export const LinkSelector: FC<LinkSelectorProps> = ({
onClick={() => { onClick={() => {
editor.chain().focus().unsetLink().run(); editor.chain().focus().unsetLink().run();
setIsOpen(false); setIsOpen(false);
}} }}>
>
<Trash className="novel-h-4 novel-w-4" /> <Trash className="novel-h-4 novel-w-4" />
</button> </button>
) : ( ) : (

View File

@ -26,6 +26,7 @@ import {
CheckSquare, CheckSquare,
Table2, Table2,
PauseCircle, PauseCircle,
FileImage,
} from "lucide-react"; } from "lucide-react";
import { LoadingCircle } from "@/ui/icons"; import { LoadingCircle } from "@/ui/icons";
import { toast } from "sonner"; import { toast } from "sonner";
@ -35,6 +36,7 @@ import { getPrevText } from "@/lib/editor";
import { startImageUpload } from "@/ui/editor/plugins/upload-images"; import { startImageUpload } from "@/ui/editor/plugins/upload-images";
import { NovelContext } from "../provider"; import { NovelContext } from "../provider";
import { Youtube } from "lucide-react"; import { Youtube } from "lucide-react";
import { isImageLink } from "@/lib/utils";
interface CommandItemProps { interface CommandItemProps {
title: string; title: string;
@ -211,10 +213,10 @@ const getSuggestionItems = ({
}, },
}, },
{ {
title: "Image", title: "Local image",
description: "Upload an image from your computer.", description: "Upload an image from your computer.",
searchTerms: ["photo", "picture", "media"], searchTerms: ["photo", "picture", "media", "img"],
icon: <ImageIcon size={18} />, icon: <FileImage size={18} />,
command: ({ editor, range }: CommandProps) => { command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).run(); editor.chain().focus().deleteRange(range).run();
// upload image // upload image
@ -231,6 +233,25 @@ const getSuggestionItems = ({
input.click(); input.click();
}, },
}, },
{
title: "Remote image",
description: "Render an image from url.",
searchTerms: ["photo", "picture", "media", "img"],
icon: <ImageIcon size={18} />,
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", title: "Youtube video",
description: "Play the Youtube video you filled out.", description: "Play the Youtube video you filled out.",

View File

@ -2,8 +2,6 @@ import { BlobResult } from "@vercel/blob";
import { toast } from "sonner"; import { toast } from "sonner";
import { EditorState, Plugin, PluginKey } from "@tiptap/pm/state"; import { EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view"; import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view";
import { NovelContext } from "../provider";
import { useContext } from "react";
const uploadKey = new PluginKey("upload-image"); const uploadKey = new PluginKey("upload-image");
@ -26,7 +24,7 @@ const UploadImagesPlugin = () =>
const image = document.createElement("img"); const image = document.createElement("img");
image.setAttribute( image.setAttribute(
"class", "class",
"opacity-40 rounded-lg border border-stone-200" "opacity-40 rounded-md border border-stone-200"
); );
image.src = src; image.src = src;
placeholder.appendChild(image); placeholder.appendChild(image);
@ -62,8 +60,8 @@ export function startImageUpload(file: File, view: EditorView, pos: number) {
if (!file.type.includes("image/")) { if (!file.type.includes("image/")) {
toast.error("File type not supported."); toast.error("File type not supported.");
return; return;
} else if (file.size / 1024 / 1024 > 5) { } else if (file.size / 1024 / 1024 > 1) {
toast.error(`File size too big (max ${15}MB).`); toast.error(`File size too big (max ${1}MB).`);
return; return;
} }
@ -111,7 +109,6 @@ export function startImageUpload(file: File, view: EditorView, pos: number) {
} }
export const handleImageUpload = (file: File) => { export const handleImageUpload = (file: File) => {
// upload to Vercel Blob
return new Promise((resolve) => { return new Promise((resolve) => {
toast.promise( toast.promise(
fetch("/api/upload", { fetch("/api/upload", {
@ -125,6 +122,7 @@ export const handleImageUpload = (file: File) => {
// Successfully uploaded image // Successfully uploaded image
if (res.status === 200) { if (res.status === 200) {
const { url } = (await res.json()) as BlobResult; const { url } = (await res.json()) as BlobResult;
// preload the image // preload the image
let image = new Image(); let image = new Image();
image.src = url; image.src = url;
@ -134,7 +132,6 @@ export const handleImageUpload = (file: File) => {
// No blob store configured // No blob store configured
} else if (res.status === 401) { } else if (res.status === 401) {
resolve(file); resolve(file);
throw new Error( throw new Error(
"`BLOB_READ_WRITE_TOKEN` environment variable not found, reading image locally instead." "`BLOB_READ_WRITE_TOKEN` environment variable not found, reading image locally instead."
); );