1
0
Fork 0

chore pkg and demo

This commit is contained in:
songjunxi 2023-10-22 10:52:46 +08:00
commit bccff40c0d
59 changed files with 12213 additions and 0 deletions

14
.eslintrc.json Normal file
View File

@ -0,0 +1,14 @@
{
"root": true,
// This tells ESLint to load the config from the package `eslint-config-custom`
"extends": ["next/core-web-vitals", "eslint:recommended"],
"settings": {
"next": {
"rootDir": ["apps/*/"]
}
},
"rules": {
"no-unused-vars": "off",
"no-undef": "off"
}
}

3
.github/FUNDING.yml vendored Normal file
View File

@ -0,0 +1,3 @@
# These are supported funding model platforms
github: [yesmore]

53
.gitignore vendored Normal file
View File

@ -0,0 +1,53 @@
# dependencies
/node_modules
/.pnp
.pnp.js
node_modules
packages/*/node_modules
apps/*/node_modules
.next
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
.pnpm-debug.log*
# other lockfiles that's not pnpm-lock.yaml
package-lock.json
yarn.lock
# local env files
.env
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# vscode
.vscode
# intellij
.idea
dist/**
/dist
packages/*/dist
.turbo
/test-results/
/playwright-report/
/playwright/.cache/

201
LICENSE Normal file
View File

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

48
README.md Normal file
View File

