[PULSE-36] feat: callout component for pages and issue descriptions (#5856)

* feat: editor callouts

* chore: backspace action updated

* chore: update callout attributes types

* chore: revert emoji picker changes

* chore: removed class atrribute

* chore: added sanitization for local storage values

* chore: disable emoji picker search
This commit is contained in:
Aaryan Khandelwal 2024-10-24 15:36:38 +05:30 committed by GitHub
parent 9fb353ef54
commit 14b31e3fcd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 1592 additions and 1012 deletions

View file

@ -36,6 +36,7 @@
"dependencies": {
"@floating-ui/react": "^0.26.4",
"@hocuspocus/provider": "^2.13.5",
"@plane/helpers": "*",
"@plane/ui": "*",
"@tiptap/core": "^2.1.13",
"@tiptap/extension-blockquote": "^2.1.13",
@ -61,6 +62,7 @@
"lowlight": "^3.0.0",
"lucide-react": "^0.378.0",
"prosemirror-codemark": "^0.4.2",
"prosemirror-utils": "^1.2.2",
"react-moveable": "^0.54.2",
"tailwind-merge": "^1.14.0",
"tippy.js": "^6.3.7",

View file

@ -6,7 +6,7 @@ import {
CheckSquare,
Heading2,
Heading3,
QuoteIcon,
TextQuote,
ImageIcon,
TableIcon,
ListIcon,
@ -180,7 +180,7 @@ export const QuoteItem = (editor: Editor): EditorMenuItem => ({
name: "Quote",
isActive: () => editor?.isActive("blockquote"),
command: () => toggleBlockquote(editor),
icon: QuoteIcon,
icon: TextQuote,
});
export const CodeItem = (editor: Editor): EditorMenuItem => ({

View file

@ -0,0 +1,56 @@
import React, { useState } from "react";
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
// constants
import { COLORS_LIST } from "@/constants/common";
// local components
import { CalloutBlockColorSelector } from "./color-selector";
import { CalloutBlockLogoSelector } from "./logo-selector";
// types
import { EAttributeNames, TCalloutBlockAttributes } from "./types";
// utils
import { updateStoredBackgroundColor } from "./utils";
type Props = NodeViewProps & {
node: NodeViewProps["node"] & {
attrs: TCalloutBlockAttributes;
};
updateAttributes: (attrs: Partial<TCalloutBlockAttributes>) => void;
};
export const CustomCalloutBlock: React.FC<Props> = (props) => {
const { editor, node, updateAttributes } = props;
// states
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false);
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
// derived values
const activeBackgroundColor = COLORS_LIST.find((c) => node.attrs["data-background"] === c.key)?.backgroundColor;
return (
<NodeViewWrapper
className="editor-callout-component group/callout-node relative bg-custom-background-90 rounded-lg text-custom-text-100 p-4 my-2 flex items-start gap-4 transition-colors duration-500 break-words"
style={{
backgroundColor: activeBackgroundColor,
}}
>
<CalloutBlockLogoSelector
blockAttributes={node.attrs}
disabled={!editor.isEditable}
isOpen={isEmojiPickerOpen}
handleOpen={(val) => setIsEmojiPickerOpen(val)}
updateAttributes={updateAttributes}
/>
<CalloutBlockColorSelector
disabled={!editor.isEditable}
isOpen={isColorPickerOpen}
toggleDropdown={() => setIsColorPickerOpen((prev) => !prev)}
onSelect={(val) => {
updateAttributes({
[EAttributeNames.BACKGROUND]: val,
});
updateStoredBackgroundColor(val);
}}
/>
<NodeViewContent as="div" className="w-full break-words" />
</NodeViewWrapper>
);
};

View file

@ -0,0 +1,75 @@
import { Ban, ChevronDown } from "lucide-react";
// constants
import { COLORS_LIST } from "@/constants/common";
// helpers
import { cn } from "@/helpers/common";
type Props = {
disabled: boolean;
isOpen: boolean;
onSelect: (color: string | null) => void;
toggleDropdown: () => void;
};
export const CalloutBlockColorSelector: React.FC<Props> = (props) => {
const { disabled, isOpen, onSelect, toggleDropdown } = props;
const handleColorSelect = (val: string | null) => {
onSelect(val);
toggleDropdown();
};
return (
<div
className={cn("opacity-0 pointer-events-none absolute top-2 right-2 z-10 transition-opacity", {
"group-hover/callout-node:opacity-100 group-hover/callout-node:pointer-events-auto": !disabled,
"opacity-100 pointer-events-auto": isOpen,
})}
contentEditable={false}
>
<div className="relative">
<button
type="button"
onClick={(e) => {
toggleDropdown();
e.stopPropagation();
}}
className={cn(
"flex items-center gap-1 h-full whitespace-nowrap py-1 px-2.5 text-sm font-medium text-custom-text-300 hover:bg-white/10 active:bg-custom-background-80 rounded transition-colors",
{
"bg-white/10": isOpen,
}
)}
disabled={disabled}
>
<span>Color</span>
<ChevronDown className="flex-shrink-0 size-3" />
</button>
{isOpen && (
<section className="absolute top-full right-0 z-10 mt-1 rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 p-2 shadow-custom-shadow-rg animate-in fade-in slide-in-from-top-1">
<div className="flex items-center gap-2">
{COLORS_LIST.map((color) => (
<button
key={color.key}
type="button"
className="flex-shrink-0 size-6 rounded border-[0.5px] border-custom-border-400 hover:opacity-60 transition-opacity"
style={{
backgroundColor: color.backgroundColor,
}}
onClick={() => handleColorSelect(color.key)}
/>
))}
<button
type="button"
className="flex-shrink-0 size-6 grid place-items-center rounded text-custom-text-300 border-[0.5px] border-custom-border-400 hover:bg-custom-background-80 transition-colors"
onClick={() => handleColorSelect(null)}
>
<Ban className="size-4" />
</button>
</div>
</section>
)}
</div>
</div>
);
};

View file

@ -0,0 +1,68 @@
import { Node, mergeAttributes } from "@tiptap/core";
import { Node as NodeType } from "@tiptap/pm/model";
import { MarkdownSerializerState } from "@tiptap/pm/markdown";
// types
import { EAttributeNames, TCalloutBlockAttributes } from "./types";
// utils
import { DEFAULT_CALLOUT_BLOCK_ATTRIBUTES } from "./utils";
// Extend Tiptap's Commands interface
declare module "@tiptap/core" {
interface Commands<ReturnType> {
calloutComponent: {
insertCallout: () => ReturnType;
};
}
}
export const CustomCalloutExtensionConfig = Node.create({
name: "calloutComponent",
group: "block",
content: "block+",
addAttributes() {
const attributes = {
// Reduce instead of map to accumulate the attributes directly into an object
...Object.values(EAttributeNames).reduce((acc, value) => {
acc[value] = {
default: DEFAULT_CALLOUT_BLOCK_ATTRIBUTES[value],
};
return acc;
}, {}),
};
return attributes;
},
addStorage() {
return {
markdown: {
serialize(state: MarkdownSerializerState, node: NodeType) {
const attrs = node.attrs as TCalloutBlockAttributes;
const logoInUse = attrs["data-logo-in-use"];
// add callout logo
if (logoInUse === "emoji") {
state.write(
`> <img src="${attrs["data-emoji-url"]}" alt="${attrs["data-emoji-unicode"]}" width="30px" />\n`
);
} else {
state.write(`> <icon>${attrs["data-icon-name"]} icon</icon>\n`);
}
// add an empty line after the logo
state.write("> \n");
// add '> ' before each line of the callout content
state.wrapBlock("> ", null, node, () => state.renderContent(node));
state.closeBlock(node);
},
},
};
},
parseHTML() {
return [{ tag: "callout-component" }];
},
// Render HTML for the callout node
renderHTML({ HTMLAttributes }) {
return ["callout-component", mergeAttributes(HTMLAttributes)];
},
});

View file

@ -0,0 +1,68 @@
import { findParentNodeClosestToPos, Predicate, ReactNodeViewRenderer } from "@tiptap/react";
// extensions
import { CustomCalloutBlock } from "@/extensions";
// helpers
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
// config
import { CustomCalloutExtensionConfig } from "./extension-config";
// utils
import { getStoredBackgroundColor, getStoredLogo } from "./utils";
export const CustomCalloutExtension = CustomCalloutExtensionConfig.extend({
selectable: true,
draggable: true,
addCommands() {
return {
insertCallout:
() =>
({ commands }) => {
// get stored logo values and background color from the local storage
const storedLogoValues = getStoredLogo();
const storedBackgroundValue = getStoredBackgroundColor();
return commands.insertContent({
type: this.name,
content: [
{
type: "paragraph",
},
],
attrs: {
...storedLogoValues,
"data-background": storedBackgroundValue,
},
});
},
};
},
addKeyboardShortcuts() {
return {
Backspace: ({ editor }) => {
const { $from, empty } = editor.state.selection;
try {
const isParentNodeCallout: Predicate = (node) => node.type === this.type;
const parentNodeDetails = findParentNodeClosestToPos($from, isParentNodeCallout);
// Check if selection is empty and at the beginning of the callout
if (empty && parentNodeDetails) {
const isCursorAtCalloutBeginning = $from.pos === parentNodeDetails.start + 1;
if (parentNodeDetails.node.content.size > 2 && isCursorAtCalloutBeginning) {
editor.commands.setTextSelection(parentNodeDetails.pos - 1);
return true;
}
}
} catch (error) {
console.error("Error in performing backspace action on callout", error);
}
return false; // Allow the default behavior if conditions are not met
},
ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name),
ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name),
};
},
addNodeView() {
return ReactNodeViewRenderer(CustomCalloutBlock);
},
});

View file

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

View file

@ -0,0 +1,97 @@
// plane helpers
import { convertHexEmojiToDecimal } from "@plane/helpers";
// plane ui
import { EmojiIconPicker, EmojiIconPickerTypes, Logo, TEmojiLogoProps } from "@plane/ui";
// helpers
import { cn } from "@/helpers/common";
// types
import { TCalloutBlockAttributes } from "./types";
// utils
import { DEFAULT_CALLOUT_BLOCK_ATTRIBUTES, updateStoredLogo } from "./utils";
type Props = {
blockAttributes: TCalloutBlockAttributes;
disabled: boolean;
handleOpen: (val: boolean) => void;
isOpen: boolean;
updateAttributes: (attrs: Partial<TCalloutBlockAttributes>) => void;
};
export const CalloutBlockLogoSelector: React.FC<Props> = (props) => {
const { blockAttributes, disabled, handleOpen, isOpen, updateAttributes } = props;
const logoValue: TEmojiLogoProps = {
in_use: blockAttributes["data-logo-in-use"],
icon: {
color: blockAttributes["data-icon-color"],
name: blockAttributes["data-icon-name"],
},
emoji: {
value: blockAttributes["data-emoji-unicode"]?.toString(),
url: blockAttributes["data-emoji-url"],
},
};
return (
<div contentEditable={false}>
<EmojiIconPicker
closeOnSelect={false}
isOpen={isOpen}
handleToggle={handleOpen}
className="flex-shrink-0 grid place-items-center"
buttonClassName={cn("flex-shrink-0 size-8 grid place-items-center rounded-lg", {
"hover:bg-white/10": !disabled,
})}
label={<Logo logo={logoValue} size={18} type="lucide" />}
onChange={(val) => {
// construct the new logo value based on the type of value
let newLogoValue: Partial<TCalloutBlockAttributes> = {};
let newLogoValueToStoreInLocalStorage: TEmojiLogoProps = {
in_use: "emoji",
emoji: {
value: DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-emoji-unicode"],
url: DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-emoji-url"],
},
};
if (val.type === "emoji") {
newLogoValue = {
"data-emoji-unicode": convertHexEmojiToDecimal(val.value.unified),
"data-emoji-url": val.value.imageUrl,
};
newLogoValueToStoreInLocalStorage = {
in_use: "emoji",
emoji: {
value: convertHexEmojiToDecimal(val.value.unified),
url: val.value.imageUrl,
},
};
} else if (val.type === "icon") {
newLogoValue = {
"data-icon-name": val.value.name,
"data-icon-color": val.value.color,
};
newLogoValueToStoreInLocalStorage = {
in_use: "icon",
icon: {
name: val.value.name,
color: val.value.color,
},
};
}
// update node attributes
updateAttributes({
"data-logo-in-use": val.type,
...newLogoValue,
});
// update stored logo in local storage
updateStoredLogo(newLogoValueToStoreInLocalStorage);
handleOpen(false);
}}
defaultIconColor={logoValue?.in_use && logoValue.in_use === "icon" ? logoValue?.icon?.color : undefined}
defaultOpen={logoValue.in_use === "emoji" ? EmojiIconPickerTypes.EMOJI : EmojiIconPickerTypes.ICON}
disabled={disabled}
searchDisabled
/>
</div>
);
};

View file

@ -0,0 +1,14 @@
import { ReactNodeViewRenderer } from "@tiptap/react";
// extensions
import { CustomCalloutBlock } from "@/extensions";
// config
import { CustomCalloutExtensionConfig } from "./extension-config";
export const CustomCalloutReadOnlyExtension = CustomCalloutExtensionConfig.extend({
selectable: false,
draggable: false,
addNodeView() {
return ReactNodeViewRenderer(CustomCalloutBlock);
},
});

View file

@ -0,0 +1,24 @@
export enum EAttributeNames {
ICON_COLOR = "data-icon-color",
ICON_NAME = "data-icon-name",
EMOJI_UNICODE = "data-emoji-unicode",
EMOJI_URL = "data-emoji-url",
LOGO_IN_USE = "data-logo-in-use",
BACKGROUND = "data-background",
}
export type TCalloutBlockIconAttributes = {
[EAttributeNames.ICON_COLOR]: string | undefined;
[EAttributeNames.ICON_NAME]: string | undefined;
};
export type TCalloutBlockEmojiAttributes = {
[EAttributeNames.EMOJI_UNICODE]: string | undefined;
[EAttributeNames.EMOJI_URL]: string | undefined;
};
export type TCalloutBlockAttributes = {
[EAttributeNames.LOGO_IN_USE]: "emoji" | "icon";
[EAttributeNames.BACKGROUND]: string;
} & TCalloutBlockIconAttributes &
TCalloutBlockEmojiAttributes;

View file

@ -0,0 +1,84 @@
// plane helpers
import { sanitizeHTML } from "@plane/helpers";
// plane ui
import { TEmojiLogoProps } from "@plane/ui";
// types
import {
EAttributeNames,
TCalloutBlockAttributes,
TCalloutBlockEmojiAttributes,
TCalloutBlockIconAttributes,
} from "./types";
export const DEFAULT_CALLOUT_BLOCK_ATTRIBUTES: TCalloutBlockAttributes = {
"data-logo-in-use": "emoji",
"data-icon-color": null,
"data-icon-name": null,
"data-emoji-unicode": "128161",
"data-emoji-url": "https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f4a1.png",
"data-background": null,
};
type TStoredLogoValue = Pick<TCalloutBlockAttributes, EAttributeNames.LOGO_IN_USE> &
(TCalloutBlockEmojiAttributes | TCalloutBlockIconAttributes);
// function to get the stored logo from local storage
export const getStoredLogo = (): TStoredLogoValue => {
const fallBackValues: TStoredLogoValue = {
"data-logo-in-use": "emoji",
"data-emoji-unicode": DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-emoji-unicode"],
"data-emoji-url": DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-emoji-url"],
};
if (typeof window !== "undefined") {
const storedData = sanitizeHTML(localStorage.getItem("editor-calloutComponent-logo"));
if (storedData) {
let parsedData: TEmojiLogoProps;
try {
parsedData = JSON.parse(storedData);
} catch (error) {
console.error(`Error parsing stored callout logo, stored value- ${storedData}`, error);
localStorage.removeItem("editor-calloutComponent-logo");
return fallBackValues;
}
if (parsedData.in_use === "emoji" && parsedData.emoji?.value) {
return {
"data-logo-in-use": "emoji",
"data-emoji-unicode": parsedData.emoji.value || DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-emoji-unicode"],
"data-emoji-url": parsedData.emoji.url || DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-emoji-url"],
};
}
if (parsedData.in_use === "icon" && parsedData.icon?.name) {
return {
"data-logo-in-use": "icon",
"data-icon-name": parsedData.icon.name || DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-icon-name"],
"data-icon-color": parsedData.icon.color || DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-icon-color"],
};
}
}
}
// fallback values
return fallBackValues;
};
// function to update the stored logo on local storage
export const updateStoredLogo = (value: TEmojiLogoProps): void => {
if (typeof window === "undefined") return;
localStorage.setItem("editor-calloutComponent-logo", JSON.stringify(value));
};
// function to get the stored background color from local storage
export const getStoredBackgroundColor = (): string | null => {
if (typeof window !== "undefined") {
return sanitizeHTML(localStorage.getItem("editor-calloutComponent-background"));
}
return null;
};
// function to update the stored background color on local storage
export const updateStoredBackgroundColor = (value: string | null): void => {
if (typeof window === "undefined") return;
if (value === null) {
localStorage.removeItem("editor-calloutComponent-background");
return;
} else {
localStorage.setItem("editor-calloutComponent-background", value);
}
};

View file

@ -16,6 +16,7 @@ import { IssueWidgetWithoutProps } from "./issue-embed/issue-embed-without-props
import { CustomMentionWithoutProps } from "./mentions/mentions-without-props";
import { CustomQuoteExtension } from "./quote";
import { TableHeader, TableCell, TableRow, Table } from "./table";
import { CustomCalloutExtensionConfig } from "./callout/extension-config";
import { CustomColorExtension } from "./custom-color";
export const CoreEditorExtensionsWithoutProps = [
@ -84,6 +85,7 @@ export const CoreEditorExtensionsWithoutProps = [
TableCell,
TableRow,
CustomMentionWithoutProps(),
CustomCalloutExtensionConfig,
CustomColorExtension,
];

View file

@ -8,6 +8,7 @@ import StarterKit from "@tiptap/starter-kit";
import { Markdown } from "tiptap-markdown";
// extensions
import {
CustomCalloutExtension,
CustomCodeBlockExtension,
CustomCodeInlineExtension,
CustomCodeMarkPlugin,
@ -157,6 +158,7 @@ export const CoreEditorExtensions = (args: TArguments) => {
includeChildren: true,
}),
CharacterCount,
CustomCalloutExtension,
CustomColorExtension,
];
};

View file

@ -1,3 +1,4 @@
export * from "./callout";
export * from "./code";
export * from "./code-inline";
export * from "./custom-image";

View file

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

View file

@ -12,9 +12,10 @@ import {
List,
ListOrdered,
ListTodo,
MessageSquareText,
MinusSquare,
Quote,
Table,
TextQuote,
} from "lucide-react";
// constants
import { COLORS_LIST } from "@/constants/common";
@ -34,6 +35,8 @@ import {
toggleTextColor,
toggleBackgroundColor,
insertImage,
insertCallout,
setText,
} from "@/helpers/editor-commands";
// types
import { CommandProps, ISlashCommandItem } from "@/types";
@ -58,12 +61,7 @@ export const getSlashCommandFilteredSections =
description: "Just start typing with plain text.",
searchTerms: ["p", "paragraph"],
icon: <CaseSensitive className="size-3.5" />,
command: ({ editor, range }: CommandProps) => {
if (range) {
editor.chain().focus().deleteRange(range).clearNodes().run();
}
editor.chain().focus().clearNodes().run();
},
command: ({ editor, range }) => setText(editor, range),
},
{
commandKey: "h1",
@ -161,7 +159,7 @@ export const getSlashCommandFilteredSections =
title: "Quote",
description: "Capture a quote.",
searchTerms: ["blockquote"],
icon: <Quote className="size-3.5" />,
icon: <TextQuote className="size-3.5" />,
command: ({ editor, range }) => toggleBlockquote(editor, range),
},
{
@ -182,6 +180,15 @@ export const getSlashCommandFilteredSections =
searchTerms: ["img", "photo", "picture", "media", "upload"],
command: ({ editor, range }: CommandProps) => insertImage({ editor, event: "insert", range }),
},
{
commandKey: "callout",
key: "callout",
title: "Callout",
icon: <MessageSquareText className="size-3.5" />,
description: "Insert callout",
searchTerms: ["callout", "comment", "message", "info", "alert"],
command: ({ editor, range }: CommandProps) => insertCallout(editor, range),
},
{
commandKey: "divider",
key: "divider",

View file

@ -7,8 +7,8 @@ import { findTableAncestor } from "@/helpers/common";
import { InsertImageComponentProps } from "@/extensions";
export const setText = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).clearNodes().run();
else editor.chain().focus().clearNodes().run();
if (range) editor.chain().focus().deleteRange(range).setNode("paragraph").run();
else editor.chain().focus().setNode("paragraph").run();
};
export const toggleHeadingOne = (editor: Editor, range?: Range) => {
@ -180,3 +180,8 @@ export const toggleBackgroundColor = (color: string | undefined, editor: Editor,
}
}
};
export const insertCallout = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).insertCallout().run();
else editor.chain().focus().insertCallout().run();
};

