Compare commits

...

1 Commits

Author SHA1 Message Date
Aaryan Khandelwal
a5fa46929b feat: toggle heading block 2024-10-21 16:50:30 +05:30
15 changed files with 229 additions and 9 deletions

View File

@@ -18,6 +18,7 @@ import {
CustomLinkExtension,
CustomMention,
CustomQuoteExtension,
CustomToggleHeadingExtension,
CustomTypographyExtension,
DropHandlerExtension,
ImageExtension,
@@ -155,5 +156,6 @@ export const CoreEditorExtensions = (args: TArguments) => {
}),
CharacterCount,
CustomColorExtension,
CustomToggleHeadingExtension,
];
};

View File

@@ -8,6 +8,7 @@ export * from "./issue-embed";
export * from "./mentions";
export * from "./slash-commands";
export * from "./table";
export * from "./toggle-heading";
export * from "./typography";
export * from "./core-without-props";
export * from "./custom-code-inline";

View File

@@ -22,6 +22,7 @@ import {
HeadingListExtension,
CustomReadOnlyImageExtension,
CustomColorExtension,
CustomToggleHeadingReadOnlyExtension,
} from "@/extensions";
// helpers
import { isValidHttpUrl } from "@/helpers/common";
@@ -124,5 +125,6 @@ export const CoreReadOnlyEditorExtensions = (props: Props) => {
CharacterCount,
CustomColorExtension,
HeadingListExtension,
CustomToggleHeadingReadOnlyExtension,
];
};

View File

@@ -10,6 +10,7 @@ import {
Heading6,
ImageIcon,
List,
ListCollapse,
ListOrdered,
ListTodo,
MinusSquare,
@@ -34,6 +35,7 @@ import {
toggleTextColor,
toggleBackgroundColor,
insertImage,
insertToggleHeading,
} from "@/helpers/editor-commands";
// types
import { CommandProps, ISlashCommandItem } from "@/types";
@@ -49,7 +51,8 @@ export const getSlashCommandFilteredSections =
({ query }: { query: string }): TSlashCommandSection[] => {
const SLASH_COMMAND_SECTIONS: TSlashCommandSection[] = [
{
key: "general",
key: "basic",
title: "Basic blocks",
items: [
{
commandKey: "text",
@@ -193,6 +196,39 @@ export const getSlashCommandFilteredSections =
},
],
},
{
key: "advanced",
title: "Advanced blocks",
items: [
{
commandKey: "toggle-heading",
key: "toggle-heading-1",
title: "Toggle heading 1",
icon: <ListCollapse className="size-3.5" />,
description: "Insert toggle heading 1",
searchTerms: ["toggle", "heading", "collapse", "disclosure", "accordion"],
command: ({ editor, range }) => insertToggleHeading(1, editor, range),
},
{
commandKey: "toggle-heading",
key: "toggle-heading-2",
title: "Toggle heading 2",
icon: <ListCollapse className="size-3.5" />,
description: "Insert toggle heading 2",
searchTerms: ["toggle", "heading", "collapse", "disclosure", "accordion"],
command: ({ editor, range }) => insertToggleHeading(2, editor, range),
},
{
commandKey: "toggle-heading",
key: "toggle-heading-3",
title: "Toggle heading 3",
icon: <ListCollapse className="size-3.5" />,
description: "Insert toggle heading 3",
searchTerms: ["toggle", "heading", "collapse", "disclosure", "accordion"],
command: ({ editor, range }) => insertToggleHeading(3, editor, range),
},
],
},
{
key: "text-color",
title: "Colors",

View File

@@ -0,0 +1,41 @@
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { ChevronRight } from "lucide-react";
// types
import { TToggleHeadingBlockAttributes } from "./types";
import { cn } from "@/helpers/common";
type Props = NodeViewProps & {
node: NodeViewProps["node"] & {
attrs: TToggleHeadingBlockAttributes;
};
};
export const CustomToggleHeadingBlock: React.FC<Props> = (props) => {
const { node, updateAttributes } = props;
// derived values
const headingLevel = Number(node.attrs["data-heading-level"] ?? 1);
const isToggleOpen = node.attrs["data-toggle-status"] === "open";
return (
<NodeViewWrapper className="editor-toggle-heading-component flex items-start gap-1 my-2">
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
updateAttributes({
"data-toggle-status": isToggleOpen ? "close" : "open",
});
}}
className="flex-shrink-0 size-5 grid place-items-center rounded hover:bg-custom-background-80 transition-colors"
>
<ChevronRight
className={cn("size-3.5 transition-all", {
"rotate-90": isToggleOpen,
})}
/>
</button>
<NodeViewContent as="div" className="w-full break-words" />
</NodeViewWrapper>
);
};

View File

@@ -0,0 +1,49 @@
import { Node, mergeAttributes } from "@tiptap/core";
import { Node as NodeType } from "@tiptap/pm/model";
import { MarkdownSerializerState } from "@tiptap/pm/markdown";
// Extend Tiptap's Commands interface
declare module "@tiptap/core" {
interface Commands<ReturnType> {
toggleHeadingComponent: {
insertToggleHeading: ({ headingLevel }: { headingLevel: number }) => ReturnType;
};
}
}
export const CustomToggleHeadingExtensionConfig = Node.create({
name: "toggleHeadingComponent",
group: "block",
content: "block+",
addAttributes() {
return {
"data-heading-level": {
default: 1,
},
"data-background-color": {
default: null,
},
"data-toggle-status": {
default: "close",
},
};
},
addStorage() {
return {
markdown: {
serialize(state: MarkdownSerializerState, node: NodeType) {},
},
};
},
parseHTML() {
return [{ tag: "toggle-heading-component" }];
},
// Render HTML for the callout node
renderHTML({ HTMLAttributes }) {
return ["toggle-heading-component", mergeAttributes(HTMLAttributes)];
},
});

View File

@@ -0,0 +1,39 @@
import { ReactNodeViewRenderer } from "@tiptap/react";
// components
import { CustomToggleHeadingBlock } from "./block";
// config
import { CustomToggleHeadingExtensionConfig } from "./extension-config";
// utils
import { DEFAULT_TOGGLE_HEADING_BLOCK_ATTRIBUTES } from "./utils";
export const CustomToggleHeadingExtension = CustomToggleHeadingExtensionConfig.extend({
selectable: true,
draggable: true,
addCommands() {
return {
insertToggleHeading:
({ headingLevel }) =>
({ commands }) =>
commands.insertContent({
type: this.name,
content: [
{
type: "heading",
attrs: {
level: headingLevel,
},
},
],
attrs: {
"data-heading-level": headingLevel ?? 1,
"data-toggle-status": DEFAULT_TOGGLE_HEADING_BLOCK_ATTRIBUTES["data-toggle-status"],
},
}),
};
},
addNodeView() {
return ReactNodeViewRenderer(CustomToggleHeadingBlock);
},
});

View File

@@ -0,0 +1,2 @@
export * from "./extension";
export * from "./read-only-extension";

View File

@@ -0,0 +1,3 @@
import { CustomToggleHeadingExtensionConfig } from "./extension-config";
export const CustomToggleHeadingReadOnlyExtension = CustomToggleHeadingExtensionConfig;

View File

@@ -0,0 +1,5 @@
export type TToggleHeadingBlockAttributes = {
"data-heading-level": number | undefined;
"data-background-color": string | undefined;
"data-toggle-status": "open" | "close" | undefined;
};

View File

@@ -0,0 +1,8 @@
// types
import { TToggleHeadingBlockAttributes } from "./types";
export const DEFAULT_TOGGLE_HEADING_BLOCK_ATTRIBUTES: TToggleHeadingBlockAttributes = {
"data-heading-level": undefined,
"data-background-color": undefined,
"data-toggle-status": "close",
};

View File

@@ -180,3 +180,8 @@ export const toggleBackgroundColor = (color: string | undefined, editor: Editor,
}
}
};
export const insertToggleHeading = (headingLevel: number, editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).insertToggleHeading({ headingLevel }).run();
else editor.chain().focus().insertToggleHeading({ headingLevel }).run();
};