@ -0,0 +1,48 @@
# Inke
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 pkg
> Coming soon
```bash
npm install inke
```
## 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
```
## 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
## License
[Apache-2.0](./LICENSE) © [yesmore](https://github.com/yesmore)

70
package.json Normal file
View File

@ -0,0 +1,70 @@
{
"name": "inke",
"private": true,
"scripts": {
"build": "turbo build",
"dev": "turbo dev",
"lint": "turbo lint",
"clean": "turbo clean",
"format:write": "prettier --write \"**/*.{css,js,json,jsx,ts,tsx}\"",
"format": "prettier \"**/*.{css,js,json,jsx,ts,tsx}\"",
"release": "turbo build && cd packages/core && npm publish && cd ../../"
},
"dependencies": {
"@radix-ui/react-popover": "^1.0.6",
"@tiptap/core": "^2.0.4",
"@tiptap/extension-color": "^2.0.4",
"@tiptap/extension-highlight": "^2.0.4",
"@tiptap/extension-horizontal-rule": "^2.0.4",
"@tiptap/extension-image": "^2.0.4",
"@tiptap/extension-link": "^2.0.4",
"@tiptap/extension-placeholder": "2.0.3",
"@tiptap/extension-task-item": "^2.0.4",
"@tiptap/extension-task-list": "^2.0.4",
"@tiptap/extension-text-style": "^2.0.4",
"@tiptap/extension-underline": "^2.0.4",
"@tiptap/pm": "^2.0.4",
"@tiptap/react": "^2.0.4",
"@tiptap/starter-kit": "^2.0.4",
"@tiptap/suggestion": "^2.0.4",
"@types/node": "18.15.3",
"@types/react": "18.0.28",
"@types/react-dom": "18.0.11",
"@upstash/ratelimit": "^0.4.3",
"@vercel/analytics": "^1.0.1",
"@vercel/blob": "^0.9.3",
"@vercel/kv": "^0.2.2",
"ai": "^2.2.9",
"cmdk": "^0.2.0",
"clsx": "^1.2.1",
"eslint": "8.36.0",
"eslint-config-next": "13.2.4",
"eventsource-parser": "^0.1.0",
"framer-motion": "^10.15.1",
"lucide-react": "^0.244.0",
"next": "13.4.20-canary.9",
"next-themes": "^0.2.1",
"openai-edge": "^1.2.2",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-markdown": "^8.0.7",
"react-moveable": "^0.54.1",
"sonner": "^0.7.0",
"tailwind-merge": "^1.14.0",
"tippy.js": "^6.3.7",
"tiptap-markdown": "^0.8.2",
"typescript": "4.9.5",
"use-debounce": "^9.0.4"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.9",
"autoprefixer": "^10.4.14",
"eslint": "^7.32.0",
"postcss": "^8.4.27",
"prettier": "^2.8.8",
"prettier-plugin-tailwindcss": "^0.3.0",
"tailwindcss": "^3.3.3",
"tailwindcss-animate": "^1.0.6",
"turbo": "^1.9.3"
}
}

0
packages/core/README.md Normal file
View File

105
packages/core/package.json Normal file
View File

@ -0,0 +1,105 @@
{
"name": "inke",
"version": "0.1.4",
"description": "Notion-style WYSIWYG editor with AI-powered autocompletions",
"license": "Apache-2.0",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"files": [
"dist/**/*"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"module": "./dist/index.mjs",
"require": "./dist/index.js"
}
},
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"check-types": "tsc --noEmit"
},
"peerDependencies": {
"react": "^18.2.0"
},
"dependencies": {
"@radix-ui/react-popover": "^1.0.6",
"@tiptap/core": "^2.1.7",
"@tiptap/extension-code-block-lowlight": "^2.1.12",
"@tiptap/extension-color": "^2.1.7",
"@tiptap/extension-highlight": "^2.1.7",
"@tiptap/extension-horizontal-rule": "^2.1.7",
"@tiptap/extension-image": "^2.1.7",
"@tiptap/extension-link": "^2.1.7",
"@tiptap/extension-placeholder": "2.0.3",
"@tiptap/extension-table": "^2.1.12",
"@tiptap/extension-table-cell": "^2.1.12",
"@tiptap/extension-table-header": "^2.1.12",
"@tiptap/extension-table-row": "^2.1.12",
"@tiptap/extension-task-item": "^2.1.7",
"@tiptap/extension-task-list": "^2.1.7",
"@tiptap/extension-text-style": "^2.1.7",
"@tiptap/extension-typography": "^2.1.11",
"@tiptap/extension-underline": "^2.1.7",
"@tiptap/extension-youtube": "^2.1.12",
"@tiptap/pm": "^2.1.7",
"@tiptap/react": "^2.1.7",
"@tiptap/starter-kit": "^2.1.7",
"@tiptap/suggestion": "^2.1.7",
"@types/node": "18.15.3",
"@types/react": "18.0.28",
"@types/react-dom": "18.0.11",
"@upstash/ratelimit": "^0.4.4",
"@vercel/analytics": "^1.0.2",
"@vercel/blob": "^0.9.3",
"@vercel/kv": "^0.2.2",
"ai": "^2.2.11",
"clsx": "^1.2.1",
"eslint": "8.36.0",
"eslint-config-next": "13.2.4",
"eventsource-parser": "^0.1.0",
"highlight.js": "^11.9.0",
"lowlight": "^3.1.0",
"lucide-react": "^0.244.0",
"next": "13.4.20-canary.15",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-markdown": "^8.0.7",
"sonner": "^0.7.0",
"tailwind-merge": "^1.14.0",
"tippy.js": "^6.3.7",
"tiptap-markdown": "^0.8.2",
"typescript": "4.9.5",
"use-debounce": "^9.0.4"
},
"devDependencies": {
"@types/react": "^18.2.5",
"eslint": "^7.32.0",
"postcss": "^8.4.29",
"react": "^18.2.0",
"tailwind-config": "workspace:*",
"tsconfig": "workspace:*",
"tsup": "^7.2.0",
"typescript": "^4.9.4"
},
"author": "Steven Tey <stevensteel97@gmail.com>",
"homepage": "https://inke.app",
"repository": {
"type": "git",
"url": "git+https://github.com/yesmore/inke.git"
},
"bugs": {
"url": "https://github.com/yesmore/inke/issues"
},
"keywords": [
"ai",
"inke",
"editor",
"markdown",
"nextjs",
"react"
]
}

View File

@ -0,0 +1,9 @@
// If you want to use other PostCSS plugins, see the following:
// https://tailwindcss.com/docs/using-with-preprocessors
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@ -0,0 +1,5 @@
import "./styles/index.css";
import "./styles/tailwind.css";
import "./styles/prosemirror.css";
export { default as Editor } from "./ui/editor";

View File

View File

@ -0,0 +1,21 @@
import { Editor } from "@tiptap/core";
export const getPrevText = (
editor: Editor,
{
chars,
offset = 0,
}: {
chars: number;
offset?: number;
}
) => {
// for now, we're using textBetween for now until we can figure out a way to stream markdown text
// with proper formatting: https://github.com/yesmore/inke/discussions/7
return editor.state.doc.textBetween(
Math.max(0, editor.state.selection.from - chars),
editor.state.selection.from - offset,
"\n"
);
// complete(editor.storage.markdown.getMarkdown());
};

View File

@ -0,0 +1,27 @@
import { useEffect, useState } from "react";
const useLocalStorage = <T>(
key: string,
initialValue: T
// eslint-disable-next-line no-unused-vars
): [T, (value: T) => void] => {
const [storedValue, setStoredValue] = useState(initialValue);
useEffect(() => {
// Retrieve from localStorage
const item = window.localStorage.getItem(key);
if (item) {
setStoredValue(JSON.parse(item));
}
}, [key]);
const setValue = (value: T) => {
// Save state
setStoredValue(value);
// Save to localStorage
window.localStorage.setItem(key, JSON.stringify(value));
};
return [storedValue, setValue];
};
export default useLocalStorage;

View File

@ -0,0 +1,26 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
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;
}
}

Binary file not shown.

View File

@ -0,0 +1,47 @@
import localFont from "next/font/local";
import { Crimson_Text, Inconsolata, Inter } from "next/font/google";
export const cal = localFont({
src: "./CalSans-SemiBold.otf",
variable: "--font-title",
});
export const crimsonBold = Crimson_Text({
weight: "700",
variable: "--font-title",
subsets: ["latin"],
});
export const inter = Inter({
variable: "--font-default",
subsets: ["latin"],
});
export const inconsolataBold = Inconsolata({
weight: "700",
variable: "--font-title",
subsets: ["latin"],
});
export const crimson = Crimson_Text({
weight: "400",
variable: "--font-default",
subsets: ["latin"],
});
export const inconsolata = Inconsolata({
variable: "--font-default",
subsets: ["latin"],
});
export const titleFontMapper = {
Default: cal.variable,
Serif: crimsonBold.variable,
Mono: inconsolataBold.variable,
};
export const defaultFontMapper = {
Default: inter.variable,
Serif: crimson.variable,
Mono: inconsolata.variable,
};

View File

@ -0,0 +1,56 @@
:root {
--inke-black: rgb(0 0 0);
--inke-white: rgb(255 255 255);
--inke-stone-50: rgb(250 250 249);
--inke-stone-100: rgb(245 245 244);
--inke-stone-200: rgb(231 229 228);
--inke-stone-300: rgb(214 211 209);
--inke-stone-400: rgb(168 162 158);
--inke-stone-500: rgb(120 113 108);
--inke-stone-600: rgb(87 83 78);
--inke-stone-700: rgb(68 64 60);
--inke-stone-800: rgb(41 37 36);
--inke-stone-900: rgb(28 25 23);
--inke-highlight-default: #ffffff;
--inke-highlight-purple: #f6f3f8;
--inke-highlight-red: #fdebeb;
--inke-highlight-yellow: #fbf4a2;
--inke-highlight-blue: #c1ecf9;
--inke-highlight-green: #acf79f;
--inke-highlight-orange: #faebdd;
--inke-highlight-pink: #faf1f5;
--inke-highlight-gray: #f1f1ef;
--font-title: "Cal Sans", sans-serif;
}
.dark-theme {
--inke-black: rgb(255 255 255);
--inke-white: rgb(25 25 25);
--inke-stone-50: rgb(35 35 34);
--inke-stone-100: rgb(41 37 36);
--inke-stone-200: rgb(66 69 71);
--inke-stone-300: rgb(112 118 123);
--inke-stone-400: rgb(160 167 173);
--inke-stone-500: rgb(193 199 204);
--inke-stone-600: rgb(212 217 221);
--inke-stone-700: rgb(229 232 235);
--inke-stone-800: rgb(232 234 235);
--inke-stone-900: rgb(240, 240, 241);
--inke-highlight-default: #000000;
--inke-highlight-purple: #3f2c4b;
--inke-highlight-red: #5c1a1a;
--inke-highlight-yellow: #5c4b1a;
--inke-highlight-blue: #1a3d5c;
--inke-highlight-green: #1a5c20;
--inke-highlight-orange: #5c3a1a;
--inke-highlight-pink: #5c1a3a;
--inke-highlight-gray: #3a3a3a;
}
@font-face {
font-family: "Cal Sans";
src: local("Cal Sans"), url(CalSans-SemiBold.otf) format("otf");
}

View File

@ -0,0 +1,170 @@
.ProseMirror {
@apply novel-p-12 novel-px-8 sm:novel-px-12;
}
.ProseMirror .is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: var(--inke-stone-400);
pointer-events: none;
height: 0;
}
.ProseMirror .is-empty::before {
content: attr(data-placeholder);
float: left;
color: var(--inke-stone-400);
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(--inke-stone-200);
border-top-color: var(--inke-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: var(--inke-white);
margin: 0;
cursor: pointer;
width: 1.2em;
height: 1.2em;
position: relative;
top: 5px;
border: 2px solid var(--inke-stone-900);
margin-right: 0.3rem;
display: grid;
place-content: center;
&:hover {
background-color: var(--inke-stone-50);
}
&:active {
background-color: var(--inke-stone-200);
}
&::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(--inke-stone-400);
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;
border-radius: 0.2rem;
background-color: var(--inke-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(--inke-stone-100);
transition: background-color 0.2s;
}
&:active {
background-color: var(--inke-stone-200);
transition: background-color 0.2s;
}
&.hide {
opacity: 0;
pointer-events: none;
}
@media screen and (max-width: 600px) {
display: none;
pointer-events: none;
}
}
.dark-theme .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");
}

View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@ -0,0 +1,17 @@
import { Magic } from "@/ui/icons";
import { PauseCircle } from "lucide-react";
export default function AIGeneratingLoading({ stop }: { stop: () => void }) {
return (
<div className="flex items-center justify-start novel-bg-white shadow-lg rounded-full px-3 py-2 w-16 h-10">
<Magic className="novel-w-7 novel-animate-pulse novel-text-purple-500" />
<span className="text-sm novel-animate-pulse novel-ml-1 novel-text-slate-500">
generating...
</span>
<PauseCircle
onClick={stop}
className="novel-h-5 hover:novel-text-stone-500 cursor-pointer novel-ml-6 novel-w-5 novel-text-stone-300"
/>
</div>
);
}

View File

@ -0,0 +1,93 @@
import LoadingDots from "@/ui/icons/loading-dots";
import Magic from "@/ui/icons/magic";
import { Editor } from "@tiptap/core";
import { useCompletion } from "ai/react";
import { X, Clipboard, Replace } from "lucide-react";
import { useContext, useEffect, useState } from "react";
import { toast } from "sonner";
import va from "@vercel/analytics";
import { NovelContext } from "../../../provider";
type Props = {
editor: Editor;
};
const AIEditorBubble: React.FC<Props> = ({ editor }: Props) => {
const [isShow, setIsShow] = useState(false);
const { completionApi, plan } = useContext(NovelContext);
const { completion, setCompletion, isLoading, stop } = useCompletion({
id: "novel-edit",
api: `${completionApi}/edit`,
body: { plan },
onError: (err) => {
toast.error(err.message);
if (err.message === "You have reached your request limit for the day.") {
va.track("Rate Limit Reached");
}
},
});
useEffect(() => {
if (completion.length > 0) {
setIsShow(true);
}
}, [completion]);
const handleCopy = () => {
navigator.clipboard.writeText(completion);
};
const handleReplace = () => {
if (completion.length > 0) {
const { from, to } = editor.state.selection;
editor.commands.insertContent(completion, {
updateSelection: true,
});
}
};
return isShow || isLoading ? (
<div className="novel-fixed z-[1000] novel-bottom-3 novel-right-3 novel-p-3 novel-overflow-hidden novel-rounded novel-border novel-border-stone-200 novel-bg-white novel-shadow-xl novel-animate-in novel-fade-in novel-slide-in-from-bottom-1">
<div className="novel-w-64 novel-max-h-48 novel-overflow-y-auto">
<div className=" novel-flex novel-gap-2 novel-items-center novel-text-slate-500">
<Magic className="novel-h-5 novel-animate-pulse novel-w-5 novel-text-purple-500" />
{isLoading && (
<div className="novel-mr-auto novel-flex novel-items-center">
<LoadingDots color="#9e9e9e" />
</div>
)}
<div className="novel-flex novel-items-center novel-ml-auto gap-2">
<button>
<Replace
onClick={handleReplace}
className="novel-w-4 novel-h-4 novel-cursor-pointer hover:novel-text-slate-300 "
/>
</button>
<button>
<Clipboard
onClick={handleCopy}
className="novel-w-4 active:novel-text-green-500 novel-h-4 novel-cursor-pointer hover:novel-text-slate-300 "
/>
</button>
<X
onClick={() => {
setIsShow(false);
setCompletion("");
}}
className="novel-w-4 novel-h-4 novel-cursor-pointer hover:novel-text-slate-300 "
/>
</div>
</div>
{completion.length > 0 && (
<div className="novel-text-sm mt-2">{completion}</div>
)}
</div>
</div>
) : null;
};
export default AIEditorBubble;

View File

@ -0,0 +1,166 @@
import { Editor } from "@tiptap/core";
import {
Beef,
Book,
CheckCheck,
ChevronDown,
Heading1,
LayoutPanelTop,
ListMinus,
ListPlus,
PartyPopper,
PauseCircle,
Pipette,
Repeat,
Scissors,
Wand,
} from "lucide-react";
import { FC, useContext, useEffect } from "react";
import { Command } from "cmdk";
import Magic from "@/ui/icons/magic";
import { useCompletion } from "ai/react";
import { NovelContext } from "../../../provider";
interface AISelectorProps {
editor: Editor;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
export const AISelector: FC<AISelectorProps> = ({
editor,
isOpen,
setIsOpen,
}) => {
const items = [
{
name: "Improve writing",
detail: "Improve writing",
icon: Wand,
},
{
name: "Fix spelling & grammar",
detail:
"Please correct spelling and grammar errors in the following text",
icon: CheckCheck,
},
{
name: "Make shorter",
detail: "Make shorter",
icon: ListMinus,
},
{
name: "Make longer",
detail: "Make longer",
icon: ListPlus,
},
{
name: "Writing suggestions",
detail: "Provide suggestions and improvements for the writing",
icon: Beef,
},
{
name: "Enhance vocabulary",
detail: "Suggest synonyms and expand vocabulary usage",
icon: Book,
},
{
name: "Generate titles",
detail: "Automatically generate compelling titles for the content",
icon: Heading1,
},
{
name: "Templates & structure",
detail:
"Offer templates and structure suggestions to improve the writing organization",
icon: LayoutPanelTop,
},
{
name: "Fix repetitive",
detail: "Identify and fix repetitive words or phrases in the content",
icon: Scissors,
},
{
name: "Adjust writing style",
detail:
"Suggest adjustments to writing style and tone based on the target audience",
icon: PartyPopper,
},
];
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (["ArrowUp", "ArrowDown", "Enter"].includes(e.key)) {
e.preventDefault();
}
};
if (isOpen) {
document.addEventListener("keydown", onKeyDown);
} else {
document.removeEventListener("keydown", onKeyDown);
}
return () => {
document.removeEventListener("keydown", onKeyDown);
};
}, [isOpen]);
const { completionApi, plan } = useContext(NovelContext);
const { complete, isLoading, stop } = useCompletion({
id: "novel-edit",
api: `${completionApi}/edit`,
body: { plan },
});
return (
<div className="novel-relative novel-h-full">
<div className="novel-flex novel-h-full novel-items-center novel-gap-1 novel-text-sm novel-font-medium novel-text-purple-500 hover:novel-bg-stone-100 active:novel-bg-stone-200">
<button
className="novel-p-2 novel-flex novel-h-full novel-items-center novel-gap-1"
onClick={() => {
if (isLoading) {
stop();
}
setIsOpen(!isOpen);
}}>
<Magic className="novel-h-4 novel-w-4" />
<span className="novel-whitespace-nowrap">Ask AI</span>
{!isLoading ? (
<ChevronDown className="novel-h-4 novel-w-4" />
) : (
<PauseCircle
onClick={stop}
className="novel-h-4 hover:novel-text-stone-500 cursor-pointer novel-w-4 novel-text-stone-300"
/>
)}
</button>
</div>
{isOpen && (
<Command className="novel-fixed novel-top-full novel-z-[99999] novel-mt-1 novel-w-60 novel-overflow-hidden novel-rounded novel-border novel-border-stone-200 novel-bg-white novel-p-2 novel-shadow-xl novel-animate-in novel-fade-in novel-slide-in-from-top-1">
<Command.List>
{items.map((item, index) => (
<Command.Item
key={index}
onSelect={() => {
if (!isLoading) {
const { from, to } = editor.state.selection;
const text = editor.state.doc.textBetween(from, to, " ");
complete(`${item.detail}:\n ${text}`);
setIsOpen(false);
}
}}
className="novel-flex group novel-cursor-pointer novel-items-center novel-justify-between novel-rounded-sm novel-px-2 novel-py-1 novel-text-sm novel-text-gray-600 active:novel-bg-stone-200 aria-selected:novel-bg-stone-100">
<div className="novel-flex novel-items-center novel-space-x-2">
<item.icon className="novel-h-4 novel-w-4 novel-text-purple-500" />
<span>{item.name}</span>
</div>
{/* <CornerDownLeft className="novel-hidden novel-h-4 novel-w-4 group-hover:novel-block" /> */}
</Command.Item>
))}
</Command.List>
</Command>
)}
</div>
);
};

View File

@ -0,0 +1,93 @@
import LoadingDots from "@/ui/icons/loading-dots";
import Magic from "@/ui/icons/magic";
import { Editor } from "@tiptap/core";
import { useCompletion } from "ai/react";
import { X, Clipboard, Replace } from "lucide-react";
import { useContext, useEffect, useState } from "react";
import { toast } from "sonner";
import va from "@vercel/analytics";
import { NovelContext } from "../../../provider";
type Props = {
editor: Editor;
};
const AITranslateBubble: React.FC<Props> = ({ editor }: Props) => {
const [isShow, setIsShow] = useState(false);
const { completionApi, plan } = useContext(NovelContext);
const { completion, setCompletion, isLoading, stop } = useCompletion({
id: "novel-translate",
api: `${completionApi}/translate`,
body: { plan },
onError: (err) => {
toast.error(err.message);
if (err.message === "You have reached your request limit for the day.") {
va.track("Rate Limit Reached");
}
},
});
useEffect(() => {
if (completion.length > 0) {
setIsShow(true);
}
}, [completion]);
const handleCopy = () => {
navigator.clipboard.writeText(completion);
};
const handleReplace = () => {
if (completion.length > 0) {
const { from, to } = editor.state.selection;
editor.commands.insertContent(completion, {
updateSelection: true,
});
}
};
return isShow || isLoading ? (
<div className="novel-fixed z-[1000] novel-bottom-3 novel-right-3 novel-p-3 novel-overflow-hidden novel-rounded novel-border novel-border-stone-200 novel-bg-white novel-shadow-xl novel-animate-in novel-fade-in novel-slide-in-from-bottom-1">
<div className="novel-w-64 novel-max-h-48 novel-overflow-y-auto">
<div className=" novel-flex novel-gap-2 novel-items-center novel-text-slate-500">
<Magic className="novel-h-5 novel-animate-pulse novel-w-5 novel-text-purple-500" />
{isLoading && (
<div className="novel-mr-auto novel-flex novel-items-center">
<LoadingDots color="#9e9e9e" />
</div>
)}
<div className="novel-flex novel-items-center novel-ml-auto gap-2">
<button>
<Replace
onClick={handleReplace}
className="novel-w-4 novel-h-4 novel-cursor-pointer hover:novel-text-slate-300 "
/>
</button>
<button>
<Clipboard
onClick={handleCopy}
className="novel-w-4 active:novel-text-green-500 novel-h-4 novel-cursor-pointer hover:novel-text-slate-300 "
/>
</button>
<X
onClick={() => {
setIsShow(false);
setCompletion("");
}}
className="novel-w-4 novel-h-4 novel-cursor-pointer hover:novel-text-slate-300 "
/>
</div>
</div>
{completion.length > 0 && (
<div className="novel-text-sm mt-2">{completion}</div>
)}
</div>
</div>
) : null;
};
export default AITranslateBubble;

View File

@ -0,0 +1,129 @@
import { Editor } from "@tiptap/core";
import { Globe2, Languages, PauseCircle } from "lucide-react";
import { FC, useContext, useEffect } from "react";
import { Command } from "cmdk";
import { useCompletion } from "ai/react";
import { NovelContext } from "../../../provider";
interface TranslateSelectorProps {
editor: Editor;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
export const TranslateSelector: FC<TranslateSelectorProps> = ({
editor,
isOpen,
setIsOpen,
}) => {
const items = [
{
name: "English",
detail: "Translate into English",
},
{
name: "Chinese",
detail: "Translate into Chinese",
},
{
name: "Spanish",
detail: "Translate into Spanish",
},
{
name: "French",
detail: "Translate into French",
},
{
name: "German",
detail: "Translate into German",
},
{
name: "Japanese",
detail: "Translate into Japanese",
},
{
name: "Russian",
detail: "Translate into Russian",
},
{
name: "Korean",
detail: "Translate into Korean",
},
{
name: "Arabic",
detail: "Translate into Arabic",
},
{
name: "Portuguese",
detail: "Translate into Portuguese",
},
];
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (["ArrowUp", "ArrowDown", "Enter"].includes(e.key)) {
e.preventDefault();
}
};
if (isOpen) {
document.addEventListener("keydown", onKeyDown);
} else {
document.removeEventListener("keydown", onKeyDown);
}
return () => {
document.removeEventListener("keydown", onKeyDown);
};
}, [isOpen]);
const { completionApi, plan } = useContext(NovelContext);
const { complete, isLoading, stop } = useCompletion({
id: "novel-translate",
api: `${completionApi}/translate`,
body: { plan },
});
return (
<div className="novel-relative novel-h-full">
<div className="novel-flex novel-h-full novel-items-center novel-text-sm novel-font-medium hover:novel-bg-stone-100 active:novel-bg-stone-200">
{isLoading ? (
<button className="p-2">
<PauseCircle
onClick={stop}
className="novel-h-5 hover:novel-text-stone-500 cursor-pointer novel-w-4 novel-text-stone-300"
/>
</button>
) : (
<button className="p-2">
<Languages
onClick={() => setIsOpen(!isOpen)}
className="novel-h-5 novel-text-stone-600 novel-w-4"
/>
</button>
)}
</div>
{isOpen && (
<Command className="novel-fixed novel-top-full novel-z-[99999] novel-mt-1 novel-w-28 novel-overflow-hidden novel-rounded novel-border novel-border-stone-200 novel-bg-white novel-p-2 novel-shadow-xl novel-animate-in novel-fade-in novel-slide-in-from-top-1">
<Command.List>
{items.map((item, index) => (
<Command.Item
key={index}
onSelect={() => {
if (!isLoading) {
const { from, to } = editor.state.selection;
const text = editor.state.doc.textBetween(from, to, " ");
complete(`${item.detail}:\n ${text}`);
setIsOpen(false);
}
}}
className="novel-flex novel-cursor-pointer novel-items-center novel-justify-between novel-rounded-sm novel-px-2 novel-py-1 novel-text-sm novel-text-gray-600 active:novel-bg-stone-200 aria-selected:novel-bg-stone-100">
<span>{item.name}</span>
</Command.Item>
))}
</Command.List>
</Command>
)}
</div>
);
};

View File

@ -0,0 +1,192 @@
import { Editor } from "@tiptap/core";
import { Check, ChevronDown } from "lucide-react";
import { Dispatch, FC, SetStateAction } from "react";
import * as Popover from "@radix-ui/react-popover";
export interface BubbleColorMenuItem {
name: string;
color: string;
}
interface ColorSelectorProps {
editor: Editor;
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
}
const TEXT_COLORS: BubbleColorMenuItem[] = [
{
name: "Default",
color: "var(--inke-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",
},
];
const HIGHLIGHT_COLORS: BubbleColorMenuItem[] = [
{
name: "Default",
color: "var(--inke-highlight-default)",
},
{
name: "Purple",
color: "var(--inke-highlight-purple)",
},
{
name: "Red",
color: "var(--inke-highlight-red)",
},
{
name: "Yellow",
color: "var(--inke-highlight-yellow)",
},
{
name: "Blue",
color: "var(--inke-highlight-blue)",
},
{
name: "Green",
color: "var(--inke-highlight-green)",
},
{
name: "Orange",
color: "var(--inke-highlight-orange)",
},
{
name: "Pink",
color: "var(--inke-highlight-pink)",
},
{
name: "Gray",
color: "var(--inke-highlight-gray)",
},
];
export const ColorSelector: FC<ColorSelectorProps> = ({
editor,
isOpen,
setIsOpen,
}) => {
const activeColorItem = TEXT_COLORS.find(({ color }) =>
editor.isActive("textStyle", { color })
);
const activeHighlightItem = HIGHLIGHT_COLORS.find(({ color }) =>
editor.isActive("highlight", { color })
);
return (
<Popover.Root open={isOpen}>
<div className="novel-relative novel-h-full">
<Popover.Trigger
className="novel-flex novel-h-full novel-items-center novel-gap-1 novel-p-2 novel-text-sm novel-font-medium novel-text-stone-600 hover:novel-bg-stone-100 active:novel-bg-stone-200"
onClick={() => setIsOpen(!isOpen)}>
<span
className="novel-rounded-sm novel-px-1"
style={{
color: activeColorItem?.color,
backgroundColor: activeHighlightItem?.color,
}}>
A
</span>
{/* <ChevronDown className="novel-h-4 novel-w-4" /> */}
</Popover.Trigger>
<Popover.Content
align="start"
className="novel-z-[99999] novel-my-1 novel-flex novel-max-h-80 novel-w-48 novel-flex-col novel-overflow-hidden novel-overflow-y-auto 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">
<div className="novel-my-1 novel-px-2 novel-text-sm novel-text-stone-500">
Color
</div>
{TEXT_COLORS.map(({ name, color }, index) => (
<button
key={index}
onClick={() => {
editor.commands.unsetColor();
name !== "Default" &&
editor
.chain()
.focus()
.setColor(color || "")
.run();
setIsOpen(false);
}}
className="novel-flex novel-items-center novel-justify-between novel-rounded-sm novel-px-2 novel-py-1 novel-text-sm novel-text-stone-600 hover:novel-bg-stone-100"
type="button">
<div className="novel-flex novel-items-center novel-space-x-2">
<div
className="novel-rounded-sm novel-border novel-border-stone-200 novel-px-1 novel-py-px novel-font-medium"
style={{ color }}>
A
</div>
<span>{name}</span>
</div>
{editor.isActive("textStyle", { color }) && (
<Check className="novel-h-4 novel-w-4" />
)}
</button>
))}
<div className="novel-mb-1 novel-mt-2 novel-px-2 novel-text-sm novel-text-stone-500">
Background
</div>
{HIGHLIGHT_COLORS.map(({ name, color }, index) => (
<button
key={index}
onClick={() => {
editor.commands.unsetHighlight();
name !== "Default" && editor.commands.setHighlight({ color });
setIsOpen(false);
}}
className="novel-flex novel-items-center novel-justify-between novel-rounded-sm novel-px-2 novel-py-1 novel-text-sm novel-text-stone-600 hover:novel-bg-stone-100"
type="button">
<div className="novel-flex novel-items-center novel-space-x-2">
<div
className="novel-rounded-sm novel-border novel-border-stone-200 novel-px-1 novel-py-px novel-font-medium"
style={{ backgroundColor: color }}>
A
</div>
<span>{name}</span>
</div>
{editor.isActive("highlight", { color }) && (
<Check className="novel-h-4 novel-w-4" />
)}
</button>
))}
</Popover.Content>
</div>
</Popover.Root>
);
};

View File

@ -0,0 +1,195 @@
import { BubbleMenu, BubbleMenuProps, isNodeSelection } from "@tiptap/react";
import { FC, useState } from "react";
import {
BoldIcon,
ItalicIcon,
UnderlineIcon,
StrikethroughIcon,
CodeIcon,
} from "lucide-react";
import { NodeSelector } from "./node-selector";
import { ColorSelector } from "./color-selector";
import { LinkSelector } from "./link-selector";
import { cn } from "@/lib/utils";
import { TableSelector } from "./table-selector";
import { AISelector } from "./ai-selectors/edit/ai-edit-selector";
import { TranslateSelector } from "./ai-selectors/translate/ai-translate-selector";
export interface BubbleMenuItem {
name: string;
isActive: () => boolean;
command: () => void;
icon: typeof BoldIcon;
}
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children">;
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const items: BubbleMenuItem[] = [
{
name: "bold",
isActive: () => props.editor!.isActive("bold"),
command: () => props.editor!.chain().focus().toggleBold().run(),
icon: BoldIcon,
},
{
name: "italic",
isActive: () => props.editor!.isActive("italic"),
command: () => props.editor!.chain().focus().toggleItalic().run(),
icon: ItalicIcon,
},
{
name: "underline",
isActive: () => props.editor!.isActive("underline"),
command: () => props.editor!.chain().focus().toggleUnderline().run(),
icon: UnderlineIcon,
},
{
name: "strike",
isActive: () => props.editor!.isActive("strike"),
command: () => props.editor!.chain().focus().toggleStrike().run(),
icon: StrikethroughIcon,
},
{
name: "code",
isActive: () => props.editor!.isActive("code"),
command: () => props.editor!.chain().focus().toggleCode().run(),
icon: CodeIcon,
},
];
const bubbleMenuProps: EditorBubbleMenuProps = {
...props,
shouldShow: ({ state, editor }) => {
const { selection } = state;
const { empty } = selection;
// don't show bubble menu if:
// - the selected node is an image
// - the selection is empty
// - the selection is a node selection (for drag handles)
if (editor.isActive("image") || empty || isNodeSelection(selection)) {
return false;
}
return true;
},
tippyOptions: {
moveTransition: "transform 0.15s ease-out",
onHidden: () => {
setIsNodeSelectorOpen(false);
setIsColorSelectorOpen(false);
setIsLinkSelectorOpen(false);
setIsTableSelectorOpen(false);
setIsAISelectorOpen(false);
setIsTranslateSelectorOpen(false);
},
},
};
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
const [isTableSelectorOpen, setIsTableSelectorOpen] = useState(false);
const [isAISelectorOpen, setIsAISelectorOpen] = useState(false);
const [isTranslateSelectorOpen, setIsTranslateSelectorOpen] = useState(false);
return (
<BubbleMenu
{...bubbleMenuProps}
className="novel-flex novel-w-fit novel-max-w-[97vw] novel-overflow-x-auto novel-divide-x novel-divide-stone-200 novel-rounded novel-border novel-border-stone-200 novel-bg-white novel-shadow-xl">
{props.editor && (
<>
<AISelector
editor={props.editor}
isOpen={isAISelectorOpen}
setIsOpen={() => {
setIsAISelectorOpen(!isAISelectorOpen);
setIsNodeSelectorOpen(false);
setIsColorSelectorOpen(false);
setIsTableSelectorOpen(false);
setIsLinkSelectorOpen(false);
setIsTranslateSelectorOpen(false);
}}
/>
<NodeSelector
editor={props.editor}
isOpen={isNodeSelectorOpen}
setIsOpen={() => {
setIsNodeSelectorOpen(!isNodeSelectorOpen);
setIsColorSelectorOpen(false);
setIsTableSelectorOpen(false);
setIsLinkSelectorOpen(false);
setIsAISelectorOpen(false);
setIsTranslateSelectorOpen(false);
}}
/>
{props.editor.isActive("table") && (
<TableSelector
editor={props.editor}
isOpen={isTableSelectorOpen}
setIsOpen={() => {
setIsTableSelectorOpen(!isTableSelectorOpen);
setIsColorSelectorOpen(false);
setIsNodeSelectorOpen(false);
setIsLinkSelectorOpen(false);
setIsAISelectorOpen(false);
setIsTranslateSelectorOpen(false);
}}
/>
)}
<LinkSelector
editor={props.editor}
isOpen={isLinkSelectorOpen}
setIsOpen={() => {
setIsLinkSelectorOpen(!isLinkSelectorOpen);
setIsColorSelectorOpen(false);
setIsTableSelectorOpen(false);
setIsNodeSelectorOpen(false);
setIsAISelectorOpen(false);
setIsTranslateSelectorOpen(false);
}}
/>
<div className="novel-flex">
{items.map((item, index) => (
<button
key={index}
onClick={item.command}
className="novel-p-2 novel-text-stone-600 hover:novel-bg-stone-100 active:novel-bg-stone-200"
type="button">
<item.icon
className={cn("novel-h-4 novel-w-4", {
"novel-text-blue-500": item.isActive(),
})}
/>
</button>
))}
</div>
<ColorSelector
editor={props.editor}
isOpen={isColorSelectorOpen}
setIsOpen={() => {
setIsColorSelectorOpen(!isColorSelectorOpen);
setIsTableSelectorOpen(false);
setIsNodeSelectorOpen(false);
setIsLinkSelectorOpen(false);
setIsAISelectorOpen(false);
setIsTranslateSelectorOpen(false);
}}
/>
<TranslateSelector
editor={props.editor}
isOpen={isTranslateSelectorOpen}
setIsOpen={() => {
setIsTranslateSelectorOpen(!isTranslateSelectorOpen);
setIsAISelectorOpen(false);
setIsNodeSelectorOpen(false);
setIsColorSelectorOpen(false);
setIsTableSelectorOpen(false);
setIsLinkSelectorOpen(false);
}}
/>
</>
)}
</BubbleMenu>
);
};

View File

@ -0,0 +1,83 @@
import { cn, getUrlFromString } from "@/lib/utils";
import { Editor } from "@tiptap/core";
import { Check, Trash } from "lucide-react";
import { Dispatch, FC, SetStateAction, useEffect, useRef } from "react";
interface LinkSelectorProps {
editor: Editor;
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
}
export const LinkSelector: FC<LinkSelectorProps> = ({
editor,
isOpen,
setIsOpen,
}) => {
const inputRef = useRef<HTMLInputElement>(null);
// Autofocus on input by default
useEffect(() => {
inputRef.current && inputRef.current?.focus();
});
return (
<div className="novel-relative">
<button
type="button"
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);
}}
>
<p className="novel-text-base"></p>
<p
className={cn(
"novel-underline novel-decoration-stone-400 novel-underline-offset-4",
{
"novel-text-blue-500": editor.isActive("link"),
}
)}
>
Link
</p>
</button>
{isOpen && (
<form
onSubmit={(e) => {
e.preventDefault();
const input = e.currentTarget[0] as HTMLInputElement;
const url = getUrlFromString(input.value);
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"
>
<input
ref={inputRef}
type="text"
placeholder="Paste a link"
className="novel-flex-1 novel-bg-white novel-p-1 novel-text-sm novel-outline-none"
defaultValue={editor.getAttributes("link").href || ""}
/>
{editor.getAttributes("link").href ? (
<button
type="button"
className="novel-flex novel-items-center novel-rounded-sm novel-p-1 novel-text-red-600 novel-transition-all hover:novel-bg-red-100 dark:hover:novel-bg-red-800"
onClick={() => {
editor.chain().focus().unsetLink().run();
setIsOpen(false);
}}
>
<Trash className="novel-h-4 novel-w-4" />
</button>
) : (
<button className="novel-flex novel-items-center novel-rounded-sm novel-p-1 novel-text-stone-600 novel-transition-all hover:novel-bg-stone-100">
<Check className="novel-h-4 novel-w-4" />
</button>
)}
</form>
)}
</div>
);
};

View File

@ -0,0 +1,140 @@
import { Editor } from "@tiptap/core";
import {
Check,
ChevronDown,
Heading1,
Heading2,
Heading3,
Heading4,
TextQuote,
ListOrdered,
TextIcon,
Code,
CheckSquare,
} from "lucide-react";
import * as Popover from "@radix-ui/react-popover";
import { Dispatch, FC, SetStateAction } from "react";
import { BubbleMenuItem } from ".";
interface NodeSelectorProps {
editor: Editor;
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
}
export const NodeSelector: FC<NodeSelectorProps> = ({
editor,
isOpen,
setIsOpen,
}) => {
const items: BubbleMenuItem[] = [
{
name: "Text",
icon: TextIcon,
command: () =>
editor.chain().focus().toggleNode("paragraph", "paragraph").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.isActive("paragraph") &&
!editor.isActive("bulletList") &&
!editor.isActive("orderedList"),
},
{
name: "Heading 1",
icon: Heading1,
command: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
isActive: () => editor.isActive("heading", { level: 1 }),
},
{
name: "Heading 2",
icon: Heading2,
command: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
isActive: () => editor.isActive("heading", { level: 2 }),
},
{
name: "Heading 3",
icon: Heading3,
command: () => editor.chain().focus().toggleHeading({ level: 3 }).run(),
isActive: () => editor.isActive("heading", { level: 3 }),
},
{
name: "To-do List",
icon: CheckSquare,
command: () => editor.chain().focus().toggleTaskList().run(),
isActive: () => editor.isActive("taskItem"),
},
{
name: "Bullet List",
icon: ListOrdered,
command: () => editor.chain().focus().toggleBulletList().run(),
isActive: () => editor.isActive("bulletList"),
},
{
name: "Numbered List",
icon: ListOrdered,
command: () => editor.chain().focus().toggleOrderedList().run(),
isActive: () => editor.isActive("orderedList"),
},
{
name: "Quote",
icon: TextQuote,
command: () =>
editor
.chain()
.focus()
.toggleNode("paragraph", "paragraph")
.toggleBlockquote()
.run(),
isActive: () => editor.isActive("blockquote"),
},
{
name: "Code",
icon: Code,
command: () => editor.chain().focus().toggleCodeBlock().run(),
isActive: () => editor.isActive("codeBlock"),
},
];
const activeItem = items.filter((item) => item.isActive()).pop() ?? {
name: "Multiple",
};
return (
<Popover.Root open={isOpen}>
<div className="novel-relative novel-h-full">
<Popover.Trigger
className="novel-flex novel-h-full novel-items-center novel-gap-1 novel-whitespace-nowrap novel-p-2 novel-text-sm novel-font-medium novel-text-stone-600 hover:novel-bg-stone-100 active:novel-bg-stone-200"
onClick={() => setIsOpen(!isOpen)}>
<span>{activeItem?.name}</span>
<ChevronDown className="h-4 w-4" />
</Popover.Trigger>
<Popover.Content
align="start"
className="novel-z-[99999] novel-my-1 novel-flex novel-max-h-80 novel-w-48 novel-flex-col novel-overflow-hidden novel-overflow-y-auto 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">
{items.map((item, index) => (
<button
key={index}
onClick={() => {
item.command();
setIsOpen(false);
}}
className="novel-flex novel-items-center novel-justify-between novel-rounded-sm novel-px-2 novel-py-1 novel-text-sm novel-text-stone-600 hover:novel-bg-stone-100"
type="button">
<div className="flex items-center space-x-2">
<div className="novel-flex novel-items-center novel-space-x-2">
{" "}
<item.icon className="novel-h-3 novel-w-3" />
</div>
<span>{item.name}</span>
</div>
{activeItem.name === item.name && (
<Check className="novel-h-4 novel-w-4" />
)}
</button>
))}
</Popover.Content>
</div>
</Popover.Root>
);
};

View File

@ -0,0 +1,163 @@
import { Editor } from "@tiptap/core";
import {
ChevronDown,
LucideIcon,
Rows,
PanelTop,
PanelBottom,
PanelRight,
PanelLeft,
Trash2,
SplitSquareHorizontal,
Heading1,
} from "lucide-react";
import { Dispatch, FC, SetStateAction } from "react";
import * as Popover from "@radix-ui/react-popover";
export interface BubbleTableMenuItem {
name: string;
icon: LucideIcon;
action: () => boolean;
}
interface TableSelectorProps {
editor: Editor;
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
}
const TABLE_COLUMN_CMDS = (editor: Editor): BubbleTableMenuItem[] => {
return [
{
name: "Add column before",
icon: PanelLeft,
action: () => editor.chain().focus().addColumnBefore().run(),
},
{
name: "Add column after",
icon: PanelRight,
action: () => editor.chain().focus().addColumnAfter().run(),
},
{
name: "Delete column",
icon: Trash2,
action: () => editor.chain().focus().deleteColumn().run(),
},
];
};
const TABLE_ROW_CMDS = (editor: Editor): BubbleTableMenuItem[] => {
return [
{
name: "Add row before",
icon: PanelTop,
action: () => editor.chain().focus().addRowBefore().run(),
},
{
name: "Add row after",
icon: PanelBottom,
action: () => editor.chain().focus().addRowAfter().run(),
},
{
name: "Delete row",
icon: Trash2,
action: () => editor.chain().focus().deleteRow().run(),
},
];
};
const TABLE_CELL_CMDS = (editor: Editor): BubbleTableMenuItem[] => {
return [
{
name: "Merge or split",
icon: SplitSquareHorizontal,
action: () => editor.chain().focus().mergeOrSplit().run(),
},
{
name: "Toggle header cell",
icon: Heading1,
action: () => editor.chain().focus().toggleHeaderCell().run(),
},
{
name: "Delete table",
icon: Trash2,
action: () => editor.chain().focus().deleteTable().run(),
},
];
};
export const TableSelector: FC<TableSelectorProps> = ({
editor,
isOpen,
setIsOpen,
}) => {
return (
<Popover.Root open={isOpen}>
<div className="novel-relative novel-h-full">
<Popover.Trigger
className="novel-flex novel-h-full novel-items-center novel-gap-1 novel-p-2 novel-text-sm novel-font-medium novel-text-stone-600 hover:novel-bg-stone-100 active:novel-bg-stone-200"
onClick={() => setIsOpen(!isOpen)}>
<span className="novel-rounded-sm novel-px-1">Table</span>
<ChevronDown className="novel-h-4 novel-w-4" />
</Popover.Trigger>
<Popover.Content
align="start"
className="novel-z-[99999] novel-my-1 novel-flex novel-max-h-80 novel-w-48 novel-flex-col novel-overflow-hidden novel-overflow-y-auto 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">
<div className="novel-my-1 novel-px-2 novel-text-sm novel-text-stone-500">
Column
</div>
{TABLE_COLUMN_CMDS(editor).map((item, index) => (
<button
key={index}
onClick={() => item.action()}
className="novel-flex novel-items-center novel-justify-between novel-rounded-sm novel-px-2 novel-py-1 novel-text-sm novel-text-stone-600 hover:novel-bg-stone-100"
type="button">
<div className="novel-flex novel-items-center novel-space-x-2">
<div className="novel-rounded-sm novel-px-1 novel-py-px novel-font-medium">
<item.icon className="w-4 h-4 novel-text-slate-400" />
</div>
<span>{item.name}</span>
</div>
</button>
))}
<div className="novel-my-1 novel-px-2 novel-text-sm novel-text-stone-500">
Row
</div>
{TABLE_ROW_CMDS(editor).map((item, index) => (
<button
key={index}
onClick={() => item.action()}
className="novel-flex novel-items-center novel-justify-between novel-rounded-sm novel-px-2 novel-py-1 novel-text-sm novel-text-stone-600 hover:novel-bg-stone-100"
type="button">
<div className="novel-flex novel-items-center novel-space-x-2">
<div className="novel-rounded-sm novel-px-1 novel-py-px novel-font-medium">
<item.icon className="w-4 h-4 novel-text-slate-400" />
</div>
<span>{item.name}</span>
</div>
</button>
))}
<div className="novel-my-1 novel-px-2 novel-text-sm novel-text-stone-500">
Cell
</div>
{TABLE_CELL_CMDS(editor).map((item, index) => (
<button
key={index}
onClick={() => item.action()}
className="novel-flex novel-items-center novel-justify-between novel-rounded-sm novel-px-2 novel-py-1 novel-text-sm novel-text-stone-600 hover:novel-bg-stone-100"
type="button">
<div className="novel-flex novel-items-center novel-space-x-2">
<div className="novel-rounded-sm novel-px-1 novel-py-px novel-font-medium">
<item.icon className="w-4 h-4 novel-text-slate-400" />
</div>
<span>{item.name}</span>
</div>
</button>
))}
</Popover.Content>
</div>
</Popover.Root>
);
};

View File

@ -0,0 +1,10 @@
export const defaultEditorContent = {
type: "doc",
content: [
{
type: "heading",
attrs: { level: 2 },
content: [{ type: "text", text: "What's New" }],
},
],
};

View File

@ -0,0 +1,56 @@
import { Extension } from "@tiptap/core";
declare module "@tiptap/core" {
// eslint-disable-next-line no-unused-vars
interface Commands<ReturnType> {
customkeymap: {
/**
* Select text between node boundaries
*/
selectTextWithinNodeBoundaries: () => ReturnType;
};
}
}
const CustomKeymap = Extension.create({
name: "CustomKeymap",
addCommands() {
return {
selectTextWithinNodeBoundaries:
() =>
({ editor, commands }) => {
const { state } = editor;
const { tr } = state;
const startNodePos = tr.selection.$from.start();
const endNodePos = tr.selection.$to.end();
return commands.setTextSelection({
from: startNodePos,
to: endNodePos,
});
},
};
},
addKeyboardShortcuts() {
return {
"Mod-a": ({ editor }) => {
const { state } = editor;
const { tr } = state;
const startSelectionPos = tr.selection.from;
const endSelectionPos = tr.selection.to;
const startNodePos = tr.selection.$from.start();
const endNodePos = tr.selection.$to.end();
const isCurrentTextSelectionNotExtendedToNodeBoundaries =
startSelectionPos > startNodePos || endSelectionPos < endNodePos;
if (isCurrentTextSelectionNotExtendedToNodeBoundaries) {
editor.chain().selectTextWithinNodeBoundaries().run();
return true;
}
return false;
},
};
},
});
export default CustomKeymap;

View File

@ -0,0 +1,213 @@
import { Extension } from "@tiptap/core";
import { NodeSelection, Plugin } from "@tiptap/pm/state";
// @ts-ignore
import { __serializeForClipboard, EditorView } from "@tiptap/pm/view";
export interface DragHandleOptions {
/**
* The width of the drag handle
*/
dragHandleWidth: number;
}
function absoluteRect(node: Element) {
const data = node.getBoundingClientRect();
return {
top: data.top,
left: data.left,
width: data.width,
};
}
function nodeDOMAtCoords(coords: { x: number; y: number }) {
return document
.elementsFromPoint(coords.x, coords.y)
.find(
(elem: Element) =>
elem.parentElement?.matches?.(".ProseMirror") ||
elem.matches(
[
"li",
"p:not(:first-child)",
"pre",
"blockquote",
"h1, h2, h3, h4, h5, h6",
].join(", ")
)
);
}
function nodePosAtDOM(node: Element, view: EditorView) {
const boundingRect = node.getBoundingClientRect();
return view.posAtCoords({
left: boundingRect.left + 1,
top: boundingRect.top + 1,
})?.inside;
}
function DragHandle(options: DragHandleOptions) {
function handleDragStart(event: DragEvent, view: EditorView) {
view.focus();
if (!event.dataTransfer) return;
const node = nodeDOMAtCoords({
x: event.clientX + 50 + options.dragHandleWidth,
y: event.clientY,
});
if (!(node instanceof Element)) return;
const nodePos = nodePosAtDOM(node, view);
if (nodePos == null || nodePos < 0) return;
view.dispatch(
view.state.tr.setSelection(NodeSelection.create(view.state.doc, nodePos))
);
const slice = view.state.selection.content();
const { dom, text } = __serializeForClipboard(view, slice);
event.dataTransfer.clearData();
event.dataTransfer.setData("text/html", dom.innerHTML);
event.dataTransfer.setData("text/plain", text);
event.dataTransfer.effectAllowed = "copyMove";
event.dataTransfer.setDragImage(node, 0, 0);
view.dragging = { slice, move: event.ctrlKey };
}
function handleClick(event: MouseEvent, view: EditorView) {
view.focus();
view.dom.classList.remove("dragging");
const node = nodeDOMAtCoords({
x: event.clientX + 50 + options.dragHandleWidth,
y: event.clientY,
});
if (!(node instanceof Element)) return;
const nodePos = nodePosAtDOM(node, view);
if (!nodePos) return;
view.dispatch(
view.state.tr.setSelection(NodeSelection.create(view.state.doc, nodePos))
);
}
let dragHandleElement: HTMLElement | null = null;
function hideDragHandle() {
if (dragHandleElement) {
dragHandleElement.classList.add("hidden");
}
}
function showDragHandle() {
if (dragHandleElement) {
dragHandleElement.classList.remove("hidden");
}
}
return new Plugin({
view: (view) => {
dragHandleElement = document.createElement("div");
dragHandleElement.draggable = true;
dragHandleElement.dataset.dragHandle = "";
dragHandleElement.classList.add("drag-handle");
dragHandleElement.addEventListener("dragstart", (e) => {
handleDragStart(e, view);
});
dragHandleElement.addEventListener("click", (e) => {
handleClick(e, view);
});
hideDragHandle();
view?.dom?.parentElement?.appendChild(dragHandleElement);
return {
destroy: () => {
dragHandleElement?.remove?.();
dragHandleElement = null;
},
};
},
props: {
handleDOMEvents: {
mousemove: (view, event) => {
if (!view.editable) {
return;
}
const node = nodeDOMAtCoords({
x: event.clientX + 50 + options.dragHandleWidth,
y: event.clientY,
});
if (!(node instanceof Element) || node.matches("ul, ol")) {
hideDragHandle();
return;
}
const compStyle = window.getComputedStyle(node);
const lineHeight = parseInt(compStyle.lineHeight, 10);
const paddingTop = parseInt(compStyle.paddingTop, 10);
const rect = absoluteRect(node);
rect.top += (lineHeight - 24) / 2;
rect.top += paddingTop;
// Li markers
if (node.matches("ul:not([data-type=taskList]) li, ol li")) {
rect.left -= options.dragHandleWidth;
}
rect.width = options.dragHandleWidth;
if (!dragHandleElement) return;
dragHandleElement.style.left = `${rect.left - rect.width}px`;
dragHandleElement.style.top = `${rect.top}px`;
showDragHandle();
},
keydown: () => {
hideDragHandle();
},
mousewheel: () => {
hideDragHandle();
},
// dragging class is used for CSS
dragstart: (view) => {
view.dom.classList.add("dragging");
},
drop: (view) => {
view.dom.classList.remove("dragging");
},
dragend: (view) => {
view.dom.classList.remove("dragging");
},
},
},
});
}
interface DragAndDropOptions {}
const DragAndDrop = Extension.create<DragAndDropOptions>({
name: "dragAndDrop",
addProseMirrorPlugins() {
return [
DragHandle({
dragHandleWidth: 24,
}),
];
},
});
export default DragAndDrop;

View File

@ -0,0 +1,51 @@
import { Extension, textInputRule } from "@tiptap/core";
const Glyphs = Extension.create({
name: "Glyphs",
addInputRules() {
return [
// Emoji Shortcodes
textInputRule({ find: /:heart:$/, replace: "❤️ " }),
textInputRule({ find: /:heart_hands:$/, replace: "🫶 " }),
textInputRule({ find: /:sparkles:$/, replace: "✨ " }),
textInputRule({ find: /:party:$/, replace: "🎉 " }),
textInputRule({ find: /:fire:$/, replace: "🔥 " }),
textInputRule({ find: /:100:$/, replace: "💯 " }),
textInputRule({ find: /:poop:$/, replace: "💩 " }),
textInputRule({ find: /:eyes:$/, replace: "👀 " }),
textInputRule({ find: /:ghost:$/, replace: "👻 " }),
textInputRule({ find: /:graduation_cap:$/, replace: "🎓 " }),
textInputRule({ find: /:thumbsup:$/, replace: "👍 " }),
textInputRule({ find: /:thumbsdown:$/, replace: "👎 " }),
textInputRule({ find: /:rocket:$/, replace: "🚀 " }),
textInputRule({ find: /:salute:$/, replace: "👋 " }),
textInputRule({ find: /:grinning_face:$/, replace: "😀 " }),
textInputRule({ find: /:sweat_smile:$/, replace: "😅 " }),
textInputRule({ find: /:droplet:$/, replace: "💧 " }),
textInputRule({ find: /:starstruck:$/, replace: "🤩 " }),
textInputRule({ find: /:sob:$/, replace: "😭 " }),
textInputRule({ find: /:skull:$/, replace: "💀 " }),
textInputRule({ find: /:smile:$/, replace: "😄 " }),
textInputRule({ find: /:rofl:$/, replace: "🤣 " }),
textInputRule({ find: /:wink:$/, replace: "😉 " }),
textInputRule({ find: /:candle:$/, replace: "🕯️ " }),
textInputRule({ find: /:diya_lamp:$/, replace: "🪔 " }),
textInputRule({ find: /:rainbow:$/, replace: "🌈 " }),
textInputRule({ find: /:om:$/, replace: "🕉️ " }),
textInputRule({ find: /:bulb:$/, replace: "💡 " }),
textInputRule({ find: /:dizzy:$/, replace: "💫 " }),
textInputRule({ find: /:bomb:$/, replace: "💣 " }),
textInputRule({ find: /:firecracker:$/, replace: "🧨 " }),
textInputRule({ find: /:fireworks:$/, replace: "🎆 " }),
textInputRule({ find: /:alien:$/, replace: "👽 " }),
textInputRule({ find: /:robot:$/, replace: "🤖 " }),
textInputRule({ find: /:crystal_ball:$/, replace: "🔮 " }),
textInputRule({ find: /:chocolate_bar:$/, replace: "🍫 " }),
textInputRule({ find: /:unicorn:$/, replace: "🦄 " }),
textInputRule({ find: /:clown_face:$/, replace: "🤡 " }),
];
},
});
export default Glyphs;

View File

@ -0,0 +1,71 @@
import Moveable from "react-moveable";
export const ImageResizer = ({ editor }) => {
const updateMediaSize = () => {
const imageInfo = document.querySelector(
".ProseMirror-selectednode"
) as HTMLImageElement;
if (imageInfo) {
const selection = editor.state.selection;
editor.commands.setImage({
src: imageInfo.src,
width: Number(imageInfo.style.width.replace("px", "")),
height: Number(imageInfo.style.height.replace("px", "")),
});
editor.commands.setNodeSelection(selection.from);
}
};
return (
<>
<Moveable
target={document.querySelector(".ProseMirror-selectednode") as any}
container={null}
origin={false}
/* Resize event edges */
edge={false}
throttleDrag={0}
/* When resize or scale, keeps a ratio of the width, height. */
keepRatio={true}
/* resizable*/
/* Only one of resizable, scalable, warpable can be used. */
resizable={true}
throttleResize={0}
onResize={({
target,
width,
height,
// dist,
delta,
}: // direction,
// clientX,
// clientY,
any) => {
delta[0] && (target!.style.width = `${width}px`);
delta[1] && (target!.style.height = `${height}px`);
}}
// { target, isDrag, clientX, clientY }: any
onResizeEnd={() => {
updateMediaSize();
}}
/* scalable */
/* Only one of resizable, scalable, warpable can be used. */
scalable={true}
throttleScale={0}
/* Set the direction of resizable */
renderDirections={["w", "e"]}
onScale={({
target,
// scale,
// dist,
// delta,
transform,
}: // clientX,
// clientY,
any) => {
target!.style.transform = transform;
}}
/>
</>
);
};

View File

@ -0,0 +1,179 @@
import StarterKit from "@tiptap/starter-kit";
import HorizontalRule from "@tiptap/extension-horizontal-rule";
import TiptapLink from "@tiptap/extension-link";
import TiptapImage from "@tiptap/extension-image";
import Placeholder from "@tiptap/extension-placeholder";
import TiptapUnderline from "@tiptap/extension-underline";
import TextStyle from "@tiptap/extension-text-style";
import { Color } from "@tiptap/extension-color";
import TaskItem from "@tiptap/extension-task-item";
import TaskList from "@tiptap/extension-task-list";
import { Markdown } from "tiptap-markdown";
import Highlight from "@tiptap/extension-highlight";
import Typography from "@tiptap/extension-typography";
import SlashCommand from "./slash-command";
import { InputRule } from "@tiptap/core";
import UploadImagesPlugin from "@/ui/editor/plugins/upload-images";
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
import { createLowlight, common } from "lowlight";
import markdown from "highlight.js/lib/languages/markdown";
import css from "highlight.js/lib/languages/css";
import js from "highlight.js/lib/languages/javascript";
import ts from "highlight.js/lib/languages/typescript";
import html from "highlight.js/lib/languages/xml";
import Table from "@tiptap/extension-table";
import TableCell from "@tiptap/extension-table-cell";
import TableHeader from "@tiptap/extension-table-header";
import TableRow from "@tiptap/extension-table-row";
import Youtube from "@tiptap/extension-youtube";
import UpdatedImage from "./updated-image";
import CustomKeymap from "./custom-keymap";
import DragAndDrop from "./drag-and-drop";
import Glyphs from "./glyphs";
const lowlight = createLowlight(common);
lowlight.register({ markdown });
lowlight.register({ html });
lowlight.register({ css });
lowlight.register({ js });
lowlight.register({ ts });
export const defaultExtensions = [
StarterKit.configure({
heading: {
levels: [1, 2, 3],
},
bulletList: {
HTMLAttributes: {
class: "novel-list-disc novel-list-outside novel-leading-3 novel--mt-2",
},
},
orderedList: {
HTMLAttributes: {
class:
"novel-list-decimal novel-list-outside novel-leading-3 novel--mt-2",
},
},
listItem: {
HTMLAttributes: {
class: "novel-leading-normal novel--mb-2",
},
},
blockquote: {
HTMLAttributes: {
class: "novel-border-l-4 novel-border-stone-700",
},
},
code: {
HTMLAttributes: {
class:
"novel-rounded-md novel-bg-stone-200 novel-px-1.5 novel-py-1 novel-font-mono novel-font-medium novel-text-stone-900",
spellcheck: "false",
},
},
horizontalRule: false,
dropcursor: {
color: "#DBEAFE",
width: 4,
},
gapcursor: false,
}),
// patch to fix horizontal rule bug: https://github.com/ueberdosis/tiptap/pull/3859#issuecomment-1536799740
HorizontalRule.extend({
addInputRules() {
return [
new InputRule({
find: /^(?:---|—-|___\s|\*\*\*\s)$/,
handler: ({ state, range }) => {
const attributes = {};
const { tr } = state;
const start = range.from;
let end = range.to;
tr.insert(start - 1, this.type.create(attributes)).delete(
tr.mapping.map(start),
tr.mapping.map(end)
);
},
}),
];
},
}).configure({
HTMLAttributes: {
class: "novel-mt-4 novel-mb-6 novel-border-t novel-border-stone-300",
},
}),
TiptapLink.configure({
HTMLAttributes: {
class:
"novel-text-stone-400 novel-underline novel-underline-offset-[3px] hover:novel-text-stone-600 novel-transition-colors novel-cursor-pointer",
},
}),
TiptapImage.extend({
addProseMirrorPlugins() {
return [UploadImagesPlugin()];
},
}).configure({
allowBase64: true,
HTMLAttributes: {
class: "novel-rounded-lg novel-border novel-border-stone-200",
},
}),
UpdatedImage.configure({
HTMLAttributes: {
class: "novel-rounded-lg novel-border novel-border-stone-200",
},
}),
Placeholder.configure({
placeholder: ({ node }) => {
if (node.type.name === "heading") {
return `Heading ${node.attrs.level}`;
}
return "Press '/' for commands, or '??' for AI autocomplete...";
},
includeChildren: true,
}),
SlashCommand,
TiptapUnderline,
TextStyle,
Color,
Highlight.configure({
multicolor: true,
}),
TaskList.configure({
HTMLAttributes: {
class: "novel-not-prose novel-pl-2",
},
}),
TaskItem.configure({
HTMLAttributes: {
class: "novel-flex novel-items-start novel-my-4",
},
nested: true,
}),
Markdown.configure({
html: true,
transformCopiedText: true,
transformPastedText: true,
}),
CodeBlockLowlight.configure({
lowlight,
}),
Table.configure({
resizable: true,
allowTableNodeSelection: true,
}),
Youtube.configure({
origin: "inke.app",
controls: true,
inline: false,
}),
TableRow,
TableHeader,
TableCell,
Typography,
CustomKeymap,
DragAndDrop,
Glyphs,
];

View File

@ -0,0 +1,485 @@
import React, {
useState,
useEffect,
useCallback,
ReactNode,
useRef,
useLayoutEffect,
useContext,
} from "react";
import { Editor, Range, Extension } from "@tiptap/core";
import Suggestion from "@tiptap/suggestion";
import { ReactRenderer } from "@tiptap/react";
import { useCompletion } from "ai/react";
import tippy from "tippy.js";
import {
Heading1,
Heading2,
Heading3,
List,
ListOrdered,
MessageSquarePlus,
Text,
TextQuote,
Image as ImageIcon,
Code,
CheckSquare,
Table2,
PauseCircle,
} from "lucide-react";
import { LoadingCircle } from "@/ui/icons";
import { toast } from "sonner";
import va from "@vercel/analytics";
import { Magic } from "@/ui/icons";
import { getPrevText } from "@/lib/editor";
import { startImageUpload } from "@/ui/editor/plugins/upload-images";
import { NovelContext } from "../provider";
import { Youtube } from "lucide-react";
interface CommandItemProps {
title: string;
description: string;
icon: ReactNode;
}
interface CommandProps {
editor: Editor;
range: Range;
}
const Command = Extension.create({
name: "slash-command",
addOptions() {
return {
suggestion: {
char: "/",
command: ({
editor,
range,
props,
}: {
editor: Editor;
range: Range;
props: any;
}) => {
props.command({ editor, range });
},
},
};
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion,
}),
];
},
});
const getSuggestionItems = ({
query,
plan,
}: {
query: string;
plan: number;
}) => {
return [
{
title: "Continue writing",
description: "Use AI to expand your thoughts.",
searchTerms: ["gpt"],
icon: <Magic className="novel-w-7" />,
},
{
title: "Text",
description: "Just start typing with plain text.",
searchTerms: ["p", "paragraph"],
icon: <Text size={18} />,
command: ({ editor, range }: CommandProps) => {
editor
.chain()
.focus()
.deleteRange(range)
.toggleNode("paragraph", "paragraph")
.run();
},
},
{
title: "To-do List",
description: "Track tasks with a to-do list.",
searchTerms: ["todo", "task", "list", "check", "checkbox"],
icon: <CheckSquare size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).toggleTaskList().run();
},
},
{
title: "Heading 1",
description: "Big section heading.",
searchTerms: ["title", "big", "large"],
icon: <Heading1 size={18} />,
command: ({ editor, range }: CommandProps) => {
editor
.chain()
.focus()
.deleteRange(range)
.setNode("heading", { level: 1 })
.run();
},
},
{
title: "Heading 2",
description: "Medium section heading.",
searchTerms: ["subtitle", "medium"],
icon: <Heading2 size={18} />,
command: ({ editor, range }: CommandProps) => {
editor
.chain()
.focus()
.deleteRange(range)
.setNode("heading", { level: 2 })
.run();
},
},
{
title: "Heading 3",
description: "Small section heading.",
searchTerms: ["subtitle", "small"],
icon: <Heading3 size={18} />,
command: ({ editor, range }: CommandProps) => {
editor
.chain()
.focus()
.deleteRange(range)
.setNode("heading", { level: 3 })
.run();
},
},
{
title: "Bullet List",
description: "Create a simple bullet list.",
searchTerms: ["unordered", "point"],
icon: <List size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).toggleBulletList().run();
},
},
{
title: "Numbered List",
description: "Create a list with numbering.",
searchTerms: ["ordered"],
icon: <ListOrdered size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).toggleOrderedList().run();
},
},
{
title: "Quote",
description: "Capture a quote.",
searchTerms: ["blockquote"],
icon: <TextQuote size={18} />,
command: ({ editor, range }: CommandProps) =>
editor
.chain()
.focus()
.deleteRange(range)
.toggleNode("paragraph", "paragraph")
.toggleBlockquote()
.run(),
},
{
title: "Code",
description: "Capture a code snippet.",
searchTerms: ["codeblock"],
icon: <Code size={18} />,
command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
},
{
title: "Table",
description: "Create a 2x2 table.",
searchTerms: ["table", "cell", "row"],
icon: <Table2 size={18} />,
command: ({ editor, range }: CommandProps) => {
editor
.chain()
.focus()
.deleteRange(range)
.insertTable({ rows: 2, cols: 2, withHeaderRow: true })
.run();
},
},
{
title: "Image",
description: "Upload an image from your computer.",
searchTerms: ["photo", "picture", "media"],
icon: <ImageIcon size={18} />,
command: ({ editor, range }: CommandProps) => {
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;
startImageUpload(file, editor.view, pos);
}
};
input.click();
},
},
{
title: "Youtube video",
description: "Play the Youtube video you filled out.",
searchTerms: ["video", "ytb", "Youtube", "youtube"],
icon: <Youtube size={19} />,
command: ({ editor, range }: CommandProps) => {
const url = prompt(
"Enter YouTube URL",
"https://www.youtube.com/watch?v="
);
if (url) {
editor
.chain()
.focus()
.deleteRange(range)
.setYoutubeVideo({
src: url,
})
.run();
}
},
},
].filter((item) => {
if (typeof query === "string" && query.length > 0) {
const search = query.toLowerCase();
return (
item.title.toLowerCase().includes(search) ||
item.description.toLowerCase().includes(search) ||
(item.searchTerms &&
item.searchTerms.some((term: string) => term.includes(search)))
);
}
return true;
});
};
export const updateScrollView = (container: HTMLElement, item: HTMLElement) => {
const containerHeight = container.offsetHeight;
const itemHeight = item ? item.offsetHeight : 0;
const top = item.offsetTop;
const bottom = top + itemHeight;
if (top < container.scrollTop) {
container.scrollTop -= container.scrollTop - top + 5;
} else if (bottom > containerHeight + container.scrollTop) {
container.scrollTop += bottom - containerHeight - container.scrollTop + 5;
}
};
const CommandList = ({
items,
command,
editor,
range,
}: {
items: CommandItemProps[];
command: any;
editor: any;
range: any;
}) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const { completionApi, plan } = useContext(NovelContext);
const { complete, isLoading, stop } = useCompletion({
id: "ai-continue",
api: `${completionApi}/continue`,
body: { plan },
onResponse: (response) => {
if (response.status === 429) {
toast.error("You have reached your request limit for the day.");
va.track("Rate Limit Reached");
return;
}
editor.chain().focus().deleteRange(range).run();
},
onFinish: (_prompt, completion) => {
// highlight the generated text
editor.commands.setTextSelection({
from: range.from,
to: range.from + completion.length,
});
},
onError: (e) => {
if (e.message !== "Failed to fetch") {
toast.error(e.message);
}
},
});
const selectItem = useCallback(
(index: number) => {
const item = items[index];
va.track("Slash Command Used", {
command: item.title,
});
if (item) {
if (item.title === "Continue writing") {
if (isLoading) return;
complete(
getPrevText(editor, {
chars: 5000,
offset: 1,
})
);
} else {
command(item);
}
}
},
[complete, isLoading, command, editor, items]
);
useEffect(() => {
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"];
const onKeyDown = (e: KeyboardEvent) => {
if (navigationKeys.includes(e.key)) {
e.preventDefault();
if (e.key === "ArrowUp") {
setSelectedIndex((selectedIndex + items.length - 1) % items.length);
return true;
}
if (e.key === "ArrowDown") {
setSelectedIndex((selectedIndex + 1) % items.length);
return true;
}
if (e.key === "Enter") {
selectItem(selectedIndex);
return true;
}
return false;
}
};
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("keydown", onKeyDown);
};
}, [items, selectedIndex, setSelectedIndex, selectItem]);
useEffect(() => {
setSelectedIndex(0);
}, [items]);
const commandListContainer = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const container = commandListContainer?.current;
const item = container?.children[selectedIndex] as HTMLElement;
if (item && container) updateScrollView(container, item);
}, [selectedIndex]);
return items.length > 0 ? (
<div
id="slash-command"
ref={commandListContainer}
className="novel-z-50 novel-h-auto novel-max-h-[330px] novel-w-72 novel-overflow-y-auto novel-rounded-md novel-border novel-border-stone-200 novel-bg-white novel-px-1 novel-py-2 novel-shadow-md novel-transition-all">
{items.map((item: CommandItemProps, index: number) => {
return (
<button
className={`novel-flex novel-w-full novel-items-center novel-space-x-2 novel-rounded-md novel-px-2 novel-py-1 novel-text-left novel-text-sm novel-text-stone-900 hover:novel-bg-stone-100 ${
index === selectedIndex
? "novel-bg-stone-100 novel-text-stone-900"
: ""
}`}
key={index}
onClick={() => selectItem(index)}>
<div className="novel-flex novel-h-10 novel-w-10 novel-items-center novel-justify-center novel-rounded-md novel-border novel-border-stone-200 novel-bg-white">
{item.title === "Continue writing" && isLoading ? (
<LoadingCircle />
) : (
item.icon
)}
</div>
<div>
<p className="novel-font-medium">{item.title}</p>
<p className="novel-text-xs novel-text-stone-500">
{item.description}
</p>
</div>
{item.title === "Continue writing" && isLoading && (
<div>
<PauseCircle
className="novel-h-5 novel-w-5 novel-text-stone-300 hover:novel-text-stone-500 novel-cursor-pointer"
onClick={stop}
/>
</div>
)}
</button>
);
})}
</div>
) : null;
};
const renderItems = () => {
let component: ReactRenderer | null = null;
let popup: any | null = null;
return {
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
component = new ReactRenderer(CommandList, {
props,
editor: props.editor,
});
// @ts-ignore
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
},
onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => {
component?.updateProps(props);
popup &&
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") {
popup?.[0].hide();
return true;
}
// @ts-ignore
return component?.ref?.onKeyDown(props);
},
onExit: () => {
popup?.[0].destroy();
component?.destroy();
},
};
};
const SlashCommand = Command.configure({
suggestion: {
items: getSuggestionItems,
render: renderItems,
},
});
export default SlashCommand;

View File

@ -0,0 +1,17 @@
import Image from "@tiptap/extension-image";
const UpdatedImage = Image.extend({
addAttributes() {
return {
...this.parent?.(),
width: {
default: null,
},
height: {
default: null,
},
};
},
});
export default UpdatedImage;

View File

@ -0,0 +1,223 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useEditor, EditorContent, JSONContent } from "@tiptap/react";
import { defaultEditorProps } from "./props";
import { defaultExtensions } from "./extensions";
import useLocalStorage from "@/lib/hooks/use-local-storage";
import { useDebouncedCallback } from "use-debounce";
import { useCompletion } from "ai/react";
import { toast } from "sonner";
import va from "@vercel/analytics";
import { defaultEditorContent } from "./default-content";
import { EditorBubbleMenu } from "./bubble-menu";
import { getPrevText } from "@/lib/editor";
import { ImageResizer } from "./extensions/image-resizer";
import { EditorProps } from "@tiptap/pm/view";
import { Editor as EditorClass, Extensions } from "@tiptap/core";
import { NovelContext } from "./provider";
import "./styles.css";
import AIEditorBubble from "./bubble-menu/ai-selectors/edit/ai-edit-bubble";
import AIGeneratingLoading from "./bubble-menu/ai-selectors/ai-loading";
import AITranslateBubble from "./bubble-menu/ai-selectors/translate/ai-translate-bubble";
export default function Editor({
completionApi = "/api/generate",
className = "novel-relative novel-min-h-[500px] novel-w-full novel-max-w-screen-lg novel-border-stone-200 novel-bg-white sm:novel-mb-[calc(20vh)] sm:novel-rounded-lg sm:novel-border sm:novel-shadow-lg",
defaultValue = defaultEditorContent,
extensions = [],
editorProps = {},
onUpdate = () => {},
onDebouncedUpdate = () => {},
debounceDuration = 750,
storageKey = "novel__content",
disableLocalStorage = false,
editable = true,
plan = "5",
}: {
/**
* The API route to use for the OpenAI completion API.
* Defaults to "/api/generate".
*/
completionApi?: string;
/**
* Additional classes to add to the editor container.
* Defaults to "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;
/**
* The default value to use for the editor.
* Defaults to defaultEditorContent.
*/
defaultValue?: JSONContent | string;
/**
* A list of extensions to use for the editor, in addition to the default inke extensions.
* Defaults to [].
*/
extensions?: Extensions;
/**
* Props to pass to the underlying Tiptap editor, in addition to the default inke editor props.
* Defaults to {}.
*/
editorProps?: EditorProps;
/**
* A callback function that is called whenever the editor is updated.
* Defaults to () => {}.
*/
// eslint-disable-next-line no-unused-vars
onUpdate?: (editor?: EditorClass) => void;
/**
* A callback function that is called whenever the editor is updated, but only after the defined debounce duration.
* Defaults to () => {}.
*/
// eslint-disable-next-line no-unused-vars
onDebouncedUpdate?: (
json: JSONContent,
text: string,
markdown: string,
editor: EditorClass
) => void;
/**
* The duration (in milliseconds) to debounce the onDebouncedUpdate callback.
* Defaults to 750.
*/
debounceDuration?: number;
/**
* The key to use for storing the editor's value in local storage.
* Defaults to "novel__content".
*/
storageKey?: string;
/**
* Disable local storage read/save.
* Defaults to false.
*/
disableLocalStorage?: boolean;
/**
* Enable editing.
* Defaults to true.
*/
editable?: boolean;
plan?: string;
}) {
const [content, setContent] = useLocalStorage(storageKey, defaultValue);
const [hydrated, setHydrated] = useState(false);
const [isLoadingOutside, setLoadingOutside] = useState(false);
const debouncedUpdates = useDebouncedCallback(async ({ editor }) => {
const json = editor.getJSON();
const text = editor.getText();
const markdown = editor.storage.markdown.getMarkdown();
onDebouncedUpdate(json, text, markdown, editor);
if (!disableLocalStorage) {
setContent(json);
}
}, debounceDuration);
const editor = useEditor({
extensions: [...defaultExtensions, ...extensions],
editorProps: {
...defaultEditorProps,
...editorProps,
},
editable: editable,
onUpdate: (e) => {
const selection = e.editor.state.selection;
const lastTwo = getPrevText(e.editor, {
chars: 2,
});
if (lastTwo === "??" && !isLoading) {
setLoadingOutside(true);
e.editor.commands.deleteRange({
from: selection.from - 2,
to: selection.from,
});
complete(
getPrevText(e.editor, {
chars: 5000,
})
);
va.track("Autocomplete Shortcut Used");
} else {
onUpdate(e.editor);
debouncedUpdates(e);
}
},
autofocus: false,
});
const { complete, completion, isLoading, stop } = useCompletion({
id: "ai-continue",
api: `${completionApi}/continue`,
body: { plan },
onFinish: (_prompt, completion) => {
editor?.commands.setTextSelection({
from: editor.state.selection.from - completion.length,
to: editor.state.selection.from,
});
},
onError: (err) => {
toast.error(err.message);
if (err.message === "You have reached your request limit for the day.") {
va.track("Rate Limit Reached");
}
},
});
const prev = useRef("");
// Insert chunks of the generated text
useEffect(() => {
const diff = completion.slice(prev.current.length);
prev.current = completion;
editor?.commands.insertContent(diff);
if (!isLoading) {
setLoadingOutside(false);
}
}, [isLoading, editor, completion]);
// Default: Hydrate the editor with the content from localStorage.
// If disableLocalStorage is true, hydrate the editor with the defaultValue.
useEffect(() => {
if (!editor || hydrated) return;
const value = disableLocalStorage ? defaultValue : content;
if (value) {
editor.commands.setContent(value);
setHydrated(true);
}
}, [editor, defaultValue, content, hydrated, disableLocalStorage]);
return (
<NovelContext.Provider
value={{
completionApi,
plan,
}}>
<div
onClick={() => {
editor?.chain().focus().run();
}}
className={className}>
{editor && (
<>
<EditorBubbleMenu editor={editor} />
<AIEditorBubble editor={editor} />
<AITranslateBubble editor={editor} />
</>
)}
{editor?.isActive("image") && <ImageResizer editor={editor} />}
<EditorContent editor={editor} />
{isLoadingOutside && isLoading && (
<div className="novel-fixed novel-bottom-3 novel-right-3">
<AIGeneratingLoading stop={stop} />
</div>
)}
</div>
</NovelContext.Provider>
);
}

View File

@ -0,0 +1,158 @@
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");
const UploadImagesPlugin = () =>
new Plugin({
key: uploadKey,
state: {
init() {
return DecorationSet.empty;
},
apply(tr, set) {
set = set.map(tr.mapping, tr.doc);
// See if the transaction adds or removes any placeholders
const action = tr.getMeta(this);
if (action && action.add) {
const { id, pos, src } = action.add;
const placeholder = document.createElement("div");
placeholder.setAttribute("class", "img-placeholder");
const image = document.createElement("img");
image.setAttribute(
"class",
"opacity-40 rounded-lg border border-stone-200"
);
image.src = src;
placeholder.appendChild(image);
const deco = Decoration.widget(pos + 1, placeholder, {
id,
});
set = set.add(tr.doc, [deco]);
} else if (action && action.remove) {
set = set.remove(
set.find(null, null, (spec) => spec.id == action.remove.id)
);
}
return set;
},
},
props: {
decorations(state) {
return this.getState(state);
},
},
});
export default UploadImagesPlugin;
function findPlaceholder(state: EditorState, id: {}) {
const decos = uploadKey.getState(state);
const found = decos.find(null, null, (spec) => spec.id == id);
return found.length ? found[0].from : null;
}
export function startImageUpload(file: File, view: EditorView, pos: number) {
// check if the file is an image
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).`);
return;
}
// A fresh object to act as the ID for this upload
const id = {};
// Replace the selection with a placeholder
const tr = view.state.tr;
if (!tr.selection.empty) tr.deleteSelection();
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
tr.setMeta(uploadKey, {
add: {
id,
pos,
src: reader.result,
},
});
view.dispatch(tr);
};
handleImageUpload(file).then((src) => {
const { schema } = view.state;
let pos = findPlaceholder(view.state, id);
// If the content around the placeholder has been deleted, drop
// the image
if (pos == null) return;
// Otherwise, insert it at the placeholder's position, and remove
// the placeholder
// When BLOB_READ_WRITE_TOKEN is not valid or unavailable, read
// the image locally
const imageSrc = typeof src === "object" ? reader.result : src;
const node = schema.nodes.image.create({ src: imageSrc });
const transaction = view.state.tr
.replaceWith(pos, pos, node)
.setMeta(uploadKey, { remove: { id } });
view.dispatch(transaction);
});
}
export const handleImageUpload = (file: File) => {
// upload to Vercel Blob
return new Promise((resolve) => {
toast.promise(
fetch("/api/upload", {
method: "POST",
headers: {
"content-type": file?.type || "application/octet-stream",
"x-vercel-filename": file?.name || "image.png",
},
body: file,
}).then(async (res) => {
// Successfully uploaded image
if (res.status === 200) {
const { url } = (await res.json()) as BlobResult;
// preload the image
let 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 if (res.status === 429) {
resolve(file);
throw new Error(
"You have exceeded the maximum size of uploads, please upgrade your plan."
);
} else {
throw new Error(`Error uploading image. Please try again.`);
}
}),
{
loading: "Uploading image...",
success: "Image uploaded successfully.",
error: (e) => e.message,
}
);
});
};

View File

@ -0,0 +1,53 @@
import { EditorProps } from "@tiptap/pm/view";
import { startImageUpload } from "@/ui/editor/plugins/upload-images";
export const defaultEditorProps: EditorProps = {
attributes: {
class: `novel-prose-lg novel-prose-stone dark:novel-prose-invert prose-headings:novel-font-title novel-font-default focus:novel-outline-none novel-max-w-full`,
},
handleDOMEvents: {
keydown: (_view, event) => {
// prevent default event listeners from firing when slash command is active
if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) {
const slashCommand = document.querySelector("#slash-command");
if (slashCommand) {
return true;
}
}
},
},
handlePaste: (view, event) => {
if (
event.clipboardData &&
event.clipboardData.files &&
event.clipboardData.files[0]
) {
event.preventDefault();
const file = event.clipboardData.files[0];
const pos = view.state.selection.from;
startImageUpload(file, view, pos);
return true;
}
return false;
},
handleDrop: (view, event, _slice, moved) => {
if (
!moved &&
event.dataTransfer &&
event.dataTransfer.files &&
event.dataTransfer.files[0]
) {
event.preventDefault();
const file = event.dataTransfer.files[0];
const coordinates = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
// here we deduct 1 from the pos or else the image will create an extra node
startImageUpload(file, view, coordinates?.pos || 0 - 1);
return true;
}
return false;
},
};