View file

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

View file

@ -8,7 +8,6 @@ import {
TAIHandler,
TColorEditorCommands,
TDisplayConfig,
TEditorCommands,
TEmbedConfig,
TExtensions,
TFileHandler,

View file

@ -23,7 +23,8 @@ export type TEditorCommands =
| "divider"
| "issue-embed"
| "text-color"
| "background-color";
| "background-color"
| "callout";
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);

View file

@ -0,0 +1,22 @@
export const convertHexEmojiToDecimal = (emojiUnified: string): string => {
if (!emojiUnified) return "";
return emojiUnified
.toString()
.split("-")
.map((e) => parseInt(e, 16))
.join("-");
};
export const emojiCodeToUnicode = (emoji: string) => {
if (!emoji) return "";
// convert emoji code to unicode
const uniCodeEmoji = emoji
.toString()
.split("-")
.map((emoji) => parseInt(emoji, 10).toString(16))
.join("-");
return uniCodeEmoji;
};

View file

@ -0,0 +1,2 @@
export * from "./emoji.helper"
export * from "./string.helper"

View file

@ -0,0 +1,15 @@
import DOMPurify from "isomorphic-dompurify";
/**
* @description: This function will remove all the HTML tags from the string
* @param {string} html
* @return {string}
* @example:
* const html = "<p>Some text</p>";
* const text = stripHTML(html);
* console.log(text); // Some text
*/
export const sanitizeHTML = (htmlString: string) => {
const sanitizedText = DOMPurify.sanitize(htmlString, { ALLOWED_TAGS: [] }); // sanitize the string to remove all HTML tags
return sanitizedText.trim(); // trim the string to remove leading and trailing whitespaces
};

