diff --git a/frontends/.eslintrc.js b/frontends/.eslintrc.js index 074b4b193e..30fb736252 100644 --- a/frontends/.eslintrc.js +++ b/frontends/.eslintrc.js @@ -14,6 +14,10 @@ module.exports = { "mit-learn", "github-pages", "storybook-static", + "**/TiptapEditor/components/**/*.tsx", + "**/TiptapEditor/components/**/*.ts", + "**/TiptapEditor/hooks/**/*.ts", + "**/TiptapEditor/lib/**/*.ts", ], settings: { "import/resolver": { diff --git a/frontends/.stylelintrc.yaml b/frontends/.stylelintrc.yaml index 947b90026b..de27ffb702 100644 --- a/frontends/.stylelintrc.yaml +++ b/frontends/.stylelintrc.yaml @@ -6,6 +6,8 @@ rules: - message: "Expected class selector to be kebab-case" ignoreFiles: - "**/*.vendor.css" + - "**/TiptapEditor/components/**/*.scss" + - "**/TiptapEditor/styles/**/*.scss" overrides: - files: - "**/*.scss" diff --git a/frontends/main/jest.config.ts b/frontends/main/jest.config.ts index b523048ae7..0c1424ee75 100644 --- a/frontends/main/jest.config.ts +++ b/frontends/main/jest.config.ts @@ -7,7 +7,9 @@ const config: Config.InitialOptions = { ...baseConfig.setupFilesAfterEnv, "./test-utils/setupJest.tsx", ], - transformIgnorePatterns: ["node_modules/(?!@faker-js).+"], + transformIgnorePatterns: [ + "node_modules/(?!(@faker-js|react-hotkeys-hook)).+", + ], moduleNameMapper: { ...baseConfig.moduleNameMapper, "^@/(.*)$": path.resolve(__dirname, "src/$1"), diff --git a/frontends/main/next.config.js b/frontends/main/next.config.js index dac2a8467d..240d63e645 100644 --- a/frontends/main/next.config.js +++ b/frontends/main/next.config.js @@ -128,7 +128,7 @@ const nextConfig = { allowCollectingMemory: true, }) } - // Important: return the modified config + return config }, } diff --git a/frontends/main/src/app-pages/ArticlePage/NewArticlePage.tsx b/frontends/main/src/app-pages/ArticlePage/NewArticlePage.tsx new file mode 100644 index 0000000000..6832e53b57 --- /dev/null +++ b/frontends/main/src/app-pages/ArticlePage/NewArticlePage.tsx @@ -0,0 +1,36 @@ +"use client" + +import React from "react" +import { TiptapEditor, theme, styled, HEADER_HEIGHT } from "ol-components" +import { Permission } from "api/hooks/user" +import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute" + +const PageContainer = styled.div({ + color: theme.custom.colors.darkGray2, + display: "flex", + height: `calc(100vh - ${HEADER_HEIGHT}px - 132px)`, +}) + +const EditorContainer = styled.div({ + minHeight: 0, +}) + +const StyledTiptapEditor = styled(TiptapEditor)({ + width: "70vw", + height: `calc(100% - ${HEADER_HEIGHT}px - 132px)`, + overscrollBehavior: "contain", +}) + +const NewArticlePage: React.FC = () => { + return ( + + + + + + + + ) +} + +export { NewArticlePage } diff --git a/frontends/main/src/app/article/new/page.tsx b/frontends/main/src/app/article/new/page.tsx new file mode 100644 index 0000000000..d0694149d1 --- /dev/null +++ b/frontends/main/src/app/article/new/page.tsx @@ -0,0 +1,15 @@ +import React from "react" +import { Metadata } from "next" +import { standardizeMetadata } from "@/common/metadata" +import { NewArticlePage } from "@/app-pages/ArticlePage/NewArticlePage" + +export const metadata: Metadata = standardizeMetadata({ + title: "New Article", + robots: "noindex, nofollow", +}) + +const Page: React.FC> = () => { + return +} + +export default Page diff --git a/frontends/ol-components/package.json b/frontends/ol-components/package.json index b075dae491..93e48f0b0f 100644 --- a/frontends/ol-components/package.json +++ b/frontends/ol-components/package.json @@ -19,13 +19,28 @@ "@dnd-kit/utilities": "^3.2.1", "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", + "@floating-ui/react": "^0.27.16", "@mui/base": "5.0.0-beta.70", "@mui/lab": "6.0.0-dev.240424162023-9968b4889d", "@mui/material": "^6.4.5", "@mui/material-nextjs": "^6.4.3", "@mui/system": "^6.4.3", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-popover": "^1.1.15", "@remixicon/react": "^4.2.0", "@testing-library/dom": "^10.4.0", + "@tiptap/extension-highlight": "^3.10.5", + "@tiptap/extension-horizontal-rule": "^3.10.5", + "@tiptap/extension-image": "^3.10.5", + "@tiptap/extension-list": "^3.10.5", + "@tiptap/extension-subscript": "^3.10.5", + "@tiptap/extension-superscript": "^3.10.5", + "@tiptap/extension-text-align": "^3.10.5", + "@tiptap/extension-typography": "^3.10.5", + "@tiptap/extensions": "^3.10.5", + "@tiptap/pm": "^3.10.5", + "@tiptap/react": "^3.10.5", + "@tiptap/starter-kit": "^3.10.5", "@types/react-dom": "^19", "@types/tinycolor2": "^1.4.6", "api": "workspace:*", @@ -33,12 +48,14 @@ "embla-carousel-react": "^8.6.0", "embla-carousel-wheel-gestures": "^8.0.2", "lodash": "^4.17.21", + "lodash.throttle": "^4.1.1", "material-ui-popup-state": "^5.1.0", "next": "^15.5.2", "ol-test-utilities": "0.0.0", "ol-utilities": "0.0.0", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-hotkeys-hook": "^5.2.1", "react-select": "^5.7.7", "react-share": "^5.0.3", "react-slick": "^0.30.2", @@ -65,10 +82,13 @@ "@storybook/types": "^8.2.9", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.5.2", + "@types/lodash.throttle": "^4.1.9", "@types/react-slick": "^0", "dotenv": "^17.0.0", "lodash": "^4.17.21", "prop-types": "^15.8.1", + "sass": "^1.93.3", + "sass-embedded": "^1.93.3", "storybook": "^8.2.9", "typescript": "^5.5.4", "webpack": "^5.94.0" diff --git a/frontends/ol-components/src/components/TiptapEditor/TiptapEditor.tsx b/frontends/ol-components/src/components/TiptapEditor/TiptapEditor.tsx new file mode 100644 index 0000000000..a45ee5d91d --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/TiptapEditor.tsx @@ -0,0 +1,174 @@ +"use client" + +// Based on ./components/tiptap-templates/simple/simple-editor.tsx + +import React, { useRef } from "react" +import { EditorContent, EditorContext, useEditor } from "@tiptap/react" + +// --- Tiptap Core Extensions --- +import { StarterKit } from "@tiptap/starter-kit" +import { TaskItem, TaskList } from "@tiptap/extension-list" +import { TextAlign } from "@tiptap/extension-text-align" +import { Typography } from "@tiptap/extension-typography" +import { Highlight } from "@tiptap/extension-highlight" +import { Subscript } from "@tiptap/extension-subscript" +import { Superscript } from "@tiptap/extension-superscript" +import { Selection } from "@tiptap/extensions" + +// --- UI Primitives --- +import { Spacer } from "./components/tiptap-ui-primitive/spacer" +import { + Toolbar, + ToolbarGroup, + ToolbarSeparator, +} from "./components/tiptap-ui-primitive/toolbar" + +// --- Tiptap Node --- +import { ImageUploadNode } from "./components/tiptap-node/image-upload-node/image-upload-node-extension" +import { HorizontalRule } from "./components/tiptap-node/horizontal-rule-node/horizontal-rule-node-extension" +import "./components/tiptap-node/blockquote-node/blockquote-node.scss" +import "./components/tiptap-node/code-block-node/code-block-node.scss" +import "./components/tiptap-node/horizontal-rule-node/horizontal-rule-node.scss" +import "./components/tiptap-node/list-node/list-node.scss" +import "./components/tiptap-node/image-node/image-node.scss" +import "./components/tiptap-node/heading-node/heading-node.scss" +import "./components/tiptap-node/paragraph-node/paragraph-node.scss" + +// --- Tiptap UI --- +import { HeadingDropdownMenu } from "./components/tiptap-ui/heading-dropdown-menu" +import { ListDropdownMenu } from "./components/tiptap-ui/list-dropdown-menu" +import { BlockquoteButton } from "./components/tiptap-ui/blockquote-button" +import { CodeBlockButton } from "./components/tiptap-ui/code-block-button" +import { ColorHighlightPopover } from "./components/tiptap-ui/color-highlight-popover" +import { LinkPopover } from "./components/tiptap-ui/link-popover" +import { MarkButton } from "./components/tiptap-ui/mark-button" +import { TextAlignButton } from "./components/tiptap-ui/text-align-button" +import { UndoRedoButton } from "./components/tiptap-ui/undo-redo-button" + +// --- Lib --- +import { handleImageUpload, MAX_FILE_SIZE } from "./lib/tiptap-utils" + +// --- Styles --- +import "./styles/_keyframe-animations.scss" +import "./styles/_variables.scss" +import "./components/tiptap-templates/simple/simple-editor.scss" + +const MainToolbarContent = () => { + return ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} + +export default function SimpleEditor() { + const toolbarRef = useRef(null) + + const editor = useEditor({ + immediatelyRender: false, + shouldRerenderOnTransaction: false, + editorProps: { + attributes: { + autocomplete: "off", + autocorrect: "off", + autocapitalize: "off", + "aria-label": "Main content area, start typing to enter text.", + class: "simple-editor", + }, + }, + extensions: [ + StarterKit.configure({ + horizontalRule: false, + link: { + openOnClick: false, + enableClickSelection: true, + }, + }), + HorizontalRule, + TextAlign.configure({ types: ["heading", "paragraph"] }), + TaskList, + TaskItem.configure({ nested: true }), + Highlight.configure({ multicolor: true }), + Typography, + Superscript, + Subscript, + Selection, + ImageUploadNode.configure({ + accept: "image/*", + maxSize: MAX_FILE_SIZE, + limit: 3, + upload: handleImageUpload, + onError: (error) => console.error("Upload failed:", error), + }), + ], + content: { + type: "doc", + content: [ + { + type: "paragraph", + content: [], + }, + ], + }, + }) + + return ( +
+ + + + + + + +
+ ) +} diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/align-center-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/align-center-icon.tsx new file mode 100644 index 0000000000..2b7d2931e8 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/align-center-icon.tsx @@ -0,0 +1,38 @@ +import React, { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const AlignCenterIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + + ) +}) + +AlignCenterIcon.displayName = "AlignCenterIcon" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/align-justify-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/align-justify-icon.tsx new file mode 100644 index 0000000000..d41f74582d --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/align-justify-icon.tsx @@ -0,0 +1,38 @@ +import React, { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const AlignJustifyIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + + ) +}) + +AlignJustifyIcon.displayName = "AlignJustifyIcon" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/align-left-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/align-left-icon.tsx new file mode 100644 index 0000000000..07cd9c88f9 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/align-left-icon.tsx @@ -0,0 +1,38 @@ +import React, { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const AlignLeftIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + + ) +}) + +AlignLeftIcon.displayName = "AlignLeftIcon" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/align-right-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/align-right-icon.tsx new file mode 100644 index 0000000000..354d71aea8 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/align-right-icon.tsx @@ -0,0 +1,38 @@ +import React, { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const AlignRightIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + + ) +}) + +AlignRightIcon.displayName = "AlignRightIcon" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/arrow-left-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/arrow-left-icon.tsx new file mode 100644 index 0000000000..d1325ee4e0 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/arrow-left-icon.tsx @@ -0,0 +1,24 @@ +import React, { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const ArrowLeftIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + ) +}) + +ArrowLeftIcon.displayName = "ArrowLeftIcon" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/ban-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/ban-icon.tsx new file mode 100644 index 0000000000..fb4e737d73 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/ban-icon.tsx @@ -0,0 +1,26 @@ +import React, { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const BanIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + ) +}) + +BanIcon.displayName = "BanIcon" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/blockquote-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/blockquote-icon.tsx new file mode 100644 index 0000000000..9ae07f20c1 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/blockquote-icon.tsx @@ -0,0 +1,44 @@ +import React, { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const BlockquoteIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + + + ) +}) + +BlockquoteIcon.displayName = "BlockquoteIcon" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/bold-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/bold-icon.tsx new file mode 100644 index 0000000000..f049c5f00b --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/bold-icon.tsx @@ -0,0 +1,26 @@ +import React, { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const BoldIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + ) +}) + +BoldIcon.displayName = "BoldIcon" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/chevron-down-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/chevron-down-icon.tsx new file mode 100644 index 0000000000..883721ce9e --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/chevron-down-icon.tsx @@ -0,0 +1,26 @@ +import React, { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const ChevronDownIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + ) +}) + +ChevronDownIcon.displayName = "ChevronDownIcon" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/close-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/close-icon.tsx new file mode 100644 index 0000000000..a5a273e47f --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/close-icon.tsx @@ -0,0 +1,24 @@ +import React, { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const CloseIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + ) +}) + +CloseIcon.displayName = "CloseIcon" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/code-block-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/code-block-icon.tsx new file mode 100644 index 0000000000..7ef6a7d5d7 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/code-block-icon.tsx @@ -0,0 +1,38 @@ +import React, { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const CodeBlockIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + + ) +}) + +CodeBlockIcon.displayName = "CodeBlockIcon" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/code2-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/code2-icon.tsx new file mode 100644 index 0000000000..f535bf84bb --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/code2-icon.tsx @@ -0,0 +1,32 @@ +import React, { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const Code2Icon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + + ) +}) + +Code2Icon.displayName = "Code2Icon" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/corner-down-left-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/corner-down-left-icon.tsx new file mode 100644 index 0000000000..e1bd49dc4c --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/corner-down-left-icon.tsx @@ -0,0 +1,26 @@ +import React, { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const CornerDownLeftIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + ) +}) + +CornerDownLeftIcon.displayName = "CornerDownLeftIcon" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/external-link-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/external-link-icon.tsx new file mode 100644 index 0000000000..8aee572d59 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/external-link-icon.tsx @@ -0,0 +1,28 @@ +import React, { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const ExternalLinkIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + ) +}) + +ExternalLinkIcon.displayName = "ExternalLinkIcon" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/heading-five-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/heading-five-icon.tsx new file mode 100644 index 0000000000..f9a19927f4 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/heading-five-icon.tsx @@ -0,0 +1,28 @@ +import React, { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const HeadingFiveIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + ) +}) + +HeadingFiveIcon.displayName = "HeadingFiveIcon" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/heading-four-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/heading-four-icon.tsx new file mode 100644 index 0000000000..1f34db6967 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/heading-four-icon.tsx @@ -0,0 +1,28 @@ +import React, { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const HeadingFourIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + ) +}) + +HeadingFourIcon.displayName = "HeadingFourIcon" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/heading-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/heading-icon.tsx new file mode 100644 index 0000000000..8ee871acd7 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/heading-icon.tsx @@ -0,0 +1,24 @@ +import React, { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const HeadingIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + ) +}) + +HeadingIcon.displayName = "HeadingIcon" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/heading-one-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/heading-one-icon.tsx new file mode 100644 index 0000000000..1c3d2830e1 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/heading-one-icon.tsx @@ -0,0 +1,28 @@ +import React, { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const HeadingOneIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + ) +}) + +HeadingOneIcon.displayName = "HeadingOneIcon" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/heading-six-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/heading-six-icon.tsx new file mode 100644 index 0000000000..3d72d47f85 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/heading-six-icon.tsx @@ -0,0 +1,30 @@ +import React, { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const HeadingSixIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + ) +}) + +HeadingSixIcon.displayName = "HeadingSixIcon" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/heading-three-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/heading-three-icon.tsx new file mode 100644 index 0000000000..ac5083bb76 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/heading-three-icon.tsx @@ -0,0 +1,36 @@ +import React, { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const HeadingThreeIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + + ) +}) + +HeadingThreeIcon.displayName = "HeadingThreeIcon" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/heading-two-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/heading-two-icon.tsx new file mode 100644 index 0000000000..0a98dab228 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/heading-two-icon.tsx @@ -0,0 +1,28 @@ +import React, { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const HeadingTwoIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + ) +}) + +HeadingTwoIcon.displayName = "HeadingTwoIcon" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/highlighter-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/highlighter-icon.tsx new file mode 100644 index 0000000000..46e77a8c42 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/highlighter-icon.tsx @@ -0,0 +1,26 @@ +import React, { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const HighlighterIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + ) +}) + +HighlighterIcon.displayName = "HighlighterIcon" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/image-plus-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/image-plus-icon.tsx new file mode 100644 index 0000000000..774e34685d --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/image-plus-icon.tsx @@ -0,0 +1,26 @@ +import React, { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const ImagePlusIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + ) +}) + +ImagePlusIcon.displayName = "ImagePlusIcon" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/italic-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/italic-icon.tsx new file mode 100644 index 0000000000..ee95b1fa40 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/italic-icon.tsx @@ -0,0 +1,24 @@ +import React, { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const ItalicIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + ) +}) + +ItalicIcon.displayName = "ItalicIcon" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/link-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/link-icon.tsx new file mode 100644 index 0000000000..8f2b5d6546 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/link-icon.tsx @@ -0,0 +1,28 @@ +import React, { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const LinkIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + ) +}) + +LinkIcon.displayName = "LinkIcon" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/list-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/list-icon.tsx new file mode 100644 index 0000000000..9952404f0b --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/list-icon.tsx @@ -0,0 +1,56 @@ +import React, { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const ListIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + + + + + ) +}) + +ListIcon.displayName = "ListIcon" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/list-ordered-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/list-ordered-icon.tsx new file mode 100644 index 0000000000..6d53e8bef4 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/list-ordered-icon.tsx @@ -0,0 +1,56 @@ +import React, { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const ListOrderedIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + + + + + ) +}) + +ListOrderedIcon.displayName = "ListOrderedIcon" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/list-todo-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/list-todo-icon.tsx new file mode 100644 index 0000000000..855ba29195 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/list-todo-icon.tsx @@ -0,0 +1,50 @@ +import React, { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const ListTodoIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + + + + ) +}) + +ListTodoIcon.displayName = "ListTodoIcon" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/moon-star-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/moon-star-icon.tsx new file mode 100644 index 0000000000..722512b84d --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/moon-star-icon.tsx @@ -0,0 +1,30 @@ +import React, { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const MoonStarIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + ) +}) + +MoonStarIcon.displayName = "MoonStarIcon" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/redo2-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/redo2-icon.tsx new file mode 100644 index 0000000000..7300743a3b --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/redo2-icon.tsx @@ -0,0 +1,26 @@ +import React, { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const Redo2Icon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + ) +}) + +Redo2Icon.displayName = "Redo2Icon" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/strike-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/strike-icon.tsx new file mode 100644 index 0000000000..2df2e778a3 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/strike-icon.tsx @@ -0,0 +1,28 @@ +import React, { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const StrikeIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + ) +}) + +StrikeIcon.displayName = "StrikeIcon" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/subscript-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/subscript-icon.tsx new file mode 100644 index 0000000000..1b051a9ad4 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/subscript-icon.tsx @@ -0,0 +1,38 @@ +import React, { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const SubscriptIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + + ) +}) + +SubscriptIcon.displayName = "SubscriptIcon" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/sun-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/sun-icon.tsx new file mode 100644 index 0000000000..b2def196f4 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/sun-icon.tsx @@ -0,0 +1,58 @@ +import React, { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const SunIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + + + + + + + + ) +}) + +SunIcon.displayName = "SunIcon" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/superscript-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/superscript-icon.tsx new file mode 100644 index 0000000000..57d0cc5893 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/superscript-icon.tsx @@ -0,0 +1,38 @@ +import React, { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const SuperscriptIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + + ) +}) + +SuperscriptIcon.displayName = "SuperscriptIcon" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/trash-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/trash-icon.tsx new file mode 100644 index 0000000000..85e64a5135 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/trash-icon.tsx @@ -0,0 +1,26 @@ +import React, { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const TrashIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + ) +}) + +TrashIcon.displayName = "TrashIcon" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/underline-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/underline-icon.tsx new file mode 100644 index 0000000000..387abd338a --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/underline-icon.tsx @@ -0,0 +1,26 @@ +import React, { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const UnderlineIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + ) +}) + +UnderlineIcon.displayName = "UnderlineIcon" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/undo2-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/undo2-icon.tsx new file mode 100644 index 0000000000..135b57c757 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/undo2-icon.tsx @@ -0,0 +1,26 @@ +import React, { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const Undo2Icon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + ) +}) + +Undo2Icon.displayName = "Undo2Icon" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/blockquote-node/blockquote-node.scss b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/blockquote-node/blockquote-node.scss new file mode 100644 index 0000000000..b49c5e11e4 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/blockquote-node/blockquote-node.scss @@ -0,0 +1,37 @@ +.tiptap.ProseMirror { + --blockquote-bg-color: var(--tt-gray-light-900); + + .dark & { + --blockquote-bg-color: var(--tt-gray-dark-900); + } +} + +/* ===================== + BLOCKQUOTE + ===================== */ +.tiptap.ProseMirror { + blockquote { + position: relative; + padding-left: 1em; + padding-top: 0.375em; + padding-bottom: 0.375em; + margin: 1.5rem 0; + + p { + margin-top: 0; + } + + &::before, + &.is-empty::before { + position: absolute; + bottom: 0; + left: 0; + top: 0; + height: 100%; + width: 0.25em; + background-color: var(--blockquote-bg-color); + content: ""; + border-radius: 0; + } + } +} diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/code-block-node/code-block-node.scss b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/code-block-node/code-block-node.scss new file mode 100644 index 0000000000..d31b312f6d --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/code-block-node/code-block-node.scss @@ -0,0 +1,54 @@ +.tiptap.ProseMirror { + --tt-inline-code-bg-color: var(--tt-gray-light-a-100); + --tt-inline-code-text-color: var(--tt-gray-light-a-700); + --tt-inline-code-border-color: var(--tt-gray-light-a-200); + --tt-codeblock-bg: var(--tt-gray-light-a-50); + --tt-codeblock-text: var(--tt-gray-light-a-800); + --tt-codeblock-border: var(--tt-gray-light-a-200); + + .dark & { + --tt-inline-code-bg-color: var(--tt-gray-dark-a-100); + --tt-inline-code-text-color: var(--tt-gray-dark-a-700); + --tt-inline-code-border-color: var(--tt-gray-dark-a-200); + --tt-codeblock-bg: var(--tt-gray-dark-a-50); + --tt-codeblock-text: var(--tt-gray-dark-a-800); + --tt-codeblock-border: var(--tt-gray-dark-a-200); + } +} + +/* ===================== + CODE FORMATTING + ===================== */ +.tiptap.ProseMirror { + // Inline code + code { + background-color: var(--tt-inline-code-bg-color); + color: var(--tt-inline-code-text-color); + border: 1px solid var(--tt-inline-code-border-color); + font-family: "JetBrains Mono NL", monospace; + font-size: 0.875em; + line-height: 1.4; + border-radius: 6px/0.375rem; + padding: 0.1em 0.2em; + } + + // Code blocks + pre { + background-color: var(--tt-codeblock-bg); + color: var(--tt-codeblock-text); + border: 1px solid var(--tt-codeblock-border); + margin-top: 1.5em; + margin-bottom: 1.5em; + padding: 1em; + font-size: 1rem; + border-radius: 6px/0.375rem; + + code { + background-color: transparent; + border: none; + border-radius: 0; + -webkit-text-fill-color: inherit; + color: inherit; + } + } +} diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/heading-node/heading-node.scss b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/heading-node/heading-node.scss new file mode 100644 index 0000000000..882dda2d3a --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/heading-node/heading-node.scss @@ -0,0 +1,39 @@ +.tiptap.ProseMirror { + h1, + h2, + h3, + h4 { + position: relative; + color: inherit; + font-style: inherit; + + &:first-child, + &:first-of-type { + margin-top: 0; + } + } + + h1 { + font-size: 1.5em; + font-weight: 700; + margin-top: 3em; + } + + h2 { + font-size: 1.25em; + font-weight: 700; + margin-top: 2.5em; + } + + h3 { + font-size: 1.125em; + font-weight: 600; + margin-top: 2em; + } + + h4 { + font-size: 1em; + font-weight: 600; + margin-top: 2em; + } +} diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/horizontal-rule-node/horizontal-rule-node-extension.ts b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/horizontal-rule-node/horizontal-rule-node-extension.ts new file mode 100644 index 0000000000..de28208616 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/horizontal-rule-node/horizontal-rule-node-extension.ts @@ -0,0 +1,14 @@ +import { mergeAttributes } from "@tiptap/react" +import TiptapHorizontalRule from "@tiptap/extension-horizontal-rule" + +export const HorizontalRule = TiptapHorizontalRule.extend({ + renderHTML() { + return [ + "div", + mergeAttributes(this.options.HTMLAttributes, { "data-type": this.name }), + ["hr"], + ] + }, +}) + +export default HorizontalRule diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/horizontal-rule-node/horizontal-rule-node.scss b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/horizontal-rule-node/horizontal-rule-node.scss new file mode 100644 index 0000000000..4626e65889 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/horizontal-rule-node/horizontal-rule-node.scss @@ -0,0 +1,25 @@ +.tiptap.ProseMirror { + --horizontal-rule-color: var(--tt-gray-light-a-200); + + .dark & { + --horizontal-rule-color: var(--tt-gray-dark-a-200); + } +} + +/* ===================== + HORIZONTAL RULE + ===================== */ +.tiptap.ProseMirror { + hr { + border: none; + height: 1px; + background-color: var(--horizontal-rule-color); + } + + [data-type="horizontalRule"] { + margin-top: 2.25em; + margin-bottom: 2.25em; + padding-top: 0.75rem; + padding-bottom: 0.75rem; + } +} diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/image-node/image-node.scss b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/image-node/image-node.scss new file mode 100644 index 0000000000..10d4231cac --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/image-node/image-node.scss @@ -0,0 +1,31 @@ +.tiptap.ProseMirror { + img { + max-width: 100%; + height: auto; + display: block; + } + + > img:not([data-type="emoji"] img) { + margin: 2rem 0; + outline: 0.125rem solid transparent; + border-radius: var(--tt-radius-xs, 0.25rem); + } + + img:not([data-type="emoji"] img).ProseMirror-selectednode { + outline-color: var(--tt-brand-color-500); + } + + // Thread image handling + .tiptap-thread:has(> img) { + margin: 2rem 0; + + img { + outline: 0.125rem solid transparent; + border-radius: var(--tt-radius-xs, 0.25rem); + } + } + + .tiptap-thread img { + margin: 0; + } +} diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/image-upload-node/image-upload-node-extension.ts b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/image-upload-node/image-upload-node-extension.ts new file mode 100644 index 0000000000..deceed64ee --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/image-upload-node/image-upload-node-extension.ts @@ -0,0 +1,162 @@ +import { mergeAttributes, Node } from "@tiptap/react" +import { ReactNodeViewRenderer } from "@tiptap/react" +import { ImageUploadNode as ImageUploadNodeComponent } from "./image-upload-node" +import type { NodeType } from "@tiptap/pm/model" + +export type UploadFunction = ( + file: File, + onProgress?: (event: { progress: number }) => void, + abortSignal?: AbortSignal, +) => Promise + +export interface ImageUploadNodeOptions { + /** + * The type of the node. + * @default 'image' + */ + type?: string | NodeType | undefined + /** + * Acceptable file types for upload. + * @default 'image/*' + */ + accept?: string + /** + * Maximum number of files that can be uploaded. + * @default 1 + */ + limit?: number + /** + * Maximum file size in bytes (0 for unlimited). + * @default 0 + */ + maxSize?: number + /** + * Function to handle the upload process. + */ + upload?: UploadFunction + /** + * Callback for upload errors. + */ + onError?: (error: Error) => void + /** + * Callback for successful uploads. + */ + onSuccess?: (url: string) => void + /** + * HTML attributes to add to the image element. + * @default {} + * @example { class: 'foo' } + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + HTMLAttributes: Record +} + +declare module "@tiptap/react" { + interface Commands { + imageUpload: { + setImageUploadNode: (options?: ImageUploadNodeOptions) => ReturnType + } + } +} + +/** + * A Tiptap node extension that creates an image upload component. + * @see registry/tiptap-node/image-upload-node/image-upload-node + */ +export const ImageUploadNode = Node.create({ + name: "imageUpload", + + group: "block", + + draggable: true, + + selectable: true, + + atom: true, + + addOptions() { + return { + type: "image", + accept: "image/*", + limit: 1, + maxSize: 0, + upload: undefined, + onError: undefined, + onSuccess: undefined, + HTMLAttributes: {}, + } + }, + + addAttributes() { + return { + accept: { + default: this.options.accept, + }, + limit: { + default: this.options.limit, + }, + maxSize: { + default: this.options.maxSize, + }, + } + }, + + parseHTML() { + return [{ tag: 'div[data-type="image-upload"]' }] + }, + + renderHTML({ HTMLAttributes }) { + return [ + "div", + mergeAttributes({ "data-type": "image-upload" }, HTMLAttributes), + ] + }, + + addNodeView() { + return ReactNodeViewRenderer(ImageUploadNodeComponent) + }, + + addCommands() { + return { + setImageUploadNode: + (options) => + ({ commands }) => { + return commands.insertContent({ + type: this.name, + attrs: options, + }) + }, + } + }, + + /** + * Adds Enter key handler to trigger the upload component when it's selected. + */ + addKeyboardShortcuts() { + return { + Enter: ({ editor }) => { + const { selection } = editor.state + const { nodeAfter } = selection.$from + + if ( + nodeAfter && + nodeAfter.type.name === "imageUpload" && + editor.isActive("imageUpload") + ) { + const nodeEl = editor.view.nodeDOM(selection.$from.pos) + if (nodeEl && nodeEl instanceof HTMLElement) { + // Since NodeViewWrapper is wrapped with a div, we need to click the first child + const firstChild = nodeEl.firstChild + if (firstChild && firstChild instanceof HTMLElement) { + firstChild.click() + return true + } + } + } + return false + }, + } + }, +}) + +export default ImageUploadNode diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/image-upload-node/image-upload-node.scss b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/image-upload-node/image-upload-node.scss new file mode 100644 index 0000000000..b85e1e33f1 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/image-upload-node/image-upload-node.scss @@ -0,0 +1,249 @@ +:root { + --tiptap-image-upload-active: var(--tt-brand-color-500); + --tiptap-image-upload-progress-bg: var(--tt-brand-color-50); + --tiptap-image-upload-icon-bg: var(--tt-brand-color-500); + + --tiptap-image-upload-text-color: var(--tt-gray-light-a-700); + --tiptap-image-upload-subtext-color: var(--tt-gray-light-a-400); + --tiptap-image-upload-border: var(--tt-gray-light-a-300); + --tiptap-image-upload-border-hover: var(--tt-gray-light-a-400); + --tiptap-image-upload-border-active: var(--tt-brand-color-500); + + --tiptap-image-upload-icon-doc-bg: var(--tt-gray-light-a-200); + --tiptap-image-upload-icon-doc-border: var(--tt-gray-light-300); + --tiptap-image-upload-icon-color: var(--white); +} + +.dark { + --tiptap-image-upload-active: var(--tt-brand-color-400); + --tiptap-image-upload-progress-bg: var(--tt-brand-color-900); + --tiptap-image-upload-icon-bg: var(--tt-brand-color-400); + + --tiptap-image-upload-text-color: var(--tt-gray-dark-a-700); + --tiptap-image-upload-subtext-color: var(--tt-gray-dark-a-400); + --tiptap-image-upload-border: var(--tt-gray-dark-a-300); + --tiptap-image-upload-border-hover: var(--tt-gray-dark-a-400); + --tiptap-image-upload-border-active: var(--tt-brand-color-400); + + --tiptap-image-upload-icon-doc-bg: var(--tt-gray-dark-a-200); + --tiptap-image-upload-icon-doc-border: var(--tt-gray-dark-300); + --tiptap-image-upload-icon-color: var(--black); +} + +.tiptap-image-upload { + margin: 2rem 0; + + input[type="file"] { + display: none; + } + + .tiptap-image-upload-dropzone { + position: relative; + width: 3.125rem; + height: 3.75rem; + display: inline-flex; + align-items: flex-start; + justify-content: center; + -webkit-user-select: none; /* Safari */ + -ms-user-select: none; /* IE 10 and IE 11 */ + user-select: none; + } + + .tiptap-image-upload-icon-container { + position: absolute; + width: 1.75rem; + height: 1.75rem; + bottom: 0; + right: 0; + background-color: var(--tiptap-image-upload-icon-bg); + border-radius: var(--tt-radius-lg, 0.75rem); + display: flex; + align-items: center; + justify-content: center; + } + + .tiptap-image-upload-icon { + width: 0.875rem; + height: 0.875rem; + color: var(--tiptap-image-upload-icon-color); + } + + .tiptap-image-upload-dropzone-rect-primary { + color: var(--tiptap-image-upload-icon-doc-bg); + position: absolute; + } + + .tiptap-image-upload-dropzone-rect-secondary { + position: absolute; + top: 0; + right: 0.25rem; + bottom: 0; + color: var(--tiptap-image-upload-icon-doc-border); + } + + .tiptap-image-upload-text { + color: var(--tiptap-image-upload-text-color); + font-weight: 500; + font-size: 0.875rem; + line-height: normal; + + em { + font-style: normal; + text-decoration: underline; + } + } + + .tiptap-image-upload-subtext { + color: var(--tiptap-image-upload-subtext-color); + font-weight: 600; + line-height: normal; + font-size: 0.75rem; + } + + .tiptap-image-upload-drag-area { + padding: 2rem 1.5rem; + border: 1.5px dashed var(--tiptap-image-upload-border); + border-radius: var(--tt-radius-md, 0.5rem); + text-align: center; + cursor: pointer; + position: relative; + overflow: hidden; + transition: all 0.2s ease; + + &:hover { + border-color: var(--tiptap-image-upload-border-hover); + } + + &.drag-active { + border-color: var(--tiptap-image-upload-border-active); + background-color: rgba( + var(--tiptap-image-upload-active-rgb, 0, 123, 255), + 0.05 + ); + } + + &.drag-over { + border-color: var(--tiptap-image-upload-border-active); + background-color: rgba( + var(--tiptap-image-upload-active-rgb, 0, 123, 255), + 0.1 + ); + } + } + + .tiptap-image-upload-content { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 0.25rem; + -webkit-user-select: none; /* Safari */ + -ms-user-select: none; /* IE 10 and IE 11 */ + user-select: none; + } + + .tiptap-image-upload-previews { + display: flex; + flex-direction: column; + gap: 0.75rem; + } + + .tiptap-image-upload-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 0; + border-bottom: 1px solid var(--tiptap-image-upload-border); + margin-bottom: 0.5rem; + + span { + font-size: 0.875rem; + font-weight: 500; + color: var(--tiptap-image-upload-text-color); + } + } + + // === Individual File Preview Styles === + .tiptap-image-upload-preview { + position: relative; + border-radius: var(--tt-radius-md, 0.5rem); + overflow: hidden; + + .tiptap-image-upload-progress { + position: absolute; + inset: 0; + background-color: var(--tiptap-image-upload-progress-bg); + transition: all 300ms ease-out; + } + + .tiptap-image-upload-preview-content { + position: relative; + border: 1px solid var(--tiptap-image-upload-border); + border-radius: var(--tt-radius-md, 0.5rem); + padding: 1rem; + display: flex; + align-items: center; + justify-content: space-between; + } + + .tiptap-image-upload-file-info { + display: flex; + align-items: center; + gap: 0.75rem; + height: 2rem; + + .tiptap-image-upload-file-icon { + padding: 0.5rem; + background-color: var(--tiptap-image-upload-icon-bg); + border-radius: var(--tt-radius-lg, 0.75rem); + + svg { + width: 0.875rem; + height: 0.875rem; + color: var(--tiptap-image-upload-icon-color); + } + } + } + + .tiptap-image-upload-details { + display: flex; + flex-direction: column; + } + + .tiptap-image-upload-actions { + display: flex; + align-items: center; + gap: 0.5rem; + + .tiptap-image-upload-progress-text { + font-size: 0.75rem; + color: var(--tiptap-image-upload-border-active); + font-weight: 600; + } + } + } +} + +.tiptap.ProseMirror.ProseMirror-focused { + .ProseMirror-selectednode .tiptap-image-upload-drag-area { + border-color: var(--tiptap-image-upload-active); + } +} + +@media (max-width: 480px) { + .tiptap-image-upload { + .tiptap-image-upload-drag-area { + padding: 1.5rem 1rem; + } + + .tiptap-image-upload-header { + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + } + + .tiptap-image-upload-preview-content { + padding: 0.75rem; + } + } +} diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/image-upload-node/image-upload-node.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/image-upload-node/image-upload-node.tsx new file mode 100644 index 0000000000..c5b5ead26d --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/image-upload-node/image-upload-node.tsx @@ -0,0 +1,554 @@ +"use client" + +import React, { useRef, useState } from "react" +import type { NodeViewProps } from "@tiptap/react" +import { NodeViewWrapper } from "@tiptap/react" +import { Button } from "../../tiptap-ui-primitive/button" +import { CloseIcon } from "../../tiptap-icons/close-icon" +import "./image-upload-node.scss" +import { focusNextNode, isValidPosition } from "../../../lib/tiptap-utils" + +export interface FileItem { + /** + * Unique identifier for the file item + */ + id: string + /** + * The actual File object being uploaded + */ + file: File + /** + * Current upload progress as a percentage (0-100) + */ + progress: number + /** + * Current status of the file upload process + * @default "uploading" + */ + status: "uploading" | "success" | "error" + + /** + * URL to the uploaded file, available after successful upload + * @optional + */ + url?: string + /** + * Controller that can be used to abort the upload process + * @optional + */ + abortController?: AbortController +} + +export interface UploadOptions { + /** + * Maximum allowed file size in bytes + */ + maxSize: number + /** + * Maximum number of files that can be uploaded + */ + limit: number + /** + * String specifying acceptable file types (MIME types or extensions) + * @example ".jpg,.png,image/jpeg" or "image/*" + */ + accept: string + /** + * Function that handles the actual file upload process + * @param {File} file - The file to be uploaded + * @param {Function} onProgress - Callback function to report upload progress + * @param {AbortSignal} signal - Signal that can be used to abort the upload + * @returns {Promise} Promise resolving to the URL of the uploaded file + */ + upload: ( + file: File, + onProgress: (event: { progress: number }) => void, + signal: AbortSignal, + ) => Promise + /** + * Callback triggered when a file is uploaded successfully + * @param {string} url - URL of the successfully uploaded file + * @optional + */ + onSuccess?: (url: string) => void + /** + * Callback triggered when an error occurs during upload + * @param {Error} error - The error that occurred + * @optional + */ + onError?: (error: Error) => void +} + +/** + * Custom hook for managing multiple file uploads with progress tracking and cancellation + */ +function useFileUpload(options: UploadOptions) { + const [fileItems, setFileItems] = useState([]) + + const uploadFile = async (file: File): Promise => { + if (file.size > options.maxSize) { + const error = new Error( + `File size exceeds maximum allowed (${options.maxSize / 1024 / 1024}MB)`, + ) + options.onError?.(error) + return null + } + + const abortController = new AbortController() + const fileId = crypto.randomUUID() + + const newFileItem: FileItem = { + id: fileId, + file, + progress: 0, + status: "uploading", + abortController, + } + + setFileItems((prev) => [...prev, newFileItem]) + + try { + if (!options.upload) { + throw new Error("Upload function is not defined") + } + + const url = await options.upload( + file, + (event: { progress: number }) => { + setFileItems((prev) => + prev.map((item) => + item.id === fileId ? { ...item, progress: event.progress } : item, + ), + ) + }, + abortController.signal, + ) + + if (!url) throw new Error("Upload failed: No URL returned") + + if (!abortController.signal.aborted) { + setFileItems((prev) => + prev.map((item) => + item.id === fileId + ? { ...item, status: "success", url, progress: 100 } + : item, + ), + ) + options.onSuccess?.(url) + return url + } + + return null + } catch (error) { + if (!abortController.signal.aborted) { + setFileItems((prev) => + prev.map((item) => + item.id === fileId + ? { ...item, status: "error", progress: 0 } + : item, + ), + ) + options.onError?.( + error instanceof Error ? error : new Error("Upload failed"), + ) + } + return null + } + } + + const uploadFiles = async (files: File[]): Promise => { + if (!files || files.length === 0) { + options.onError?.(new Error("No files to upload")) + return [] + } + + if (options.limit && files.length > options.limit) { + options.onError?.( + new Error( + `Maximum ${options.limit} file${options.limit === 1 ? "" : "s"} allowed`, + ), + ) + return [] + } + + // Upload all files concurrently + const uploadPromises = files.map((file) => uploadFile(file)) + const results = await Promise.all(uploadPromises) + + // Filter out null results (failed uploads) + return results.filter((url): url is string => url !== null) + } + + const removeFileItem = (fileId: string) => { + setFileItems((prev) => { + const fileToRemove = prev.find((item) => item.id === fileId) + if (fileToRemove?.abortController) { + fileToRemove.abortController.abort() + } + if (fileToRemove?.url) { + URL.revokeObjectURL(fileToRemove.url) + } + return prev.filter((item) => item.id !== fileId) + }) + } + + const clearAllFiles = () => { + fileItems.forEach((item) => { + if (item.abortController) { + item.abortController.abort() + } + if (item.url) { + URL.revokeObjectURL(item.url) + } + }) + setFileItems([]) + } + + return { + fileItems, + uploadFiles, + removeFileItem, + clearAllFiles, + } +} + +const CloudUploadIcon: React.FC = () => ( + + + + +) + +const FileIcon: React.FC = () => ( + + + +) + +const FileCornerIcon: React.FC = () => ( + + + +) + +interface ImageUploadDragAreaProps { + /** + * Callback function triggered when files are dropped or selected + * @param {File[]} files - Array of File objects that were dropped or selected + */ + onFile: (files: File[]) => void + /** + * Optional child elements to render inside the drag area + * @optional + * @default undefined + */ + children?: React.ReactNode +} + +/** + * A component that creates a drag-and-drop area for image uploads + */ +const ImageUploadDragArea: React.FC = ({ + onFile, + children, +}) => { + const [isDragOver, setIsDragOver] = useState(false) + const [isDragActive, setIsDragActive] = useState(false) + + const handleDragEnter = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragActive(true) + } + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + if (!e.currentTarget.contains(e.relatedTarget as Node)) { + setIsDragActive(false) + setIsDragOver(false) + } + } + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragOver(true) + } + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragActive(false) + setIsDragOver(false) + + const files = Array.from(e.dataTransfer.files) + if (files.length > 0) { + onFile(files) + } + } + + return ( +
+ {children} +
+ ) +} + +interface ImageUploadPreviewProps { + /** + * The file item to preview + */ + fileItem: FileItem + /** + * Callback to remove this file from upload queue + */ + onRemove: () => void +} + +/** + * Component that displays a preview of an uploading file with progress + */ +const ImageUploadPreview: React.FC = ({ + fileItem, + onRemove, +}) => { + const formatFileSize = (bytes: number) => { + if (bytes === 0) return "0 Bytes" + const k = 1024 + const sizes = ["Bytes", "KB", "MB", "GB"] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}` + } + + return ( +
+ {fileItem.status === "uploading" && ( +
+ )} + +
+
+
+ +
+
+ + {fileItem.file.name} + + + {formatFileSize(fileItem.file.size)} + +
+
+
+ {fileItem.status === "uploading" && ( + + {fileItem.progress}% + + )} + +
+
+
+ ) +} + +const DropZoneContent: React.FC<{ maxSize: number; limit: number }> = ({ + maxSize, + limit, +}) => ( + <> +
+ + +
+ +
+
+ +
+ + Click to upload or drag and drop + + + Maximum {limit} file{limit === 1 ? "" : "s"}, {maxSize / 1024 / 1024}MB + each. + +
+ +) + +export const ImageUploadNode: React.FC = (props) => { + const { accept, limit, maxSize } = props.node.attrs + const inputRef = useRef(null) + const extension = props.extension + + const uploadOptions: UploadOptions = { + maxSize, + limit, + accept, + upload: extension.options.upload, + onSuccess: extension.options.onSuccess, + onError: extension.options.onError, + } + + const { fileItems, uploadFiles, removeFileItem, clearAllFiles } = + useFileUpload(uploadOptions) + + const handleUpload = async (files: File[]) => { + const urls = await uploadFiles(files) + + if (urls.length > 0) { + const pos = props.getPos() + + if (isValidPosition(pos)) { + const imageNodes = urls.map((url, index) => { + const filename = + files[index]?.name.replace(/\.[^/.]+$/, "") || "unknown" + return { + type: extension.options.type, + attrs: { + ...extension.options, + src: url, + alt: filename, + title: filename, + }, + } + }) + + props.editor + .chain() + .focus() + .deleteRange({ from: pos, to: pos + props.node.nodeSize }) + .insertContentAt(pos, imageNodes) + .run() + + focusNextNode(props.editor) + } + } + } + + const handleChange = (e: React.ChangeEvent) => { + const files = e.target.files + if (!files || files.length === 0) { + extension.options.onError?.(new Error("No file selected")) + return + } + handleUpload(Array.from(files)) + } + + const handleClick = () => { + if (inputRef.current && fileItems.length === 0) { + inputRef.current.value = "" + inputRef.current.click() + } + } + + const hasFiles = fileItems.length > 0 + + return ( + + {!hasFiles && ( + + + + )} + + {hasFiles && ( +
+ {fileItems.length > 1 && ( +
+ Uploading {fileItems.length} files + +
+ )} + {fileItems.map((fileItem) => ( + removeFileItem(fileItem.id)} + /> + ))} +
+ )} + + 1} + onChange={handleChange} + onClick={(e: React.MouseEvent) => e.stopPropagation()} + /> +
+ ) +} diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/image-upload-node/index.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/image-upload-node/index.tsx new file mode 100644 index 0000000000..2510a62fae --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/image-upload-node/index.tsx @@ -0,0 +1 @@ +export * from "./image-upload-node-extension" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/list-node/list-node.scss b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/list-node/list-node.scss new file mode 100644 index 0000000000..d0fe5c8f25 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/list-node/list-node.scss @@ -0,0 +1,160 @@ +.tiptap.ProseMirror { + --tt-checklist-bg-color: var(--tt-gray-light-a-100); + --tt-checklist-bg-active-color: var(--tt-gray-light-a-900); + --tt-checklist-border-color: var(--tt-gray-light-a-200); + --tt-checklist-border-active-color: var(--tt-gray-light-a-900); + --tt-checklist-check-icon-color: var(--white); + --tt-checklist-text-active: var(--tt-gray-light-a-500); + + .dark & { + --tt-checklist-bg-color: var(--tt-gray-dark-a-100); + --tt-checklist-bg-active-color: var(--tt-gray-dark-a-900); + --tt-checklist-border-color: var(--tt-gray-dark-a-200); + --tt-checklist-border-active-color: var(--tt-gray-dark-a-900); + --tt-checklist-check-icon-color: var(--black); + --tt-checklist-text-active: var(--tt-gray-dark-a-500); + } +} + +/* ===================== + LISTS + ===================== */ +.tiptap.ProseMirror { + // Common list styles + ol, + ul { + margin-top: 1.5em; + margin-bottom: 1.5em; + padding-left: 1.5em; + + &:first-child { + margin-top: 0; + } + + &:last-child { + margin-bottom: 0; + } + + ol, + ul { + margin-top: 0; + margin-bottom: 0; + } + } + + li { + p { + margin-top: 0; + line-height: 1.6; + } + } + + // Ordered lists + ol { + list-style: decimal; + + ol { + list-style: lower-alpha; + + ol { + list-style: lower-roman; + } + } + } + + // Unordered lists + ul:not([data-type="taskList"]) { + list-style: disc; + + ul { + list-style: circle; + + ul { + list-style: square; + } + } + } + + // Task lists + ul[data-type="taskList"] { + padding-left: 0.25em; + + li { + display: flex; + flex-direction: row; + align-items: flex-start; + + &:not(:has(> p:first-child)) { + list-style-type: none; + } + + &[data-checked="true"] { + > div > p { + opacity: 0.5; + text-decoration: line-through; + } + + > div > p span { + text-decoration: line-through; + } + } + + label { + position: relative; + padding-top: 0.375rem; + padding-right: 0.5rem; + + input[type="checkbox"] { + position: absolute; + opacity: 0; + width: 0; + height: 0; + } + + span { + display: block; + width: 1em; + height: 1em; + border: 1px solid var(--tt-checklist-border-color); + border-radius: var(--tt-radius-xs, 0.25rem); + position: relative; + cursor: pointer; + background-color: var(--tt-checklist-bg-color); + transition: + background-color 80ms ease-out, + border-color 80ms ease-out; + + &::before { + content: ""; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: 0.75em; + height: 0.75em; + background-color: var(--tt-checklist-check-icon-color); + opacity: 0; + -webkit-mask: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22currentColor%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M21.4142%204.58579C22.1953%205.36683%2022.1953%206.63317%2021.4142%207.41421L10.4142%2018.4142C9.63317%2019.1953%208.36684%2019.1953%207.58579%2018.4142L2.58579%2013.4142C1.80474%2012.6332%201.80474%2011.3668%202.58579%2010.5858C3.36683%209.80474%204.63317%209.80474%205.41421%2010.5858L9%2014.1716L18.5858%204.58579C19.3668%203.80474%2020.6332%203.80474%2021.4142%204.58579Z%22%20fill%3D%22currentColor%22%2F%3E%3C%2Fsvg%3E") + center/contain no-repeat; + mask: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22currentColor%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M21.4142%204.58579C22.1953%205.36683%2022.1953%206.63317%2021.4142%207.41421L10.4142%2018.4142C9.63317%2019.1953%208.36684%2019.1953%207.58579%2018.4142L2.58579%2013.4142C1.80474%2012.6332%201.80474%2011.3668%202.58579%2010.5858C3.36683%209.80474%204.63317%209.80474%205.41421%2010.5858L9%2014.1716L18.5858%204.58579C19.3668%203.80474%2020.6332%203.80474%2021.4142%204.58579Z%22%20fill%3D%22currentColor%22%2F%3E%3C%2Fsvg%3E") + center/contain no-repeat; + } + } + + input[type="checkbox"]:checked + span { + background: var(--tt-checklist-bg-active-color); + border-color: var(--tt-checklist-border-active-color); + + &::before { + opacity: 1; + } + } + } + + div { + flex: 1 1 0%; + min-width: 0; + } + } + } +} diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/paragraph-node/paragraph-node.scss b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/paragraph-node/paragraph-node.scss new file mode 100644 index 0000000000..e5ff0b965d --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/paragraph-node/paragraph-node.scss @@ -0,0 +1,273 @@ +.tiptap.ProseMirror { + --tt-collaboration-carets-label: var(--tt-gray-light-900); + --link-text-color: var(--tt-brand-color-500); + --thread-text: var(--tt-gray-light-900); + --placeholder-color: var(--tt-gray-light-a-400); + --thread-bg-color: var(--tt-color-yellow-inc-2); + + // ai + --tiptap-ai-insertion-color: var(--tt-brand-color-600); + + .dark & { + --tt-collaboration-carets-label: var(--tt-gray-dark-100); + --link-text-color: var(--tt-brand-color-400); + --thread-text: var(--tt-gray-dark-900); + --placeholder-color: var(--tt-gray-dark-a-400); + --thread-bg-color: var(--tt-color-yellow-dec-2); + + --tiptap-ai-insertion-color: var(--tt-brand-color-400); + } +} + +/* Ensure each top-level node has relative positioning + so absolutely positioned placeholders work correctly */ +.tiptap.ProseMirror > * { + position: relative; +} + +/* ===================== + CORE EDITOR STYLES + ===================== */ +.tiptap.ProseMirror { + white-space: pre-wrap; + outline: none; + caret-color: var(--tt-cursor-color); + + // Paragraph spacing + p:not(:first-child):not(td p):not(th p) { + font-size: 1rem; + line-height: 1.6; + font-weight: normal; + margin-top: 20px; + } + + // Selection styles + &:not(.readonly):not(.ProseMirror-hideselection) { + ::selection { + background-color: var(--tt-selection-color); + } + + .selection::selection { + background: transparent; + } + } + + .selection { + display: inline; + background-color: var(--tt-selection-color); + } + + // Selected node styles + .ProseMirror-selectednode:not(img):not(pre):not(.react-renderer) { + border-radius: var(--tt-radius-md); + background-color: var(--tt-selection-color); + } + + .ProseMirror-hideselection { + caret-color: transparent; + } + + // Resize cursor + &.resize-cursor { + cursor: ew-resize; + cursor: col-resize; + } +} + +/* ===================== + TEXT DECORATION + ===================== */ +.tiptap.ProseMirror { + // Text decoration inheritance for spans + a span { + text-decoration: underline; + } + + s span { + text-decoration: line-through; + } + + u span { + text-decoration: underline; + } + + .tiptap-ai-insertion { + color: var(--tiptap-ai-insertion-color); + } +} + +/* ===================== + COLLABORATION + ===================== */ +.tiptap.ProseMirror { + .collaboration-carets { + &__caret { + border-right: 1px solid transparent; + border-left: 1px solid transparent; + pointer-events: none; + margin-left: -1px; + margin-right: -1px; + position: relative; + word-break: normal; + } + + &__label { + color: var(--tt-collaboration-carets-label); + border-radius: 0.25rem; + border-bottom-left-radius: 0; + font-size: 0.75rem; + font-weight: 600; + left: -1px; + line-height: 1; + padding: 0.125rem 0.375rem; + position: absolute; + top: -1.3em; + user-select: none; + white-space: nowrap; + } + } +} + +/* ===================== + EMOJI + ===================== */ +.tiptap.ProseMirror [data-type="emoji"] img { + display: inline-block; + width: 1.25em; + height: 1.25em; + cursor: text; +} + +/* ===================== + LINKS + ===================== */ +.tiptap.ProseMirror { + a { + color: var(--link-text-color); + text-decoration: underline; + } +} + +/* ===================== + MENTION + ===================== */ +.tiptap.ProseMirror { + [data-type="mention"] { + display: inline-block; + color: var(--tt-brand-color-500); + } +} + +/* ===================== + THREADS + ===================== */ +.tiptap.ProseMirror { + // Base styles for inline threads + .tiptap-thread.tiptap-thread--unresolved.tiptap-thread--inline { + transition: + color 0.2s ease-in-out, + background-color 0.2s ease-in-out; + color: var(--thread-text); + border-bottom: 2px dashed var(--tt-color-yellow-base); + font-weight: 600; + + &.tiptap-thread--selected, + &.tiptap-thread--hovered { + background-color: var(--thread-bg-color); + border-bottom-color: transparent; + } + } + + // Block thread styles with images + .tiptap-thread.tiptap-thread--unresolved.tiptap-thread--block { + &:has(img) { + outline: 0.125rem solid var(--tt-color-yellow-base); + border-radius: var(--tt-radius-xs, 0.25rem); + overflow: hidden; + width: fit-content; + + &.tiptap-thread--selected { + outline-width: 0.25rem; + outline-color: var(--tt-color-yellow-base); + } + + &.tiptap-thread--hovered { + outline-width: 0.25rem; + } + } + + // Block thread styles without images + &:not(:has(img)) { + border-radius: 0.25rem; + border-bottom: 0.125rem dashed var(--tt-color-yellow-base); + border-top: 0.125rem dashed var(--tt-color-yellow-base); + // padding-bottom: 0.5rem; + outline: 0.25rem solid transparent; + + &.tiptap-thread--hovered, + &.tiptap-thread--selected { + background-color: var(--tt-color-yellow-base); + outline-color: var(--tt-color-yellow-base); + } + } + } + + // Resolved thread styles + .tiptap-thread.tiptap-thread--resolved.tiptap-thread--inline.tiptap-thread--selected { + background-color: var(--tt-color-yellow-base); + border-color: transparent; + opacity: 0.5; + } + + // React renderer specific styles + .tiptap-thread.tiptap-thread--block:has(.react-renderer) { + margin-top: 3rem; + margin-bottom: 3rem; + } +} + +/* ===================== + PLACEHOLDER + ===================== */ +.is-empty:not(.with-slash)[data-placeholder]:has( + > .ProseMirror-trailingBreak:only-child + )::before { + content: attr(data-placeholder); +} + +.is-empty.with-slash[data-placeholder]:has( + > .ProseMirror-trailingBreak:only-child + )::before { + content: "Write, type '/' for commands…"; + font-style: italic; +} + +.is-empty[data-placeholder]:has( + > .ProseMirror-trailingBreak:only-child + ):before { + pointer-events: none; + height: 0; + position: absolute; + width: 100%; + text-align: inherit; + left: 0; + right: 0; +} + +.is-empty[data-placeholder]:has(> .ProseMirror-trailingBreak):before { + color: var(--placeholder-color); +} + +/* ===================== + DROPCURSOR + ===================== */ +.prosemirror-dropcursor-block, +.prosemirror-dropcursor-inline { + background: var(--tt-brand-color-400) !important; + border-radius: 0.25rem; + margin-left: -1px; + margin-right: -1px; + width: 100%; + height: 0.188rem; + cursor: grabbing; +} diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-templates/simple/data/content.json b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-templates/simple/data/content.json new file mode 100644 index 0000000000..4a3c0e8617 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-templates/simple/data/content.json @@ -0,0 +1,477 @@ +{ + "type": "doc", + "content": [ + { + "type": "heading", + "attrs": { + "textAlign": null, + "level": 1 + }, + "content": [ + { + "type": "text", + "text": "Getting started" + } + ] + }, + { + "type": "paragraph", + "attrs": { + "textAlign": null + }, + "content": [ + { + "type": "text", + "text": "Welcome to the " + }, + { + "type": "text", + "marks": [ + { + "type": "italic" + }, + { + "type": "highlight", + "attrs": { + "color": "var(--tt-color-highlight-yellow)" + } + } + ], + "text": "Simple Editor" + }, + { + "type": "text", + "text": " template! This template integrates " + }, + { + "type": "text", + "marks": [ + { + "type": "bold" + } + ], + "text": "open source" + }, + { + "type": "text", + "text": " UI components and Tiptap extensions licensed under " + }, + { + "type": "text", + "marks": [ + { + "type": "bold" + } + ], + "text": "MIT" + }, + { + "type": "text", + "text": "." + } + ] + }, + { + "type": "paragraph", + "attrs": { + "textAlign": null + }, + "content": [ + { + "type": "text", + "text": "Integrate it by following the " + }, + { + "type": "text", + "marks": [ + { + "type": "link", + "attrs": { + "href": "https://tiptap.dev/docs/ui-components/templates/simple-editor", + "target": "_blank", + "rel": "noopener noreferrer nofollow", + "class": null + } + } + ], + "text": "Tiptap UI Components docs" + }, + { + "type": "text", + "text": " or using our CLI tool." + } + ] + }, + { + "type": "codeBlock", + "attrs": { + "language": null + }, + "content": [ + { + "type": "text", + "text": "npx @tiptap/cli init" + } + ] + }, + { + "type": "heading", + "attrs": { + "textAlign": null, + "level": 2 + }, + "content": [ + { + "type": "text", + "text": "Features" + } + ] + }, + { + "type": "blockquote", + "content": [ + { + "type": "paragraph", + "attrs": { + "textAlign": null + }, + "content": [ + { + "type": "text", + "marks": [ + { + "type": "italic" + } + ], + "text": "A fully responsive rich text editor with built-in support for common formatting and layout tools. Type markdown " + }, + { + "type": "text", + "marks": [ + { + "type": "code" + } + ], + "text": "**" + }, + { + "type": "text", + "marks": [ + { + "type": "italic" + } + ], + "text": " or use keyboard shortcuts " + }, + { + "type": "text", + "marks": [ + { + "type": "code" + } + ], + "text": "⌘+B" + }, + { + "type": "text", + "text": " for " + }, + { + "type": "text", + "marks": [ + { + "type": "strike" + } + ], + "text": "most" + }, + { + "type": "text", + "text": " all common markdown marks. 🪄" + } + ] + } + ] + }, + { + "type": "paragraph", + "attrs": { + "textAlign": "left" + }, + "content": [ + { + "type": "text", + "text": "Add images, customize alignment, and apply " + }, + { + "type": "text", + "marks": [ + { + "type": "highlight", + "attrs": { + "color": "var(--tt-color-highlight-blue)" + } + } + ], + "text": "advanced formatting" + }, + { + "type": "text", + "text": " to make your writing more engaging and professional." + } + ] + }, + { + "type": "image", + "attrs": { + "src": "/images/tiptap-ui-placeholder-image.jpg", + "alt": "placeholder-image", + "title": "placeholder-image" + } + }, + { + "type": "bulletList", + "content": [ + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "attrs": { + "textAlign": "left" + }, + "content": [ + { + "type": "text", + "marks": [ + { + "type": "bold" + } + ], + "text": "Superscript" + }, + { + "type": "text", + "text": " (x" + }, + { + "type": "text", + "marks": [ + { + "type": "superscript" + } + ], + "text": "2" + }, + { + "type": "text", + "text": ") and " + }, + { + "type": "text", + "marks": [ + { + "type": "bold" + } + ], + "text": "Subscript" + }, + { + "type": "text", + "text": " (H" + }, + { + "type": "text", + "marks": [ + { + "type": "subscript" + } + ], + "text": "2" + }, + { + "type": "text", + "text": "O) for precision." + } + ] + } + ] + }, + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "attrs": { + "textAlign": "left" + }, + "content": [ + { + "type": "text", + "marks": [ + { + "type": "bold" + } + ], + "text": "Typographic conversion" + }, + { + "type": "text", + "text": ": automatically convert to " + }, + { + "type": "text", + "marks": [ + { + "type": "code" + } + ], + "text": "->" + }, + { + "type": "text", + "text": " an arrow " + }, + { + "type": "text", + "marks": [ + { + "type": "bold" + } + ], + "text": "→" + }, + { + "type": "text", + "text": "." + } + ] + } + ] + } + ] + }, + { + "type": "paragraph", + "attrs": { + "textAlign": "left" + }, + "content": [ + { + "type": "text", + "marks": [ + { + "type": "italic" + } + ], + "text": "→ " + }, + { + "type": "text", + "marks": [ + { + "type": "link", + "attrs": { + "href": "https://tiptap.dev/docs/ui-components/templates/simple-editor#features", + "target": "_blank", + "rel": "noopener noreferrer nofollow", + "class": null + } + } + ], + "text": "Learn more" + } + ] + }, + { + "type": "horizontalRule" + }, + { + "type": "heading", + "attrs": { + "textAlign": "left", + "level": 2 + }, + "content": [ + { + "type": "text", + "text": "Make it your own" + } + ] + }, + { + "type": "paragraph", + "attrs": { + "textAlign": "left" + }, + "content": [ + { + "type": "text", + "text": "Switch between light and dark modes, and tailor the editor's appearance with customizable CSS to match your style." + } + ] + }, + { + "type": "taskList", + "content": [ + { + "type": "taskItem", + "attrs": { + "checked": true + }, + "content": [ + { + "type": "paragraph", + "attrs": { + "textAlign": "left" + }, + "content": [ + { + "type": "text", + "text": "Test template" + } + ] + } + ] + }, + { + "type": "taskItem", + "attrs": { + "checked": false + }, + "content": [ + { + "type": "paragraph", + "attrs": { + "textAlign": "left" + }, + "content": [ + { + "type": "text", + "marks": [ + { + "type": "link", + "attrs": { + "href": "https://tiptap.dev/docs/ui-components/templates/simple-editor", + "target": "_blank", + "rel": "noopener noreferrer nofollow", + "class": null + } + } + ], + "text": "Integrate the free template" + } + ] + } + ] + } + ] + }, + { + "type": "paragraph", + "attrs": { + "textAlign": "left" + } + } + ] +} diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-templates/simple/simple-editor.scss b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-templates/simple/simple-editor.scss new file mode 100644 index 0000000000..8faf836440 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-templates/simple/simple-editor.scss @@ -0,0 +1,82 @@ +@import url("https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap"); + +body { + --tt-toolbar-height: 44px; + --tt-theme-text: var(--tt-gray-light-900); + + .dark & { + --tt-theme-text: var(--tt-gray-dark-900); + } +} + +body { + font-family: "Inter", sans-serif; + color: var(--tt-theme-text); + font-optical-sizing: auto; + font-weight: 400; + font-style: normal; + padding: 0; + overscroll-behavior-y: none; +} + +html, +body { + overscroll-behavior-x: none; +} + +html, +body, +#root, +#app { + height: 100%; + background-color: var(--tt-bg-color); +} + +::-webkit-scrollbar { + width: 0.25rem; +} + +* { + scrollbar-width: thin; + scrollbar-color: var(--tt-scrollbar-color) transparent; +} + +::-webkit-scrollbar-thumb { + background-color: var(--tt-scrollbar-color); + border-radius: 9999px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +.tiptap.ProseMirror { + font-family: "DM Sans", sans-serif; +} + +.simple-editor-wrapper { + width: 100vw; + height: 100vh; + overflow: auto; +} + +.simple-editor-content { + max-width: 648px; + width: 100%; + margin: 0 auto; + height: 100%; + display: flex; + flex-direction: column; + flex: 1; +} + +.simple-editor-content .tiptap.ProseMirror.simple-editor { + flex: 1; + padding: 3rem 3rem 30vh; +} + +@media screen and (max-width: 480px) { + .simple-editor-content .tiptap.ProseMirror.simple-editor { + padding: 1rem 1.5rem 30vh; + } +} diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-templates/simple/simple-editor.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-templates/simple/simple-editor.tsx new file mode 100644 index 0000000000..5be12cc8a7 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-templates/simple/simple-editor.tsx @@ -0,0 +1,290 @@ +// File not in use. This is the base Simple Editor file, https://tiptap.dev/docs/ui-components/templates/simple-editor. +// Selectively used in ol-components/src/components/TiptapEditor/TiptapEditor.tsx. Left in project for reference. + +"use client" + +import React, { useEffect, useRef, useState } from "react" +import { EditorContent, EditorContext, useEditor } from "@tiptap/react" + +// --- Tiptap Core Extensions --- +import { StarterKit } from "@tiptap/starter-kit" +import { Image } from "@tiptap/extension-image" +import { TaskItem, TaskList } from "@tiptap/extension-list" +import { TextAlign } from "@tiptap/extension-text-align" +import { Typography } from "@tiptap/extension-typography" +import { Highlight } from "@tiptap/extension-highlight" +import { Subscript } from "@tiptap/extension-subscript" +import { Superscript } from "@tiptap/extension-superscript" +import { Selection } from "@tiptap/extensions" + +// --- UI Primitives --- +import { Button } from "../../tiptap-ui-primitive/button" +import { Spacer } from "../../tiptap-ui-primitive/spacer" +import { + Toolbar, + ToolbarGroup, + ToolbarSeparator, +} from "../../tiptap-ui-primitive/toolbar" + +// --- Tiptap Node --- +import { ImageUploadNode } from "../../tiptap-node/image-upload-node/image-upload-node-extension" +import { HorizontalRule } from "../../tiptap-node/horizontal-rule-node/horizontal-rule-node-extension" +import "../../tiptap-node/blockquote-node/blockquote-node.scss" +import "../../tiptap-node/code-block-node/code-block-node.scss" +import "../../tiptap-node/horizontal-rule-node/horizontal-rule-node.scss" +import "../../tiptap-node/list-node/list-node.scss" +import "../../tiptap-node/image-node/image-node.scss" +import "../../tiptap-node/heading-node/heading-node.scss" +import "../../tiptap-node/paragraph-node/paragraph-node.scss" + +// --- Tiptap UI --- +import { HeadingDropdownMenu } from "../../tiptap-ui/heading-dropdown-menu" +import { ImageUploadButton } from "../../tiptap-ui/image-upload-button" +import { ListDropdownMenu } from "../../tiptap-ui/list-dropdown-menu" +import { BlockquoteButton } from "../../tiptap-ui/blockquote-button" +import { CodeBlockButton } from "../../tiptap-ui/code-block-button" +import { + ColorHighlightPopover, + ColorHighlightPopoverContent, + ColorHighlightPopoverButton, +} from "../../tiptap-ui/color-highlight-popover" +import { + LinkPopover, + LinkContent, + LinkButton, +} from "../../tiptap-ui/link-popover" +import { MarkButton } from "../../tiptap-ui/mark-button" +import { TextAlignButton } from "../../tiptap-ui/text-align-button" +import { UndoRedoButton } from "../../tiptap-ui/undo-redo-button" + +// --- Icons --- +import { ArrowLeftIcon } from "../../tiptap-icons/arrow-left-icon" +import { HighlighterIcon } from "../../tiptap-icons/highlighter-icon" +import { LinkIcon } from "../../tiptap-icons/link-icon" + +// --- Hooks --- +import { useIsMobile } from "../../../hooks/use-mobile" +import { useWindowSize } from "../../../hooks/use-window-size" +import { useCursorVisibility } from "../../../hooks/use-cursor-visibility" + +// --- Components --- +import { ThemeToggle } from "./theme-toggle" + +// --- Lib --- +import { handleImageUpload, MAX_FILE_SIZE } from "../../../lib/tiptap-utils" + +// --- Styles --- +import "./simple-editor.scss" + +const MainToolbarContent = ({ + onHighlighterClick, + onLinkClick, + isMobile, +}: { + onHighlighterClick: () => void + onLinkClick: () => void + isMobile: boolean +}) => { + return ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + {!isMobile ? ( + + ) : ( + + )} + {!isMobile ? : } + + + + + + + + + + + + + + + + + + + + + + + + + + + {isMobile && } + + + + + + ) +} + +const MobileToolbarContent = ({ + type, + onBack, +}: { + type: "highlighter" | "link" + onBack: () => void +}) => ( + <> + + + + + + + {type === "highlighter" ? ( + + ) : ( + + )} + +) + +export function SimpleEditor() { + const isMobile = useIsMobile() + const { height } = useWindowSize() + const [mobileView, setMobileView] = useState<"main" | "highlighter" | "link">( + "main", + ) + const toolbarRef = useRef(null) + + const editor = useEditor({ + immediatelyRender: false, + shouldRerenderOnTransaction: false, + editorProps: { + attributes: { + autocomplete: "off", + autocorrect: "off", + autocapitalize: "off", + "aria-label": "Main content area, start typing to enter text.", + class: "simple-editor", + }, + }, + extensions: [ + StarterKit.configure({ + horizontalRule: false, + link: { + openOnClick: false, + enableClickSelection: true, + }, + }), + HorizontalRule, + TextAlign.configure({ types: ["heading", "paragraph"] }), + TaskList, + TaskItem.configure({ nested: true }), + Highlight.configure({ multicolor: true }), + Image, + Typography, + Superscript, + Subscript, + Selection, + ImageUploadNode.configure({ + accept: "image/*", + maxSize: MAX_FILE_SIZE, + limit: 3, + upload: handleImageUpload, + onError: (error) => console.error("Upload failed:", error), + }), + ], + content: { + type: "doc", + content: [ + { + type: "paragraph", + content: [], + }, + ], + }, + }) + + const rect = useCursorVisibility({ + editor, + overlayHeight: toolbarRef.current?.getBoundingClientRect().height ?? 0, + }) + + useEffect(() => { + if (!isMobile && mobileView !== "main") { + setMobileView("main") + } + }, [isMobile, mobileView]) + + return ( +
+ + + {mobileView === "main" ? ( + setMobileView("highlighter")} + onLinkClick={() => setMobileView("link")} + isMobile={isMobile} + /> + ) : ( + setMobileView("main")} + /> + )} + + + + +
+ ) +} diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-templates/simple/theme-toggle.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-templates/simple/theme-toggle.tsx new file mode 100644 index 0000000000..473e37ec40 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-templates/simple/theme-toggle.tsx @@ -0,0 +1,44 @@ +import { Button } from "../../tiptap-ui-primitive/button" + +// --- Icons --- +import { MoonStarIcon } from "../../tiptap-icons/moon-star-icon" +import { SunIcon } from "../../tiptap-icons/sun-icon" +import React, { useEffect, useState } from "react" + +export function ThemeToggle() { + const [isDarkMode, setIsDarkMode] = useState(false) + + useEffect(() => { + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)") + const handleChange = () => setIsDarkMode(mediaQuery.matches) + mediaQuery.addEventListener("change", handleChange) + return () => mediaQuery.removeEventListener("change", handleChange) + }, []) + + useEffect(() => { + const initialDarkMode = + !!document.querySelector('meta[name="color-scheme"][content="dark"]') || + window.matchMedia("(prefers-color-scheme: dark)").matches + setIsDarkMode(initialDarkMode) + }, []) + + useEffect(() => { + document.documentElement.classList.toggle("dark", isDarkMode) + }, [isDarkMode]) + + const toggleDarkMode = () => setIsDarkMode((isDark) => !isDark) + + return ( + + ) +} diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/badge/badge-colors.scss b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/badge/badge-colors.scss new file mode 100644 index 0000000000..2044b94194 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/badge/badge-colors.scss @@ -0,0 +1,395 @@ +.tiptap-badge { + /************************************************** + Default + **************************************************/ + + /* Light mode */ + --tt-badge-border-color: var(--tt-gray-light-a-200); + --tt-badge-border-color-subdued: var(--tt-gray-light-a-200); + --tt-badge-border-color-emphasized: var(--tt-gray-light-a-600); + --tt-badge-text-color: var(--tt-gray-light-a-500); + --tt-badge-text-color-subdued: var( + --tt-gray-light-a-400 + ); //less important badge + --tt-badge-text-color-emphasized: var( + --tt-gray-light-a-600 + ); //more important badge + --tt-badge-bg-color: var(--white); + --tt-badge-bg-color-subdued: var(--white); //less important badge + --tt-badge-bg-color-emphasized: var(--white); //more important badge + --tt-badge-icon-color: var(--tt-gray-light-a-500); + --tt-badge-icon-color-subdued: var( + --tt-gray-light-a-400 + ); //less important badge + --tt-badge-icon-color-emphasized: var( + --tt-brand-color-600 + ); //more important badge + + /* Dark mode */ + .dark & { + --tt-badge-border-color: var(--tt-gray-dark-a-200); + --tt-badge-border-color-subdued: var(--tt-gray-dark-a-200); + --tt-badge-border-color-emphasized: var(--tt-gray-dark-a-500); + --tt-badge-text-color: var(--tt-gray-dark-a-500); + --tt-badge-text-color-subdued: var( + --tt-gray-dark-a-400 + ); //less important badge + --tt-badge-text-color-emphasized: var( + --tt-gray-dark-a-600 + ); //more important badge + --tt-badge-bg-color: var(--black); + --tt-badge-bg-color-subdued: var(--black); //less important badge + --tt-badge-bg-color-emphasized: var(--black); //more important badge + --tt-badge-icon-color: var(--tt-gray-dark-a-500); + --tt-badge-icon-color-subdued: var( + --tt-gray-dark-a-400 + ); //less important badge + --tt-badge-icon-color-emphasized: var( + --tt-brand-color-400 + ); //more important badge + } + + /************************************************** + Ghost + **************************************************/ + + &[data-style="ghost"] { + /* Light mode */ + --tt-badge-border-color: var(--tt-gray-light-a-200); + --tt-badge-border-color-subdued: var(--tt-gray-light-a-200); + --tt-badge-border-color-emphasized: var(--tt-gray-light-a-600); + --tt-badge-text-color: var(--tt-gray-light-a-500); + --tt-badge-text-color-subdued: var( + --tt-gray-light-a-400 + ); //less important badge + --tt-badge-text-color-emphasized: var( + --tt-gray-light-a-600 + ); //more important badge + --tt-badge-bg-color: var(--transparent); + --tt-badge-bg-color-subdued: var(--transparent); //less important badge + --tt-badge-bg-color-emphasized: var(--transparent); //more important badge + --tt-badge-icon-color: var(--tt-gray-light-a-500); + --tt-badge-icon-color-subdued: var( + --tt-gray-light-a-400 + ); //less important badge + --tt-badge-icon-color-emphasized: var( + --tt-brand-color-600 + ); //more important badge + + /* Dark mode */ + .dark & { + --tt-badge-border-color: var(--tt-gray-dark-a-200); + --tt-badge-border-color-subdued: var(--tt-gray-dark-a-200); + --tt-badge-border-color-emphasized: var(--tt-gray-dark-a-500); + --tt-badge-text-color: var(--tt-gray-dark-a-500); + --tt-badge-text-color-subdued: var( + --tt-gray-dark-a-400 + ); //less important badge + --tt-badge-text-color-emphasized: var( + --tt-gray-dark-a-600 + ); //more important badge + --tt-badge-bg-color: var(--transparent); + --tt-badge-bg-color-subdued: var(--transparent); //less important badge + --tt-badge-bg-color-emphasized: var(--transparent); //more important badge + --tt-badge-icon-color: var(--tt-gray-dark-a-500); + --tt-badge-icon-color-subdued: var( + --tt-gray-dark-a-400 + ); //less important badge + --tt-badge-icon-color-emphasized: var( + --tt-brand-color-400 + ); //more important badge + } + } + + /************************************************** + Gray + **************************************************/ + + &[data-style="gray"] { + /* Light mode */ + --tt-badge-border-color: var(--tt-gray-light-a-200); + --tt-badge-border-color-subdued: var(--tt-gray-light-a-200); + --tt-badge-border-color-emphasized: var(--tt-gray-light-a-500); + --tt-badge-text-color: var(--tt-gray-light-a-500); + --tt-badge-text-color-subdued: var( + --tt-gray-light-a-400 + ); //less important badge + --tt-badge-text-color-emphasized: var(--white); //more important badge + --tt-badge-bg-color: var(--tt-gray-light-a-100); + --tt-badge-bg-color-subdued: var( + --tt-gray-light-a-50 + ); //less important badge + --tt-badge-bg-color-emphasized: var( + --tt-gray-light-a-700 + ); //more important badge + --tt-badge-icon-color: var(--tt-gray-light-a-500); + --tt-badge-icon-color-subdued: var( + --tt-gray-light-a-400 + ); //less important badge + --tt-badge-icon-color-emphasized: var(--white); //more important badge + + /* Dark mode */ + .dark & { + --tt-badge-border-color: var(--tt-gray-dark-a-200); + --tt-badge-border-color-subdued: var(--tt-gray-dark-a-200); + --tt-badge-border-color-emphasized: var(--tt-gray-dark-a-500); + --tt-badge-text-color: var(--tt-gray-dark-a-500); + --tt-badge-text-color-subdued: var( + --tt-gray-dark-a-400 + ); //less important badge + --tt-badge-text-color-emphasized: var(--black); //more important badge + --tt-badge-bg-color: var(--tt-gray-dark-a-100); + --tt-badge-bg-color-subdued: var( + --tt-gray-dark-a-50 + ); //less important badge + --tt-badge-bg-color-emphasized: var( + --tt-gray-dark-a-800 + ); //more important badge + --tt-badge-icon-color: var(--tt-gray-dark-a-500); + --tt-badge-icon-color-subdued: var( + --tt-gray-dark-a-400 + ); //less important badge + --tt-badge-icon-color-emphasized: var(--black); //more important badge + } + } + + /************************************************** + Green + **************************************************/ + + &[data-style="green"] { + /* Light mode */ + --tt-badge-border-color: var(--tt-color-green-inc-2); + --tt-badge-border-color-subdued: var(--tt-color-green-inc-3); + --tt-badge-border-color-emphasized: var(--tt-color-green-dec-2); + --tt-badge-text-color: var(--tt-color-green-dec-3); + --tt-badge-text-color-subdued: var( + --tt-color-green-dec-2 + ); //less important badge + --tt-badge-text-color-emphasized: var( + --tt-color-green-inc-5 + ); //more important badge + --tt-badge-bg-color: var(--tt-color-green-inc-4); + --tt-badge-bg-color-subdued: var( + --tt-color-green-inc-5 + ); //less important badge + --tt-badge-bg-color-emphasized: var( + --tt-color-green-dec-1 + ); //more important badge + --tt-badge-icon-color: var(--tt-color-green-dec-3); + --tt-badge-icon-color-subdued: var( + --tt-color-green-dec-2 + ); //less important badge + --tt-badge-icon-color-emphasized: var( + --tt-color-green-inc-5 + ); //more important badge + + /* Dark mode */ + .dark & { + --tt-badge-border-color: var(--tt-color-green-dec-2); + --tt-badge-border-color-subdued: var(--tt-color-green-dec-3); + --tt-badge-border-color-emphasized: var(--tt-color-green-base); + --tt-badge-text-color: var(--tt-color-green-inc-3); + --tt-badge-text-color-subdued: var( + --tt-color-green-inc-2 + ); //less important badge + --tt-badge-text-color-emphasized: var( + --tt-color-green-dec-5 + ); //more important badge + --tt-badge-bg-color: var(--tt-color-green-dec-4); + --tt-badge-bg-color-subdued: var( + --tt-color-green-dec-5 + ); //less important badge + --tt-badge-bg-color-emphasized: var( + --tt-color-green-inc-1 + ); //more important badge + --tt-badge-icon-color: var(--tt-color-green-inc-3); + --tt-badge-icon-color-subdued: var( + --tt-color-green-inc-2 + ); //less important badge + --tt-badge-icon-color-emphasized: var( + --tt-color-green-dec-5 + ); //more important badge + } + } + + /************************************************** + Yellow + **************************************************/ + + &[data-style="yellow"] { + /* Light mode */ + --tt-badge-border-color: var(--tt-color-yellow-inc-2); + --tt-badge-border-color-subdued: var(--tt-color-yellow-inc-3); + --tt-badge-border-color-emphasized: var(--tt-color-yellow-dec-1); + --tt-badge-text-color: var(--tt-color-yellow-dec-3); + --tt-badge-text-color-subdued: var( + --tt-color-yellow-dec-2 + ); //less important badge + --tt-badge-text-color-emphasized: var( + --tt-color-yellow-dec-3 + ); //more important badge + --tt-badge-bg-color: var(--tt-color-yellow-inc-4); + --tt-badge-bg-color-subdued: var( + --tt-color-yellow-inc-5 + ); //less important badge + --tt-badge-bg-color-emphasized: var( + --tt-color-yellow-base + ); //more important badge + --tt-badge-icon-color: var(--tt-color-yellow-dec-3); + --tt-badge-icon-color-subdued: var( + --tt-color-yellow-dec-2 + ); //less important badge + --tt-badge-icon-color-emphasized: var( + --tt-color-yellow-dec-3 + ); //more important badge + + /* Dark mode */ + .dark & { + --tt-badge-border-color: var(--tt-color-yellow-dec-2); + --tt-badge-border-color-subdued: var(--tt-color-yellow-dec-3); + --tt-badge-border-color-emphasized: var(--tt-color-yellow-inc-1); + --tt-badge-text-color: var(--tt-color-yellow-inc-3); + --tt-badge-text-color-subdued: var( + --tt-color-yellow-inc-2 + ); //less important badge + --tt-badge-text-color-emphasized: var( + --tt-color-yellow-dec-3 + ); //more important badge + --tt-badge-bg-color: var(--tt-color-yellow-dec-4); + --tt-badge-bg-color-subdued: var( + --tt-color-yellow-dec-5 + ); //less important badge + --tt-badge-bg-color-emphasized: var( + --tt-color-yellow-base + ); //more important badge + --tt-badge-icon-color: var(--tt-color-yellow-inc-3); + --tt-badge-icon-color-subdued: var( + --tt-color-yellow-inc-2 + ); //less important badge + --tt-badge-icon-color-emphasized: var( + --tt-color-yellow-dec-3 + ); //more important badge + } + } + + /************************************************** + Red + **************************************************/ + + &[data-style="red"] { + /* Light mode */ + --tt-badge-border-color: var(--tt-color-red-inc-2); + --tt-badge-border-color-subdued: var(--tt-color-red-inc-3); + --tt-badge-border-color-emphasized: var(--tt-color-red-dec-2); + --tt-badge-text-color: var(--tt-color-red-dec-3); + --tt-badge-text-color-subdued: var( + --tt-color-red-dec-2 + ); //less important badge + --tt-badge-text-color-emphasized: var( + --tt-color-red-inc-5 + ); //more important badge + --tt-badge-bg-color: var(--tt-color-red-inc-4); + --tt-badge-bg-color-subdued: var( + --tt-color-red-inc-5 + ); //less important badge + --tt-badge-bg-color-emphasized: var( + --tt-color-red-dec-1 + ); //more important badge + --tt-badge-icon-color: var(--tt-color-red-dec-3); + --tt-badge-icon-color-subdued: var( + --tt-color-red-dec-2 + ); //less important badge + --tt-badge-icon-color-emphasized: var( + --tt-color-red-inc-5 + ); //more important badge + + /* Dark mode */ + .dark & { + --tt-badge-border-color: var(--tt-color-red-dec-2); + --tt-badge-border-color-subdued: var(--tt-color-red-dec-3); + --tt-badge-border-color-emphasized: var(--tt-color-red-base); + --tt-badge-text-color: var(--tt-color-red-inc-3); + --tt-badge-text-color-subdued: var( + --tt-color-red-inc-2 + ); //less important badge + --tt-badge-text-color-emphasized: var( + --tt-color-red-dec-5 + ); //more important badge + --tt-badge-bg-color: var(--tt-color-red-dec-4); + --tt-badge-bg-color-subdued: var( + --tt-color-red-dec-5 + ); //less important badge + --tt-badge-bg-color-emphasized: var( + --tt-color-red-inc-1 + ); //more important badge + --tt-badge-icon-color: var(--tt-color-red-inc-3); + --tt-badge-icon-color-subdued: var( + --tt-color-red-inc-2 + ); //less important badge + --tt-badge-icon-color-emphasized: var( + --tt-color-red-dec-5 + ); //more important badge + } + } + + /************************************************** + Brand + **************************************************/ + + &[data-style="brand"] { + /* Light mode */ + --tt-badge-border-color: var(--tt-brand-color-300); + --tt-badge-border-color-subdued: var(--tt-brand-color-200); + --tt-badge-border-color-emphasized: var(--tt-brand-color-600); + --tt-badge-text-color: var(--tt-brand-color-800); + --tt-badge-text-color-subdued: var( + --tt-brand-color-700 + ); //less important badge + --tt-badge-text-color-emphasized: var( + --tt-brand-color-50 + ); //more important badge + --tt-badge-bg-color: var(--tt-brand-color-100); + --tt-badge-bg-color-subdued: var( + --tt-brand-color-50 + ); //less important badge + --tt-badge-bg-color-emphasized: var( + --tt-brand-color-600 + ); //more important badge + --tt-badge-icon-color: var(--tt-brand-color-800); + --tt-badge-icon-color-subdued: var( + --tt-brand-color-700 + ); //less important badge + --tt-badge-icon-color-emphasized: var( + --tt-brand-color-100 + ); //more important badge + + /* Dark mode */ + .dark & { + --tt-badge-border-color: var(--tt-brand-color-700); + --tt-badge-border-color-subdued: var(--tt-brand-color-800); + --tt-badge-border-color-emphasized: var(--tt-brand-color-400); + --tt-badge-text-color: var(--tt-brand-color-200); + --tt-badge-text-color-subdued: var( + --tt-brand-color-300 + ); //less important badge + --tt-badge-text-color-emphasized: var( + --tt-brand-color-950 + ); //more important badge + --tt-badge-bg-color: var(--tt-brand-color-900); + --tt-badge-bg-color-subdued: var( + --tt-brand-color-950 + ); //less important badge + --tt-badge-bg-color-emphasized: var( + --tt-brand-color-400 + ); //more important badge + --tt-badge-icon-color: var(--tt-brand-color-200); + --tt-badge-icon-color-subdued: var( + --tt-brand-color-300 + ); //less important badge + --tt-badge-icon-color-emphasized: var( + --tt-brand-color-900 + ); //more important badge + } + } +} diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/badge/badge-group.scss b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/badge/badge-group.scss new file mode 100644 index 0000000000..91bd45b10e --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/badge/badge-group.scss @@ -0,0 +1,16 @@ +.tiptap-badge-group { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 0.25rem; +} + +.tiptap-badge-group { + [data-orientation="vertical"] { + flex-direction: column; + } + + [data-orientation="horizontal"] { + flex-direction: row; + } +} diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/badge/badge.scss b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/badge/badge.scss new file mode 100644 index 0000000000..b2ca9a8829 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/badge/badge.scss @@ -0,0 +1,99 @@ +.tiptap-badge { + font-size: 0.625rem; + font-weight: 700; + font-feature-settings: + "salt" on, + "cv01" on; + line-height: 1.15; + height: 1.25rem; + min-width: 1.25rem; + padding: 0.25rem; + display: flex; + align-items: center; + justify-content: center; + border: solid 1px; + border-radius: var(--tt-radius-sm, 0.375rem); + transition-property: background, color, opacity; + transition-duration: var(--tt-transition-duration-default); + transition-timing-function: var(--tt-transition-easing-default); + + /* button size large */ + &[data-size="large"] { + font-size: 0.75rem; + height: 1.5rem; + min-width: 1.5rem; + padding: 0.375rem; + border-radius: var(--tt-radius-md, 0.375rem); + } + + /* button size small */ + &[data-size="small"] { + height: 1rem; + min-width: 1rem; + padding: 0.125rem; + border-radius: var(--tt-radius-xs, 0.25rem); + } + + /* trim / expand text of the button */ + .tiptap-badge-text { + padding: 0 0.125rem; + flex-grow: 1; + text-align: left; + } + + &[data-text-trim="on"] { + .tiptap-badge-text { + text-overflow: ellipsis; + overflow: hidden; + } + } + + /* standard icon, what is used */ + .tiptap-badge-icon { + pointer-events: none; + flex-shrink: 0; + width: 0.625rem; + height: 0.625rem; + } + + &[data-size="large"] .tiptap-badge-icon { + width: 0.75rem; + height: 0.75rem; + } +} + +/* -------------------------------------------- +----------- BADGE COLOR SETTINGS ------------- +-------------------------------------------- */ + +.tiptap-badge { + background-color: var(--tt-badge-bg-color); + border-color: var(--tt-badge-border-color); + color: var(--tt-badge-text-color); + + .tiptap-badge-icon { + color: var(--tt-badge-icon-color); + } + + /* Emphasized */ + &[data-appearance="emphasized"] { + background-color: var(--tt-badge-bg-color-emphasized); + border-color: var(--tt-badge-border-color-emphasized); + color: var(--tt-badge-text-color-emphasized); + + .tiptap-badge-icon { + color: var(--tt-badge-icon-color-emphasized); + } + } + + /* Subdued */ + &[data-appearance="subdued"] { + background-color: var(--tt-badge-bg-color-subdued); + border-color: var(--tt-badge-border-color-subdued); + color: var(--tt-badge-text-color-subdued); + + .tiptap-badge-icon { + color: var(--tt-badge-icon-color-subdued); + } + } +} diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/badge/badge.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/badge/badge.tsx new file mode 100644 index 0000000000..13b7ba1f6f --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/badge/badge.tsx @@ -0,0 +1,44 @@ +import React, { forwardRef } from "react" +import "./badge-colors.scss" +import "./badge-group.scss" +import "./badge.scss" + +export interface BadgeProps extends React.HTMLAttributes { + variant?: "ghost" | "white" | "gray" | "green" | "default" + size?: "default" | "small" + appearance?: "default" | "subdued" | "emphasized" + trimText?: boolean +} + +export const Badge = forwardRef( + ( + { + variant, + size = "default", + appearance = "default", + trimText = false, + className, + children, + ...props + }, + ref, + ) => { + return ( +
+ {children} +
+ ) + }, +) + +Badge.displayName = "Badge" + +export default Badge diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/badge/index.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/badge/index.tsx new file mode 100644 index 0000000000..051fa6ea23 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/badge/index.tsx @@ -0,0 +1 @@ +export * from "./badge" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/button/button-colors.scss b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/button/button-colors.scss new file mode 100644 index 0000000000..c9683e4252 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/button/button-colors.scss @@ -0,0 +1,429 @@ +.tiptap-button { + /************************************************** + Default button background color + **************************************************/ + + /* Light mode */ + --tt-button-default-bg-color: var(--tt-gray-light-a-100); + --tt-button-hover-bg-color: var(--tt-gray-light-200); + --tt-button-active-bg-color: var(--tt-gray-light-a-200); + --tt-button-active-bg-color-emphasized: var( + --tt-brand-color-100 + ); //more important active state + --tt-button-active-bg-color-subdued: var( + --tt-gray-light-a-200 + ); //less important active state + --tt-button-active-hover-bg-color: var(--tt-gray-light-300); + --tt-button-active-hover-bg-color-emphasized: var( + --tt-brand-color-200 + ); //more important active state hover + --tt-button-active-hover-bg-color-subdued: var( + --tt-gray-light-a-300 + ); //less important active state hover + --tt-button-disabled-bg-color: var(--tt-gray-light-a-50); + + /* Dark mode */ + .dark & { + --tt-button-default-bg-color: var(--tt-gray-dark-a-100); + --tt-button-hover-bg-color: var(--tt-gray-dark-200); + --tt-button-active-bg-color: var(--tt-gray-dark-a-200); + --tt-button-active-bg-color-emphasized: var( + --tt-brand-color-900 + ); //more important active state + --tt-button-active-bg-color-subdued: var( + --tt-gray-dark-a-200 + ); //less important active state + --tt-button-active-hover-bg-color: var(--tt-gray-dark-300); + --tt-button-active-hover-bg-color-emphasized: var( + --tt-brand-color-800 + ); //more important active state hover + --tt-button-active-hover-bg-color-subdued: var( + --tt-gray-dark-a-300 + ); //less important active state hover + --tt-button-disabled-bg-color: var(--tt-gray-dark-a-50); + } + + /************************************************** + Default button text color + **************************************************/ + + /* Light mode */ + --tt-button-default-text-color: var(--tt-gray-light-a-600); + --tt-button-hover-text-color: var(--tt-gray-light-a-900); + --tt-button-active-text-color: var(--tt-gray-light-a-900); + --tt-button-active-text-color-emphasized: var(--tt-gray-light-a-900); + --tt-button-active-text-color-subdued: var(--tt-gray-light-a-900); + --tt-button-disabled-text-color: var(--tt-gray-light-a-400); + + /* Dark mode */ + .dark & { + --tt-button-default-text-color: var(--tt-gray-dark-a-600); + --tt-button-hover-text-color: var(--tt-gray-dark-a-900); + --tt-button-active-text-color: var(--tt-gray-dark-a-900); + --tt-button-active-text-color-emphasized: var(--tt-gray-dark-a-900); + --tt-button-active-text-color-subdued: var(--tt-gray-dark-a-900); + --tt-button-disabled-text-color: var(--tt-gray-dark-a-300); + } + + /************************************************** + Default button icon color + **************************************************/ + + /* Light mode */ + --tt-button-default-icon-color: var(--tt-gray-light-a-600); + --tt-button-hover-icon-color: var(--tt-gray-light-a-900); + --tt-button-active-icon-color: var(--tt-brand-color-500); + --tt-button-active-icon-color-emphasized: var(--tt-brand-color-600); + --tt-button-active-icon-color-subdued: var(--tt-gray-light-a-900); + --tt-button-disabled-icon-color: var(--tt-gray-light-a-400); + + /* Dark mode */ + .dark & { + --tt-button-default-icon-color: var(--tt-gray-dark-a-600); + --tt-button-hover-icon-color: var(--tt-gray-dark-a-900); + --tt-button-active-icon-color: var(--tt-brand-color-400); + --tt-button-active-icon-color-emphasized: var(--tt-brand-color-400); + --tt-button-active-icon-color-subdued: var(--tt-gray-dark-a-900); + --tt-button-disabled-icon-color: var(--tt-gray-dark-a-400); + } + + /************************************************** + Default button subicon color + **************************************************/ + + /* Light mode */ + --tt-button-default-icon-sub-color: var(--tt-gray-light-a-400); + --tt-button-hover-icon-sub-color: var(--tt-gray-light-a-500); + --tt-button-active-icon-sub-color: var(--tt-gray-light-a-400); + --tt-button-active-icon-sub-color-emphasized: var(--tt-gray-light-a-500); + --tt-button-active-icon-sub-color-subdued: var(--tt-gray-light-a-400); + --tt-button-disabled-icon-sub-color: var(--tt-gray-light-a-100); + + /* Dark mode */ + .dark & { + --tt-button-default-icon-sub-color: var(--tt-gray-dark-a-300); + --tt-button-hover-icon-sub-color: var(--tt-gray-dark-a-400); + --tt-button-active-icon-sub-color: var(--tt-gray-dark-a-300); + --tt-button-active-icon-sub-color-emphasized: var(--tt-gray-dark-a-400); + --tt-button-active-icon-sub-color-subdued: var(--tt-gray-dark-a-300); + --tt-button-disabled-icon-sub-color: var(--tt-gray-dark-a-100); + } + + /************************************************** + Default button dropdown / arrows color + **************************************************/ + + /* Light mode */ + --tt-button-default-dropdown-arrows-color: var(--tt-gray-light-a-600); + --tt-button-hover-dropdown-arrows-color: var(--tt-gray-light-a-700); + --tt-button-active-dropdown-arrows-color: var(--tt-gray-light-a-600); + --tt-button-active-dropdown-arrows-color-emphasized: var( + --tt-gray-light-a-700 + ); + --tt-button-active-dropdown-arrows-color-subdued: var(--tt-gray-light-a-600); + --tt-button-disabled-dropdown-arrows-color: var(--tt-gray-light-a-400); + + /* Dark mode */ + .dark & { + --tt-button-default-dropdown-arrows-color: var(--tt-gray-dark-a-600); + --tt-button-hover-dropdown-arrows-color: var(--tt-gray-dark-a-700); + --tt-button-active-dropdown-arrows-color: var(--tt-gray-dark-a-600); + --tt-button-active-dropdown-arrows-color-emphasized: var( + --tt-gray-dark-a-700 + ); + --tt-button-active-dropdown-arrows-color-subdued: var(--tt-gray-dark-a-600); + --tt-button-disabled-dropdown-arrows-color: var(--tt-gray-dark-a-400); + } + + /* ---------------------------------------------------------------- + --------------------------- GHOST BUTTON -------------------------- + ---------------------------------------------------------------- */ + + &[data-style="ghost"] { + /************************************************** + Ghost button background color + **************************************************/ + + /* Light mode */ + --tt-button-default-bg-color: var(--transparent); + --tt-button-hover-bg-color: var(--tt-gray-light-200); + --tt-button-active-bg-color: var(--tt-gray-light-a-100); + --tt-button-active-bg-color-emphasized: var( + --tt-brand-color-100 + ); //more important active state + --tt-button-active-bg-color-subdued: var( + --tt-gray-light-a-100 + ); //less important active state + --tt-button-active-hover-bg-color: var(--tt-gray-light-200); + --tt-button-active-hover-bg-color-emphasized: var( + --tt-brand-color-200 + ); //more important active state hover + --tt-button-active-hover-bg-color-subdued: var( + --tt-gray-light-a-200 + ); //less important active state hover + --tt-button-disabled-bg-color: var(--transparent); + + /* Dark mode */ + .dark & { + --tt-button-default-bg-color: var(--transparent); + --tt-button-hover-bg-color: var(--tt-gray-dark-200); + --tt-button-active-bg-color: var(--tt-gray-dark-a-100); + --tt-button-active-bg-color-emphasized: var( + --tt-brand-color-900 + ); //more important active state + --tt-button-active-bg-color-subdued: var( + --tt-gray-dark-a-100 + ); //less important active state + --tt-button-active-hover-bg-color: var(--tt-gray-dark-200); + --tt-button-active-hover-bg-color-emphasized: var( + --tt-brand-color-800 + ); //more important active state hover + --tt-button-active-hover-bg-color-subdued: var( + --tt-gray-dark-a-200 + ); //less important active state hover + --tt-button-disabled-bg-color: var(--transparent); + } + + /************************************************** + Ghost button text color + **************************************************/ + + /* Light mode */ + --tt-button-default-text-color: var(--tt-gray-light-a-600); + --tt-button-hover-text-color: var(--tt-gray-light-a-900); + --tt-button-active-text-color: var(--tt-gray-light-a-900); + --tt-button-active-text-color-emphasized: var(--tt-gray-light-a-900); + --tt-button-active-text-color-subdued: var(--tt-gray-light-a-900); + --tt-button-disabled-text-color: var(--tt-gray-light-a-400); + + /* Dark mode */ + .dark & { + --tt-button-default-text-color: var(--tt-gray-dark-a-600); + --tt-button-hover-text-color: var(--tt-gray-dark-a-900); + --tt-button-active-text-color: var(--tt-gray-dark-a-900); + --tt-button-active-text-color-emphasized: var(--tt-gray-dark-a-900); + --tt-button-active-text-color-subdued: var(--tt-gray-dark-a-900); + --tt-button-disabled-text-color: var(--tt-gray-dark-a-300); + } + + /************************************************** + Ghost button icon color + **************************************************/ + + /* Light mode */ + --tt-button-default-icon-color: var(--tt-gray-light-a-600); + --tt-button-hover-icon-color: var(--tt-gray-light-a-900); + --tt-button-active-icon-color: var(--tt-brand-color-500); + --tt-button-active-icon-color-emphasized: var(--tt-brand-color-600); + --tt-button-active-icon-color-subdued: var(--tt-gray-light-a-900); + --tt-button-disabled-icon-color: var(--tt-gray-light-a-400); + + /* Dark mode */ + .dark & { + --tt-button-default-icon-color: var(--tt-gray-dark-a-600); + --tt-button-hover-icon-color: var(--tt-gray-dark-a-900); + --tt-button-active-icon-color: var(--tt-brand-color-400); + --tt-button-active-icon-color-emphasized: var(--tt-brand-color-300); + --tt-button-active-icon-color-subdued: var(--tt-gray-dark-a-900); + --tt-button-disabled-icon-color: var(--tt-gray-dark-a-400); + } + + /************************************************** + Ghost button subicon color + **************************************************/ + + /* Light mode */ + --tt-button-default-icon-sub-color: var(--tt-gray-light-a-400); + --tt-button-hover-icon-sub-color: var(--tt-gray-light-a-500); + --tt-button-active-icon-sub-color: var(--tt-gray-light-a-400); + --tt-button-active-icon-sub-color-emphasized: var(--tt-gray-light-a-500); + --tt-button-active-icon-sub-color-subdued: var(--tt-gray-light-a-400); + --tt-button-disabled-icon-sub-color: var(--tt-gray-light-a-100); + + /* Dark mode */ + .dark & { + --tt-button-default-icon-sub-color: var(--tt-gray-dark-a-300); + --tt-button-hover-icon-sub-color: var(--tt-gray-dark-a-400); + --tt-button-active-icon-sub-color: var(--tt-gray-dark-a-300); + --tt-button-active-icon-sub-color-emphasized: var(--tt-gray-dark-a-400); + --tt-button-active-icon-sub-color-subdued: var(--tt-gray-dark-a-300); + --tt-button-disabled-icon-sub-color: var(--tt-gray-dark-a-100); + } + + /************************************************** + Ghost button dropdown / arrows color + **************************************************/ + + /* Light mode */ + --tt-button-default-dropdown-arrows-color: var(--tt-gray-light-a-600); + --tt-button-hover-dropdown-arrows-color: var(--tt-gray-light-a-700); + --tt-button-active-dropdown-arrows-color: var(--tt-gray-light-a-600); + --tt-button-active-dropdown-arrows-color-emphasized: var( + --tt-gray-light-a-700 + ); + --tt-button-active-dropdown-arrows-color-subdued: var( + --tt-gray-light-a-600 + ); + --tt-button-disabled-dropdown-arrows-color: var(--tt-gray-light-a-400); + + /* Dark mode */ + .dark & { + --tt-button-default-dropdown-arrows-color: var(--tt-gray-dark-a-600); + --tt-button-hover-dropdown-arrows-color: var(--tt-gray-dark-a-700); + --tt-button-active-dropdown-arrows-color: var(--tt-gray-dark-a-600); + --tt-button-active-dropdown-arrows-color-emphasized: var( + --tt-gray-dark-a-700 + ); + --tt-button-active-dropdown-arrows-color-subdued: var( + --tt-gray-dark-a-600 + ); + --tt-button-disabled-dropdown-arrows-color: var(--tt-gray-dark-a-400); + } + } + + /* ---------------------------------------------------------------- + -------------------------- PRIMARY BUTTON ------------------------- + ---------------------------------------------------------------- */ + + &[data-style="primary"] { + /************************************************** + Primary button background color + **************************************************/ + + /* Light mode */ + --tt-button-default-bg-color: var(--tt-brand-color-500); + --tt-button-hover-bg-color: var(--tt-brand-color-600); + --tt-button-active-bg-color: var(--tt-brand-color-100); + --tt-button-active-bg-color-emphasized: var( + --tt-brand-color-100 + ); //more important active state + --tt-button-active-bg-color-subdued: var( + --tt-brand-color-100 + ); //less important active state + --tt-button-active-hover-bg-color: var(--tt-brand-color-200); + --tt-button-active-hover-bg-color-emphasized: var( + --tt-brand-color-200 + ); //more important active state hover + --tt-button-active-hover-bg-color-subdued: var( + --tt-brand-color-200 + ); //less important active state hover + --tt-button-disabled-bg-color: var(--tt-gray-light-a-100); + + /* Dark mode */ + .dark & { + --tt-button-default-bg-color: var(--tt-brand-color-500); + --tt-button-hover-bg-color: var(--tt-brand-color-600); + --tt-button-active-bg-color: var(--tt-brand-color-900); + --tt-button-active-bg-color-emphasized: var( + --tt-brand-color-900 + ); //more important active state + --tt-button-active-bg-color-subdued: var( + --tt-brand-color-900 + ); //less important active state + --tt-button-active-hover-bg-color: var(--tt-brand-color-800); + --tt-button-active-hover-bg-color-emphasized: var( + --tt-brand-color-800 + ); //more important active state hover + --tt-button-active-hover-bg-color-subdued: var( + --tt-brand-color-800 + ); //less important active state hover + --tt-button-disabled-bg-color: var(--tt-gray-dark-a-100); + } + + /************************************************** + Primary button text color + **************************************************/ + + /* Light mode */ + --tt-button-default-text-color: var(--white); + --tt-button-hover-text-color: var(--white); + --tt-button-active-text-color: var(--tt-gray-light-a-900); + --tt-button-active-text-color-emphasized: var(--tt-gray-light-a-900); + --tt-button-active-text-color-subdued: var(--tt-gray-light-a-900); + --tt-button-disabled-text-color: var(--tt-gray-light-a-400); + + /* Dark mode */ + .dark & { + --tt-button-default-text-color: var(--white); + --tt-button-hover-text-color: var(--white); + --tt-button-active-text-color: var(--tt-gray-dark-a-900); + --tt-button-active-text-color-emphasized: var(--tt-gray-dark-a-900); + --tt-button-active-text-color-subdued: var(--tt-gray-dark-a-900); + --tt-button-disabled-text-color: var(--tt-gray-dark-a-300); + } + + /************************************************** + Primary button icon color + **************************************************/ + + /* Light mode */ + --tt-button-default-icon-color: var(--white); + --tt-button-hover-icon-color: var(--white); + --tt-button-active-icon-color: var(--tt-brand-color-600); + --tt-button-active-icon-color-emphasized: var(--tt-brand-color-600); + --tt-button-active-icon-color-subdued: var(--tt-brand-color-600); + --tt-button-disabled-icon-color: var(--tt-gray-light-a-400); + + /* Dark mode */ + .dark & { + --tt-button-default-icon-color: var(--white); + --tt-button-hover-icon-color: var(--white); + --tt-button-active-icon-color: var(--tt-brand-color-400); + --tt-button-active-icon-color-emphasized: var(--tt-brand-color-400); + --tt-button-active-icon-color-subdued: var(--tt-brand-color-400); + --tt-button-disabled-icon-color: var(--tt-gray-dark-a-300); + } + + /************************************************** + Primary button subicon color + **************************************************/ + + /* Light mode */ + --tt-button-default-icon-sub-color: var(--tt-gray-dark-a-500); + --tt-button-hover-icon-sub-color: var(--tt-gray-dark-a-500); + --tt-button-active-icon-sub-color: var(--tt-gray-light-a-500); + --tt-button-active-icon-sub-color-emphasized: var(--tt-gray-light-a-500); + --tt-button-active-icon-sub-color-subdued: var(--tt-gray-light-a-500); + --tt-button-disabled-icon-sub-color: var(--tt-gray-light-a-100); + + /* Dark mode */ + .dark & { + --tt-button-default-icon-sub-color: var(--tt-gray-dark-a-400); + --tt-button-hover-icon-sub-color: var(--tt-gray-dark-a-500); + --tt-button-active-icon-sub-color: var(--tt-gray-dark-a-300); + --tt-button-active-icon-sub-color-emphasized: var(--tt-gray-dark-a-400); + --tt-button-active-icon-sub-color-subdued: var(--tt-gray-dark-a-300); + --tt-button-disabled-icon-sub-color: var(--tt-gray-dark-a-100); + } + + /************************************************** + Primary button dropdown / arrows color + **************************************************/ + + /* Light mode */ + --tt-button-default-dropdown-arrows-color: var(--white); + --tt-button-hover-dropdown-arrows-color: var(--white); + --tt-button-active-dropdown-arrows-color: var(--tt-gray-light-a-700); + --tt-button-active-dropdown-arrows-color-emphasized: var( + --tt-gray-light-a-700 + ); + --tt-button-active-dropdown-arrows-color-subdued: var( + --tt-gray-light-a-700 + ); + --tt-button-disabled-dropdown-arrows-color: var(--tt-gray-light-a-400); + + /* Dark mode */ + .dark & { + --tt-button-default-dropdown-arrows-color: var(--white); + --tt-button-hover-dropdown-arrows-color: var(--white); + --tt-button-active-dropdown-arrows-color: var(--tt-gray-dark-a-600); + --tt-button-active-dropdown-arrows-color-emphasized: var( + --tt-gray-dark-a-600 + ); + --tt-button-active-dropdown-arrows-color-subdued: var( + --tt-gray-dark-a-600 + ); + --tt-button-disabled-dropdown-arrows-color: var(--tt-gray-dark-a-400); + } + } +} diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/button/button-group.scss b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/button/button-group.scss new file mode 100644 index 0000000000..59fd2561df --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/button/button-group.scss @@ -0,0 +1,22 @@ +.tiptap-button-group { + position: relative; + display: flex; + vertical-align: middle; + + &[data-orientation="vertical"] { + flex-direction: column; + align-items: flex-start; + justify-content: center; + min-width: max-content; + + > .tiptap-button { + width: 100%; + } + } + + &[data-orientation="horizontal"] { + gap: 0.125rem; + flex-direction: row; + align-items: center; + } +} diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/button/button.scss b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/button/button.scss new file mode 100644 index 0000000000..32d1499b3c --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/button/button.scss @@ -0,0 +1,314 @@ +.tiptap-button { + font-size: 0.875rem; + font-weight: 500; + font-feature-settings: + "salt" on, + "cv01" on; + line-height: 1.15; + height: 2rem; + min-width: 2rem; + border: none; + padding: 0.5rem; + gap: 0.25rem; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--tt-radius-lg, 0.75rem); + transition-property: background, color, opacity; + transition-duration: var(--tt-transition-duration-default); + transition-timing-function: var(--tt-transition-easing-default); + + // focus-visible + &:focus-visible { + outline: none; + } + + &[data-highlighted="true"], + &[data-focus-visible="true"] { + background-color: var(--tt-button-hover-bg-color); + color: var(--tt-button-hover-text-color); + // outline: 2px solid var(--tt-button-active-icon-color); + } + + &[data-weight="small"] { + width: 1.5rem; + min-width: 1.5rem; + padding-right: 0; + padding-left: 0; + } + + /* button size large */ + &[data-size="large"] { + font-size: 0.9375rem; + height: 2.375rem; + min-width: 2.375rem; + padding: 0.625rem; + } + + /* button size small */ + &[data-size="small"] { + font-size: 0.75rem; + line-height: 1.2; + height: 1.5rem; + min-width: 1.5rem; + padding: 0.3125rem; + border-radius: var(--tt-radius-md, 0.5rem); + } + + /* trim / expand text of the button */ + .tiptap-button-text { + padding: 0 0.125rem; + flex-grow: 1; + text-align: left; + line-height: 1.5rem; + } + + &[data-text-trim="on"] { + .tiptap-button-text { + text-overflow: ellipsis; + overflow: hidden; + } + } + + /* global icon settings */ + .tiptap-button-icon, + .tiptap-button-icon-sub, + .tiptap-button-dropdown-arrows, + .tiptap-button-dropdown-small { + flex-shrink: 0; + } + + /* standard icon, what is used */ + .tiptap-button-icon { + width: 1rem; + height: 1rem; + } + + &[data-size="large"] .tiptap-button-icon { + width: 1.125rem; + height: 1.125rem; + } + + &[data-size="small"] .tiptap-button-icon { + width: 0.875rem; + height: 0.875rem; + } + + /* if 2 icons are used and this icon should be more subtle */ + .tiptap-button-icon-sub { + width: 1rem; + height: 1rem; + } + + &[data-size="large"] .tiptap-button-icon-sub { + width: 1.125rem; + height: 1.125rem; + } + + &[data-size="small"] .tiptap-button-icon-sub { + width: 0.875rem; + height: 0.875rem; + } + + /* dropdown menus or arrows that are slightly smaller */ + .tiptap-button-dropdown-arrows { + width: 0.75rem; + height: 0.75rem; + } + + &[data-size="large"] .tiptap-button-dropdown-arrows { + width: 0.875rem; + height: 0.875rem; + } + + &[data-size="small"] .tiptap-button-dropdown-arrows { + width: 0.625rem; + height: 0.625rem; + } + + /* dropdown menu for icon buttons only */ + .tiptap-button-dropdown-small { + width: 0.625rem; + height: 0.625rem; + } + + &[data-size="large"] .tiptap-button-dropdown-small { + width: 0.75rem; + height: 0.75rem; + } + + &[data-size="small"] .tiptap-button-dropdown-small { + width: 0.5rem; + height: 0.5rem; + } + + /* button only has icons */ + &:has(> svg):not(:has(> :not(svg))) { + gap: 0.125rem; + + &[data-size="large"], + &[data-size="small"] { + gap: 0.125rem; + } + } + + /* button only has 2 icons and one of them is dropdown small */ + &:has(> svg:nth-of-type(2)):has(> .tiptap-button-dropdown-small):not( + :has(> svg:nth-of-type(3)) + ):not(:has(> .tiptap-button-text)) { + gap: 0; + padding-right: 0.25rem; + + &[data-size="large"] { + padding-right: 0.375rem; + } + + &[data-size="small"] { + padding-right: 0.25rem; + } + } + + /* Emoji is used in a button */ + .tiptap-button-emoji { + width: 1rem; + display: flex; + justify-content: center; + } + + &[data-size="large"] .tiptap-button-emoji { + width: 1.125rem; + } + + &[data-size="small"] .tiptap-button-emoji { + width: 0.875rem; + } +} + +/* -------------------------------------------- +----------- BUTTON COLOR SETTINGS ------------- +-------------------------------------------- */ + +.tiptap-button { + background-color: var(--tt-button-default-bg-color); + color: var(--tt-button-default-text-color); + + .tiptap-button-icon { + color: var(--tt-button-default-icon-color); + } + + .tiptap-button-icon-sub { + color: var(--tt-button-default-icon-sub-color); + } + + .tiptap-button-dropdown-arrows { + color: var(--tt-button-default-dropdown-arrows-color); + } + + .tiptap-button-dropdown-small { + color: var(--tt-button-default-dropdown-arrows-color); + } + + /* hover state of a button */ + &:hover:not([data-active-item="true"]):not([disabled]), + &[data-active-item="true"]:not([disabled]), + &[data-highlighted]:not([disabled]):not([data-highlighted="false"]) { + background-color: var(--tt-button-hover-bg-color); + color: var(--tt-button-hover-text-color); + + .tiptap-button-icon { + color: var(--tt-button-hover-icon-color); + } + + .tiptap-button-icon-sub { + color: var(--tt-button-hover-icon-sub-color); + } + + .tiptap-button-dropdown-arrows, + .tiptap-button-dropdown-small { + color: var(--tt-button-hover-dropdown-arrows-color); + } + } + + /* Active state of a button */ + &[data-active-state="on"]:not([disabled]), + &[data-state="open"]:not([disabled]) { + background-color: var(--tt-button-active-bg-color); + color: var(--tt-button-active-text-color); + + .tiptap-button-icon { + color: var(--tt-button-active-icon-color); + } + + .tiptap-button-icon-sub { + color: var(--tt-button-active-icon-sub-color); + } + + .tiptap-button-dropdown-arrows, + .tiptap-button-dropdown-small { + color: var(--tt-button-active-dropdown-arrows-color); + } + + &:hover { + background-color: var(--tt-button-active-hover-bg-color); + } + + /* Emphasized */ + &[data-appearance="emphasized"] { + background-color: var(--tt-button-active-bg-color-emphasized); + color: var(--tt-button-active-text-color-emphasized); + + .tiptap-button-icon { + color: var(--tt-button-active-icon-color-emphasized); + } + + .tiptap-button-icon-sub { + color: var(--tt-button-active-icon-sub-color-emphasized); + } + + .tiptap-button-dropdown-arrows, + .tiptap-button-dropdown-small { + color: var(--tt-button-active-dropdown-arrows-color-emphasized); + } + + &:hover { + background-color: var(--tt-button-active-hover-bg-color-emphasized); + } + } + + /* Subdued */ + &[data-appearance="subdued"] { + background-color: var(--tt-button-active-bg-color-subdued); + color: var(--tt-button-active-text-color-subdued); + + .tiptap-button-icon { + color: var(--tt-button-active-icon-color-subdued); + } + + .tiptap-button-icon-sub { + color: var(--tt-button-active-icon-sub-color-subdued); + } + + .tiptap-button-dropdown-arrows, + .tiptap-button-dropdown-small { + color: var(--tt-button-active-dropdown-arrows-color-subdued); + } + + &:hover { + background-color: var(--tt-button-active-hover-bg-color-subdued); + + .tiptap-button-icon { + color: var(--tt-button-active-icon-color-subdued); + } + } + } + } + + &:disabled { + background-color: var(--tt-button-disabled-bg-color); + color: var(--tt-button-disabled-text-color); + + .tiptap-button-icon { + color: var(--tt-button-disabled-icon-color); + } + } +} diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/button/button.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/button/button.tsx new file mode 100644 index 0000000000..0cb6506c2b --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/button/button.tsx @@ -0,0 +1,110 @@ +import React, { forwardRef, Fragment, useMemo } from "react" + +// --- Tiptap UI Primitive --- +import { Tooltip, TooltipContent, TooltipTrigger } from "../tooltip" + +// --- Lib --- +import { cn, parseShortcutKeys } from "../../../lib/tiptap-utils" + +import "./button-colors.scss" +import "./button-group.scss" +import "./button.scss" + +export interface ButtonProps + extends React.ButtonHTMLAttributes { + className?: string + showTooltip?: boolean + tooltip?: React.ReactNode + shortcutKeys?: string +} + +export const ShortcutDisplay: React.FC<{ shortcuts: string[] }> = ({ + shortcuts, +}) => { + if (shortcuts.length === 0) return null + + return ( +
+ {shortcuts.map((key, index) => ( + + {index > 0 && +} + {key} + + ))} +
+ ) +} + +export const Button = forwardRef( + ( + { + className, + children, + tooltip, + showTooltip = true, + shortcutKeys, + "aria-label": ariaLabel, + ...props + }, + ref, + ) => { + const shortcuts = useMemo( + () => parseShortcutKeys({ shortcutKeys }), + [shortcutKeys], + ) + + if (!tooltip || !showTooltip) { + return ( + + ) + } + + return ( + + + {children} + + + {tooltip} + + + + ) + }, +) + +Button.displayName = "Button" + +export const ButtonGroup = forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + orientation?: "horizontal" | "vertical" + } +>(({ className, children, orientation = "vertical", ...props }, ref) => { + return ( +
+ {children} +
+ ) +}) +ButtonGroup.displayName = "ButtonGroup" + +export default Button diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/button/index.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/button/index.tsx new file mode 100644 index 0000000000..e93d26f6b0 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/button/index.tsx @@ -0,0 +1 @@ +export * from "./button" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/card/card.scss b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/card/card.scss new file mode 100644 index 0000000000..97b757e045 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/card/card.scss @@ -0,0 +1,77 @@ +:root { + --tiptap-card-bg-color: var(--white); + --tiptap-card-border-color: var(--tt-gray-light-a-100); + --tiptap-card-group-label-color: var(--tt-gray-light-a-800); +} + +.dark { + --tiptap-card-bg-color: var(--tt-gray-dark-50); + --tiptap-card-border-color: var(--tt-gray-dark-a-100); + --tiptap-card-group-label-color: var(--tt-gray-dark-a-800); +} + +.tiptap-card { + --padding: 0.375rem; + --border-width: 1px; + + border-radius: calc(var(--padding) + var(--tt-radius-lg)); + box-shadow: var(--tt-shadow-elevated-md); + background-color: var(--tiptap-card-bg-color); + border: 1px solid var(--tiptap-card-border-color); + display: flex; + flex-direction: column; + outline: none; + align-items: center; + + position: relative; + min-width: 0; + word-wrap: break-word; + background-clip: border-box; +} + +.tiptap-card-header { + padding: 0.375rem; + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + border-bottom: var(--border-width) solid var(--tiptap-card-border-color); +} + +.tiptap-card-body { + padding: 0.375rem; + flex: 1 1 auto; + overflow-y: auto; +} + +.tiptap-card-item-group { + position: relative; + display: flex; + vertical-align: middle; + min-width: max-content; + + &[data-orientation="vertical"] { + flex-direction: column; + justify-content: center; + } + + &[data-orientation="horizontal"] { + gap: 0.25rem; + flex-direction: row; + align-items: center; + } +} + +.tiptap-card-group-label { + padding-top: 0.75rem; + padding-left: 0.5rem; + padding-right: 0.5rem; + padding-bottom: 0.25rem; + line-height: normal; + font-size: 0.75rem; + font-weight: 600; + line-height: normal; + text-transform: capitalize; + color: var(--tiptap-card-group-label-color); +} diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/card/card.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/card/card.tsx new file mode 100644 index 0000000000..75c53b3391 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/card/card.tsx @@ -0,0 +1,79 @@ +"use client" + +import React, { forwardRef } from "react" +import { cn } from "../../../lib/tiptap-utils" +import "./card.scss" + +const Card = forwardRef>( + ({ className, ...props }, ref) => { + return
+ }, +) +Card.displayName = "Card" + +const CardHeader = forwardRef>( + ({ className, ...props }, ref) => { + return ( +
+ ) + }, +) +CardHeader.displayName = "CardHeader" + +const CardBody = forwardRef>( + ({ className, ...props }, ref) => { + return ( +
+ ) + }, +) +CardBody.displayName = "CardBody" + +const CardItemGroup = forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + orientation?: "horizontal" | "vertical" + } +>(({ className, orientation = "vertical", ...props }, ref) => { + return ( +
+ ) +}) +CardItemGroup.displayName = "CardItemGroup" + +const CardGroupLabel = forwardRef>( + ({ className, ...props }, ref) => { + return ( +
+ ) + }, +) +CardGroupLabel.displayName = "CardGroupLabel" + +const CardFooter = forwardRef>( + ({ className, ...props }, ref) => { + return ( +
+ ) + }, +) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardBody, CardItemGroup, CardGroupLabel } diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/card/index.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/card/index.tsx new file mode 100644 index 0000000000..288c75f729 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/card/index.tsx @@ -0,0 +1 @@ +export * from "./card" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.scss b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.scss new file mode 100644 index 0000000000..03b47e8631 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.scss @@ -0,0 +1,63 @@ +.tiptap-dropdown-menu { + --tt-dropdown-menu-bg-color: var(--white); + --tt-dropdown-menu-border-color: var(--tt-gray-light-a-100); + --tt-dropdown-menu-text-color: var(--tt-gray-light-a-600); + + .dark & { + --tt-dropdown-menu-border-color: var(--tt-gray-dark-a-50); + --tt-dropdown-menu-bg-color: var(--tt-gray-dark-50); + --tt-dropdown-menu-text-color: var(--tt-gray-dark-a-600); + } +} + +/* -------------------------------------------- + --------- DROPDOWN MENU STYLING SETTINGS ----------- + -------------------------------------------- */ +.tiptap-dropdown-menu { + z-index: 50; + outline: none; + transform-origin: var(--radix-dropdown-menu-content-transform-origin); + max-height: var(--radix-dropdown-menu-content-available-height); + + > * { + max-height: var(--radix-dropdown-menu-content-available-height); + } + + /* Animation states */ + &[data-state="open"] { + animation: + fadeIn 150ms cubic-bezier(0.16, 1, 0.3, 1), + zoomIn 150ms cubic-bezier(0.16, 1, 0.3, 1); + } + + &[data-state="closed"] { + animation: + fadeOut 150ms cubic-bezier(0.16, 1, 0.3, 1), + zoomOut 150ms cubic-bezier(0.16, 1, 0.3, 1); + } + + /* Position-based animations */ + &[data-side="top"], + &[data-side="top-start"], + &[data-side="top-end"] { + animation: slideFromBottom 150ms cubic-bezier(0.16, 1, 0.3, 1); + } + + &[data-side="right"], + &[data-side="right-start"], + &[data-side="right-end"] { + animation: slideFromLeft 150ms cubic-bezier(0.16, 1, 0.3, 1); + } + + &[data-side="bottom"], + &[data-side="bottom-start"], + &[data-side="bottom-end"] { + animation: slideFromTop 150ms cubic-bezier(0.16, 1, 0.3, 1); + } + + &[data-side="left"], + &[data-side="left-start"], + &[data-side="left-end"] { + animation: slideFromRight 150ms cubic-bezier(0.16, 1, 0.3, 1); + } +} diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.tsx new file mode 100644 index 0000000000..44aee418fd --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.tsx @@ -0,0 +1,96 @@ +import React, { forwardRef } from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { cn } from "../../../lib/tiptap-utils" +import "./dropdown-menu.scss" + +function DropdownMenu({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return +} + +const DropdownMenuTrigger = forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ ...props }, ref) => ) +DropdownMenuTrigger.displayName = DropdownMenuPrimitive.Trigger.displayName + +const DropdownMenuGroup = DropdownMenuPrimitive.Group + +const DropdownMenuSub = DropdownMenuPrimitive.Sub + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup + +const DropdownMenuItem = DropdownMenuPrimitive.Item + +const DropdownMenuSubTrigger = DropdownMenuPrimitive.SubTrigger + +const DropdownMenuSubContent = forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef & { + portal?: boolean | React.ComponentProps + } +>(({ className, portal = true, ...props }, ref) => { + const content = ( + + ) + + return portal ? ( + + {content} + + ) : ( + content + ) +}) +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef & { + portal?: boolean + } +>(({ className, sideOffset = 4, portal = false, ...props }, ref) => { + const content = ( + e.preventDefault()} + className={cn("tiptap-dropdown-menu", className)} + {...props} + /> + ) + + return portal ? ( + + {content} + + ) : ( + content + ) +}) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuGroup, + DropdownMenuSub, + DropdownMenuPortal, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +} diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/dropdown-menu/index.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/dropdown-menu/index.tsx new file mode 100644 index 0000000000..c4adeceeee --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/dropdown-menu/index.tsx @@ -0,0 +1 @@ +export * from "./dropdown-menu" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/input/index.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/input/index.tsx new file mode 100644 index 0000000000..be91c8ec4b --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/input/index.tsx @@ -0,0 +1 @@ +export * from "./input" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/input/input.scss b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/input/input.scss new file mode 100644 index 0000000000..b9f777cffe --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/input/input.scss @@ -0,0 +1,45 @@ +:root { + --tiptap-input-placeholder: var(--tt-gray-light-a-400); +} + +.dark { + --tiptap-input-placeholder: var(--tt-gray-dark-a-400); +} + +.tiptap-input { + display: block; + width: 100%; + height: 2rem; + font-size: 0.875rem; + font-weight: 400; + line-height: 1.5; + padding: 0.375rem 0.5rem; + border-radius: 0.375rem; + background: none; + appearance: none; + outline: none; + + &::placeholder { + color: var(--tiptap-input-placeholder); + } +} + +.tiptap-input-clamp { + min-width: 12rem; + padding-right: 0; + + text-overflow: ellipsis; + white-space: nowrap; + + &:focus { + text-overflow: clip; + overflow: visible; + } +} + +.tiptap-input-group { + position: relative; + display: flex; + flex-wrap: wrap; + align-items: stretch; +} diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/input/input.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/input/input.tsx new file mode 100644 index 0000000000..4ed77cede6 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/input/input.tsx @@ -0,0 +1,23 @@ +import React from "react" +import { cn } from "../../../lib/tiptap-utils" +import "./input.scss" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ) +} + +function InputGroup({ + className, + children, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ {children} +
+ ) +} + +export { Input, InputGroup } diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/popover/index.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/popover/index.tsx new file mode 100644 index 0000000000..137ef5d362 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/popover/index.tsx @@ -0,0 +1 @@ +export * from "./popover" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/popover/popover.scss b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/popover/popover.scss new file mode 100644 index 0000000000..07fb0e57bd --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/popover/popover.scss @@ -0,0 +1,63 @@ +.tiptap-popover { + --tt-popover-bg-color: var(--white); + --tt-popover-border-color: var(--tt-gray-light-a-100); + --tt-popover-text-color: var(--tt-gray-light-a-600); + + .dark & { + --tt-popover-border-color: var(--tt-gray-dark-a-50); + --tt-popover-bg-color: var(--tt-gray-dark-50); + --tt-popover-text-color: var(--tt-gray-dark-a-600); + } +} + +/* -------------------------------------------- + --------- POPOVER STYLING SETTINGS ----------- + -------------------------------------------- */ +.tiptap-popover { + z-index: 50; + outline: none; + transform-origin: var(--radix-popover-content-transform-origin); + max-height: var(--radix-popover-content-available-height); + + > * { + max-height: var(--radix-popover-content-available-height); + } + + /* Animation states */ + &[data-state="open"] { + animation: + fadeIn 150ms cubic-bezier(0.16, 1, 0.3, 1), + zoomIn 150ms cubic-bezier(0.16, 1, 0.3, 1); + } + + &[data-state="closed"] { + animation: + fadeOut 150ms cubic-bezier(0.16, 1, 0.3, 1), + zoomOut 150ms cubic-bezier(0.16, 1, 0.3, 1); + } + + /* Position-based animations */ + &[data-side="top"], + &[data-side="top-start"], + &[data-side="top-end"] { + animation: slideFromBottom 150ms cubic-bezier(0.16, 1, 0.3, 1); + } + + &[data-side="right"], + &[data-side="right-start"], + &[data-side="right-end"] { + animation: slideFromLeft 150ms cubic-bezier(0.16, 1, 0.3, 1); + } + + &[data-side="bottom"], + &[data-side="bottom-start"], + &[data-side="bottom-end"] { + animation: slideFromTop 150ms cubic-bezier(0.16, 1, 0.3, 1); + } + + &[data-side="left"], + &[data-side="left-start"], + &[data-side="left-end"] { + animation: slideFromRight 150ms cubic-bezier(0.16, 1, 0.3, 1); + } +} diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/popover/popover.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/popover/popover.tsx new file mode 100644 index 0000000000..8fc8a2f602 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/popover/popover.tsx @@ -0,0 +1,36 @@ +import React from "react" +import * as PopoverPrimitive from "@radix-ui/react-popover" +import { cn } from "../../../lib/tiptap-utils" +import "./popover.scss" + +function Popover({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverContent({ + className, + align = "center", + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { Popover, PopoverTrigger, PopoverContent } diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/separator/index.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/separator/index.tsx new file mode 100644 index 0000000000..068cfa8369 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/separator/index.tsx @@ -0,0 +1 @@ +export * from "./separator" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/separator/separator.scss b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/separator/separator.scss new file mode 100644 index 0000000000..78ec9ac6c4 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/separator/separator.scss @@ -0,0 +1,23 @@ +.tiptap-separator { + --tt-link-border-color: var(--tt-gray-light-a-200); + + .dark & { + --tt-link-border-color: var(--tt-gray-dark-a-200); + } +} + +.tiptap-separator { + flex-shrink: 0; + background-color: var(--tt-link-border-color); + + &[data-orientation="horizontal"] { + height: 1px; + width: 100%; + margin: 0.5rem 0; + } + + &[data-orientation="vertical"] { + height: 1.5rem; + width: 1px; + } +} diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/separator/separator.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/separator/separator.tsx new file mode 100644 index 0000000000..090a41f32e --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/separator/separator.tsx @@ -0,0 +1,31 @@ +import React, { forwardRef } from "react" +import "./separator.scss" +import { cn } from "../../../lib/tiptap-utils" + +export type Orientation = "horizontal" | "vertical" + +export interface SeparatorProps extends React.HTMLAttributes { + orientation?: Orientation + decorative?: boolean +} + +export const Separator = forwardRef( + ({ decorative, orientation = "vertical", className, ...divProps }, ref) => { + const ariaOrientation = orientation === "vertical" ? orientation : undefined + const semanticProps = decorative + ? { role: "none" } + : { "aria-orientation": ariaOrientation, role: "separator" } + + return ( +
+ ) + }, +) + +Separator.displayName = "Separator" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/spacer/index.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/spacer/index.tsx new file mode 100644 index 0000000000..b0789bf135 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/spacer/index.tsx @@ -0,0 +1 @@ +export * from "./spacer" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/spacer/spacer.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/spacer/spacer.tsx new file mode 100644 index 0000000000..bc7eae7105 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/spacer/spacer.tsx @@ -0,0 +1,28 @@ +"use client" + +import React from "react" + +export type SpacerOrientation = "horizontal" | "vertical" + +export interface SpacerProps extends React.HTMLAttributes { + orientation?: SpacerOrientation + size?: string | number +} + +export function Spacer({ + orientation = "horizontal", + size, + style = {}, + ...props +}: SpacerProps) { + const computedStyle = { + ...style, + ...(orientation === "horizontal" && !size && { flex: 1 }), + ...(size && { + width: orientation === "vertical" ? "1px" : size, + height: orientation === "horizontal" ? "1px" : size, + }), + } + + return
+} diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/toolbar/index.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/toolbar/index.tsx new file mode 100644 index 0000000000..94b181962f --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/toolbar/index.tsx @@ -0,0 +1 @@ +export * from "./toolbar" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/toolbar/toolbar.scss b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/toolbar/toolbar.scss new file mode 100644 index 0000000000..3ce1862beb --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/toolbar/toolbar.scss @@ -0,0 +1,98 @@ +:root { + --tt-toolbar-height: 2.75rem; + --tt-safe-area-bottom: env(safe-area-inset-bottom, 0px); + --tt-toolbar-bg-color: var(--white); + --tt-toolbar-border-color: var(--tt-gray-light-a-100); +} + +.dark { + --tt-toolbar-bg-color: var(--black); + --tt-toolbar-border-color: var(--tt-gray-dark-a-50); +} + +.tiptap-toolbar { + display: flex; + align-items: center; + gap: 0.25rem; + + &-group { + display: flex; + align-items: center; + gap: 0.125rem; + + &:empty { + display: none; + } + + &:empty + .tiptap-separator, + .tiptap-separator + &:empty { + display: none; + } + } + + &[data-variant="fixed"] { + position: sticky; + top: 0; + z-index: 10; + width: 100%; + min-height: var(--tt-toolbar-height); + background: var(--tt-toolbar-bg-color); + border-bottom: 1px solid var(--tt-toolbar-border-color); + padding: 0 0.5rem; + overflow-x: auto; + overscroll-behavior-x: contain; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + -ms-overflow-style: none; + + &::-webkit-scrollbar { + display: none; + } + + @media (max-width: 480px) { + position: absolute; + top: auto; + height: calc(var(--tt-toolbar-height) + var(--tt-safe-area-bottom)); + border-top: 1px solid var(--tt-toolbar-border-color); + border-bottom: none; + padding: 0 0.5rem var(--tt-safe-area-bottom); + flex-wrap: nowrap; + justify-content: flex-start; + + .tiptap-toolbar-group { + flex: 0 0 auto; + } + } + } + + &[data-variant="floating"] { + --tt-toolbar-padding: 0.125rem; + --tt-toolbar-border-width: 1px; + + padding: 0.188rem; + border-radius: calc( + var(--tt-toolbar-padding) + var(--tt-radius-lg) + + var(--tt-toolbar-border-width) + ); + border: var(--tt-toolbar-border-width) solid var(--tt-toolbar-border-color); + background-color: var(--tt-toolbar-bg-color); + box-shadow: var(--tt-shadow-elevated-md); + outline: none; + overflow: hidden; + + &[data-plain="true"] { + padding: 0; + border-radius: 0; + border: none; + box-shadow: none; + background-color: transparent; + } + + @media screen and (max-width: 480px) { + width: 100%; + border-radius: 0; + border: none; + box-shadow: none; + } + } +} diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/toolbar/toolbar.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/toolbar/toolbar.tsx new file mode 100644 index 0000000000..2dc9f3a7b7 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/toolbar/toolbar.tsx @@ -0,0 +1,127 @@ +import React, { + forwardRef, + useCallback, + useEffect, + useRef, + useState, +} from "react" +import { Separator } from "../separator" +import "./toolbar.scss" +import { cn } from "../../../lib/tiptap-utils" +import { useMenuNavigation } from "../../../hooks/use-menu-navigation" +import { useComposedRef } from "../../../hooks/use-composed-ref" + +type BaseProps = React.HTMLAttributes + +interface ToolbarProps extends BaseProps { + variant?: "floating" | "fixed" +} + +const useToolbarNavigation = ( + toolbarRef: React.RefObject, +) => { + const [items, setItems] = useState([]) + + const collectItems = useCallback(() => { + if (!toolbarRef.current) return [] + return Array.from( + toolbarRef.current.querySelectorAll( + 'button:not([disabled]), [role="button"]:not([disabled]), [tabindex="0"]:not([disabled])', + ), + ) + }, [toolbarRef]) + + useEffect(() => { + const toolbar = toolbarRef.current + if (!toolbar) return + + const updateItems = () => setItems(collectItems()) + + updateItems() + const observer = new MutationObserver(updateItems) + observer.observe(toolbar, { childList: true, subtree: true }) + + return () => observer.disconnect() + }, [collectItems, toolbarRef]) + + const { selectedIndex } = useMenuNavigation({ + containerRef: toolbarRef, + items, + orientation: "horizontal", + onSelect: (el) => el.click(), + autoSelectFirstItem: false, + }) + + useEffect(() => { + const toolbar = toolbarRef.current + if (!toolbar) return + + const handleFocus = (e: FocusEvent) => { + const target = e.target as HTMLElement + if (toolbar.contains(target)) + target.setAttribute("data-focus-visible", "true") + } + + const handleBlur = (e: FocusEvent) => { + const target = e.target as HTMLElement + if (toolbar.contains(target)) target.removeAttribute("data-focus-visible") + } + + toolbar.addEventListener("focus", handleFocus, true) + toolbar.addEventListener("blur", handleBlur, true) + + return () => { + toolbar.removeEventListener("focus", handleFocus, true) + toolbar.removeEventListener("blur", handleBlur, true) + } + }, [toolbarRef]) + + useEffect(() => { + if (selectedIndex !== undefined && items[selectedIndex]) { + items[selectedIndex].focus() + } + }, [selectedIndex, items]) +} + +export const Toolbar = forwardRef( + ({ children, className, variant = "fixed", ...props }, ref) => { + const toolbarRef = useRef(null) + const composedRef = useComposedRef(toolbarRef, ref) + useToolbarNavigation(toolbarRef) + + return ( +
+ {children} +
+ ) + }, +) +Toolbar.displayName = "Toolbar" + +export const ToolbarGroup = forwardRef( + ({ children, className, ...props }, ref) => ( +
+ {children} +
+ ), +) +ToolbarGroup.displayName = "ToolbarGroup" + +export const ToolbarSeparator = forwardRef( + ({ ...props }, ref) => ( + + ), +) +ToolbarSeparator.displayName = "ToolbarSeparator" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/tooltip/index.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/tooltip/index.tsx new file mode 100644 index 0000000000..e12712a782 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/tooltip/index.tsx @@ -0,0 +1 @@ +export * from "./tooltip" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/tooltip/tooltip.scss b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/tooltip/tooltip.scss new file mode 100644 index 0000000000..d717757fa0 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/tooltip/tooltip.scss @@ -0,0 +1,43 @@ +.tiptap-tooltip { + --tt-tooltip-bg: var(--tt-gray-light-900); + --tt-tooltip-text: var(--white); + --tt-kbd: var(--tt-gray-dark-a-400); + + .dark & { + --tt-tooltip-bg: var(--white); + --tt-tooltip-text: var(--tt-gray-light-600); + --tt-kbd: var(--tt-gray-light-a-400); + } +} + +.tiptap-tooltip { + z-index: 200; + overflow: hidden; + border-radius: var(--tt-radius-md, 0.375rem); + background-color: var(--tt-tooltip-bg); + padding: 0.375rem 0.5rem; + font-size: 0.75rem; + font-weight: 500; + color: var(--tt-tooltip-text); + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1); + text-align: center; + + kbd { + display: inline-block; + text-align: center; + vertical-align: baseline; + font-family: + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + Roboto, + "Helvetica Neue", + Arial, + "Noto Sans", + sans-serif; + text-transform: capitalize; + color: var(--tt-kbd); + } +} diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/tooltip/tooltip.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/tooltip/tooltip.tsx new file mode 100644 index 0000000000..d3fdea69d6 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/tooltip/tooltip.tsx @@ -0,0 +1,237 @@ +"use client" + +import React, { + cloneElement, + createContext, + forwardRef, + isValidElement, + useContext, + useMemo, + useState, + version, +} from "react" +import { + useFloating, + autoUpdate, + offset, + flip, + shift, + useHover, + useFocus, + useDismiss, + useRole, + useInteractions, + useMergeRefs, + FloatingPortal, + type Placement, + type UseFloatingReturn, + type ReferenceType, + FloatingDelayGroup, +} from "@floating-ui/react" +import "./tooltip.scss" + +interface TooltipProviderProps { + children: React.ReactNode + initialOpen?: boolean + placement?: Placement + open?: boolean + onOpenChange?: (open: boolean) => void + delay?: number + closeDelay?: number + timeout?: number + useDelayGroup?: boolean +} + +interface TooltipTriggerProps + extends Omit, "ref"> { + asChild?: boolean + children: React.ReactNode +} + +interface TooltipContentProps + extends Omit, "ref"> { + children?: React.ReactNode + portal?: boolean + portalProps?: Omit, "children"> +} + +interface TooltipContextValue extends UseFloatingReturn { + open: boolean + setOpen: (open: boolean) => void + getReferenceProps: ( + userProps?: React.HTMLProps, + ) => Record + getFloatingProps: ( + userProps?: React.HTMLProps, + ) => Record +} + +function useTooltip({ + initialOpen = false, + placement = "top", + open: controlledOpen, + onOpenChange: setControlledOpen, + delay = 600, + closeDelay = 0, +}: Omit = {}) { + const [uncontrolledOpen, setUncontrolledOpen] = useState(initialOpen) + + const open = controlledOpen ?? uncontrolledOpen + const setOpen = setControlledOpen ?? setUncontrolledOpen + + const data = useFloating({ + placement, + open, + onOpenChange: setOpen, + whileElementsMounted: autoUpdate, + middleware: [ + offset(4), + flip({ + crossAxis: placement.includes("-"), + fallbackAxisSideDirection: "start", + padding: 4, + }), + shift({ padding: 4 }), + ], + }) + + const context = data.context + + const hover = useHover(context, { + mouseOnly: true, + move: false, + restMs: delay, + enabled: controlledOpen == null, + delay: { + close: closeDelay, + }, + }) + const focus = useFocus(context, { + enabled: controlledOpen == null, + }) + const dismiss = useDismiss(context) + const role = useRole(context, { role: "tooltip" }) + + const interactions = useInteractions([hover, focus, dismiss, role]) + + return useMemo( + () => ({ + open, + setOpen, + ...interactions, + ...data, + }), + [open, setOpen, interactions, data], + ) +} + +const TooltipContext = createContext(null) + +function useTooltipContext() { + const context = useContext(TooltipContext) + + if (context == null) { + throw new Error("Tooltip components must be wrapped in ") + } + + return context +} + +export function Tooltip({ children, ...props }: TooltipProviderProps) { + const tooltip = useTooltip(props) + + if (!props.useDelayGroup) { + return ( + + {children} + + ) + } + + return ( + + + {children} + + + ) +} + +export const TooltipTrigger = forwardRef( + function TooltipTrigger({ children, asChild = false, ...props }, propRef) { + const context = useTooltipContext() + const childrenRef = isValidElement(children) + ? parseInt(version, 10) >= 19 + ? // eslint-disable-next-line @typescript-eslint/no-explicit-any + (children as { props: { ref?: React.Ref } }).props.ref + : // eslint-disable-next-line @typescript-eslint/no-explicit-any + (children as any).ref + : undefined + const ref = useMergeRefs([context.refs.setReference, propRef, childrenRef]) + + if (asChild && isValidElement(children)) { + const dataAttributes = { + "data-tooltip-state": context.open ? "open" : "closed", + } + + return cloneElement( + children, + context.getReferenceProps({ + ref, + ...props, + ...(typeof children.props === "object" ? children.props : {}), + ...dataAttributes, + }), + ) + } + + return ( + + ) + }, +) + +export const TooltipContent = forwardRef( + function TooltipContent( + { style, children, portal = true, portalProps = {}, ...props }, + propRef, + ) { + const context = useTooltipContext() + const ref = useMergeRefs([context.refs.setFloating, propRef]) + + if (!context.open) return null + + const content = ( +
+ {children} +
+ ) + + if (portal) { + return {content} + } + + return content + }, +) + +Tooltip.displayName = "Tooltip" +TooltipTrigger.displayName = "TooltipTrigger" +TooltipContent.displayName = "TooltipContent" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/blockquote-button/blockquote-button.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/blockquote-button/blockquote-button.tsx new file mode 100644 index 0000000000..1a1812e350 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/blockquote-button/blockquote-button.tsx @@ -0,0 +1,120 @@ +import React, { forwardRef, useCallback } from "react" + +// --- Tiptap UI --- +import type { UseBlockquoteConfig } from "./" +import { BLOCKQUOTE_SHORTCUT_KEY, useBlockquote } from "./" + +// --- Hooks --- +import { useTiptapEditor } from "../../../hooks/use-tiptap-editor" + +// --- Lib --- +import { parseShortcutKeys } from "../../../lib/tiptap-utils" + +// --- UI Primitives --- +import type { ButtonProps } from "../../tiptap-ui-primitive/button" +import { Button } from "../../tiptap-ui-primitive/button" +import { Badge } from "../../tiptap-ui-primitive/badge" + +export interface BlockquoteButtonProps + extends Omit, + UseBlockquoteConfig { + /** + * Optional text to display alongside the icon. + */ + text?: string + /** + * Optional show shortcut keys in the button. + * @default false + */ + showShortcut?: boolean +} + +export function BlockquoteShortcutBadge({ + shortcutKeys = BLOCKQUOTE_SHORTCUT_KEY, +}: { + shortcutKeys?: string +}) { + return {parseShortcutKeys({ shortcutKeys })} +} + +/** + * Button component for toggling blockquote in a Tiptap editor. + * + * For custom button implementations, use the `useBlockquote` hook instead. + */ +export const BlockquoteButton = forwardRef< + HTMLButtonElement, + BlockquoteButtonProps +>( + ( + { + editor: providedEditor, + text, + hideWhenUnavailable = false, + onToggled, + showShortcut = false, + onClick, + children, + ...buttonProps + }, + ref, + ) => { + const { editor } = useTiptapEditor(providedEditor) + const { + isVisible, + canToggle, + isActive, + handleToggle, + label, + shortcutKeys, + Icon, + } = useBlockquote({ + editor, + hideWhenUnavailable, + onToggled, + }) + + const handleClick = useCallback( + (event: React.MouseEvent) => { + onClick?.(event) + if (event.defaultPrevented) return + handleToggle() + }, + [handleToggle, onClick], + ) + + if (!isVisible) { + return null + } + + return ( + + ) + }, +) + +BlockquoteButton.displayName = "BlockquoteButton" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/blockquote-button/index.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/blockquote-button/index.tsx new file mode 100644 index 0000000000..0b46edfc32 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/blockquote-button/index.tsx @@ -0,0 +1,2 @@ +export * from "./blockquote-button" +export * from "./use-blockquote" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/blockquote-button/use-blockquote.ts b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/blockquote-button/use-blockquote.ts new file mode 100644 index 0000000000..81161fc61b --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/blockquote-button/use-blockquote.ts @@ -0,0 +1,246 @@ +"use client" + +import { useCallback, useEffect, useState } from "react" +import type { Editor } from "@tiptap/react" +import { NodeSelection, TextSelection } from "@tiptap/pm/state" + +// --- Hooks --- +import { useTiptapEditor } from "../../../hooks/use-tiptap-editor" + +// --- Icons --- +import { BlockquoteIcon } from "../../tiptap-icons/blockquote-icon" + +// --- UI Utils --- +import { + findNodePosition, + isNodeInSchema, + isNodeTypeSelected, + isValidPosition, + selectionWithinConvertibleTypes, +} from "../../../lib/tiptap-utils" + +export const BLOCKQUOTE_SHORTCUT_KEY = "mod+shift+b" + +/** + * Configuration for the blockquote functionality + */ +export interface UseBlockquoteConfig { + /** + * The Tiptap editor instance. + */ + editor?: Editor | null + /** + * Whether the button should hide when blockquote is not available. + * @default false + */ + hideWhenUnavailable?: boolean + /** + * Callback function called after a successful toggle. + */ + onToggled?: () => void +} + +/** + * Checks if blockquote can be toggled in the current editor state + */ +export function canToggleBlockquote( + editor: Editor | null, + turnInto: boolean = true, +): boolean { + if (!editor || !editor.isEditable) return false + if ( + !isNodeInSchema("blockquote", editor) || + isNodeTypeSelected(editor, ["image"]) + ) + return false + + if (!turnInto) { + return editor.can().toggleWrap("blockquote") + } + + // Ensure selection is in nodes we're allowed to convert + if ( + !selectionWithinConvertibleTypes(editor, [ + "paragraph", + "heading", + "bulletList", + "orderedList", + "taskList", + "blockquote", + "codeBlock", + ]) + ) + return false + + // Either we can wrap in blockquote directly on the selection, + // or we can clear formatting/nodes to arrive at a blockquote. + return editor.can().toggleWrap("blockquote") || editor.can().clearNodes() +} + +/** + * Toggles blockquote formatting for a specific node or the current selection + */ +export function toggleBlockquote(editor: Editor | null): boolean { + if (!editor || !editor.isEditable) return false + if (!canToggleBlockquote(editor)) return false + + try { + const view = editor.view + let state = view.state + let tr = state.tr + + // No selection, find the the cursor position + if (state.selection.empty || state.selection instanceof TextSelection) { + const pos = findNodePosition({ + editor, + node: state.selection.$anchor.node(1), + })?.pos + if (!isValidPosition(pos)) return false + + tr = tr.setSelection(NodeSelection.create(state.doc, pos)) + view.dispatch(tr) + state = view.state + } + + const selection = state.selection + + let chain = editor.chain().focus() + + // Handle NodeSelection + if (selection instanceof NodeSelection) { + const firstChild = selection.node.firstChild?.firstChild + const lastChild = selection.node.lastChild?.lastChild + + const from = firstChild + ? selection.from + firstChild.nodeSize + : selection.from + 1 + + const to = lastChild + ? selection.to - lastChild.nodeSize + : selection.to - 1 + + const resolvedFrom = state.doc.resolve(from) + const resolvedTo = state.doc.resolve(to) + + chain = chain + .setTextSelection(TextSelection.between(resolvedFrom, resolvedTo)) + .clearNodes() + } + + const toggle = editor.isActive("blockquote") + ? chain.lift("blockquote") + : chain.wrapIn("blockquote") + + toggle.run() + + editor.chain().focus().selectTextblockEnd().run() + + return true + } catch { + return false + } +} + +/** + * Determines if the blockquote button should be shown + */ +export function shouldShowButton(props: { + editor: Editor | null + hideWhenUnavailable: boolean +}): boolean { + const { editor, hideWhenUnavailable } = props + + if (!editor || !editor.isEditable) return false + if (!isNodeInSchema("blockquote", editor)) return false + + if (hideWhenUnavailable && !editor.isActive("code")) { + return canToggleBlockquote(editor) + } + + return true +} + +/** + * Custom hook that provides blockquote functionality for Tiptap editor + * + * @example + * ```tsx + * // Simple usage - no params needed + * function MySimpleBlockquoteButton() { + * const { isVisible, handleToggle, isActive } = useBlockquote() + * + * if (!isVisible) return null + * + * return + * } + * + * // Advanced usage with configuration + * function MyAdvancedBlockquoteButton() { + * const { isVisible, handleToggle, label, isActive } = useBlockquote({ + * editor: myEditor, + * hideWhenUnavailable: true, + * onToggled: () => console.log('Blockquote toggled!') + * }) + * + * if (!isVisible) return null + * + * return ( + * + * Toggle Blockquote + * + * ) + * } + * ``` + */ +export function useBlockquote(config?: UseBlockquoteConfig) { + const { + editor: providedEditor, + hideWhenUnavailable = false, + onToggled, + } = config || {} + + const { editor } = useTiptapEditor(providedEditor) + const [isVisible, setIsVisible] = useState(true) + const canToggle = canToggleBlockquote(editor) + const isActive = editor?.isActive("blockquote") || false + + useEffect(() => { + if (!editor) return + + const handleSelectionUpdate = () => { + setIsVisible(shouldShowButton({ editor, hideWhenUnavailable })) + } + + handleSelectionUpdate() + + editor.on("selectionUpdate", handleSelectionUpdate) + + return () => { + editor.off("selectionUpdate", handleSelectionUpdate) + } + }, [editor, hideWhenUnavailable]) + + const handleToggle = useCallback(() => { + if (!editor) return false + + const success = toggleBlockquote(editor) + if (success) { + onToggled?.() + } + return success + }, [editor, onToggled]) + + return { + isVisible, + isActive, + handleToggle, + canToggle, + label: "Blockquote", + shortcutKeys: BLOCKQUOTE_SHORTCUT_KEY, + Icon: BlockquoteIcon, + } +} diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/code-block-button/code-block-button.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/code-block-button/code-block-button.tsx new file mode 100644 index 0000000000..fb90f647be --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/code-block-button/code-block-button.tsx @@ -0,0 +1,120 @@ +import React, { forwardRef, useCallback } from "react" + +// --- Hooks --- +import { useTiptapEditor } from "../../../hooks/use-tiptap-editor" + +// --- Lib --- +import { parseShortcutKeys } from "../../../lib/tiptap-utils" + +// --- Tiptap UI --- +import type { UseCodeBlockConfig } from "./" +import { CODE_BLOCK_SHORTCUT_KEY, useCodeBlock } from "./" + +// --- UI Primitives --- +import type { ButtonProps } from "../../tiptap-ui-primitive/button" +import { Button } from "../../tiptap-ui-primitive/button" +import { Badge } from "../../tiptap-ui-primitive/badge" + +export interface CodeBlockButtonProps + extends Omit, + UseCodeBlockConfig { + /** + * Optional text to display alongside the icon. + */ + text?: string + /** + * Optional show shortcut keys in the button. + * @default false + */ + showShortcut?: boolean +} + +export function CodeBlockShortcutBadge({ + shortcutKeys = CODE_BLOCK_SHORTCUT_KEY, +}: { + shortcutKeys?: string +}) { + return {parseShortcutKeys({ shortcutKeys })} +} + +/** + * Button component for toggling code block in a Tiptap editor. + * + * For custom button implementations, use the `useCodeBlock` hook instead. + */ +export const CodeBlockButton = forwardRef< + HTMLButtonElement, + CodeBlockButtonProps +>( + ( + { + editor: providedEditor, + text, + hideWhenUnavailable = false, + onToggled, + showShortcut = false, + onClick, + children, + ...buttonProps + }, + ref, + ) => { + const { editor } = useTiptapEditor(providedEditor) + const { + isVisible, + canToggle, + isActive, + handleToggle, + label, + shortcutKeys, + Icon, + } = useCodeBlock({ + editor, + hideWhenUnavailable, + onToggled, + }) + + const handleClick = useCallback( + (event: React.MouseEvent) => { + onClick?.(event) + if (event.defaultPrevented) return + handleToggle() + }, + [handleToggle, onClick], + ) + + if (!isVisible) { + return null + } + + return ( + + ) + }, +) + +CodeBlockButton.displayName = "CodeBlockButton" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/code-block-button/index.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/code-block-button/index.tsx new file mode 100644 index 0000000000..77d541f9c4 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/code-block-button/index.tsx @@ -0,0 +1,2 @@ +export * from "./code-block-button" +export * from "./use-code-block" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/code-block-button/use-code-block.ts b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/code-block-button/use-code-block.ts new file mode 100644 index 0000000000..167860a625 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/code-block-button/use-code-block.ts @@ -0,0 +1,256 @@ +"use client" + +import { useCallback, useEffect, useState } from "react" +import { type Editor } from "@tiptap/react" +import { NodeSelection, TextSelection } from "@tiptap/pm/state" + +// --- Hooks --- +import { useTiptapEditor } from "../../../hooks/use-tiptap-editor" + +// --- Lib --- +import { + findNodePosition, + isNodeInSchema, + isNodeTypeSelected, + isValidPosition, + selectionWithinConvertibleTypes, +} from "../../../lib/tiptap-utils" + +// --- Icons --- +import { CodeBlockIcon } from "../../tiptap-icons/code-block-icon" + +export const CODE_BLOCK_SHORTCUT_KEY = "mod+alt+c" + +/** + * Configuration for the code block functionality + */ +export interface UseCodeBlockConfig { + /** + * The Tiptap editor instance. + */ + editor?: Editor | null + /** + * Whether the button should hide when code block is not available. + * @default false + */ + hideWhenUnavailable?: boolean + /** + * Callback function called after a successful code block toggle. + */ + onToggled?: () => void +} + +/** + * Checks if code block can be toggled in the current editor state + */ +export function canToggle( + editor: Editor | null, + turnInto: boolean = true, +): boolean { + if (!editor || !editor.isEditable) return false + if ( + !isNodeInSchema("codeBlock", editor) || + isNodeTypeSelected(editor, ["image"]) + ) + return false + + if (!turnInto) { + return editor.can().toggleNode("codeBlock", "paragraph") + } + + // Ensure selection is in nodes we're allowed to convert + if ( + !selectionWithinConvertibleTypes(editor, [ + "paragraph", + "heading", + "bulletList", + "orderedList", + "taskList", + "blockquote", + "codeBlock", + ]) + ) + return false + + // Either we can toggle code block directly on the selection, + // or we can clear formatting/nodes to arrive at a code block. + return ( + editor.can().toggleNode("codeBlock", "paragraph") || + editor.can().clearNodes() + ) +} + +/** + * Toggles code block in the editor + */ +export function toggleCodeBlock(editor: Editor | null): boolean { + if (!editor || !editor.isEditable) return false + if (!canToggle(editor)) return false + + try { + const view = editor.view + let state = view.state + let tr = state.tr + + // No selection, find the the cursor position + if (state.selection.empty || state.selection instanceof TextSelection) { + const pos = findNodePosition({ + editor, + node: state.selection.$anchor.node(1), + })?.pos + if (!isValidPosition(pos)) return false + + tr = tr.setSelection(NodeSelection.create(state.doc, pos)) + view.dispatch(tr) + state = view.state + } + + const selection = state.selection + + let chain = editor.chain().focus() + + // Handle NodeSelection + if (selection instanceof NodeSelection) { + const firstChild = selection.node.firstChild?.firstChild + const lastChild = selection.node.lastChild?.lastChild + + const from = firstChild + ? selection.from + firstChild.nodeSize + : selection.from + 1 + + const to = lastChild + ? selection.to - lastChild.nodeSize + : selection.to - 1 + + const resolvedFrom = state.doc.resolve(from) + const resolvedTo = state.doc.resolve(to) + + chain = chain + .setTextSelection(TextSelection.between(resolvedFrom, resolvedTo)) + .clearNodes() + } + + const toggle = editor.isActive("codeBlock") + ? chain.setNode("paragraph") + : chain.toggleNode("codeBlock", "paragraph") + + toggle.run() + + editor.chain().focus().selectTextblockEnd().run() + + return true + } catch { + return false + } +} + +/** + * Determines if the code block button should be shown + */ +export function shouldShowButton(props: { + editor: Editor | null + hideWhenUnavailable: boolean +}): boolean { + const { editor, hideWhenUnavailable } = props + + if (!editor || !editor.isEditable) return false + if (!isNodeInSchema("codeBlock", editor)) return false + + if (hideWhenUnavailable && !editor.isActive("code")) { + return canToggle(editor) + } + + return true +} + +/** + * Custom hook that provides code block functionality for Tiptap editor + * + * @example + * ```tsx + * // Simple usage - no params needed + * function MySimpleCodeBlockButton() { + * const { isVisible, isActive, handleToggle } = useCodeBlock() + * + * if (!isVisible) return null + * + * return ( + * + * ) + * } + * + * // Advanced usage with configuration + * function MyAdvancedCodeBlockButton() { + * const { isVisible, isActive, handleToggle, label } = useCodeBlock({ + * editor: myEditor, + * hideWhenUnavailable: true, + * onToggled: (isActive) => console.log('Code block toggled:', isActive) + * }) + * + * if (!isVisible) return null + * + * return ( + * + * Toggle Code Block + * + * ) + * } + * ``` + */ +export function useCodeBlock(config?: UseCodeBlockConfig) { + const { + editor: providedEditor, + hideWhenUnavailable = false, + onToggled, + } = config || {} + + const { editor } = useTiptapEditor(providedEditor) + const [isVisible, setIsVisible] = useState(true) + const canToggleState = canToggle(editor) + const isActive = editor?.isActive("codeBlock") || false + + useEffect(() => { + if (!editor) return + + const handleSelectionUpdate = () => { + setIsVisible(shouldShowButton({ editor, hideWhenUnavailable })) + } + + handleSelectionUpdate() + + editor.on("selectionUpdate", handleSelectionUpdate) + + return () => { + editor.off("selectionUpdate", handleSelectionUpdate) + } + }, [editor, hideWhenUnavailable]) + + const handleToggle = useCallback(() => { + if (!editor) return false + + const success = toggleCodeBlock(editor) + if (success) { + onToggled?.() + } + return success + }, [editor, onToggled]) + + return { + isVisible, + isActive, + handleToggle, + canToggle: canToggleState, + label: "Code Block", + shortcutKeys: CODE_BLOCK_SHORTCUT_KEY, + Icon: CodeBlockIcon, + } +} diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/color-highlight-button/color-highlight-button.scss b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/color-highlight-button/color-highlight-button.scss new file mode 100644 index 0000000000..2c6f387732 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/color-highlight-button/color-highlight-button.scss @@ -0,0 +1,49 @@ +.tiptap-button-highlight { + position: relative; + width: 1.25rem; + height: 1.25rem; + margin: 0 -0.175rem; + border-radius: var(--tt-radius-xl); + background-color: var(--highlight-color); + transition: transform 0.2s ease; + + &::after { + content: ""; + position: absolute; + width: 100%; + height: 100%; + left: 0; + top: 0; + border-radius: inherit; + box-sizing: border-box; + border: 1px solid var(--highlight-color); + filter: brightness(95%); + mix-blend-mode: multiply; + + .dark & { + filter: brightness(140%); + mix-blend-mode: lighten; + } + } +} + +.tiptap-button { + &[data-active-state="on"] { + .tiptap-button-highlight { + &::after { + filter: brightness(80%); + } + } + } + + .dark & { + &[data-active-state="on"] { + .tiptap-button-highlight { + &::after { + // Andere Eigenschaft für .dark Kontext + filter: brightness(180%); + } + } + } + } +} diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/color-highlight-button/color-highlight-button.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/color-highlight-button/color-highlight-button.tsx new file mode 100644 index 0000000000..b064d97bb8 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/color-highlight-button/color-highlight-button.tsx @@ -0,0 +1,166 @@ +import React, { forwardRef, useCallback, useMemo } from "react" + +// --- Lib --- +import { parseShortcutKeys } from "../../../lib/tiptap-utils" + +// --- Hooks --- +import { useTiptapEditor } from "../../../hooks/use-tiptap-editor" + +// --- Tiptap UI --- +import type { UseColorHighlightConfig } from "./" +import { COLOR_HIGHLIGHT_SHORTCUT_KEY, useColorHighlight } from "./" + +// --- UI Primitives --- +import type { ButtonProps } from "../../tiptap-ui-primitive/button" +import { Button } from "../../tiptap-ui-primitive/button" +import { Badge } from "../../tiptap-ui-primitive/badge" + +// --- Styles --- +import "./color-highlight-button.scss" + +export interface ColorHighlightButtonProps + extends Omit, + UseColorHighlightConfig { + /** + * Optional text to display alongside the icon. + */ + text?: string + /** + * Optional show shortcut keys in the button. + * @default false + */ + showShortcut?: boolean +} + +export function ColorHighlightShortcutBadge({ + shortcutKeys = COLOR_HIGHLIGHT_SHORTCUT_KEY, +}: { + shortcutKeys?: string +}) { + return {parseShortcutKeys({ shortcutKeys })} +} + +/** + * Button component for applying color highlights in a Tiptap editor. + * + * Supports two highlighting modes: + * - "mark": Uses the highlight mark extension (default) + * - "node": Uses the node background extension + * + * For custom button implementations, use the `useColorHighlight` hook instead. + * + * @example + * ```tsx + * // Mark-based highlighting (default) + * + * + * // Node-based background coloring + * + * + * // With custom callback + * console.log(`Applied ${color} in ${mode} mode`)} + * /> + * ``` + */ +export const ColorHighlightButton = forwardRef< + HTMLButtonElement, + ColorHighlightButtonProps +>( + ( + { + editor: providedEditor, + highlightColor, + text, + hideWhenUnavailable = false, + mode = "mark", + onApplied, + showShortcut = false, + onClick, + children, + style, + ...buttonProps + }, + ref, + ) => { + const { editor } = useTiptapEditor(providedEditor) + const { + isVisible, + canColorHighlight, + isActive, + handleColorHighlight, + label, + shortcutKeys, + } = useColorHighlight({ + editor, + highlightColor, + label: text || `Toggle highlight (${highlightColor})`, + hideWhenUnavailable, + mode, + onApplied, + }) + + const handleClick = useCallback( + (event: React.MouseEvent) => { + onClick?.(event) + if (event.defaultPrevented) return + handleColorHighlight() + }, + [handleColorHighlight, onClick], + ) + + const buttonStyle = useMemo( + () => + ({ + ...style, + "--highlight-color": highlightColor, + }) as React.CSSProperties, + [highlightColor, style], + ) + + if (!isVisible) { + return null + } + + return ( + + ) + }, +) + +ColorHighlightButton.displayName = "ColorHighlightButton" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/color-highlight-button/index.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/color-highlight-button/index.tsx new file mode 100644 index 0000000000..c517648273 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/color-highlight-button/index.tsx @@ -0,0 +1,2 @@ +export * from "./color-highlight-button" +export * from "./use-color-highlight" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/color-highlight-button/use-color-highlight.ts b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/color-highlight-button/use-color-highlight.ts new file mode 100644 index 0000000000..3b4cfe61db --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/color-highlight-button/use-color-highlight.ts @@ -0,0 +1,355 @@ +"use client" + +import { useCallback, useEffect, useState } from "react" +import { type Editor } from "@tiptap/react" +import { useHotkeys } from "react-hotkeys-hook" + +// --- Hooks --- +import { useTiptapEditor } from "../../../hooks/use-tiptap-editor" +import { useIsMobile } from "../../../hooks/use-mobile" + +// --- Lib --- +import { + isMarkInSchema, + isNodeTypeSelected, + isExtensionAvailable, +} from "../../../lib/tiptap-utils" + +// --- Icons --- +import { HighlighterIcon } from "../../tiptap-icons/highlighter-icon" + +export const COLOR_HIGHLIGHT_SHORTCUT_KEY = "mod+shift+h" +export const HIGHLIGHT_COLORS = [ + { + label: "Default background", + value: "var(--tt-bg-color)", + border: "var(--tt-bg-color-contrast)", + }, + { + label: "Gray background", + value: "var(--tt-color-highlight-gray)", + border: "var(--tt-color-highlight-gray-contrast)", + }, + { + label: "Brown background", + value: "var(--tt-color-highlight-brown)", + border: "var(--tt-color-highlight-brown-contrast)", + }, + { + label: "Orange background", + value: "var(--tt-color-highlight-orange)", + border: "var(--tt-color-highlight-orange-contrast)", + }, + { + label: "Yellow background", + value: "var(--tt-color-highlight-yellow)", + border: "var(--tt-color-highlight-yellow-contrast)", + }, + { + label: "Green background", + value: "var(--tt-color-highlight-green)", + border: "var(--tt-color-highlight-green-contrast)", + }, + { + label: "Blue background", + value: "var(--tt-color-highlight-blue)", + border: "var(--tt-color-highlight-blue-contrast)", + }, + { + label: "Purple background", + value: "var(--tt-color-highlight-purple)", + border: "var(--tt-color-highlight-purple-contrast)", + }, + { + label: "Pink background", + value: "var(--tt-color-highlight-pink)", + border: "var(--tt-color-highlight-pink-contrast)", + }, + { + label: "Red background", + value: "var(--tt-color-highlight-red)", + border: "var(--tt-color-highlight-red-contrast)", + }, +] +export type HighlightColor = (typeof HIGHLIGHT_COLORS)[number] + +export type HighlightMode = "mark" | "node" + +/** + * Configuration for the color highlight functionality + */ +export interface UseColorHighlightConfig { + /** + * The Tiptap editor instance. + */ + editor?: Editor | null + /** + * The color to apply when toggling the highlight. + */ + highlightColor?: string + /** + * Optional label to display alongside the icon. + */ + label?: string + /** + * Whether the button should hide when the mark is not available. + * @default false + */ + hideWhenUnavailable?: boolean + /** + * The highlighting mode to use. + * - "mark": Uses the highlight mark extension (default) + * - "node": Uses the node background extension + * @default "mark" + */ + mode?: HighlightMode + /** + * Called when the highlight is applied. + */ + onApplied?: ({ + color, + label, + mode, + }: { + color: string + label: string + mode: HighlightMode + }) => void +} + +export function pickHighlightColorsByValue(values: string[]) { + const colorMap = new Map( + HIGHLIGHT_COLORS.map((color) => [color.value, color]), + ) + return values + .map((value) => colorMap.get(value)) + .filter((color): color is (typeof HIGHLIGHT_COLORS)[number] => !!color) +} + +/** + * Checks if highlight can be applied based on the mode and current editor state + */ +export function canColorHighlight( + editor: Editor | null, + mode: HighlightMode = "mark", +): boolean { + if (!editor || !editor.isEditable) return false + + if (mode === "mark") { + if ( + !isMarkInSchema("highlight", editor) || + isNodeTypeSelected(editor, ["image"]) + ) + return false + + return editor.can().setMark("highlight") + } else { + if (!isExtensionAvailable(editor, ["nodeBackground"])) return false + + try { + // Some editor instances may not have this command, + // so check for its existence before trying to call it. + const canCommands = editor.can?.() + if ( + canCommands && + typeof (canCommands as any).toggleNodeBackgroundColor === "function" + ) { + return (canCommands as any).toggleNodeBackgroundColor("test") + } + return false + } catch { + return false + } + } +} + +/** + * Checks if highlight is currently active + */ +export function isColorHighlightActive( + editor: Editor | null, + highlightColor?: string, + mode: HighlightMode = "mark", +): boolean { + if (!editor || !editor.isEditable) return false + + if (mode === "mark") { + return highlightColor + ? editor.isActive("highlight", { color: highlightColor }) + : editor.isActive("highlight") + } else { + if (!highlightColor) return false + + try { + const { state } = editor + const { selection } = state + + const $pos = selection.$anchor + for (let depth = $pos.depth; depth >= 0; depth--) { + const node = $pos.node(depth) + if (node && node.attrs?.backgroundColor === highlightColor) { + return true + } + } + return false + } catch { + return false + } + } +} + +/** + * Removes highlight based on the mode + */ +export function removeHighlight( + editor: Editor | null, + mode: HighlightMode = "mark", +): boolean { + if (!editor || !editor.isEditable) return false + if (!canColorHighlight(editor, mode)) return false + + if (mode === "mark") { + return editor.chain().focus().unsetMark("highlight").run() + } else { + // The chained command `unsetNodeBackgroundColor` does not exist. + // We'll fallback to a helper if available, or do nothing (return false). + if ( + typeof (editor as any).commands?.unsetNodeBackgroundColor === "function" + ) { + ;(editor as any).commands.unsetNodeBackgroundColor() + return true + } + return false + } +} +/** + * Determines if the highlight button should be shown + */ +export function shouldShowButton(props: { + editor: Editor | null + hideWhenUnavailable: boolean + mode: HighlightMode +}): boolean { + const { editor, hideWhenUnavailable, mode } = props + + if (!editor || !editor.isEditable) return false + + if (mode === "mark") { + if (!isMarkInSchema("highlight", editor)) return false + } else { + if (!isExtensionAvailable(editor, ["nodeBackground"])) return false + } + + if (hideWhenUnavailable && !editor.isActive("code")) { + return canColorHighlight(editor, mode) + } + + return true +} + +export function useColorHighlight(config: UseColorHighlightConfig) { + const { + editor: providedEditor, + label, + highlightColor, + hideWhenUnavailable = false, + mode = "mark", + onApplied, + } = config + + const { editor } = useTiptapEditor(providedEditor) + const isMobile = useIsMobile() + const [isVisible, setIsVisible] = useState(true) + const canColorHighlightState = canColorHighlight(editor, mode) + const isActive = isColorHighlightActive(editor, highlightColor, mode) + + useEffect(() => { + if (!editor) return + + const handleSelectionUpdate = () => { + setIsVisible(shouldShowButton({ editor, hideWhenUnavailable, mode })) + } + + handleSelectionUpdate() + + editor.on("selectionUpdate", handleSelectionUpdate) + + return () => { + editor.off("selectionUpdate", handleSelectionUpdate) + } + }, [editor, hideWhenUnavailable, mode]) + + const handleColorHighlight = useCallback(() => { + if (!editor || !canColorHighlightState || !highlightColor || !label) + return false + + if (mode === "mark") { + if (editor.state.storedMarks) { + const highlightMarkType = editor.schema.marks.highlight + if (highlightMarkType) { + editor.view.dispatch( + editor.state.tr.removeStoredMark(highlightMarkType), + ) + } + } + + setTimeout(() => { + const success = editor + .chain() + .focus() + .toggleMark("highlight", { color: highlightColor }) + .run() + if (success) { + onApplied?.({ color: highlightColor, label, mode }) + } + return success + }, 0) + + return true + } else { + const success = editor + .chain() + .focus() + .setHighlight({ color: highlightColor }) + .run() + + if (success) { + onApplied?.({ color: highlightColor, label, mode }) + } + return success + } + }, [canColorHighlightState, highlightColor, editor, label, onApplied, mode]) + + const handleRemoveHighlight = useCallback(() => { + const success = removeHighlight(editor, mode) + if (success) { + onApplied?.({ color: "", label: "Remove highlight", mode }) + } + return success + }, [editor, onApplied, mode]) + + useHotkeys( + COLOR_HIGHLIGHT_SHORTCUT_KEY, + (event) => { + event.preventDefault() + handleColorHighlight() + }, + { + enabled: isVisible && canColorHighlightState, + enableOnContentEditable: !isMobile, + enableOnFormTags: true, + }, + ) + + return { + isVisible, + isActive, + handleColorHighlight, + handleRemoveHighlight, + canColorHighlight: canColorHighlightState, + label: label || `Highlight`, + shortcutKeys: COLOR_HIGHLIGHT_SHORTCUT_KEY, + Icon: HighlighterIcon, + mode, + } +} diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/color-highlight-popover/color-highlight-popover.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/color-highlight-popover/color-highlight-popover.tsx new file mode 100644 index 0000000000..fd2fe7c790 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/color-highlight-popover/color-highlight-popover.tsx @@ -0,0 +1,205 @@ +import React, { forwardRef, useMemo, useRef, useState } from "react" +import { type Editor } from "@tiptap/react" + +// --- Hooks --- +import { useMenuNavigation } from "../../../hooks/use-menu-navigation" +import { useIsMobile } from "../../../hooks/use-mobile" +import { useTiptapEditor } from "../../../hooks/use-tiptap-editor" + +// --- Icons --- +import { BanIcon } from "../../tiptap-icons/ban-icon" +import { HighlighterIcon } from "../../tiptap-icons/highlighter-icon" + +// --- UI Primitives --- +import type { ButtonProps } from "../../tiptap-ui-primitive/button" +import { Button, ButtonGroup } from "../../tiptap-ui-primitive/button" +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "../../tiptap-ui-primitive/popover" +import { Separator } from "../../tiptap-ui-primitive/separator" +import { Card, CardBody, CardItemGroup } from "../../tiptap-ui-primitive/card" + +// --- Tiptap UI --- +import type { + HighlightColor, + UseColorHighlightConfig, +} from "../color-highlight-button" +import { + ColorHighlightButton, + pickHighlightColorsByValue, + useColorHighlight, +} from "../color-highlight-button" + +export interface ColorHighlightPopoverContentProps { + /** + * The Tiptap editor instance. + */ + editor?: Editor | null + /** + * Optional colors to use in the highlight popover. + * If not provided, defaults to a predefined set of colors. + */ + colors?: HighlightColor[] +} + +export interface ColorHighlightPopoverProps + extends Omit, + Pick< + UseColorHighlightConfig, + "editor" | "hideWhenUnavailable" | "onApplied" + > { + /** + * Optional colors to use in the highlight popover. + * If not provided, defaults to a predefined set of colors. + */ + colors?: HighlightColor[] +} + +export const ColorHighlightPopoverButton = forwardRef< + HTMLButtonElement, + ButtonProps +>(({ className, children, ...props }, ref) => ( + +)) + +ColorHighlightPopoverButton.displayName = "ColorHighlightPopoverButton" + +export function ColorHighlightPopoverContent({ + editor, + colors = pickHighlightColorsByValue([ + "var(--tt-color-highlight-green)", + "var(--tt-color-highlight-blue)", + "var(--tt-color-highlight-red)", + "var(--tt-color-highlight-purple)", + "var(--tt-color-highlight-yellow)", + ]), +}: ColorHighlightPopoverContentProps) { + const { handleRemoveHighlight } = useColorHighlight({ editor }) + const isMobile = useIsMobile() + const containerRef = useRef(null) + + const menuItems = useMemo( + () => [...colors, { label: "Remove highlight", value: "none" }], + [colors], + ) + + const { selectedIndex } = useMenuNavigation({ + containerRef, + items: menuItems, + orientation: "both", + onSelect: (item) => { + if (!containerRef.current) return false + const highlightedElement = containerRef.current.querySelector( + '[data-highlighted="true"]', + ) as HTMLElement + if (highlightedElement) highlightedElement.click() + if (item.value === "none") handleRemoveHighlight() + return true + }, + autoSelectFirstItem: false, + }) + + return ( + + + + + {colors.map((color, index) => ( + + ))} + + + + + + + + + ) +} + +export function ColorHighlightPopover({ + editor: providedEditor, + colors = pickHighlightColorsByValue([ + "var(--tt-color-highlight-green)", + "var(--tt-color-highlight-blue)", + "var(--tt-color-highlight-red)", + "var(--tt-color-highlight-purple)", + "var(--tt-color-highlight-yellow)", + ]), + hideWhenUnavailable = false, + onApplied, + ...props +}: ColorHighlightPopoverProps) { + const { editor } = useTiptapEditor(providedEditor) + const [isOpen, setIsOpen] = useState(false) + const { isVisible, canColorHighlight, isActive, label, Icon } = + useColorHighlight({ + editor, + hideWhenUnavailable, + onApplied, + }) + + if (!isVisible) return null + + return ( + + + + + + + + + + + ) +} + +export default ColorHighlightPopover diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/color-highlight-popover/index.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/color-highlight-popover/index.tsx new file mode 100644 index 0000000000..626b81f6e7 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/color-highlight-popover/index.tsx @@ -0,0 +1 @@ +export * from "./color-highlight-popover" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/heading-button/heading-button.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/heading-button/heading-button.tsx new file mode 100644 index 0000000000..7dedde4d25 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/heading-button/heading-button.tsx @@ -0,0 +1,119 @@ +import React, { forwardRef, useCallback } from "react" + +// --- Lib --- +import { parseShortcutKeys } from "../../../lib/tiptap-utils" + +// --- Tiptap UI --- +import type { Level, UseHeadingConfig } from "./" +import { HEADING_SHORTCUT_KEYS, useHeading } from "./" + +// --- UI Primitives --- +import type { ButtonProps } from "../../tiptap-ui-primitive/button" +import { Button } from "../../tiptap-ui-primitive/button" +import { Badge } from "../../tiptap-ui-primitive/badge" +import { useTiptapEditor } from "../../../hooks/use-tiptap-editor" + +export interface HeadingButtonProps + extends Omit, + UseHeadingConfig { + /** + * Optional text to display alongside the icon. + */ + text?: string + /** + * Optional show shortcut keys in the button. + * @default false + */ + showShortcut?: boolean +} + +export function HeadingShortcutBadge({ + level, + shortcutKeys = HEADING_SHORTCUT_KEYS[level], +}: { + level: Level + shortcutKeys?: string +}) { + return {parseShortcutKeys({ shortcutKeys })} +} + +/** + * Button component for toggling heading in a Tiptap editor. + * + * For custom button implementations, use the `useHeading` hook instead. + */ +export const HeadingButton = forwardRef( + ( + { + editor: providedEditor, + level, + text, + hideWhenUnavailable = false, + onToggled, + showShortcut = false, + onClick, + children, + ...buttonProps + }, + ref, + ) => { + const { editor } = useTiptapEditor(providedEditor) + const { + isVisible, + canToggle, + isActive, + handleToggle, + label, + Icon, + shortcutKeys, + } = useHeading({ + editor, + level, + hideWhenUnavailable, + onToggled, + }) + + const handleClick = useCallback( + (event: React.MouseEvent) => { + onClick?.(event) + if (event.defaultPrevented) return + handleToggle() + }, + [handleToggle, onClick], + ) + + if (!isVisible) { + return null + } + + return ( + + ) + }, +) + +HeadingButton.displayName = "HeadingButton" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/heading-button/index.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/heading-button/index.tsx new file mode 100644 index 0000000000..009a7005b6 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/heading-button/index.tsx @@ -0,0 +1,2 @@ +export * from "./heading-button" +export * from "./use-heading" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/heading-button/use-heading.ts b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/heading-button/use-heading.ts new file mode 100644 index 0000000000..f6a647b801 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/heading-button/use-heading.ts @@ -0,0 +1,321 @@ +"use client" + +import { useCallback, useEffect, useState } from "react" +import { type Editor } from "@tiptap/react" +import { NodeSelection, TextSelection } from "@tiptap/pm/state" + +// --- Hooks --- +import { useTiptapEditor } from "../../../hooks/use-tiptap-editor" + +// --- Lib --- +import { + findNodePosition, + isNodeInSchema, + isNodeTypeSelected, + isValidPosition, + selectionWithinConvertibleTypes, +} from "../../../lib/tiptap-utils" + +// --- Icons --- +import { HeadingOneIcon } from "../../tiptap-icons/heading-one-icon" +import { HeadingTwoIcon } from "../../tiptap-icons/heading-two-icon" +import { HeadingThreeIcon } from "../../tiptap-icons/heading-three-icon" +import { HeadingFourIcon } from "../../tiptap-icons/heading-four-icon" +import { HeadingFiveIcon } from "../../tiptap-icons/heading-five-icon" +import { HeadingSixIcon } from "../../tiptap-icons/heading-six-icon" + +export type Level = 1 | 2 | 3 | 4 | 5 | 6 + +/** + * Configuration for the heading functionality + */ +export interface UseHeadingConfig { + /** + * The Tiptap editor instance. + */ + editor?: Editor | null + /** + * The heading level. + */ + level: Level + /** + * Whether the button should hide when heading is not available. + * @default false + */ + hideWhenUnavailable?: boolean + /** + * Callback function called after a successful heading toggle. + */ + onToggled?: () => void +} + +export const headingIcons = { + 1: HeadingOneIcon, + 2: HeadingTwoIcon, + 3: HeadingThreeIcon, + 4: HeadingFourIcon, + 5: HeadingFiveIcon, + 6: HeadingSixIcon, +} + +export const HEADING_SHORTCUT_KEYS: Record = { + 1: "ctrl+alt+1", + 2: "ctrl+alt+2", + 3: "ctrl+alt+3", + 4: "ctrl+alt+4", + 5: "ctrl+alt+5", + 6: "ctrl+alt+6", +} + +/** + * Checks if heading can be toggled in the current editor state + */ +export function canToggle( + editor: Editor | null, + level?: Level, + turnInto: boolean = true, +): boolean { + if (!editor || !editor.isEditable) return false + if ( + !isNodeInSchema("heading", editor) || + isNodeTypeSelected(editor, ["image"]) + ) + return false + + if (!turnInto) { + return level + ? editor.can().setNode("heading", { level }) + : editor.can().setNode("heading") + } + + // Ensure selection is in nodes we're allowed to convert + if ( + !selectionWithinConvertibleTypes(editor, [ + "paragraph", + "heading", + "bulletList", + "orderedList", + "taskList", + "blockquote", + "codeBlock", + ]) + ) + return false + + // Either we can set heading directly on the selection, + // or we can clear formatting/nodes to arrive at a heading. + return level + ? editor.can().setNode("heading", { level }) || editor.can().clearNodes() + : editor.can().setNode("heading") || editor.can().clearNodes() +} + +/** + * Checks if heading is currently active + */ +export function isHeadingActive( + editor: Editor | null, + level?: Level | Level[], +): boolean { + if (!editor || !editor.isEditable) return false + + if (Array.isArray(level)) { + return level.some((l) => editor.isActive("heading", { level: l })) + } + + return level + ? editor.isActive("heading", { level }) + : editor.isActive("heading") +} + +/** + * Toggles heading in the editor + */ +export function toggleHeading( + editor: Editor | null, + level: Level | Level[], +): boolean { + if (!editor || !editor.isEditable) return false + + const levels = Array.isArray(level) ? level : [level] + const toggleLevel = levels.find((l) => canToggle(editor, l)) + + if (!toggleLevel) return false + + try { + const view = editor.view + let state = view.state + let tr = state.tr + + // No selection, find the cursor position + if (state.selection.empty || state.selection instanceof TextSelection) { + const pos = findNodePosition({ + editor, + node: state.selection.$anchor.node(1), + })?.pos + if (!isValidPosition(pos)) return false + + tr = tr.setSelection(NodeSelection.create(state.doc, pos)) + view.dispatch(tr) + state = view.state + } + + const selection = state.selection + let chain = editor.chain().focus() + + // Handle NodeSelection + if (selection instanceof NodeSelection) { + const firstChild = selection.node.firstChild?.firstChild + const lastChild = selection.node.lastChild?.lastChild + + const from = firstChild + ? selection.from + firstChild.nodeSize + : selection.from + 1 + + const to = lastChild + ? selection.to - lastChild.nodeSize + : selection.to - 1 + + const resolvedFrom = state.doc.resolve(from) + const resolvedTo = state.doc.resolve(to) + + chain = chain + .setTextSelection(TextSelection.between(resolvedFrom, resolvedTo)) + .clearNodes() + } + + const isActive = levels.some((l) => + editor.isActive("heading", { level: l }), + ) + + const toggle = isActive + ? chain.setNode("paragraph") + : chain.setNode("heading", { level: toggleLevel }) + + toggle.run() + + editor.chain().focus().selectTextblockEnd().run() + + return true + } catch { + return false + } +} + +/** + * Determines if the heading button should be shown + */ +export function shouldShowButton(props: { + editor: Editor | null + level?: Level | Level[] + hideWhenUnavailable: boolean +}): boolean { + const { editor, level, hideWhenUnavailable } = props + + if (!editor || !editor.isEditable) return false + if (!isNodeInSchema("heading", editor)) return false + + if (hideWhenUnavailable && !editor.isActive("code")) { + if (Array.isArray(level)) { + return level.some((l) => canToggle(editor, l)) + } + return canToggle(editor, level) + } + + return true +} + +/** + * Custom hook that provides heading functionality for Tiptap editor + * + * @example + * ```tsx + * // Simple usage + * function MySimpleHeadingButton() { + * const { isVisible, isActive, handleToggle, Icon } = useHeading({ level: 1 }) + * + * if (!isVisible) return null + * + * return ( + * + * ) + * } + * + * // Advanced usage with configuration + * function MyAdvancedHeadingButton() { + * const { isVisible, isActive, handleToggle, label, Icon } = useHeading({ + * level: 2, + * editor: myEditor, + * hideWhenUnavailable: true, + * onToggled: (isActive) => console.log('Heading toggled:', isActive) + * }) + * + * if (!isVisible) return null + * + * return ( + * + * + * Toggle Heading 2 + * + * ) + * } + * ``` + */ +export function useHeading(config: UseHeadingConfig) { + const { + editor: providedEditor, + level, + hideWhenUnavailable = false, + onToggled, + } = config + + const { editor } = useTiptapEditor(providedEditor) + const [isVisible, setIsVisible] = useState(true) + const canToggleState = canToggle(editor, level) + const isActive = isHeadingActive(editor, level) + + useEffect(() => { + if (!editor) return + + const handleSelectionUpdate = () => { + setIsVisible(shouldShowButton({ editor, level, hideWhenUnavailable })) + } + + handleSelectionUpdate() + + editor.on("selectionUpdate", handleSelectionUpdate) + + return () => { + editor.off("selectionUpdate", handleSelectionUpdate) + } + }, [editor, level, hideWhenUnavailable]) + + const handleToggle = useCallback(() => { + if (!editor) return false + + const success = toggleHeading(editor, level) + if (success) { + onToggled?.() + } + return success + }, [editor, level, onToggled]) + + return { + isVisible, + isActive, + handleToggle, + canToggle: canToggleState, + label: `Heading ${level}`, + shortcutKeys: HEADING_SHORTCUT_KEYS[level], + Icon: headingIcons[level], + } +} diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/heading-dropdown-menu/heading-dropdown-menu.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/heading-dropdown-menu/heading-dropdown-menu.tsx new file mode 100644 index 0000000000..22a9d099a4 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/heading-dropdown-menu/heading-dropdown-menu.tsx @@ -0,0 +1,127 @@ +import React, { forwardRef, useCallback, useState } from "react" + +// --- Icons --- +import { ChevronDownIcon } from "../../tiptap-icons/chevron-down-icon" + +// --- Hooks --- +import { useTiptapEditor } from "../../../hooks/use-tiptap-editor" + +// --- Tiptap UI --- +import { HeadingButton } from "../heading-button" +import type { UseHeadingDropdownMenuConfig } from "./" +import { useHeadingDropdownMenu } from "./" + +// --- UI Primitives --- +import type { ButtonProps } from "../../tiptap-ui-primitive/button" +import { Button, ButtonGroup } from "../../tiptap-ui-primitive/button" +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, +} from "../../tiptap-ui-primitive/dropdown-menu" +import { Card, CardBody } from "../../tiptap-ui-primitive/card" + +export interface HeadingDropdownMenuProps + extends Omit, + UseHeadingDropdownMenuConfig { + /** + * Whether to render the dropdown menu in a portal + * @default false + */ + portal?: boolean + /** + * Callback for when the dropdown opens or closes + */ + onOpenChange?: (isOpen: boolean) => void +} + +/** + * Dropdown menu component for selecting heading levels in a Tiptap editor. + * + * For custom dropdown implementations, use the `useHeadingDropdownMenu` hook instead. + */ +export const HeadingDropdownMenu = forwardRef< + HTMLButtonElement, + HeadingDropdownMenuProps +>( + ( + { + editor: providedEditor, + levels = [1, 2, 3, 4, 5, 6], + hideWhenUnavailable = false, + portal = false, + onOpenChange, + ...buttonProps + }, + ref, + ) => { + const { editor } = useTiptapEditor(providedEditor) + const [isOpen, setIsOpen] = useState(false) + const { isVisible, isActive, canToggle, Icon } = useHeadingDropdownMenu({ + editor, + levels, + hideWhenUnavailable, + }) + + const handleOpenChange = useCallback( + (open: boolean) => { + if (!editor || !canToggle) return + setIsOpen(open) + onOpenChange?.(open) + }, + [canToggle, editor, onOpenChange], + ) + + if (!isVisible) { + return null + } + + return ( + + + + + + + + + + {levels.map((level) => ( + + + + ))} + + + + + + ) + }, +) + +HeadingDropdownMenu.displayName = "HeadingDropdownMenu" + +export default HeadingDropdownMenu diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/heading-dropdown-menu/index.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/heading-dropdown-menu/index.tsx new file mode 100644 index 0000000000..33b9679900 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/heading-dropdown-menu/index.tsx @@ -0,0 +1,2 @@ +export * from "./heading-dropdown-menu" +export * from "./use-heading-dropdown-menu" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/heading-dropdown-menu/use-heading-dropdown-menu.ts b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/heading-dropdown-menu/use-heading-dropdown-menu.ts new file mode 100644 index 0000000000..597109ad00 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/heading-dropdown-menu/use-heading-dropdown-menu.ts @@ -0,0 +1,132 @@ +"use client" + +import { useEffect, useState } from "react" +import type { Editor } from "@tiptap/react" + +// --- Hooks --- +import { useTiptapEditor } from "../../../hooks/use-tiptap-editor" + +// --- Icons --- +import { HeadingIcon } from "../../tiptap-icons/heading-icon" + +// --- Tiptap UI --- +import { + headingIcons, + type Level, + isHeadingActive, + canToggle, + shouldShowButton, +} from "../heading-button" + +/** + * Configuration for the heading dropdown menu functionality + */ +export interface UseHeadingDropdownMenuConfig { + /** + * The Tiptap editor instance. + */ + editor?: Editor | null + /** + * Available heading levels to show in the dropdown + * @default [1, 2, 3, 4, 5, 6] + */ + levels?: Level[] + /** + * Whether the dropdown should hide when headings are not available. + * @default false + */ + hideWhenUnavailable?: boolean +} + +/** + * Gets the currently active heading level from the available levels + */ +export function getActiveHeadingLevel( + editor: Editor | null, + levels: Level[] = [1, 2, 3, 4, 5, 6], +): Level | undefined { + if (!editor || !editor.isEditable) return undefined + return levels.find((level) => isHeadingActive(editor, level)) +} + +/** + * Custom hook that provides heading dropdown menu functionality for Tiptap editor + * + * @example + * ```tsx + * // Simple usage + * function MyHeadingDropdown() { + * const { + * isVisible, + * activeLevel, + * isAnyHeadingActive, + * canToggle, + * levels, + * } = useHeadingDropdownMenu() + * + * if (!isVisible) return null + * + * return ( + * + * // dropdown content + * + * ) + * } + * + * // Advanced usage with configuration + * function MyAdvancedHeadingDropdown() { + * const { + * isVisible, + * activeLevel, + * } = useHeadingDropdownMenu({ + * editor: myEditor, + * levels: [1, 2, 3], + * hideWhenUnavailable: true, + * }) + * + * // component implementation + * } + * ``` + */ +export function useHeadingDropdownMenu(config?: UseHeadingDropdownMenuConfig) { + const { + editor: providedEditor, + levels = [1, 2, 3, 4, 5, 6], + hideWhenUnavailable = false, + } = config || {} + + const { editor } = useTiptapEditor(providedEditor) + const [isVisible, setIsVisible] = useState(true) + + const activeLevel = getActiveHeadingLevel(editor, levels) + const isActive = isHeadingActive(editor) + const canToggleState = canToggle(editor) + + useEffect(() => { + if (!editor) return + + const handleSelectionUpdate = () => { + setIsVisible( + shouldShowButton({ editor, hideWhenUnavailable, level: levels }), + ) + } + + handleSelectionUpdate() + + editor.on("selectionUpdate", handleSelectionUpdate) + + return () => { + editor.off("selectionUpdate", handleSelectionUpdate) + } + }, [editor, hideWhenUnavailable, levels]) + + return { + isVisible, + activeLevel, + isActive, + canToggle: canToggleState, + levels, + label: "Heading", + Icon: activeLevel ? headingIcons[activeLevel] : HeadingIcon, + } +} diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/image-upload-button/image-upload-button.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/image-upload-button/image-upload-button.tsx new file mode 100644 index 0000000000..f349d1865d --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/image-upload-button/image-upload-button.tsx @@ -0,0 +1,128 @@ +import React, { forwardRef, useCallback } from "react" + +// --- Lib --- +import { parseShortcutKeys } from "../../../lib/tiptap-utils" + +// --- Hooks --- +import { useTiptapEditor } from "../../../hooks/use-tiptap-editor" + +// --- Tiptap UI --- +import type { UseImageUploadConfig } from "./" +import { IMAGE_UPLOAD_SHORTCUT_KEY, useImageUpload } from "./" + +// --- UI Primitives --- +import type { ButtonProps } from "../../tiptap-ui-primitive/button" +import { Button } from "../../tiptap-ui-primitive/button" +import { Badge } from "../../tiptap-ui-primitive/badge" + +type IconProps = React.SVGProps +type IconComponent = ({ className, ...props }: IconProps) => React.ReactElement + +export interface ImageUploadButtonProps + extends Omit, + UseImageUploadConfig { + /** + * Optional text to display alongside the icon. + */ + text?: string + /** + * Optional show shortcut keys in the button. + * @default false + */ + showShortcut?: boolean + /** + * Optional custom icon component to render instead of the default. + */ + icon?: React.MemoExoticComponent | React.FC +} + +export function ImageShortcutBadge({ + shortcutKeys = IMAGE_UPLOAD_SHORTCUT_KEY, +}: { + shortcutKeys?: string +}) { + return {parseShortcutKeys({ shortcutKeys })} +} + +/** + * Button component for uploading/inserting images in a Tiptap editor. + * + * For custom button implementations, use the `useImage` hook instead. + */ +export const ImageUploadButton = forwardRef< + HTMLButtonElement, + ImageUploadButtonProps +>( + ( + { + editor: providedEditor, + text, + hideWhenUnavailable = false, + onInserted, + showShortcut = false, + onClick, + icon: CustomIcon, + children, + ...buttonProps + }, + ref, + ) => { + const { editor } = useTiptapEditor(providedEditor) + const { + isVisible, + canInsert, + handleImage, + label, + isActive, + shortcutKeys, + Icon, + } = useImageUpload({ + editor, + hideWhenUnavailable, + onInserted, + }) + + const handleClick = useCallback( + (event: React.MouseEvent) => { + onClick?.(event) + if (event.defaultPrevented) return + handleImage() + }, + [handleImage, onClick], + ) + + if (!isVisible) { + return null + } + + const RenderIcon = CustomIcon ?? Icon + + return ( + + ) + }, +) + +ImageUploadButton.displayName = "ImageUploadButton" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/image-upload-button/index.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/image-upload-button/index.tsx new file mode 100644 index 0000000000..815d5bb5ef --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/image-upload-button/index.tsx @@ -0,0 +1,2 @@ +export * from "./image-upload-button" +export * from "./use-image-upload" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/image-upload-button/use-image-upload.ts b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/image-upload-button/use-image-upload.ts new file mode 100644 index 0000000000..156bf0649b --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/image-upload-button/use-image-upload.ts @@ -0,0 +1,192 @@ +"use client" + +import { useCallback, useEffect, useState } from "react" +import { useHotkeys } from "react-hotkeys-hook" +import { type Editor } from "@tiptap/react" + +// --- Hooks --- +import { useTiptapEditor } from "../../../hooks/use-tiptap-editor" +import { useIsMobile } from "../../../hooks/use-mobile" + +// --- Lib --- +import { isExtensionAvailable } from "../../../lib/tiptap-utils" + +// --- Icons --- +import { ImagePlusIcon } from "../../tiptap-icons/image-plus-icon" + +export const IMAGE_UPLOAD_SHORTCUT_KEY = "mod+shift+i" + +/** + * Configuration for the image upload functionality + */ +export interface UseImageUploadConfig { + /** + * The Tiptap editor instance. + */ + editor?: Editor | null + /** + * Whether the button should hide when insertion is not available. + * @default false + */ + hideWhenUnavailable?: boolean + /** + * Callback function called after a successful image insertion. + */ + onInserted?: () => void +} + +/** + * Checks if image can be inserted in the current editor state + */ +export function canInsertImage(editor: Editor | null): boolean { + if (!editor || !editor.isEditable) return false + if (!isExtensionAvailable(editor, "imageUpload")) return false + + return editor.can().insertContent({ type: "imageUpload" }) +} + +/** + * Checks if image is currently active + */ +export function isImageActive(editor: Editor | null): boolean { + if (!editor || !editor.isEditable) return false + return editor.isActive("imageUpload") +} + +/** + * Inserts an image in the editor + */ +export function insertImage(editor: Editor | null): boolean { + if (!editor || !editor.isEditable) return false + if (!canInsertImage(editor)) return false + + try { + return editor + .chain() + .focus() + .insertContent({ + type: "imageUpload", + }) + .run() + } catch { + return false + } +} + +/** + * Determines if the image button should be shown + */ +export function shouldShowButton(props: { + editor: Editor | null + hideWhenUnavailable: boolean +}): boolean { + const { editor, hideWhenUnavailable } = props + + if (!editor || !editor.isEditable) return false + if (!isExtensionAvailable(editor, "imageUpload")) return false + + if (hideWhenUnavailable && !editor.isActive("code")) { + return canInsertImage(editor) + } + + return true +} + +/** + * Custom hook that provides image functionality for Tiptap editor + * + * @example + * ```tsx + * // Simple usage - no params needed + * function MySimpleImageButton() { + * const { isVisible, handleImage } = useImage() + * + * if (!isVisible) return null + * + * return + * } + * + * // Advanced usage with configuration + * function MyAdvancedImageButton() { + * const { isVisible, handleImage, label, isActive } = useImage({ + * editor: myEditor, + * hideWhenUnavailable: true, + * onInserted: () => console.log('Image inserted!') + * }) + * + * if (!isVisible) return null + * + * return ( + * + * Add Image + * + * ) + * } + * ``` + */ +export function useImageUpload(config?: UseImageUploadConfig) { + const { + editor: providedEditor, + hideWhenUnavailable = false, + onInserted, + } = config || {} + + const { editor } = useTiptapEditor(providedEditor) + const isMobile = useIsMobile() + const [isVisible, setIsVisible] = useState(true) + const canInsert = canInsertImage(editor) + const isActive = isImageActive(editor) + + useEffect(() => { + if (!editor) return + + const handleSelectionUpdate = () => { + setIsVisible(shouldShowButton({ editor, hideWhenUnavailable })) + } + + handleSelectionUpdate() + + editor.on("selectionUpdate", handleSelectionUpdate) + + return () => { + editor.off("selectionUpdate", handleSelectionUpdate) + } + }, [editor, hideWhenUnavailable]) + + const handleImage = useCallback(() => { + if (!editor) return false + + const success = insertImage(editor) + if (success) { + onInserted?.() + } + return success + }, [editor, onInserted]) + + useHotkeys( + IMAGE_UPLOAD_SHORTCUT_KEY, + (event) => { + event.preventDefault() + handleImage() + }, + { + enabled: isVisible && canInsert, + enableOnContentEditable: !isMobile, + enableOnFormTags: true, + }, + ) + + return { + isVisible, + isActive, + handleImage, + canInsert, + label: "Add image", + shortcutKeys: IMAGE_UPLOAD_SHORTCUT_KEY, + Icon: ImagePlusIcon, + } +} diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/link-popover/index.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/link-popover/index.tsx new file mode 100644 index 0000000000..e725ea83ae --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/link-popover/index.tsx @@ -0,0 +1,2 @@ +export * from "./link-popover" +export * from "./use-link-popover" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/link-popover/link-popover.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/link-popover/link-popover.tsx new file mode 100644 index 0000000000..e1d6696177 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/link-popover/link-popover.tsx @@ -0,0 +1,303 @@ +"use client" + +import React, { forwardRef, useCallback, useEffect, useState } from "react" +import type { Editor } from "@tiptap/react" + +// --- Hooks --- +import { useIsMobile } from "../../../hooks/use-mobile" +import { useTiptapEditor } from "../../../hooks/use-tiptap-editor" + +// --- Icons --- +import { CornerDownLeftIcon } from "../../tiptap-icons/corner-down-left-icon" +import { ExternalLinkIcon } from "../../tiptap-icons/external-link-icon" +import { LinkIcon } from "../../tiptap-icons/link-icon" +import { TrashIcon } from "../../tiptap-icons/trash-icon" + +// --- Tiptap UI --- +import type { UseLinkPopoverConfig } from "./" +import { useLinkPopover } from "./" + +// --- UI Primitives --- +import type { ButtonProps } from "../../tiptap-ui-primitive/button" +import { Button, ButtonGroup } from "../../tiptap-ui-primitive/button" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "../../tiptap-ui-primitive/popover" +import { Separator } from "../../tiptap-ui-primitive/separator" +import { Card, CardBody, CardItemGroup } from "../../tiptap-ui-primitive/card" +import { Input, InputGroup } from "../../tiptap-ui-primitive/input" + +export interface LinkMainProps { + /** + * The URL to set for the link. + */ + url: string + /** + * Function to update the URL state. + */ + setUrl: React.Dispatch> + /** + * Function to set the link in the editor. + */ + setLink: () => void + /** + * Function to remove the link from the editor. + */ + removeLink: () => void + /** + * Function to open the link. + */ + openLink: () => void + /** + * Whether the link is currently active in the editor. + */ + isActive: boolean +} + +export interface LinkPopoverProps + extends Omit, + UseLinkPopoverConfig { + /** + * Callback for when the popover opens or closes. + */ + onOpenChange?: (isOpen: boolean) => void + /** + * Whether to automatically open the popover when a link is active. + * @default true + */ + autoOpenOnLinkActive?: boolean +} + +/** + * Link button component for triggering the link popover + */ +export const LinkButton = forwardRef( + ({ className, children, ...props }, ref) => { + return ( + + ) + }, +) + +LinkButton.displayName = "LinkButton" + +/** + * Main content component for the link popover + */ +const LinkMain: React.FC = ({ + url, + setUrl, + setLink, + removeLink, + openLink, + isActive, +}) => { + const isMobile = useIsMobile() + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Enter") { + event.preventDefault() + setLink() + } + } + + return ( + + + + + setUrl(e.target.value)} + onKeyDown={handleKeyDown} + autoFocus + autoComplete="off" + autoCorrect="off" + autoCapitalize="off" + /> + + + + + + + + + + + + + + + + + ) +} + +/** + * Link content component for standalone use + */ +export const LinkContent: React.FC<{ + editor?: Editor | null +}> = ({ editor }) => { + const linkPopover = useLinkPopover({ + editor, + }) + + return +} + +/** + * Link popover component for Tiptap editors. + * + * For custom popover implementations, use the `useLinkPopover` hook instead. + */ +export const LinkPopover = forwardRef( + ( + { + editor: providedEditor, + hideWhenUnavailable = false, + onSetLink, + onOpenChange, + autoOpenOnLinkActive = true, + onClick, + children, + ...buttonProps + }, + ref, + ) => { + const { editor } = useTiptapEditor(providedEditor) + const [isOpen, setIsOpen] = useState(false) + + const { + isVisible, + canSet, + isActive, + url, + setUrl, + setLink, + removeLink, + openLink, + label, + Icon, + } = useLinkPopover({ + editor, + hideWhenUnavailable, + onSetLink, + }) + + const handleOnOpenChange = useCallback( + (nextIsOpen: boolean) => { + setIsOpen(nextIsOpen) + onOpenChange?.(nextIsOpen) + }, + [onOpenChange], + ) + + const handleSetLink = useCallback(() => { + setLink() + setIsOpen(false) + }, [setLink]) + + const handleClick = useCallback( + (event: React.MouseEvent) => { + onClick?.(event) + if (event.defaultPrevented) return + setIsOpen(!isOpen) + }, + [onClick, isOpen], + ) + + useEffect(() => { + if (autoOpenOnLinkActive && isActive) { + setIsOpen(true) + } + }, [autoOpenOnLinkActive, isActive]) + + if (!isVisible) { + return null + } + + return ( + + + + {children ?? } + + + + + + + + ) + }, +) + +LinkPopover.displayName = "LinkPopover" + +export default LinkPopover diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/link-popover/use-link-popover.ts b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/link-popover/use-link-popover.ts new file mode 100644 index 0000000000..3b1d8abeb5 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/link-popover/use-link-popover.ts @@ -0,0 +1,284 @@ +import { useCallback, useEffect, useState } from "react" +import type { Editor } from "@tiptap/react" + +// --- Hooks --- +import { useTiptapEditor } from "../../../hooks/use-tiptap-editor" + +// --- Icons --- +import { LinkIcon } from "../../tiptap-icons/link-icon" + +// --- Lib --- +import { + isMarkInSchema, + isNodeTypeSelected, + sanitizeUrl, +} from "../../../lib/tiptap-utils" + +/** + * Configuration for the link popover functionality + */ +export interface UseLinkPopoverConfig { + /** + * The Tiptap editor instance. + */ + editor?: Editor | null + /** + * Whether to hide the link popover when not available. + * @default false + */ + hideWhenUnavailable?: boolean + /** + * Callback function called when the link is set. + */ + onSetLink?: () => void +} + +/** + * Configuration for the link handler functionality + */ +export interface LinkHandlerProps { + /** + * The Tiptap editor instance. + */ + editor: Editor | null + /** + * Callback function called when the link is set. + */ + onSetLink?: () => void +} + +/** + * Checks if a link can be set in the current editor state + */ +export function canSetLink(editor: Editor | null): boolean { + if (!editor || !editor.isEditable) return false + + // The third argument 'true' checks whether the current selection is inside an image caption, and prevents setting a link there + // If the selection is inside an image caption, we can't set a link + if (isNodeTypeSelected(editor, ["image"], true)) return false + return editor.can().setMark("link") +} + +/** + * Checks if a link is currently active in the editor + */ +export function isLinkActive(editor: Editor | null): boolean { + if (!editor || !editor.isEditable) return false + return editor.isActive("link") +} + +/** + * Determines if the link button should be shown + */ +export function shouldShowLinkButton(props: { + editor: Editor | null + hideWhenUnavailable: boolean +}): boolean { + const { editor, hideWhenUnavailable } = props + + const linkInSchema = isMarkInSchema("link", editor) + + if (!linkInSchema || !editor) { + return false + } + + if (hideWhenUnavailable && !editor.isActive("code")) { + return canSetLink(editor) + } + + return true +} + +/** + * Custom hook for handling link operations in a Tiptap editor + */ +export function useLinkHandler(props: LinkHandlerProps) { + const { editor, onSetLink } = props + const [url, setUrl] = useState(null) + + useEffect(() => { + if (!editor) return + + // Get URL immediately on mount + const { href } = editor.getAttributes("link") + + if (isLinkActive(editor) && url === null) { + setUrl(href || "") + } + }, [editor, url]) + + useEffect(() => { + if (!editor) return + + const updateLinkState = () => { + const { href } = editor.getAttributes("link") + setUrl(href || "") + } + + editor.on("selectionUpdate", updateLinkState) + return () => { + editor.off("selectionUpdate", updateLinkState) + } + }, [editor]) + + const setLink = useCallback(() => { + if (!url || !editor) return + + const { selection } = editor.state + const isEmpty = selection.empty + + let chain = editor.chain().focus() + + chain = chain.extendMarkRange("link").setLink({ href: url }) + + if (isEmpty) { + chain = chain.insertContent({ type: "text", text: url }) + } + + chain.run() + + setUrl(null) + + onSetLink?.() + }, [editor, onSetLink, url]) + + const removeLink = useCallback(() => { + if (!editor) return + editor + .chain() + .focus() + .extendMarkRange("link") + .unsetLink() + .setMeta("preventAutolink", true) + .run() + setUrl("") + }, [editor]) + + const openLink = useCallback( + (target: string = "_blank", features: string = "noopener,noreferrer") => { + if (!url) return + + const safeUrl = sanitizeUrl(url, window.location.href) + if (safeUrl !== "#") { + window.open(safeUrl, target, features) + } + }, + [url], + ) + + return { + url: url || "", + setUrl, + setLink, + removeLink, + openLink, + } +} + +/** + * Custom hook for link popover state management + */ +export function useLinkState(props: { + editor: Editor | null + hideWhenUnavailable: boolean +}) { + const { editor, hideWhenUnavailable = false } = props + + const canSet = canSetLink(editor) + const isActive = isLinkActive(editor) + + const [isVisible, setIsVisible] = useState(false) + + useEffect(() => { + if (!editor) return + + const handleSelectionUpdate = () => { + setIsVisible( + shouldShowLinkButton({ + editor, + hideWhenUnavailable, + }), + ) + } + + handleSelectionUpdate() + + editor.on("selectionUpdate", handleSelectionUpdate) + + return () => { + editor.off("selectionUpdate", handleSelectionUpdate) + } + }, [editor, hideWhenUnavailable]) + + return { + isVisible, + canSet, + isActive, + } +} + +/** + * Main hook that provides link popover functionality for Tiptap editor + * + * @example + * ```tsx + * // Simple usage + * function MyLinkButton() { + * const { isVisible, canSet, isActive, Icon, label } = useLinkPopover() + * + * if (!isVisible) return null + * + * return + * } + * + * // Advanced usage with configuration + * function MyAdvancedLinkButton() { + * const { isVisible, canSet, isActive, Icon, label } = useLinkPopover({ + * editor: myEditor, + * hideWhenUnavailable: true, + * onSetLink: () => console.log('Link set!') + * }) + * + * if (!isVisible) return null + * + * return ( + * + * + * {label} + * + * ) + * } + * ``` + */ +export function useLinkPopover(config?: UseLinkPopoverConfig) { + const { + editor: providedEditor, + hideWhenUnavailable = false, + onSetLink, + } = config || {} + + const { editor } = useTiptapEditor(providedEditor) + + const { isVisible, canSet, isActive } = useLinkState({ + editor, + hideWhenUnavailable, + }) + + const linkHandler = useLinkHandler({ + editor, + onSetLink, + }) + + return { + isVisible, + canSet, + isActive, + label: "Link", + Icon: LinkIcon, + ...linkHandler, + } +} diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/list-button/index.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/list-button/index.tsx new file mode 100644 index 0000000000..9f3d066656 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/list-button/index.tsx @@ -0,0 +1,2 @@ +export * from "./list-button" +export * from "./use-list" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/list-button/list-button.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/list-button/list-button.tsx new file mode 100644 index 0000000000..225e22225d --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/list-button/list-button.tsx @@ -0,0 +1,121 @@ +import React, { forwardRef, useCallback } from "react" + +// --- Lib --- +import { parseShortcutKeys } from "../../../lib/tiptap-utils" + +// --- Hooks --- +import { useTiptapEditor } from "../../../hooks/use-tiptap-editor" + +// --- UI Primitives --- +import type { ButtonProps } from "../../tiptap-ui-primitive/button" +import { Button } from "../../tiptap-ui-primitive/button" +import { Badge } from "../../tiptap-ui-primitive/badge" + +// --- Tiptap UI --- +import type { ListType, UseListConfig } from "./" +import { LIST_SHORTCUT_KEYS, useList } from "./" + +export interface ListButtonProps + extends Omit, + UseListConfig { + /** + * Optional text to display alongside the icon. + */ + text?: string + /** + * Optional show shortcut keys in the button. + * @default false + */ + showShortcut?: boolean +} + +export function ListShortcutBadge({ + type, + shortcutKeys = LIST_SHORTCUT_KEYS[type], +}: { + type: ListType + shortcutKeys?: string +}) { + return {parseShortcutKeys({ shortcutKeys })} +} + +/** + * Button component for toggling lists in a Tiptap editor. + * + * For custom button implementations, use the `useList` hook instead. + */ +export const ListButton = forwardRef( + ( + { + editor: providedEditor, + type, + text, + hideWhenUnavailable = false, + onToggled, + showShortcut = false, + onClick, + children, + ...buttonProps + }, + ref, + ) => { + const { editor } = useTiptapEditor(providedEditor) + const { + isVisible, + canToggle, + isActive, + handleToggle, + label, + shortcutKeys, + Icon, + } = useList({ + editor, + type, + hideWhenUnavailable, + onToggled, + }) + + const handleClick = useCallback( + (event: React.MouseEvent) => { + onClick?.(event) + if (event.defaultPrevented) return + handleToggle() + }, + [handleToggle, onClick], + ) + + if (!isVisible) { + return null + } + + return ( + + ) + }, +) + +ListButton.displayName = "ListButton" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/list-button/use-list.ts b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/list-button/use-list.ts new file mode 100644 index 0000000000..2309f55439 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/list-button/use-list.ts @@ -0,0 +1,326 @@ +"use client" + +import { useCallback, useEffect, useState } from "react" +import { type Editor } from "@tiptap/react" +import { NodeSelection, TextSelection } from "@tiptap/pm/state" + +// --- Hooks --- +import { useTiptapEditor } from "../../../hooks/use-tiptap-editor" + +// --- Icons --- +import { ListIcon } from "../../tiptap-icons/list-icon" +import { ListOrderedIcon } from "../../tiptap-icons/list-ordered-icon" +import { ListTodoIcon } from "../../tiptap-icons/list-todo-icon" + +// --- Lib --- +import { + findNodePosition, + isNodeInSchema, + isNodeTypeSelected, + isValidPosition, + selectionWithinConvertibleTypes, +} from "../../../lib/tiptap-utils" + +export type ListType = "bulletList" | "orderedList" | "taskList" + +/** + * Configuration for the list functionality + */ +export interface UseListConfig { + /** + * The Tiptap editor instance. + */ + editor?: Editor | null + /** + * The type of list to toggle. + */ + type: ListType + /** + * Whether the button should hide when list is not available. + * @default false + */ + hideWhenUnavailable?: boolean + /** + * Callback function called after a successful toggle. + */ + onToggled?: () => void +} + +export const listIcons = { + bulletList: ListIcon, + orderedList: ListOrderedIcon, + taskList: ListTodoIcon, +} + +export const listLabels: Record = { + bulletList: "Bullet List", + orderedList: "Ordered List", + taskList: "Task List", +} + +export const LIST_SHORTCUT_KEYS: Record = { + bulletList: "mod+shift+8", + orderedList: "mod+shift+7", + taskList: "mod+shift+9", +} + +/** + * Checks if a list can be toggled in the current editor state + */ +export function canToggleList( + editor: Editor | null, + type: ListType, + turnInto: boolean = true, +): boolean { + if (!editor || !editor.isEditable) return false + if (!isNodeInSchema(type, editor) || isNodeTypeSelected(editor, ["image"])) + return false + + if (!turnInto) { + switch (type) { + case "bulletList": + return editor.can().toggleBulletList() + case "orderedList": + return editor.can().toggleOrderedList() + case "taskList": + return editor.can().toggleList("taskList", "taskItem") + default: + return false + } + } + + // Ensure selection is in nodes we're allowed to convert + if ( + !selectionWithinConvertibleTypes(editor, [ + "paragraph", + "heading", + "bulletList", + "orderedList", + "taskList", + "blockquote", + "codeBlock", + ]) + ) + return false + + // Either we can set list directly on the selection, + // or we can clear formatting/nodes to arrive at a list. + switch (type) { + case "bulletList": + return editor.can().toggleBulletList() || editor.can().clearNodes() + case "orderedList": + return editor.can().toggleOrderedList() || editor.can().clearNodes() + case "taskList": + return ( + editor.can().toggleList("taskList", "taskItem") || + editor.can().clearNodes() + ) + default: + return false + } +} + +/** + * Checks if list is currently active + */ +export function isListActive(editor: Editor | null, type: ListType): boolean { + if (!editor || !editor.isEditable) return false + + switch (type) { + case "bulletList": + return editor.isActive("bulletList") + case "orderedList": + return editor.isActive("orderedList") + case "taskList": + return editor.isActive("taskList") + default: + return false + } +} + +/** + * Toggles list in the editor + */ +export function toggleList(editor: Editor | null, type: ListType): boolean { + if (!editor || !editor.isEditable) return false + if (!canToggleList(editor, type)) return false + + try { + const view = editor.view + let state = view.state + let tr = state.tr + + // No selection, find the the cursor position + if (state.selection.empty || state.selection instanceof TextSelection) { + const pos = findNodePosition({ + editor, + node: state.selection.$anchor.node(1), + })?.pos + if (!isValidPosition(pos)) return false + + tr = tr.setSelection(NodeSelection.create(state.doc, pos)) + view.dispatch(tr) + state = view.state + } + + const selection = state.selection + + let chain = editor.chain().focus() + + // Handle NodeSelection + if (selection instanceof NodeSelection) { + const firstChild = selection.node.firstChild?.firstChild + const lastChild = selection.node.lastChild?.lastChild + + const from = firstChild + ? selection.from + firstChild.nodeSize + : selection.from + 1 + + const to = lastChild + ? selection.to - lastChild.nodeSize + : selection.to - 1 + + const resolvedFrom = state.doc.resolve(from) + const resolvedTo = state.doc.resolve(to) + + chain = chain + .setTextSelection(TextSelection.between(resolvedFrom, resolvedTo)) + .clearNodes() + } + + if (editor.isActive(type)) { + // Unwrap list + chain + .liftListItem("listItem") + .lift("bulletList") + .lift("orderedList") + .lift("taskList") + .run() + } else { + // Wrap in specific list type + const toggleMap: Record typeof chain> = { + bulletList: () => chain.toggleBulletList(), + orderedList: () => chain.toggleOrderedList(), + taskList: () => chain.toggleList("taskList", "taskItem"), + } + + const toggle = toggleMap[type] + if (!toggle) return false + + toggle().run() + } + + editor.chain().focus().selectTextblockEnd().run() + + return true + } catch { + return false + } +} + +/** + * Determines if the list button should be shown + */ +export function shouldShowButton(props: { + editor: Editor | null + type: ListType + hideWhenUnavailable: boolean +}): boolean { + const { editor, type, hideWhenUnavailable } = props + + if (!editor || !editor.isEditable) return false + if (!isNodeInSchema(type, editor)) return false + + if (hideWhenUnavailable && !editor.isActive("code")) { + return canToggleList(editor, type) + } + + return true +} + +/** + * Custom hook that provides list functionality for Tiptap editor + * + * @example + * ```tsx + * // Simple usage + * function MySimpleListButton() { + * const { isVisible, handleToggle, isActive } = useList({ type: "bulletList" }) + * + * if (!isVisible) return null + * + * return + * } + * + * // Advanced usage with configuration + * function MyAdvancedListButton() { + * const { isVisible, handleToggle, label, isActive } = useList({ + * type: "orderedList", + * editor: myEditor, + * hideWhenUnavailable: true, + * onToggled: () => console.log('List toggled!') + * }) + * + * if (!isVisible) return null + * + * return ( + * + * Toggle List + * + * ) + * } + * ``` + */ +export function useList(config: UseListConfig) { + const { + editor: providedEditor, + type, + hideWhenUnavailable = false, + onToggled, + } = config + + const { editor } = useTiptapEditor(providedEditor) + const [isVisible, setIsVisible] = useState(true) + const canToggle = canToggleList(editor, type) + const isActive = isListActive(editor, type) + + useEffect(() => { + if (!editor) return + + const handleSelectionUpdate = () => { + setIsVisible(shouldShowButton({ editor, type, hideWhenUnavailable })) + } + + handleSelectionUpdate() + + editor.on("selectionUpdate", handleSelectionUpdate) + + return () => { + editor.off("selectionUpdate", handleSelectionUpdate) + } + }, [editor, type, hideWhenUnavailable]) + + const handleToggle = useCallback(() => { + if (!editor) return false + + const success = toggleList(editor, type) + if (success) { + onToggled?.() + } + return success + }, [editor, type, onToggled]) + + return { + isVisible, + isActive, + handleToggle, + canToggle, + label: listLabels[type], + shortcutKeys: LIST_SHORTCUT_KEYS[type], + Icon: listIcons[type], + } +} diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/list-dropdown-menu/index.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/list-dropdown-menu/index.tsx new file mode 100644 index 0000000000..9a215b8016 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/list-dropdown-menu/index.tsx @@ -0,0 +1 @@ +export * from "./list-dropdown-menu" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/list-dropdown-menu/list-dropdown-menu.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/list-dropdown-menu/list-dropdown-menu.tsx new file mode 100644 index 0000000000..d143b9929f --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/list-dropdown-menu/list-dropdown-menu.tsx @@ -0,0 +1,123 @@ +import React, { useCallback, useState } from "react" +import { type Editor } from "@tiptap/react" + +// --- Hooks --- +import { useTiptapEditor } from "../../../hooks/use-tiptap-editor" + +// --- Icons --- +import { ChevronDownIcon } from "../../tiptap-icons/chevron-down-icon" + +// --- Tiptap UI --- +import { ListButton, type ListType } from "../list-button" + +import { useListDropdownMenu } from "./use-list-dropdown-menu" + +// --- UI Primitives --- +import type { ButtonProps } from "../../tiptap-ui-primitive/button" +import { Button, ButtonGroup } from "../../tiptap-ui-primitive/button" +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, +} from "../../tiptap-ui-primitive/dropdown-menu" +import { Card, CardBody } from "../../tiptap-ui-primitive/card" + +export interface ListDropdownMenuProps extends Omit { + /** + * The Tiptap editor instance. + */ + editor?: Editor + /** + * The list types to display in the dropdown. + */ + types?: ListType[] + /** + * Whether the dropdown should be hidden when no list types are available + * @default false + */ + hideWhenUnavailable?: boolean + /** + * Callback for when the dropdown opens or closes + */ + onOpenChange?: (isOpen: boolean) => void + /** + * Whether to render the dropdown menu in a portal + * @default false + */ + portal?: boolean +} + +export function ListDropdownMenu({ + editor: providedEditor, + types = ["bulletList", "orderedList", "taskList"], + hideWhenUnavailable = false, + onOpenChange, + portal = false, + ...props +}: ListDropdownMenuProps) { + const { editor } = useTiptapEditor(providedEditor) + const [isOpen, setIsOpen] = useState(false) + + const { filteredLists, canToggle, isActive, isVisible, Icon } = + useListDropdownMenu({ + editor, + types, + hideWhenUnavailable, + }) + + const handleOnOpenChange = useCallback( + (open: boolean) => { + setIsOpen(open) + onOpenChange?.(open) + }, + [onOpenChange], + ) + + if (!isVisible || !editor || !editor.isEditable) { + return null + } + + return ( + + + + + + + + + + {filteredLists.map((option) => ( + + + + ))} + + + + + + ) +} + +export default ListDropdownMenu diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/list-dropdown-menu/use-list-dropdown-menu.ts b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/list-dropdown-menu/use-list-dropdown-menu.ts new file mode 100644 index 0000000000..21affaa15b --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/list-dropdown-menu/use-list-dropdown-menu.ts @@ -0,0 +1,216 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import type { Editor } from "@tiptap/react" + +// --- Hooks --- +import { useTiptapEditor } from "../../../hooks/use-tiptap-editor" + +// --- Icons --- +import { ListIcon } from "../../tiptap-icons/list-icon" +import { ListOrderedIcon } from "../../tiptap-icons/list-ordered-icon" +import { ListTodoIcon } from "../../tiptap-icons/list-todo-icon" + +// --- Lib --- +import { isNodeInSchema } from "../../../lib/tiptap-utils" + +// --- Tiptap UI --- +import { + canToggleList, + isListActive, + listIcons, + type ListType, +} from "../list-button" + +/** + * Configuration for the list dropdown menu functionality + */ +export interface UseListDropdownMenuConfig { + /** + * The Tiptap editor instance. + */ + editor?: Editor | null + /** + * The list types to display in the dropdown. + * @default ["bulletList", "orderedList", "taskList"] + */ + types?: ListType[] + /** + * Whether the dropdown should be hidden when no list types are available + * @default false + */ + hideWhenUnavailable?: boolean +} + +export interface ListOption { + label: string + type: ListType + icon: React.ElementType +} + +export const listOptions: ListOption[] = [ + { + label: "Bullet List", + type: "bulletList", + icon: ListIcon, + }, + { + label: "Ordered List", + type: "orderedList", + icon: ListOrderedIcon, + }, + { + label: "Task List", + type: "taskList", + icon: ListTodoIcon, + }, +] + +export function canToggleAnyList( + editor: Editor | null, + listTypes: ListType[], +): boolean { + if (!editor || !editor.isEditable) return false + return listTypes.some((type) => canToggleList(editor, type)) +} + +export function isAnyListActive( + editor: Editor | null, + listTypes: ListType[], +): boolean { + if (!editor || !editor.isEditable) return false + return listTypes.some((type) => isListActive(editor, type)) +} + +export function getFilteredListOptions( + availableTypes: ListType[], +): typeof listOptions { + return listOptions.filter( + (option) => !option.type || availableTypes.includes(option.type), + ) +} + +export function shouldShowListDropdown(params: { + editor: Editor | null + listTypes: ListType[] + hideWhenUnavailable: boolean + listInSchema: boolean + canToggleAny: boolean +}): boolean { + const { editor, hideWhenUnavailable, listInSchema, canToggleAny } = params + + if (!listInSchema || !editor) { + return false + } + + if (hideWhenUnavailable && !editor.isActive("code")) { + return canToggleAny + } + + return true +} + +/** + * Gets the currently active list type from the available types + */ +export function getActiveListType( + editor: Editor | null, + availableTypes: ListType[], +): ListType | undefined { + if (!editor || !editor.isEditable) return undefined + return availableTypes.find((type) => isListActive(editor, type)) +} + +/** + * Custom hook that provides list dropdown menu functionality for Tiptap editor + * + * @example + * ```tsx + * // Simple usage + * function MyListDropdown() { + * const { + * isVisible, + * activeType, + * isAnyActive, + * canToggleAny, + * filteredLists, + * } = useListDropdownMenu() + * + * if (!isVisible) return null + * + * return ( + * + * // dropdown content + * + * ) + * } + * + * // Advanced usage with configuration + * function MyAdvancedListDropdown() { + * const { + * isVisible, + * activeType, + * } = useListDropdownMenu({ + * editor: myEditor, + * types: ["bulletList", "orderedList"], + * hideWhenUnavailable: true, + * }) + * + * // component implementation + * } + * ``` + */ +export function useListDropdownMenu(config?: UseListDropdownMenuConfig) { + const { + editor: providedEditor, + types = ["bulletList", "orderedList", "taskList"], + hideWhenUnavailable = false, + } = config || {} + + const { editor } = useTiptapEditor(providedEditor) + const [isVisible, setIsVisible] = useState(false) + + const listInSchema = types.some((type) => isNodeInSchema(type, editor)) + + const filteredLists = useMemo(() => getFilteredListOptions(types), [types]) + + const canToggleAny = canToggleAnyList(editor, types) + const isAnyActive = isAnyListActive(editor, types) + const activeType = getActiveListType(editor, types) + const activeList = filteredLists.find((option) => option.type === activeType) + + useEffect(() => { + if (!editor) return + + const handleSelectionUpdate = () => { + setIsVisible( + shouldShowListDropdown({ + editor, + listTypes: types, + hideWhenUnavailable, + listInSchema, + canToggleAny, + }), + ) + } + + handleSelectionUpdate() + + editor.on("selectionUpdate", handleSelectionUpdate) + + return () => { + editor.off("selectionUpdate", handleSelectionUpdate) + } + }, [canToggleAny, editor, hideWhenUnavailable, listInSchema, types]) + + return { + isVisible, + activeType, + isActive: isAnyActive, + canToggle: canToggleAny, + types, + filteredLists, + label: "List", + Icon: activeList ? listIcons[activeList.type] : ListIcon, + } +} diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/mark-button/index.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/mark-button/index.tsx new file mode 100644 index 0000000000..32e85b9c7b --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/mark-button/index.tsx @@ -0,0 +1,2 @@ +export * from "./mark-button" +export * from "./use-mark" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/mark-button/mark-button.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/mark-button/mark-button.tsx new file mode 100644 index 0000000000..b6bb47073c --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/mark-button/mark-button.tsx @@ -0,0 +1,123 @@ +"use client" + +import React, { forwardRef, useCallback } from "react" + +// --- Lib --- +import { parseShortcutKeys } from "../../../lib/tiptap-utils" + +// --- Hooks --- +import { useTiptapEditor } from "../../../hooks/use-tiptap-editor" + +// --- Tiptap UI --- +import type { Mark, UseMarkConfig } from "./" +import { MARK_SHORTCUT_KEYS, useMark } from "./" + +// --- UI Primitives --- +import type { ButtonProps } from "../../tiptap-ui-primitive/button" +import { Button } from "../../tiptap-ui-primitive/button" +import { Badge } from "../../tiptap-ui-primitive/badge" + +export interface MarkButtonProps + extends Omit, + UseMarkConfig { + /** + * Optional text to display alongside the icon. + */ + text?: string + /** + * Optional show shortcut keys in the button. + * @default false + */ + showShortcut?: boolean +} + +export function MarkShortcutBadge({ + type, + shortcutKeys = MARK_SHORTCUT_KEYS[type], +}: { + type: Mark + shortcutKeys?: string +}) { + return {parseShortcutKeys({ shortcutKeys })} +} + +/** + * Button component for toggling marks in a Tiptap editor. + * + * For custom button implementations, use the `useMark` hook instead. + */ +export const MarkButton = forwardRef( + ( + { + editor: providedEditor, + type, + text, + hideWhenUnavailable = false, + onToggled, + showShortcut = false, + onClick, + children, + ...buttonProps + }, + ref, + ) => { + const { editor } = useTiptapEditor(providedEditor) + const { + isVisible, + handleMark, + label, + canToggle, + isActive, + Icon, + shortcutKeys, + } = useMark({ + editor, + type, + hideWhenUnavailable, + onToggled, + }) + + const handleClick = useCallback( + (event: React.MouseEvent) => { + onClick?.(event) + if (event.defaultPrevented) return + handleMark() + }, + [handleMark, onClick], + ) + + if (!isVisible) { + return null + } + + return ( + + ) + }, +) + +MarkButton.displayName = "MarkButton" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/mark-button/use-mark.ts b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/mark-button/use-mark.ts new file mode 100644 index 0000000000..c67352618e --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/mark-button/use-mark.ts @@ -0,0 +1,212 @@ +import { useCallback, useEffect, useState } from "react" +import type { Editor } from "@tiptap/react" + +// --- Hooks --- +import { useTiptapEditor } from "../../../hooks/use-tiptap-editor" + +// --- Lib --- +import { isMarkInSchema, isNodeTypeSelected } from "../../../lib/tiptap-utils" + +// --- Icons --- +import { BoldIcon } from "../../tiptap-icons/bold-icon" +import { Code2Icon } from "../../tiptap-icons/code2-icon" +import { ItalicIcon } from "../../tiptap-icons/italic-icon" +import { StrikeIcon } from "../../tiptap-icons/strike-icon" +import { SubscriptIcon } from "../../tiptap-icons/subscript-icon" +import { SuperscriptIcon } from "../../tiptap-icons/superscript-icon" +import { UnderlineIcon } from "../../tiptap-icons/underline-icon" + +export type Mark = + | "bold" + | "italic" + | "strike" + | "code" + | "underline" + | "superscript" + | "subscript" + +/** + * Configuration for the mark functionality + */ +export interface UseMarkConfig { + /** + * The Tiptap editor instance. + */ + editor?: Editor | null + /** + * The type of mark to toggle + */ + type: Mark + /** + * Whether the button should hide when mark is not available. + * @default false + */ + hideWhenUnavailable?: boolean + /** + * Callback function called after a successful mark toggle. + */ + onToggled?: () => void +} + +export const markIcons = { + bold: BoldIcon, + italic: ItalicIcon, + underline: UnderlineIcon, + strike: StrikeIcon, + code: Code2Icon, + superscript: SuperscriptIcon, + subscript: SubscriptIcon, +} + +export const MARK_SHORTCUT_KEYS: Record = { + bold: "mod+b", + italic: "mod+i", + underline: "mod+u", + strike: "mod+shift+s", + code: "mod+e", + superscript: "mod+.", + subscript: "mod+,", +} + +/** + * Checks if a mark can be toggled in the current editor state + */ +export function canToggleMark(editor: Editor | null, type: Mark): boolean { + if (!editor || !editor.isEditable) return false + if (!isMarkInSchema(type, editor) || isNodeTypeSelected(editor, ["image"])) + return false + + return editor.can().toggleMark(type) +} + +/** + * Checks if a mark is currently active + */ +export function isMarkActive(editor: Editor | null, type: Mark): boolean { + if (!editor || !editor.isEditable) return false + return editor.isActive(type) +} + +/** + * Toggles a mark in the editor + */ +export function toggleMark(editor: Editor | null, type: Mark): boolean { + if (!editor || !editor.isEditable) return false + if (!canToggleMark(editor, type)) return false + + return editor.chain().focus().toggleMark(type).run() +} + +/** + * Determines if the mark button should be shown + */ +export function shouldShowButton(props: { + editor: Editor | null + type: Mark + hideWhenUnavailable: boolean +}): boolean { + const { editor, type, hideWhenUnavailable } = props + + if (!editor || !editor.isEditable) return false + if (!isMarkInSchema(type, editor)) return false + + if (hideWhenUnavailable && !editor.isActive("code")) { + return canToggleMark(editor, type) + } + + return true +} + +/** + * Gets the formatted mark name + */ +export function getFormattedMarkName(type: Mark): string { + return type.charAt(0).toUpperCase() + type.slice(1) +} + +/** + * Custom hook that provides mark functionality for Tiptap editor + * + * @example + * ```tsx + * // Simple usage + * function MySimpleBoldButton() { + * const { isVisible, handleMark } = useMark({ type: "bold" }) + * + * if (!isVisible) return null + * + * return + * } + * + * // Advanced usage with configuration + * function MyAdvancedItalicButton() { + * const { isVisible, handleMark, label, isActive } = useMark({ + * editor: myEditor, + * type: "italic", + * hideWhenUnavailable: true, + * onToggled: () => console.log('Mark toggled!') + * }) + * + * if (!isVisible) return null + * + * return ( + * + * Italic + * + * ) + * } + * ``` + */ +export function useMark(config: UseMarkConfig) { + const { + editor: providedEditor, + type, + hideWhenUnavailable = false, + onToggled, + } = config + + const { editor } = useTiptapEditor(providedEditor) + const [isVisible, setIsVisible] = useState(true) + const canToggle = canToggleMark(editor, type) + const isActive = isMarkActive(editor, type) + + useEffect(() => { + if (!editor) return + + const handleSelectionUpdate = () => { + setIsVisible(shouldShowButton({ editor, type, hideWhenUnavailable })) + } + + handleSelectionUpdate() + + editor.on("selectionUpdate", handleSelectionUpdate) + + return () => { + editor.off("selectionUpdate", handleSelectionUpdate) + } + }, [editor, type, hideWhenUnavailable]) + + const handleMark = useCallback(() => { + if (!editor) return false + + const success = toggleMark(editor, type) + if (success) { + onToggled?.() + } + return success + }, [editor, type, onToggled]) + + return { + isVisible, + isActive, + handleMark, + canToggle, + label: getFormattedMarkName(type), + shortcutKeys: MARK_SHORTCUT_KEYS[type], + Icon: markIcons[type], + } +} diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/text-align-button/index.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/text-align-button/index.tsx new file mode 100644 index 0000000000..d19f95cf02 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/text-align-button/index.tsx @@ -0,0 +1,2 @@ +export * from "./text-align-button" +export * from "./use-text-align" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/text-align-button/text-align-button.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/text-align-button/text-align-button.tsx new file mode 100644 index 0000000000..4e6c0db077 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/text-align-button/text-align-button.tsx @@ -0,0 +1,139 @@ +"use client" + +import React, { forwardRef, useCallback } from "react" + +// --- Lib --- +import { parseShortcutKeys } from "../../../lib/tiptap-utils" + +// --- Hooks --- +import { useTiptapEditor } from "../../../hooks/use-tiptap-editor" + +// --- Tiptap UI --- +import type { TextAlign, UseTextAlignConfig } from "./" +import { TEXT_ALIGN_SHORTCUT_KEYS, useTextAlign } from "./" + +// --- UI Primitives --- +import type { ButtonProps } from "../../tiptap-ui-primitive/button" +import { Button } from "../../tiptap-ui-primitive/button" +import { Badge } from "../../tiptap-ui-primitive/badge" + +type IconProps = React.SVGProps +type IconComponent = ({ className, ...props }: IconProps) => React.ReactElement + +export interface TextAlignButtonProps + extends Omit, + UseTextAlignConfig { + /** + * Optional text to display alongside the icon. + */ + text?: string + /** + * Optional show shortcut keys in the button. + * @default false + */ + showShortcut?: boolean + /** + * Optional custom icon component to render instead of the default. + */ + icon?: React.MemoExoticComponent | React.FC +} + +export function TextAlignShortcutBadge({ + align, + shortcutKeys = TEXT_ALIGN_SHORTCUT_KEYS[align], +}: { + align: TextAlign + shortcutKeys?: string +}) { + return {parseShortcutKeys({ shortcutKeys })} +} + +/** + * Button component for setting text alignment in a Tiptap editor. + * + * For custom button implementations, use the `useTextAlign` hook instead. + */ +export const TextAlignButton = forwardRef< + HTMLButtonElement, + TextAlignButtonProps +>( + ( + { + editor: providedEditor, + align, + text, + hideWhenUnavailable = false, + onAligned, + showShortcut = false, + onClick, + icon: CustomIcon, + children, + ...buttonProps + }, + ref, + ) => { + const { editor } = useTiptapEditor(providedEditor) + const { + isVisible, + handleTextAlign, + label, + canAlign, + isActive, + Icon, + shortcutKeys, + } = useTextAlign({ + editor, + align, + hideWhenUnavailable, + onAligned, + }) + + const handleClick = useCallback( + (event: React.MouseEvent) => { + onClick?.(event) + if (event.defaultPrevented) return + handleTextAlign() + }, + [handleTextAlign, onClick], + ) + + if (!isVisible) { + return null + } + + const RenderIcon = CustomIcon ?? Icon + + return ( + + ) + }, +) + +TextAlignButton.displayName = "TextAlignButton" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/text-align-button/use-text-align.ts b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/text-align-button/use-text-align.ts new file mode 100644 index 0000000000..d52c948404 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/text-align-button/use-text-align.ts @@ -0,0 +1,222 @@ +import { useCallback, useEffect, useState } from "react" +import type { ChainedCommands } from "@tiptap/react" +import { type Editor } from "@tiptap/react" + +// --- Hooks --- +import { useTiptapEditor } from "../../../hooks/use-tiptap-editor" + +// --- Lib --- +import { + isExtensionAvailable, + isNodeTypeSelected, +} from "../../../lib/tiptap-utils" + +// --- Icons --- +import { AlignCenterIcon } from "../../tiptap-icons/align-center-icon" +import { AlignJustifyIcon } from "../../tiptap-icons/align-justify-icon" +import { AlignLeftIcon } from "../../tiptap-icons/align-left-icon" +import { AlignRightIcon } from "../../tiptap-icons/align-right-icon" + +export type TextAlign = "left" | "center" | "right" | "justify" + +/** + * Configuration for the text align functionality + */ +export interface UseTextAlignConfig { + /** + * The Tiptap editor instance. + */ + editor?: Editor | null + /** + * The text alignment to apply. + */ + align: TextAlign + /** + * Whether the button should hide when alignment is not available. + * @default false + */ + hideWhenUnavailable?: boolean + /** + * Callback function called after a successful alignment change. + */ + onAligned?: () => void +} + +export const TEXT_ALIGN_SHORTCUT_KEYS: Record = { + left: "mod+shift+l", + center: "mod+shift+e", + right: "mod+shift+r", + justify: "mod+shift+j", +} + +export const textAlignIcons = { + left: AlignLeftIcon, + center: AlignCenterIcon, + right: AlignRightIcon, + justify: AlignJustifyIcon, +} + +export const textAlignLabels: Record = { + left: "Align left", + center: "Align center", + right: "Align right", + justify: "Align justify", +} + +/** + * Checks if text alignment can be performed in the current editor state + */ +export function canSetTextAlign( + editor: Editor | null, + align: TextAlign, +): boolean { + if (!editor || !editor.isEditable) return false + if ( + !isExtensionAvailable(editor, "textAlign") || + isNodeTypeSelected(editor, ["image", "horizontalRule"]) + ) + return false + + return editor.can().setTextAlign(align) +} + +export function hasSetTextAlign( + commands: ChainedCommands, +): commands is ChainedCommands & { + setTextAlign: (align: TextAlign) => ChainedCommands +} { + return "setTextAlign" in commands +} + +/** + * Checks if the text alignment is currently active + */ +export function isTextAlignActive( + editor: Editor | null, + align: TextAlign, +): boolean { + if (!editor || !editor.isEditable) return false + return editor.isActive({ textAlign: align }) +} + +/** + * Sets text alignment in the editor + */ +export function setTextAlign(editor: Editor | null, align: TextAlign): boolean { + if (!editor || !editor.isEditable) return false + if (!canSetTextAlign(editor, align)) return false + + const chain = editor.chain().focus() + if (hasSetTextAlign(chain)) { + return chain.setTextAlign(align).run() + } + + return false +} + +/** + * Determines if the text align button should be shown + */ +export function shouldShowButton(props: { + editor: Editor | null + hideWhenUnavailable: boolean + align: TextAlign +}): boolean { + const { editor, hideWhenUnavailable, align } = props + + if (!editor || !editor.isEditable) return false + if (!isExtensionAvailable(editor, "textAlign")) return false + + if (hideWhenUnavailable && !editor.isActive("code")) { + return canSetTextAlign(editor, align) + } + + return true +} + +/** + * Custom hook that provides text align functionality for Tiptap editor + * + * @example + * ```tsx + * // Simple usage + * function MySimpleAlignButton() { + * const { isVisible, handleTextAlign } = useTextAlign({ align: "center" }) + * + * if (!isVisible) return null + * + * return + * } + * + * // Advanced usage with configuration + * function MyAdvancedAlignButton() { + * const { isVisible, handleTextAlign, label, isActive } = useTextAlign({ + * editor: myEditor, + * align: "right", + * hideWhenUnavailable: true, + * onAligned: () => console.log('Text aligned!') + * }) + * + * if (!isVisible) return null + * + * return ( + * + * Align Right + * + * ) + * } + * ``` + */ +export function useTextAlign(config: UseTextAlignConfig) { + const { + editor: providedEditor, + align, + hideWhenUnavailable = false, + onAligned, + } = config + + const { editor } = useTiptapEditor(providedEditor) + const [isVisible, setIsVisible] = useState(true) + const canAlign = canSetTextAlign(editor, align) + const isActive = isTextAlignActive(editor, align) + + useEffect(() => { + if (!editor) return + + const handleSelectionUpdate = () => { + setIsVisible(shouldShowButton({ editor, align, hideWhenUnavailable })) + } + + handleSelectionUpdate() + + editor.on("selectionUpdate", handleSelectionUpdate) + + return () => { + editor.off("selectionUpdate", handleSelectionUpdate) + } + }, [editor, hideWhenUnavailable, align]) + + const handleTextAlign = useCallback(() => { + if (!editor) return false + + const success = setTextAlign(editor, align) + if (success) { + onAligned?.() + } + return success + }, [editor, align, onAligned]) + + return { + isVisible, + isActive, + handleTextAlign, + canAlign, + label: textAlignLabels[align], + shortcutKeys: TEXT_ALIGN_SHORTCUT_KEYS[align], + Icon: textAlignIcons[align], + } +} diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/undo-redo-button/index.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/undo-redo-button/index.tsx new file mode 100644 index 0000000000..fa0fdbeb08 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/undo-redo-button/index.tsx @@ -0,0 +1,2 @@ +export * from "./undo-redo-button" +export * from "./use-undo-redo" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/undo-redo-button/undo-redo-button.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/undo-redo-button/undo-redo-button.tsx new file mode 100644 index 0000000000..5743e02410 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/undo-redo-button/undo-redo-button.tsx @@ -0,0 +1,120 @@ +"use client" + +import React, { forwardRef, useCallback } from "react" + +// --- Lib --- +import { parseShortcutKeys } from "../../../lib/tiptap-utils" + +// --- Hooks --- +import { useTiptapEditor } from "../../../hooks/use-tiptap-editor" + +// --- Tiptap UI --- +import type { UndoRedoAction, UseUndoRedoConfig } from "./" +import { UNDO_REDO_SHORTCUT_KEYS, useUndoRedo } from "./" + +// --- UI Primitives --- +import type { ButtonProps } from "../../tiptap-ui-primitive/button" +import { Button } from "../../tiptap-ui-primitive/button" +import { Badge } from "../../tiptap-ui-primitive/badge" + +export interface UndoRedoButtonProps + extends Omit, + UseUndoRedoConfig { + /** + * Optional text to display alongside the icon. + */ + text?: string + /** + * Optional show shortcut keys in the button. + * @default false + */ + showShortcut?: boolean +} + +export function HistoryShortcutBadge({ + action, + shortcutKeys = UNDO_REDO_SHORTCUT_KEYS[action], +}: { + action: UndoRedoAction + shortcutKeys?: string +}) { + return {parseShortcutKeys({ shortcutKeys })} +} + +/** + * Button component for triggering undo/redo actions in a Tiptap editor. + * + * For custom button implementations, use the `useHistory` hook instead. + */ +export const UndoRedoButton = forwardRef< + HTMLButtonElement, + UndoRedoButtonProps +>( + ( + { + editor: providedEditor, + action, + text, + hideWhenUnavailable = false, + onExecuted, + showShortcut = false, + onClick, + children, + ...buttonProps + }, + ref, + ) => { + const { editor } = useTiptapEditor(providedEditor) + const { isVisible, handleAction, label, canExecute, Icon, shortcutKeys } = + useUndoRedo({ + editor, + action, + hideWhenUnavailable, + onExecuted, + }) + + const handleClick = useCallback( + (event: React.MouseEvent) => { + onClick?.(event) + if (event.defaultPrevented) return + handleAction() + }, + [handleAction, onClick], + ) + + if (!isVisible) { + return null + } + + return ( + + ) + }, +) + +UndoRedoButton.displayName = "UndoRedoButton" diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/undo-redo-button/use-undo-redo.ts b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/undo-redo-button/use-undo-redo.ts new file mode 100644 index 0000000000..3add2c48e9 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/undo-redo-button/use-undo-redo.ts @@ -0,0 +1,182 @@ +import { useCallback, useEffect, useState } from "react" +import { type Editor } from "@tiptap/react" + +// --- Hooks --- +import { useTiptapEditor } from "../../../hooks/use-tiptap-editor" + +// --- Lib --- +import { isNodeTypeSelected } from "../../../lib/tiptap-utils" + +// --- Icons --- +import { Redo2Icon } from "../../tiptap-icons/redo2-icon" +import { Undo2Icon } from "../../tiptap-icons/undo2-icon" + +export type UndoRedoAction = "undo" | "redo" + +/** + * Configuration for the history functionality + */ +export interface UseUndoRedoConfig { + /** + * The Tiptap editor instance. + */ + editor?: Editor | null + /** + * The history action to perform (undo or redo). + */ + action: UndoRedoAction + /** + * Whether the button should hide when action is not available. + * @default false + */ + hideWhenUnavailable?: boolean + /** + * Callback function called after a successful action execution. + */ + onExecuted?: () => void +} + +export const UNDO_REDO_SHORTCUT_KEYS: Record = { + undo: "mod+z", + redo: "mod+shift+z", +} + +export const historyActionLabels: Record = { + undo: "Undo", + redo: "Redo", +} + +export const historyIcons = { + undo: Undo2Icon, + redo: Redo2Icon, +} + +/** + * Checks if a history action can be executed + */ +export function canExecuteUndoRedoAction( + editor: Editor | null, + action: UndoRedoAction, +): boolean { + if (!editor || !editor.isEditable) return false + if (isNodeTypeSelected(editor, ["image"])) return false + + return action === "undo" ? editor.can().undo() : editor.can().redo() +} + +/** + * Executes a history action on the editor + */ +export function executeUndoRedoAction( + editor: Editor | null, + action: UndoRedoAction, +): boolean { + if (!editor || !editor.isEditable) return false + if (!canExecuteUndoRedoAction(editor, action)) return false + + const chain = editor.chain().focus() + return action === "undo" ? chain.undo().run() : chain.redo().run() +} + +/** + * Determines if the history button should be shown + */ +export function shouldShowButton(props: { + editor: Editor | null + hideWhenUnavailable: boolean + action: UndoRedoAction +}): boolean { + const { editor, hideWhenUnavailable, action } = props + + if (!editor || !editor.isEditable) return false + + if (hideWhenUnavailable && !editor.isActive("code")) { + return canExecuteUndoRedoAction(editor, action) + } + + return true +} + +/** + * Custom hook that provides history functionality for Tiptap editor + * + * @example + * ```tsx + * // Simple usage + * function MySimpleUndoButton() { + * const { isVisible, handleAction } = useHistory({ action: "undo" }) + * + * if (!isVisible) return null + * + * return + * } + * + * // Advanced usage with configuration + * function MyAdvancedRedoButton() { + * const { isVisible, handleAction, label } = useHistory({ + * editor: myEditor, + * action: "redo", + * hideWhenUnavailable: true, + * onExecuted: () => console.log('Action executed!') + * }) + * + * if (!isVisible) return null + * + * return ( + * + * Redo + * + * ) + * } + * ``` + */ +export function useUndoRedo(config: UseUndoRedoConfig) { + const { + editor: providedEditor, + action, + hideWhenUnavailable = false, + onExecuted, + } = config + + const { editor } = useTiptapEditor(providedEditor) + const [isVisible, setIsVisible] = useState(true) + const canExecute = canExecuteUndoRedoAction(editor, action) + + useEffect(() => { + if (!editor) return + + const handleUpdate = () => { + setIsVisible(shouldShowButton({ editor, hideWhenUnavailable, action })) + } + + handleUpdate() + + editor.on("transaction", handleUpdate) + + return () => { + editor.off("transaction", handleUpdate) + } + }, [editor, hideWhenUnavailable, action]) + + const handleAction = useCallback(() => { + if (!editor) return false + + const success = executeUndoRedoAction(editor, action) + if (success) { + onExecuted?.() + } + return success + }, [editor, action, onExecuted]) + + return { + isVisible, + handleAction, + canExecute, + label: historyActionLabels[action], + shortcutKeys: UNDO_REDO_SHORTCUT_KEYS[action], + Icon: historyIcons[action], + } +} diff --git a/frontends/ol-components/src/components/TiptapEditor/hooks/use-composed-ref.ts b/frontends/ol-components/src/components/TiptapEditor/hooks/use-composed-ref.ts new file mode 100644 index 0000000000..7b6d8aa0dc --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/hooks/use-composed-ref.ts @@ -0,0 +1,47 @@ +"use client" + +import { useCallback, useRef } from "react" + +// basically Exclude["ref"], string> +type UserRef = + | ((instance: T | null) => void) + | React.RefObject + | null + | undefined + +const updateRef = (ref: NonNullable>, value: T | null) => { + if (typeof ref === "function") { + ref(value) + } else if (ref && typeof ref === "object" && "current" in ref) { + // Safe assignment without MutableRefObject + ;(ref as { current: T | null }).current = value + } +} + +export const useComposedRef = ( + libRef: React.RefObject, + userRef: UserRef, +) => { + const prevUserRef = useRef>(null) + + return useCallback( + (instance: T | null) => { + if (libRef && "current" in libRef) { + ;(libRef as { current: T | null }).current = instance + } + + if (prevUserRef.current) { + updateRef(prevUserRef.current, null) + } + + prevUserRef.current = userRef + + if (userRef) { + updateRef(userRef, instance) + } + }, + [libRef, userRef], + ) +} + +export default useComposedRef diff --git a/frontends/ol-components/src/components/TiptapEditor/hooks/use-cursor-visibility.ts b/frontends/ol-components/src/components/TiptapEditor/hooks/use-cursor-visibility.ts new file mode 100644 index 0000000000..5146bea8b1 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/hooks/use-cursor-visibility.ts @@ -0,0 +1,69 @@ +import type { Editor } from "@tiptap/react" +import { useWindowSize } from "./use-window-size" +import { useBodyRect } from "./use-element-rect" +import { useEffect } from "react" + +export interface CursorVisibilityOptions { + /** + * The Tiptap editor instance + */ + editor?: Editor | null + /** + * Reference to the toolbar element that may obscure the cursor + */ + overlayHeight?: number +} + +/** + * Custom hook that ensures the cursor remains visible when typing in a Tiptap editor. + * Automatically scrolls the window when the cursor would be hidden by the toolbar. + * + * @param options.editor The Tiptap editor instance + * @param options.overlayHeight Toolbar height to account for + * @returns The bounding rect of the body + */ +export function useCursorVisibility({ + editor, + overlayHeight = 0, +}: CursorVisibilityOptions) { + const { height: windowHeight } = useWindowSize() + const rect = useBodyRect({ + enabled: true, + throttleMs: 100, + useResizeObserver: true, + }) + + useEffect(() => { + const ensureCursorVisibility = () => { + if (!editor) return + + const { state, view } = editor + if (!view.hasFocus()) return + + // Get current cursor position coordinates + const { from } = state.selection + const cursorCoords = view.coordsAtPos(from) + + if (windowHeight < rect.height && cursorCoords) { + const availableSpace = windowHeight - cursorCoords.top + + // If the cursor is hidden behind the overlay or offscreen, scroll it into view + if (availableSpace < overlayHeight) { + const targetCursorY = Math.max(windowHeight / 2, overlayHeight) + const currentScrollY = window.scrollY + const cursorAbsoluteY = cursorCoords.top + currentScrollY + const newScrollY = cursorAbsoluteY - targetCursorY + + window.scrollTo({ + top: Math.max(0, newScrollY), + behavior: "smooth", + }) + } + } + } + + ensureCursorVisibility() + }, [editor, overlayHeight, windowHeight, rect.height]) + + return rect +} diff --git a/frontends/ol-components/src/components/TiptapEditor/hooks/use-element-rect.ts b/frontends/ol-components/src/components/TiptapEditor/hooks/use-element-rect.ts new file mode 100644 index 0000000000..eacb84ac29 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/hooks/use-element-rect.ts @@ -0,0 +1,166 @@ +"use client" + +import { useCallback, useEffect, useState } from "react" +import { useThrottledCallback } from "./use-throttled-callback" + +export type RectState = Omit + +export interface ElementRectOptions { + /** + * The element to track. Can be an Element, ref, or selector string. + * Defaults to document.body if not provided. + */ + element?: Element | React.RefObject | string | null + /** + * Whether to enable rect tracking + */ + enabled?: boolean + /** + * Throttle delay in milliseconds for rect updates + */ + throttleMs?: number + /** + * Whether to use ResizeObserver for more accurate tracking + */ + useResizeObserver?: boolean +} + +const initialRect: RectState = { + x: 0, + y: 0, + width: 0, + height: 0, + top: 0, + right: 0, + bottom: 0, + left: 0, +} + +const isSSR = typeof window === "undefined" +const hasResizeObserver = !isSSR && typeof ResizeObserver !== "undefined" + +/** + * Helper function to check if code is running on client side + */ +const isClientSide = (): boolean => !isSSR + +/** + * Custom hook that tracks an element's bounding rectangle and updates on resize, scroll, etc. + * + * @param options Configuration options for element rect tracking + * @returns The current bounding rectangle of the element + */ +export function useElementRect({ + element, + enabled = true, + throttleMs = 100, + useResizeObserver = true, +}: ElementRectOptions = {}): RectState { + const [rect, setRect] = useState(initialRect) + + const getTargetElement = useCallback((): Element | null => { + if (!enabled || !isClientSide()) return null + + if (!element) { + return document.body + } + + if (typeof element === "string") { + return document.querySelector(element) + } + + if ("current" in element) { + return element.current + } + + return element + }, [element, enabled]) + + const updateRect = useThrottledCallback( + () => { + if (!enabled || !isClientSide()) return + + const targetElement = getTargetElement() + if (!targetElement) { + setRect(initialRect) + return + } + + const newRect = targetElement.getBoundingClientRect() + setRect({ + x: newRect.x, + y: newRect.y, + width: newRect.width, + height: newRect.height, + top: newRect.top, + right: newRect.right, + bottom: newRect.bottom, + left: newRect.left, + }) + }, + throttleMs, + [enabled, getTargetElement], + { leading: true, trailing: true }, + ) + + useEffect(() => { + if (!enabled || !isClientSide()) { + setRect(initialRect) + return + } + + const targetElement = getTargetElement() + if (!targetElement) return + + updateRect() + + const cleanup: (() => void)[] = [] + + if (useResizeObserver && hasResizeObserver) { + const resizeObserver = new ResizeObserver(() => { + window.requestAnimationFrame(updateRect) + }) + resizeObserver.observe(targetElement) + cleanup.push(() => resizeObserver.disconnect()) + } + + const handleUpdate = () => updateRect() + + window.addEventListener("scroll", handleUpdate, true) + window.addEventListener("resize", handleUpdate, true) + + cleanup.push(() => { + window.removeEventListener("scroll", handleUpdate) + window.removeEventListener("resize", handleUpdate) + }) + + return () => { + cleanup.forEach((fn) => fn()) + setRect(initialRect) + } + }, [enabled, getTargetElement, updateRect, useResizeObserver]) + + return rect +} + +/** + * Convenience hook for tracking document.body rect + */ +export function useBodyRect( + options: Omit = {}, +): RectState { + return useElementRect({ + ...options, + element: isClientSide() ? document.body : null, + }) +} + +/** + * Convenience hook for tracking a ref element's rect + */ +export function useRefRect( + ref: React.RefObject, + options: Omit = {}, +): RectState { + return useElementRect({ ...options, element: ref }) +} diff --git a/frontends/ol-components/src/components/TiptapEditor/hooks/use-menu-navigation.ts b/frontends/ol-components/src/components/TiptapEditor/hooks/use-menu-navigation.ts new file mode 100644 index 0000000000..9080453885 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/hooks/use-menu-navigation.ts @@ -0,0 +1,194 @@ +import type { Editor } from "@tiptap/react" +import { useEffect, useState } from "react" + +type Orientation = "horizontal" | "vertical" | "both" + +interface MenuNavigationOptions { + /** + * The Tiptap editor instance, if using with a Tiptap editor. + */ + editor?: Editor | null + /** + * Reference to the container element for handling keyboard events. + */ + containerRef?: React.RefObject + /** + * Search query that affects the selected item. + */ + query?: string + /** + * Array of items to navigate through. + */ + items: T[] + /** + * Callback fired when an item is selected. + */ + onSelect?: (item: T) => void + /** + * Callback fired when the menu should close. + */ + onClose?: () => void + /** + * The navigation orientation of the menu. + * @default "vertical" + */ + orientation?: Orientation + /** + * Whether to automatically select the first item when the menu opens. + * @default true + */ + autoSelectFirstItem?: boolean +} + +/** + * Hook that implements keyboard navigation for dropdown menus and command palettes. + * + * Handles arrow keys, tab, home/end, enter for selection, and escape to close. + * Works with both Tiptap editors and regular DOM elements. + * + * @param options - Configuration options for the menu navigation + * @returns Object containing the selected index and a setter function + */ +export function useMenuNavigation({ + editor, + containerRef, + query, + items, + onSelect, + onClose, + orientation = "vertical", + autoSelectFirstItem = true, +}: MenuNavigationOptions) { + const [selectedIndex, setSelectedIndex] = useState( + autoSelectFirstItem ? 0 : -1, + ) + + useEffect(() => { + const handleKeyboardNavigation = (event: KeyboardEvent) => { + if (!items.length) return false + + const moveNext = () => + setSelectedIndex((currentIndex) => { + if (currentIndex === -1) return 0 + return (currentIndex + 1) % items.length + }) + + const movePrev = () => + setSelectedIndex((currentIndex) => { + if (currentIndex === -1) return items.length - 1 + return (currentIndex - 1 + items.length) % items.length + }) + + switch (event.key) { + case "ArrowUp": { + if (orientation === "horizontal") return false + event.preventDefault() + movePrev() + return true + } + + case "ArrowDown": { + if (orientation === "horizontal") return false + event.preventDefault() + moveNext() + return true + } + + case "ArrowLeft": { + if (orientation === "vertical") return false + event.preventDefault() + movePrev() + return true + } + + case "ArrowRight": { + if (orientation === "vertical") return false + event.preventDefault() + moveNext() + return true + } + + case "Tab": { + event.preventDefault() + if (event.shiftKey) { + movePrev() + } else { + moveNext() + } + return true + } + + case "Home": { + event.preventDefault() + setSelectedIndex(0) + return true + } + + case "End": { + event.preventDefault() + setSelectedIndex(items.length - 1) + return true + } + + case "Enter": { + if (event.isComposing) return false + event.preventDefault() + if (selectedIndex !== -1 && items[selectedIndex]) { + onSelect?.(items[selectedIndex]) + } + return true + } + + case "Escape": { + event.preventDefault() + onClose?.() + return true + } + + default: + return false + } + } + + let targetElement: HTMLElement | null = null + + if (editor) { + targetElement = editor.view.dom + } else if (containerRef?.current) { + targetElement = containerRef.current + } + + if (targetElement) { + targetElement.addEventListener("keydown", handleKeyboardNavigation, true) + + return () => { + targetElement?.removeEventListener( + "keydown", + handleKeyboardNavigation, + true, + ) + } + } + + return undefined + }, [ + editor, + containerRef, + items, + selectedIndex, + onSelect, + onClose, + orientation, + ]) + + useEffect(() => { + if (query) { + setSelectedIndex(autoSelectFirstItem ? 0 : -1) + } + }, [query, autoSelectFirstItem]) + + return { + selectedIndex: items.length ? selectedIndex : undefined, + setSelectedIndex, + } +} diff --git a/frontends/ol-components/src/components/TiptapEditor/hooks/use-mobile.ts b/frontends/ol-components/src/components/TiptapEditor/hooks/use-mobile.ts new file mode 100644 index 0000000000..8088e59bb9 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/hooks/use-mobile.ts @@ -0,0 +1,17 @@ +import { useEffect, useState } from "react" + +export function useIsMobile(breakpoint = 768) { + const [isMobile, setIsMobile] = useState(undefined) + + useEffect(() => { + const mql = window.matchMedia(`(max-width: ${breakpoint - 1}px)`) + const onChange = () => { + setIsMobile(window.innerWidth < breakpoint) + } + mql.addEventListener("change", onChange) + setIsMobile(window.innerWidth < breakpoint) + return () => mql.removeEventListener("change", onChange) + }, [breakpoint]) + + return !!isMobile +} diff --git a/frontends/ol-components/src/components/TiptapEditor/hooks/use-scrolling.ts b/frontends/ol-components/src/components/TiptapEditor/hooks/use-scrolling.ts new file mode 100644 index 0000000000..0c4bcfcefe --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/hooks/use-scrolling.ts @@ -0,0 +1,75 @@ +import type { RefObject } from "react" +import { useEffect, useState } from "react" + +type ScrollTarget = RefObject | Window | null | undefined +type EventTargetWithScroll = Window | HTMLElement | Document + +interface UseScrollingOptions { + debounce?: number + fallbackToDocument?: boolean +} + +export function useScrolling( + target?: ScrollTarget, + options: UseScrollingOptions = {}, +): boolean { + const { debounce = 150, fallbackToDocument = true } = options + const [isScrolling, setIsScrolling] = useState(false) + + useEffect(() => { + // Resolve element or window + const element: EventTargetWithScroll = + target && typeof Window !== "undefined" && target instanceof Window + ? target + : ((target as RefObject)?.current ?? window) + + // Mobile: fallback to document when using window + const eventTarget: EventTargetWithScroll = + fallbackToDocument && + element === window && + typeof document !== "undefined" + ? document + : element + + const on = ( + el: EventTargetWithScroll, + event: string, + handler: EventListener, + ) => el.addEventListener(event, handler, true) + + const off = ( + el: EventTargetWithScroll, + event: string, + handler: EventListener, + ) => el.removeEventListener(event, handler) + + let timeout: ReturnType + const supportsScrollEnd = element === window && "onscrollend" in window + + const handleScroll: EventListener = () => { + if (!isScrolling) setIsScrolling(true) + + if (!supportsScrollEnd) { + clearTimeout(timeout) + timeout = setTimeout(() => setIsScrolling(false), debounce) + } + } + + const handleScrollEnd: EventListener = () => setIsScrolling(false) + + on(eventTarget, "scroll", handleScroll) + if (supportsScrollEnd) { + on(eventTarget, "scrollend", handleScrollEnd) + } + + return () => { + off(eventTarget, "scroll", handleScroll) + if (supportsScrollEnd) { + off(eventTarget, "scrollend", handleScrollEnd) + } + clearTimeout(timeout) + } + }, [target, debounce, fallbackToDocument, isScrolling]) + + return isScrolling +} diff --git a/frontends/ol-components/src/components/TiptapEditor/hooks/use-throttled-callback.ts b/frontends/ol-components/src/components/TiptapEditor/hooks/use-throttled-callback.ts new file mode 100644 index 0000000000..dadabd8fdf --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/hooks/use-throttled-callback.ts @@ -0,0 +1,48 @@ +import throttle from "lodash.throttle" + +import { useUnmount } from "./use-unmount" +import { useMemo } from "react" + +interface ThrottleSettings { + leading?: boolean | undefined + trailing?: boolean | undefined +} + +const defaultOptions: ThrottleSettings = { + leading: false, + trailing: true, +} + +/** + * A hook that returns a throttled callback function. + * + * @param fn The function to throttle + * @param wait The time in ms to wait before calling the function + * @param dependencies The dependencies to watch for changes + * @param options The throttle options + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function useThrottledCallback any>( + fn: T, + wait = 250, + dependencies: React.DependencyList = [], + options: ThrottleSettings = defaultOptions, +): { + (this: ThisParameterType, ...args: Parameters): ReturnType + cancel: () => void + flush: () => void +} { + const handler = useMemo( + () => throttle(fn, wait, options), + // eslint-disable-next-line react-hooks/exhaustive-deps + dependencies, + ) + + useUnmount(() => { + handler.cancel() + }) + + return handler +} + +export default useThrottledCallback diff --git a/frontends/ol-components/src/components/TiptapEditor/hooks/use-tiptap-editor.ts b/frontends/ol-components/src/components/TiptapEditor/hooks/use-tiptap-editor.ts new file mode 100644 index 0000000000..056159dada --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/hooks/use-tiptap-editor.ts @@ -0,0 +1,47 @@ +import type { Editor } from "@tiptap/react" +import { useCurrentEditor, useEditorState } from "@tiptap/react" +import { useMemo } from "react" + +/** + * Hook that provides access to a Tiptap editor instance. + * + * Accepts an optional editor instance directly, or falls back to retrieving + * the editor from the Tiptap context if available. This allows components + * to work both when given an editor directly and when used within a Tiptap + * editor context. + * + * @param providedEditor - Optional editor instance to use instead of the context editor + * @returns The provided editor or the editor from context, whichever is available + */ +export function useTiptapEditor(providedEditor?: Editor | null): { + editor: Editor | null + editorState?: Editor["state"] + canCommand?: Editor["can"] +} { + const { editor: coreEditor } = useCurrentEditor() + const mainEditor = useMemo( + () => providedEditor || coreEditor, + [providedEditor, coreEditor], + ) + + const editorState = useEditorState({ + editor: mainEditor, + selector(context) { + if (!context.editor) { + return { + editor: null, + editorState: undefined, + canCommand: undefined, + } + } + + return { + editor: context.editor, + editorState: context.editor.state, + canCommand: context.editor.can, + } + }, + }) + + return editorState || { editor: null } +} diff --git a/frontends/ol-components/src/components/TiptapEditor/hooks/use-unmount.ts b/frontends/ol-components/src/components/TiptapEditor/hooks/use-unmount.ts new file mode 100644 index 0000000000..bd229255e9 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/hooks/use-unmount.ts @@ -0,0 +1,21 @@ +import { useRef, useEffect } from "react" + +/** + * Hook that executes a callback when the component unmounts. + * + * @param callback Function to be called on component unmount + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const useUnmount = (callback: (...args: Array) => any) => { + const ref = useRef(callback) + ref.current = callback + + useEffect( + () => () => { + ref.current() + }, + [], + ) +} + +export default useUnmount diff --git a/frontends/ol-components/src/components/TiptapEditor/hooks/use-window-size.ts b/frontends/ol-components/src/components/TiptapEditor/hooks/use-window-size.ts new file mode 100644 index 0000000000..6df968b353 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/hooks/use-window-size.ts @@ -0,0 +1,93 @@ +"use client" + +import { useEffect, useState } from "react" +import { useThrottledCallback } from "./use-throttled-callback" + +export interface WindowSizeState { + /** + * The width of the window's visual viewport in pixels. + */ + width: number + /** + * The height of the window's visual viewport in pixels. + */ + height: number + /** + * The distance from the top of the visual viewport to the top of the layout viewport. + * Particularly useful for handling mobile keyboard appearance. + */ + offsetTop: number + /** + * The distance from the left of the visual viewport to the left of the layout viewport. + */ + offsetLeft: number + /** + * The scale factor of the visual viewport. + * This is useful for scaling elements based on the current zoom level. + */ + scale: number +} + +/** + * Hook that tracks the window's visual viewport dimensions, position, and provides + * a CSS transform for positioning elements. + * + * Uses the Visual Viewport API to get accurate measurements, especially important + * for mobile devices where virtual keyboards can change the visible area. + * Only updates state when values actually change to optimize performance. + * + * @returns An object containing viewport properties and a CSS transform string + */ +export function useWindowSize(): WindowSizeState { + const [windowSize, setWindowSize] = useState({ + width: 0, + height: 0, + offsetTop: 0, + offsetLeft: 0, + scale: 0, + }) + + const handleViewportChange = useThrottledCallback(() => { + if (typeof window === "undefined") return + + const vp = window.visualViewport + if (!vp) return + + const { + width = 0, + height = 0, + offsetTop = 0, + offsetLeft = 0, + scale = 0, + } = vp + + setWindowSize((prevState) => { + if ( + width === prevState.width && + height === prevState.height && + offsetTop === prevState.offsetTop && + offsetLeft === prevState.offsetLeft && + scale === prevState.scale + ) { + return prevState + } + + return { width, height, offsetTop, offsetLeft, scale } + }) + }, 200) + + useEffect(() => { + const visualViewport = window.visualViewport + if (!visualViewport) return + + visualViewport.addEventListener("resize", handleViewportChange) + + handleViewportChange() + + return () => { + visualViewport.removeEventListener("resize", handleViewportChange) + } + }, [handleViewportChange]) + + return windowSize +} diff --git a/frontends/ol-components/src/components/TiptapEditor/lib/tiptap-utils.ts b/frontends/ol-components/src/components/TiptapEditor/lib/tiptap-utils.ts new file mode 100644 index 0000000000..2c0f1bb813 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/lib/tiptap-utils.ts @@ -0,0 +1,555 @@ +import type { Node as TiptapNode } from "@tiptap/pm/model" +import type { Transaction } from "@tiptap/pm/state" +import { + AllSelection, + NodeSelection, + Selection, + TextSelection, +} from "@tiptap/pm/state" +import type { Editor, NodeWithPos } from "@tiptap/react" + +export const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB + +export const MAC_SYMBOLS: Record = { + mod: "⌘", + command: "⌘", + meta: "⌘", + ctrl: "⌃", + control: "⌃", + alt: "⌥", + option: "⌥", + shift: "⇧", + backspace: "Del", + delete: "⌦", + enter: "⏎", + escape: "⎋", + capslock: "⇪", +} as const + +export const SR_ONLY = { + position: "absolute", + width: "1px", + height: "1px", + padding: 0, + margin: "-1px", + overflow: "hidden", + clip: "rect(0, 0, 0, 0)", + whiteSpace: "nowrap", + borderWidth: 0, +} as const + +export function cn( + ...classes: (string | boolean | undefined | null)[] +): string { + return classes.filter(Boolean).join(" ") +} + +/** + * Determines if the current platform is macOS + * @returns boolean indicating if the current platform is Mac + */ +export function isMac(): boolean { + return ( + typeof navigator !== "undefined" && + navigator.platform.toLowerCase().includes("mac") + ) +} + +/** + * Formats a shortcut key based on the platform (Mac or non-Mac) + * @param key - The key to format (e.g., "ctrl", "alt", "shift") + * @param isMac - Boolean indicating if the platform is Mac + * @param capitalize - Whether to capitalize the key (default: true) + * @returns Formatted shortcut key symbol + */ +export const formatShortcutKey = ( + key: string, + isMac: boolean, + capitalize: boolean = true, +) => { + if (isMac) { + const lowerKey = key.toLowerCase() + return MAC_SYMBOLS[lowerKey] || (capitalize ? key.toUpperCase() : key) + } + + return capitalize ? key.charAt(0).toUpperCase() + key.slice(1) : key +} + +/** + * Parses a shortcut key string into an array of formatted key symbols + * @param shortcutKeys - The string of shortcut keys (e.g., "ctrl-alt-shift") + * @param delimiter - The delimiter used to split the keys (default: "-") + * @param capitalize - Whether to capitalize the keys (default: true) + * @returns Array of formatted shortcut key symbols + */ +export const parseShortcutKeys = (props: { + shortcutKeys: string | undefined + delimiter?: string + capitalize?: boolean +}) => { + const { shortcutKeys, delimiter = "+", capitalize = true } = props + + if (!shortcutKeys) return [] + + return shortcutKeys + .split(delimiter) + .map((key) => key.trim()) + .map((key) => formatShortcutKey(key, isMac(), capitalize)) +} + +/** + * Checks if a mark exists in the editor schema + * @param markName - The name of the mark to check + * @param editor - The editor instance + * @returns boolean indicating if the mark exists in the schema + */ +export const isMarkInSchema = ( + markName: string, + editor: Editor | null, +): boolean => { + if (!editor?.schema) return false + return editor.schema.spec.marks.get(markName) !== undefined +} + +/** + * Checks if a node exists in the editor schema + * @param nodeName - The name of the node to check + * @param editor - The editor instance + * @returns boolean indicating if the node exists in the schema + */ +export const isNodeInSchema = ( + nodeName: string, + editor: Editor | null, +): boolean => { + if (!editor?.schema) return false + return editor.schema.spec.nodes.get(nodeName) !== undefined +} + +/** + * Moves the focus to the next node in the editor + * @param editor - The editor instance + * @returns boolean indicating if the focus was moved + */ +export function focusNextNode(editor: Editor) { + const { state, view } = editor + const { doc, selection } = state + + const nextSel = Selection.findFrom(selection.$to, 1, true) + if (nextSel) { + view.dispatch(state.tr.setSelection(nextSel).scrollIntoView()) + return true + } + + const paragraphType = state.schema.nodes.paragraph + if (!paragraphType) { + console.warn("No paragraph node type found in schema.") + return false + } + + const end = doc.content.size + const para = paragraphType.create() + let tr = state.tr.insert(end, para) + + // Place the selection inside the new paragraph + const $inside = tr.doc.resolve(end + 1) + tr = tr.setSelection(TextSelection.near($inside)).scrollIntoView() + view.dispatch(tr) + return true +} + +/** + * Checks if a value is a valid number (not null, undefined, or NaN) + * @param value - The value to check + * @returns boolean indicating if the value is a valid number + */ +export function isValidPosition(pos: number | null | undefined): pos is number { + return typeof pos === "number" && pos >= 0 +} + +/** + * Checks if one or more extensions are registered in the Tiptap editor. + * @param editor - The Tiptap editor instance + * @param extensionNames - A single extension name or an array of names to check + * @returns True if at least one of the extensions is available, false otherwise + */ +export function isExtensionAvailable( + editor: Editor | null, + extensionNames: string | string[], +): boolean { + if (!editor) return false + + const names = Array.isArray(extensionNames) + ? extensionNames + : [extensionNames] + + const found = names.some((name) => + editor.extensionManager.extensions.some((ext) => ext.name === name), + ) + + if (!found) { + console.warn( + `None of the extensions [${names.join(", ")}] were found in the editor schema. Ensure they are included in the editor configuration.`, + ) + } + + return found +} + +/** + * Finds a node at the specified position with error handling + * @param editor The Tiptap editor instance + * @param position The position in the document to find the node + * @returns The node at the specified position, or null if not found + */ +export function findNodeAtPosition(editor: Editor, position: number) { + try { + const node = editor.state.doc.nodeAt(position) + if (!node) { + console.warn(`No node found at position ${position}`) + return null + } + return node + } catch (error) { + console.error(`Error getting node at position ${position}:`, error) + return null + } +} + +/** + * Finds the position and instance of a node in the document + * @param props Object containing editor, node (optional), and nodePos (optional) + * @param props.editor The Tiptap editor instance + * @param props.node The node to find (optional if nodePos is provided) + * @param props.nodePos The position of the node to find (optional if node is provided) + * @returns An object with the position and node, or null if not found + */ +export function findNodePosition(props: { + editor: Editor | null + node?: TiptapNode | null + nodePos?: number | null +}): { pos: number; node: TiptapNode } | null { + const { editor, node, nodePos } = props + + if (!editor || !editor.state?.doc) return null + + // Zero is valid position + const hasValidNode = node !== undefined && node !== null + const hasValidPos = isValidPosition(nodePos) + + if (!hasValidNode && !hasValidPos) { + return null + } + + // First search for the node in the document if we have a node + if (hasValidNode) { + let foundPos = -1 + let foundNode: TiptapNode | null = null + + editor.state.doc.descendants((currentNode, pos) => { + // TODO: Needed? + // if (currentNode.type && currentNode.type.name === node!.type.name) { + if (currentNode === node) { + foundPos = pos + foundNode = currentNode + return false + } + return true + }) + + if (foundPos !== -1 && foundNode !== null) { + return { pos: foundPos, node: foundNode } + } + } + + // If we have a valid position, use findNodeAtPosition + if (hasValidPos) { + const nodeAtPos = findNodeAtPosition(editor, nodePos!) + if (nodeAtPos) { + return { pos: nodePos!, node: nodeAtPos } + } + } + + return null +} + +/** + * Determines whether the current selection contains a node whose type matches + * any of the provided node type names. + * @param editor Tiptap editor instance + * @param nodeTypeNames List of node type names to match against + * @param checkAncestorNodes Whether to check ancestor node types up the depth chain + */ +export function isNodeTypeSelected( + editor: Editor | null, + nodeTypeNames: string[] = [], + checkAncestorNodes: boolean = false, +): boolean { + if (!editor || !editor.state.selection) return false + + const { selection } = editor.state + if (selection.empty) return false + + // Direct node selection check + if (selection instanceof NodeSelection) { + const selectedNode = selection.node + return selectedNode ? nodeTypeNames.includes(selectedNode.type.name) : false + } + + // Depth-based ancestor node check + if (checkAncestorNodes) { + const { $from } = selection + for (let depth = $from.depth; depth > 0; depth--) { + const ancestorNode = $from.node(depth) + if (nodeTypeNames.includes(ancestorNode.type.name)) { + return true + } + } + } + + return false +} + +/** + * Check whether the current selection is fully within nodes + * whose type names are in the provided `types` list. + * + * - NodeSelection → checks the selected node. + * - Text/AllSelection → ensures all textblocks within [from, to) are allowed. + */ +export function selectionWithinConvertibleTypes( + editor: Editor, + types: string[] = [], +): boolean { + if (!editor || types.length === 0) return false + + const { state } = editor + const { selection } = state + const allowed = new Set(types) + + if (selection instanceof NodeSelection) { + const nodeType = selection.node?.type?.name + return !!nodeType && allowed.has(nodeType) + } + + if (selection instanceof TextSelection || selection instanceof AllSelection) { + let valid = true + state.doc.nodesBetween(selection.from, selection.to, (node) => { + if (node.isTextblock && !allowed.has(node.type.name)) { + valid = false + return false // stop early + } + return valid + }) + return valid + } + + return false +} + +/** + * Handles image upload with progress tracking and abort capability + * @param file The file to upload + * @param onProgress Optional callback for tracking upload progress + * @param abortSignal Optional AbortSignal for cancelling the upload + * @returns Promise resolving to the URL of the uploaded image + */ +export const handleImageUpload = async ( + file: File, + onProgress?: (event: { progress: number }) => void, + abortSignal?: AbortSignal, +): Promise => { + // Validate file + if (!file) { + throw new Error("No file provided") + } + + if (file.size > MAX_FILE_SIZE) { + throw new Error( + `File size exceeds maximum allowed (${MAX_FILE_SIZE / (1024 * 1024)}MB)`, + ) + } + + // For demo/testing: Simulate upload progress. In production, replace the following code + // with your own upload implementation. + for (let progress = 0; progress <= 100; progress += 10) { + if (abortSignal?.aborted) { + throw new Error("Upload cancelled") + } + await new Promise((resolve) => setTimeout(resolve, 500)) + onProgress?.({ progress }) + } + + return "/images/tiptap-ui-placeholder-image.jpg" +} + +type ProtocolOptions = { + /** + * The protocol scheme to be registered. + * @default ''' + * @example 'ftp' + * @example 'git' + */ + scheme: string + + /** + * If enabled, it allows optional slashes after the protocol. + * @default false + * @example true + */ + optionalSlashes?: boolean +} + +type ProtocolConfig = Array + +const ATTR_WHITESPACE = + // eslint-disable-next-line no-control-regex + /[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g + +export function isAllowedUri( + uri: string | undefined, + protocols?: ProtocolConfig, +) { + const allowedProtocols: string[] = [ + "http", + "https", + "ftp", + "ftps", + "mailto", + "tel", + "callto", + "sms", + "cid", + "xmpp", + ] + + if (protocols) { + protocols.forEach((protocol) => { + const nextProtocol = + typeof protocol === "string" ? protocol : protocol.scheme + + if (nextProtocol) { + allowedProtocols.push(nextProtocol) + } + }) + } + + return ( + !uri || + uri + .replace(ATTR_WHITESPACE, "") + .match( + new RegExp( + `^(?:(?:${allowedProtocols.join("|")}):|[^a-z]|[a-z0-9+.-]+(?:[^a-z+.:-]|$))`, + "i", + ), + ) + ) +} + +export function sanitizeUrl( + inputUrl: string, + baseUrl: string, + protocols?: ProtocolConfig, +): string { + try { + const url = new URL(inputUrl, baseUrl) + + if (isAllowedUri(url.href, protocols)) { + return url.href + } + } catch { + // If URL creation fails, it's considered invalid + } + return "#" +} + +/** + * Update a single attribute on multiple nodes. + * + * @param tr - The transaction to mutate + * @param targets - Array of { node, pos } + * @param attrName - Attribute key to update + * @param next - New value OR updater function receiving previous value + * Pass `undefined` to remove the attribute. + * @returns true if at least one node was updated, false otherwise + */ +export function updateNodesAttr( + tr: Transaction, + targets: readonly NodeWithPos[], + attrName: A, + next: V | ((prev: V | undefined) => V | undefined), +): boolean { + if (!targets.length) return false + + let changed = false + + for (const { pos } of targets) { + // Always re-read from the transaction's current doc + const currentNode = tr.doc.nodeAt(pos) + if (!currentNode) continue + + const prevValue = (currentNode.attrs as Record)[ + attrName + ] as V | undefined + const resolvedNext = + typeof next === "function" + ? (next as (p: V | undefined) => V | undefined)(prevValue) + : next + + if (prevValue === resolvedNext) continue + + const nextAttrs: Record = { ...currentNode.attrs } + if (resolvedNext === undefined) { + // Remove the key entirely instead of setting null + delete nextAttrs[attrName] + } else { + nextAttrs[attrName] = resolvedNext + } + + tr.setNodeMarkup(pos, undefined, nextAttrs) + changed = true + } + + return changed +} + +/** + * Selects the entire content of the current block node if the selection is empty. + * If the selection is not empty, it does nothing. + * @param editor The Tiptap editor instance + */ +export function selectCurrentBlockContent(editor: Editor) { + const { selection, doc } = editor.state + + if (!selection.empty) return + + const $pos = selection.$from + let blockNode = null + let blockPos = -1 + + for (let depth = $pos.depth; depth >= 0; depth--) { + const node = $pos.node(depth) + const pos = $pos.start(depth) + + if (node.isBlock && node.textContent.trim()) { + blockNode = node + blockPos = pos + break + } + } + + if (blockNode && blockPos >= 0) { + const from = blockPos + const to = blockPos + blockNode.nodeSize - 2 // -2 to exclude the closing tag + + if (from < to) { + const $from = doc.resolve(from) + const $to = doc.resolve(to) + const newSelection = TextSelection.between($from, $to, 1) + + if (newSelection && !selection.eq(newSelection)) { + editor.view.dispatch(editor.state.tr.setSelection(newSelection)) + } + } + } +} diff --git a/frontends/ol-components/src/components/TiptapEditor/styles/_keyframe-animations.scss b/frontends/ol-components/src/components/TiptapEditor/styles/_keyframe-animations.scss new file mode 100644 index 0000000000..dd98b7cbc6 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/styles/_keyframe-animations.scss @@ -0,0 +1,91 @@ +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes fadeOut { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +@keyframes zoomIn { + from { + transform: scale(0.95); + } + to { + transform: scale(1); + } +} + +@keyframes zoomOut { + from { + transform: scale(1); + } + to { + transform: scale(0.95); + } +} + +@keyframes zoom { + 0% { + opacity: 0; + transform: scale(0.95); + } + 100% { + opacity: 1; + transform: scale(1); + } +} + +@keyframes slideFromTop { + from { + transform: translateY(-0.5rem); + } + to { + transform: translateY(0); + } +} + +@keyframes slideFromRight { + from { + transform: translateX(0.5rem); + } + to { + transform: translateX(0); + } +} + +@keyframes slideFromLeft { + from { + transform: translateX(-0.5rem); + } + to { + transform: translateX(0); + } +} + +@keyframes slideFromBottom { + from { + transform: translateY(0.5rem); + } + to { + transform: translateY(0); + } +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} diff --git a/frontends/ol-components/src/components/TiptapEditor/styles/_variables.scss b/frontends/ol-components/src/components/TiptapEditor/styles/_variables.scss new file mode 100644 index 0000000000..aaf40caa36 --- /dev/null +++ b/frontends/ol-components/src/components/TiptapEditor/styles/_variables.scss @@ -0,0 +1,295 @@ +:root { + /****************** + Basics + ******************/ + + overflow-wrap: break-word; + text-size-adjust: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + + /****************** + Colors variables + ******************/ + + /* Gray alpha (light mode) */ + --tt-gray-light-a-50: rgba(56, 56, 56, 0.04); + --tt-gray-light-a-100: rgba(15, 22, 36, 0.05); + --tt-gray-light-a-200: rgba(37, 39, 45, 0.1); + --tt-gray-light-a-300: rgba(47, 50, 55, 0.2); + --tt-gray-light-a-400: rgba(40, 44, 51, 0.42); + --tt-gray-light-a-500: rgba(52, 55, 60, 0.64); + --tt-gray-light-a-600: rgba(36, 39, 46, 0.78); + --tt-gray-light-a-700: rgba(35, 37, 42, 0.87); + --tt-gray-light-a-800: rgba(30, 32, 36, 0.95); + --tt-gray-light-a-900: rgba(29, 30, 32, 0.98); + + /* Gray (light mode) */ + --tt-gray-light-50: rgba(250, 250, 250, 1); + --tt-gray-light-100: rgba(244, 244, 245, 1); + --tt-gray-light-200: rgba(234, 234, 235, 1); + --tt-gray-light-300: rgba(213, 214, 215, 1); + --tt-gray-light-400: rgba(166, 167, 171, 1); + --tt-gray-light-500: rgba(125, 127, 130, 1); + --tt-gray-light-600: rgba(83, 86, 90, 1); + --tt-gray-light-700: rgba(64, 65, 69, 1); + --tt-gray-light-800: rgba(44, 45, 48, 1); + --tt-gray-light-900: rgba(34, 35, 37, 1); + + /* Gray alpha (dark mode) */ + --tt-gray-dark-a-50: rgba(232, 232, 253, 0.05); + --tt-gray-dark-a-100: rgba(231, 231, 243, 0.07); + --tt-gray-dark-a-200: rgba(238, 238, 246, 0.11); + --tt-gray-dark-a-300: rgba(239, 239, 245, 0.22); + --tt-gray-dark-a-400: rgba(244, 244, 255, 0.37); + --tt-gray-dark-a-500: rgba(236, 238, 253, 0.5); + --tt-gray-dark-a-600: rgba(247, 247, 253, 0.64); + --tt-gray-dark-a-700: rgba(251, 251, 254, 0.75); + --tt-gray-dark-a-800: rgba(253, 253, 253, 0.88); + --tt-gray-dark-a-900: rgba(255, 255, 255, 0.96); + + /* Gray (dark mode) */ + --tt-gray-dark-50: rgba(25, 25, 26, 1); + --tt-gray-dark-100: rgba(32, 32, 34, 1); + --tt-gray-dark-200: rgba(45, 45, 47, 1); + --tt-gray-dark-300: rgba(70, 70, 73, 1); + --tt-gray-dark-400: rgba(99, 99, 105, 1); + --tt-gray-dark-500: rgba(124, 124, 131, 1); + --tt-gray-dark-600: rgba(163, 163, 168, 1); + --tt-gray-dark-700: rgba(192, 192, 195, 1); + --tt-gray-dark-800: rgba(224, 224, 225, 1); + --tt-gray-dark-900: rgba(245, 245, 245, 1); + + /* Brand colors */ + --tt-brand-color-50: rgba(239, 238, 255, 1); + --tt-brand-color-100: rgba(222, 219, 255, 1); + --tt-brand-color-200: rgba(195, 189, 255, 1); + --tt-brand-color-300: rgba(157, 138, 255, 1); + --tt-brand-color-400: rgba(122, 82, 255, 1); + --tt-brand-color-500: rgba(98, 41, 255, 1); + --tt-brand-color-600: rgba(84, 0, 229, 1); + --tt-brand-color-700: rgba(75, 0, 204, 1); + --tt-brand-color-800: rgba(56, 0, 153, 1); + --tt-brand-color-900: rgba(43, 25, 102, 1); + --tt-brand-color-950: hsla(257, 100%, 9%, 1); + + /* Green */ + --tt-color-green-inc-5: hsla(129, 100%, 97%, 1); + --tt-color-green-inc-4: hsla(129, 100%, 92%, 1); + --tt-color-green-inc-3: hsla(131, 100%, 86%, 1); + --tt-color-green-inc-2: hsla(133, 98%, 78%, 1); + --tt-color-green-inc-1: hsla(137, 99%, 70%, 1); + --tt-color-green-base: hsla(147, 99%, 50%, 1); + --tt-color-green-dec-1: hsla(147, 97%, 41%, 1); + --tt-color-green-dec-2: hsla(146, 98%, 32%, 1); + --tt-color-green-dec-3: hsla(146, 100%, 24%, 1); + --tt-color-green-dec-4: hsla(144, 100%, 16%, 1); + --tt-color-green-dec-5: hsla(140, 100%, 9%, 1); + + /* Yellow */ + --tt-color-yellow-inc-5: hsla(50, 100%, 97%, 1); + --tt-color-yellow-inc-4: hsla(50, 100%, 91%, 1); + --tt-color-yellow-inc-3: hsla(50, 100%, 84%, 1); + --tt-color-yellow-inc-2: hsla(50, 100%, 77%, 1); + --tt-color-yellow-inc-1: hsla(50, 100%, 68%, 1); + --tt-color-yellow-base: hsla(52, 100%, 50%, 1); + --tt-color-yellow-dec-1: hsla(52, 100%, 41%, 1); + --tt-color-yellow-dec-2: hsla(52, 100%, 32%, 1); + --tt-color-yellow-dec-3: hsla(52, 100%, 24%, 1); + --tt-color-yellow-dec-4: hsla(51, 100%, 16%, 1); + --tt-color-yellow-dec-5: hsla(50, 100%, 9%, 1); + + /* Red */ + --tt-color-red-inc-5: hsla(11, 100%, 96%, 1); + --tt-color-red-inc-4: hsla(11, 100%, 88%, 1); + --tt-color-red-inc-3: hsla(10, 100%, 80%, 1); + --tt-color-red-inc-2: hsla(9, 100%, 73%, 1); + --tt-color-red-inc-1: hsla(7, 100%, 64%, 1); + --tt-color-red-base: hsla(7, 100%, 54%, 1); + --tt-color-red-dec-1: hsla(7, 100%, 41%, 1); + --tt-color-red-dec-2: hsla(5, 100%, 32%, 1); + --tt-color-red-dec-3: hsla(4, 100%, 24%, 1); + --tt-color-red-dec-4: hsla(3, 100%, 16%, 1); + --tt-color-red-dec-5: hsla(1, 100%, 9%, 1); + + /* Basic colors */ + --white: rgba(255, 255, 255, 1); + --black: rgba(14, 14, 17, 1); + --transparent: rgba(255, 255, 255, 0); + + /****************** + Shadow variables + ******************/ + + /* Shadows Light */ + --tt-shadow-elevated-md: 0px 16px 48px 0px rgba(17, 24, 39, 0.04), + 0px 12px 24px 0px rgba(17, 24, 39, 0.04), + 0px 6px 8px 0px rgba(17, 24, 39, 0.02), + 0px 2px 3px 0px rgba(17, 24, 39, 0.02); + + /************************************************** + Radius variables + **************************************************/ + + --tt-radius-xxs: 0.125rem; /* 2px */ + --tt-radius-xs: 0.25rem; /* 4px */ + --tt-radius-sm: 0.375rem; /* 6px */ + --tt-radius-md: 0.5rem; /* 8px */ + --tt-radius-lg: 0.75rem; /* 12px */ + --tt-radius-xl: 1rem; /* 16px */ + + /************************************************** + Transition variables + **************************************************/ + + --tt-transition-duration-short: 0.1s; + --tt-transition-duration-default: 0.2s; + --tt-transition-duration-long: 0.64s; + --tt-transition-easing-default: cubic-bezier(0.46, 0.03, 0.52, 0.96); + --tt-transition-easing-cubic: cubic-bezier(0.65, 0.05, 0.36, 1); + --tt-transition-easing-quart: cubic-bezier(0.77, 0, 0.18, 1); + --tt-transition-easing-circ: cubic-bezier(0.79, 0.14, 0.15, 0.86); + --tt-transition-easing-back: cubic-bezier(0.68, -0.55, 0.27, 1.55); + + /****************** + Contrast variables + ******************/ + + --tt-accent-contrast: 8%; + --tt-destructive-contrast: 8%; + --tt-foreground-contrast: 8%; + + &, + *, + ::before, + ::after { + box-sizing: border-box; + transition: none var(--tt-transition-duration-default) + var(--tt-transition-easing-default); + } +} + +:root { + /************************************************** + Global colors + **************************************************/ + + /* Global colors - Light mode */ + --tt-bg-color: var(--white); + --tt-border-color: var(--tt-gray-light-a-200); + --tt-border-color-tint: var(--tt-gray-light-a-100); + --tt-sidebar-bg-color: var(--tt-gray-light-100); + --tt-scrollbar-color: var(--tt-gray-light-a-200); + --tt-cursor-color: var(--tt-brand-color-500); + --tt-selection-color: rgba(157, 138, 255, 0.2); + --tt-card-bg-color: var(--white); + --tt-card-border-color: var(--tt-gray-light-a-100); +} + +/* Global colors - Dark mode */ +.dark { + --tt-bg-color: var(--black); + --tt-border-color: var(--tt-gray-dark-a-200); + --tt-border-color-tint: var(--tt-gray-dark-a-100); + --tt-sidebar-bg-color: var(--tt-gray-dark-100); + --tt-scrollbar-color: var(--tt-gray-dark-a-200); + --tt-cursor-color: var(--tt-brand-color-400); + --tt-selection-color: rgba(122, 82, 255, 0.2); + --tt-card-bg-color: var(--tt-gray-dark-50); + --tt-card-border-color: var(--tt-gray-dark-a-50); + + --tt-shadow-elevated-md: 0px 16px 48px 0px rgba(0, 0, 0, 0.5), + 0px 12px 24px 0px rgba(0, 0, 0, 0.24), 0px 6px 8px 0px rgba(0, 0, 0, 0.22), + 0px 2px 3px 0px rgba(0, 0, 0, 0.12); +} + +/* Text colors */ +:root { + --tt-color-text-gray: hsl(45, 2%, 46%); + --tt-color-text-brown: hsl(19, 31%, 47%); + --tt-color-text-orange: hsl(30, 89%, 45%); + --tt-color-text-yellow: hsl(38, 62%, 49%); + --tt-color-text-green: hsl(148, 32%, 39%); + --tt-color-text-blue: hsl(202, 54%, 43%); + --tt-color-text-purple: hsl(274, 32%, 54%); + --tt-color-text-pink: hsl(328, 49%, 53%); + --tt-color-text-red: hsl(2, 62%, 55%); + + --tt-color-text-gray-contrast: hsla(39, 26%, 26%, 0.15); + --tt-color-text-brown-contrast: hsla(18, 43%, 69%, 0.35); + --tt-color-text-orange-contrast: hsla(24, 73%, 55%, 0.27); + --tt-color-text-yellow-contrast: hsla(44, 82%, 59%, 0.39); + --tt-color-text-green-contrast: hsla(126, 29%, 60%, 0.27); + --tt-color-text-blue-contrast: hsla(202, 54%, 59%, 0.27); + --tt-color-text-purple-contrast: hsla(274, 37%, 64%, 0.27); + --tt-color-text-pink-contrast: hsla(331, 60%, 71%, 0.27); + --tt-color-text-red-contrast: hsla(8, 79%, 79%, 0.4); +} + +.dark { + --tt-color-text-gray: hsl(0, 0%, 61%); + --tt-color-text-brown: hsl(18, 35%, 58%); + --tt-color-text-orange: hsl(25, 53%, 53%); + --tt-color-text-yellow: hsl(36, 54%, 55%); + --tt-color-text-green: hsl(145, 32%, 47%); + --tt-color-text-blue: hsl(202, 64%, 52%); + --tt-color-text-purple: hsl(270, 55%, 62%); + --tt-color-text-pink: hsl(329, 57%, 58%); + --tt-color-text-red: hsl(1, 69%, 60%); + + --tt-color-text-gray-contrast: hsla(0, 0%, 100%, 0.09); + --tt-color-text-brown-contrast: hsla(17, 45%, 50%, 0.25); + --tt-color-text-orange-contrast: hsla(27, 82%, 53%, 0.2); + --tt-color-text-yellow-contrast: hsla(35, 49%, 47%, 0.2); + --tt-color-text-green-contrast: hsla(151, 55%, 39%, 0.2); + --tt-color-text-blue-contrast: hsla(202, 54%, 43%, 0.2); + --tt-color-text-purple-contrast: hsla(271, 56%, 60%, 0.18); + --tt-color-text-pink-contrast: hsla(331, 67%, 58%, 0.22); + --tt-color-text-red-contrast: hsla(0, 67%, 60%, 0.25); +} + +/* Highlight colors */ +:root { + --tt-color-highlight-yellow: #fef9c3; + --tt-color-highlight-green: #dcfce7; + --tt-color-highlight-blue: #e0f2fe; + --tt-color-highlight-purple: #f3e8ff; + --tt-color-highlight-red: #ffe4e6; + --tt-color-highlight-gray: rgb(248, 248, 247); + --tt-color-highlight-brown: rgb(244, 238, 238); + --tt-color-highlight-orange: rgb(251, 236, 221); + --tt-color-highlight-pink: rgb(252, 241, 246); + + --tt-color-highlight-yellow-contrast: #fbe604; + --tt-color-highlight-green-contrast: #c7fad8; + --tt-color-highlight-blue-contrast: #ceeafd; + --tt-color-highlight-purple-contrast: #e4ccff; + --tt-color-highlight-red-contrast: #ffccd0; + --tt-color-highlight-gray-contrast: rgba(84, 72, 49, 0.15); + --tt-color-highlight-brown-contrast: rgba(210, 162, 141, 0.35); + --tt-color-highlight-orange-contrast: rgba(224, 124, 57, 0.27); + --tt-color-highlight-pink-contrast: rgba(225, 136, 179, 0.27); +} + +.dark { + --tt-color-highlight-yellow: #6b6524; + --tt-color-highlight-green: #509568; + --tt-color-highlight-blue: #6e92aa; + --tt-color-highlight-purple: #583e74; + --tt-color-highlight-red: #743e42; + --tt-color-highlight-gray: rgb(47, 47, 47); + --tt-color-highlight-brown: rgb(74, 50, 40); + --tt-color-highlight-orange: rgb(92, 59, 35); + --tt-color-highlight-pink: rgb(78, 44, 60); + + --tt-color-highlight-yellow-contrast: #58531e; + --tt-color-highlight-green-contrast: #47855d; + --tt-color-highlight-blue-contrast: #5e86a1; + --tt-color-highlight-purple-contrast: #4c3564; + --tt-color-highlight-red-contrast: #643539; + --tt-color-highlight-gray-contrast: rgba(255, 255, 255, 0.094); + --tt-color-highlight-brown-contrast: rgba(184, 101, 69, 0.25); + --tt-color-highlight-orange-contrast: rgba(233, 126, 37, 0.2); + --tt-color-highlight-pink-contrast: rgba(220, 76, 145, 0.22); +} diff --git a/frontends/ol-components/src/index.ts b/frontends/ol-components/src/index.ts index fddd94de71..a04bded224 100644 --- a/frontends/ol-components/src/index.ts +++ b/frontends/ol-components/src/index.ts @@ -171,6 +171,8 @@ export * from "./components/ThemeProvider/MITLearnGlobalStyles" export { AppRouterCacheProvider as NextJsAppRouterCacheProvider } from "@mui/material-nextjs/v15-appRouter" +export { default as TiptapEditor } from "./components/TiptapEditor/TiptapEditor" + // /** // * @deprecated Please use component from @mitodl/smoot-design instead // */ diff --git a/frontends/ol-components/tsconfig.json b/frontends/ol-components/tsconfig.json index 746eeeb926..d319d7d722 100644 --- a/frontends/ol-components/tsconfig.json +++ b/frontends/ol-components/tsconfig.json @@ -4,5 +4,6 @@ "outDir": "./build", "rootDir": "./src" }, - "include": ["./src/**/*.ts", "./src/**/*.tsx"] + "include": ["./src/**/*.ts", "./src/**/*.tsx"], + "aliases": {} } diff --git a/yarn.lock b/yarn.lock index 889357270f..56d8d1401f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1526,6 +1526,13 @@ __metadata: languageName: node linkType: hard +"@bufbuild/protobuf@npm:^2.5.0": + version: 2.10.0 + resolution: "@bufbuild/protobuf@npm:2.10.0" + checksum: 10/58a213899a34d6a742da6ce6f7405583b390a23243287799d869afb21a7a7778475529b50f5bdbf2acbf50cce740dba1292aa7b548c576514f8b55e139ca90c4 + languageName: node + linkType: hard + "@chromatic-com/storybook@npm:^3.2.7": version: 3.2.7 resolution: "@chromatic-com/storybook@npm:3.2.7" @@ -2117,6 +2124,15 @@ __metadata: languageName: node linkType: hard +"@floating-ui/core@npm:^1.7.3": + version: 1.7.3 + resolution: "@floating-ui/core@npm:1.7.3" + dependencies: + "@floating-ui/utils": "npm:^0.2.10" + checksum: 10/a8952ff2673ddf28f12feeb86d90c54949e45bcb1af5758b7672850ac0dadb36d4bd61aa45dad1b6a35ba40d4756d3573afac6610b90502639d7266b91e0864e + languageName: node + linkType: hard + "@floating-ui/dom@npm:^1.0.0, @floating-ui/dom@npm:^1.0.1": version: 1.6.11 resolution: "@floating-ui/dom@npm:1.6.11" @@ -2127,6 +2143,28 @@ __metadata: languageName: node linkType: hard +"@floating-ui/dom@npm:^1.7.4": + version: 1.7.4 + resolution: "@floating-ui/dom@npm:1.7.4" + dependencies: + "@floating-ui/core": "npm:^1.7.3" + "@floating-ui/utils": "npm:^0.2.10" + checksum: 10/d3d6a23e7b9804ba56338c7c666590258683af14b6026270d32afc1202f72b5b82cca359004bdc7830bf2463a045da6c7bd4e7d5351218cf270ff94206197971 + languageName: node + linkType: hard + +"@floating-ui/react-dom@npm:^2.0.0, @floating-ui/react-dom@npm:^2.1.6": + version: 2.1.6 + resolution: "@floating-ui/react-dom@npm:2.1.6" + dependencies: + "@floating-ui/dom": "npm:^1.7.4" + peerDependencies: + react: ">=16.8.0" + react-dom: ">=16.8.0" + checksum: 10/fbfd3319b42edb9c156e4e872f500d2edb112bc9cfd1b45892bff16ccf21c2484ddc9c416f7631c2aaaadec1b2f98b205db8a3f89eb78ca870905fcfe3917c35 + languageName: node + linkType: hard + "@floating-ui/react-dom@npm:^2.0.8, @floating-ui/react-dom@npm:^2.1.1": version: 2.1.2 resolution: "@floating-ui/react-dom@npm:2.1.2" @@ -2139,6 +2177,27 @@ __metadata: languageName: node linkType: hard +"@floating-ui/react@npm:^0.27.16": + version: 0.27.16 + resolution: "@floating-ui/react@npm:0.27.16" + dependencies: + "@floating-ui/react-dom": "npm:^2.1.6" + "@floating-ui/utils": "npm:^0.2.10" + tabbable: "npm:^6.0.0" + peerDependencies: + react: ">=17.0.0" + react-dom: ">=17.0.0" + checksum: 10/b9baedee124035323a8f74794ec782678faf52af1c88731ce7d2641b7e7c97748fda1e711a3c4db007a0153d93158d867f4726ee632d713d3de76ec4bdfd84e1 + languageName: node + linkType: hard + +"@floating-ui/utils@npm:^0.2.10": + version: 0.2.10 + resolution: "@floating-ui/utils@npm:0.2.10" + checksum: 10/b635ea865a8be2484b608b7157f5abf9ed439f351011a74b7e988439e2898199a9a8b790f52291e05bdcf119088160dc782d98cff45cc98c5a271bc6f51327ae + languageName: node + linkType: hard + "@floating-ui/utils@npm:^0.2.8": version: 0.2.8 resolution: "@floating-ui/utils@npm:0.2.8" @@ -4134,6 +4193,150 @@ __metadata: languageName: node linkType: hard +"@parcel/watcher-android-arm64@npm:2.5.1": + version: 2.5.1 + resolution: "@parcel/watcher-android-arm64@npm:2.5.1" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@parcel/watcher-darwin-arm64@npm:2.5.1": + version: 2.5.1 + resolution: "@parcel/watcher-darwin-arm64@npm:2.5.1" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@parcel/watcher-darwin-x64@npm:2.5.1": + version: 2.5.1 + resolution: "@parcel/watcher-darwin-x64@npm:2.5.1" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@parcel/watcher-freebsd-x64@npm:2.5.1": + version: 2.5.1 + resolution: "@parcel/watcher-freebsd-x64@npm:2.5.1" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@parcel/watcher-linux-arm-glibc@npm:2.5.1": + version: 2.5.1 + resolution: "@parcel/watcher-linux-arm-glibc@npm:2.5.1" + conditions: os=linux & cpu=arm & libc=glibc + languageName: node + linkType: hard + +"@parcel/watcher-linux-arm-musl@npm:2.5.1": + version: 2.5.1 + resolution: "@parcel/watcher-linux-arm-musl@npm:2.5.1" + conditions: os=linux & cpu=arm & libc=musl + languageName: node + linkType: hard + +"@parcel/watcher-linux-arm64-glibc@npm:2.5.1": + version: 2.5.1 + resolution: "@parcel/watcher-linux-arm64-glibc@npm:2.5.1" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@parcel/watcher-linux-arm64-musl@npm:2.5.1": + version: 2.5.1 + resolution: "@parcel/watcher-linux-arm64-musl@npm:2.5.1" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@parcel/watcher-linux-x64-glibc@npm:2.5.1": + version: 2.5.1 + resolution: "@parcel/watcher-linux-x64-glibc@npm:2.5.1" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@parcel/watcher-linux-x64-musl@npm:2.5.1": + version: 2.5.1 + resolution: "@parcel/watcher-linux-x64-musl@npm:2.5.1" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@parcel/watcher-win32-arm64@npm:2.5.1": + version: 2.5.1 + resolution: "@parcel/watcher-win32-arm64@npm:2.5.1" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@parcel/watcher-win32-ia32@npm:2.5.1": + version: 2.5.1 + resolution: "@parcel/watcher-win32-ia32@npm:2.5.1" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@parcel/watcher-win32-x64@npm:2.5.1": + version: 2.5.1 + resolution: "@parcel/watcher-win32-x64@npm:2.5.1" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@parcel/watcher@npm:^2.4.1": + version: 2.5.1 + resolution: "@parcel/watcher@npm:2.5.1" + dependencies: + "@parcel/watcher-android-arm64": "npm:2.5.1" + "@parcel/watcher-darwin-arm64": "npm:2.5.1" + "@parcel/watcher-darwin-x64": "npm:2.5.1" + "@parcel/watcher-freebsd-x64": "npm:2.5.1" + "@parcel/watcher-linux-arm-glibc": "npm:2.5.1" + "@parcel/watcher-linux-arm-musl": "npm:2.5.1" + "@parcel/watcher-linux-arm64-glibc": "npm:2.5.1" + "@parcel/watcher-linux-arm64-musl": "npm:2.5.1" + "@parcel/watcher-linux-x64-glibc": "npm:2.5.1" + "@parcel/watcher-linux-x64-musl": "npm:2.5.1" + "@parcel/watcher-win32-arm64": "npm:2.5.1" + "@parcel/watcher-win32-ia32": "npm:2.5.1" + "@parcel/watcher-win32-x64": "npm:2.5.1" + detect-libc: "npm:^1.0.3" + is-glob: "npm:^4.0.3" + micromatch: "npm:^4.0.5" + node-addon-api: "npm:^7.0.0" + node-gyp: "npm:latest" + dependenciesMeta: + "@parcel/watcher-android-arm64": + optional: true + "@parcel/watcher-darwin-arm64": + optional: true + "@parcel/watcher-darwin-x64": + optional: true + "@parcel/watcher-freebsd-x64": + optional: true + "@parcel/watcher-linux-arm-glibc": + optional: true + "@parcel/watcher-linux-arm-musl": + optional: true + "@parcel/watcher-linux-arm64-glibc": + optional: true + "@parcel/watcher-linux-arm64-musl": + optional: true + "@parcel/watcher-linux-x64-glibc": + optional: true + "@parcel/watcher-linux-x64-musl": + optional: true + "@parcel/watcher-win32-arm64": + optional: true + "@parcel/watcher-win32-ia32": + optional: true + "@parcel/watcher-win32-x64": + optional: true + checksum: 10/2cc1405166fb3016b34508661902ab08b6dec59513708165c633c84a4696fff64f9b99ea116e747c121215e09619f1decab6f0350d1cb26c9210b98eb28a6a56 + languageName: node + linkType: hard + "@pkgjs/parseargs@npm:^0.11.0": version: 0.11.0 resolution: "@pkgjs/parseargs@npm:0.11.0" @@ -4210,124 +4413,615 @@ __metadata: languageName: node linkType: hard -"@react-pdf/fns@npm:3.1.2": - version: 3.1.2 - resolution: "@react-pdf/fns@npm:3.1.2" - checksum: 10/b4b48167ae454587e2513b07166f44a21d908817b5e59dd72693f762c647038ecf6b4f18eecb468566613338ad1d5b4e16ade68d39ae852c9ef91ab54820224b +"@radix-ui/primitive@npm:1.1.3": + version: 1.1.3 + resolution: "@radix-ui/primitive@npm:1.1.3" + checksum: 10/ee27abbff0d6d305816e9314655eb35e72478ba47416bc9d5cb0581728be35e3408cfc0748313837561d635f0cb7dfaae26e61831f0e16c0fd7d669a612f2cb0 languageName: node linkType: hard -"@react-pdf/font@npm:^4.0.2": - version: 4.0.2 - resolution: "@react-pdf/font@npm:4.0.2" +"@radix-ui/react-arrow@npm:1.1.7": + version: 1.1.7 + resolution: "@radix-ui/react-arrow@npm:1.1.7" dependencies: - "@react-pdf/pdfkit": "npm:^4.0.3" - "@react-pdf/types": "npm:^2.9.0" - fontkit: "npm:^2.0.2" - is-url: "npm:^1.2.4" - checksum: 10/7b0504a11b96a08aec60558b0cf14be2374c2e12fbe164be63fb21ebb4356a52ff9cdcbf94c3143643d37f1f0794acb6315c1cf681f65f569b0c05ccf1a6477d + "@radix-ui/react-primitive": "npm:2.1.3" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10/6cdf74f06090f8994cdf6d3935a44ea3ac309163a4f59c476482c4907e8e0775f224045030abf10fa4f9e1cb7743db034429249b9e59354988e247eeb0f4fdcf languageName: node linkType: hard -"@react-pdf/image@npm:^3.0.3": - version: 3.0.3 - resolution: "@react-pdf/image@npm:3.0.3" +"@radix-ui/react-collection@npm:1.1.7": + version: 1.1.7 + resolution: "@radix-ui/react-collection@npm:1.1.7" dependencies: - "@react-pdf/png-js": "npm:^3.0.0" - jay-peg: "npm:^1.1.1" - checksum: 10/1d72f36dcfe1b5da5a67c9a905e4796ad2f0a9d96df1dea4998363759814712ca46f9877519dfb26653d6ba92705d2821984e09611b43582790fb534de3e7eb3 + "@radix-ui/react-compose-refs": "npm:1.1.2" + "@radix-ui/react-context": "npm:1.1.2" + "@radix-ui/react-primitive": "npm:2.1.3" + "@radix-ui/react-slot": "npm:1.2.3" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10/cd53e2a2be82be7bc4014164cac0b42948401a203e5d0294d3947a5193f1d56bd23eb60e878a98dba50d08283254e79c3b873de5f935276b849686a868d51dd5 languageName: node linkType: hard -"@react-pdf/layout@npm:^4.4.0": - version: 4.4.0 - resolution: "@react-pdf/layout@npm:4.4.0" - dependencies: - "@react-pdf/fns": "npm:3.1.2" - "@react-pdf/image": "npm:^3.0.3" - "@react-pdf/primitives": "npm:^4.1.1" - "@react-pdf/stylesheet": "npm:^6.1.0" - "@react-pdf/textkit": "npm:^6.0.0" - "@react-pdf/types": "npm:^2.9.0" - emoji-regex: "npm:^10.3.0" - queue: "npm:^6.0.1" - yoga-layout: "npm:^3.2.1" - checksum: 10/f40c4e9028ed169ee77b49b197acf4bb0b75dacbbdaf55b36f7a9953d7152e377e7bf62756c0efed40fed403b84ee1752120826851fbb670aa8fafb267d0d4f7 +"@radix-ui/react-compose-refs@npm:1.1.2": + version: 1.1.2 + resolution: "@radix-ui/react-compose-refs@npm:1.1.2" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10/9a91f0213014ffa40c5b8aae4debb993be5654217e504e35aa7422887eb2d114486d37e53c482d0fffb00cd44f51b5269fcdf397b280c71666fa11b7f32f165d languageName: node linkType: hard -"@react-pdf/pdfkit@npm:^4.0.3": - version: 4.0.3 - resolution: "@react-pdf/pdfkit@npm:4.0.3" - dependencies: - "@babel/runtime": "npm:^7.20.13" - "@react-pdf/png-js": "npm:^3.0.0" - browserify-zlib: "npm:^0.2.0" - crypto-js: "npm:^4.2.0" - fontkit: "npm:^2.0.2" - jay-peg: "npm:^1.1.1" - linebreak: "npm:^1.1.0" - vite-compatible-readable-stream: "npm:^3.6.1" - checksum: 10/7571931928b1514917e210a0a6e167e7b0ef8a107a5accedf13d461fa4829ebb04844a5d5626f7c78c4410c53b69e30095008a8b3b588666581970b4ea7a5abe +"@radix-ui/react-context@npm:1.1.2": + version: 1.1.2 + resolution: "@radix-ui/react-context@npm:1.1.2" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10/156088367de42afa3c7e3acf5f0ba7cad6b359f3d17485585e80c2418434a6ed7cac2602eb73bca265d0091a1ad380f9405c069f103983e53497097ff35ba8f2 languageName: node linkType: hard -"@react-pdf/png-js@npm:^3.0.0": - version: 3.0.0 - resolution: "@react-pdf/png-js@npm:3.0.0" - dependencies: - browserify-zlib: "npm:^0.2.0" - checksum: 10/5e972302a5d93f67d3dc1438bb4cb8ee708802fb6d513b651aa0857909a185f06e5288b59e82cffa9fe5587289c97de7f5c5401101f50faebc32bef50f398ee5 +"@radix-ui/react-direction@npm:1.1.1": + version: 1.1.1 + resolution: "@radix-ui/react-direction@npm:1.1.1" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10/8cc330285f1d06829568042ca9aabd3295be4690ae93683033fc8632b5c4dfc60f5c1312f6e2cae27c196189c719de3cfbcf792ff74800f9ccae0ab4abc1bc92 languageName: node linkType: hard -"@react-pdf/primitives@npm:^4.1.1": - version: 4.1.1 - resolution: "@react-pdf/primitives@npm:4.1.1" - checksum: 10/adadff1996daeca693aa59844ab613e597fdb674fce9f2c03f52573b593982ef49ff47d861290235861d02462ffbc87b7ed3da0d71af0d61c9226ce61b94ada8 +"@radix-ui/react-dismissable-layer@npm:1.1.11": + version: 1.1.11 + resolution: "@radix-ui/react-dismissable-layer@npm:1.1.11" + dependencies: + "@radix-ui/primitive": "npm:1.1.3" + "@radix-ui/react-compose-refs": "npm:1.1.2" + "@radix-ui/react-primitive": "npm:2.1.3" + "@radix-ui/react-use-callback-ref": "npm:1.1.1" + "@radix-ui/react-use-escape-keydown": "npm:1.1.1" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10/c20772588423379dee47fbe1d45c238c45a3bbe612eaf64a86576bf81821975e256d92ac71f9151e91b94a73068656143a11da9a3e77de7564d2a9926468e37a languageName: node linkType: hard -"@react-pdf/reconciler@npm:^1.1.4": - version: 1.1.4 - resolution: "@react-pdf/reconciler@npm:1.1.4" +"@radix-ui/react-dropdown-menu@npm:^2.1.16": + version: 2.1.16 + resolution: "@radix-ui/react-dropdown-menu@npm:2.1.16" dependencies: - object-assign: "npm:^4.1.1" - scheduler: "npm:0.25.0-rc-603e6108-20241029" + "@radix-ui/primitive": "npm:1.1.3" + "@radix-ui/react-compose-refs": "npm:1.1.2" + "@radix-ui/react-context": "npm:1.1.2" + "@radix-ui/react-id": "npm:1.1.1" + "@radix-ui/react-menu": "npm:2.1.16" + "@radix-ui/react-primitive": "npm:2.1.3" + "@radix-ui/react-use-controllable-state": "npm:1.2.2" peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - checksum: 10/6562961c6ead2b4392a32506d761c84d8c74f43378616cdab84dbdcbfd64e83b18e6e2eeeb962de7e3f3ab672ae335c3560b7f9e0cd275515de2ee55489e5425 + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10/da215196b5dde5619cdb424b1b5236159e4bb949974b7f4ffbf047d467c55116229a8f9cf07eae6457afefb4a2b07888bb30542f303045e05d90a4b072941ae2 languageName: node linkType: hard -"@react-pdf/render@npm:^4.3.0": - version: 4.3.0 - resolution: "@react-pdf/render@npm:4.3.0" - dependencies: - "@babel/runtime": "npm:^7.20.13" - "@react-pdf/fns": "npm:3.1.2" - "@react-pdf/primitives": "npm:^4.1.1" - "@react-pdf/textkit": "npm:^6.0.0" - "@react-pdf/types": "npm:^2.9.0" - abs-svg-path: "npm:^0.1.1" - color-string: "npm:^1.9.1" - normalize-svg-path: "npm:^1.1.0" - parse-svg-path: "npm:^0.1.2" - svg-arc-to-cubic-bezier: "npm:^3.2.0" - checksum: 10/7d1465e9411c1c4f170ef741226b5008b1236ec7eee2ed3b87248aeec7a42ecd5826fe7a351a0069826abbf4b4051f98e76fc1708d4cc5d5542f8be1058da978 +"@radix-ui/react-focus-guards@npm:1.1.3": + version: 1.1.3 + resolution: "@radix-ui/react-focus-guards@npm:1.1.3" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10/b57878f6cf0ebc3e8d7c5c6bbaad44598daac19c921551ca541c104201048a9a902f3d69196e7a09995fd46e998c309aab64dc30fa184b3609d67d187a6a9c24 languageName: node linkType: hard -"@react-pdf/renderer@npm:^4.3.0": - version: 4.3.0 - resolution: "@react-pdf/renderer@npm:4.3.0" +"@radix-ui/react-focus-scope@npm:1.1.7": + version: 1.1.7 + resolution: "@radix-ui/react-focus-scope@npm:1.1.7" dependencies: - "@babel/runtime": "npm:^7.20.13" - "@react-pdf/fns": "npm:3.1.2" - "@react-pdf/font": "npm:^4.0.2" - "@react-pdf/layout": "npm:^4.4.0" - "@react-pdf/pdfkit": "npm:^4.0.3" - "@react-pdf/primitives": "npm:^4.1.1" + "@radix-ui/react-compose-refs": "npm:1.1.2" + "@radix-ui/react-primitive": "npm:2.1.3" + "@radix-ui/react-use-callback-ref": "npm:1.1.1" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10/2a7cd00e39e01756999ebf0bdb3401d6a8efa489a7b19e6b629b40bad3022b7b1f616555ccb4b0505bc0ba53e13a1fb51be905db138b16ec39c4fe319fe701d3 + languageName: node + linkType: hard + +"@radix-ui/react-id@npm:1.1.1": + version: 1.1.1 + resolution: "@radix-ui/react-id@npm:1.1.1" + dependencies: + "@radix-ui/react-use-layout-effect": "npm:1.1.1" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10/8d68e200778eb3038906870fc869b3d881f4a46715fb20cddd9c76cba42fdaaa4810a3365b6ec2daf0f185b9201fc99d009167f59c7921bc3a139722c2e976db + languageName: node + linkType: hard + +"@radix-ui/react-menu@npm:2.1.16": + version: 2.1.16 + resolution: "@radix-ui/react-menu@npm:2.1.16" + dependencies: + "@radix-ui/primitive": "npm:1.1.3" + "@radix-ui/react-collection": "npm:1.1.7" + "@radix-ui/react-compose-refs": "npm:1.1.2" + "@radix-ui/react-context": "npm:1.1.2" + "@radix-ui/react-direction": "npm:1.1.1" + "@radix-ui/react-dismissable-layer": "npm:1.1.11" + "@radix-ui/react-focus-guards": "npm:1.1.3" + "@radix-ui/react-focus-scope": "npm:1.1.7" + "@radix-ui/react-id": "npm:1.1.1" + "@radix-ui/react-popper": "npm:1.2.8" + "@radix-ui/react-portal": "npm:1.1.9" + "@radix-ui/react-presence": "npm:1.1.5" + "@radix-ui/react-primitive": "npm:2.1.3" + "@radix-ui/react-roving-focus": "npm:1.1.11" + "@radix-ui/react-slot": "npm:1.2.3" + "@radix-ui/react-use-callback-ref": "npm:1.1.1" + aria-hidden: "npm:^1.2.4" + react-remove-scroll: "npm:^2.6.3" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10/2ffdfa08822c8c4ffc265d02d16c83d725114f9c0e9b510e73e431306dedddd507ef2861ccd67ec8c0d21cb24cd6401e42f16f3e65b30be627c7e22159151e40 + languageName: node + linkType: hard + +"@radix-ui/react-popover@npm:^1.1.15": + version: 1.1.15 + resolution: "@radix-ui/react-popover@npm:1.1.15" + dependencies: + "@radix-ui/primitive": "npm:1.1.3" + "@radix-ui/react-compose-refs": "npm:1.1.2" + "@radix-ui/react-context": "npm:1.1.2" + "@radix-ui/react-dismissable-layer": "npm:1.1.11" + "@radix-ui/react-focus-guards": "npm:1.1.3" + "@radix-ui/react-focus-scope": "npm:1.1.7" + "@radix-ui/react-id": "npm:1.1.1" + "@radix-ui/react-popper": "npm:1.2.8" + "@radix-ui/react-portal": "npm:1.1.9" + "@radix-ui/react-presence": "npm:1.1.5" + "@radix-ui/react-primitive": "npm:2.1.3" + "@radix-ui/react-slot": "npm:1.2.3" + "@radix-ui/react-use-controllable-state": "npm:1.2.2" + aria-hidden: "npm:^1.2.4" + react-remove-scroll: "npm:^2.6.3" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10/0ea7c8bb827e44d5c02b3f7193d9ac8085c71a01bf601b1afeb2bb0ec0124756e03db3471606e89e4d014e4de7c7066c8e2e9b81bb4b31ea321890ec33421f31 + languageName: node + linkType: hard + +"@radix-ui/react-popper@npm:1.2.8": + version: 1.2.8 + resolution: "@radix-ui/react-popper@npm:1.2.8" + dependencies: + "@floating-ui/react-dom": "npm:^2.0.0" + "@radix-ui/react-arrow": "npm:1.1.7" + "@radix-ui/react-compose-refs": "npm:1.1.2" + "@radix-ui/react-context": "npm:1.1.2" + "@radix-ui/react-primitive": "npm:2.1.3" + "@radix-ui/react-use-callback-ref": "npm:1.1.1" + "@radix-ui/react-use-layout-effect": "npm:1.1.1" + "@radix-ui/react-use-rect": "npm:1.1.1" + "@radix-ui/react-use-size": "npm:1.1.1" + "@radix-ui/rect": "npm:1.1.1" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10/01366054e1e63dd9394f77afb9da3367709478a5adf4436c080fc5bbe9456170192ff9d1425d9fae5b246e1ba95173848f84b6f2a06b21b47d966367ec7cb997 + languageName: node + linkType: hard + +"@radix-ui/react-portal@npm:1.1.9": + version: 1.1.9 + resolution: "@radix-ui/react-portal@npm:1.1.9" + dependencies: + "@radix-ui/react-primitive": "npm:2.1.3" + "@radix-ui/react-use-layout-effect": "npm:1.1.1" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10/bd6be39bf021d5c917e2474ecba411e2625171f7ef96862b9af04bbd68833bb3662a7f1fbdeb5a7a237111b10e811e76d2cd03e957dadd6e668ef16541bfbd68 + languageName: node + linkType: hard + +"@radix-ui/react-presence@npm:1.1.5": + version: 1.1.5 + resolution: "@radix-ui/react-presence@npm:1.1.5" + dependencies: + "@radix-ui/react-compose-refs": "npm:1.1.2" + "@radix-ui/react-use-layout-effect": "npm:1.1.1" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10/4cdb05844c18877efb4b9739b46b7e5850b81d7ede994e75b5d62e8153a43c6e16b3ff9e55ff716e20b74b99b9415a94e97fd636bcb8698d5bbf7ab7b8663f9b + languageName: node + linkType: hard + +"@radix-ui/react-primitive@npm:2.1.3": + version: 2.1.3 + resolution: "@radix-ui/react-primitive@npm:2.1.3" + dependencies: + "@radix-ui/react-slot": "npm:1.2.3" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10/1dbbf932a3527f4e62f210bb72944eff605c3e38c8d3275ed5a5c570c02820ab156169756a65ad9a638d2089a828a04a7903795377384e98c87d0ca456303253 + languageName: node + linkType: hard + +"@radix-ui/react-roving-focus@npm:1.1.11": + version: 1.1.11 + resolution: "@radix-ui/react-roving-focus@npm:1.1.11" + dependencies: + "@radix-ui/primitive": "npm:1.1.3" + "@radix-ui/react-collection": "npm:1.1.7" + "@radix-ui/react-compose-refs": "npm:1.1.2" + "@radix-ui/react-context": "npm:1.1.2" + "@radix-ui/react-direction": "npm:1.1.1" + "@radix-ui/react-id": "npm:1.1.1" + "@radix-ui/react-primitive": "npm:2.1.3" + "@radix-ui/react-use-callback-ref": "npm:1.1.1" + "@radix-ui/react-use-controllable-state": "npm:1.2.2" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10/0eddafa942332c95622ab8b53cce2fa25fd0dcaf4797218e9e6725da0734a81a438852cdcb3f588521018f68d38c6c5e50c64fda78c655f4e69dd45681ecc5e7 + languageName: node + linkType: hard + +"@radix-ui/react-slot@npm:1.2.3": + version: 1.2.3 + resolution: "@radix-ui/react-slot@npm:1.2.3" + dependencies: + "@radix-ui/react-compose-refs": "npm:1.1.2" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10/fe484c2741e31d9c20a8fb53c5790a73c0664e2bea35e27f4d484a90c42135fcfffe11a08abfcacb7a8ee2faf013471f0e856818f3ddac8ac51ceb8869e0fd08 + languageName: node + linkType: hard + +"@radix-ui/react-use-callback-ref@npm:1.1.1": + version: 1.1.1 + resolution: "@radix-ui/react-use-callback-ref@npm:1.1.1" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10/cde8c40f1d4e79e6e71470218163a746858304bad03758ac84dc1f94247a046478e8e397518350c8d6609c84b7e78565441d7505bb3ed573afce82cfdcd19faf + languageName: node + linkType: hard + +"@radix-ui/react-use-controllable-state@npm:1.2.2": + version: 1.2.2 + resolution: "@radix-ui/react-use-controllable-state@npm:1.2.2" + dependencies: + "@radix-ui/react-use-effect-event": "npm:0.0.2" + "@radix-ui/react-use-layout-effect": "npm:1.1.1" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10/a100bff3ddecb753dab17444147273c9f70046c5949712c52174b259622eaef12acbf7ebcf289bae4e714eb84d0a7317c1aa44064cd997f327d77b62bc732a7c + languageName: node + linkType: hard + +"@radix-ui/react-use-effect-event@npm:0.0.2": + version: 0.0.2 + resolution: "@radix-ui/react-use-effect-event@npm:0.0.2" + dependencies: + "@radix-ui/react-use-layout-effect": "npm:1.1.1" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10/5a1950a30a399ea7e4b98154da9f536737a610de80189b7aacd4f064a89a3cd0d2a48571d527435227252e72e872bdb544ff6ffcfbdd02de2efd011be4aaa902 + languageName: node + linkType: hard + +"@radix-ui/react-use-escape-keydown@npm:1.1.1": + version: 1.1.1 + resolution: "@radix-ui/react-use-escape-keydown@npm:1.1.1" + dependencies: + "@radix-ui/react-use-callback-ref": "npm:1.1.1" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10/0eb0756c2c55ddcde9ff01446ab01c085ab2bf799173e97db7ef5f85126f9e8600225570801a1f64740e6d14c39ffe8eed7c14d29737345a5797f4622ac96f6f + languageName: node + linkType: hard + +"@radix-ui/react-use-layout-effect@npm:1.1.1": + version: 1.1.1 + resolution: "@radix-ui/react-use-layout-effect@npm:1.1.1" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10/bad2ba4f206e6255263582bedfb7868773c400836f9a1b423c0b464ffe4a17e13d3f306d1ce19cf7a19a492e9d0e49747464f2656451bb7c6a99f5a57bd34de2 + languageName: node + linkType: hard + +"@radix-ui/react-use-rect@npm:1.1.1": + version: 1.1.1 + resolution: "@radix-ui/react-use-rect@npm:1.1.1" + dependencies: + "@radix-ui/rect": "npm:1.1.1" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10/116461bebc49472f7497e66a9bd413541181b3d00c5e0aaeef45d790dc1fbd7c8dcea80b169ea273306228b9a3c2b70067e902d1fd5004b3057e3bbe35b9d55d + languageName: node + linkType: hard + +"@radix-ui/react-use-size@npm:1.1.1": + version: 1.1.1 + resolution: "@radix-ui/react-use-size@npm:1.1.1" + dependencies: + "@radix-ui/react-use-layout-effect": "npm:1.1.1" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10/64e61f65feb67ffc80e1fc4a8d5e32480fb6d68475e2640377e021178dead101568cba5f936c9c33e6c142c7cf2fb5d76ad7b23ef80e556ba142d56cf306147b + languageName: node + linkType: hard + +"@radix-ui/rect@npm:1.1.1": + version: 1.1.1 + resolution: "@radix-ui/rect@npm:1.1.1" + checksum: 10/b6c5eb787640775b53dd52fa47218a089f0a0d8220d3ebff079c0b754e1fb82d89b6bdf08a82fd0d59549bdeb52678c0cca091c302da49dcf74c3c989cb55678 + languageName: node + linkType: hard + +"@react-pdf/fns@npm:3.1.2": + version: 3.1.2 + resolution: "@react-pdf/fns@npm:3.1.2" + checksum: 10/b4b48167ae454587e2513b07166f44a21d908817b5e59dd72693f762c647038ecf6b4f18eecb468566613338ad1d5b4e16ade68d39ae852c9ef91ab54820224b + languageName: node + linkType: hard + +"@react-pdf/font@npm:^4.0.2": + version: 4.0.2 + resolution: "@react-pdf/font@npm:4.0.2" + dependencies: + "@react-pdf/pdfkit": "npm:^4.0.3" + "@react-pdf/types": "npm:^2.9.0" + fontkit: "npm:^2.0.2" + is-url: "npm:^1.2.4" + checksum: 10/7b0504a11b96a08aec60558b0cf14be2374c2e12fbe164be63fb21ebb4356a52ff9cdcbf94c3143643d37f1f0794acb6315c1cf681f65f569b0c05ccf1a6477d + languageName: node + linkType: hard + +"@react-pdf/image@npm:^3.0.3": + version: 3.0.3 + resolution: "@react-pdf/image@npm:3.0.3" + dependencies: + "@react-pdf/png-js": "npm:^3.0.0" + jay-peg: "npm:^1.1.1" + checksum: 10/1d72f36dcfe1b5da5a67c9a905e4796ad2f0a9d96df1dea4998363759814712ca46f9877519dfb26653d6ba92705d2821984e09611b43582790fb534de3e7eb3 + languageName: node + linkType: hard + +"@react-pdf/layout@npm:^4.4.0": + version: 4.4.0 + resolution: "@react-pdf/layout@npm:4.4.0" + dependencies: + "@react-pdf/fns": "npm:3.1.2" + "@react-pdf/image": "npm:^3.0.3" + "@react-pdf/primitives": "npm:^4.1.1" + "@react-pdf/stylesheet": "npm:^6.1.0" + "@react-pdf/textkit": "npm:^6.0.0" + "@react-pdf/types": "npm:^2.9.0" + emoji-regex: "npm:^10.3.0" + queue: "npm:^6.0.1" + yoga-layout: "npm:^3.2.1" + checksum: 10/f40c4e9028ed169ee77b49b197acf4bb0b75dacbbdaf55b36f7a9953d7152e377e7bf62756c0efed40fed403b84ee1752120826851fbb670aa8fafb267d0d4f7 + languageName: node + linkType: hard + +"@react-pdf/pdfkit@npm:^4.0.3": + version: 4.0.3 + resolution: "@react-pdf/pdfkit@npm:4.0.3" + dependencies: + "@babel/runtime": "npm:^7.20.13" + "@react-pdf/png-js": "npm:^3.0.0" + browserify-zlib: "npm:^0.2.0" + crypto-js: "npm:^4.2.0" + fontkit: "npm:^2.0.2" + jay-peg: "npm:^1.1.1" + linebreak: "npm:^1.1.0" + vite-compatible-readable-stream: "npm:^3.6.1" + checksum: 10/7571931928b1514917e210a0a6e167e7b0ef8a107a5accedf13d461fa4829ebb04844a5d5626f7c78c4410c53b69e30095008a8b3b588666581970b4ea7a5abe + languageName: node + linkType: hard + +"@react-pdf/png-js@npm:^3.0.0": + version: 3.0.0 + resolution: "@react-pdf/png-js@npm:3.0.0" + dependencies: + browserify-zlib: "npm:^0.2.0" + checksum: 10/5e972302a5d93f67d3dc1438bb4cb8ee708802fb6d513b651aa0857909a185f06e5288b59e82cffa9fe5587289c97de7f5c5401101f50faebc32bef50f398ee5 + languageName: node + linkType: hard + +"@react-pdf/primitives@npm:^4.1.1": + version: 4.1.1 + resolution: "@react-pdf/primitives@npm:4.1.1" + checksum: 10/adadff1996daeca693aa59844ab613e597fdb674fce9f2c03f52573b593982ef49ff47d861290235861d02462ffbc87b7ed3da0d71af0d61c9226ce61b94ada8 + languageName: node + linkType: hard + +"@react-pdf/reconciler@npm:^1.1.4": + version: 1.1.4 + resolution: "@react-pdf/reconciler@npm:1.1.4" + dependencies: + object-assign: "npm:^4.1.1" + scheduler: "npm:0.25.0-rc-603e6108-20241029" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + checksum: 10/6562961c6ead2b4392a32506d761c84d8c74f43378616cdab84dbdcbfd64e83b18e6e2eeeb962de7e3f3ab672ae335c3560b7f9e0cd275515de2ee55489e5425 + languageName: node + linkType: hard + +"@react-pdf/render@npm:^4.3.0": + version: 4.3.0 + resolution: "@react-pdf/render@npm:4.3.0" + dependencies: + "@babel/runtime": "npm:^7.20.13" + "@react-pdf/fns": "npm:3.1.2" + "@react-pdf/primitives": "npm:^4.1.1" + "@react-pdf/textkit": "npm:^6.0.0" + "@react-pdf/types": "npm:^2.9.0" + abs-svg-path: "npm:^0.1.1" + color-string: "npm:^1.9.1" + normalize-svg-path: "npm:^1.1.0" + parse-svg-path: "npm:^0.1.2" + svg-arc-to-cubic-bezier: "npm:^3.2.0" + checksum: 10/7d1465e9411c1c4f170ef741226b5008b1236ec7eee2ed3b87248aeec7a42ecd5826fe7a351a0069826abbf4b4051f98e76fc1708d4cc5d5542f8be1058da978 + languageName: node + linkType: hard + +"@react-pdf/renderer@npm:^4.3.0": + version: 4.3.0 + resolution: "@react-pdf/renderer@npm:4.3.0" + dependencies: + "@babel/runtime": "npm:^7.20.13" + "@react-pdf/fns": "npm:3.1.2" + "@react-pdf/font": "npm:^4.0.2" + "@react-pdf/layout": "npm:^4.4.0" + "@react-pdf/pdfkit": "npm:^4.0.3" + "@react-pdf/primitives": "npm:^4.1.1" "@react-pdf/reconciler": "npm:^1.1.4" "@react-pdf/render": "npm:^4.3.0" "@react-pdf/types": "npm:^2.9.0" @@ -4378,6 +5072,13 @@ __metadata: languageName: node linkType: hard +"@remirror/core-constants@npm:3.0.0": + version: 3.0.0 + resolution: "@remirror/core-constants@npm:3.0.0" + checksum: 10/de15b1df099a7646739e5fb6bb55195618a8ac4fa938db7c719e867eefd72ebc5a05865591788ade449613141619cc1002fb6c0f824de4468dfefa951fbf19a2 + languageName: node + linkType: hard + "@remixicon/react@npm:^4.2.0": version: 4.2.0 resolution: "@remixicon/react@npm:4.2.0" @@ -5708,47 +6409,423 @@ __metadata: languageName: node linkType: hard -"@testing-library/jest-dom@npm:6.5.0, @testing-library/jest-dom@npm:^6.4.2, @testing-library/jest-dom@npm:^6.4.8": - version: 6.5.0 - resolution: "@testing-library/jest-dom@npm:6.5.0" - dependencies: - "@adobe/css-tools": "npm:^4.4.0" - aria-query: "npm:^5.0.0" - chalk: "npm:^3.0.0" - css.escape: "npm:^1.5.1" - dom-accessibility-api: "npm:^0.6.3" - lodash: "npm:^4.17.21" - redent: "npm:^3.0.0" - checksum: 10/3d2080888af5fd7306f57448beb5a23f55d965e265b5e53394fffc112dfb0678d616a5274ff0200c46c7618f293520f86fc8562eecd8bdbc0dbb3294d63ec431 +"@testing-library/jest-dom@npm:6.5.0, @testing-library/jest-dom@npm:^6.4.2, @testing-library/jest-dom@npm:^6.4.8": + version: 6.5.0 + resolution: "@testing-library/jest-dom@npm:6.5.0" + dependencies: + "@adobe/css-tools": "npm:^4.4.0" + aria-query: "npm:^5.0.0" + chalk: "npm:^3.0.0" + css.escape: "npm:^1.5.1" + dom-accessibility-api: "npm:^0.6.3" + lodash: "npm:^4.17.21" + redent: "npm:^3.0.0" + checksum: 10/3d2080888af5fd7306f57448beb5a23f55d965e265b5e53394fffc112dfb0678d616a5274ff0200c46c7618f293520f86fc8562eecd8bdbc0dbb3294d63ec431 + languageName: node + linkType: hard + +"@testing-library/react@npm:^16.3.0": + version: 16.3.0 + resolution: "@testing-library/react@npm:16.3.0" + dependencies: + "@babel/runtime": "npm:^7.12.5" + peerDependencies: + "@testing-library/dom": ^10.0.0 + "@types/react": ^18.0.0 || ^19.0.0 + "@types/react-dom": ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10/0ee9e31dd0d2396a924682d0e61a4ecc6bfab8eaff23dbf8a72c3c2ce22c116fa578148baeb4de75b968ef99d22e6e6aa0a00dba40286f71184918bb6bb5b06a + languageName: node + linkType: hard + +"@testing-library/user-event@npm:14.5.2, @testing-library/user-event@npm:^14.5.2": + version: 14.5.2 + resolution: "@testing-library/user-event@npm:14.5.2" + peerDependencies: + "@testing-library/dom": ">=7.21.4" + checksum: 10/49821459d81c6bc435d97128d6386ca24f1e4b3ba8e46cb5a96fe3643efa6e002d88c1b02b7f2ec58da593e805c59b78d7fdf0db565c1f02ba782f63ee984040 + languageName: node + linkType: hard + +"@tiptap/core@npm:^3.10.5": + version: 3.10.5 + resolution: "@tiptap/core@npm:3.10.5" + peerDependencies: + "@tiptap/pm": ^3.10.5 + checksum: 10/17a00eb0962537406c303a29034a8ac06646c53d0e0e7b77384d7b930a873f09d21680d2a376a5f2a3eaa32e9928a7abf782112bf7373a6ca56474d718757256 + languageName: node + linkType: hard + +"@tiptap/extension-blockquote@npm:^3.10.5": + version: 3.10.5 + resolution: "@tiptap/extension-blockquote@npm:3.10.5" + peerDependencies: + "@tiptap/core": ^3.10.5 + checksum: 10/e24cceb69c37cbbd3aa13190733d73bdf6a3118ee2830b19e2acf4a8b74d20628cb3baefc464d15109c8ba84bed4f1804d2e32740c7f071c7ac0184cf02a30b1 + languageName: node + linkType: hard + +"@tiptap/extension-bold@npm:^3.10.5": + version: 3.10.5 + resolution: "@tiptap/extension-bold@npm:3.10.5" + peerDependencies: + "@tiptap/core": ^3.10.5 + checksum: 10/5c2e389a42981e04dcf43112e5329822bd30327dc14f17acb3967565b26ec706cb85e6f3a1ef5eea4149bcd9f0b5dee5e9f0c2f3dfbef706a1f5d0f1e9f930ec + languageName: node + linkType: hard + +"@tiptap/extension-bubble-menu@npm:^3.10.5": + version: 3.10.5 + resolution: "@tiptap/extension-bubble-menu@npm:3.10.5" + dependencies: + "@floating-ui/dom": "npm:^1.0.0" + peerDependencies: + "@tiptap/core": ^3.10.5 + "@tiptap/pm": ^3.10.5 + checksum: 10/f95dcfc771402179a96f6eb05089c1c281d54559b25c0efeccf19ba46bd33ec506a1b1a97923c7215513d4daada13d23c1194e14eaf0e58c35be773ce36009df + languageName: node + linkType: hard + +"@tiptap/extension-bullet-list@npm:^3.10.5": + version: 3.10.5 + resolution: "@tiptap/extension-bullet-list@npm:3.10.5" + peerDependencies: + "@tiptap/extension-list": ^3.10.5 + checksum: 10/9220ee3030d2e98a5caac21a8eae2968979756c038d5010105ec920fdbdeaaf37509d5e7b716dbcc28f2029ab5fe74fe12d7656ab94658a1a00c1a5c53531e4c + languageName: node + linkType: hard + +"@tiptap/extension-code-block@npm:^3.10.5": + version: 3.10.5 + resolution: "@tiptap/extension-code-block@npm:3.10.5" + peerDependencies: + "@tiptap/core": ^3.10.5 + "@tiptap/pm": ^3.10.5 + checksum: 10/76ce92c76a7be22ffacbba9faf71731b34cff765e9117843c8dd451d5c9b7dc0c83a5d12d8a62cca37eb50de97cf38793d4904d90e72ac2aa4c8657f1312198c + languageName: node + linkType: hard + +"@tiptap/extension-code@npm:^3.10.5": + version: 3.10.5 + resolution: "@tiptap/extension-code@npm:3.10.5" + peerDependencies: + "@tiptap/core": ^3.10.5 + checksum: 10/0554e61969d0a8afd1485ba54380a4e231fc7373f3a29d32aa252d4b55237ebec80f0f06df9dec0df5c16399fd35d1b745c33783aba4914115db32149461b9df + languageName: node + linkType: hard + +"@tiptap/extension-document@npm:^3.10.5": + version: 3.10.5 + resolution: "@tiptap/extension-document@npm:3.10.5" + peerDependencies: + "@tiptap/core": ^3.10.5 + checksum: 10/89254ad1791cd4cc744b62e514aaf82364bad4009981c30d78092aa6d77017719e385d4d0996ef511ab49041bb22ffb6b408beb504009c2b404dbf4a0b93d1a1 + languageName: node + linkType: hard + +"@tiptap/extension-dropcursor@npm:^3.10.5": + version: 3.10.5 + resolution: "@tiptap/extension-dropcursor@npm:3.10.5" + peerDependencies: + "@tiptap/extensions": ^3.10.5 + checksum: 10/ea87a3065e4f5576b28eb81e306ac5b2e59c20f34b7509a492dcfe6699b0a9d1c7111028e34fc5a20fc25b68b2c17aed203314029103631ed1cfe3599eefc50f + languageName: node + linkType: hard + +"@tiptap/extension-floating-menu@npm:^3.10.5": + version: 3.10.5 + resolution: "@tiptap/extension-floating-menu@npm:3.10.5" + peerDependencies: + "@floating-ui/dom": ^1.0.0 + "@tiptap/core": ^3.10.5 + "@tiptap/pm": ^3.10.5 + checksum: 10/e1a4aec62b381bd7acb2abd633d9bec89446aa19a7cfc1b16fe383db31be0cb9ffcdc872bc96b4744cced64e83fea6c65efe207adfe4860fb80778588ab95a9a + languageName: node + linkType: hard + +"@tiptap/extension-gapcursor@npm:^3.10.5": + version: 3.10.5 + resolution: "@tiptap/extension-gapcursor@npm:3.10.5" + peerDependencies: + "@tiptap/extensions": ^3.10.5 + checksum: 10/11ae46f2f2978f2c1f883fcac7c8d9fc212c6210648074c2f338a874d3b423d48d89caab57c5d7d890e670cdd63968f1f5caa206208afff8c56ade1f7c501bf4 + languageName: node + linkType: hard + +"@tiptap/extension-hard-break@npm:^3.10.5": + version: 3.10.5 + resolution: "@tiptap/extension-hard-break@npm:3.10.5" + peerDependencies: + "@tiptap/core": ^3.10.5 + checksum: 10/5d36703f3fb0aa5ffcbd1e9b402e5a725dbe2ffc39d5d71f163ce51205e9573742417746f0d680fbfd770ac4f3101b9e1b9673b81c9cf25249532ea7332a145c + languageName: node + linkType: hard + +"@tiptap/extension-heading@npm:^3.10.5": + version: 3.10.5 + resolution: "@tiptap/extension-heading@npm:3.10.5" + peerDependencies: + "@tiptap/core": ^3.10.5 + checksum: 10/a2aa19acabc0ceda669121af7df4f17bfc00eee9725ecad151c7743955d6d1a8efe6b026695bf021ae1089764771763dcdee4f507e7938fe5c168b2687db23ff + languageName: node + linkType: hard + +"@tiptap/extension-highlight@npm:^3.10.5": + version: 3.10.5 + resolution: "@tiptap/extension-highlight@npm:3.10.5" + peerDependencies: + "@tiptap/core": ^3.10.5 + checksum: 10/c5010058072f84a962cc9efcabf17fea74748e025d9778f13352a1ac19f69147adc632940e6e094defd8eabeeae770b9a84acd88b391b1a27401b8469ab5d24f + languageName: node + linkType: hard + +"@tiptap/extension-horizontal-rule@npm:^3.10.5": + version: 3.10.5 + resolution: "@tiptap/extension-horizontal-rule@npm:3.10.5" + peerDependencies: + "@tiptap/core": ^3.10.5 + "@tiptap/pm": ^3.10.5 + checksum: 10/0836bab3c41217053c60244f162a7a7e751c429d38b4d5134a7d7c7a76d0acaceef6b9ffcb976a9d49bf972232b4563aa1d71cf22a24fd94a0fd61f57f7987fc + languageName: node + linkType: hard + +"@tiptap/extension-image@npm:^3.10.5": + version: 3.10.5 + resolution: "@tiptap/extension-image@npm:3.10.5" + peerDependencies: + "@tiptap/core": ^3.10.5 + checksum: 10/2a34e75684e9289addee39fae6980b19b6618c0dfbbb79ea40748f43e8b0998d6e87bab0c5fac43328fd94bb0c32dfb12fadd898a50e5531d0a7c6f1f56a7310 + languageName: node + linkType: hard + +"@tiptap/extension-italic@npm:^3.10.5": + version: 3.10.5 + resolution: "@tiptap/extension-italic@npm:3.10.5" + peerDependencies: + "@tiptap/core": ^3.10.5 + checksum: 10/b442d903de338af99e718484e9b021dc371fbeb838a2e73638f5b295b230dacb16414218e3a55e22446dd4240065da770e1a634354d4848b3aee052e60a771e3 + languageName: node + linkType: hard + +"@tiptap/extension-link@npm:^3.10.5": + version: 3.10.5 + resolution: "@tiptap/extension-link@npm:3.10.5" + dependencies: + linkifyjs: "npm:^4.3.2" + peerDependencies: + "@tiptap/core": ^3.10.5 + "@tiptap/pm": ^3.10.5 + checksum: 10/09f09ba9ce65dfc3e88cbf7a2d690a331e89011d58c57d44686f9c9a98faf9220c03d4b73bf8a87c754a7c822c537b288db94af740b0344f3b14e1f1f3ed4580 + languageName: node + linkType: hard + +"@tiptap/extension-list-item@npm:^3.10.5": + version: 3.10.5 + resolution: "@tiptap/extension-list-item@npm:3.10.5" + peerDependencies: + "@tiptap/extension-list": ^3.10.5 + checksum: 10/15fa06dbaccd9b5e26835889a75ede3911c5581c9886235fac65c8ed6b4ef456da7ecf9f8aa6880cda346f7e9968047526e97db7991fc137b43077a3c8df21a4 + languageName: node + linkType: hard + +"@tiptap/extension-list-keymap@npm:^3.10.5": + version: 3.10.5 + resolution: "@tiptap/extension-list-keymap@npm:3.10.5" + peerDependencies: + "@tiptap/extension-list": ^3.10.5 + checksum: 10/1e00ebd89a6ee622170c93174a44abbd22cc8a92a5daed4dad71e685cc40a3825ae7edf979b169d33bfb1270412c9d16f9c3e4cc7159ecd5113b50928b0424e4 + languageName: node + linkType: hard + +"@tiptap/extension-list@npm:^3.10.5": + version: 3.10.5 + resolution: "@tiptap/extension-list@npm:3.10.5" + peerDependencies: + "@tiptap/core": ^3.10.5 + "@tiptap/pm": ^3.10.5 + checksum: 10/7a52d7abae3f26a03a9be576e7916d8ec4c23c7206be03948c822722ee20e7ab404cb2ac9d29d6999a3387475f8fb3dc1316dfd84c0f083f2c577ca28c2a7e29 + languageName: node + linkType: hard + +"@tiptap/extension-ordered-list@npm:^3.10.5": + version: 3.10.5 + resolution: "@tiptap/extension-ordered-list@npm:3.10.5" + peerDependencies: + "@tiptap/extension-list": ^3.10.5 + checksum: 10/17c8cc6c4a3113e4a3bc2e189b6a19c2c027af92b1c6ec41380671258390aa2fac46671195d29def576e85d17c6fdd4465c4f70cefe262a6a0a637e59009d7b7 + languageName: node + linkType: hard + +"@tiptap/extension-paragraph@npm:^3.10.5": + version: 3.10.5 + resolution: "@tiptap/extension-paragraph@npm:3.10.5" + peerDependencies: + "@tiptap/core": ^3.10.5 + checksum: 10/ef0b5dad615ffd30fcdfa28f5079feea442ba9776c385ce491567a7552b1b59de8e674b5063e5390f184db897f6eb82b53cdb30ca900a3607afa24d9d5d68840 + languageName: node + linkType: hard + +"@tiptap/extension-strike@npm:^3.10.5": + version: 3.10.5 + resolution: "@tiptap/extension-strike@npm:3.10.5" + peerDependencies: + "@tiptap/core": ^3.10.5 + checksum: 10/54d8ea0b1f8c103ce2e9e35fedaf18036bb39d941dc6360855e67165d027ec0b0eece5fe2488acdb248534871a43dd4d99b18e046ea020ac8f2a3eb42ee61776 + languageName: node + linkType: hard + +"@tiptap/extension-subscript@npm:^3.10.5": + version: 3.10.5 + resolution: "@tiptap/extension-subscript@npm:3.10.5" + peerDependencies: + "@tiptap/core": ^3.10.5 + "@tiptap/pm": ^3.10.5 + checksum: 10/7f862cef396054c0943fd36f7855d8cf755db6d7ca588ffdd55229f08d14ffea3cb5705d62f96b9da80e519912b63aa62c9a12ed31bfc7846208c3bdd5948837 + languageName: node + linkType: hard + +"@tiptap/extension-superscript@npm:^3.10.5": + version: 3.10.5 + resolution: "@tiptap/extension-superscript@npm:3.10.5" + peerDependencies: + "@tiptap/core": ^3.10.5 + "@tiptap/pm": ^3.10.5 + checksum: 10/503232e20ef5cac9e5ab71c598256086aa43f30c1bba2e575d1f8941ac716cc6e16d572409fd4e87092dd09bd29f52cbee2d115f69174a9435baf7c0b43336f9 + languageName: node + linkType: hard + +"@tiptap/extension-text-align@npm:^3.10.5": + version: 3.10.5 + resolution: "@tiptap/extension-text-align@npm:3.10.5" + peerDependencies: + "@tiptap/core": ^3.10.5 + checksum: 10/8288cbf18e9831725b99e3749a60eff19a758697d1373c6e89b0f4ac1446768f6289b00294799b18a2aa66300dbd9b67ae7d66c332c47cb8f08f4855d89d04ce + languageName: node + linkType: hard + +"@tiptap/extension-text@npm:^3.10.5": + version: 3.10.5 + resolution: "@tiptap/extension-text@npm:3.10.5" + peerDependencies: + "@tiptap/core": ^3.10.5 + checksum: 10/1fc3eee565eb15c2ce3fae378c7edb8e8055d306093bc15486c709ea0850ea869596b2f5eaef9fc2a5d6a1138df2037164dbe5d17b02956302939e5600433e76 + languageName: node + linkType: hard + +"@tiptap/extension-typography@npm:^3.10.5": + version: 3.10.5 + resolution: "@tiptap/extension-typography@npm:3.10.5" + peerDependencies: + "@tiptap/core": ^3.10.5 + checksum: 10/4c059575647a8848224704488cdfaa3b0181b9e385f7a30568c6b68c349f1fb9ba04dcd0e9b2b3bf4e0eabefab8b14f306f5f11922d2fb8185db1050e06ca891 + languageName: node + linkType: hard + +"@tiptap/extension-underline@npm:^3.10.5": + version: 3.10.5 + resolution: "@tiptap/extension-underline@npm:3.10.5" + peerDependencies: + "@tiptap/core": ^3.10.5 + checksum: 10/d949a1dd93490c983f54762a6892b48707f13934a9a17490ec832ae748240416e319c24485f84ac3c34256ef924baa436392c61438bbc4b0a2607623764abe58 languageName: node linkType: hard -"@testing-library/react@npm:^16.3.0": - version: 16.3.0 - resolution: "@testing-library/react@npm:16.3.0" - dependencies: - "@babel/runtime": "npm:^7.12.5" +"@tiptap/extensions@npm:^3.10.5": + version: 3.10.5 + resolution: "@tiptap/extensions@npm:3.10.5" peerDependencies: - "@testing-library/dom": ^10.0.0 - "@types/react": ^18.0.0 || ^19.0.0 - "@types/react-dom": ^18.0.0 || ^19.0.0 - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - "@types/react": - optional: true - "@types/react-dom": - optional: true - checksum: 10/0ee9e31dd0d2396a924682d0e61a4ecc6bfab8eaff23dbf8a72c3c2ce22c116fa578148baeb4de75b968ef99d22e6e6aa0a00dba40286f71184918bb6bb5b06a + "@tiptap/core": ^3.10.5 + "@tiptap/pm": ^3.10.5 + checksum: 10/a3def19cbebbb10d721322173265ab90498376917bf7dd1182c6047f689dbc1d0577418a51f985b4261357de20498235d49196951238eaea5f6581cf478a232d languageName: node linkType: hard -"@testing-library/user-event@npm:14.5.2, @testing-library/user-event@npm:^14.5.2": - version: 14.5.2 - resolution: "@testing-library/user-event@npm:14.5.2" +"@tiptap/pm@npm:^3.10.5": + version: 3.10.5 + resolution: "@tiptap/pm@npm:3.10.5" + dependencies: + prosemirror-changeset: "npm:^2.3.0" + prosemirror-collab: "npm:^1.3.1" + prosemirror-commands: "npm:^1.6.2" + prosemirror-dropcursor: "npm:^1.8.1" + prosemirror-gapcursor: "npm:^1.3.2" + prosemirror-history: "npm:^1.4.1" + prosemirror-inputrules: "npm:^1.4.0" + prosemirror-keymap: "npm:^1.2.2" + prosemirror-markdown: "npm:^1.13.1" + prosemirror-menu: "npm:^1.2.4" + prosemirror-model: "npm:^1.24.1" + prosemirror-schema-basic: "npm:^1.2.3" + prosemirror-schema-list: "npm:^1.5.0" + prosemirror-state: "npm:^1.4.3" + prosemirror-tables: "npm:^1.6.4" + prosemirror-trailing-node: "npm:^3.0.0" + prosemirror-transform: "npm:^1.10.2" + prosemirror-view: "npm:^1.38.1" + checksum: 10/c73fedb51906fc24fdb429d8dce04973e1d14c1eade990f3d8d31df14d70f6d0a28b02c502bf49f807536952cd72a99d9cef531ff7cb146368a7493862f1d37f + languageName: node + linkType: hard + +"@tiptap/react@npm:^3.10.5": + version: 3.10.5 + resolution: "@tiptap/react@npm:3.10.5" + dependencies: + "@tiptap/extension-bubble-menu": "npm:^3.10.5" + "@tiptap/extension-floating-menu": "npm:^3.10.5" + "@types/use-sync-external-store": "npm:^0.0.6" + fast-deep-equal: "npm:^3.1.3" + use-sync-external-store: "npm:^1.4.0" peerDependencies: - "@testing-library/dom": ">=7.21.4" - checksum: 10/49821459d81c6bc435d97128d6386ca24f1e4b3ba8e46cb5a96fe3643efa6e002d88c1b02b7f2ec58da593e805c59b78d7fdf0db565c1f02ba782f63ee984040 + "@tiptap/core": ^3.10.5 + "@tiptap/pm": ^3.10.5 + "@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0 + "@types/react-dom": ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + dependenciesMeta: + "@tiptap/extension-bubble-menu": + optional: true + "@tiptap/extension-floating-menu": + optional: true + checksum: 10/409b283ad78b923524e37d1cd95570509ed0eaf3da4bd47066f8c434406afb39712f58e5c9f81db1611fad359e7b065652f6056fb11c286592916c12b0d7bee6 + languageName: node + linkType: hard + +"@tiptap/starter-kit@npm:^3.10.5": + version: 3.10.5 + resolution: "@tiptap/starter-kit@npm:3.10.5" + dependencies: + "@tiptap/core": "npm:^3.10.5" + "@tiptap/extension-blockquote": "npm:^3.10.5" + "@tiptap/extension-bold": "npm:^3.10.5" + "@tiptap/extension-bullet-list": "npm:^3.10.5" + "@tiptap/extension-code": "npm:^3.10.5" + "@tiptap/extension-code-block": "npm:^3.10.5" + "@tiptap/extension-document": "npm:^3.10.5" + "@tiptap/extension-dropcursor": "npm:^3.10.5" + "@tiptap/extension-gapcursor": "npm:^3.10.5" + "@tiptap/extension-hard-break": "npm:^3.10.5" + "@tiptap/extension-heading": "npm:^3.10.5" + "@tiptap/extension-horizontal-rule": "npm:^3.10.5" + "@tiptap/extension-italic": "npm:^3.10.5" + "@tiptap/extension-link": "npm:^3.10.5" + "@tiptap/extension-list": "npm:^3.10.5" + "@tiptap/extension-list-item": "npm:^3.10.5" + "@tiptap/extension-list-keymap": "npm:^3.10.5" + "@tiptap/extension-ordered-list": "npm:^3.10.5" + "@tiptap/extension-paragraph": "npm:^3.10.5" + "@tiptap/extension-strike": "npm:^3.10.5" + "@tiptap/extension-text": "npm:^3.10.5" + "@tiptap/extension-underline": "npm:^3.10.5" + "@tiptap/extensions": "npm:^3.10.5" + "@tiptap/pm": "npm:^3.10.5" + checksum: 10/3005464bc831cd7fb3cb083528c2000951625b0242467a6ed35c46736e5d359408859ef519cd30f3cae2bbca43c326576205a1b504fb0ebd47d53a31fd17a8d0 languageName: node linkType: hard @@ -6101,6 +7178,29 @@ __metadata: languageName: node linkType: hard +"@types/linkify-it@npm:^5": + version: 5.0.0 + resolution: "@types/linkify-it@npm:5.0.0" + checksum: 10/c3919044d4876f9d71d037e861745cd2485c95ac8c36a4fa67b132d4e60eb1d067e123cc7965c9cf5110eea351517d767f0d306af5e9147d6d0af87bc374ddcf + languageName: node + linkType: hard + +"@types/lodash.throttle@npm:^4.1.9": + version: 4.1.9 + resolution: "@types/lodash.throttle@npm:4.1.9" + dependencies: + "@types/lodash": "npm:*" + checksum: 10/6d330072387f062d408747f0dbe62869820ee3f3fbec43965f703ce9c9083e4ff9082faa4fe92aea000d6367b7645955e9c8db6a4e04e6bd769697fdd19c12b1 + languageName: node + linkType: hard + +"@types/lodash@npm:*": + version: 4.17.20 + resolution: "@types/lodash@npm:4.17.20" + checksum: 10/8cd8ad3bd78d2e06a93ae8d6c9907981d5673655fec7cb274a4d9a59549aab5bb5b3017361280773b8990ddfccf363e14d1b37c97af8a9fe363de677f9a61524 + languageName: node + linkType: hard + "@types/lodash@npm:^4.14.167, @types/lodash@npm:^4.17.7": version: 4.17.10 resolution: "@types/lodash@npm:4.17.10" @@ -6108,6 +7208,16 @@ __metadata: languageName: node linkType: hard +"@types/markdown-it@npm:^14.0.0": + version: 14.1.2 + resolution: "@types/markdown-it@npm:14.1.2" + dependencies: + "@types/linkify-it": "npm:^5" + "@types/mdurl": "npm:^2" + checksum: 10/ca2f239c8d59610b9f936fd40261a6ccf2fa1ae27a21816c031e5712542dcf9ee01e2fe29b31118df90716e11ade54e47d92a498e9b6488800e77ca8827255a2 + languageName: node + linkType: hard + "@types/mathjax@npm:^0.0.40": version: 0.0.40 resolution: "@types/mathjax@npm:0.0.40" @@ -6133,6 +7243,13 @@ __metadata: languageName: node linkType: hard +"@types/mdurl@npm:^2": + version: 2.0.0 + resolution: "@types/mdurl@npm:2.0.0" + checksum: 10/78746e96c655ceed63db06382da466fd52c7e9dc54d60b12973dfdd110cae06b9439c4b90e17bb8d4461109184b3ea9f3e9f96b3e4bf4aa9fe18b6ac35f283c8 + languageName: node + linkType: hard + "@types/mdx@npm:^2.0.0": version: 2.0.13 resolution: "@types/mdx@npm:2.0.13" @@ -6390,6 +7507,13 @@ __metadata: languageName: node linkType: hard +"@types/use-sync-external-store@npm:^0.0.6": + version: 0.0.6 + resolution: "@types/use-sync-external-store@npm:0.0.6" + checksum: 10/a95ce330668501ad9b1c5b7f2b14872ad201e552a0e567787b8f1588b22c7040c7c3d80f142cbb9f92d13c4ea41c46af57a20f2af4edf27f224d352abcfe4049 + languageName: node + linkType: hard + "@types/uuid@npm:^9.0.1": version: 9.0.8 resolution: "@types/uuid@npm:9.0.8" @@ -7421,6 +8545,15 @@ __metadata: languageName: node linkType: hard +"aria-hidden@npm:^1.2.4": + version: 1.2.6 + resolution: "aria-hidden@npm:1.2.6" + dependencies: + tslib: "npm:^2.0.0" + checksum: 10/1914e5a36225dccdb29f0b88cc891eeca736cdc5b0c905ab1437b90b28b5286263ed3a221c75b7dc788f25b942367be0044b2ac8ccf073a72e07a50b1d964202 + languageName: node + linkType: hard + "aria-query@npm:5.3.0": version: 5.3.0 resolution: "aria-query@npm:5.3.0" @@ -8097,6 +9230,13 @@ __metadata: languageName: node linkType: hard +"buffer-builder@npm:^0.2.0": + version: 0.2.0 + resolution: "buffer-builder@npm:0.2.0" + checksum: 10/16bd9eb8ac6630a05441bcb56522e956ae6a0724371ecc49b9a6bc10d35690489140df73573d0577e1e85c875737e560a4e2e67521fddd14714ddf4e0097d0ec + languageName: node + linkType: hard + "buffer-from@npm:^1.0.0": version: 1.1.2 resolution: "buffer-from@npm:1.1.2" @@ -8390,6 +9530,15 @@ __metadata: languageName: node linkType: hard +"chokidar@npm:^4.0.0": + version: 4.0.3 + resolution: "chokidar@npm:4.0.3" + dependencies: + readdirp: "npm:^4.0.1" + checksum: 10/bf2a575ea5596000e88f5db95461a9d59ad2047e939d5a4aac59dd472d126be8f1c1ff3c7654b477cf532d18f42a97279ef80ee847972fd2a25410bf00b80b59 + languageName: node + linkType: hard + "chownr@npm:^2.0.0": version: 2.0.0 resolution: "chownr@npm:2.0.0" @@ -8589,6 +9738,13 @@ __metadata: languageName: node linkType: hard +"colorjs.io@npm:^0.5.0": + version: 0.5.2 + resolution: "colorjs.io@npm:0.5.2" + checksum: 10/a6f6345865b177d19481008cb299c46ec9ff1fd206f472cd9ef69ddbca65832c81237b19fdcd24f3f9540c3e6343a22eb486cd800f5eab9815ce7c98c16a0f0e + languageName: node + linkType: hard + "combined-stream@npm:^1.0.8": version: 1.0.8 resolution: "combined-stream@npm:1.0.8" @@ -8857,6 +10013,13 @@ __metadata: languageName: node linkType: hard +"crelt@npm:^1.0.0": + version: 1.0.6 + resolution: "crelt@npm:1.0.6" + checksum: 10/5ed326ca6bd243b1dba6b943f665b21c2c04be03271824bc48f20dba324b0f8233e221f8c67312526d24af2b1243c023dc05a41bd8bd05d1a479fd2c72fb39c3 + languageName: node + linkType: hard + "cross-fetch@npm:^4.0.0": version: 4.0.0 resolution: "cross-fetch@npm:4.0.0" @@ -9296,6 +10459,15 @@ __metadata: languageName: node linkType: hard +"detect-libc@npm:^1.0.3": + version: 1.0.3 + resolution: "detect-libc@npm:1.0.3" + bin: + detect-libc: ./bin/detect-libc.js + checksum: 10/3849fe7720feb153e4ac9407086956e073f1ce1704488290ef0ca8aab9430a8d48c8a9f8351889e7cdc64e5b1128589501e4fef48f3a4a49ba92cd6d112d0757 + languageName: node + linkType: hard + "detect-libc@npm:^2.0.3, detect-libc@npm:^2.0.4": version: 2.0.4 resolution: "detect-libc@npm:2.0.4" @@ -9317,6 +10489,13 @@ __metadata: languageName: node linkType: hard +"detect-node-es@npm:^1.1.0": + version: 1.1.0 + resolution: "detect-node-es@npm:1.1.0" + checksum: 10/e46307d7264644975b71c104b9f028ed1d3d34b83a15b8a22373640ce5ea630e5640b1078b8ea15f202b54641da71e4aa7597093bd4b91f113db520a26a37449 + languageName: node + linkType: hard + "devlop@npm:^1.0.0, devlop@npm:^1.1.0": version: 1.1.0 resolution: "devlop@npm:1.1.0" @@ -9754,6 +10933,13 @@ __metadata: languageName: node linkType: hard +"entities@npm:^4.4.0": + version: 4.5.0 + resolution: "entities@npm:4.5.0" + checksum: 10/ede2a35c9bce1aeccd055a1b445d41c75a14a2bb1cd22e242f20cf04d236cdcd7f9c859eb83f76885327bfae0c25bf03303665ee1ce3d47c5927b98b0e3e3d48 + languageName: node + linkType: hard + "entities@npm:^6.0.0": version: 6.0.1 resolution: "entities@npm:6.0.1" @@ -11317,6 +12503,13 @@ __metadata: languageName: node linkType: hard +"get-nonce@npm:^1.0.0": + version: 1.0.1 + resolution: "get-nonce@npm:1.0.1" + checksum: 10/ad5104871d114a694ecc506a2d406e2331beccb961fe1e110dc25556b38bcdbf399a823a8a375976cd8889668156a9561e12ebe3fa6a4c6ba169c8466c2ff868 + languageName: node + linkType: hard + "get-package-type@npm:^0.1.0": version: 0.1.0 resolution: "get-package-type@npm:0.1.0" @@ -12131,6 +13324,13 @@ __metadata: languageName: node linkType: hard +"immutable@npm:^5.0.2": + version: 5.1.4 + resolution: "immutable@npm:5.1.4" + checksum: 10/0655b33af249ff99c7a56f9e6d7aee632af2dc25758710ddf224bda645f66dd2dd98119c0d86986895ea52cc889b6c5127a848c6fba21aadabdc4c5ead04be2b + languageName: node + linkType: hard + "import-fresh@npm:^3.2.1, import-fresh@npm:^3.3.0": version: 3.3.0 resolution: "import-fresh@npm:3.3.0" @@ -13859,6 +15059,22 @@ __metadata: languageName: node linkType: hard +"linkify-it@npm:^5.0.0": + version: 5.0.0 + resolution: "linkify-it@npm:5.0.0" + dependencies: + uc.micro: "npm:^2.0.0" + checksum: 10/ef3b7609dda6ec0c0be8a7b879cea195f0d36387b0011660cd6711bba0ad82137f59b458b7e703ec74f11d88e7c1328e2ad9b855a8500c0ded67461a8c4519e6 + languageName: node + linkType: hard + +"linkifyjs@npm:^4.3.2": + version: 4.3.2 + resolution: "linkifyjs@npm:4.3.2" + checksum: 10/b03477486658d1e5531bf65ee1fdc0f79423594e689184c67b8a63c75d9f35d1cd0344edd97d5799502cde4f3163d620e2cbd9e72ad718c6a95084177c004386 + languageName: node + linkType: hard + "load-plugin@npm:^6.0.0": version: 6.0.3 resolution: "load-plugin@npm:6.0.3" @@ -13949,6 +15165,13 @@ __metadata: languageName: node linkType: hard +"lodash.throttle@npm:^4.1.1": + version: 4.1.1 + resolution: "lodash.throttle@npm:4.1.1" + checksum: 10/9be9fb2ffd686c20543167883305542f4564062a5f712a40e8c6f2f0d9fd8254a6e9d801c2470b1b24e0cdf2ae83c1277b55aa0fb4799a2db6daf545f53820e1 + languageName: node + linkType: hard + "lodash.truncate@npm:^4.4.2": version: 4.4.2 resolution: "lodash.truncate@npm:4.4.2" @@ -14179,6 +15402,22 @@ __metadata: languageName: node linkType: hard +"markdown-it@npm:^14.0.0": + version: 14.1.0 + resolution: "markdown-it@npm:14.1.0" + dependencies: + argparse: "npm:^2.0.1" + entities: "npm:^4.4.0" + linkify-it: "npm:^5.0.0" + mdurl: "npm:^2.0.0" + punycode.js: "npm:^2.3.1" + uc.micro: "npm:^2.1.0" + bin: + markdown-it: bin/markdown-it.mjs + checksum: 10/f34f921be178ed0607ba9e3e27c733642be445e9bb6b1dba88da7aafe8ba1bc5d2f1c3aa8f3fc33b49a902da4e4c08c2feadfafb290b8c7dda766208bb6483a9 + languageName: node + linkType: hard + "markdown-table@npm:^3.0.0": version: 3.0.3 resolution: "markdown-table@npm:3.0.3" @@ -14520,6 +15759,13 @@ __metadata: languageName: node linkType: hard +"mdurl@npm:^2.0.0": + version: 2.0.0 + resolution: "mdurl@npm:2.0.0" + checksum: 10/1720349d4a53e401aa993241368e35c0ad13d816ad0b28388928c58ca9faa0cf755fa45f18ccbf64f4ce54a845a50ddce5c84e4016897b513096a68dac4b0158 + languageName: node + linkType: hard + "media-engine@npm:^1.0.3": version: 1.0.3 resolution: "media-engine@npm:1.0.3" @@ -15072,7 +16318,7 @@ __metadata: languageName: node linkType: hard -"micromatch@npm:^4.0.2, micromatch@npm:^4.0.4, micromatch@npm:^4.0.8": +"micromatch@npm:^4.0.2, micromatch@npm:^4.0.4, micromatch@npm:^4.0.5, micromatch@npm:^4.0.8": version: 4.0.8 resolution: "micromatch@npm:4.0.8" dependencies: @@ -15497,6 +16743,15 @@ __metadata: languageName: node linkType: hard +"node-addon-api@npm:^7.0.0": + version: 7.1.1 + resolution: "node-addon-api@npm:7.1.1" + dependencies: + node-gyp: "npm:latest" + checksum: 10/ee1e1ed6284a2f8cd1d59ac6175ecbabf8978dcf570345e9a8095a9d0a2b9ced591074ae77f9009287b00c402352b38aa9322a34f2199cdc9f567b842a636b94 + languageName: node + linkType: hard + "node-fetch@npm:^2.6.12, node-fetch@npm:^2.6.7": version: 2.7.0 resolution: "node-fetch@npm:2.7.0" @@ -15808,11 +17063,14 @@ __metadata: "@emotion/react": "npm:^11.11.1" "@emotion/styled": "npm:^11.11.0" "@faker-js/faker": "npm:^10.0.0" + "@floating-ui/react": "npm:^0.27.16" "@mui/base": "npm:5.0.0-beta.70" "@mui/lab": "npm:6.0.0-dev.240424162023-9968b4889d" "@mui/material": "npm:^6.4.5" "@mui/material-nextjs": "npm:^6.4.3" "@mui/system": "npm:^6.4.3" + "@radix-ui/react-dropdown-menu": "npm:^2.1.16" + "@radix-ui/react-popover": "npm:^1.1.15" "@remixicon/react": "npm:^4.2.0" "@storybook/addon-actions": "npm:^8.2.9" "@storybook/addon-essentials": "npm:^8.2.9" @@ -15830,6 +17088,19 @@ __metadata: "@testing-library/dom": "npm:^10.4.0" "@testing-library/react": "npm:^16.3.0" "@testing-library/user-event": "npm:^14.5.2" + "@tiptap/extension-highlight": "npm:^3.10.5" + "@tiptap/extension-horizontal-rule": "npm:^3.10.5" + "@tiptap/extension-image": "npm:^3.10.5" + "@tiptap/extension-list": "npm:^3.10.5" + "@tiptap/extension-subscript": "npm:^3.10.5" + "@tiptap/extension-superscript": "npm:^3.10.5" + "@tiptap/extension-text-align": "npm:^3.10.5" + "@tiptap/extension-typography": "npm:^3.10.5" + "@tiptap/extensions": "npm:^3.10.5" + "@tiptap/pm": "npm:^3.10.5" + "@tiptap/react": "npm:^3.10.5" + "@tiptap/starter-kit": "npm:^3.10.5" + "@types/lodash.throttle": "npm:^4.1.9" "@types/react-dom": "npm:^19" "@types/react-slick": "npm:^0" "@types/tinycolor2": "npm:^1.4.6" @@ -15839,6 +17110,7 @@ __metadata: embla-carousel-react: "npm:^8.6.0" embla-carousel-wheel-gestures: "npm:^8.0.2" lodash: "npm:^4.17.21" + lodash.throttle: "npm:^4.1.1" material-ui-popup-state: "npm:^5.1.0" next: "npm:^15.5.2" ol-test-utilities: "npm:0.0.0" @@ -15846,9 +17118,12 @@ __metadata: prop-types: "npm:^15.8.1" react: "npm:^19.0.0" react-dom: "npm:^19.0.0" + react-hotkeys-hook: "npm:^5.2.1" react-select: "npm:^5.7.7" react-share: "npm:^5.0.3" react-slick: "npm:^0.30.2" + sass: "npm:^1.93.3" + sass-embedded: "npm:^1.93.3" storybook: "npm:^8.2.9" tiny-invariant: "npm:^1.3.1" tinycolor2: "npm:^1.6.0" @@ -15996,6 +17271,13 @@ __metadata: languageName: node linkType: hard +"orderedmap@npm:^2.0.0": + version: 2.1.1 + resolution: "orderedmap@npm:2.1.1" + checksum: 10/082cf970b0b66d1c5a904b07880534092ce8a2f2eea7a52cf111f6c956210fa88226c13866aef4d22a3abe56924f21ead12f7ee8c1dfaf2f63d897a4e7c23328 + languageName: node + linkType: hard + "os-browserify@npm:^0.3.0": version: 0.3.0 resolution: "os-browserify@npm:0.3.0" @@ -16704,69 +17986,263 @@ __metadata: languageName: node linkType: hard -"progress@npm:^2.0.3": - version: 2.0.3 - resolution: "progress@npm:2.0.3" - checksum: 10/e6f0bcb71f716eee9dfac0fe8a2606e3704d6a64dd93baaf49fbadbc8499989a610fe14cf1bc6f61b6d6653c49408d94f4a94e124538084efd8e4cf525e0293d +"progress@npm:^2.0.3": + version: 2.0.3 + resolution: "progress@npm:2.0.3" + checksum: 10/e6f0bcb71f716eee9dfac0fe8a2606e3704d6a64dd93baaf49fbadbc8499989a610fe14cf1bc6f61b6d6653c49408d94f4a94e124538084efd8e4cf525e0293d + languageName: node + linkType: hard + +"promise-inflight@npm:^1.0.1": + version: 1.0.1 + resolution: "promise-inflight@npm:1.0.1" + checksum: 10/1560d413ea20c5a74f3631d39ba8cbd1972b9228072a755d01e1f5ca5110382d9af76a1582d889445adc6e75bb5ac4886b56dc4b6eae51b30145d7bb1ac7505b + languageName: node + linkType: hard + +"promise-retry@npm:^2.0.1": + version: 2.0.1 + resolution: "promise-retry@npm:2.0.1" + dependencies: + err-code: "npm:^2.0.2" + retry: "npm:^0.12.0" + checksum: 10/96e1a82453c6c96eef53a37a1d6134c9f2482f94068f98a59145d0986ca4e497bf110a410adf73857e588165eab3899f0ebcf7b3890c1b3ce802abc0d65967d4 + languageName: node + linkType: hard + +"prompts@npm:2.4.2, prompts@npm:^2.0.1": + version: 2.4.2 + resolution: "prompts@npm:2.4.2" + dependencies: + kleur: "npm:^3.0.3" + sisteransi: "npm:^1.0.5" + checksum: 10/c52536521a4d21eff4f2f2aa4572446cad227464066365a7167e52ccf8d9839c099f9afec1aba0eed3d5a2514b3e79e0b3e7a1dc326b9acde6b75d27ed74b1a9 + languageName: node + linkType: hard + +"prop-types@npm:^15.6.0, prop-types@npm:^15.6.2, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1": + version: 15.8.1 + resolution: "prop-types@npm:15.8.1" + dependencies: + loose-envify: "npm:^1.4.0" + object-assign: "npm:^4.1.1" + react-is: "npm:^16.13.1" + checksum: 10/7d959caec002bc964c86cdc461ec93108b27337dabe6192fb97d69e16a0c799a03462713868b40749bfc1caf5f57ef80ac3e4ffad3effa636ee667582a75e2c0 + languageName: node + linkType: hard + +"property-expr@npm:^2.0.5": + version: 2.0.6 + resolution: "property-expr@npm:2.0.6" + checksum: 10/89977f4bb230736c1876f460dd7ca9328034502fd92e738deb40516d16564b850c0bbc4e052c3df88b5b8cd58e51c93b46a94bea049a3f23f4a022c038864cab + languageName: node + linkType: hard + +"property-information@npm:^6.0.0": + version: 6.5.0 + resolution: "property-information@npm:6.5.0" + checksum: 10/fced94f3a09bf651ad1824d1bdc8980428e3e480e6d01e98df6babe2cc9d45a1c52eee9a7736d2006958f9b394eb5964dedd37e23038086ddc143fc2fd5e426c + languageName: node + linkType: hard + +"property-information@npm:^7.0.0": + version: 7.0.0 + resolution: "property-information@npm:7.0.0" + checksum: 10/55f443088456cddc2fe499d6f5895e68cbd465e39dc318ecc63a0d2432d1b918f51fb6d13f8b1adf8a78337bc4e608baa6e46afbe0c6d50d2e38588b2c409f86 + languageName: node + linkType: hard + +"prosemirror-changeset@npm:^2.3.0": + version: 2.3.1 + resolution: "prosemirror-changeset@npm:2.3.1" + dependencies: + prosemirror-transform: "npm:^1.0.0" + checksum: 10/a951daee431b9ff6a2aa24ce1eb755ef3c1fba72191760289ed9f91abbccb8e98a5c24697598b93df496662509bfd0345a52ed9a63d9535190ba3a1802a53c10 + languageName: node + linkType: hard + +"prosemirror-collab@npm:^1.3.1": + version: 1.3.1 + resolution: "prosemirror-collab@npm:1.3.1" + dependencies: + prosemirror-state: "npm:^1.0.0" + checksum: 10/6b1ccc52841fbb62a39ef0fb8da2d731381030609ea7a0ba7d533b1937d56fe4b91344e79c023e790bed5392efe9f917c41c8434e0a379dc1dc842ba83594e34 + languageName: node + linkType: hard + +"prosemirror-commands@npm:^1.0.0, prosemirror-commands@npm:^1.6.2": + version: 1.7.1 + resolution: "prosemirror-commands@npm:1.7.1" + dependencies: + prosemirror-model: "npm:^1.0.0" + prosemirror-state: "npm:^1.0.0" + prosemirror-transform: "npm:^1.10.2" + checksum: 10/60efe77a9a6b9f06d66442980946f49a4804c24a7aca06ee4d55333a6c55d0f1e3189613bc27f31d16496133969a454f29f071caa8ef2d38126aefbc36f81c4a + languageName: node + linkType: hard + +"prosemirror-dropcursor@npm:^1.8.1": + version: 1.8.2 + resolution: "prosemirror-dropcursor@npm:1.8.2" + dependencies: + prosemirror-state: "npm:^1.0.0" + prosemirror-transform: "npm:^1.1.0" + prosemirror-view: "npm:^1.1.0" + checksum: 10/02349c56152d0261c61d462b07684bb8179ab0ea488ab333dadcc9181b9ec8d5aa625feb2c54088090bd8f4e540321219938a0642d8ed043fea8a11f371ef058 + languageName: node + linkType: hard + +"prosemirror-gapcursor@npm:^1.3.2": + version: 1.4.0 + resolution: "prosemirror-gapcursor@npm:1.4.0" + dependencies: + prosemirror-keymap: "npm:^1.0.0" + prosemirror-model: "npm:^1.0.0" + prosemirror-state: "npm:^1.0.0" + prosemirror-view: "npm:^1.0.0" + checksum: 10/ec17d7ca4d9b134d8db04180a9d399a0552373d6a7491fbf1720c15b5ca6d6f617485dcabed774961cc9b1ce01de99796505b30a0fcb8372f9cdff8966b09a7f + languageName: node + linkType: hard + +"prosemirror-history@npm:^1.0.0, prosemirror-history@npm:^1.4.1": + version: 1.4.1 + resolution: "prosemirror-history@npm:1.4.1" + dependencies: + prosemirror-state: "npm:^1.2.2" + prosemirror-transform: "npm:^1.0.0" + prosemirror-view: "npm:^1.31.0" + rope-sequence: "npm:^1.3.0" + checksum: 10/7ac68fc8233dcd159bb15c2aaf542fd9aa0524b50523b24de6c8209b1f5eae9545f7fa82d584c93e68b1e910bcae5e07bee1085094aca4c565c607cf737c39b8 + languageName: node + linkType: hard + +"prosemirror-inputrules@npm:^1.4.0": + version: 1.5.1 + resolution: "prosemirror-inputrules@npm:1.5.1" + dependencies: + prosemirror-state: "npm:^1.0.0" + prosemirror-transform: "npm:^1.0.0" + checksum: 10/81f08415a39ad795a2a097c8667eb86e4473eca1389c793139d6bacbb69be740732e8ff8f21b2808130bacd7f53a1b0b7621b48a22ff44d902995b41654e4b80 + languageName: node + linkType: hard + +"prosemirror-keymap@npm:^1.0.0, prosemirror-keymap@npm:^1.2.2": + version: 1.2.3 + resolution: "prosemirror-keymap@npm:1.2.3" + dependencies: + prosemirror-state: "npm:^1.0.0" + w3c-keyname: "npm:^2.2.0" + checksum: 10/acb251b03f57920282342bf404755de6ff68e150aaf252aca9bbeb63bbba8f53e03091095d399def979c95db0649620a22a47a332418c3a2408a0f43737b18d9 + languageName: node + linkType: hard + +"prosemirror-markdown@npm:^1.13.1": + version: 1.13.2 + resolution: "prosemirror-markdown@npm:1.13.2" + dependencies: + "@types/markdown-it": "npm:^14.0.0" + markdown-it: "npm:^14.0.0" + prosemirror-model: "npm:^1.25.0" + checksum: 10/805f5b5b246250ebd14aedb3de5b683c637c58e0ecf13b6a6fedcbec7e761700e1d9c1371d5a24b10577cd574db5a240a883175b80a87fec24165c16c47ca9aa + languageName: node + linkType: hard + +"prosemirror-menu@npm:^1.2.4": + version: 1.2.5 + resolution: "prosemirror-menu@npm:1.2.5" + dependencies: + crelt: "npm:^1.0.0" + prosemirror-commands: "npm:^1.0.0" + prosemirror-history: "npm:^1.0.0" + prosemirror-state: "npm:^1.0.0" + checksum: 10/68ccff3793906a70ef9e64d953027cf231c045119e62ef37a4631f012066c546bee96454ca98103382bf8699e9040e2e6551c518259543ffb149d4d6609114b5 + languageName: node + linkType: hard + +"prosemirror-model@npm:^1.0.0, prosemirror-model@npm:^1.20.0, prosemirror-model@npm:^1.21.0, prosemirror-model@npm:^1.24.1, prosemirror-model@npm:^1.25.0": + version: 1.25.4 + resolution: "prosemirror-model@npm:1.25.4" + dependencies: + orderedmap: "npm:^2.0.0" + checksum: 10/63c5d6dd3b70e42650f07b4a2ed87e7442291b1f95a9930bf4ff2f7c6a1228e95db0e996cdabed667f4cbaf67c3ee290e19b91a31dd199da95477ac4d1b868e3 languageName: node linkType: hard -"promise-inflight@npm:^1.0.1": - version: 1.0.1 - resolution: "promise-inflight@npm:1.0.1" - checksum: 10/1560d413ea20c5a74f3631d39ba8cbd1972b9228072a755d01e1f5ca5110382d9af76a1582d889445adc6e75bb5ac4886b56dc4b6eae51b30145d7bb1ac7505b +"prosemirror-schema-basic@npm:^1.2.3": + version: 1.2.4 + resolution: "prosemirror-schema-basic@npm:1.2.4" + dependencies: + prosemirror-model: "npm:^1.25.0" + checksum: 10/51972732657b7eca6d0fc294d980f7ef0079acee962451280ef79e59790717a1f65e9227364de8780caa8e1e5c7d48a57b3b58488d24e23b8b035ea7ec0ec37a languageName: node linkType: hard -"promise-retry@npm:^2.0.1": - version: 2.0.1 - resolution: "promise-retry@npm:2.0.1" +"prosemirror-schema-list@npm:^1.5.0": + version: 1.5.1 + resolution: "prosemirror-schema-list@npm:1.5.1" dependencies: - err-code: "npm:^2.0.2" - retry: "npm:^0.12.0" - checksum: 10/96e1a82453c6c96eef53a37a1d6134c9f2482f94068f98a59145d0986ca4e497bf110a410adf73857e588165eab3899f0ebcf7b3890c1b3ce802abc0d65967d4 + prosemirror-model: "npm:^1.0.0" + prosemirror-state: "npm:^1.0.0" + prosemirror-transform: "npm:^1.7.3" + checksum: 10/eaf308093ecbce7fc7d0e3c3653faa120afb3679bdb20c99f79c75c90b4f57d61c6580ea162dd0af274013e98c5d4529ecbe6b9bd7e66e7d6d6b5be6aae0f052 languageName: node linkType: hard -"prompts@npm:2.4.2, prompts@npm:^2.0.1": - version: 2.4.2 - resolution: "prompts@npm:2.4.2" +"prosemirror-state@npm:^1.0.0, prosemirror-state@npm:^1.2.2, prosemirror-state@npm:^1.4.3": + version: 1.4.4 + resolution: "prosemirror-state@npm:1.4.4" dependencies: - kleur: "npm:^3.0.3" - sisteransi: "npm:^1.0.5" - checksum: 10/c52536521a4d21eff4f2f2aa4572446cad227464066365a7167e52ccf8d9839c099f9afec1aba0eed3d5a2514b3e79e0b3e7a1dc326b9acde6b75d27ed74b1a9 + prosemirror-model: "npm:^1.0.0" + prosemirror-transform: "npm:^1.0.0" + prosemirror-view: "npm:^1.27.0" + checksum: 10/90e66cbc49f2eceeb174f5184ed94df432a8b83866ab74ef602d21555dd151eb1ff9a0794b12d98384981601ebaa885d930f6c667165ff969874944a2f9e2488 languageName: node linkType: hard -"prop-types@npm:^15.6.0, prop-types@npm:^15.6.2, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1": - version: 15.8.1 - resolution: "prop-types@npm:15.8.1" +"prosemirror-tables@npm:^1.6.4": + version: 1.8.1 + resolution: "prosemirror-tables@npm:1.8.1" dependencies: - loose-envify: "npm:^1.4.0" - object-assign: "npm:^4.1.1" - react-is: "npm:^16.13.1" - checksum: 10/7d959caec002bc964c86cdc461ec93108b27337dabe6192fb97d69e16a0c799a03462713868b40749bfc1caf5f57ef80ac3e4ffad3effa636ee667582a75e2c0 + prosemirror-keymap: "npm:^1.2.2" + prosemirror-model: "npm:^1.25.0" + prosemirror-state: "npm:^1.4.3" + prosemirror-transform: "npm:^1.10.3" + prosemirror-view: "npm:^1.39.1" + checksum: 10/d2bc4cd5e17cf51d5a822b1a5db318a5e0e7784a76ae54a87eb43a50359dda8f181b9bee54c59c72b29b7d37ec417a70c16085093810752ee03296b989eb546b languageName: node linkType: hard -"property-expr@npm:^2.0.5": - version: 2.0.6 - resolution: "property-expr@npm:2.0.6" - checksum: 10/89977f4bb230736c1876f460dd7ca9328034502fd92e738deb40516d16564b850c0bbc4e052c3df88b5b8cd58e51c93b46a94bea049a3f23f4a022c038864cab +"prosemirror-trailing-node@npm:^3.0.0": + version: 3.0.0 + resolution: "prosemirror-trailing-node@npm:3.0.0" + dependencies: + "@remirror/core-constants": "npm:3.0.0" + escape-string-regexp: "npm:^4.0.0" + peerDependencies: + prosemirror-model: ^1.22.1 + prosemirror-state: ^1.4.2 + prosemirror-view: ^1.33.8 + checksum: 10/044b199b8001373c1bd4c1573876597840df89e66c1f02497a8bb4f2885ebe830faa9764e1269ed6c24bf2fde06ad5f40322afde648ae331d4663f531000adaa languageName: node linkType: hard -"property-information@npm:^6.0.0": - version: 6.5.0 - resolution: "property-information@npm:6.5.0" - checksum: 10/fced94f3a09bf651ad1824d1bdc8980428e3e480e6d01e98df6babe2cc9d45a1c52eee9a7736d2006958f9b394eb5964dedd37e23038086ddc143fc2fd5e426c +"prosemirror-transform@npm:^1.0.0, prosemirror-transform@npm:^1.1.0, prosemirror-transform@npm:^1.10.2, prosemirror-transform@npm:^1.10.3, prosemirror-transform@npm:^1.7.3": + version: 1.10.4 + resolution: "prosemirror-transform@npm:1.10.4" + dependencies: + prosemirror-model: "npm:^1.21.0" + checksum: 10/a5835bdd7e66e455f52115d63b48a1b9c6a0741fdefa277894c7ed4f5baf3570b226a135b11036abaa7fe7c5e98de466692779d356e65302b8e929a13a1c4391 languageName: node linkType: hard -"property-information@npm:^7.0.0": - version: 7.0.0 - resolution: "property-information@npm:7.0.0" - checksum: 10/55f443088456cddc2fe499d6f5895e68cbd465e39dc318ecc63a0d2432d1b918f51fb6d13f8b1adf8a78337bc4e608baa6e46afbe0c6d50d2e38588b2c409f86 +"prosemirror-view@npm:^1.0.0, prosemirror-view@npm:^1.1.0, prosemirror-view@npm:^1.27.0, prosemirror-view@npm:^1.31.0, prosemirror-view@npm:^1.38.1, prosemirror-view@npm:^1.39.1": + version: 1.41.3 + resolution: "prosemirror-view@npm:1.41.3" + dependencies: + prosemirror-model: "npm:^1.20.0" + prosemirror-state: "npm:^1.0.0" + prosemirror-transform: "npm:^1.1.0" + checksum: 10/2cd8c29c28f6061f31cac371667baa47a7f20705897f31b8ba4fb6d2dc226f9ce38a91068e3faf8b40c7f76727c3953df293223dc6aee0079b5dc3a44ccb7dca languageName: node linkType: hard @@ -16808,6 +18284,13 @@ __metadata: languageName: node linkType: hard +"punycode.js@npm:^2.3.1": + version: 2.3.1 + resolution: "punycode.js@npm:2.3.1" + checksum: 10/f0e946d1edf063f9e3d30a32ca86d8ff90ed13ca40dad9c75d37510a04473340cfc98db23a905cc1e517b1e9deb0f6021dce6f422ace235c60d3c9ac47c5a16a + languageName: node + linkType: hard + "punycode@npm:^1.4.1": version: 1.4.1 resolution: "punycode@npm:1.4.1" @@ -17018,6 +18501,16 @@ __metadata: languageName: node linkType: hard +"react-hotkeys-hook@npm:^5.2.1": + version: 5.2.1 + resolution: "react-hotkeys-hook@npm:5.2.1" + peerDependencies: + react: ">=16.8.0" + react-dom: ">=16.8.0" + checksum: 10/4581b6bc2496954b5bd3c41924aba28f0d2e4a996476f38cce80e777b6a63f51e024992a11c2d7d4b4abe4815e0bba04fa692a0ed28aeb5f58df7852a77aab70 + languageName: node + linkType: hard + "react-is@npm:18.1.0": version: 18.1.0 resolution: "react-is@npm:18.1.0" @@ -17082,6 +18575,41 @@ __metadata: languageName: node linkType: hard +"react-remove-scroll-bar@npm:^2.3.7": + version: 2.3.8 + resolution: "react-remove-scroll-bar@npm:2.3.8" + dependencies: + react-style-singleton: "npm:^2.2.2" + tslib: "npm:^2.0.0" + peerDependencies: + "@types/react": "*" + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10/6c0f8cff98b9f49a4ee2263f1eedf12926dced5ce220fbe83bd93544460e2a7ec8ec39b35d1b2a75d2fced0b2d64afeb8e66f830431ca896e05a20585f9fc350 + languageName: node + linkType: hard + +"react-remove-scroll@npm:^2.6.3": + version: 2.7.1 + resolution: "react-remove-scroll@npm:2.7.1" + dependencies: + react-remove-scroll-bar: "npm:^2.3.7" + react-style-singleton: "npm:^2.2.3" + tslib: "npm:^2.1.0" + use-callback-ref: "npm:^1.3.3" + use-sidecar: "npm:^1.1.3" + peerDependencies: + "@types/react": "*" + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10/5e571ba35ba527047c54c9c4a271363167770556fb85ee45ead8310673197719425cc8f7a2b7f672abf530294c41c8c34bdae325a571994cc1e694b664b52734 + languageName: node + linkType: hard + "react-select@npm:^5.7.7": version: 5.8.1 resolution: "react-select@npm:5.8.1" @@ -17130,6 +18658,22 @@ __metadata: languageName: node linkType: hard +"react-style-singleton@npm:^2.2.2, react-style-singleton@npm:^2.2.3": + version: 2.2.3 + resolution: "react-style-singleton@npm:2.2.3" + dependencies: + get-nonce: "npm:^1.0.0" + tslib: "npm:^2.0.0" + peerDependencies: + "@types/react": "*" + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10/62498094ff3877a37f351b29e6cad9e38b2eb1ac3c0cb27ebf80aee96554f80b35e17bdb552bcd7ac8b7cb9904fea93ea5668f2057c73d38f90b5d46bb9b27ab + languageName: node + linkType: hard + "react-transition-group@npm:^4.3.0, react-transition-group@npm:^4.4.5": version: 4.4.5 resolution: "react-transition-group@npm:4.4.5" @@ -17220,6 +18764,13 @@ __metadata: languageName: node linkType: hard +"readdirp@npm:^4.0.1": + version: 4.1.2 + resolution: "readdirp@npm:4.1.2" + checksum: 10/7b817c265940dba90bb9c94d82920d76c3a35ea2d67f9f9d8bd936adcfe02d50c802b14be3dd2e725e002dddbe2cc1c7a0edfb1bc3a365c9dfd5a61e612eea1e + languageName: node + linkType: hard + "readdirp@npm:~3.6.0": version: 3.6.0 resolution: "readdirp@npm:3.6.0" @@ -17766,6 +19317,13 @@ __metadata: languageName: node linkType: hard +"rope-sequence@npm:^1.3.0": + version: 1.3.4 + resolution: "rope-sequence@npm:1.3.4" + checksum: 10/57b5dd8c28ece05bb5f33eea6ea56facb00d4893269bb83aa8656f69065c1bc0707ec9bb816bce0e5f4d489d88942c7f0f0a1c3655773753ef158c9dd0e9456d + languageName: node + linkType: hard + "rrweb-cssom@npm:^0.8.0": version: 0.8.0 resolution: "rrweb-cssom@npm:0.8.0" @@ -17782,6 +19340,15 @@ __metadata: languageName: node linkType: hard +"rxjs@npm:^7.4.0": + version: 7.8.2 + resolution: "rxjs@npm:7.8.2" + dependencies: + tslib: "npm:^2.1.0" + checksum: 10/03dff09191356b2b87d94fbc1e97c4e9eb3c09d4452399dddd451b09c2f1ba8d56925a40af114282d7bc0c6fe7514a2236ca09f903cf70e4bbf156650dddb49d + languageName: node + linkType: hard + "sade@npm:^1.7.3": version: 1.8.1 resolution: "sade@npm:1.8.1" @@ -17835,6 +19402,209 @@ __metadata: languageName: node linkType: hard +"sass-embedded-all-unknown@npm:1.93.3": + version: 1.93.3 + resolution: "sass-embedded-all-unknown@npm:1.93.3" + dependencies: + sass: "npm:1.93.3" + conditions: (!cpu=arm | !cpu=arm64 | !cpu=riscv64 | !cpu=x64) + languageName: node + linkType: hard + +"sass-embedded-android-arm64@npm:1.93.3": + version: 1.93.3 + resolution: "sass-embedded-android-arm64@npm:1.93.3" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"sass-embedded-android-arm@npm:1.93.3": + version: 1.93.3 + resolution: "sass-embedded-android-arm@npm:1.93.3" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + +"sass-embedded-android-riscv64@npm:1.93.3": + version: 1.93.3 + resolution: "sass-embedded-android-riscv64@npm:1.93.3" + conditions: os=android & cpu=riscv64 + languageName: node + linkType: hard + +"sass-embedded-android-x64@npm:1.93.3": + version: 1.93.3 + resolution: "sass-embedded-android-x64@npm:1.93.3" + conditions: os=android & cpu=x64 + languageName: node + linkType: hard + +"sass-embedded-darwin-arm64@npm:1.93.3": + version: 1.93.3 + resolution: "sass-embedded-darwin-arm64@npm:1.93.3" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"sass-embedded-darwin-x64@npm:1.93.3": + version: 1.93.3 + resolution: "sass-embedded-darwin-x64@npm:1.93.3" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"sass-embedded-linux-arm64@npm:1.93.3": + version: 1.93.3 + resolution: "sass-embedded-linux-arm64@npm:1.93.3" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + +"sass-embedded-linux-arm@npm:1.93.3": + version: 1.93.3 + resolution: "sass-embedded-linux-arm@npm:1.93.3" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"sass-embedded-linux-musl-arm64@npm:1.93.3": + version: 1.93.3 + resolution: "sass-embedded-linux-musl-arm64@npm:1.93.3" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + +"sass-embedded-linux-musl-arm@npm:1.93.3": + version: 1.93.3 + resolution: "sass-embedded-linux-musl-arm@npm:1.93.3" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"sass-embedded-linux-musl-riscv64@npm:1.93.3": + version: 1.93.3 + resolution: "sass-embedded-linux-musl-riscv64@npm:1.93.3" + conditions: os=linux & cpu=riscv64 + languageName: node + linkType: hard + +"sass-embedded-linux-musl-x64@npm:1.93.3": + version: 1.93.3 + resolution: "sass-embedded-linux-musl-x64@npm:1.93.3" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + +"sass-embedded-linux-riscv64@npm:1.93.3": + version: 1.93.3 + resolution: "sass-embedded-linux-riscv64@npm:1.93.3" + conditions: os=linux & cpu=riscv64 + languageName: node + linkType: hard + +"sass-embedded-linux-x64@npm:1.93.3": + version: 1.93.3 + resolution: "sass-embedded-linux-x64@npm:1.93.3" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + +"sass-embedded-unknown-all@npm:1.93.3": + version: 1.93.3 + resolution: "sass-embedded-unknown-all@npm:1.93.3" + dependencies: + sass: "npm:1.93.3" + conditions: (!os=android | !os=darwin | !os=linux | !os=win32) + languageName: node + linkType: hard + +"sass-embedded-win32-arm64@npm:1.93.3": + version: 1.93.3 + resolution: "sass-embedded-win32-arm64@npm:1.93.3" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"sass-embedded-win32-x64@npm:1.93.3": + version: 1.93.3 + resolution: "sass-embedded-win32-x64@npm:1.93.3" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"sass-embedded@npm:^1.93.3": + version: 1.93.3 + resolution: "sass-embedded@npm:1.93.3" + dependencies: + "@bufbuild/protobuf": "npm:^2.5.0" + buffer-builder: "npm:^0.2.0" + colorjs.io: "npm:^0.5.0" + immutable: "npm:^5.0.2" + rxjs: "npm:^7.4.0" + sass-embedded-all-unknown: "npm:1.93.3" + sass-embedded-android-arm: "npm:1.93.3" + sass-embedded-android-arm64: "npm:1.93.3" + sass-embedded-android-riscv64: "npm:1.93.3" + sass-embedded-android-x64: "npm:1.93.3" + sass-embedded-darwin-arm64: "npm:1.93.3" + sass-embedded-darwin-x64: "npm:1.93.3" + sass-embedded-linux-arm: "npm:1.93.3" + sass-embedded-linux-arm64: "npm:1.93.3" + sass-embedded-linux-musl-arm: "npm:1.93.3" + sass-embedded-linux-musl-arm64: "npm:1.93.3" + sass-embedded-linux-musl-riscv64: "npm:1.93.3" + sass-embedded-linux-musl-x64: "npm:1.93.3" + sass-embedded-linux-riscv64: "npm:1.93.3" + sass-embedded-linux-x64: "npm:1.93.3" + sass-embedded-unknown-all: "npm:1.93.3" + sass-embedded-win32-arm64: "npm:1.93.3" + sass-embedded-win32-x64: "npm:1.93.3" + supports-color: "npm:^8.1.1" + sync-child-process: "npm:^1.0.2" + varint: "npm:^6.0.0" + dependenciesMeta: + sass-embedded-all-unknown: + optional: true + sass-embedded-android-arm: + optional: true + sass-embedded-android-arm64: + optional: true + sass-embedded-android-riscv64: + optional: true + sass-embedded-android-x64: + optional: true + sass-embedded-darwin-arm64: + optional: true + sass-embedded-darwin-x64: + optional: true + sass-embedded-linux-arm: + optional: true + sass-embedded-linux-arm64: + optional: true + sass-embedded-linux-musl-arm: + optional: true + sass-embedded-linux-musl-arm64: + optional: true + sass-embedded-linux-musl-riscv64: + optional: true + sass-embedded-linux-musl-x64: + optional: true + sass-embedded-linux-riscv64: + optional: true + sass-embedded-linux-x64: + optional: true + sass-embedded-unknown-all: + optional: true + sass-embedded-win32-arm64: + optional: true + sass-embedded-win32-x64: + optional: true + bin: + sass: dist/bin/sass.js + checksum: 10/e2a1d6a31da76ce94df75f690a434ecd6467209eca6333951f1008a17d54693643e8a7cf2e82e0514f07f98b6de17dcf2b2fdadd5ad8abdac113c40c350fe154 + languageName: node + linkType: hard + "sass-loader@npm:^13.2.0": version: 13.3.3 resolution: "sass-loader@npm:13.3.3" @@ -17859,6 +19629,23 @@ __metadata: languageName: node linkType: hard +"sass@npm:1.93.3, sass@npm:^1.93.3": + version: 1.93.3 + resolution: "sass@npm:1.93.3" + dependencies: + "@parcel/watcher": "npm:^2.4.1" + chokidar: "npm:^4.0.0" + immutable: "npm:^5.0.2" + source-map-js: "npm:>=0.6.2 <2.0.0" + dependenciesMeta: + "@parcel/watcher": + optional: true + bin: + sass: sass.js + checksum: 10/41f23b10bb203ee46b82b880e566edc3264cd00b0424bb7293c6aedb66fd4d6b9b7a217e91f98fb4653eee6538150bbe1a663abde03ad69cd5172beebf108ae0 + languageName: node + linkType: hard + "saxes@npm:^6.0.0": version: 6.0.0 resolution: "saxes@npm:6.0.0" @@ -18413,7 +20200,7 @@ __metadata: languageName: node linkType: hard -"source-map-js@npm:^1.0.1, source-map-js@npm:^1.0.2, source-map-js@npm:^1.2.1": +"source-map-js@npm:>=0.6.2 <2.0.0, source-map-js@npm:^1.0.1, source-map-js@npm:^1.0.2, source-map-js@npm:^1.2.1": version: 1.2.1 resolution: "source-map-js@npm:1.2.1" checksum: 10/ff9d8c8bf096d534a5b7707e0382ef827b4dd360a577d3f34d2b9f48e12c9d230b5747974ee7c607f0df65113732711bb701fe9ece3c7edbd43cb2294d707df3 @@ -19033,7 +20820,7 @@ __metadata: languageName: node linkType: hard -"supports-color@npm:^8.0.0": +"supports-color@npm:^8.0.0, supports-color@npm:^8.1.1": version: 8.1.1 resolution: "supports-color@npm:8.1.1" dependencies: @@ -19111,6 +20898,22 @@ __metadata: languageName: node linkType: hard +"sync-child-process@npm:^1.0.2": + version: 1.0.2 + resolution: "sync-child-process@npm:1.0.2" + dependencies: + sync-message-port: "npm:^1.0.0" + checksum: 10/6fbdbb7b6f5730a1966d6a77cdbfe7f5cb8d1a582dab955c62c32b56dc6c432ccdbfc68027265486f8f4b1a998cc4d7ee21856e8125748bef70b8874aaedb21c + languageName: node + linkType: hard + +"sync-message-port@npm:^1.0.0": + version: 1.1.3 + resolution: "sync-message-port@npm:1.1.3" + checksum: 10/a84b681afd678f28af4498074c4bc5cd5c763395fbf169f1bc9777c2e01aa8d41a3046dcca43a41e81102a7fd697713dfc03e155d1c662fec88af9481b249b8a + languageName: node + linkType: hard + "synckit@npm:^0.9.0": version: 0.9.2 resolution: "synckit@npm:0.9.2" @@ -19158,6 +20961,13 @@ __metadata: languageName: node linkType: hard +"tabbable@npm:^6.0.0": + version: 6.3.0 + resolution: "tabbable@npm:6.3.0" + checksum: 10/3e54a0b770d26bc20c3de5837652be19f5efa8bfa869f580af24bcf60de934506e9401a577213186b5e86ebcf6b5290a5429d354cc3041471815f5095e44e51a + languageName: node + linkType: hard + "table@npm:^6.9.0": version: 6.9.0 resolution: "table@npm:6.9.0" @@ -19628,7 +21438,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.0.3, tslib@npm:^2.4.0, tslib@npm:^2.6.2, tslib@npm:^2.8.0": +"tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.0.3, tslib@npm:^2.1.0, tslib@npm:^2.4.0, tslib@npm:^2.6.2, tslib@npm:^2.8.0": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: 10/3e2e043d5c2316461cb54e5c7fe02c30ef6dccb3384717ca22ae5c6b5bc95232a6241df19c622d9c73b809bea33b187f6dbc73030963e29950c2141bc32a79f7 @@ -19805,6 +21615,13 @@ __metadata: languageName: node linkType: hard +"uc.micro@npm:^2.0.0, uc.micro@npm:^2.1.0": + version: 2.1.0 + resolution: "uc.micro@npm:2.1.0" + checksum: 10/37197358242eb9afe367502d4638ac8c5838b78792ab218eafe48287b0ed28aaca268ec0392cc5729f6c90266744de32c06ae938549aee041fc93b0f9672d6b2 + languageName: node + linkType: hard + "unbox-primitive@npm:^1.0.2": version: 1.0.2 resolution: "unbox-primitive@npm:1.0.2" @@ -20221,6 +22038,21 @@ __metadata: languageName: node linkType: hard +"use-callback-ref@npm:^1.3.3": + version: 1.3.3 + resolution: "use-callback-ref@npm:1.3.3" + dependencies: + tslib: "npm:^2.0.0" + peerDependencies: + "@types/react": "*" + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10/adf06a7b6a27d3651c325ac9b66d2b82ccacaed7450b85b211d123e91d9a23cb5a587fcc6db5b4fd07ac7233e5abf024d30cf02ddc2ec46bca712151c0836151 + languageName: node + linkType: hard + "use-isomorphic-layout-effect@npm:^1.1.2": version: 1.1.2 resolution: "use-isomorphic-layout-effect@npm:1.1.2" @@ -20233,6 +22065,22 @@ __metadata: languageName: node linkType: hard +"use-sidecar@npm:^1.1.3": + version: 1.1.3 + resolution: "use-sidecar@npm:1.1.3" + dependencies: + detect-node-es: "npm:^1.1.0" + tslib: "npm:^2.0.0" + peerDependencies: + "@types/react": "*" + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10/2fec05eb851cdfc4a4657b1dfb434e686f346c3265ffc9db8a974bb58f8128bd4a708a3cc00e8f51655fccf81822ed4419ebed42f41610589e3aab0cf2492edb + languageName: node + linkType: hard + "use-sync-external-store@npm:^1.4.0": version: 1.4.0 resolution: "use-sync-external-store@npm:1.4.0" @@ -20348,6 +22196,13 @@ __metadata: languageName: node linkType: hard +"varint@npm:^6.0.0": + version: 6.0.0 + resolution: "varint@npm:6.0.0" + checksum: 10/7684113c9d497c01e40396e50169c502eb2176203219b96e1c5ac965a3e15b4892bd22b7e48d87148e10fffe638130516b6dbeedd0efde2b2d0395aa1772eea7 + languageName: node + linkType: hard + "vary@npm:~1.1.2": version: 1.1.2 resolution: "vary@npm:1.1.2" @@ -20439,6 +22294,13 @@ __metadata: languageName: node linkType: hard +"w3c-keyname@npm:^2.2.0": + version: 2.2.8 + resolution: "w3c-keyname@npm:2.2.8" + checksum: 10/95bafa4c04fa2f685a86ca1000069c1ec43ace1f8776c10f226a73296caeddd83f893db885c2c220ebeb6c52d424e3b54d7c0c1e963bbf204038ff1a944fbb07 + languageName: node + linkType: hard + "w3c-xmlserializer@npm:^4.0.0": version: 4.0.0 resolution: "w3c-xmlserializer@npm:4.0.0"