View File

@ -0,0 +1,11 @@
"use client";
import { createContext } from "react";
export const NovelContext = createContext<{
completionApi: string;
plan: string;
}>({
completionApi: "/api/generate",
plan: "5",
});

View File

@ -0,0 +1,164 @@
.tiptap pre {
background: #000;
border-radius: 0.5rem;
color: #fff;
font-family: "JetBrainsMono", monospace;
padding: 0.75rem 1rem;
overflow: hidden;
}
.tiptap pre .hljs-emphasis {
font-style: italic;
}
.tiptap pre .hljs-strong {
font-weight: 700;
}
.tiptap pre code {
background: none;
color: inherit;
font-size: 0.8rem;
padding: 0;
}
.tiptap pre .hljs-comment,
.tiptap pre .hljs-quote {
color: #616161;
}
.tiptap pre .hljs-variable,
.tiptap pre .hljs-template-variable,
.tiptap pre .hljs-attribute,
.tiptap pre .hljs-tag,
.tiptap pre .hljs-name,
.tiptap pre .hljs-regexp,
.tiptap pre .hljs-link,
.tiptap pre .hljs-name,
.tiptap pre .hljs-selector-id,
.tiptap pre .hljs-selector-class {
color: #f98181;
}
.tiptap pre .hljs-number,
.tiptap pre .hljs-meta,
.tiptap pre .hljs-built_in,
.tiptap pre .hljs-builtin-name,
.tiptap pre .hljs-literal,
.tiptap pre .hljs-type,
.tiptap pre .hljs-params {
color: #fbbc88;
}
.tiptap pre .hljs-string,
.tiptap pre .hljs-symbol,
.tiptap pre .hljs-bullet {
color: #b9f18d;
}
.tiptap pre .hljs-title,
.tiptap pre .hljs-section {
color: #faf594;
}
.tiptap pre .hljs-keyword,
.tiptap pre .hljs-selector-tag {
color: #70cff8;
}
/* table */
.tiptap table {
margin: 0;
overflow: hidden;
table-layout: fixed;
width: 100%;
border-collapse: collapse;
border-radius: 2px;
}
.tiptap table td,
.tiptap table th {
border: 1px solid #ced4da;
box-sizing: border-box;
min-width: 1em;
padding: 6px 6px;
position: relative;
vertical-align: top;
/* > * {
margin-bottom: 0;
} */
}
.tiptap table th {
background-color: rgb(248, 248, 248);
color: #000;
font-weight: bold;
text-align: left;
}
.tiptap table .selectedCell:after {
background: rgba(200, 200, 255, 0.4);
content: "";
left: 0;
right: 0;
top: 0;
bottom: 0;
pointer-events: none;
position: absolute;
z-index: 2;
}
.tiptap table .column-resize-handle {
background-color: #adf;
bottom: -2px;
position: absolute;
right: -2px;
pointer-events: none;
top: 0;
width: 4px;
}
.tiptap table p {
margin: 0;
overflow: hidden;
}
.tableWrapper {
padding: 1rem 0;
overflow-x: auto;
}
.resize-cursor {
cursor: ew-resize;
cursor: col-resize;
}
/* youtube */
.tiptap iframe {
border: 1px solid #c4c4c400;
border-radius: 6px;
min-width: 200px;
min-height: 180px;
width: 100%;
display: block;
outline: 0px solid transparent;
box-shadow: 1px 1px 10px #72727236;
}
.tiptap iframe:before {
content: "";
display: inline-block;
padding-bottom: 100%;
width: 0.1px;
vertical-align: middle;
}
.tiptap div[data-youtube-video] {
cursor: move;
padding-right: 24px;
width: 100%;
}
.tiptap .ProseMirror-selectednode iframe {
transition: outline 0.15s;
outline: 3px solid #3c69ff;
}

