mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-02 22:02:13 +08:00
feat: add novel editor
This commit is contained in:
@@ -17,8 +17,10 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "^1.2.9",
|
||||
"@ant-design/icons": "^6.0.0",
|
||||
"@hookform/resolvers": "^5.0.1",
|
||||
"@nanostores/react": "github:ai/react",
|
||||
"@radix-ui/react-accordion": "^1.2.8",
|
||||
"@radix-ui/react-checkbox": "^1.2.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.8",
|
||||
@@ -26,6 +28,7 @@
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.11",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-label": "^2.1.4",
|
||||
"@radix-ui/react-popover": "^1.1.11",
|
||||
"@radix-ui/react-scroll-area": "^1.2.4",
|
||||
"@radix-ui/react-select": "^2.2.2",
|
||||
"@radix-ui/react-separator": "^1.1.4",
|
||||
@@ -34,18 +37,23 @@
|
||||
"@radix-ui/react-tabs": "^1.1.4",
|
||||
"@radix-ui/react-tooltip": "^1.2.0",
|
||||
"@t3-oss/env-nextjs": "^0.11.0",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"best-effort-json-parser": "^1.1.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"framer-motion": "^12.6.5",
|
||||
"hast": "^1.0.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"katex": "^0.16.21",
|
||||
"lowlight": "^3.3.0",
|
||||
"lru-cache": "^11.1.0",
|
||||
"lucide-react": "^0.487.0",
|
||||
"motion": "^12.6.5",
|
||||
"nanoid": "^5.1.5",
|
||||
"next": "^15.2.3",
|
||||
"next-themes": "^0.4.6",
|
||||
"novel": "^1.0.2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.56.1",
|
||||
@@ -54,9 +62,12 @@
|
||||
"rehype-katex": "^7.0.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"sonner": "^2.0.3",
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"tiptap-markdown": "^0.8.10",
|
||||
"tw-animate-css": "^1.2.5",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"use-debounce": "^10.0.4",
|
||||
"use-stick-to-bottom": "^1.1.0",
|
||||
"zod": "^3.24.3",
|
||||
"zustand": "^5.0.3"
|
||||
@@ -88,4 +99,4 @@
|
||||
"sharp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
1322
web/pnpm-lock.yaml
generated
1322
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
11
web/src/app/editor/page.tsx
Normal file
11
web/src/app/editor/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import ReportEditor from "~/components/editor";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<main className="flex h-full w-full">
|
||||
<div className="flex h-screen flex-auto">
|
||||
<ReportEditor />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
356
web/src/components/editor/content.ts
Normal file
356
web/src/components/editor/content.ts
Normal file
@@ -0,0 +1,356 @@
|
||||
export const defaultEditorContent = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "heading",
|
||||
attrs: { level: 2 },
|
||||
content: [{ type: "text", text: "Introducing Novel" }],
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
marks: [
|
||||
{
|
||||
type: "link",
|
||||
attrs: {
|
||||
href: "https://github.com/steven-tey/novel",
|
||||
target: "_blank",
|
||||
},
|
||||
},
|
||||
],
|
||||
text: "Novel",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: " is a Notion-style WYSIWYG editor with AI-powered autocompletion. Built with ",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
marks: [
|
||||
{
|
||||
type: "link",
|
||||
attrs: {
|
||||
href: "https://tiptap.dev/",
|
||||
target: "_blank",
|
||||
},
|
||||
},
|
||||
],
|
||||
text: "Tiptap",
|
||||
},
|
||||
{ type: "text", text: " + " },
|
||||
{
|
||||
type: "text",
|
||||
marks: [
|
||||
{
|
||||
type: "link",
|
||||
attrs: {
|
||||
href: "https://sdk.vercel.ai/docs",
|
||||
target: "_blank",
|
||||
},
|
||||
},
|
||||
],
|
||||
text: "Vercel AI SDK",
|
||||
},
|
||||
{ type: "text", text: "." },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "heading",
|
||||
attrs: { level: 3 },
|
||||
content: [{ type: "text", text: "Installation" }],
|
||||
},
|
||||
{
|
||||
type: "codeBlock",
|
||||
attrs: { language: null },
|
||||
content: [{ type: "text", text: "npm i novel" }],
|
||||
},
|
||||
{
|
||||
type: "heading",
|
||||
attrs: { level: 3 },
|
||||
content: [{ type: "text", text: "Usage" }],
|
||||
},
|
||||
{
|
||||
type: "codeBlock",
|
||||
attrs: { language: null },
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: 'import { Editor } from "novel";\n\nexport default function App() {\n return (\n <Editor />\n )\n}',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "heading",
|
||||
attrs: { level: 3 },
|
||||
content: [{ type: "text", text: "Features" }],
|
||||
},
|
||||
{
|
||||
type: "orderedList",
|
||||
attrs: { tight: true, start: 1 },
|
||||
content: [
|
||||
{
|
||||
type: "listItem",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "Slash menu & bubble menu" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "listItem",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{ type: "text", text: "AI autocomplete (type " },
|
||||
{ type: "text", marks: [{ type: "code" }], text: "++" },
|
||||
{
|
||||
type: "text",
|
||||
text: " to activate, or select from slash menu)",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "listItem",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Image uploads (drag & drop / copy & paste, or select from slash menu) ",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "listItem",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Add tweets from the command slash menu:",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "twitter",
|
||||
attrs: {
|
||||
src: "https://x.com/elonmusk/status/1800759252224729577",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "listItem",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Mathematical symbols with LaTeX expression:",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "orderedList",
|
||||
attrs: {
|
||||
tight: true,
|
||||
start: 1,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "listItem",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "math",
|
||||
attrs: {
|
||||
latex: "E = mc^2",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "listItem",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "math",
|
||||
attrs: {
|
||||
latex: "a^2 = \\sqrt{b^2 + c^2}",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "listItem",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "math",
|
||||
attrs: {
|
||||
latex:
|
||||
"\\hat{f} (\\xi)=\\int_{-\\infty}^{\\infty}f(x)e^{-2\\pi ix\\xi}dx",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "listItem",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "math",
|
||||
attrs: {
|
||||
latex:
|
||||
"A=\\begin{bmatrix}a&b\\\\c&d \\end{bmatrix}",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "listItem",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "math",
|
||||
attrs: {
|
||||
latex: "\\sum_{i=0}^n x_i",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "image",
|
||||
attrs: {
|
||||
src: "https://public.blob.vercel-storage.com/pJrjXbdONOnAeZAZ/banner-2wQk82qTwyVgvlhTW21GIkWgqPGD2C.png",
|
||||
alt: "banner.png",
|
||||
title: "banner.png",
|
||||
width: null,
|
||||
height: null,
|
||||
},
|
||||
},
|
||||
{ type: "horizontalRule" },
|
||||
{
|
||||
type: "heading",
|
||||
attrs: { level: 3 },
|
||||
content: [{ type: "text", text: "Learn more" }],
|
||||
},
|
||||
{
|
||||
type: "taskList",
|
||||
content: [
|
||||
{
|
||||
type: "taskItem",
|
||||
attrs: { checked: false },
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{ type: "text", text: "Star us on " },
|
||||
{
|
||||
type: "text",
|
||||
marks: [
|
||||
{
|
||||
type: "link",
|
||||
attrs: {
|
||||
href: "https://github.com/steven-tey/novel",
|
||||
target: "_blank",
|
||||
},
|
||||
},
|
||||
],
|
||||
text: "GitHub",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "taskItem",
|
||||
attrs: { checked: false },
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{ type: "text", text: "Install the " },
|
||||
{
|
||||
type: "text",
|
||||
marks: [
|
||||
{
|
||||
type: "link",
|
||||
attrs: {
|
||||
href: "https://www.npmjs.com/package/novel",
|
||||
target: "_blank",
|
||||
},
|
||||
},
|
||||
],
|
||||
text: "NPM package",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "taskItem",
|
||||
attrs: { checked: false },
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
marks: [
|
||||
{
|
||||
type: "link",
|
||||
attrs: {
|
||||
href: "https://vercel.com/templates/next.js/novel",
|
||||
target: "_blank",
|
||||
},
|
||||
},
|
||||
],
|
||||
text: "Deploy your own",
|
||||
},
|
||||
{ type: "text", text: " to Vercel" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
180
web/src/components/editor/extensions.tsx
Normal file
180
web/src/components/editor/extensions.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import {
|
||||
AIHighlight,
|
||||
CharacterCount,
|
||||
CodeBlockLowlight,
|
||||
Color,
|
||||
CustomKeymap,
|
||||
GlobalDragHandle,
|
||||
HighlightExtension,
|
||||
HorizontalRule,
|
||||
Mathematics,
|
||||
Placeholder,
|
||||
StarterKit,
|
||||
TaskItem,
|
||||
TaskList,
|
||||
TextStyle,
|
||||
TiptapImage,
|
||||
TiptapLink,
|
||||
TiptapUnderline,
|
||||
Twitter,
|
||||
UpdatedImage,
|
||||
UploadImagesPlugin,
|
||||
Youtube,
|
||||
} from "novel";
|
||||
import { Markdown } from "tiptap-markdown";
|
||||
import { cx } from "class-variance-authority";
|
||||
import { common, createLowlight } from "lowlight";
|
||||
|
||||
//TODO I am using cx here to get tailwind autocomplete working, idk if someone else can write a regex to just capture the class key in objects
|
||||
const aiHighlight = AIHighlight;
|
||||
//You can overwrite the placeholder with your own configuration
|
||||
const placeholder = Placeholder;
|
||||
const tiptapLink = TiptapLink.configure({
|
||||
HTMLAttributes: {
|
||||
class: cx(
|
||||
"text-muted-foreground underline underline-offset-[3px] hover:text-primary transition-colors cursor-pointer",
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
const tiptapImage = TiptapImage.extend({
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
UploadImagesPlugin({
|
||||
imageClass: cx("opacity-40 rounded-lg border border-stone-200"),
|
||||
}),
|
||||
];
|
||||
},
|
||||
}).configure({
|
||||
allowBase64: true,
|
||||
HTMLAttributes: {
|
||||
class: cx("rounded-lg border border-muted"),
|
||||
},
|
||||
});
|
||||
|
||||
const updatedImage = UpdatedImage.configure({
|
||||
HTMLAttributes: {
|
||||
class: cx("rounded-lg border border-muted"),
|
||||
},
|
||||
});
|
||||
|
||||
const taskList = TaskList.configure({
|
||||
HTMLAttributes: {
|
||||
class: cx("not-prose pl-2 "),
|
||||
},
|
||||
});
|
||||
const taskItem = TaskItem.configure({
|
||||
HTMLAttributes: {
|
||||
class: cx("flex gap-2 items-start my-4"),
|
||||
},
|
||||
nested: true,
|
||||
});
|
||||
|
||||
const horizontalRule = HorizontalRule.configure({
|
||||
HTMLAttributes: {
|
||||
class: cx("mt-4 mb-6 border-t border-muted-foreground"),
|
||||
},
|
||||
});
|
||||
|
||||
const starterKit = StarterKit.configure({
|
||||
bulletList: {
|
||||
HTMLAttributes: {
|
||||
class: cx("list-disc list-outside leading-3 -mt-2"),
|
||||
},
|
||||
},
|
||||
orderedList: {
|
||||
HTMLAttributes: {
|
||||
class: cx("list-decimal list-outside leading-3 -mt-2"),
|
||||
},
|
||||
},
|
||||
listItem: {
|
||||
HTMLAttributes: {
|
||||
class: cx("leading-normal -mb-2"),
|
||||
},
|
||||
},
|
||||
blockquote: {
|
||||
HTMLAttributes: {
|
||||
class: cx("border-l-4 border-primary"),
|
||||
},
|
||||
},
|
||||
codeBlock: false,
|
||||
code: {
|
||||
HTMLAttributes: {
|
||||
class: cx("rounded-md bg-muted px-1.5 py-1 font-mono font-medium"),
|
||||
spellcheck: "false",
|
||||
},
|
||||
},
|
||||
horizontalRule: false,
|
||||
dropcursor: {
|
||||
color: "#DBEAFE",
|
||||
width: 4,
|
||||
},
|
||||
gapcursor: false,
|
||||
});
|
||||
|
||||
const codeBlockLowlight = CodeBlockLowlight.configure({
|
||||
// configure lowlight: common / all / use highlightJS in case there is a need to specify certain language grammars only
|
||||
// common: covers 37 language grammars which should be good enough in most cases
|
||||
lowlight: createLowlight(common),
|
||||
});
|
||||
|
||||
const youtube = Youtube.configure({
|
||||
HTMLAttributes: {
|
||||
class: cx("rounded-lg border border-muted"),
|
||||
},
|
||||
inline: false,
|
||||
});
|
||||
|
||||
const twitter = Twitter.configure({
|
||||
HTMLAttributes: {
|
||||
class: cx("not-prose"),
|
||||
},
|
||||
inline: false,
|
||||
});
|
||||
|
||||
const mathematics = Mathematics.configure({
|
||||
HTMLAttributes: {
|
||||
class: cx("text-foreground rounded p-1 hover:bg-accent cursor-pointer"),
|
||||
},
|
||||
katexOptions: {
|
||||
throwOnError: false,
|
||||
},
|
||||
});
|
||||
|
||||
const characterCount = CharacterCount.configure();
|
||||
|
||||
const markdownExtension = Markdown.configure({
|
||||
html: true,
|
||||
tightLists: true,
|
||||
tightListClass: "tight",
|
||||
bulletListMarker: "-",
|
||||
linkify: false,
|
||||
breaks: false,
|
||||
transformPastedText: false,
|
||||
transformCopiedText: false,
|
||||
});
|
||||
|
||||
const globalDragHandle = GlobalDragHandle.configure({});
|
||||
|
||||
export const defaultExtensions = [
|
||||
starterKit,
|
||||
placeholder,
|
||||
tiptapLink,
|
||||
updatedImage,
|
||||
taskList,
|
||||
taskItem,
|
||||
horizontalRule,
|
||||
aiHighlight,
|
||||
codeBlockLowlight,
|
||||
youtube,
|
||||
twitter,
|
||||
mathematics,
|
||||
characterCount,
|
||||
TiptapUnderline,
|
||||
markdownExtension,
|
||||
HighlightExtension,
|
||||
TextStyle,
|
||||
Color,
|
||||
CustomKeymap,
|
||||
globalDragHandle,
|
||||
];
|
||||
@@ -0,0 +1,66 @@
|
||||
import { CommandGroup, CommandItem, CommandSeparator } from "../../ui/command";
|
||||
import { useEditor } from "novel";
|
||||
import { Check, TextQuote, TrashIcon } from "lucide-react";
|
||||
|
||||
const AICompletionCommands = ({
|
||||
completion,
|
||||
onDiscard,
|
||||
}: {
|
||||
completion: string;
|
||||
onDiscard: () => void;
|
||||
}) => {
|
||||
const { editor } = useEditor();
|
||||
if (!editor) return null;
|
||||
return (
|
||||
<>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
className="gap-2 px-4"
|
||||
value="replace"
|
||||
onSelect={() => {
|
||||
const selection = editor.view.state.selection;
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContentAt(
|
||||
{
|
||||
from: selection.from,
|
||||
to: selection.to,
|
||||
},
|
||||
completion,
|
||||
)
|
||||
.run();
|
||||
}}
|
||||
>
|
||||
<Check className="text-muted-foreground h-4 w-4" />
|
||||
Replace selection
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
className="gap-2 px-4"
|
||||
value="insert"
|
||||
onSelect={() => {
|
||||
const selection = editor.view.state.selection;
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContentAt(selection.to + 1, completion)
|
||||
.run();
|
||||
}}
|
||||
>
|
||||
<TextQuote className="text-muted-foreground h-4 w-4" />
|
||||
Insert below
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
|
||||
<CommandGroup>
|
||||
<CommandItem onSelect={onDiscard} value="thrash" className="gap-2 px-4">
|
||||
<TrashIcon className="text-muted-foreground h-4 w-4" />
|
||||
Discard
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AICompletionCommands;
|
||||
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
ArrowDownWideNarrow,
|
||||
CheckCheck,
|
||||
RefreshCcwDot,
|
||||
StepForward,
|
||||
WrapText,
|
||||
} from "lucide-react";
|
||||
import { getPrevText, useEditor } from "novel";
|
||||
import { CommandGroup, CommandItem, CommandSeparator } from "../../ui/command";
|
||||
|
||||
const options = [
|
||||
{
|
||||
value: "improve",
|
||||
label: "Improve writing",
|
||||
icon: RefreshCcwDot,
|
||||
},
|
||||
{
|
||||
value: "fix",
|
||||
label: "Fix grammar",
|
||||
icon: CheckCheck,
|
||||
},
|
||||
{
|
||||
value: "shorter",
|
||||
label: "Make shorter",
|
||||
icon: ArrowDownWideNarrow,
|
||||
},
|
||||
{
|
||||
value: "longer",
|
||||
label: "Make longer",
|
||||
icon: WrapText,
|
||||
},
|
||||
];
|
||||
|
||||
interface AISelectorCommandsProps {
|
||||
onSelect: (value: string, option: string) => void;
|
||||
}
|
||||
|
||||
const AISelectorCommands = ({ onSelect }: AISelectorCommandsProps) => {
|
||||
const { editor } = useEditor();
|
||||
if (!editor) return null;
|
||||
return (
|
||||
<>
|
||||
<CommandGroup heading="Edit or review selection">
|
||||
{options.map((option) => (
|
||||
<CommandItem
|
||||
onSelect={(value) => {
|
||||
const slice = editor.state.selection.content();
|
||||
const text = editor.storage.markdown.serializer.serialize(
|
||||
slice.content,
|
||||
);
|
||||
onSelect(text, value);
|
||||
}}
|
||||
className="flex gap-2 px-4"
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
>
|
||||
<option.icon className="h-4 w-4 text-purple-500" />
|
||||
{option.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="Use AI to do more">
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
const pos = editor.state.selection.from;
|
||||
const text = getPrevText(editor, pos);
|
||||
onSelect(text, "continue");
|
||||
}}
|
||||
value="continue"
|
||||
className="gap-2 px-4"
|
||||
>
|
||||
<StepForward className="h-4 w-4 text-purple-500" />
|
||||
Continue writing
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AISelectorCommands;
|
||||
123
web/src/components/editor/generative/ai-selector.tsx
Normal file
123
web/src/components/editor/generative/ai-selector.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import { Command, CommandInput } from "../../ui/command";
|
||||
|
||||
import { useCompletion } from "@ai-sdk/react";
|
||||
import { ArrowUp } from "lucide-react";
|
||||
import { useEditor } from "novel";
|
||||
import { addAIHighlight } from "novel";
|
||||
import { useState } from "react";
|
||||
import Markdown from "react-markdown";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "../../ui/button";
|
||||
import Magic from "../../ui/icons/magic";
|
||||
import { ScrollArea } from "../../ui/scroll-area";
|
||||
import AICompletionCommands from "./ai-completion-command";
|
||||
import AISelectorCommands from "./ai-selector-commands";
|
||||
import { LoadingOutlined } from "@ant-design/icons";
|
||||
//TODO: I think it makes more sense to create a custom Tiptap extension for this functionality https://tiptap.dev/docs/editor/ai/introduction
|
||||
|
||||
interface AISelectorProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function AISelector({ onOpenChange }: AISelectorProps) {
|
||||
const { editor } = useEditor();
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
|
||||
const { completion, complete, isLoading } = useCompletion({
|
||||
// id: "novel",
|
||||
api: "/api/generate",
|
||||
onResponse: (response) => {
|
||||
if (response.status === 429) {
|
||||
toast.error("You have reached your request limit for the day.");
|
||||
return;
|
||||
}
|
||||
},
|
||||
onError: (e) => {
|
||||
toast.error(e.message);
|
||||
},
|
||||
});
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
const hasCompletion = completion.length > 0;
|
||||
|
||||
return (
|
||||
<Command className="w-[350px]">
|
||||
{hasCompletion && (
|
||||
<div className="flex max-h-[400px]">
|
||||
<ScrollArea>
|
||||
<div className="prose prose-sm p-2 px-4">
|
||||
<Markdown>{completion}</Markdown>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="text-muted-foreground flex h-12 w-full items-center px-4 text-sm font-medium text-purple-500">
|
||||
<Magic className="mr-2 h-4 w-4 shrink-0" />
|
||||
AI is thinking
|
||||
<div className="mt-1 ml-2">
|
||||
<LoadingOutlined />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && (
|
||||
<>
|
||||
<div className="relative">
|
||||
<CommandInput
|
||||
value={inputValue}
|
||||
onValueChange={setInputValue}
|
||||
autoFocus
|
||||
placeholder={
|
||||
hasCompletion
|
||||
? "Tell AI what to do next"
|
||||
: "Ask AI to edit or generate..."
|
||||
}
|
||||
onFocus={() => addAIHighlight(editor)}
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
className="absolute top-1/2 right-2 h-6 w-6 -translate-y-1/2 rounded-full bg-purple-500 hover:bg-purple-900"
|
||||
onClick={() => {
|
||||
if (completion)
|
||||
return complete(completion, {
|
||||
body: { option: "zap", command: inputValue },
|
||||
}).then(() => setInputValue(""));
|
||||
|
||||
const slice = editor.state.selection.content();
|
||||
const text = editor.storage.markdown.serializer.serialize(
|
||||
slice.content,
|
||||
);
|
||||
|
||||
complete(text, {
|
||||
body: { option: "zap", command: inputValue },
|
||||
}).then(() => setInputValue(""));
|
||||
}}
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{hasCompletion ? (
|
||||
<AICompletionCommands
|
||||
onDiscard={() => {
|
||||
editor.chain().unsetHighlight().focus().run();
|
||||
onOpenChange(false);
|
||||
}}
|
||||
completion={completion}
|
||||
/>
|
||||
) : (
|
||||
<AISelectorCommands
|
||||
onSelect={(value, option) =>
|
||||
complete(value, { body: { option } })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Command>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { EditorBubble, removeAIHighlight, useEditor } from "novel";
|
||||
import { Fragment, type ReactNode, useEffect } from "react";
|
||||
import { Button } from "../../ui/button";
|
||||
import Magic from "../../ui/icons/magic";
|
||||
import { AISelector } from "./ai-selector";
|
||||
|
||||
interface GenerativeMenuSwitchProps {
|
||||
children: ReactNode;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
const GenerativeMenuSwitch = ({
|
||||
children,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: GenerativeMenuSwitchProps) => {
|
||||
const { editor } = useEditor();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open && editor) removeAIHighlight(editor);
|
||||
}, [open]);
|
||||
|
||||
if (!editor) return null;
|
||||
return (
|
||||
<EditorBubble
|
||||
tippyOptions={{
|
||||
placement: open ? "bottom-start" : "top",
|
||||
onHidden: () => {
|
||||
onOpenChange(false);
|
||||
editor.chain().unsetHighlight().run();
|
||||
},
|
||||
}}
|
||||
className="border-muted bg-background flex w-fit max-w-[90vw] overflow-hidden rounded-md border shadow-xl"
|
||||
>
|
||||
{open && <AISelector open={open} onOpenChange={onOpenChange} />}
|
||||
{!open && (
|
||||
<Fragment>
|
||||
<Button
|
||||
className="gap-1 rounded-none text-purple-500"
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(true)}
|
||||
size="sm"
|
||||
>
|
||||
<Magic className="h-5 w-5" />
|
||||
Ask AI
|
||||
</Button>
|
||||
{children}
|
||||
</Fragment>
|
||||
)}
|
||||
</EditorBubble>
|
||||
);
|
||||
};
|
||||
|
||||
export default GenerativeMenuSwitch;
|
||||
60
web/src/components/editor/image-upload.ts
Normal file
60
web/src/components/editor/image-upload.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { createImageUpload } from "novel";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const onUpload = (file: File) => {
|
||||
const promise = fetch("/api/upload", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": file?.type || "application/octet-stream",
|
||||
"x-vercel-filename": file?.name || "image.png",
|
||||
},
|
||||
body: file,
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
toast.promise(
|
||||
promise.then(async (res) => {
|
||||
// Successfully uploaded image
|
||||
if (res.status === 200) {
|
||||
const { url } = (await res.json()) as { url: string };
|
||||
// preload the image
|
||||
const image = new Image();
|
||||
image.src = url;
|
||||
image.onload = () => {
|
||||
resolve(url);
|
||||
};
|
||||
// No blob store configured
|
||||
} else if (res.status === 401) {
|
||||
resolve(file);
|
||||
throw new Error("`BLOB_READ_WRITE_TOKEN` environment variable not found, reading image locally instead.");
|
||||
// Unknown error
|
||||
} else {
|
||||
throw new Error("Error uploading image. Please try again.");
|
||||
}
|
||||
}),
|
||||
{
|
||||
loading: "Uploading image...",
|
||||
success: "Image uploaded successfully.",
|
||||
error: (e) => {
|
||||
reject(e);
|
||||
return e.message;
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const uploadFn = createImageUpload({
|
||||
onUpload,
|
||||
validateFn: (file) => {
|
||||
if (!file.type.includes("image/")) {
|
||||
toast.error("File type not supported.");
|
||||
return false;
|
||||
}
|
||||
if (file.size / 1024 / 1024 > 20) {
|
||||
toast.error("File size too big (max 20MB).");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
173
web/src/components/editor/index.tsx
Normal file
173
web/src/components/editor/index.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
EditorCommand,
|
||||
EditorCommandEmpty,
|
||||
EditorCommandItem,
|
||||
EditorCommandList,
|
||||
EditorContent,
|
||||
type EditorInstance,
|
||||
EditorRoot,
|
||||
ImageResizer,
|
||||
type JSONContent,
|
||||
handleCommandNavigation,
|
||||
handleImageDrop,
|
||||
handleImagePaste,
|
||||
} from "novel";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import { defaultExtensions } from "./extensions";
|
||||
import { ColorSelector } from "./selectors/color-selector";
|
||||
import { LinkSelector } from "./selectors/link-selector";
|
||||
import { MathSelector } from "./selectors/math-selector";
|
||||
import { NodeSelector } from "./selectors/node-selector";
|
||||
import { Separator } from "../ui/separator";
|
||||
|
||||
import GenerativeMenuSwitch from "./generative/generative-menu-switch";
|
||||
import { uploadFn } from "./image-upload";
|
||||
import { TextButtons } from "./selectors/text-buttons";
|
||||
import { slashCommand, suggestionItems } from "./slash-command";
|
||||
import { defaultEditorContent } from "./content";
|
||||
|
||||
import "~/styles/prosemirror.css";
|
||||
|
||||
const hljs = require("highlight.js");
|
||||
|
||||
const extensions = [...defaultExtensions, slashCommand];
|
||||
|
||||
const ReportEditor = () => {
|
||||
const [initialContent, setInitialContent] = useState<null | JSONContent>(
|
||||
null,
|
||||
);
|
||||
const [saveStatus, setSaveStatus] = useState("Saved");
|
||||
const [charsCount, setCharsCount] = useState();
|
||||
|
||||
const [openNode, setOpenNode] = useState(false);
|
||||
const [openColor, setOpenColor] = useState(false);
|
||||
const [openLink, setOpenLink] = useState(false);
|
||||
const [openAI, setOpenAI] = useState(false);
|
||||
|
||||
//Apply Codeblock Highlighting on the HTML from editor.getHTML()
|
||||
const highlightCodeblocks = (content: string) => {
|
||||
const doc = new DOMParser().parseFromString(content, "text/html");
|
||||
doc.querySelectorAll("pre code").forEach((el) => {
|
||||
// @ts-ignore
|
||||
// https://highlightjs.readthedocs.io/en/latest/api.html?highlight=highlightElement#highlightelement
|
||||
hljs.highlightElement(el);
|
||||
});
|
||||
return new XMLSerializer().serializeToString(doc);
|
||||
};
|
||||
|
||||
const debouncedUpdates = useDebouncedCallback(
|
||||
async (editor: EditorInstance) => {
|
||||
const json = editor.getJSON();
|
||||
setCharsCount(editor.storage.characterCount.words());
|
||||
window.localStorage.setItem(
|
||||
"html-content",
|
||||
highlightCodeblocks(editor.getHTML()),
|
||||
);
|
||||
window.localStorage.setItem("novel-content", JSON.stringify(json));
|
||||
window.localStorage.setItem(
|
||||
"markdown",
|
||||
editor.storage.markdown.getMarkdown(),
|
||||
);
|
||||
setSaveStatus("Saved");
|
||||
},
|
||||
500,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const content = window.localStorage.getItem("novel-content");
|
||||
if (content) setInitialContent(JSON.parse(content));
|
||||
else setInitialContent(defaultEditorContent);
|
||||
}, []);
|
||||
|
||||
if (!initialContent) return null;
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<div className="absolute top-5 right-5 z-10 mb-5 flex gap-2">
|
||||
<div className="bg-accent text-muted-foreground rounded-lg px-2 py-1 text-sm">
|
||||
{saveStatus}
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
charsCount
|
||||
? "bg-accent text-muted-foreground rounded-lg px-2 py-1 text-sm"
|
||||
: "hidden"
|
||||
}
|
||||
>
|
||||
{charsCount} Words
|
||||
</div>
|
||||
</div>
|
||||
<EditorRoot>
|
||||
<EditorContent
|
||||
immediatelyRender={false}
|
||||
initialContent={initialContent}
|
||||
extensions={extensions}
|
||||
className="border-muted bg-background relative h-full w-full overflow-auto sm:mb-[calc(20vh)] sm:border sm:shadow-lg"
|
||||
editorProps={{
|
||||
handleDOMEvents: {
|
||||
keydown: (_view, event) => handleCommandNavigation(event),
|
||||
},
|
||||
handlePaste: (view, event) =>
|
||||
handleImagePaste(view, event, uploadFn),
|
||||
handleDrop: (view, event, _slice, moved) =>
|
||||
handleImageDrop(view, event, moved, uploadFn),
|
||||
attributes: {
|
||||
class:
|
||||
"prose prose-base prose-p:my-4 dark:prose-invert prose-headings:font-title font-default focus:outline-none max-w-full",
|
||||
},
|
||||
}}
|
||||
onUpdate={({ editor }) => {
|
||||
debouncedUpdates(editor);
|
||||
setSaveStatus("Unsaved");
|
||||
}}
|
||||
slotAfter={<ImageResizer />}
|
||||
>
|
||||
<EditorCommand className="border-muted bg-background z-50 h-auto max-h-[330px] overflow-y-auto rounded-md border px-1 py-2 shadow-md transition-all">
|
||||
<EditorCommandEmpty className="text-muted-foreground px-2">
|
||||
No results
|
||||
</EditorCommandEmpty>
|
||||
<EditorCommandList>
|
||||
{suggestionItems.map((item) => (
|
||||
<EditorCommandItem
|
||||
value={item.title}
|
||||
onCommand={(val) => item.command?.(val)}
|
||||
className="hover:bg-accent aria-selected:bg-accent flex w-full items-center space-x-2 rounded-md px-2 py-1 text-left text-sm"
|
||||
key={item.title}
|
||||
>
|
||||
<div className="border-muted bg-background flex h-10 w-10 items-center justify-center rounded-md border">
|
||||
{item.icon}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{item.title}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</EditorCommandItem>
|
||||
))}
|
||||
</EditorCommandList>
|
||||
</EditorCommand>
|
||||
|
||||
<GenerativeMenuSwitch open={openAI} onOpenChange={setOpenAI}>
|
||||
<Separator orientation="vertical" />
|
||||
<NodeSelector open={openNode} onOpenChange={setOpenNode} />
|
||||
<Separator orientation="vertical" />
|
||||
|
||||
<LinkSelector open={openLink} onOpenChange={setOpenLink} />
|
||||
<Separator orientation="vertical" />
|
||||
<MathSelector />
|
||||
<Separator orientation="vertical" />
|
||||
<TextButtons />
|
||||
<Separator orientation="vertical" />
|
||||
<ColorSelector open={openColor} onOpenChange={setOpenColor} />
|
||||
</GenerativeMenuSwitch>
|
||||
</EditorContent>
|
||||
</EditorRoot>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReportEditor;
|
||||
192
web/src/components/editor/selectors/color-selector.tsx
Normal file
192
web/src/components/editor/selectors/color-selector.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
import { EditorBubbleItem, useEditor } from "novel";
|
||||
|
||||
import { Button } from "../../ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../../ui/popover";
|
||||
export interface BubbleColorMenuItem {
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const TEXT_COLORS: BubbleColorMenuItem[] = [
|
||||
{
|
||||
name: "Default",
|
||||
color: "var(--novel-black)",
|
||||
},
|
||||
{
|
||||
name: "Purple",
|
||||
color: "#9333EA",
|
||||
},
|
||||
{
|
||||
name: "Red",
|
||||
color: "#E00000",
|
||||
},
|
||||
{
|
||||
name: "Yellow",
|
||||
color: "#EAB308",
|
||||
},
|
||||
{
|
||||
name: "Blue",
|
||||
color: "#2563EB",
|
||||
},
|
||||
{
|
||||
name: "Green",
|
||||
color: "#008A00",
|
||||
},
|
||||
{
|
||||
name: "Orange",
|
||||
color: "#FFA500",
|
||||
},
|
||||
{
|
||||
name: "Pink",
|
||||
color: "#BA4081",
|
||||
},
|
||||
{
|
||||
name: "Gray",
|
||||
color: "#A8A29E",
|
||||
},
|
||||
];
|
||||
|
||||
const HIGHLIGHT_COLORS: BubbleColorMenuItem[] = [
|
||||
{
|
||||
name: "Default",
|
||||
color: "var(--novel-highlight-default)",
|
||||
},
|
||||
{
|
||||
name: "Purple",
|
||||
color: "var(--novel-highlight-purple)",
|
||||
},
|
||||
{
|
||||
name: "Red",
|
||||
color: "var(--novel-highlight-red)",
|
||||
},
|
||||
{
|
||||
name: "Yellow",
|
||||
color: "var(--novel-highlight-yellow)",
|
||||
},
|
||||
{
|
||||
name: "Blue",
|
||||
color: "var(--novel-highlight-blue)",
|
||||
},
|
||||
{
|
||||
name: "Green",
|
||||
color: "var(--novel-highlight-green)",
|
||||
},
|
||||
{
|
||||
name: "Orange",
|
||||
color: "var(--novel-highlight-orange)",
|
||||
},
|
||||
{
|
||||
name: "Pink",
|
||||
color: "var(--novel-highlight-pink)",
|
||||
},
|
||||
{
|
||||
name: "Gray",
|
||||
color: "var(--novel-highlight-gray)",
|
||||
},
|
||||
];
|
||||
|
||||
interface ColorSelectorProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const ColorSelector = ({ open, onOpenChange }: ColorSelectorProps) => {
|
||||
const { editor } = useEditor();
|
||||
|
||||
if (!editor) return null;
|
||||
const activeColorItem = TEXT_COLORS.find(({ color }) =>
|
||||
editor.isActive("textStyle", { color }),
|
||||
);
|
||||
|
||||
const activeHighlightItem = HIGHLIGHT_COLORS.find(({ color }) =>
|
||||
editor.isActive("highlight", { color }),
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover modal={true} open={open} onOpenChange={onOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button size="sm" className="gap-2 rounded-none" variant="ghost">
|
||||
<span
|
||||
className="rounded-sm px-1"
|
||||
style={{
|
||||
color: activeColorItem?.color,
|
||||
backgroundColor: activeHighlightItem?.color,
|
||||
}}
|
||||
>
|
||||
A
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
sideOffset={5}
|
||||
className="my-1 flex max-h-80 w-48 flex-col overflow-hidden overflow-y-auto rounded border p-1 shadow-xl"
|
||||
align="start"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<div className="text-muted-foreground my-1 px-2 text-sm font-semibold">
|
||||
Color
|
||||
</div>
|
||||
{TEXT_COLORS.map(({ name, color }) => (
|
||||
<EditorBubbleItem
|
||||
key={name}
|
||||
onSelect={() => {
|
||||
editor.commands.unsetColor();
|
||||
name !== "Default" &&
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setColor(color || "")
|
||||
.run();
|
||||
onOpenChange(false);
|
||||
}}
|
||||
className="hover:bg-accent flex cursor-pointer items-center justify-between px-2 py-1 text-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="rounded-sm border px-2 py-px font-medium"
|
||||
style={{ color }}
|
||||
>
|
||||
A
|
||||
</div>
|
||||
<span>{name}</span>
|
||||
</div>
|
||||
</EditorBubbleItem>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground my-1 px-2 text-sm font-semibold">
|
||||
Background
|
||||
</div>
|
||||
{HIGHLIGHT_COLORS.map(({ name, color }) => (
|
||||
<EditorBubbleItem
|
||||
key={name}
|
||||
onSelect={() => {
|
||||
editor.commands.unsetHighlight();
|
||||
name !== "Default" &&
|
||||
editor.chain().focus().setHighlight({ color }).run();
|
||||
onOpenChange(false);
|
||||
}}
|
||||
className="hover:bg-accent flex cursor-pointer items-center justify-between px-2 py-1 text-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="rounded-sm border px-2 py-px font-medium"
|
||||
style={{ backgroundColor: color }}
|
||||
>
|
||||
A
|
||||
</div>
|
||||
<span>{name}</span>
|
||||
</div>
|
||||
{editor.isActive("highlight", { color }) && (
|
||||
<Check className="h-4 w-4" />
|
||||
)}
|
||||
</EditorBubbleItem>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
104
web/src/components/editor/selectors/link-selector.tsx
Normal file
104
web/src/components/editor/selectors/link-selector.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Button } from "../../ui/button";
|
||||
import { PopoverContent } from "../../ui/popover";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import { Popover, PopoverTrigger } from "@radix-ui/react-popover";
|
||||
import { Check, Trash } from "lucide-react";
|
||||
import { useEditor } from "novel";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export function isValidUrl(url: string) {
|
||||
try {
|
||||
new URL(url);
|
||||
return true;
|
||||
} catch (_e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
export function getUrlFromString(str: string) {
|
||||
if (isValidUrl(str)) return str;
|
||||
try {
|
||||
if (str.includes(".") && !str.includes(" ")) {
|
||||
return new URL(`https://${str}`).toString();
|
||||
}
|
||||
} catch (_e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
interface LinkSelectorProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const LinkSelector = ({ open, onOpenChange }: LinkSelectorProps) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const { editor } = useEditor();
|
||||
|
||||
// Autofocus on input by default
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
});
|
||||
if (!editor) return null;
|
||||
|
||||
return (
|
||||
<Popover modal={true} open={open} onOpenChange={onOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="gap-2 rounded-none border-none"
|
||||
>
|
||||
<p className="text-base">↗</p>
|
||||
<p
|
||||
className={cn("underline decoration-stone-400 underline-offset-4", {
|
||||
"text-blue-500": editor.isActive("link"),
|
||||
})}
|
||||
>
|
||||
Link
|
||||
</p>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-60 p-0" sideOffset={10}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
const target = e.currentTarget as HTMLFormElement;
|
||||
e.preventDefault();
|
||||
const input = target[0] as HTMLInputElement;
|
||||
const url = getUrlFromString(input.value);
|
||||
if (url) {
|
||||
editor.chain().focus().setLink({ href: url }).run();
|
||||
onOpenChange(false);
|
||||
}
|
||||
}}
|
||||
className="flex p-1"
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Paste a link"
|
||||
className="bg-background flex-1 p-1 text-sm outline-none"
|
||||
defaultValue={editor.getAttributes("link").href || ""}
|
||||
/>
|
||||
{editor.getAttributes("link").href ? (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
type="button"
|
||||
className="flex h-8 items-center rounded-sm p-1 text-red-600 transition-all hover:bg-red-100 dark:hover:bg-red-800"
|
||||
onClick={() => {
|
||||
editor.chain().focus().unsetLink().run();
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="icon" className="h-8">
|
||||
<Check className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
35
web/src/components/editor/selectors/math-selector.tsx
Normal file
35
web/src/components/editor/selectors/math-selector.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Button } from "../../ui/button";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import { SigmaIcon } from "lucide-react";
|
||||
import { useEditor } from "novel";
|
||||
|
||||
export const MathSelector = () => {
|
||||
const { editor } = useEditor();
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-12 rounded-none"
|
||||
onClick={(evt) => {
|
||||
if (editor.isActive("math")) {
|
||||
editor.chain().focus().unsetLatex().run();
|
||||
} else {
|
||||
const { from, to } = editor.state.selection;
|
||||
const latex = editor.state.doc.textBetween(from, to);
|
||||
|
||||
if (!latex) return;
|
||||
|
||||
editor.chain().focus().setLatex({ latex }).run();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SigmaIcon
|
||||
className={cn("size-4", { "text-blue-500": editor.isActive("math") })}
|
||||
strokeWidth={2.3}
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
144
web/src/components/editor/selectors/node-selector.tsx
Normal file
144
web/src/components/editor/selectors/node-selector.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import {
|
||||
Check,
|
||||
CheckSquare,
|
||||
ChevronDown,
|
||||
Code,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
ListOrdered,
|
||||
type LucideIcon,
|
||||
TextIcon,
|
||||
TextQuote,
|
||||
} from "lucide-react";
|
||||
import { EditorBubbleItem, useEditor } from "novel";
|
||||
|
||||
import { Button } from "../../ui/button";
|
||||
import { PopoverContent, PopoverTrigger } from "../../ui/popover";
|
||||
import { Popover } from "@radix-ui/react-popover";
|
||||
|
||||
export type SelectorItem = {
|
||||
name: string;
|
||||
icon: LucideIcon;
|
||||
command: (
|
||||
editor: NonNullable<ReturnType<typeof useEditor>["editor"]>,
|
||||
) => void;
|
||||
isActive: (
|
||||
editor: NonNullable<ReturnType<typeof useEditor>["editor"]>,
|
||||
) => boolean;
|
||||
};
|
||||
|
||||
const items: SelectorItem[] = [
|
||||
{
|
||||
name: "Text",
|
||||
icon: TextIcon,
|
||||
command: (editor) => editor.chain().focus().clearNodes().run(),
|
||||
// I feel like there has to be a more efficient way to do this – feel free to PR if you know how!
|
||||
isActive: (editor) =>
|
||||
editor.isActive("paragraph") &&
|
||||
!editor.isActive("bulletList") &&
|
||||
!editor.isActive("orderedList"),
|
||||
},
|
||||
{
|
||||
name: "Heading 1",
|
||||
icon: Heading1,
|
||||
command: (editor) =>
|
||||
editor.chain().focus().clearNodes().toggleHeading({ level: 1 }).run(),
|
||||
isActive: (editor) => editor.isActive("heading", { level: 1 }),
|
||||
},
|
||||
{
|
||||
name: "Heading 2",
|
||||
icon: Heading2,
|
||||
command: (editor) =>
|
||||
editor.chain().focus().clearNodes().toggleHeading({ level: 2 }).run(),
|
||||
isActive: (editor) => editor.isActive("heading", { level: 2 }),
|
||||
},
|
||||
{
|
||||
name: "Heading 3",
|
||||
icon: Heading3,
|
||||
command: (editor) =>
|
||||
editor.chain().focus().clearNodes().toggleHeading({ level: 3 }).run(),
|
||||
isActive: (editor) => editor.isActive("heading", { level: 3 }),
|
||||
},
|
||||
{
|
||||
name: "To-do List",
|
||||
icon: CheckSquare,
|
||||
command: (editor) =>
|
||||
editor.chain().focus().clearNodes().toggleTaskList().run(),
|
||||
isActive: (editor) => editor.isActive("taskItem"),
|
||||
},
|
||||
{
|
||||
name: "Bullet List",
|
||||
icon: ListOrdered,
|
||||
command: (editor) =>
|
||||
editor.chain().focus().clearNodes().toggleBulletList().run(),
|
||||
isActive: (editor) => editor.isActive("bulletList"),
|
||||
},
|
||||
{
|
||||
name: "Numbered List",
|
||||
icon: ListOrdered,
|
||||
command: (editor) =>
|
||||
editor.chain().focus().clearNodes().toggleOrderedList().run(),
|
||||
isActive: (editor) => editor.isActive("orderedList"),
|
||||
},
|
||||
{
|
||||
name: "Quote",
|
||||
icon: TextQuote,
|
||||
command: (editor) =>
|
||||
editor.chain().focus().clearNodes().toggleBlockquote().run(),
|
||||
isActive: (editor) => editor.isActive("blockquote"),
|
||||
},
|
||||
{
|
||||
name: "Code",
|
||||
icon: Code,
|
||||
command: (editor) =>
|
||||
editor.chain().focus().clearNodes().toggleCodeBlock().run(),
|
||||
isActive: (editor) => editor.isActive("codeBlock"),
|
||||
},
|
||||
];
|
||||
interface NodeSelectorProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const NodeSelector = ({ open, onOpenChange }: NodeSelectorProps) => {
|
||||
const { editor } = useEditor();
|
||||
if (!editor) return null;
|
||||
const activeItem = items.filter((item) => item.isActive(editor)).pop() ?? {
|
||||
name: "Multiple",
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover modal={true} open={open} onOpenChange={onOpenChange}>
|
||||
<PopoverTrigger
|
||||
asChild
|
||||
className="hover:bg-accent gap-2 rounded-none border-none focus:ring-0"
|
||||
>
|
||||
<Button size="sm" variant="ghost" className="gap-2">
|
||||
<span className="text-sm whitespace-nowrap">{activeItem.name}</span>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent sideOffset={5} align="start" className="w-48 p-1">
|
||||
{items.map((item) => (
|
||||
<EditorBubbleItem
|
||||
key={item.name}
|
||||
onSelect={(editor) => {
|
||||
item.command(editor);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
className="hover:bg-accent flex cursor-pointer items-center justify-between rounded-sm px-2 py-1 text-sm"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="rounded-sm border p-1">
|
||||
<item.icon className="h-3 w-3" />
|
||||
</div>
|
||||
<span>{item.name}</span>
|
||||
</div>
|
||||
{activeItem.name === item.name && <Check className="h-4 w-4" />}
|
||||
</EditorBubbleItem>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
73
web/src/components/editor/selectors/text-buttons.tsx
Normal file
73
web/src/components/editor/selectors/text-buttons.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Button } from "../../ui/button";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import {
|
||||
BoldIcon,
|
||||
CodeIcon,
|
||||
ItalicIcon,
|
||||
StrikethroughIcon,
|
||||
UnderlineIcon,
|
||||
} from "lucide-react";
|
||||
import { EditorBubbleItem, useEditor } from "novel";
|
||||
import type { SelectorItem } from "./node-selector";
|
||||
|
||||
export const TextButtons = () => {
|
||||
const { editor } = useEditor();
|
||||
if (!editor) return null;
|
||||
const items: SelectorItem[] = [
|
||||
{
|
||||
name: "bold",
|
||||
isActive: (editor) => editor.isActive("bold"),
|
||||
command: (editor) => editor.chain().focus().toggleBold().run(),
|
||||
icon: BoldIcon,
|
||||
},
|
||||
{
|
||||
name: "italic",
|
||||
isActive: (editor) => editor.isActive("italic"),
|
||||
command: (editor) => editor.chain().focus().toggleItalic().run(),
|
||||
icon: ItalicIcon,
|
||||
},
|
||||
{
|
||||
name: "underline",
|
||||
isActive: (editor) => editor.isActive("underline"),
|
||||
command: (editor) => editor.chain().focus().toggleUnderline().run(),
|
||||
icon: UnderlineIcon,
|
||||
},
|
||||
{
|
||||
name: "strike",
|
||||
isActive: (editor) => editor.isActive("strike"),
|
||||
command: (editor) => editor.chain().focus().toggleStrike().run(),
|
||||
icon: StrikethroughIcon,
|
||||
},
|
||||
{
|
||||
name: "code",
|
||||
isActive: (editor) => editor.isActive("code"),
|
||||
command: (editor) => editor.chain().focus().toggleCode().run(),
|
||||
icon: CodeIcon,
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div className="flex">
|
||||
{items.map((item) => (
|
||||
<EditorBubbleItem
|
||||
key={item.name}
|
||||
onSelect={(editor) => {
|
||||
item.command(editor);
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
className="rounded-none"
|
||||
variant="ghost"
|
||||
type="button"
|
||||
>
|
||||
<item.icon
|
||||
className={cn("h-4 w-4", {
|
||||
"text-blue-500": item.isActive(editor),
|
||||
})}
|
||||
/>
|
||||
</Button>
|
||||
</EditorBubbleItem>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
219
web/src/components/editor/slash-command.tsx
Normal file
219
web/src/components/editor/slash-command.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import {
|
||||
Brain,
|
||||
CheckSquare,
|
||||
Code,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
ImageIcon,
|
||||
List,
|
||||
ListOrdered,
|
||||
MessageSquarePlus,
|
||||
Text,
|
||||
TextQuote,
|
||||
Twitter,
|
||||
Youtube,
|
||||
} from "lucide-react";
|
||||
import { Command, createSuggestionItems, renderItems } from "novel";
|
||||
import { uploadFn } from "./image-upload";
|
||||
|
||||
export const suggestionItems = createSuggestionItems([
|
||||
{
|
||||
title: "Send Feedback",
|
||||
description: "Let us know how we can improve.",
|
||||
icon: <MessageSquarePlus size={18} />,
|
||||
command: ({ editor, range }) => {
|
||||
editor.chain().focus().deleteRange(range).run();
|
||||
window.open("/feedback", "_blank");
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Text",
|
||||
description: "Just start typing with plain text.",
|
||||
searchTerms: ["p", "paragraph"],
|
||||
icon: <Text size={18} />,
|
||||
command: ({ editor, range }) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.toggleNode("paragraph", "paragraph")
|
||||
.run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "To-do List",
|
||||
description: "Track tasks with a to-do list.",
|
||||
searchTerms: ["todo", "task", "list", "check", "checkbox"],
|
||||
icon: <CheckSquare size={18} />,
|
||||
command: ({ editor, range }) => {
|
||||
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 }) => {
|
||||
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 }) => {
|
||||
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 }) => {
|
||||
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 }) => {
|
||||
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 }) => {
|
||||
editor.chain().focus().deleteRange(range).toggleOrderedList().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Quote",
|
||||
description: "Capture a quote.",
|
||||
searchTerms: ["blockquote"],
|
||||
icon: <TextQuote size={18} />,
|
||||
command: ({ editor, range }) =>
|
||||
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 }) =>
|
||||
editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
|
||||
},
|
||||
{
|
||||
title: "Image",
|
||||
description: "Upload an image from your computer.",
|
||||
searchTerms: ["photo", "picture", "media"],
|
||||
icon: <ImageIcon size={18} />,
|
||||
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];
|
||||
if (!file) return;
|
||||
const pos = editor.view.state.selection.from;
|
||||
uploadFn(file, editor.view, pos);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Youtube",
|
||||
description: "Embed a Youtube video.",
|
||||
searchTerms: ["video", "youtube", "embed"],
|
||||
icon: <Youtube size={18} />,
|
||||
command: ({ editor, range }) => {
|
||||
const videoLink = prompt("Please enter Youtube Video Link");
|
||||
//From https://regexr.com/3dj5t
|
||||
const ytRegex = new RegExp(
|
||||
/^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w\-]+\?v=|embed\/|v\/)?)([\w\-]+)(\S+)?$/,
|
||||
);
|
||||
|
||||
if (videoLink && ytRegex.test(videoLink)) {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.setYoutubeVideo({
|
||||
src: videoLink,
|
||||
})
|
||||
.run();
|
||||
} else {
|
||||
if (videoLink !== null) {
|
||||
alert("Please enter a correct Youtube Video Link");
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Twitter",
|
||||
description: "Embed a Tweet.",
|
||||
searchTerms: ["twitter", "embed"],
|
||||
icon: <Twitter size={18} />,
|
||||
command: ({ editor, range }) => {
|
||||
const tweetLink = prompt("Please enter Twitter Link");
|
||||
const tweetRegex = new RegExp(
|
||||
/^https?:\/\/(www\.)?x\.com\/([a-zA-Z0-9_]{1,15})(\/status\/(\d+))?(\/\S*)?$/,
|
||||
);
|
||||
|
||||
if (tweetLink && tweetRegex.test(tweetLink)) {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.setTweet({
|
||||
src: tweetLink,
|
||||
})
|
||||
.run();
|
||||
} else {
|
||||
if (tweetLink !== null) {
|
||||
alert("Please enter a correct Twitter Link");
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export const slashCommand = Command.configure({
|
||||
suggestion: {
|
||||
items: () => suggestionItems,
|
||||
render: renderItems,
|
||||
},
|
||||
});
|
||||
177
web/src/components/ui/command.tsx
Normal file
177
web/src/components/ui/command.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog"
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string
|
||||
description?: string
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="command-input-wrapper"
|
||||
className="flex h-9 items-center gap-2 border-b px-3"
|
||||
>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("bg-border -mx-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
32
web/src/components/ui/icons/magic.tsx
Normal file
32
web/src/components/ui/icons/magic.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
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}
|
||||
>
|
||||
<title>Magic AI icon</title>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
48
web/src/components/ui/popover.tsx
Normal file
48
web/src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
@@ -1,5 +1,6 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@@ -128,6 +129,16 @@
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--brand: #007aff;
|
||||
|
||||
--novel-highlight-default: #ffffff;
|
||||
--novel-highlight-purple: #f6f3f8;
|
||||
--novel-highlight-red: #fdebeb;
|
||||
--novel-highlight-yellow: #fbf4a2;
|
||||
--novel-highlight-blue: #c1ecf9;
|
||||
--novel-highlight-green: #acf79f;
|
||||
--novel-highlight-orange: #faebdd;
|
||||
--novel-highlight-pink: #faf1f5;
|
||||
--novel-highlight-gray: #f1f1ef;
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -164,6 +175,16 @@
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
--brand: #4087f4;
|
||||
|
||||
--novel-highlight-default: #000000;
|
||||
--novel-highlight-purple: #3f2c4b;
|
||||
--novel-highlight-red: #5c1a1a;
|
||||
--novel-highlight-yellow: #5c4b1a;
|
||||
--novel-highlight-blue: #1a3d5c;
|
||||
--novel-highlight-green: #1a5c20;
|
||||
--novel-highlight-orange: #5c3a1a;
|
||||
--novel-highlight-pink: #5c1a3a;
|
||||
--novel-highlight-gray: #3a3a3a;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
281
web/src/styles/prosemirror.css
Normal file
281
web/src/styles/prosemirror.css
Normal file
@@ -0,0 +1,281 @@
|
||||
@import "./globals.css";
|
||||
|
||||
.ProseMirror {
|
||||
@apply p-12 px-8 sm:px-12;
|
||||
}
|
||||
|
||||
.ProseMirror .is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
color: hsl(var(--muted-foreground));
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
.ProseMirror p.is-empty::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
color: hsl(var(--muted-foreground));
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/* Custom image styles */
|
||||
|
||||
.ProseMirror img {
|
||||
transition: filter 0.1s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
filter: brightness(90%);
|
||||
}
|
||||
|
||||
&.ProseMirror-selectednode {
|
||||
outline: 3px solid #5abbf7;
|
||||
filter: brightness(90%);
|
||||
}
|
||||
}
|
||||
|
||||
.img-placeholder {
|
||||
position: relative;
|
||||
|
||||
&:before {
|
||||
content: "";
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid var(--novel-stone-200);
|
||||
border-top-color: var(--novel-stone-800);
|
||||
animation: spinning 0.6s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror pre {
|
||||
background: #0d0d0d;
|
||||
border-radius: 0.5rem;
|
||||
color: #fff;
|
||||
font-family: "JetBrainsMono", monospace;
|
||||
padding: 0.75rem 1rem;
|
||||
|
||||
code {
|
||||
background: none;
|
||||
color: inherit;
|
||||
font-size: 0.8rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.hljs-comment,
|
||||
.hljs-quote {
|
||||
color: #616161;
|
||||
}
|
||||
|
||||
.hljs-variable,
|
||||
.hljs-template-variable,
|
||||
.hljs-attribute,
|
||||
.hljs-tag,
|
||||
.hljs-name,
|
||||
.hljs-regexp,
|
||||
.hljs-link,
|
||||
.hljs-name,
|
||||
.hljs-selector-id,
|
||||
.hljs-selector-class {
|
||||
color: #f98181;
|
||||
}
|
||||
|
||||
.hljs-number,
|
||||
.hljs-meta,
|
||||
.hljs-built_in,
|
||||
.hljs-builtin-name,
|
||||
.hljs-literal,
|
||||
.hljs-type,
|
||||
.hljs-params {
|
||||
color: #fbbc88;
|
||||
}
|
||||
|
||||
.hljs-string,
|
||||
.hljs-symbol,
|
||||
.hljs-bullet {
|
||||
color: #b9f18d;
|
||||
}
|
||||
|
||||
.hljs-title,
|
||||
.hljs-section {
|
||||
color: #faf594;
|
||||
}
|
||||
|
||||
.hljs-keyword,
|
||||
.hljs-selector-tag {
|
||||
color: #70cff8;
|
||||
}
|
||||
|
||||
.hljs-emphasis {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.hljs-strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spinning {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom TODO list checkboxes – shoutout to this awesome tutorial: https://moderncss.dev/pure-css-custom-checkbox-style/ */
|
||||
|
||||
ul[data-type="taskList"] li > label {
|
||||
margin-right: 0.2rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
ul[data-type="taskList"] li > label {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
ul[data-type="taskList"] li > label input[type="checkbox"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background-color: hsl(var(--background));
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
width: 1.2em;
|
||||
height: 1.2em;
|
||||
position: relative;
|
||||
top: 5px;
|
||||
border: 2px solid hsl(var(--border));
|
||||
margin-right: 0.3rem;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
|
||||
&:hover {
|
||||
background-color: hsl(var(--accent));
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: hsl(var(--accent));
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
width: 0.65em;
|
||||
height: 0.65em;
|
||||
transform: scale(0);
|
||||
transition: 120ms transform ease-in-out;
|
||||
box-shadow: inset 1em 1em;
|
||||
transform-origin: center;
|
||||
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
|
||||
}
|
||||
|
||||
&:checked::before {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
ul[data-type="taskList"] li[data-checked="true"] > div > p {
|
||||
color: var(--muted-foreground);
|
||||
text-decoration: line-through;
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
|
||||
/* Overwrite tippy-box original max-width */
|
||||
|
||||
.tippy-box {
|
||||
max-width: 400px !important;
|
||||
}
|
||||
|
||||
.ProseMirror:not(.dragging) .ProseMirror-selectednode {
|
||||
outline: none !important;
|
||||
background-color: var(--novel-highlight-blue);
|
||||
transition: background-color 0.2s;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
position: fixed;
|
||||
opacity: 1;
|
||||
transition: opacity ease-in 0.2s;
|
||||
border-radius: 0.25rem;
|
||||
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10' style='fill: rgba(0, 0, 0, 0.5)'%3E%3Cpath d='M3,2 C2.44771525,2 2,1.55228475 2,1 C2,0.44771525 2.44771525,0 3,0 C3.55228475,0 4,0.44771525 4,1 C4,1.55228475 3.55228475,2 3,2 Z M3,6 C2.44771525,6 2,5.55228475 2,5 C2,4.44771525 2.44771525,4 3,4 C3.55228475,4 4,4.44771525 4,5 C4,5.55228475 3.55228475,6 3,6 Z M3,10 C2.44771525,10 2,9.55228475 2,9 C2,8.44771525 2.44771525,8 3,8 C3.55228475,8 4,8.44771525 4,9 C4,9.55228475 3.55228475,10 3,10 Z M7,2 C6.44771525,2 6,1.55228475 6,1 C6,0.44771525 6.44771525,0 7,0 C7.55228475,0 8,0.44771525 8,1 C8,1.55228475 7.55228475,2 7,2 Z M7,6 C6.44771525,6 6,5.55228475 6,5 C6,4.44771525 6.44771525,4 7,4 C7.55228475,4 8,4.44771525 8,5 C8,5.55228475 7.55228475,6 7,6 Z M7,10 C6.44771525,10 6,9.55228475 6,9 C6,8.44771525 6.44771525,8 7,8 C7.55228475,8 8,8.44771525 8,9 C8,9.55228475 7.55228475,10 7,10 Z'%3E%3C/path%3E%3C/svg%3E");
|
||||
background-size: calc(0.5em + 0.375rem) calc(0.5em + 0.375rem);
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
width: 1.2rem;
|
||||
height: 1.5rem;
|
||||
z-index: 50;
|
||||
cursor: grab;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--novel-stone-100);
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--novel-stone-200);
|
||||
transition: background-color 0.2s;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
&.hide {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.dark .drag-handle {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10' style='fill: rgba(255, 255, 255, 0.5)'%3E%3Cpath d='M3,2 C2.44771525,2 2,1.55228475 2,1 C2,0.44771525 2.44771525,0 3,0 C3.55228475,0 4,0.44771525 4,1 C4,1.55228475 3.55228475,2 3,2 Z M3,6 C2.44771525,6 2,5.55228475 2,5 C2,4.44771525 2.44771525,4 3,4 C3.55228475,4 4,4.44771525 4,5 C4,5.55228475 3.55228475,6 3,6 Z M3,10 C2.44771525,10 2,9.55228475 2,9 C2,8.44771525 2.44771525,8 3,8 C3.55228475,8 4,8.44771525 4,9 C4,9.55228475 3.55228475,10 3,10 Z M7,2 C6.44771525,2 6,1.55228475 6,1 C6,0.44771525 6.44771525,0 7,0 C7.55228475,0 8,0.44771525 8,1 C8,1.55228475 7.55228475,2 7,2 Z M7,6 C6.44771525,6 6,5.55228475 6,5 C6,4.44771525 6.44771525,4 7,4 C7.55228475,4 8,4.44771525 8,5 C8,5.55228475 7.55228475,6 7,6 Z M7,10 C6.44771525,10 6,9.55228475 6,9 C6,8.44771525 6.44771525,8 7,8 C7.55228475,8 8,8.44771525 8,9 C8,9.55228475 7.55228475,10 7,10 Z'%3E%3C/path%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* Custom Youtube Video CSS */
|
||||
iframe {
|
||||
border: 8px solid #ffd00027;
|
||||
border-radius: 4px;
|
||||
min-width: 200px;
|
||||
min-height: 200px;
|
||||
display: block;
|
||||
outline: 0px solid transparent;
|
||||
}
|
||||
|
||||
div[data-youtube-video] > iframe {
|
||||
cursor: move;
|
||||
aspect-ratio: 16 / 9;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ProseMirror-selectednode iframe {
|
||||
transition: outline 0.15s;
|
||||
outline: 6px solid #fbbf24;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 480px) {
|
||||
div[data-youtube-video] > iframe {
|
||||
max-height: 50px;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 720px) {
|
||||
div[data-youtube-video] > iframe {
|
||||
max-height: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
/* CSS for bold coloring and highlighting issue*/
|
||||
span[style] > strong {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
mark[style] > strong {
|
||||
color: inherit;
|
||||
}
|
||||
Reference in New Issue
Block a user