View file

@ -1 +1,2 @@
export * from "./helpers";
export * from "./hooks";

View file

@ -15,10 +15,11 @@
"devDependencies": {
"@types/node": "^22.5.4",
"@types/react": "^18.3.11",
"typescript": "^5.3.3",
"tsup": "^7.2.0"
"tsup": "^7.2.0",
"typescript": "^5.6.2"
},
"dependencies": {
"isomorphic-dompurify": "^2.16.0",
"react": "^18.3.1"
}
}

View file

@ -43,6 +43,7 @@ export type TCustomEmojiPicker = {
label: React.ReactNode;
onChange: (value: TChangeHandlerProps) => void;
placement?: Placement;
searchDisabled?: boolean;
searchPlaceholder?: string;
theme?: Theme;
iconType?: "material" | "lucide";
@ -53,6 +54,7 @@ export const DEFAULT_COLORS = ["#95999f", "#6d7b8a", "#5e6ad2", "#02b5ed", "#02b
export type TIconsListProps = {
defaultColor: string;
onChange: (val: { name: string; color: string }) => void;
searchDisabled?: boolean;
};
/**

View file

@ -25,6 +25,7 @@ export const EmojiIconPicker: React.FC<TCustomEmojiPicker> = (props) => {
label,
onChange,
placement = "bottom-start",
searchDisabled = false,
searchPlaceholder = "Search",
theme,
} = props;
@ -107,10 +108,12 @@ export const EmojiIconPicker: React.FC<TCustomEmojiPicker> = (props) => {
height="20rem"
width="100%"
theme={theme}
searchDisabled={searchDisabled}
searchPlaceholder={searchPlaceholder}
previewConfig={{
showPreview: false,
}}
lazyLoadEmojis
/>
</Tab.Panel>
<Tab.Panel className="h-80 w-full relative overflow-hidden overflow-y-auto">
@ -123,6 +126,7 @@ export const EmojiIconPicker: React.FC<TCustomEmojiPicker> = (props) => {
});
if (closeOnSelect) handleToggle(false);
}}
searchDisabled={searchDisabled}
/>
</Tab.Panel>
</Tab.Panels>

View file

@ -25,6 +25,7 @@ export const CustomEmojiIconPicker: React.FC<TCustomEmojiPicker> = (props) => {
label,
onChange,
placement = "bottom-start",
searchDisabled = false,
searchPlaceholder = "Search",
theme,
} = props;
@ -107,6 +108,7 @@ export const CustomEmojiIconPicker: React.FC<TCustomEmojiPicker> = (props) => {
height="20rem"
width="100%"
theme={theme}
searchDisabled={searchDisabled}
searchPlaceholder={searchPlaceholder}
previewConfig={{
showPreview: false,
@ -123,6 +125,7 @@ export const CustomEmojiIconPicker: React.FC<TCustomEmojiPicker> = (props) => {
});
if (closeOnSelect) handleToggle(false);
}}
searchDisabled={searchDisabled}
/>
</Tab.Panel>
</Tab.Panels>

View file

@ -3,6 +3,7 @@ export const emojiCodeToUnicode = (emoji: string) => {
// convert emoji code to unicode
const uniCodeEmoji = emoji
.toString()
.split("-")
.map((emoji) => parseInt(emoji, 10).toString(16))
.join("-");

View file

@ -12,7 +12,7 @@ import { cn } from "../../helpers";
import { DEFAULT_COLORS, TIconsListProps, adjustColorForContrast } from "./emoji-icon-helper";
export const IconsList: React.FC<TIconsListProps> = (props) => {
const { defaultColor, onChange } = props;
const { defaultColor, onChange, searchDisabled = false } = props;
// states
const [activeColor, setActiveColor] = useState(defaultColor);
const [showHexInput, setShowHexInput] = useState(false);
@ -42,21 +42,23 @@ export const IconsList: React.FC<TIconsListProps> = (props) => {
return (
<>
<div className="flex flex-col sticky top-0 bg-custom-background-100">
<div className="flex items-center px-2 py-[15px] w-full ">
<div
className={`relative flex items-center gap-2 bg-custom-background-90 h-10 rounded-lg w-full px-[30px] border ${isInputFocused ? "border-custom-primary-100" : "border-transparent"}`}
onFocus={() => setIsInputFocused(true)}
onBlur={() => setIsInputFocused(false)}
>
<Search className="absolute left-2.5 bottom-3 h-3.5 w-3.5 text-custom-text-400" />
<Input
placeholder="Search"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="text-[1rem] border-none p-0 h-full w-full "
/>
{!searchDisabled && (
<div className="flex items-center px-2 py-[15px] w-full ">
<div
className={`relative flex items-center gap-2 bg-custom-background-90 h-10 rounded-lg w-full px-[30px] border ${isInputFocused ? "border-custom-primary-100" : "border-transparent"}`}
onFocus={() => setIsInputFocused(true)}
onBlur={() => setIsInputFocused(false)}
>
<Search className="absolute left-2.5 bottom-3 h-3.5 w-3.5 text-custom-text-400" />
<Input
placeholder="Search"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="text-[1rem] border-none p-0 h-full w-full "
/>
</div>
</div>
</div>
)}
<div className="grid grid-cols-9 gap-2 items-center justify-items-center px-2.5 py-1 h-9">
{showHexInput ? (
<div className="col-span-8 flex items-center gap-1 justify-self-stretch ml-2">

View file

@ -6,7 +6,7 @@ import { LUCIDE_ICONS_LIST } from "./icons";
// helpers
import { emojiCodeToUnicode } from "./helpers";
type TLogoProps = {
export type TEmojiLogoProps = {
in_use: "emoji" | "icon";
emoji?: {
value?: string;
@ -19,7 +19,7 @@ type TLogoProps = {
};
type Props = {
logo: TLogoProps;
logo: TEmojiLogoProps;
size?: number;
type?: "lucide" | "material";
};

View file

@ -11,7 +11,7 @@ import { LUCIDE_ICONS_LIST } from "./icons";
import { Search } from "lucide-react";
export const LucideIconsList: React.FC<TIconsListProps> = (props) => {
const { defaultColor, onChange } = props;
const { defaultColor, onChange, searchDisabled = false } = props;
// states
const [activeColor, setActiveColor] = useState(defaultColor);
const [showHexInput, setShowHexInput] = useState(false);
@ -32,21 +32,23 @@ export const LucideIconsList: React.FC<TIconsListProps> = (props) => {
return (
<>
<div className="flex flex-col sticky top-0 bg-custom-background-100">
<div className="flex items-center px-2 py-[15px] w-full ">
<div
className={`relative flex items-center gap-2 bg-custom-background-90 h-10 rounded-lg w-full px-[30px] border ${isInputFocused ? "border-custom-primary-100" : "border-transparent"}`}
onFocus={() => setIsInputFocused(true)}
onBlur={() => setIsInputFocused(false)}
>
<Search className="absolute left-2.5 bottom-3 h-3.5 w-3.5 text-custom-text-400" />
<Input
placeholder="Search"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="text-[1rem] border-none p-0 h-full w-full "
/>
{!searchDisabled && (
<div className="flex items-center px-2 py-[15px] w-full ">
<div
className={`relative flex items-center gap-2 bg-custom-background-90 h-10 rounded-lg w-full px-[30px] border ${isInputFocused ? "border-custom-primary-100" : "border-transparent"}`}
onFocus={() => setIsInputFocused(true)}
onBlur={() => setIsInputFocused(false)}
>
<Search className="absolute left-2.5 bottom-3 h-3.5 w-3.5 text-custom-text-400" />
<Input
placeholder="Search"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="text-[1rem] border-none p-0 h-full w-full "
/>
</div>
</div>
</div>
)}
<div className="grid grid-cols-9 gap-2 items-center justify-items-center px-2.5 py-1 h-9">
{showHexInput ? (
<div className="col-span-8 flex items-center gap-1 justify-self-stretch ml-2">
@ -104,8 +106,8 @@ export const LucideIconsList: React.FC<TIconsListProps> = (props) => {
</button>
</div>
<div className="flex items-center gap-2 w-full pl-4 pr-3 py-1 h-6">
<InfoIcon className="h-3 w-3" />
<p className="text-xs"> Colors will be adjusted to ensure sufficient contrast.</p>
<InfoIcon className="flex-shrink-0 h-3 w-3" />
<p className="!text-xs"> Colors will be adjusted to ensure sufficient contrast.</p>
</div>
</div>
<div className="grid grid-cols-8 gap-1 px-2.5 justify-items-center mt-2">

View file

@ -13,9 +13,9 @@ import {
ListOrdered,
ListTodo,
LucideIcon,
Quote,
Strikethrough,
Table,
TextQuote,
Underline,
} from "lucide-react";
// editor
@ -75,7 +75,7 @@ export const LIST_ITEMS: ToolbarMenuItem[] = [
];
export const USER_ACTION_ITEMS: ToolbarMenuItem[] = [
{ key: "quote", name: "Quote", icon: Quote, editors: ["lite", "document"] },
{ key: "quote", name: "Quote", icon: TextQuote, editors: ["lite", "document"] },
{ key: "code", name: "Code", icon: Code2, editors: ["lite", "document"] },
];

View file

@ -15,9 +15,9 @@ import {
ListOrdered,
ListTodo,
LucideIcon,
Quote,
Strikethrough,
Table,
TextQuote,
Underline,
} from "lucide-react";
// editor
@ -85,7 +85,7 @@ const LIST_ITEMS: ToolbarMenuItem[] = [
];
const USER_ACTION_ITEMS: ToolbarMenuItem[] = [
{ key: "quote", name: "Quote", icon: Quote, editors: ["lite", "document"] },
{ key: "quote", name: "Quote", icon: TextQuote, editors: ["lite", "document"] },
{ key: "code", name: "Code", icon: Code2, editors: ["lite", "document"] },
];

View file

@ -64,6 +64,7 @@ export const convertHexEmojiToDecimal = (emojiUnified: string): string => {
if (!emojiUnified) return "";
return emojiUnified
.toString()
.split("-")
.map((e) => parseInt(e, 16))
.join("-");
@ -74,6 +75,7 @@ export const emojiCodeToUnicode = (emoji: string) => {
// convert emoji code to unicode
const uniCodeEmoji = emoji
.toString()
.split("-")
.map((emoji) => parseInt(emoji, 10).toString(16))
.join("-");

1889
yarn.lock

File diff suppressed because it is too large Load diff