View File

@ -0,0 +1,2 @@
export { default as LoadingCircle } from "./loading-circle";
export { default as Magic } from "./magic";

View File

@ -0,0 +1,22 @@
export default function LoadingCircle({ dimensions }: { dimensions?: string }) {
return (
<svg
aria-hidden="true"
className={`${
dimensions || "novel-h-4 novel-w-4"
} novel-animate-spin novel-fill-stone-600 novel-text-stone-200`}
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
);
}

View File

@ -0,0 +1,40 @@
.loading {
display: inline-flex;
align-items: center;
}
.loading .spacer {
margin-right: 2px;
}
.loading span {
animation-name: blink;
animation-duration: 1.4s;
animation-iteration-count: infinite;
animation-fill-mode: both;
width: 5px;
height: 5px;
border-radius: 50%;
display: inline-block;
margin: 0 1px;
}
.loading span:nth-of-type(2) {
animation-delay: 0.2s;
}
.loading span:nth-of-type(3) {
animation-delay: 0.4s;
}
@keyframes blink {
0% {
opacity: 0.2;
}
20% {
opacity: 1;
}
100% {
opacity: 0.2;
}
}

View File

@ -0,0 +1,13 @@
import "./index.css";
const LoadingDots = ({ color = "#000" }: { color?: string }) => {
return (
<span className="loading">
<span style={{ backgroundColor: color }} />
<span style={{ backgroundColor: color }} />
<span style={{ backgroundColor: color }} />
</span>
);
};
export default LoadingDots;

View File

@ -0,0 +1,30 @@
export default function Magic({ className }: { className: string }) {
return (
<svg
width="469"
height="469"
viewBox="0 0 469 469"
fill="none"
xmlns="http://www.w3.org/2000/svg"
shapeRendering="geometricPrecision"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
className={className}
>
<path
d="M237.092 62.3004L266.754 71.4198C267.156 71.5285 267.51 71.765 267.765 72.0934C268.02 72.4218 268.161 72.8243 268.166 73.2399C268.172 73.6555 268.042 74.0616 267.796 74.3967C267.55 74.7318 267.201 74.9777 266.803 75.097L237.141 84.3145C236.84 84.4058 236.566 84.5699 236.344 84.7922C236.121 85.0146 235.957 85.2883 235.866 85.5893L226.747 115.252C226.638 115.653 226.401 116.008 226.073 116.263C225.745 116.517 225.342 116.658 224.926 116.664C224.511 116.669 224.105 116.539 223.77 116.293C223.435 116.047 223.189 115.699 223.069 115.301L213.852 85.6383C213.761 85.3374 213.597 85.0636 213.374 84.8412C213.152 84.6189 212.878 84.4548 212.577 84.3635L182.914 75.2441C182.513 75.1354 182.158 74.8989 181.904 74.5705C181.649 74.2421 181.508 73.8396 181.503 73.424C181.497 73.0084 181.627 72.6023 181.873 72.2672C182.119 71.9321 182.467 71.6863 182.865 71.5669L212.528 62.3494C212.829 62.2582 213.103 62.0941 213.325 61.8717C213.547 61.6494 213.712 61.3756 213.803 61.0747L222.922 31.4121C223.031 31.0109 223.267 30.656 223.596 30.4013C223.924 30.1465 224.327 30.0057 224.742 30.0002C225.158 29.9946 225.564 30.1247 225.899 30.3706C226.234 30.6165 226.48 30.9649 226.599 31.363L235.817 61.0257C235.908 61.3266 236.072 61.6003 236.295 61.8227C236.517 62.0451 236.791 62.2091 237.092 62.3004Z"
fill="currentColor"
/>
<path
d="M155.948 155.848L202.771 168.939C203.449 169.131 204.045 169.539 204.47 170.101C204.895 170.663 205.125 171.348 205.125 172.052C205.125 172.757 204.895 173.442 204.47 174.004C204.045 174.566 203.449 174.974 202.771 175.166L155.899 188.06C155.361 188.209 154.87 188.496 154.475 188.891C154.079 189.286 153.793 189.777 153.644 190.316L140.553 237.138C140.361 237.816 139.953 238.413 139.391 238.838C138.829 239.262 138.144 239.492 137.44 239.492C136.735 239.492 136.05 239.262 135.488 238.838C134.927 238.413 134.519 237.816 134.327 237.138L121.432 190.267C121.283 189.728 120.997 189.237 120.601 188.842C120.206 188.446 119.715 188.16 119.177 188.011L72.3537 174.92C71.676 174.728 71.0795 174.32 70.6547 173.759C70.2299 173.197 70 172.512 70 171.807C70 171.103 70.2299 170.418 70.6547 169.856C71.0795 169.294 71.676 168.886 72.3537 168.694L119.226 155.799C119.764 155.65 120.255 155.364 120.65 154.969C121.046 154.573 121.332 154.082 121.481 153.544L134.572 106.721C134.764 106.043 135.172 105.447 135.734 105.022C136.295 104.597 136.981 104.367 137.685 104.367C138.389 104.367 139.075 104.597 139.637 105.022C140.198 105.447 140.606 106.043 140.798 106.721L153.693 153.593C153.842 154.131 154.128 154.622 154.524 155.018C154.919 155.413 155.41 155.699 155.948 155.848Z"
fill="currentColor"
/>
<path
d="M386.827 289.992C404.33 292.149 403.84 305.828 386.876 307.299C346.623 310.829 298.869 316.271 282.199 360.005C274.844 379.192 269.942 403.2 267.49 432.029C267.427 432.846 267.211 433.626 266.856 434.319C266.501 435.012 266.015 435.602 265.431 436.05C254.988 444.041 251.212 434.186 250.183 425.606C239.2 332.353 214.588 316.909 124.668 306.122C123.892 306.031 123.151 305.767 122.504 305.35C121.857 304.933 121.322 304.375 120.942 303.72C116.399 295.679 119.324 291.038 129.718 289.796C224.688 278.47 236.062 262.83 250.183 169.331C252.177 156.355 257.259 154.083 265.431 162.516C266.51 163.593 267.202 165.099 267.392 166.782C279.257 258.564 293.328 278.617 386.827 289.992Z"
fill="currentColor"
/>
</svg>
);
}

