[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:
parent
9fb353ef54
commit
14b31e3fcd
37 changed files with 1592 additions and 1012 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 => ({
|
||||
|
|
|
|||
56
packages/editor/src/core/extensions/callout/block.tsx
Normal file
56
packages/editor/src/core/extensions/callout/block.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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)];
|
||||
},
|
||||
});
|
||||
68
packages/editor/src/core/extensions/callout/extension.tsx
Normal file
68
packages/editor/src/core/extensions/callout/extension.tsx
Normal 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);
|
||||
},
|
||||
});
|
||||
3
packages/editor/src/core/extensions/callout/index.ts
Normal file
3
packages/editor/src/core/extensions/callout/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from "./block";
|
||||
export * from "./extension";
|
||||
export * from "./read-only-extension";
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
24
packages/editor/src/core/extensions/callout/types.ts
Normal file
24
packages/editor/src/core/extensions/callout/types.ts
Normal 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;
|
||||
84
packages/editor/src/core/extensions/callout/utils.ts
Normal file
84
packages/editor/src/core/extensions/callout/utils.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
|
|
@ -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,
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export * from "./callout";
|
||||
export * from "./code";
|
||||
export * from "./code-inline";
|
||||
export * from "./custom-image";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import {
|
|||
TAIHandler,
|
||||
TColorEditorCommands,
|
||||
TDisplayConfig,
|
||||
TEditorCommands,
|
||||
TEmbedConfig,
|
||||
TExtensions,
|
||||
TFileHandler,
|
||||
|
|
|
|||
|
|
@ -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">;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
22
packages/helpers/helpers/emoji.helper.ts
Normal file
22
packages/helpers/helpers/emoji.helper.ts
Normal 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;
|
||||
};
|
||||
2
packages/helpers/helpers/index.ts
Normal file
2
packages/helpers/helpers/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./emoji.helper"
|
||||
export * from "./string.helper"
|
||||
15
packages/helpers/helpers/string.helper.ts
Normal file
15
packages/helpers/helpers/string.helper.ts
Normal 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
|
||||
};
|
||||
|
|
@ -1 +1,2 @@
|
|||
export * from "./helpers";
|
||||
export * from "./hooks";
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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("-");
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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"] },
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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"] },
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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("-");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue