From 9534c6ed78fc5850e46673499117fc144c770058 Mon Sep 17 00:00:00 2001 From: andrewdoro Date: Thu, 7 Mar 2024 13:58:44 +0200 Subject: [PATCH] feat: update docs --- apps/docs/guides/image-upload.mdx | 137 ++++++++++++++++++ apps/docs/guides/tailwind/extensions.mdx | 20 +-- apps/docs/guides/tailwind/setup.mdx | 94 +++++------- apps/docs/guides/tailwind/slash-command.mdx | 50 +++---- apps/docs/mint.json | 3 +- apps/web/components/tailwind/image-upload.ts | 6 +- packages/headless/src/components/editor.tsx | 2 +- .../headless/src/plugins/upload-images.tsx | 3 +- 8 files changed, 205 insertions(+), 110 deletions(-) create mode 100644 apps/docs/guides/image-upload.mdx diff --git a/apps/docs/guides/image-upload.mdx b/apps/docs/guides/image-upload.mdx new file mode 100644 index 000000000..60e9098e4 --- /dev/null +++ b/apps/docs/guides/image-upload.mdx @@ -0,0 +1,137 @@ +--- +title: "Image Upload (New)" +description: "Uploading images in the editor" +--- + + + + + Configure image extension with your styling. The `imageClass` is used for styling the placeholder image. + + ```tsx + //extensions.ts + import { UploadImagesPlugin } from "novel/plugins"; + + const tiptapImage = TiptapImage.extend({ + addProseMirrorPlugins() { + return [ + UploadImagesPlugin({ + imageClass: cx("opacity-40 rounded-lg border border-stone-200"), + }), + ]; + }, + }).configure({ + allowBase64: true, + HTMLAttributes: { + class: cx("rounded-lg border border-muted"), + }, + }); + + export const defaultExtensions = [ + tiptapImage, + //other extensions + ]; + + //editor.tsx + const Editor = () => { + return + } + + ``` + + + + + `onUpload` should return a `Promise` + `validateFn` is triggered before an image is uploaded. It should return a `boolean` value. + + ```tsx image-upload.ts + import { createImageUpload } from "novel/plugins"; + import { toast } from "sonner"; + + const onUpload = async (file: File) => { + const promise = fetch("/api/upload", { + method: "POST", + headers: { + "content-type": file?.type || "application/octet-stream", + "x-vercel-filename": file?.name || "image.png", + }, + body: file, + }); + + //This should return a src of the uploaded image + return promise; + }; + + export const uploadFn = createImageUpload({ + onUpload, + validateFn: (file) => { + if (!file.type.includes("image/")) { + toast.error("File type not supported."); + return false; + } else if (file.size / 1024 / 1024 > 20) { + toast.error("File size too big (max 20MB)."); + return false; + } + return true; + }, + }); + + ``` + + + + This is required to handle image paste and drop events in the editor. + ```tsx editor.tsx + import { handleImageDrop, handleImagePaste } from "novel/plugins"; + import { uploadFn } from "./image-upload"; + + ... + handleImagePaste(view, event, uploadFn), + handleDrop: (view, event, _slice, moved) => handleImageDrop(view, event, moved, uploadFn), + ... + }} + /> + ... + ``` + + + + + + ```tsx + import { ImageIcon } from "lucide-react"; + import { createSuggestionItems } from "novel/extensions"; + import { uploadFn } from "./image-upload"; + + export const suggestionItems = createSuggestionItems([ + ..., + { + title: "Image", + description: "Upload an image from your computer.", + searchTerms: ["photo", "picture", "media"], + icon: , + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).run(); + // upload image + const input = document.createElement("input"); + input.type = "file"; + input.accept = "image/*"; + input.onchange = async () => { + if (input.files?.length) { + const file = input.files[0]; + const pos = editor.view.state.selection.from; + uploadFn(file, editor.view, pos); + } + }; + input.click(); + }, + } + ]) + ``` + + + + diff --git a/apps/docs/guides/tailwind/extensions.mdx b/apps/docs/guides/tailwind/extensions.mdx index 4303ea3b8..720bc8030 100644 --- a/apps/docs/guides/tailwind/extensions.mdx +++ b/apps/docs/guides/tailwind/extensions.mdx @@ -18,7 +18,6 @@ import { StarterKit, Placeholder, } from "novel/extensions"; -import { UploadImagesPlugin } from "novel/plugins"; import { cx } from "class-variance-authority"; import { slashCommand } from "./slash-command"; @@ -30,28 +29,11 @@ const placeholder = Placeholder; const tiptapLink = TiptapLink.configure({ HTMLAttributes: { class: cx( - "text-muted-foreground underline underline-offset-[3px] hover:text-primary transition-colors cursor-pointer" + "text-muted-foreground underline underline-offset-[3px] hover:text-primary transition-colors cursor-pointer", ), }, }); -const tiptapImage = TiptapImage.extend({ - addProseMirrorPlugins() { - return [UploadImagesPlugin()]; - }, -}).configure({ - allowBase64: true, - HTMLAttributes: { - class: cx("rounded-lg border border-muted"), - }, -}); - -const updatedImage = UpdatedImage.configure({ - HTMLAttributes: { - class: cx("rounded-lg border border-muted"), - }, -}); - const taskList = TaskList.configure({ HTMLAttributes: { class: cx("not-prose pl-2"), diff --git a/apps/docs/guides/tailwind/setup.mdx b/apps/docs/guides/tailwind/setup.mdx index 7f6aa5fb4..3f0a79714 100644 --- a/apps/docs/guides/tailwind/setup.mdx +++ b/apps/docs/guides/tailwind/setup.mdx @@ -4,13 +4,17 @@ description: "Follow this guide to set up Novel with Tailwindcss" --- - This example demonstrates the use of Shadcn-ui for ui, but alternative libraries and components - can also be employed. + This example demonstrates the use of Shadcn-ui for ui, but alternative + libraries and components can also be employed. - - You can find more info about installing shadcn-ui here. You will need to add the following - components: Button, Separator, Popover, Command, Dialog, + + You can find more info about installing shadcn-ui here. You will need to add + the following components: Button, Separator, Popover, Command, Dialog, This example will use the same stucture from here: [Anatomy](/quickstart#anatomy)\ @@ -76,70 +80,40 @@ You can find the full example here: [Tailwind Example](https://github.com/steven ## Create Menus - + Slash commands are a way to quickly insert content into the editor. - + The bubble menu is a context menu that appears when you select text. ## Add Editor Props -`defaultEditorProps` are required to fix the slash command keyboard navigation. For any custom use case you can write your own or extend the default props. - -```tsx novel/src/editor.tsx -export const defaultEditorProps: EditorProviderProps["editorProps"] = { - 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; - }, -}; -``` +`handleCommandNavigation` is required for fixing the arrow navigation in the / command; ```tsx +import { handleCommandNavigation } from "novel/extensions"; import { defaultEditorProps, EditorContent } from "novel"; handleCommandNavigation(event), + }, + attributes: { + class: `prose prose-lg dark:prose-invert prose-headings:font-title font-default focus:outline-none max-w-full`, + } + }} /> ``` @@ -223,24 +197,24 @@ import { defaultEditorProps, EditorContent } from "novel"; ul[data-type="taskList"] li > label input[type="checkbox"] { -webkit-appearance: none; appearance: none; - background-color: var(--novel-white); + background-color: hsl(var(--background)); margin: 0; cursor: pointer; width: 1.2em; height: 1.2em; position: relative; top: 5px; - border: 2px solid var(--novel-stone-900); + border: 2px solid hsl(var(--border)); margin-right: 0.3rem; display: grid; place-content: center; &:hover { - background-color: var(--novel-stone-50); + background-color: hsl(var(--accent)); } &:active { - background-color: var(--novel-stone-200); + background-color: hsl(var(--accent)); } &::before { @@ -260,7 +234,7 @@ import { defaultEditorProps, EditorContent } from "novel"; } ul[data-type="taskList"] li[data-checked="true"] > div > p { - color: var(--novel-stone-400); + color: var(--muted-foreground); text-decoration: line-through; text-decoration-thickness: 2px; } diff --git a/apps/docs/guides/tailwind/slash-command.mdx b/apps/docs/guides/tailwind/slash-command.mdx index 7b44aac9f..142f41620 100644 --- a/apps/docs/guides/tailwind/slash-command.mdx +++ b/apps/docs/guides/tailwind/slash-command.mdx @@ -14,7 +14,6 @@ import { Heading1, Heading2, Heading3, - ImageIcon, List, ListOrdered, MessageSquarePlus, @@ -41,7 +40,12 @@ export const suggestionItems = createSuggestionItems([ searchTerms: ["p", "paragraph"], icon: , command: ({ editor, range }) => { - editor.chain().focus().deleteRange(range).toggleNode("paragraph", "paragraph").run(); + editor + .chain() + .focus() + .deleteRange(range) + .toggleNode("paragraph", "paragraph") + .run(); }, }, { @@ -59,7 +63,12 @@ export const suggestionItems = createSuggestionItems([ searchTerms: ["title", "big", "large"], icon: , command: ({ editor, range }) => { - editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run(); + editor + .chain() + .focus() + .deleteRange(range) + .setNode("heading", { level: 1 }) + .run(); }, }, { @@ -68,7 +77,12 @@ export const suggestionItems = createSuggestionItems([ searchTerms: ["subtitle", "medium"], icon: , command: ({ editor, range }) => { - editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run(); + editor + .chain() + .focus() + .deleteRange(range) + .setNode("heading", { level: 2 }) + .run(); }, }, { @@ -77,7 +91,12 @@ export const suggestionItems = createSuggestionItems([ searchTerms: ["subtitle", "small"], icon: , command: ({ editor, range }) => { - editor.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run(); + editor + .chain() + .focus() + .deleteRange(range) + .setNode("heading", { level: 3 }) + .run(); }, }, { @@ -120,27 +139,6 @@ export const suggestionItems = createSuggestionItems([ command: ({ editor, range }) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(), }, - { - title: "Image", - description: "Upload an image from your computer.", - searchTerms: ["photo", "picture", "media"], - icon: , - command: ({ editor, range }) => { - editor.chain().focus().deleteRange(range).run(); - // upload image - const input = document.createElement("input"); - input.type = "file"; - input.accept = "image/*"; - input.onchange = async () => { - if (input.files?.length) { - const file = input.files[0]; - const pos = editor.view.state.selection.from; - startImageUpload(file, editor.view, pos); - } - }; - input.click(); - }, - }, ]); export const slashCommand = Command.configure({ diff --git a/apps/docs/mint.json b/apps/docs/mint.json index 4f1ee9c4a..35d9ae858 100644 --- a/apps/docs/mint.json +++ b/apps/docs/mint.json @@ -50,7 +50,8 @@ "guides/tailwind/bubble-menu" ] }, - "guides/ai-command" + "guides/ai-command", + "guides/image-upload" ] }, { diff --git a/apps/web/components/tailwind/image-upload.ts b/apps/web/components/tailwind/image-upload.ts index 2424d7f45..24145ff4e 100644 --- a/apps/web/components/tailwind/image-upload.ts +++ b/apps/web/components/tailwind/image-upload.ts @@ -10,6 +10,7 @@ const onUpload = (file: File) => { }, body: file, }); + return new Promise((resolve) => { toast.promise( promise.then(async (res) => { @@ -47,10 +48,11 @@ export const uploadFn = createImageUpload({ validateFn: (file) => { if (!file.type.includes("image/")) { toast.error("File type not supported."); - return; + return false; } else if (file.size / 1024 / 1024 > 20) { toast.error("File size too big (max 20MB)."); - return; + return false; } + return true; }, }); diff --git a/packages/headless/src/components/editor.tsx b/packages/headless/src/components/editor.tsx index 95c7f6a48..d38faec22 100644 --- a/packages/headless/src/components/editor.tsx +++ b/packages/headless/src/components/editor.tsx @@ -31,7 +31,7 @@ export const EditorRoot: FC = ({ children }) => { }; export type EditorContentProps = Omit & { - readonly children: ReactNode; + readonly children?: ReactNode; readonly className?: string; readonly initialContent?: JSONContent; }; diff --git a/packages/headless/src/plugins/upload-images.tsx b/packages/headless/src/plugins/upload-images.tsx index f2a6e1fb4..d7ee9df8f 100644 --- a/packages/headless/src/plugins/upload-images.tsx +++ b/packages/headless/src/plugins/upload-images.tsx @@ -62,7 +62,8 @@ export const createImageUpload = ({ validateFn, onUpload }: ImageUploadOptions): UploadFn => (file, view, pos) => { // check if the file is an image - validateFn?.(file); + const validated = validateFn?.(file); + if (!validated) return; // A fresh object to act as the ID for this upload const id = {};