View File

@ -0,0 +1,7 @@
const sharedConfig = require("tailwind-config/tailwind.config.js");
module.exports = {
// prefix ui lib classes to avoid conflicting with the app
...sharedConfig,
prefix: "novel-",
};

View File

@ -0,0 +1,11 @@
{
"extends": "tsconfig/react.json",
"include": ["."],
"exclude": ["dist", "build", "node_modules"],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}

View File

@ -0,0 +1,14 @@
import { defineConfig, Options } from "tsup";
export default defineConfig((options: Options) => ({
entry: ["src/index.ts"],
banner: {
js: "'use client'",
},
format: ["cjs", "esm"],
dts: true,
clean: true,
external: ["react"],
injectStyle: true,
...options,
}));

View File

@ -0,0 +1,15 @@
{
"name": "tailwind-config",
"version": "0.0.0",
"private": true,
"main": "index.js",
"devDependencies": {
"@tailwindcss/typography": "^0.5.9",
"autoprefixer": "^10.4.14",
"postcss": "^8.4.21",
"prettier": "^2.8.8",
"prettier-plugin-tailwindcss": "^0.3.0",
"tailwindcss": "^3.2.7",
"tailwindcss-animate": "^1.0.6"
}
}

View File

@ -0,0 +1,39 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ["class", ".dark-theme"],
content: [
"./app/**/*.{js,ts,jsx,tsx}", // Note the addition of the `app` directory.
"./pages/**/*.{js,ts,jsx,tsx}",
"./ui/**/*.{js,ts,jsx,tsx}",
`src/**/*.{js,ts,jsx,tsx}`,
"../../packages/core/src/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
fontFamily: {
title: ["var(--font-title)", "system-ui", "sans-serif"],
default: ["var(--font-default)", "system-ui", "sans-serif"],
},
colors: {
white: "var(--inke-white)",
stone: {
50: "var(--inke-stone-50)",
100: "var(--inke-stone-100)",
200: "var(--inke-stone-200)",
300: "var(--inke-stone-300)",
400: "var(--inke-stone-400)",
500: "var(--inke-stone-500)",
600: "var(--inke-stone-600)",
700: "var(--inke-stone-700)",
800: "var(--inke-stone-800)",
900: "var(--inke-stone-900)",
},
},
},
},
plugins: [
// Tailwind plugins
require("@tailwindcss/typography"),
require("tailwindcss-animate"),
],
};