View File

@@ -43,6 +43,7 @@ export const nodeDOMAtCoords = (coords: { x: number; y: number }) => {
".issue-embed",
".image-component",
".image-upload-component",
".editor-toggle-heading-component",
].join(", ");
for (const elem of elements) {

View File

@@ -23,7 +23,8 @@ export type TEditorCommands =
| "divider"
| "issue-embed"
| "text-color"
| "background-color";
| "background-color"
| "toggle-heading";
export type TColorEditorCommands = Extract<TEditorCommands, "text-color" | "background-color">;
export type TNonColorEditorCommands = Exclude<TEditorCommands, "text-color" | "background-color">;

View File

@@ -316,7 +316,10 @@ ul[data-type="taskList"] ul[data-type="taskList"] {
/* tailwind typography */
.prose :where(h1):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
margin-top: 2rem;
&:not(:first-child) {
margin-top: 2rem;
}
margin-bottom: 4px;
font-size: var(--font-size-h1);
line-height: var(--line-height-h1);
@@ -324,7 +327,10 @@ ul[data-type="taskList"] ul[data-type="taskList"] {
}
.prose :where(h2):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
margin-top: 1.4rem;
&:not(:first-child) {
margin-top: 1.4rem;
}
margin-bottom: 1px;
font-size: var(--font-size-h2);
line-height: var(--line-height-h2);
@@ -332,7 +338,10 @@ ul[data-type="taskList"] ul[data-type="taskList"] {
}
.prose :where(h3):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
margin-top: 1rem;
&:not(:first-child) {
margin-top: 1rem;
}
margin-bottom: 1px;
font-size: var(--font-size-h3);
line-height: var(--line-height-h3);
@@ -340,7 +349,10 @@ ul[data-type="taskList"] ul[data-type="taskList"] {
}
.prose :where(h4):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
margin-top: 1rem;
&:not(:first-child) {
margin-top: 1rem;
}
margin-bottom: 1px;
font-size: var(--font-size-h4);
line-height: var(--line-height-h4);
@@ -348,7 +360,10 @@ ul[data-type="taskList"] ul[data-type="taskList"] {
}
.prose :where(h5):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
margin-top: 1rem;
&:not(:first-child) {
margin-top: 1rem;
}
margin-bottom: 1px;
font-size: var(--font-size-h5);
line-height: var(--line-height-h5);
@@ -356,7 +371,10 @@ ul[data-type="taskList"] ul[data-type="taskList"] {
}
.prose :where(h6):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
margin-top: 1rem;
&:not(:first-child) {
margin-top: 1rem;
}
margin-bottom: 1px;
font-size: var(--font-size-h6);
line-height: var(--line-height-h6);
@@ -364,7 +382,14 @@ ul[data-type="taskList"] ul[data-type="taskList"] {
}
.prose :where(p):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
margin-top: 0.25rem;
&:not(:first-child) {
margin-top: 0.25rem;
}
&:first-child {
margin-top: 0;
}
margin-bottom: 1px;
padding: 3px 0;
font-size: var(--font-size-regular);