View File

@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Default",
"compilerOptions": {
"composite": false,
"declaration": true,
"declarationMap": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"inlineSources": false,
"isolatedModules": true,
"moduleResolution": "node",
"noUnusedLocals": false,
"noUnusedParameters": false,
"preserveWatchOutput": true,
"skipLibCheck": true,
"strict": true,
"paths": {
"@/*": ["./*"]
}
},
"exclude": ["node_modules"]
}

View File

@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Next.js",
"extends": "./base.json",
"compilerOptions": {
"plugins": [{ "name": "next" }],
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"incremental": true,
"esModuleInterop": true,
"module": "esnext",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve"
},
"include": ["src", "next-env.d.ts"],
"exclude": ["node_modules"]
}

View File

@ -0,0 +1,9 @@
{
"name": "tsconfig",
"version": "0.0.0",
"private": true,
"license": "MIT",
"publishConfig": {
"access": "public"
}
}

View File

@ -0,0 +1,11 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "React Library",
"extends": "./base.json",
"compilerOptions": {
"lib": ["DOM"],
"module": "ESNext",
"target": "ES6",
"jsx": "react-jsx"
}
}

8112
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

3
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,3 @@
packages:
- "apps/*"
- "packages/*"

19
turbo.json Normal file
View File

@ -0,0 +1,19 @@
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["**/.env.*local"],
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
},
"lint": {},
"check-types": {},
"dev": {
"cache": false,
"persistent": true
},
"clean": {
"cache": false
}
}
}