From 61f37629d6b9453d760ec88113c17889ef42841c Mon Sep 17 00:00:00 2001 From: Jon Kafton <939376+jonkafton@users.noreply.github.com> Date: Fri, 31 Oct 2025 22:30:51 +0100 Subject: [PATCH 1/8] Editor test page --- frontends/main/package.json | 22 +- .../app-pages/ArticlePage/NewArticlePage.tsx | 367 ++++ frontends/main/src/app/GlobalStyles.ts | 3 + frontends/main/src/app/article/new/page.tsx | 14 + frontends/main/src/app/simple/page.tsx | 5 + .../tiptap-icons/align-center-icon.tsx | 38 + .../tiptap-icons/align-justify-icon.tsx | 38 + .../tiptap-icons/align-left-icon.tsx | 38 + .../tiptap-icons/align-right-icon.tsx | 38 + .../tiptap-icons/arrow-left-icon.tsx | 24 + .../src/components/tiptap-icons/ban-icon.tsx | 26 + .../tiptap-icons/blockquote-icon.tsx | 44 + .../src/components/tiptap-icons/bold-icon.tsx | 26 + .../tiptap-icons/chevron-down-icon.tsx | 26 + .../components/tiptap-icons/close-icon.tsx | 24 + .../tiptap-icons/code-block-icon.tsx | 38 + .../components/tiptap-icons/code2-icon.tsx | 32 + .../tiptap-icons/corner-down-left-icon.tsx | 26 + .../tiptap-icons/external-link-icon.tsx | 28 + .../tiptap-icons/heading-five-icon.tsx | 28 + .../tiptap-icons/heading-four-icon.tsx | 28 + .../components/tiptap-icons/heading-icon.tsx | 24 + .../tiptap-icons/heading-one-icon.tsx | 28 + .../tiptap-icons/heading-six-icon.tsx | 30 + .../tiptap-icons/heading-three-icon.tsx | 36 + .../tiptap-icons/heading-two-icon.tsx | 28 + .../tiptap-icons/highlighter-icon.tsx | 26 + .../tiptap-icons/image-plus-icon.tsx | 26 + .../components/tiptap-icons/italic-icon.tsx | 24 + .../src/components/tiptap-icons/link-icon.tsx | 28 + .../src/components/tiptap-icons/list-icon.tsx | 56 + .../tiptap-icons/list-ordered-icon.tsx | 56 + .../tiptap-icons/list-todo-icon.tsx | 50 + .../tiptap-icons/moon-star-icon.tsx | 30 + .../components/tiptap-icons/redo2-icon.tsx | 26 + .../components/tiptap-icons/strike-icon.tsx | 28 + .../tiptap-icons/subscript-icon.tsx | 38 + .../src/components/tiptap-icons/sun-icon.tsx | 58 + .../tiptap-icons/superscript-icon.tsx | 38 + .../components/tiptap-icons/trash-icon.tsx | 26 + .../tiptap-icons/underline-icon.tsx | 26 + .../components/tiptap-icons/undo2-icon.tsx | 26 + .../blockquote-node/blockquote-node.scss | 37 + .../code-block-node/code-block-node.scss | 54 + .../heading-node/heading-node.scss | 39 + .../horizontal-rule-node-extension.ts | 14 + .../horizontal-rule-node.scss | 25 + .../tiptap-node/image-node/image-node.scss | 31 + .../image-upload-node-extension.ts | 162 ++ .../image-upload-node/image-upload-node.scss | 249 +++ .../image-upload-node/image-upload-node.tsx | 554 +++++ .../tiptap-node/image-upload-node/index.tsx | 1 + .../tiptap-node/list-node/list-node.scss | 160 ++ .../paragraph-node/paragraph-node.scss | 272 +++ .../tiptap-templates/simple/data/content.json | 469 +++++ .../simple/simple-editor.scss | 82 + .../tiptap-templates/simple/simple-editor.tsx | 313 +++ .../tiptap-templates/simple/theme-toggle.tsx | 47 + .../badge/badge-colors.scss | 395 ++++ .../badge/badge-group.scss | 16 + .../tiptap-ui-primitive/badge/badge.scss | 99 + .../tiptap-ui-primitive/badge/badge.tsx | 46 + .../tiptap-ui-primitive/badge/index.tsx | 1 + .../button/button-colors.scss | 429 ++++ .../button/button-group.scss | 22 + .../tiptap-ui-primitive/button/button.scss | 314 +++ .../tiptap-ui-primitive/button/button.tsx | 116 ++ .../tiptap-ui-primitive/button/index.tsx | 1 + .../tiptap-ui-primitive/card/card.scss | 77 + .../tiptap-ui-primitive/card/card.tsx | 79 + .../tiptap-ui-primitive/card/index.tsx | 1 + .../dropdown-menu/dropdown-menu.scss | 63 + .../dropdown-menu/dropdown-menu.tsx | 98 + .../dropdown-menu/index.tsx | 1 + .../tiptap-ui-primitive/input/index.tsx | 1 + .../tiptap-ui-primitive/input/input.scss | 45 + .../tiptap-ui-primitive/input/input.tsx | 24 + .../tiptap-ui-primitive/popover/index.tsx | 1 + .../tiptap-ui-primitive/popover/popover.scss | 63 + .../tiptap-ui-primitive/popover/popover.tsx | 37 + .../tiptap-ui-primitive/separator/index.tsx | 1 + .../separator/separator.scss | 23 + .../separator/separator.tsx | 33 + .../tiptap-ui-primitive/spacer/index.tsx | 1 + .../tiptap-ui-primitive/spacer/spacer.tsx | 26 + .../tiptap-ui-primitive/toolbar/index.tsx | 1 + .../tiptap-ui-primitive/toolbar/toolbar.scss | 98 + .../tiptap-ui-primitive/toolbar/toolbar.tsx | 123 ++ .../tiptap-ui-primitive/tooltip/index.tsx | 1 + .../tiptap-ui-primitive/tooltip/tooltip.scss | 43 + .../tiptap-ui-primitive/tooltip/tooltip.tsx | 237 +++ .../blockquote-button/blockquote-button.tsx | 125 ++ .../tiptap-ui/blockquote-button/index.tsx | 2 + .../blockquote-button/use-blockquote.ts | 246 +++ .../code-block-button/code-block-button.tsx | 125 ++ .../tiptap-ui/code-block-button/index.tsx | 2 + .../code-block-button/use-code-block.ts | 256 +++ .../color-highlight-button.scss | 49 + .../color-highlight-button.tsx | 171 ++ .../color-highlight-button/index.tsx | 2 + .../use-color-highlight.ts | 339 +++ .../color-highlight-popover.tsx | 211 ++ .../color-highlight-popover/index.tsx | 1 + .../heading-button/heading-button.tsx | 127 ++ .../tiptap-ui/heading-button/index.tsx | 2 + .../tiptap-ui/heading-button/use-heading.ts | 321 +++ .../heading-dropdown-menu.tsx | 129 ++ .../tiptap-ui/heading-dropdown-menu/index.tsx | 2 + .../use-heading-dropdown-menu.ts | 132 ++ .../image-upload-button.tsx | 133 ++ .../tiptap-ui/image-upload-button/index.tsx | 2 + .../image-upload-button/use-image-upload.ts | 192 ++ .../tiptap-ui/link-popover/index.tsx | 2 + .../tiptap-ui/link-popover/link-popover.tsx | 307 +++ .../link-popover/use-link-popover.ts | 286 +++ .../tiptap-ui/list-button/index.tsx | 2 + .../tiptap-ui/list-button/list-button.tsx | 123 ++ .../tiptap-ui/list-button/use-list.ts | 326 +++ .../tiptap-ui/list-dropdown-menu/index.tsx | 1 + .../list-dropdown-menu/list-dropdown-menu.tsx | 125 ++ .../use-list-dropdown-menu.ts | 216 ++ .../tiptap-ui/mark-button/index.tsx | 2 + .../tiptap-ui/mark-button/mark-button.tsx | 123 ++ .../tiptap-ui/mark-button/use-mark.ts | 214 ++ .../tiptap-ui/text-align-button/index.tsx | 2 + .../text-align-button/text-align-button.tsx | 145 ++ .../text-align-button/use-text-align.ts | 224 ++ .../tiptap-ui/undo-redo-button/index.tsx | 2 + .../undo-redo-button/undo-redo-button.tsx | 126 ++ .../undo-redo-button/use-undo-redo.ts | 184 ++ frontends/main/src/hooks/use-composed-ref.ts | 47 + .../main/src/hooks/use-cursor-visibility.ts | 71 + frontends/main/src/hooks/use-element-rect.ts | 166 ++ .../main/src/hooks/use-menu-navigation.ts | 196 ++ frontends/main/src/hooks/use-mobile.ts | 19 + frontends/main/src/hooks/use-scrolling.ts | 75 + .../main/src/hooks/use-throttled-callback.ts | 48 + frontends/main/src/hooks/use-tiptap-editor.ts | 49 + frontends/main/src/hooks/use-unmount.ts | 21 + frontends/main/src/hooks/use-window-size.ts | 93 + frontends/main/src/lib/tiptap-utils.ts | 554 +++++ .../AiChat/AskTimDrawerButton.tsx | 31 +- .../main/src/styles/_keyframe-animations.scss | 91 + frontends/main/src/styles/_variables.scss | 296 +++ package.json | 3 + yarn.lock | 1831 ++++++++++++++++- 146 files changed, 15106 insertions(+), 110 deletions(-) create mode 100644 frontends/main/src/app-pages/ArticlePage/NewArticlePage.tsx create mode 100644 frontends/main/src/app/article/new/page.tsx create mode 100644 frontends/main/src/app/simple/page.tsx create mode 100644 frontends/main/src/components/tiptap-icons/align-center-icon.tsx create mode 100644 frontends/main/src/components/tiptap-icons/align-justify-icon.tsx create mode 100644 frontends/main/src/components/tiptap-icons/align-left-icon.tsx create mode 100644 frontends/main/src/components/tiptap-icons/align-right-icon.tsx create mode 100644 frontends/main/src/components/tiptap-icons/arrow-left-icon.tsx create mode 100644 frontends/main/src/components/tiptap-icons/ban-icon.tsx create mode 100644 frontends/main/src/components/tiptap-icons/blockquote-icon.tsx create mode 100644 frontends/main/src/components/tiptap-icons/bold-icon.tsx create mode 100644 frontends/main/src/components/tiptap-icons/chevron-down-icon.tsx create mode 100644 frontends/main/src/components/tiptap-icons/close-icon.tsx create mode 100644 frontends/main/src/components/tiptap-icons/code-block-icon.tsx create mode 100644 frontends/main/src/components/tiptap-icons/code2-icon.tsx create mode 100644 frontends/main/src/components/tiptap-icons/corner-down-left-icon.tsx create mode 100644 frontends/main/src/components/tiptap-icons/external-link-icon.tsx create mode 100644 frontends/main/src/components/tiptap-icons/heading-five-icon.tsx create mode 100644 frontends/main/src/components/tiptap-icons/heading-four-icon.tsx create mode 100644 frontends/main/src/components/tiptap-icons/heading-icon.tsx create mode 100644 frontends/main/src/components/tiptap-icons/heading-one-icon.tsx create mode 100644 frontends/main/src/components/tiptap-icons/heading-six-icon.tsx create mode 100644 frontends/main/src/components/tiptap-icons/heading-three-icon.tsx create mode 100644 frontends/main/src/components/tiptap-icons/heading-two-icon.tsx create mode 100644 frontends/main/src/components/tiptap-icons/highlighter-icon.tsx create mode 100644 frontends/main/src/components/tiptap-icons/image-plus-icon.tsx create mode 100644 frontends/main/src/components/tiptap-icons/italic-icon.tsx create mode 100644 frontends/main/src/components/tiptap-icons/link-icon.tsx create mode 100644 frontends/main/src/components/tiptap-icons/list-icon.tsx create mode 100644 frontends/main/src/components/tiptap-icons/list-ordered-icon.tsx create mode 100644 frontends/main/src/components/tiptap-icons/list-todo-icon.tsx create mode 100644 frontends/main/src/components/tiptap-icons/moon-star-icon.tsx create mode 100644 frontends/main/src/components/tiptap-icons/redo2-icon.tsx create mode 100644 frontends/main/src/components/tiptap-icons/strike-icon.tsx create mode 100644 frontends/main/src/components/tiptap-icons/subscript-icon.tsx create mode 100644 frontends/main/src/components/tiptap-icons/sun-icon.tsx create mode 100644 frontends/main/src/components/tiptap-icons/superscript-icon.tsx create mode 100644 frontends/main/src/components/tiptap-icons/trash-icon.tsx create mode 100644 frontends/main/src/components/tiptap-icons/underline-icon.tsx create mode 100644 frontends/main/src/components/tiptap-icons/undo2-icon.tsx create mode 100644 frontends/main/src/components/tiptap-node/blockquote-node/blockquote-node.scss create mode 100644 frontends/main/src/components/tiptap-node/code-block-node/code-block-node.scss create mode 100644 frontends/main/src/components/tiptap-node/heading-node/heading-node.scss create mode 100644 frontends/main/src/components/tiptap-node/horizontal-rule-node/horizontal-rule-node-extension.ts create mode 100644 frontends/main/src/components/tiptap-node/horizontal-rule-node/horizontal-rule-node.scss create mode 100644 frontends/main/src/components/tiptap-node/image-node/image-node.scss create mode 100644 frontends/main/src/components/tiptap-node/image-upload-node/image-upload-node-extension.ts create mode 100644 frontends/main/src/components/tiptap-node/image-upload-node/image-upload-node.scss create mode 100644 frontends/main/src/components/tiptap-node/image-upload-node/image-upload-node.tsx create mode 100644 frontends/main/src/components/tiptap-node/image-upload-node/index.tsx create mode 100644 frontends/main/src/components/tiptap-node/list-node/list-node.scss create mode 100644 frontends/main/src/components/tiptap-node/paragraph-node/paragraph-node.scss create mode 100644 frontends/main/src/components/tiptap-templates/simple/data/content.json create mode 100644 frontends/main/src/components/tiptap-templates/simple/simple-editor.scss create mode 100644 frontends/main/src/components/tiptap-templates/simple/simple-editor.tsx create mode 100644 frontends/main/src/components/tiptap-templates/simple/theme-toggle.tsx create mode 100644 frontends/main/src/components/tiptap-ui-primitive/badge/badge-colors.scss create mode 100644 frontends/main/src/components/tiptap-ui-primitive/badge/badge-group.scss create mode 100644 frontends/main/src/components/tiptap-ui-primitive/badge/badge.scss create mode 100644 frontends/main/src/components/tiptap-ui-primitive/badge/badge.tsx create mode 100644 frontends/main/src/components/tiptap-ui-primitive/badge/index.tsx create mode 100644 frontends/main/src/components/tiptap-ui-primitive/button/button-colors.scss create mode 100644 frontends/main/src/components/tiptap-ui-primitive/button/button-group.scss create mode 100644 frontends/main/src/components/tiptap-ui-primitive/button/button.scss create mode 100644 frontends/main/src/components/tiptap-ui-primitive/button/button.tsx create mode 100644 frontends/main/src/components/tiptap-ui-primitive/button/index.tsx create mode 100644 frontends/main/src/components/tiptap-ui-primitive/card/card.scss create mode 100644 frontends/main/src/components/tiptap-ui-primitive/card/card.tsx create mode 100644 frontends/main/src/components/tiptap-ui-primitive/card/index.tsx create mode 100644 frontends/main/src/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.scss create mode 100644 frontends/main/src/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.tsx create mode 100644 frontends/main/src/components/tiptap-ui-primitive/dropdown-menu/index.tsx create mode 100644 frontends/main/src/components/tiptap-ui-primitive/input/index.tsx create mode 100644 frontends/main/src/components/tiptap-ui-primitive/input/input.scss create mode 100644 frontends/main/src/components/tiptap-ui-primitive/input/input.tsx create mode 100644 frontends/main/src/components/tiptap-ui-primitive/popover/index.tsx create mode 100644 frontends/main/src/components/tiptap-ui-primitive/popover/popover.scss create mode 100644 frontends/main/src/components/tiptap-ui-primitive/popover/popover.tsx create mode 100644 frontends/main/src/components/tiptap-ui-primitive/separator/index.tsx create mode 100644 frontends/main/src/components/tiptap-ui-primitive/separator/separator.scss create mode 100644 frontends/main/src/components/tiptap-ui-primitive/separator/separator.tsx create mode 100644 frontends/main/src/components/tiptap-ui-primitive/spacer/index.tsx create mode 100644 frontends/main/src/components/tiptap-ui-primitive/spacer/spacer.tsx create mode 100644 frontends/main/src/components/tiptap-ui-primitive/toolbar/index.tsx create mode 100644 frontends/main/src/components/tiptap-ui-primitive/toolbar/toolbar.scss create mode 100644 frontends/main/src/components/tiptap-ui-primitive/toolbar/toolbar.tsx create mode 100644 frontends/main/src/components/tiptap-ui-primitive/tooltip/index.tsx create mode 100644 frontends/main/src/components/tiptap-ui-primitive/tooltip/tooltip.scss create mode 100644 frontends/main/src/components/tiptap-ui-primitive/tooltip/tooltip.tsx create mode 100644 frontends/main/src/components/tiptap-ui/blockquote-button/blockquote-button.tsx create mode 100644 frontends/main/src/components/tiptap-ui/blockquote-button/index.tsx create mode 100644 frontends/main/src/components/tiptap-ui/blockquote-button/use-blockquote.ts create mode 100644 frontends/main/src/components/tiptap-ui/code-block-button/code-block-button.tsx create mode 100644 frontends/main/src/components/tiptap-ui/code-block-button/index.tsx create mode 100644 frontends/main/src/components/tiptap-ui/code-block-button/use-code-block.ts create mode 100644 frontends/main/src/components/tiptap-ui/color-highlight-button/color-highlight-button.scss create mode 100644 frontends/main/src/components/tiptap-ui/color-highlight-button/color-highlight-button.tsx create mode 100644 frontends/main/src/components/tiptap-ui/color-highlight-button/index.tsx create mode 100644 frontends/main/src/components/tiptap-ui/color-highlight-button/use-color-highlight.ts create mode 100644 frontends/main/src/components/tiptap-ui/color-highlight-popover/color-highlight-popover.tsx create mode 100644 frontends/main/src/components/tiptap-ui/color-highlight-popover/index.tsx create mode 100644 frontends/main/src/components/tiptap-ui/heading-button/heading-button.tsx create mode 100644 frontends/main/src/components/tiptap-ui/heading-button/index.tsx create mode 100644 frontends/main/src/components/tiptap-ui/heading-button/use-heading.ts create mode 100644 frontends/main/src/components/tiptap-ui/heading-dropdown-menu/heading-dropdown-menu.tsx create mode 100644 frontends/main/src/components/tiptap-ui/heading-dropdown-menu/index.tsx create mode 100644 frontends/main/src/components/tiptap-ui/heading-dropdown-menu/use-heading-dropdown-menu.ts create mode 100644 frontends/main/src/components/tiptap-ui/image-upload-button/image-upload-button.tsx create mode 100644 frontends/main/src/components/tiptap-ui/image-upload-button/index.tsx create mode 100644 frontends/main/src/components/tiptap-ui/image-upload-button/use-image-upload.ts create mode 100644 frontends/main/src/components/tiptap-ui/link-popover/index.tsx create mode 100644 frontends/main/src/components/tiptap-ui/link-popover/link-popover.tsx create mode 100644 frontends/main/src/components/tiptap-ui/link-popover/use-link-popover.ts create mode 100644 frontends/main/src/components/tiptap-ui/list-button/index.tsx create mode 100644 frontends/main/src/components/tiptap-ui/list-button/list-button.tsx create mode 100644 frontends/main/src/components/tiptap-ui/list-button/use-list.ts create mode 100644 frontends/main/src/components/tiptap-ui/list-dropdown-menu/index.tsx create mode 100644 frontends/main/src/components/tiptap-ui/list-dropdown-menu/list-dropdown-menu.tsx create mode 100644 frontends/main/src/components/tiptap-ui/list-dropdown-menu/use-list-dropdown-menu.ts create mode 100644 frontends/main/src/components/tiptap-ui/mark-button/index.tsx create mode 100644 frontends/main/src/components/tiptap-ui/mark-button/mark-button.tsx create mode 100644 frontends/main/src/components/tiptap-ui/mark-button/use-mark.ts create mode 100644 frontends/main/src/components/tiptap-ui/text-align-button/index.tsx create mode 100644 frontends/main/src/components/tiptap-ui/text-align-button/text-align-button.tsx create mode 100644 frontends/main/src/components/tiptap-ui/text-align-button/use-text-align.ts create mode 100644 frontends/main/src/components/tiptap-ui/undo-redo-button/index.tsx create mode 100644 frontends/main/src/components/tiptap-ui/undo-redo-button/undo-redo-button.tsx create mode 100644 frontends/main/src/components/tiptap-ui/undo-redo-button/use-undo-redo.ts create mode 100644 frontends/main/src/hooks/use-composed-ref.ts create mode 100644 frontends/main/src/hooks/use-cursor-visibility.ts create mode 100644 frontends/main/src/hooks/use-element-rect.ts create mode 100644 frontends/main/src/hooks/use-menu-navigation.ts create mode 100644 frontends/main/src/hooks/use-mobile.ts create mode 100644 frontends/main/src/hooks/use-scrolling.ts create mode 100644 frontends/main/src/hooks/use-throttled-callback.ts create mode 100644 frontends/main/src/hooks/use-tiptap-editor.ts create mode 100644 frontends/main/src/hooks/use-unmount.ts create mode 100644 frontends/main/src/hooks/use-window-size.ts create mode 100644 frontends/main/src/lib/tiptap-utils.ts create mode 100644 frontends/main/src/styles/_keyframe-animations.scss create mode 100644 frontends/main/src/styles/_variables.scss diff --git a/frontends/main/package.json b/frontends/main/package.json index b53d266257..beb2c11138 100644 --- a/frontends/main/package.json +++ b/frontends/main/package.json @@ -13,14 +13,30 @@ "@ebay/nice-modal-react": "^1.2.13", "@emotion/cache": "^11.13.1", "@emotion/styled": "^11.11.0", + "@floating-ui/react": "^0.27.16", "@mitodl/course-search-utils": "^3.5.0", "@mitodl/mitxonline-api-axios": "^2025.10.21", "@mitodl/smoot-design": "^6.17.1", "@next/bundle-analyzer": "^14.2.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-popover": "^1.1.15", "@react-pdf/renderer": "^4.3.0", - "@remixicon/react": "^4.2.0", + "@remixicon/react": "^4.7.0", "@sentry/nextjs": "^10.0.0", "@tanstack/react-query": "^5.66", + "@tiptap/extension-highlight": "^3.10.1", + "@tiptap/extension-horizontal-rule": "^3.10.1", + "@tiptap/extension-image": "^3.10.1", + "@tiptap/extension-list": "^3.10.1", + "@tiptap/extension-subscript": "^3.10.1", + "@tiptap/extension-superscript": "^3.10.1", + "@tiptap/extension-text-align": "^3.10.1", + "@tiptap/extension-typography": "^3.10.1", + "@tiptap/extensions": "^3.10.1", + "@tiptap/markdown": "^3.10.1", + "@tiptap/pm": "^3.10.1", + "@tiptap/react": "^3.10.1", + "@tiptap/starter-kit": "^3.10.1", "api": "workspace:*", "classnames": "^2.5.1", "formik": "^2.4.6", @@ -28,6 +44,7 @@ "isomorphic-dompurify": "^2.27.0", "jsdom": "^27", "lodash": "^4.17.21", + "lodash.throttle": "^4.1.1", "moment": "^2.30.1", "next": "^15.5.2", "next-nprogress-bar": "^2.4.2", @@ -36,6 +53,7 @@ "posthog-js": "^1.157.2", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-hotkeys-hook": "^5.2.1", "react-slick": "^0.30.2", "sharp": "0.34.4", "slick-carousel": "^1.8.1", @@ -49,6 +67,7 @@ "@testing-library/user-event": "^14.5.2", "@types/jest": "^29.5.12", "@types/lodash": "^4.17.7", + "@types/lodash.throttle": "^4.1.9", "@types/node": "^22.0.0", "@types/react": "^19", "@types/react-dom": "^19", @@ -62,6 +81,7 @@ "jest-next-dynamic-ts": "^0.1.1", "next-router-mock": "^1.0.2", "ol-test-utilities": "0.0.0", + "sass": "^1.93.3", "ts-jest": "^29.2.4", "type-fest": "^5.0.1", "typescript": "^5" 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..07a379f01d --- /dev/null +++ b/frontends/main/src/app-pages/ArticlePage/NewArticlePage.tsx @@ -0,0 +1,367 @@ +"use client" + +import React, { useState, useRef } from "react" +import { Typography, theme, styled, LearningResourceCard } from "ol-components" +import { SimpleEditor } from "@/components/tiptap-templates/simple/simple-editor" +import { + EditorContext, + useEditor, + ReactNodeViewRenderer, + Node, + mergeAttributes, + NodeViewWrapper, + type NodeViewProps, +} from "@tiptap/react" +import { Superscript } from "@tiptap/extension-superscript" +import { Subscript } from "@tiptap/extension-subscript" + +// --- 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 as TipTapTypography } from "@tiptap/extension-typography" +import { Highlight } from "@tiptap/extension-highlight" +import { Selection } from "@tiptap/extensions" + +// --- 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 content from "@/components/tiptap-templates/simple/data/content.json" + +// --- Lib --- +import { handleImageUpload, MAX_FILE_SIZE } from "@/lib/tiptap-utils" + +import { useLearningResourcesDetail } from "api/hooks/learningResources" +import AskTimDrawerButton from "@/page-components/AiChat/AskTimDrawerButton" + +import { Markdown } from "@tiptap/markdown" + +const PageContainer = styled.div({ + color: theme.custom.colors.darkGray2, + display: "flex", + marginTop: "72px", +}) + +const EditorContainer = styled.div({ + flex: 6, +}) + +const PreviewContainer = styled.div({ + flex: 4, + backgroundColor: "white", + padding: "16px", + display: "flex", + flexDirection: "column", +}) + +const MarkdownTextarea = styled.textarea({ + flex: 1, + fontFamily: "monospace", + fontSize: "14px", + padding: "16px", + border: `1px solid ${theme.custom.colors.lightGray2}`, + borderRadius: "4px", + resize: "none", + whiteSpace: "pre", + overflowWrap: "normal", + overflowX: "auto", + "&:focus": { + outline: `2px solid ${theme.custom.colors.mitRed}`, + outlineOffset: "2px", + }, +}) + +const StyledSimpleEditor = styled(SimpleEditor)({ + width: "60vw", + height: "calc(100vh - 205px)", +}) + +const Card = styled.div({ + borderRadius: "8px", + border: `1px solid ${theme.custom.colors.lightGray2}`, + background: theme.custom.colors.white, + display: "block", + overflow: "hidden", + minWidth: "300px", + maxWidth: "300px", + padding: "16px", +}) + +const ResourceCardWrapper = (props: NodeViewProps) => { + const { node } = props + + const { data: resource, isLoading } = useLearningResourcesDetail( + node.attrs.resourceId, + ) + if (isLoading) { + return Loading... + } + if (!resource) { + return Resource not found + } + + return ( + + + + ) +} + +const ResourceCardExtension = Node.create({ + name: "resourceCard", + + group: "block", + atom: true, + selectable: true, + + addAttributes() { + return { + resourceId: { + default: null, + }, + } + }, + + parseHTML() { + return [{ tag: "resource-card" }] + }, + + renderHTML({ HTMLAttributes }) { + return ["resource-card", mergeAttributes(HTMLAttributes)] + }, + + markdownTokenizer: { + name: "resourceCard", + level: "block", + + start: (src) => { + return src.indexOf("[[resource-card:") + }, + + tokenize: (src, _tokens, _lexer) => { + // Match [[resource:resourceId]] + const match = /^\[\[resource-card:(\d+)\]\]/.exec(src) + + if (!match) { + return undefined + } + + return { + type: "resourceCard", + raw: match[0], + resourceId: match[1], + } + }, + }, + + parseMarkdown: (token, _helpers) => { + return { + type: "resourceCard", + attrs: { + resourceId: token.resourceId || null, + }, + } + }, + + renderMarkdown: (node, _helpers) => { + const resourceId = node.attrs?.resourceId || "" + return `[[resource-card:${resourceId}]]\n\n` + }, + + addNodeView() { + return ReactNodeViewRenderer(ResourceCardWrapper) + }, +}) + +const AskTimDrawerButtonWrapper = () => { + return ( + + + + ) +} + +const AskTimDrawerButtonExtension = Node.create({ + name: "askTimDrawerButton", + group: "block", + atom: true, + selectable: true, + + markdownTokenizer: { + name: "askTimDrawerButton", + level: "block", + + start: (src) => { + return src.indexOf("[[asktim]]") + }, + + tokenize: (src, _tokens, _lexer) => { + // Match [[asktim]] + const match = /^\[\[asktim\]\]/.exec(src) + + if (!match) { + return undefined + } + + return { + type: "askTimDrawerButton", + raw: match[0], + } + }, + }, + + parseMarkdown: (_token, _helpers) => { + return { + type: "askTimDrawerButton", + } + }, + + renderMarkdown: (_node, _helpers) => { + return "[[asktim]]\n\n" + }, + + addNodeView() { + return ReactNodeViewRenderer(AskTimDrawerButtonWrapper) + }, +}) + +const extensions = [ + Markdown, + 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, + TipTapTypography, + Superscript, + Subscript, + Selection, + ImageUploadNode.configure({ + accept: "image/*", + maxSize: MAX_FILE_SIZE, + limit: 3, + upload: handleImageUpload, + onError: (error) => console.error("Upload failed:", error), + }), + ResourceCardExtension, + AskTimDrawerButtonExtension, +] + +interface ViewerProps { + content: string + onChange: (markdown: string) => void +} + +const Viewer: React.FC = ({ content, onChange }) => { + const handleChange = (e: React.ChangeEvent) => { + onChange(e.target.value) + } + + return ( + + ) +} + +const NewArticlePage: React.FC = () => { + const [serializedContent, setSerializedContent] = useState("") + const isUpdatingFromMarkdown = useRef(false) + + 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, + content, + onUpdate: ({ editor: currentEditor }) => { + // Only update markdown if we're not in the middle of updating from markdown + if (!isUpdatingFromMarkdown.current) { + setSerializedContent(currentEditor.getMarkdown()) + } + }, + }) + + const handleMarkdownChange = (markdown: string) => { + if (!editor) return + + // Set flag to prevent the editor's onUpdate from firing back + isUpdatingFromMarkdown.current = true + + setSerializedContent(markdown) + + // Update the editor content from markdown + try { + editor.commands.setContent(markdown, { + contentType: "markdown", + }) + } catch (error) { + console.error("Error parsing markdown:", error) + } + + // Reset flag after update completes + // Use setTimeout to ensure all updates are processed + setTimeout(() => { + isUpdatingFromMarkdown.current = false + }, 50) + } + + return ( + + + + + + + + {editor && ( + + )} + + + ) + + // return ( + // + // console.log("Mark toggled!")} + // /> + // + // + // + // + // + // + + // + // + // ) +} + +export { NewArticlePage } diff --git a/frontends/main/src/app/GlobalStyles.ts b/frontends/main/src/app/GlobalStyles.ts index 5d750c7153..5c87096252 100644 --- a/frontends/main/src/app/GlobalStyles.ts +++ b/frontends/main/src/app/GlobalStyles.ts @@ -1,2 +1,5 @@ import "slick-carousel/slick/slick.css" import "slick-carousel/slick/slick-theme.css" + +import "../styles/_variables.scss" +import "../styles/_keyframe-animations.scss" 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..a2040ce6ee --- /dev/null +++ b/frontends/main/src/app/article/new/page.tsx @@ -0,0 +1,14 @@ +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", +}) + +const Page: React.FC = () => { + return +} + +export default Page diff --git a/frontends/main/src/app/simple/page.tsx b/frontends/main/src/app/simple/page.tsx new file mode 100644 index 0000000000..5bb89be93d --- /dev/null +++ b/frontends/main/src/app/simple/page.tsx @@ -0,0 +1,5 @@ +import { SimpleEditor } from "@/components/tiptap-templates/simple/simple-editor" + +export default function Page() { + return +} diff --git a/frontends/main/src/components/tiptap-icons/align-center-icon.tsx b/frontends/main/src/components/tiptap-icons/align-center-icon.tsx new file mode 100644 index 0000000000..bb720609ff --- /dev/null +++ b/frontends/main/src/components/tiptap-icons/align-center-icon.tsx @@ -0,0 +1,38 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const AlignCenterIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + + ) +}) + +AlignCenterIcon.displayName = "AlignCenterIcon" diff --git a/frontends/main/src/components/tiptap-icons/align-justify-icon.tsx b/frontends/main/src/components/tiptap-icons/align-justify-icon.tsx new file mode 100644 index 0000000000..61cc6def89 --- /dev/null +++ b/frontends/main/src/components/tiptap-icons/align-justify-icon.tsx @@ -0,0 +1,38 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const AlignJustifyIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + + + ) +}) + +AlignJustifyIcon.displayName = "AlignJustifyIcon" diff --git a/frontends/main/src/components/tiptap-icons/align-left-icon.tsx b/frontends/main/src/components/tiptap-icons/align-left-icon.tsx new file mode 100644 index 0000000000..f54d1e509a --- /dev/null +++ b/frontends/main/src/components/tiptap-icons/align-left-icon.tsx @@ -0,0 +1,38 @@ +import { memo } from "react" + +export const AlignLeftIcon = memo( + ({ className, ...props }: React.SVGProps) => { + return ( + + + + + + ) + } +) + +AlignLeftIcon.displayName = "AlignLeftIcon" diff --git a/frontends/main/src/components/tiptap-icons/align-right-icon.tsx b/frontends/main/src/components/tiptap-icons/align-right-icon.tsx new file mode 100644 index 0000000000..fc13757bf9 --- /dev/null +++ b/frontends/main/src/components/tiptap-icons/align-right-icon.tsx @@ -0,0 +1,38 @@ +import { memo } from "react" + +export const AlignRightIcon = memo( + ({ className, ...props }: React.SVGProps) => { + return ( + + + + + + ) + } +) + +AlignRightIcon.displayName = "AlignRightIcon" diff --git a/frontends/main/src/components/tiptap-icons/arrow-left-icon.tsx b/frontends/main/src/components/tiptap-icons/arrow-left-icon.tsx new file mode 100644 index 0000000000..e978befa8f --- /dev/null +++ b/frontends/main/src/components/tiptap-icons/arrow-left-icon.tsx @@ -0,0 +1,24 @@ +import { memo } from "react" + +export const ArrowLeftIcon = memo( + ({ className, ...props }: React.SVGProps) => { + return ( + + + + ) + } +) + +ArrowLeftIcon.displayName = "ArrowLeftIcon" diff --git a/frontends/main/src/components/tiptap-icons/ban-icon.tsx b/frontends/main/src/components/tiptap-icons/ban-icon.tsx new file mode 100644 index 0000000000..7747a2a6ea --- /dev/null +++ b/frontends/main/src/components/tiptap-icons/ban-icon.tsx @@ -0,0 +1,26 @@ +import { memo } from "react" + +export const BanIcon = memo( + ({ className, ...props }: React.SVGProps) => { + return ( + + + + ) + } +) + +BanIcon.displayName = "BanIcon" diff --git a/frontends/main/src/components/tiptap-icons/blockquote-icon.tsx b/frontends/main/src/components/tiptap-icons/blockquote-icon.tsx new file mode 100644 index 0000000000..aa19858d15 --- /dev/null +++ b/frontends/main/src/components/tiptap-icons/blockquote-icon.tsx @@ -0,0 +1,44 @@ +import { memo } from "react" + +export const BlockquoteIcon = memo( + ({ className, ...props }: React.SVGProps) => { + return ( + + + + + + + ) + } +) + +BlockquoteIcon.displayName = "BlockquoteIcon" diff --git a/frontends/main/src/components/tiptap-icons/bold-icon.tsx b/frontends/main/src/components/tiptap-icons/bold-icon.tsx new file mode 100644 index 0000000000..fbdcf9ed94 --- /dev/null +++ b/frontends/main/src/components/tiptap-icons/bold-icon.tsx @@ -0,0 +1,26 @@ +import { memo } from "react" + +export const BoldIcon = memo( + ({ className, ...props }: React.SVGProps) => { + return ( + + + + ) + } +) + +BoldIcon.displayName = "BoldIcon" diff --git a/frontends/main/src/components/tiptap-icons/chevron-down-icon.tsx b/frontends/main/src/components/tiptap-icons/chevron-down-icon.tsx new file mode 100644 index 0000000000..8f7844d937 --- /dev/null +++ b/frontends/main/src/components/tiptap-icons/chevron-down-icon.tsx @@ -0,0 +1,26 @@ +import { memo } from "react" + +type SvgProps = React.ComponentPropsWithoutRef<"svg"> + +export const ChevronDownIcon = memo(({ className, ...props }: SvgProps) => { + return ( + + + + ) +}) + +ChevronDownIcon.displayName = "ChevronDownIcon" diff --git a/frontends/main/src/components/tiptap-icons/close-icon.tsx b/frontends/main/src/components/tiptap-icons/close-icon.tsx new file mode 100644 index 0000000000..6ed2cba32d --- /dev/null +++ b/frontends/main/src/components/tiptap-icons/close-icon.tsx @@ -0,0 +1,24 @@ +import { memo } from "react" + +export const CloseIcon = memo( + ({ className, ...props }: React.SVGProps) => { + return ( + + + + ) + } +) + +CloseIcon.displayName = "CloseIcon" diff --git a/frontends/main/src/components/tiptap-icons/code-block-icon.tsx b/frontends/main/src/components/tiptap-icons/code-block-icon.tsx new file mode 100644 index 0000000000..1c6bf8b941 --- /dev/null +++ b/frontends/main/src/components/tiptap-icons/code-block-icon.tsx @@ -0,0 +1,38 @@ +import { memo } from "react" + +export const CodeBlockIcon = memo( + ({ className, ...props }: React.SVGProps) => { + return ( + + + + + + ) + } +) + +CodeBlockIcon.displayName = "CodeBlockIcon" diff --git a/frontends/main/src/components/tiptap-icons/code2-icon.tsx b/frontends/main/src/components/tiptap-icons/code2-icon.tsx new file mode 100644 index 0000000000..6b673d1466 --- /dev/null +++ b/frontends/main/src/components/tiptap-icons/code2-icon.tsx @@ -0,0 +1,32 @@ +import { memo } from "react" + +export const Code2Icon = memo( + ({ className, ...props }: React.SVGProps) => { + return ( + + + + + + ) + } +) + +Code2Icon.displayName = "Code2Icon" diff --git a/frontends/main/src/components/tiptap-icons/corner-down-left-icon.tsx b/frontends/main/src/components/tiptap-icons/corner-down-left-icon.tsx new file mode 100644 index 0000000000..7c6daa31a2 --- /dev/null +++ b/frontends/main/src/components/tiptap-icons/corner-down-left-icon.tsx @@ -0,0 +1,26 @@ +import { memo } from "react" + +export const CornerDownLeftIcon = memo( + ({ className, ...props }: React.SVGProps) => { + return ( + + + + ) + } +) + +CornerDownLeftIcon.displayName = "CornerDownLeftIcon" diff --git a/frontends/main/src/components/tiptap-icons/external-link-icon.tsx b/frontends/main/src/components/tiptap-icons/external-link-icon.tsx new file mode 100644 index 0000000000..72b379caab --- /dev/null +++ b/frontends/main/src/components/tiptap-icons/external-link-icon.tsx @@ -0,0 +1,28 @@ +import { memo } from "react" + +export const ExternalLinkIcon = memo( + ({ className, ...props }: React.SVGProps) => { + return ( + + + + + ) + } +) + +ExternalLinkIcon.displayName = "ExternalLinkIcon" diff --git a/frontends/main/src/components/tiptap-icons/heading-five-icon.tsx b/frontends/main/src/components/tiptap-icons/heading-five-icon.tsx new file mode 100644 index 0000000000..90f80d1c71 --- /dev/null +++ b/frontends/main/src/components/tiptap-icons/heading-five-icon.tsx @@ -0,0 +1,28 @@ +import { memo } from "react" + +export const HeadingFiveIcon = memo( + ({ className, ...props }: React.SVGProps) => { + return ( + + + + + ) + } +) + +HeadingFiveIcon.displayName = "HeadingFiveIcon" diff --git a/frontends/main/src/components/tiptap-icons/heading-four-icon.tsx b/frontends/main/src/components/tiptap-icons/heading-four-icon.tsx new file mode 100644 index 0000000000..4664ba77ed --- /dev/null +++ b/frontends/main/src/components/tiptap-icons/heading-four-icon.tsx @@ -0,0 +1,28 @@ +import { memo } from "react" + +export const HeadingFourIcon = memo( + ({ className, ...props }: React.SVGProps) => { + return ( + + + + + ) + } +) + +HeadingFourIcon.displayName = "HeadingFourIcon" diff --git a/frontends/main/src/components/tiptap-icons/heading-icon.tsx b/frontends/main/src/components/tiptap-icons/heading-icon.tsx new file mode 100644 index 0000000000..070d7155e6 --- /dev/null +++ b/frontends/main/src/components/tiptap-icons/heading-icon.tsx @@ -0,0 +1,24 @@ +import { memo } from "react" + +export const HeadingIcon = memo( + ({ className, ...props }: React.SVGProps) => { + return ( + + + + ) + } +) + +HeadingIcon.displayName = "HeadingIcon" diff --git a/frontends/main/src/components/tiptap-icons/heading-one-icon.tsx b/frontends/main/src/components/tiptap-icons/heading-one-icon.tsx new file mode 100644 index 0000000000..a8f734d485 --- /dev/null +++ b/frontends/main/src/components/tiptap-icons/heading-one-icon.tsx @@ -0,0 +1,28 @@ +import { memo } from "react" + +export const HeadingOneIcon = memo( + ({ className, ...props }: React.SVGProps) => { + return ( + + + + + ) + } +) + +HeadingOneIcon.displayName = "HeadingOneIcon" diff --git a/frontends/main/src/components/tiptap-icons/heading-six-icon.tsx b/frontends/main/src/components/tiptap-icons/heading-six-icon.tsx new file mode 100644 index 0000000000..49e7a0ae5e --- /dev/null +++ b/frontends/main/src/components/tiptap-icons/heading-six-icon.tsx @@ -0,0 +1,30 @@ +import { memo } from "react" + +export const HeadingSixIcon = memo( + ({ className, ...props }: React.SVGProps) => { + return ( + + + + + ) + } +) + +HeadingSixIcon.displayName = "HeadingSixIcon" diff --git a/frontends/main/src/components/tiptap-icons/heading-three-icon.tsx b/frontends/main/src/components/tiptap-icons/heading-three-icon.tsx new file mode 100644 index 0000000000..91a76eabbe --- /dev/null +++ b/frontends/main/src/components/tiptap-icons/heading-three-icon.tsx @@ -0,0 +1,36 @@ +import { memo } from "react" + +export const HeadingThreeIcon = memo( + ({ className, ...props }: React.SVGProps) => { + return ( + + + + + + ) + } +) + +HeadingThreeIcon.displayName = "HeadingThreeIcon" diff --git a/frontends/main/src/components/tiptap-icons/heading-two-icon.tsx b/frontends/main/src/components/tiptap-icons/heading-two-icon.tsx new file mode 100644 index 0000000000..78b5ddfc83 --- /dev/null +++ b/frontends/main/src/components/tiptap-icons/heading-two-icon.tsx @@ -0,0 +1,28 @@ +import { memo } from "react" + +export const HeadingTwoIcon = memo( + ({ className, ...props }: React.SVGProps) => { + return ( + + + + + ) + } +) + +HeadingTwoIcon.displayName = "HeadingTwoIcon" diff --git a/frontends/main/src/components/tiptap-icons/highlighter-icon.tsx b/frontends/main/src/components/tiptap-icons/highlighter-icon.tsx new file mode 100644 index 0000000000..49c9e42b44 --- /dev/null +++ b/frontends/main/src/components/tiptap-icons/highlighter-icon.tsx @@ -0,0 +1,26 @@ +import { memo } from "react" + +export const HighlighterIcon = memo( + ({ className, ...props }: React.SVGProps) => { + return ( + + + + ) + } +) + +HighlighterIcon.displayName = "HighlighterIcon" diff --git a/frontends/main/src/components/tiptap-icons/image-plus-icon.tsx b/frontends/main/src/components/tiptap-icons/image-plus-icon.tsx new file mode 100644 index 0000000000..053acf34d2 --- /dev/null +++ b/frontends/main/src/components/tiptap-icons/image-plus-icon.tsx @@ -0,0 +1,26 @@ +import { memo } from "react" + +export const ImagePlusIcon = memo( + ({ className, ...props }: React.SVGProps) => { + return ( + + + + ) + } +) + +ImagePlusIcon.displayName = "ImagePlusIcon" diff --git a/frontends/main/src/components/tiptap-icons/italic-icon.tsx b/frontends/main/src/components/tiptap-icons/italic-icon.tsx new file mode 100644 index 0000000000..b18cd647b3 --- /dev/null +++ b/frontends/main/src/components/tiptap-icons/italic-icon.tsx @@ -0,0 +1,24 @@ +import { memo } from "react" + +export const ItalicIcon = memo( + ({ className, ...props }: React.SVGProps) => { + return ( + + + + ) + } +) + +ItalicIcon.displayName = "ItalicIcon" diff --git a/frontends/main/src/components/tiptap-icons/link-icon.tsx b/frontends/main/src/components/tiptap-icons/link-icon.tsx new file mode 100644 index 0000000000..6ee5c1551a --- /dev/null +++ b/frontends/main/src/components/tiptap-icons/link-icon.tsx @@ -0,0 +1,28 @@ +import { memo } from "react" + +export const LinkIcon = memo( + ({ className, ...props }: React.SVGProps) => { + return ( + + + + + ) + } +) + +LinkIcon.displayName = "LinkIcon" diff --git a/frontends/main/src/components/tiptap-icons/list-icon.tsx b/frontends/main/src/components/tiptap-icons/list-icon.tsx new file mode 100644 index 0000000000..895128fdaa --- /dev/null +++ b/frontends/main/src/components/tiptap-icons/list-icon.tsx @@ -0,0 +1,56 @@ +import { memo } from "react" + +export const ListIcon = memo( + ({ className, ...props }: React.SVGProps) => { + return ( + + + + + + + + + ) + } +) + +ListIcon.displayName = "ListIcon" diff --git a/frontends/main/src/components/tiptap-icons/list-ordered-icon.tsx b/frontends/main/src/components/tiptap-icons/list-ordered-icon.tsx new file mode 100644 index 0000000000..d393b7c6da --- /dev/null +++ b/frontends/main/src/components/tiptap-icons/list-ordered-icon.tsx @@ -0,0 +1,56 @@ +import { memo } from "react" + +export const ListOrderedIcon = memo( + ({ className, ...props }: React.SVGProps) => { + return ( + + + + + + + + + ) + } +) + +ListOrderedIcon.displayName = "ListOrderedIcon" diff --git a/frontends/main/src/components/tiptap-icons/list-todo-icon.tsx b/frontends/main/src/components/tiptap-icons/list-todo-icon.tsx new file mode 100644 index 0000000000..182c78576a --- /dev/null +++ b/frontends/main/src/components/tiptap-icons/list-todo-icon.tsx @@ -0,0 +1,50 @@ +import { memo } from "react" + +export const ListTodoIcon = memo( + ({ className, ...props }: React.SVGProps) => { + return ( + + + + + + + + ) + } +) + +ListTodoIcon.displayName = "ListTodoIcon" diff --git a/frontends/main/src/components/tiptap-icons/moon-star-icon.tsx b/frontends/main/src/components/tiptap-icons/moon-star-icon.tsx new file mode 100644 index 0000000000..2d5821797d --- /dev/null +++ b/frontends/main/src/components/tiptap-icons/moon-star-icon.tsx @@ -0,0 +1,30 @@ +import { memo } from "react" + +export const MoonStarIcon = memo( + ({ className, ...props }: React.SVGProps) => { + return ( + + + + + ) + } +) + +MoonStarIcon.displayName = "MoonStarIcon" diff --git a/frontends/main/src/components/tiptap-icons/redo2-icon.tsx b/frontends/main/src/components/tiptap-icons/redo2-icon.tsx new file mode 100644 index 0000000000..0356256489 --- /dev/null +++ b/frontends/main/src/components/tiptap-icons/redo2-icon.tsx @@ -0,0 +1,26 @@ +import { memo } from "react" + +export const Redo2Icon = memo( + ({ className, ...props }: React.SVGProps) => { + return ( + + + + ) + } +) + +Redo2Icon.displayName = "Redo2Icon" diff --git a/frontends/main/src/components/tiptap-icons/strike-icon.tsx b/frontends/main/src/components/tiptap-icons/strike-icon.tsx new file mode 100644 index 0000000000..084ba1c5aa --- /dev/null +++ b/frontends/main/src/components/tiptap-icons/strike-icon.tsx @@ -0,0 +1,28 @@ +import { memo } from "react" + +export const StrikeIcon = memo( + ({ className, ...props }: React.SVGProps) => { + return ( + + + + + ) + } +) + +StrikeIcon.displayName = "StrikeIcon" diff --git a/frontends/main/src/components/tiptap-icons/subscript-icon.tsx b/frontends/main/src/components/tiptap-icons/subscript-icon.tsx new file mode 100644 index 0000000000..e94f7be1ce --- /dev/null +++ b/frontends/main/src/components/tiptap-icons/subscript-icon.tsx @@ -0,0 +1,38 @@ +import { memo } from "react" + +export const SubscriptIcon = memo( + ({ className, ...props }: React.SVGProps) => { + return ( + + + + + + ) + } +) + +SubscriptIcon.displayName = "SubscriptIcon" diff --git a/frontends/main/src/components/tiptap-icons/sun-icon.tsx b/frontends/main/src/components/tiptap-icons/sun-icon.tsx new file mode 100644 index 0000000000..0c9462dbfe --- /dev/null +++ b/frontends/main/src/components/tiptap-icons/sun-icon.tsx @@ -0,0 +1,58 @@ +import { memo } from "react" + +export const SunIcon = memo( + ({ className, ...props }: React.SVGProps) => { + return ( + + + + + + + + + + + + ) + } +) + +SunIcon.displayName = "SunIcon" diff --git a/frontends/main/src/components/tiptap-icons/superscript-icon.tsx b/frontends/main/src/components/tiptap-icons/superscript-icon.tsx new file mode 100644 index 0000000000..e40b45ffc4 --- /dev/null +++ b/frontends/main/src/components/tiptap-icons/superscript-icon.tsx @@ -0,0 +1,38 @@ +import { memo } from "react" + +export const SuperscriptIcon = memo( + ({ className, ...props }: React.SVGProps) => { + return ( + + + + + + ) + } +) + +SuperscriptIcon.displayName = "SuperscriptIcon" diff --git a/frontends/main/src/components/tiptap-icons/trash-icon.tsx b/frontends/main/src/components/tiptap-icons/trash-icon.tsx new file mode 100644 index 0000000000..23ee1fecd4 --- /dev/null +++ b/frontends/main/src/components/tiptap-icons/trash-icon.tsx @@ -0,0 +1,26 @@ +import { memo } from "react" + +export const TrashIcon = memo( + ({ className, ...props }: React.SVGProps) => { + return ( + + + + ) + } +) + +TrashIcon.displayName = "TrashIcon" diff --git a/frontends/main/src/components/tiptap-icons/underline-icon.tsx b/frontends/main/src/components/tiptap-icons/underline-icon.tsx new file mode 100644 index 0000000000..abe842d364 --- /dev/null +++ b/frontends/main/src/components/tiptap-icons/underline-icon.tsx @@ -0,0 +1,26 @@ +import { memo } from "react" + +export const UnderlineIcon = memo( + ({ className, ...props }: React.SVGProps) => { + return ( + + + + ) + } +) + +UnderlineIcon.displayName = "UnderlineIcon" diff --git a/frontends/main/src/components/tiptap-icons/undo2-icon.tsx b/frontends/main/src/components/tiptap-icons/undo2-icon.tsx new file mode 100644 index 0000000000..f337a4ec25 --- /dev/null +++ b/frontends/main/src/components/tiptap-icons/undo2-icon.tsx @@ -0,0 +1,26 @@ +import { memo } from "react" + +export const Undo2Icon = memo( + ({ className, ...props }: React.SVGProps) => { + return ( + + + + ) + } +) + +Undo2Icon.displayName = "Undo2Icon" diff --git a/frontends/main/src/components/tiptap-node/blockquote-node/blockquote-node.scss b/frontends/main/src/components/tiptap-node/blockquote-node/blockquote-node.scss new file mode 100644 index 0000000000..b49c5e11e4 --- /dev/null +++ b/frontends/main/src/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/main/src/components/tiptap-node/code-block-node/code-block-node.scss b/frontends/main/src/components/tiptap-node/code-block-node/code-block-node.scss new file mode 100644 index 0000000000..d31b312f6d --- /dev/null +++ b/frontends/main/src/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/main/src/components/tiptap-node/heading-node/heading-node.scss b/frontends/main/src/components/tiptap-node/heading-node/heading-node.scss new file mode 100644 index 0000000000..882dda2d3a --- /dev/null +++ b/frontends/main/src/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/main/src/components/tiptap-node/horizontal-rule-node/horizontal-rule-node-extension.ts b/frontends/main/src/components/tiptap-node/horizontal-rule-node/horizontal-rule-node-extension.ts new file mode 100644 index 0000000000..de28208616 --- /dev/null +++ b/frontends/main/src/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/main/src/components/tiptap-node/horizontal-rule-node/horizontal-rule-node.scss b/frontends/main/src/components/tiptap-node/horizontal-rule-node/horizontal-rule-node.scss new file mode 100644 index 0000000000..4626e65889 --- /dev/null +++ b/frontends/main/src/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/main/src/components/tiptap-node/image-node/image-node.scss b/frontends/main/src/components/tiptap-node/image-node/image-node.scss new file mode 100644 index 0000000000..10d4231cac --- /dev/null +++ b/frontends/main/src/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/main/src/components/tiptap-node/image-upload-node/image-upload-node-extension.ts b/frontends/main/src/components/tiptap-node/image-upload-node/image-upload-node-extension.ts new file mode 100644 index 0000000000..c282b1b35c --- /dev/null +++ b/frontends/main/src/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 "@/components/tiptap-node/image-upload-node/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/main/src/components/tiptap-node/image-upload-node/image-upload-node.scss b/frontends/main/src/components/tiptap-node/image-upload-node/image-upload-node.scss new file mode 100644 index 0000000000..b85e1e33f1 --- /dev/null +++ b/frontends/main/src/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/main/src/components/tiptap-node/image-upload-node/image-upload-node.tsx b/frontends/main/src/components/tiptap-node/image-upload-node/image-upload-node.tsx new file mode 100644 index 0000000000..4b56f1cb60 --- /dev/null +++ b/frontends/main/src/components/tiptap-node/image-upload-node/image-upload-node.tsx @@ -0,0 +1,554 @@ +"use client" + +import { useRef, useState } from "react" +import type { NodeViewProps } from "@tiptap/react" +import { NodeViewWrapper } from "@tiptap/react" +import { Button } from "@/components/tiptap-ui-primitive/button" +import { CloseIcon } from "@/components/tiptap-icons/close-icon" +import "@/components/tiptap-node/image-upload-node/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}% + + )} + { + e.stopPropagation() + onRemove() + }} + > + + + + + + ) +} + +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 + { + e.stopPropagation() + clearAllFiles() + }} + > + Clear All + + + )} + {fileItems.map((fileItem) => ( + removeFileItem(fileItem.id)} + /> + ))} + + )} + + 1} + onChange={handleChange} + onClick={(e: React.MouseEvent) => e.stopPropagation()} + /> + + ) +} diff --git a/frontends/main/src/components/tiptap-node/image-upload-node/index.tsx b/frontends/main/src/components/tiptap-node/image-upload-node/index.tsx new file mode 100644 index 0000000000..2510a62fae --- /dev/null +++ b/frontends/main/src/components/tiptap-node/image-upload-node/index.tsx @@ -0,0 +1 @@ +export * from "./image-upload-node-extension" diff --git a/frontends/main/src/components/tiptap-node/list-node/list-node.scss b/frontends/main/src/components/tiptap-node/list-node/list-node.scss new file mode 100644 index 0000000000..d0fe5c8f25 --- /dev/null +++ b/frontends/main/src/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/main/src/components/tiptap-node/paragraph-node/paragraph-node.scss b/frontends/main/src/components/tiptap-node/paragraph-node/paragraph-node.scss new file mode 100644 index 0000000000..8b73f9f0f5 --- /dev/null +++ b/frontends/main/src/components/tiptap-node/paragraph-node/paragraph-node.scss @@ -0,0 +1,272 @@ +.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); + 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/main/src/components/tiptap-templates/simple/data/content.json b/frontends/main/src/components/tiptap-templates/simple/data/content.json new file mode 100644 index 0000000000..d9ff103110 --- /dev/null +++ b/frontends/main/src/components/tiptap-templates/simple/data/content.json @@ -0,0 +1,469 @@ +{ + "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": "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/main/src/components/tiptap-templates/simple/simple-editor.scss b/frontends/main/src/components/tiptap-templates/simple/simple-editor.scss new file mode 100644 index 0000000000..8faf836440 --- /dev/null +++ b/frontends/main/src/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/main/src/components/tiptap-templates/simple/simple-editor.tsx b/frontends/main/src/components/tiptap-templates/simple/simple-editor.tsx new file mode 100644 index 0000000000..53683bd66e --- /dev/null +++ b/frontends/main/src/components/tiptap-templates/simple/simple-editor.tsx @@ -0,0 +1,313 @@ +"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 as TiptapButton } from "@/components/tiptap-ui-primitive/button" +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, + ColorHighlightPopoverContent, + ColorHighlightPopoverButton, +} from "@/components/tiptap-ui/color-highlight-popover" +import { + LinkPopover, + LinkContent, + LinkButton, +} 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" + +// --- Icons --- +import { ArrowLeftIcon } from "@/components/tiptap-icons/arrow-left-icon" +import { HighlighterIcon } from "@/components/tiptap-icons/highlighter-icon" +import { LinkIcon } from "@/components/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 "@/components/tiptap-templates/simple/theme-toggle" + +// // --- Lib --- +// import { handleImageUpload, MAX_FILE_SIZE } from "@/lib/tiptap-utils" + +// --- Styles --- +import "@/components/tiptap-templates/simple/simple-editor.scss" + +import { Button } from "@mitodl/smoot-design" + +const MainToolbarContent = ({ + onHighlighterClick, + onLinkClick, + isMobile, + editor, +}: { + onHighlighterClick: () => void + onLinkClick: () => void + isMobile: boolean + editor: Editor +}) => { + return ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + {!isMobile ? ( + + ) : ( + + )} + {!isMobile ? : } + + + + + {/* + + + */} + + + + + + + + + + + + + + { + editor.commands.insertContent({ + type: "resourceCard", + attrs: { resourceId: "14731" }, + }) + }} + > + + Resource Card + + { + editor.commands.insertContent({ + type: "askTimDrawerButton", + }) + }} + > + + AskTIM Button + + + + + + {isMobile && } + + + + + > + ) +} + +const MobileToolbarContent = ({ + type, + onBack, +}: { + type: "highlighter" | "link" + onBack: () => void +}) => ( + <> + + + + {type === "highlighter" ? ( + + ) : ( + + )} + + + + + + {type === "highlighter" ? ( + + ) : ( + + )} + > +) + +export function SimpleEditor({ + editor, + className, +}: { + editor: Editor + className?: string +}) { + 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, + // }) + + const rect = useCursorVisibility({ + editor, + overlayHeight: toolbarRef.current?.getBoundingClientRect().height ?? 0, + }) + + useEffect(() => { + if (!isMobile && mobileView !== "main") { + setMobileView("main") + } + }, [isMobile, mobileView]) + + console.log("editor", editor) + + return ( + + {/* */} + + {mobileView === "main" ? ( + setMobileView("highlighter")} + onLinkClick={() => setMobileView("link")} + isMobile={isMobile} + editor={editor} + /> + ) : ( + setMobileView("main")} + /> + )} + + + + {/* */} + + ) +} diff --git a/frontends/main/src/components/tiptap-templates/simple/theme-toggle.tsx b/frontends/main/src/components/tiptap-templates/simple/theme-toggle.tsx new file mode 100644 index 0000000000..6152302539 --- /dev/null +++ b/frontends/main/src/components/tiptap-templates/simple/theme-toggle.tsx @@ -0,0 +1,47 @@ +"use client" + +// --- UI Primitives --- +import { Button } from "@/components/tiptap-ui-primitive/button" + +// --- Icons --- +import { MoonStarIcon } from "@/components/tiptap-icons/moon-star-icon" +import { SunIcon } from "@/components/tiptap-icons/sun-icon" +import { 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 ( + + {isDarkMode ? ( + + ) : ( + + )} + + ) +} diff --git a/frontends/main/src/components/tiptap-ui-primitive/badge/badge-colors.scss b/frontends/main/src/components/tiptap-ui-primitive/badge/badge-colors.scss new file mode 100644 index 0000000000..8f8a988fbb --- /dev/null +++ b/frontends/main/src/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/main/src/components/tiptap-ui-primitive/badge/badge-group.scss b/frontends/main/src/components/tiptap-ui-primitive/badge/badge-group.scss new file mode 100644 index 0000000000..91bd45b10e --- /dev/null +++ b/frontends/main/src/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/main/src/components/tiptap-ui-primitive/badge/badge.scss b/frontends/main/src/components/tiptap-ui-primitive/badge/badge.scss new file mode 100644 index 0000000000..b2ca9a8829 --- /dev/null +++ b/frontends/main/src/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/main/src/components/tiptap-ui-primitive/badge/badge.tsx b/frontends/main/src/components/tiptap-ui-primitive/badge/badge.tsx new file mode 100644 index 0000000000..bc379a707b --- /dev/null +++ b/frontends/main/src/components/tiptap-ui-primitive/badge/badge.tsx @@ -0,0 +1,46 @@ +"use client" + +import { forwardRef } from "react" +import "@/components/tiptap-ui-primitive/badge/badge-colors.scss" +import "@/components/tiptap-ui-primitive/badge/badge-group.scss" +import "@/components/tiptap-ui-primitive/badge/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/main/src/components/tiptap-ui-primitive/badge/index.tsx b/frontends/main/src/components/tiptap-ui-primitive/badge/index.tsx new file mode 100644 index 0000000000..051fa6ea23 --- /dev/null +++ b/frontends/main/src/components/tiptap-ui-primitive/badge/index.tsx @@ -0,0 +1 @@ +export * from "./badge" diff --git a/frontends/main/src/components/tiptap-ui-primitive/button/button-colors.scss b/frontends/main/src/components/tiptap-ui-primitive/button/button-colors.scss new file mode 100644 index 0000000000..fc0dd35e44 --- /dev/null +++ b/frontends/main/src/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/main/src/components/tiptap-ui-primitive/button/button-group.scss b/frontends/main/src/components/tiptap-ui-primitive/button/button-group.scss new file mode 100644 index 0000000000..59fd2561df --- /dev/null +++ b/frontends/main/src/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/main/src/components/tiptap-ui-primitive/button/button.scss b/frontends/main/src/components/tiptap-ui-primitive/button/button.scss new file mode 100644 index 0000000000..32d1499b3c --- /dev/null +++ b/frontends/main/src/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/main/src/components/tiptap-ui-primitive/button/button.tsx b/frontends/main/src/components/tiptap-ui-primitive/button/button.tsx new file mode 100644 index 0000000000..d01cf13eed --- /dev/null +++ b/frontends/main/src/components/tiptap-ui-primitive/button/button.tsx @@ -0,0 +1,116 @@ +"use client" + +import { forwardRef, Fragment, useMemo } from "react" + +// --- Tiptap UI Primitive --- +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/tiptap-ui-primitive/tooltip" + +// --- Lib --- +import { cn, parseShortcutKeys } from "@/lib/tiptap-utils" + +import "@/components/tiptap-ui-primitive/button/button-colors.scss" +import "@/components/tiptap-ui-primitive/button/button-group.scss" +import "@/components/tiptap-ui-primitive/button/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 ( + + {children} + + ) + } + + 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/main/src/components/tiptap-ui-primitive/button/index.tsx b/frontends/main/src/components/tiptap-ui-primitive/button/index.tsx new file mode 100644 index 0000000000..e93d26f6b0 --- /dev/null +++ b/frontends/main/src/components/tiptap-ui-primitive/button/index.tsx @@ -0,0 +1 @@ +export * from "./button" diff --git a/frontends/main/src/components/tiptap-ui-primitive/card/card.scss b/frontends/main/src/components/tiptap-ui-primitive/card/card.scss new file mode 100644 index 0000000000..97b757e045 --- /dev/null +++ b/frontends/main/src/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/main/src/components/tiptap-ui-primitive/card/card.tsx b/frontends/main/src/components/tiptap-ui-primitive/card/card.tsx new file mode 100644 index 0000000000..1cd8d32441 --- /dev/null +++ b/frontends/main/src/components/tiptap-ui-primitive/card/card.tsx @@ -0,0 +1,79 @@ +"use client" + +import { forwardRef } from "react" +import { cn } from "@/lib/tiptap-utils" +import "@/components/tiptap-ui-primitive/card/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/main/src/components/tiptap-ui-primitive/card/index.tsx b/frontends/main/src/components/tiptap-ui-primitive/card/index.tsx new file mode 100644 index 0000000000..288c75f729 --- /dev/null +++ b/frontends/main/src/components/tiptap-ui-primitive/card/index.tsx @@ -0,0 +1 @@ +export * from "./card" diff --git a/frontends/main/src/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.scss b/frontends/main/src/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.scss new file mode 100644 index 0000000000..03b47e8631 --- /dev/null +++ b/frontends/main/src/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/main/src/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.tsx b/frontends/main/src/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.tsx new file mode 100644 index 0000000000..cc3dea8b5c --- /dev/null +++ b/frontends/main/src/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.tsx @@ -0,0 +1,98 @@ +"use client" + +import { forwardRef } from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { cn } from "@/lib/tiptap-utils" +import "@/components/tiptap-ui-primitive/dropdown-menu/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/main/src/components/tiptap-ui-primitive/dropdown-menu/index.tsx b/frontends/main/src/components/tiptap-ui-primitive/dropdown-menu/index.tsx new file mode 100644 index 0000000000..c4adeceeee --- /dev/null +++ b/frontends/main/src/components/tiptap-ui-primitive/dropdown-menu/index.tsx @@ -0,0 +1 @@ +export * from "./dropdown-menu" diff --git a/frontends/main/src/components/tiptap-ui-primitive/input/index.tsx b/frontends/main/src/components/tiptap-ui-primitive/input/index.tsx new file mode 100644 index 0000000000..be91c8ec4b --- /dev/null +++ b/frontends/main/src/components/tiptap-ui-primitive/input/index.tsx @@ -0,0 +1 @@ +export * from "./input" diff --git a/frontends/main/src/components/tiptap-ui-primitive/input/input.scss b/frontends/main/src/components/tiptap-ui-primitive/input/input.scss new file mode 100644 index 0000000000..b9f777cffe --- /dev/null +++ b/frontends/main/src/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/main/src/components/tiptap-ui-primitive/input/input.tsx b/frontends/main/src/components/tiptap-ui-primitive/input/input.tsx new file mode 100644 index 0000000000..272af6594b --- /dev/null +++ b/frontends/main/src/components/tiptap-ui-primitive/input/input.tsx @@ -0,0 +1,24 @@ +"use client" + +import { cn } from "@/lib/tiptap-utils" +import "@/components/tiptap-ui-primitive/input/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/main/src/components/tiptap-ui-primitive/popover/index.tsx b/frontends/main/src/components/tiptap-ui-primitive/popover/index.tsx new file mode 100644 index 0000000000..137ef5d362 --- /dev/null +++ b/frontends/main/src/components/tiptap-ui-primitive/popover/index.tsx @@ -0,0 +1 @@ +export * from "./popover" diff --git a/frontends/main/src/components/tiptap-ui-primitive/popover/popover.scss b/frontends/main/src/components/tiptap-ui-primitive/popover/popover.scss new file mode 100644 index 0000000000..07fb0e57bd --- /dev/null +++ b/frontends/main/src/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/main/src/components/tiptap-ui-primitive/popover/popover.tsx b/frontends/main/src/components/tiptap-ui-primitive/popover/popover.tsx new file mode 100644 index 0000000000..5d6c23b934 --- /dev/null +++ b/frontends/main/src/components/tiptap-ui-primitive/popover/popover.tsx @@ -0,0 +1,37 @@ +"use client" + +import * as PopoverPrimitive from "@radix-ui/react-popover" +import { cn } from "@/lib/tiptap-utils" +import "@/components/tiptap-ui-primitive/popover/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/main/src/components/tiptap-ui-primitive/separator/index.tsx b/frontends/main/src/components/tiptap-ui-primitive/separator/index.tsx new file mode 100644 index 0000000000..068cfa8369 --- /dev/null +++ b/frontends/main/src/components/tiptap-ui-primitive/separator/index.tsx @@ -0,0 +1 @@ +export * from "./separator" diff --git a/frontends/main/src/components/tiptap-ui-primitive/separator/separator.scss b/frontends/main/src/components/tiptap-ui-primitive/separator/separator.scss new file mode 100644 index 0000000000..78ec9ac6c4 --- /dev/null +++ b/frontends/main/src/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/main/src/components/tiptap-ui-primitive/separator/separator.tsx b/frontends/main/src/components/tiptap-ui-primitive/separator/separator.tsx new file mode 100644 index 0000000000..5f09c50c41 --- /dev/null +++ b/frontends/main/src/components/tiptap-ui-primitive/separator/separator.tsx @@ -0,0 +1,33 @@ +"use client" + +import { forwardRef } from "react" +import "@/components/tiptap-ui-primitive/separator/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/main/src/components/tiptap-ui-primitive/spacer/index.tsx b/frontends/main/src/components/tiptap-ui-primitive/spacer/index.tsx new file mode 100644 index 0000000000..b0789bf135 --- /dev/null +++ b/frontends/main/src/components/tiptap-ui-primitive/spacer/index.tsx @@ -0,0 +1 @@ +export * from "./spacer" diff --git a/frontends/main/src/components/tiptap-ui-primitive/spacer/spacer.tsx b/frontends/main/src/components/tiptap-ui-primitive/spacer/spacer.tsx new file mode 100644 index 0000000000..95e379c21d --- /dev/null +++ b/frontends/main/src/components/tiptap-ui-primitive/spacer/spacer.tsx @@ -0,0 +1,26 @@ +"use client" + +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/main/src/components/tiptap-ui-primitive/toolbar/index.tsx b/frontends/main/src/components/tiptap-ui-primitive/toolbar/index.tsx new file mode 100644 index 0000000000..94b181962f --- /dev/null +++ b/frontends/main/src/components/tiptap-ui-primitive/toolbar/index.tsx @@ -0,0 +1 @@ +export * from "./toolbar" diff --git a/frontends/main/src/components/tiptap-ui-primitive/toolbar/toolbar.scss b/frontends/main/src/components/tiptap-ui-primitive/toolbar/toolbar.scss new file mode 100644 index 0000000000..3ce1862beb --- /dev/null +++ b/frontends/main/src/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/main/src/components/tiptap-ui-primitive/toolbar/toolbar.tsx b/frontends/main/src/components/tiptap-ui-primitive/toolbar/toolbar.tsx new file mode 100644 index 0000000000..3b99e54a78 --- /dev/null +++ b/frontends/main/src/components/tiptap-ui-primitive/toolbar/toolbar.tsx @@ -0,0 +1,123 @@ +"use client" + +import { forwardRef, useCallback, useEffect, useRef, useState } from "react" +import { Separator } from "@/components/tiptap-ui-primitive/separator" +import "@/components/tiptap-ui-primitive/toolbar/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/main/src/components/tiptap-ui-primitive/tooltip/index.tsx b/frontends/main/src/components/tiptap-ui-primitive/tooltip/index.tsx new file mode 100644 index 0000000000..e12712a782 --- /dev/null +++ b/frontends/main/src/components/tiptap-ui-primitive/tooltip/index.tsx @@ -0,0 +1 @@ +export * from "./tooltip" diff --git a/frontends/main/src/components/tiptap-ui-primitive/tooltip/tooltip.scss b/frontends/main/src/components/tiptap-ui-primitive/tooltip/tooltip.scss new file mode 100644 index 0000000000..d717757fa0 --- /dev/null +++ b/frontends/main/src/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/main/src/components/tiptap-ui-primitive/tooltip/tooltip.tsx b/frontends/main/src/components/tiptap-ui-primitive/tooltip/tooltip.tsx new file mode 100644 index 0000000000..59ecc44a80 --- /dev/null +++ b/frontends/main/src/components/tiptap-ui-primitive/tooltip/tooltip.tsx @@ -0,0 +1,237 @@ +"use client" + +import { + 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 "@/components/tiptap-ui-primitive/tooltip/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 ( + + {children} + + ) + } +) + +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/main/src/components/tiptap-ui/blockquote-button/blockquote-button.tsx b/frontends/main/src/components/tiptap-ui/blockquote-button/blockquote-button.tsx new file mode 100644 index 0000000000..b2ac501e46 --- /dev/null +++ b/frontends/main/src/components/tiptap-ui/blockquote-button/blockquote-button.tsx @@ -0,0 +1,125 @@ +"use client" + +import { forwardRef, useCallback } from "react" + +// --- Tiptap UI --- +import type { UseBlockquoteConfig } from "@/components/tiptap-ui/blockquote-button" +import { + BLOCKQUOTE_SHORTCUT_KEY, + useBlockquote, +} from "@/components/tiptap-ui/blockquote-button" + +// --- Hooks --- +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" + +// --- Lib --- +import { parseShortcutKeys } from "@/lib/tiptap-utils" + +// --- UI Primitives --- +import type { ButtonProps } from "@/components/tiptap-ui-primitive/button" +import { Button } from "@/components/tiptap-ui-primitive/button" +import { Badge } from "@/components/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 ( + + {children ?? ( + <> + + {text && {text}} + {showShortcut && ( + + )} + > + )} + + ) + } +) + +BlockquoteButton.displayName = "BlockquoteButton" diff --git a/frontends/main/src/components/tiptap-ui/blockquote-button/index.tsx b/frontends/main/src/components/tiptap-ui/blockquote-button/index.tsx new file mode 100644 index 0000000000..0b46edfc32 --- /dev/null +++ b/frontends/main/src/components/tiptap-ui/blockquote-button/index.tsx @@ -0,0 +1,2 @@ +export * from "./blockquote-button" +export * from "./use-blockquote" diff --git a/frontends/main/src/components/tiptap-ui/blockquote-button/use-blockquote.ts b/frontends/main/src/components/tiptap-ui/blockquote-button/use-blockquote.ts new file mode 100644 index 0000000000..79d7a9294c --- /dev/null +++ b/frontends/main/src/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 "@/components/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 Blockquote + * } + * + * // 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/main/src/components/tiptap-ui/code-block-button/code-block-button.tsx b/frontends/main/src/components/tiptap-ui/code-block-button/code-block-button.tsx new file mode 100644 index 0000000000..93449bb60b --- /dev/null +++ b/frontends/main/src/components/tiptap-ui/code-block-button/code-block-button.tsx @@ -0,0 +1,125 @@ +"use client" + +import { 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 "@/components/tiptap-ui/code-block-button" +import { + CODE_BLOCK_SHORTCUT_KEY, + useCodeBlock, +} from "@/components/tiptap-ui/code-block-button" + +// --- UI Primitives --- +import type { ButtonProps } from "@/components/tiptap-ui-primitive/button" +import { Button } from "@/components/tiptap-ui-primitive/button" +import { Badge } from "@/components/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 ( + + {children ?? ( + <> + + {text && {text}} + {showShortcut && ( + + )} + > + )} + + ) + } +) + +CodeBlockButton.displayName = "CodeBlockButton" diff --git a/frontends/main/src/components/tiptap-ui/code-block-button/index.tsx b/frontends/main/src/components/tiptap-ui/code-block-button/index.tsx new file mode 100644 index 0000000000..77d541f9c4 --- /dev/null +++ b/frontends/main/src/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/main/src/components/tiptap-ui/code-block-button/use-code-block.ts b/frontends/main/src/components/tiptap-ui/code-block-button/use-code-block.ts new file mode 100644 index 0000000000..0475eb72bb --- /dev/null +++ b/frontends/main/src/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 "@/components/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 ( + * + * Code Block + * + * ) + * } + * + * // 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/main/src/components/tiptap-ui/color-highlight-button/color-highlight-button.scss b/frontends/main/src/components/tiptap-ui/color-highlight-button/color-highlight-button.scss new file mode 100644 index 0000000000..2c6f387732 --- /dev/null +++ b/frontends/main/src/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/main/src/components/tiptap-ui/color-highlight-button/color-highlight-button.tsx b/frontends/main/src/components/tiptap-ui/color-highlight-button/color-highlight-button.tsx new file mode 100644 index 0000000000..cea6b060ad --- /dev/null +++ b/frontends/main/src/components/tiptap-ui/color-highlight-button/color-highlight-button.tsx @@ -0,0 +1,171 @@ +"use client" + +import { 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 "@/components/tiptap-ui/color-highlight-button" +import { + COLOR_HIGHLIGHT_SHORTCUT_KEY, + useColorHighlight, +} from "@/components/tiptap-ui/color-highlight-button" + +// --- UI Primitives --- +import type { ButtonProps } from "@/components/tiptap-ui-primitive/button" +import { Button } from "@/components/tiptap-ui-primitive/button" +import { Badge } from "@/components/tiptap-ui-primitive/badge" + +// --- Styles --- +import "@/components/tiptap-ui/color-highlight-button/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 ( + + {children ?? ( + <> + + {text && {text}} + {showShortcut && ( + + )} + > + )} + + ) + } +) + +ColorHighlightButton.displayName = "ColorHighlightButton" diff --git a/frontends/main/src/components/tiptap-ui/color-highlight-button/index.tsx b/frontends/main/src/components/tiptap-ui/color-highlight-button/index.tsx new file mode 100644 index 0000000000..c517648273 --- /dev/null +++ b/frontends/main/src/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/main/src/components/tiptap-ui/color-highlight-button/use-color-highlight.ts b/frontends/main/src/components/tiptap-ui/color-highlight-button/use-color-highlight.ts new file mode 100644 index 0000000000..b71215e0b8 --- /dev/null +++ b/frontends/main/src/components/tiptap-ui/color-highlight-button/use-color-highlight.ts @@ -0,0 +1,339 @@ +"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 "@/components/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 { + return editor.can().toggleNodeBackgroundColor("test") + } 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 { + return editor.chain().focus().unsetNodeBackgroundColor().run() + } +} + +/** + * 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() + .toggleNodeBackgroundColor(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/main/src/components/tiptap-ui/color-highlight-popover/color-highlight-popover.tsx b/frontends/main/src/components/tiptap-ui/color-highlight-popover/color-highlight-popover.tsx new file mode 100644 index 0000000000..604c7bf502 --- /dev/null +++ b/frontends/main/src/components/tiptap-ui/color-highlight-popover/color-highlight-popover.tsx @@ -0,0 +1,211 @@ +"use client" + +import { 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 "@/components/tiptap-icons/ban-icon" +import { HighlighterIcon } from "@/components/tiptap-icons/highlighter-icon" + +// --- UI Primitives --- +import type { ButtonProps } from "@/components/tiptap-ui-primitive/button" +import { Button, ButtonGroup } from "@/components/tiptap-ui-primitive/button" +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "@/components/tiptap-ui-primitive/popover" +import { Separator } from "@/components/tiptap-ui-primitive/separator" +import { + Card, + CardBody, + CardItemGroup, +} from "@/components/tiptap-ui-primitive/card" + +// --- Tiptap UI --- +import type { + HighlightColor, + UseColorHighlightConfig, +} from "@/components/tiptap-ui/color-highlight-button" +import { + ColorHighlightButton, + pickHighlightColorsByValue, + useColorHighlight, +} from "@/components/tiptap-ui/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) => ( + + {children ?? } + +)) + +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/main/src/components/tiptap-ui/color-highlight-popover/index.tsx b/frontends/main/src/components/tiptap-ui/color-highlight-popover/index.tsx new file mode 100644 index 0000000000..626b81f6e7 --- /dev/null +++ b/frontends/main/src/components/tiptap-ui/color-highlight-popover/index.tsx @@ -0,0 +1 @@ +export * from "./color-highlight-popover" diff --git a/frontends/main/src/components/tiptap-ui/heading-button/heading-button.tsx b/frontends/main/src/components/tiptap-ui/heading-button/heading-button.tsx new file mode 100644 index 0000000000..187c863c97 --- /dev/null +++ b/frontends/main/src/components/tiptap-ui/heading-button/heading-button.tsx @@ -0,0 +1,127 @@ +"use client" + +import { forwardRef, useCallback } from "react" + +// --- Lib --- +import { parseShortcutKeys } from "@/lib/tiptap-utils" + +// --- Tiptap UI --- +import type { + Level, + UseHeadingConfig, +} from "@/components/tiptap-ui/heading-button" +import { + HEADING_SHORTCUT_KEYS, + useHeading, +} from "@/components/tiptap-ui/heading-button" + +// --- UI Primitives --- +import type { ButtonProps } from "@/components/tiptap-ui-primitive/button" +import { Button } from "@/components/tiptap-ui-primitive/button" +import { Badge } from "@/components/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 ( + + {children ?? ( + <> + + {text && {text}} + {showShortcut && ( + + )} + > + )} + + ) + } +) + +HeadingButton.displayName = "HeadingButton" diff --git a/frontends/main/src/components/tiptap-ui/heading-button/index.tsx b/frontends/main/src/components/tiptap-ui/heading-button/index.tsx new file mode 100644 index 0000000000..009a7005b6 --- /dev/null +++ b/frontends/main/src/components/tiptap-ui/heading-button/index.tsx @@ -0,0 +1,2 @@ +export * from "./heading-button" +export * from "./use-heading" diff --git a/frontends/main/src/components/tiptap-ui/heading-button/use-heading.ts b/frontends/main/src/components/tiptap-ui/heading-button/use-heading.ts new file mode 100644 index 0000000000..0157f988e7 --- /dev/null +++ b/frontends/main/src/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 "@/components/tiptap-icons/heading-one-icon" +import { HeadingTwoIcon } from "@/components/tiptap-icons/heading-two-icon" +import { HeadingThreeIcon } from "@/components/tiptap-icons/heading-three-icon" +import { HeadingFourIcon } from "@/components/tiptap-icons/heading-four-icon" +import { HeadingFiveIcon } from "@/components/tiptap-icons/heading-five-icon" +import { HeadingSixIcon } from "@/components/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 ( + * + * + * Heading 1 + * + * ) + * } + * + * // 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/main/src/components/tiptap-ui/heading-dropdown-menu/heading-dropdown-menu.tsx b/frontends/main/src/components/tiptap-ui/heading-dropdown-menu/heading-dropdown-menu.tsx new file mode 100644 index 0000000000..7bf1c8f6c6 --- /dev/null +++ b/frontends/main/src/components/tiptap-ui/heading-dropdown-menu/heading-dropdown-menu.tsx @@ -0,0 +1,129 @@ +"use client" + +import { forwardRef, useCallback, useState } from "react" + +// --- Icons --- +import { ChevronDownIcon } from "@/components/tiptap-icons/chevron-down-icon" + +// --- Hooks --- +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" + +// --- Tiptap UI --- +import { HeadingButton } from "@/components/tiptap-ui/heading-button" +import type { UseHeadingDropdownMenuConfig } from "@/components/tiptap-ui/heading-dropdown-menu" +import { useHeadingDropdownMenu } from "@/components/tiptap-ui/heading-dropdown-menu" + +// --- UI Primitives --- +import type { ButtonProps } from "@/components/tiptap-ui-primitive/button" +import { Button, ButtonGroup } from "@/components/tiptap-ui-primitive/button" +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, +} from "@/components/tiptap-ui-primitive/dropdown-menu" +import { Card, CardBody } from "@/components/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/main/src/components/tiptap-ui/heading-dropdown-menu/index.tsx b/frontends/main/src/components/tiptap-ui/heading-dropdown-menu/index.tsx new file mode 100644 index 0000000000..33b9679900 --- /dev/null +++ b/frontends/main/src/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/main/src/components/tiptap-ui/heading-dropdown-menu/use-heading-dropdown-menu.ts b/frontends/main/src/components/tiptap-ui/heading-dropdown-menu/use-heading-dropdown-menu.ts new file mode 100644 index 0000000000..b25ce63785 --- /dev/null +++ b/frontends/main/src/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 "@/components/tiptap-icons/heading-icon" + +// --- Tiptap UI --- +import { + headingIcons, + type Level, + isHeadingActive, + canToggle, + shouldShowButton, +} from "@/components/tiptap-ui/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/main/src/components/tiptap-ui/image-upload-button/image-upload-button.tsx b/frontends/main/src/components/tiptap-ui/image-upload-button/image-upload-button.tsx new file mode 100644 index 0000000000..94f34b34e7 --- /dev/null +++ b/frontends/main/src/components/tiptap-ui/image-upload-button/image-upload-button.tsx @@ -0,0 +1,133 @@ +"use client" + +import { 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 "@/components/tiptap-ui/image-upload-button" +import { + IMAGE_UPLOAD_SHORTCUT_KEY, + useImageUpload, +} from "@/components/tiptap-ui/image-upload-button" + +// --- UI Primitives --- +import type { ButtonProps } from "@/components/tiptap-ui-primitive/button" +import { Button } from "@/components/tiptap-ui-primitive/button" +import { Badge } from "@/components/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 ( + + {children ?? ( + <> + + {text && {text}} + {showShortcut && } + > + )} + + ) + } +) + +ImageUploadButton.displayName = "ImageUploadButton" diff --git a/frontends/main/src/components/tiptap-ui/image-upload-button/index.tsx b/frontends/main/src/components/tiptap-ui/image-upload-button/index.tsx new file mode 100644 index 0000000000..815d5bb5ef --- /dev/null +++ b/frontends/main/src/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/main/src/components/tiptap-ui/image-upload-button/use-image-upload.ts b/frontends/main/src/components/tiptap-ui/image-upload-button/use-image-upload.ts new file mode 100644 index 0000000000..7d91811ff3 --- /dev/null +++ b/frontends/main/src/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 "@/components/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 Add Image + * } + * + * // 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/main/src/components/tiptap-ui/link-popover/index.tsx b/frontends/main/src/components/tiptap-ui/link-popover/index.tsx new file mode 100644 index 0000000000..e725ea83ae --- /dev/null +++ b/frontends/main/src/components/tiptap-ui/link-popover/index.tsx @@ -0,0 +1,2 @@ +export * from "./link-popover" +export * from "./use-link-popover" diff --git a/frontends/main/src/components/tiptap-ui/link-popover/link-popover.tsx b/frontends/main/src/components/tiptap-ui/link-popover/link-popover.tsx new file mode 100644 index 0000000000..4f6151db66 --- /dev/null +++ b/frontends/main/src/components/tiptap-ui/link-popover/link-popover.tsx @@ -0,0 +1,307 @@ +"use client" + +import { 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 "@/components/tiptap-icons/corner-down-left-icon" +import { ExternalLinkIcon } from "@/components/tiptap-icons/external-link-icon" +import { LinkIcon } from "@/components/tiptap-icons/link-icon" +import { TrashIcon } from "@/components/tiptap-icons/trash-icon" + +// --- Tiptap UI --- +import type { UseLinkPopoverConfig } from "@/components/tiptap-ui/link-popover" +import { useLinkPopover } from "@/components/tiptap-ui/link-popover" + +// --- UI Primitives --- +import type { ButtonProps } from "@/components/tiptap-ui-primitive/button" +import { Button, ButtonGroup } from "@/components/tiptap-ui-primitive/button" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/tiptap-ui-primitive/popover" +import { Separator } from "@/components/tiptap-ui-primitive/separator" +import { + Card, + CardBody, + CardItemGroup, +} from "@/components/tiptap-ui-primitive/card" +import { Input, InputGroup } from "@/components/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 ( + + {children || } + + ) + } +) + +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/main/src/components/tiptap-ui/link-popover/use-link-popover.ts b/frontends/main/src/components/tiptap-ui/link-popover/use-link-popover.ts new file mode 100644 index 0000000000..338ee84bdb --- /dev/null +++ b/frontends/main/src/components/tiptap-ui/link-popover/use-link-popover.ts @@ -0,0 +1,286 @@ +"use client" + +import { useCallback, useEffect, useState } from "react" +import type { Editor } from "@tiptap/react" + +// --- Hooks --- +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" + +// --- Icons --- +import { LinkIcon } from "@/components/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 Link + * } + * + * // 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/main/src/components/tiptap-ui/list-button/index.tsx b/frontends/main/src/components/tiptap-ui/list-button/index.tsx new file mode 100644 index 0000000000..9f3d066656 --- /dev/null +++ b/frontends/main/src/components/tiptap-ui/list-button/index.tsx @@ -0,0 +1,2 @@ +export * from "./list-button" +export * from "./use-list" diff --git a/frontends/main/src/components/tiptap-ui/list-button/list-button.tsx b/frontends/main/src/components/tiptap-ui/list-button/list-button.tsx new file mode 100644 index 0000000000..0d9c437185 --- /dev/null +++ b/frontends/main/src/components/tiptap-ui/list-button/list-button.tsx @@ -0,0 +1,123 @@ +"use client" + +import { 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 "@/components/tiptap-ui-primitive/button" +import { Button } from "@/components/tiptap-ui-primitive/button" +import { Badge } from "@/components/tiptap-ui-primitive/badge" + +// --- Tiptap UI --- +import type { ListType, UseListConfig } from "@/components/tiptap-ui/list-button" +import { LIST_SHORTCUT_KEYS, useList } from "@/components/tiptap-ui/list-button" + +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 ( + + {children ?? ( + <> + + {text && {text}} + {showShortcut && ( + + )} + > + )} + + ) + } +) + +ListButton.displayName = "ListButton" diff --git a/frontends/main/src/components/tiptap-ui/list-button/use-list.ts b/frontends/main/src/components/tiptap-ui/list-button/use-list.ts new file mode 100644 index 0000000000..55bbbff6bc --- /dev/null +++ b/frontends/main/src/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 "@/components/tiptap-icons/list-icon" +import { ListOrderedIcon } from "@/components/tiptap-icons/list-ordered-icon" +import { ListTodoIcon } from "@/components/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 Bullet List + * } + * + * // 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/main/src/components/tiptap-ui/list-dropdown-menu/index.tsx b/frontends/main/src/components/tiptap-ui/list-dropdown-menu/index.tsx new file mode 100644 index 0000000000..9a215b8016 --- /dev/null +++ b/frontends/main/src/components/tiptap-ui/list-dropdown-menu/index.tsx @@ -0,0 +1 @@ +export * from "./list-dropdown-menu" diff --git a/frontends/main/src/components/tiptap-ui/list-dropdown-menu/list-dropdown-menu.tsx b/frontends/main/src/components/tiptap-ui/list-dropdown-menu/list-dropdown-menu.tsx new file mode 100644 index 0000000000..57dbbeaf1e --- /dev/null +++ b/frontends/main/src/components/tiptap-ui/list-dropdown-menu/list-dropdown-menu.tsx @@ -0,0 +1,125 @@ +"use client" + +import { useCallback, useState } from "react" +import { type Editor } from "@tiptap/react" + +// --- Hooks --- +import { useTiptapEditor } from "@/hooks/use-tiptap-editor" + +// --- Icons --- +import { ChevronDownIcon } from "@/components/tiptap-icons/chevron-down-icon" + +// --- Tiptap UI --- +import { ListButton, type ListType } from "@/components/tiptap-ui/list-button" + +import { useListDropdownMenu } from "./use-list-dropdown-menu" + +// --- UI Primitives --- +import type { ButtonProps } from "@/components/tiptap-ui-primitive/button" +import { Button, ButtonGroup } from "@/components/tiptap-ui-primitive/button" +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, +} from "@/components/tiptap-ui-primitive/dropdown-menu" +import { Card, CardBody } from "@/components/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/main/src/components/tiptap-ui/list-dropdown-menu/use-list-dropdown-menu.ts b/frontends/main/src/components/tiptap-ui/list-dropdown-menu/use-list-dropdown-menu.ts new file mode 100644 index 0000000000..ed7b5c1bff --- /dev/null +++ b/frontends/main/src/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 "@/components/tiptap-icons/list-icon" +import { ListOrderedIcon } from "@/components/tiptap-icons/list-ordered-icon" +import { ListTodoIcon } from "@/components/tiptap-icons/list-todo-icon" + +// --- Lib --- +import { isNodeInSchema } from "@/lib/tiptap-utils" + +// --- Tiptap UI --- +import { + canToggleList, + isListActive, + listIcons, + type ListType, +} from "@/components/tiptap-ui/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/main/src/components/tiptap-ui/mark-button/index.tsx b/frontends/main/src/components/tiptap-ui/mark-button/index.tsx new file mode 100644 index 0000000000..32e85b9c7b --- /dev/null +++ b/frontends/main/src/components/tiptap-ui/mark-button/index.tsx @@ -0,0 +1,2 @@ +export * from "./mark-button" +export * from "./use-mark" diff --git a/frontends/main/src/components/tiptap-ui/mark-button/mark-button.tsx b/frontends/main/src/components/tiptap-ui/mark-button/mark-button.tsx new file mode 100644 index 0000000000..338bae71c0 --- /dev/null +++ b/frontends/main/src/components/tiptap-ui/mark-button/mark-button.tsx @@ -0,0 +1,123 @@ +"use client" + +import { 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 "@/components/tiptap-ui/mark-button" +import { MARK_SHORTCUT_KEYS, useMark } from "@/components/tiptap-ui/mark-button" + +// --- UI Primitives --- +import type { ButtonProps } from "@/components/tiptap-ui-primitive/button" +import { Button } from "@/components/tiptap-ui-primitive/button" +import { Badge } from "@/components/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 ( + + {children ?? ( + <> + + {text && {text}} + {showShortcut && ( + + )} + > + )} + + ) + } +) + +MarkButton.displayName = "MarkButton" diff --git a/frontends/main/src/components/tiptap-ui/mark-button/use-mark.ts b/frontends/main/src/components/tiptap-ui/mark-button/use-mark.ts new file mode 100644 index 0000000000..4689ab0c83 --- /dev/null +++ b/frontends/main/src/components/tiptap-ui/mark-button/use-mark.ts @@ -0,0 +1,214 @@ +"use client" + +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 "@/components/tiptap-icons/bold-icon" +import { Code2Icon } from "@/components/tiptap-icons/code2-icon" +import { ItalicIcon } from "@/components/tiptap-icons/italic-icon" +import { StrikeIcon } from "@/components/tiptap-icons/strike-icon" +import { SubscriptIcon } from "@/components/tiptap-icons/subscript-icon" +import { SuperscriptIcon } from "@/components/tiptap-icons/superscript-icon" +import { UnderlineIcon } from "@/components/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 Bold + * } + * + * // 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/main/src/components/tiptap-ui/text-align-button/index.tsx b/frontends/main/src/components/tiptap-ui/text-align-button/index.tsx new file mode 100644 index 0000000000..d19f95cf02 --- /dev/null +++ b/frontends/main/src/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/main/src/components/tiptap-ui/text-align-button/text-align-button.tsx b/frontends/main/src/components/tiptap-ui/text-align-button/text-align-button.tsx new file mode 100644 index 0000000000..b62598831b --- /dev/null +++ b/frontends/main/src/components/tiptap-ui/text-align-button/text-align-button.tsx @@ -0,0 +1,145 @@ +"use client" + +import { 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 "@/components/tiptap-ui/text-align-button" +import { + TEXT_ALIGN_SHORTCUT_KEYS, + useTextAlign, +} from "@/components/tiptap-ui/text-align-button" + +// --- UI Primitives --- +import type { ButtonProps } from "@/components/tiptap-ui-primitive/button" +import { Button } from "@/components/tiptap-ui-primitive/button" +import { Badge } from "@/components/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 ( + + {children ?? ( + <> + + {text && {text}} + {showShortcut && ( + + )} + > + )} + + ) + } +) + +TextAlignButton.displayName = "TextAlignButton" diff --git a/frontends/main/src/components/tiptap-ui/text-align-button/use-text-align.ts b/frontends/main/src/components/tiptap-ui/text-align-button/use-text-align.ts new file mode 100644 index 0000000000..56ba0b4252 --- /dev/null +++ b/frontends/main/src/components/tiptap-ui/text-align-button/use-text-align.ts @@ -0,0 +1,224 @@ +"use client" + +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 "@/components/tiptap-icons/align-center-icon" +import { AlignJustifyIcon } from "@/components/tiptap-icons/align-justify-icon" +import { AlignLeftIcon } from "@/components/tiptap-icons/align-left-icon" +import { AlignRightIcon } from "@/components/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 Align Center + * } + * + * // 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/main/src/components/tiptap-ui/undo-redo-button/index.tsx b/frontends/main/src/components/tiptap-ui/undo-redo-button/index.tsx new file mode 100644 index 0000000000..fa0fdbeb08 --- /dev/null +++ b/frontends/main/src/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/main/src/components/tiptap-ui/undo-redo-button/undo-redo-button.tsx b/frontends/main/src/components/tiptap-ui/undo-redo-button/undo-redo-button.tsx new file mode 100644 index 0000000000..d990b037ef --- /dev/null +++ b/frontends/main/src/components/tiptap-ui/undo-redo-button/undo-redo-button.tsx @@ -0,0 +1,126 @@ +"use client" + +import { 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 "@/components/tiptap-ui/undo-redo-button" +import { + UNDO_REDO_SHORTCUT_KEYS, + useUndoRedo, +} from "@/components/tiptap-ui/undo-redo-button" + +// --- UI Primitives --- +import type { ButtonProps } from "@/components/tiptap-ui-primitive/button" +import { Button } from "@/components/tiptap-ui-primitive/button" +import { Badge } from "@/components/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 ( + + {children ?? ( + <> + + {text && {text}} + {showShortcut && ( + + )} + > + )} + + ) + } +) + +UndoRedoButton.displayName = "UndoRedoButton" diff --git a/frontends/main/src/components/tiptap-ui/undo-redo-button/use-undo-redo.ts b/frontends/main/src/components/tiptap-ui/undo-redo-button/use-undo-redo.ts new file mode 100644 index 0000000000..e3cd0b8cc7 --- /dev/null +++ b/frontends/main/src/components/tiptap-ui/undo-redo-button/use-undo-redo.ts @@ -0,0 +1,184 @@ +"use client" + +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 "@/components/tiptap-icons/redo2-icon" +import { Undo2Icon } from "@/components/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 Undo + * } + * + * // 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/main/src/hooks/use-composed-ref.ts b/frontends/main/src/hooks/use-composed-ref.ts new file mode 100644 index 0000000000..30745b8f98 --- /dev/null +++ b/frontends/main/src/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/main/src/hooks/use-cursor-visibility.ts b/frontends/main/src/hooks/use-cursor-visibility.ts new file mode 100644 index 0000000000..d06d43104b --- /dev/null +++ b/frontends/main/src/hooks/use-cursor-visibility.ts @@ -0,0 +1,71 @@ +"use client" + +import type { Editor } from "@tiptap/react" +import { useWindowSize } from "@/hooks/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/main/src/hooks/use-element-rect.ts b/frontends/main/src/hooks/use-element-rect.ts new file mode 100644 index 0000000000..aede7f8a79 --- /dev/null +++ b/frontends/main/src/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, { passive: true }) + window.addEventListener("resize", handleUpdate, { passive: 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/main/src/hooks/use-menu-navigation.ts b/frontends/main/src/hooks/use-menu-navigation.ts new file mode 100644 index 0000000000..4308fa73d5 --- /dev/null +++ b/frontends/main/src/hooks/use-menu-navigation.ts @@ -0,0 +1,196 @@ +"use client" + +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/main/src/hooks/use-mobile.ts b/frontends/main/src/hooks/use-mobile.ts new file mode 100644 index 0000000000..852eb0002b --- /dev/null +++ b/frontends/main/src/hooks/use-mobile.ts @@ -0,0 +1,19 @@ +"use client" + +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/main/src/hooks/use-scrolling.ts b/frontends/main/src/hooks/use-scrolling.ts new file mode 100644 index 0000000000..3ba2a40e50 --- /dev/null +++ b/frontends/main/src/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, { passive: 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/main/src/hooks/use-throttled-callback.ts b/frontends/main/src/hooks/use-throttled-callback.ts new file mode 100644 index 0000000000..045d3aee7d --- /dev/null +++ b/frontends/main/src/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/main/src/hooks/use-tiptap-editor.ts b/frontends/main/src/hooks/use-tiptap-editor.ts new file mode 100644 index 0000000000..6ec37d26ab --- /dev/null +++ b/frontends/main/src/hooks/use-tiptap-editor.ts @@ -0,0 +1,49 @@ +"use client" + +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/main/src/hooks/use-unmount.ts b/frontends/main/src/hooks/use-unmount.ts new file mode 100644 index 0000000000..91a22e7132 --- /dev/null +++ b/frontends/main/src/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/main/src/hooks/use-window-size.ts b/frontends/main/src/hooks/use-window-size.ts new file mode 100644 index 0000000000..6df968b353 --- /dev/null +++ b/frontends/main/src/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/main/src/lib/tiptap-utils.ts b/frontends/main/src/lib/tiptap-utils.ts new file mode 100644 index 0000000000..e57c245c8e --- /dev/null +++ b/frontends/main/src/lib/tiptap-utils.ts @@ -0,0 +1,554 @@ +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( + // eslint-disable-next-line no-useless-escape + `^(?:(?:${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/main/src/page-components/AiChat/AskTimDrawerButton.tsx b/frontends/main/src/page-components/AiChat/AskTimDrawerButton.tsx index 015f202225..2a939a344c 100644 --- a/frontends/main/src/page-components/AiChat/AskTimDrawerButton.tsx +++ b/frontends/main/src/page-components/AiChat/AskTimDrawerButton.tsx @@ -5,18 +5,25 @@ import AiRecommendationBotDrawer from "./AiRecommendationBotDrawer" import { RECOMMENDER_QUERY_PARAM } from "@/common/urls" const StyledButton = styled(LinkAdapter)(({ theme }) => ({ - display: "flex", - flexDirection: "row", - gap: "8px", - padding: "4px 0", - color: theme.custom.colors.darkGray2, - svg: { - fill: theme.custom.colors.lightRed, - width: "20px", - height: "20px", - }, - "&:hover": { - color: theme.custom.colors.red, + "&&&&": { + display: "flex", + flexDirection: "row", + gap: "8px", + padding: "4px 0", + color: theme.custom.colors.darkGray2, + svg: { + fill: theme.custom.colors.lightRed, + width: "20px", + height: "20px", + }, + "&:hover": { + color: theme.custom.colors.red, + }, + p: { + marginTop: 0, + lineHeight: 1.2, + }, + textDecoration: "none", }, })) diff --git a/frontends/main/src/styles/_keyframe-animations.scss b/frontends/main/src/styles/_keyframe-animations.scss new file mode 100644 index 0000000000..dd98b7cbc6 --- /dev/null +++ b/frontends/main/src/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/main/src/styles/_variables.scss b/frontends/main/src/styles/_variables.scss new file mode 100644 index 0000000000..113a16b404 --- /dev/null +++ b/frontends/main/src/styles/_variables.scss @@ -0,0 +1,296 @@ +: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/package.json b/package.json index 481732a050..3b069ec4fd 100644 --- a/package.json +++ b/package.json @@ -29,5 +29,8 @@ "resolutions": { "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0" + }, + "dependencies": { + "@mitodl/smoot-design": "^6.18.2" } } diff --git a/yarn.lock b/yarn.lock index 482e84ed98..eeae396464 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2117,6 +2117,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 +2136,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 +2170,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" @@ -3215,9 +3267,9 @@ __metadata: languageName: node linkType: hard -"@mitodl/smoot-design@npm:^6.17.1": - version: 6.17.1 - resolution: "@mitodl/smoot-design@npm:6.17.1" +"@mitodl/smoot-design@npm:^6.17.1, @mitodl/smoot-design@npm:^6.18.2": + version: 6.18.2 + resolution: "@mitodl/smoot-design@npm:6.18.2" dependencies: "@ai-sdk/react": "npm:1.2.12" "@emotion/cache": "npm:^11.14.0" @@ -3242,7 +3294,7 @@ __metadata: "@remixicon/react": ^4.2.0 react: ^18 || ^19 react-dom: ^18 || ^19 - checksum: 10/b94bc5d0f89e706701e1e3a7dc2f9a31ba40aaa142ce4740de74b3d79e0ead8dc49aad325c607692a48ea086b9d2803967a9b8834897f0d387229a0b8d53d2ee + checksum: 10/1428acb202a1e0a50fbb5f2b69d66240b53f9c803ef070bbb3e34322793fd2046e18ec349bd5455843a2a1d776bb614c9b3dfca550c3a988481ee17612219fd0 languageName: node linkType: hard @@ -4134,6 +4186,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,127 +4406,618 @@ __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" - "@react-pdf/reconciler": "npm:^1.1.4" - "@react-pdf/render": "npm:^4.3.0" - "@react-pdf/types": "npm:^2.9.0" + "@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" events: "npm:^3.3.0" object-assign: "npm:^4.1.1" prop-types: "npm:^15.6.2" @@ -4378,6 +5065,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" @@ -4387,6 +5081,15 @@ __metadata: languageName: node linkType: hard +"@remixicon/react@npm:^4.7.0": + version: 4.7.0 + resolution: "@remixicon/react@npm:4.7.0" + peerDependencies: + react: ">=18.2.0" + checksum: 10/9ecee093dd4aec3744bcc7562eddb987bf9c7c059787dfecfbcc25a64c2f4833dc815dff715aa2a7f75011d65f877b3751a401d19cf2d725b5bcc685587d37f4 + languageName: node + linkType: hard + "@rollup/plugin-commonjs@npm:28.0.1": version: 28.0.1 resolution: "@rollup/plugin-commonjs@npm:28.0.1" @@ -5752,6 +6455,394 @@ __metadata: languageName: node linkType: hard +"@tiptap/core@npm:^3.10.1": + version: 3.10.1 + resolution: "@tiptap/core@npm:3.10.1" + peerDependencies: + "@tiptap/pm": ^3.10.1 + checksum: 10/2e81e1667d1651b8e604c12a7e8cc16bcfc7d9c41ec9b40ffc0d36798787cff14f32c9c84fa80326c71ac1eada703ec9ef8b061ed1a1835bd46bc179a46d0b50 + languageName: node + linkType: hard + +"@tiptap/extension-blockquote@npm:^3.10.1": + version: 3.10.1 + resolution: "@tiptap/extension-blockquote@npm:3.10.1" + peerDependencies: + "@tiptap/core": ^3.10.1 + checksum: 10/93f8a316dfacee56343c7f740aee16d7c7e6def71af6391092218bdb4692fe57aee9ef893f1486fe24ccd9c3cfc0387a73e9bd24e6f2c8dddde147aa5ccf559a + languageName: node + linkType: hard + +"@tiptap/extension-bold@npm:^3.10.1": + version: 3.10.1 + resolution: "@tiptap/extension-bold@npm:3.10.1" + peerDependencies: + "@tiptap/core": ^3.10.1 + checksum: 10/95e0f590426734c883aa94f36d29934f1ca046191b6527698afaceb37f064c8211ce3dccb21ab1b7483024938526db751e06572b1698fe9df0aed15081a8a2ef + languageName: node + linkType: hard + +"@tiptap/extension-bubble-menu@npm:^3.10.1": + version: 3.10.1 + resolution: "@tiptap/extension-bubble-menu@npm:3.10.1" + dependencies: + "@floating-ui/dom": "npm:^1.0.0" + peerDependencies: + "@tiptap/core": ^3.10.1 + "@tiptap/pm": ^3.10.1 + checksum: 10/41fe0e2403129f081bf7246c20d5b4334f3a1e4761f337379f5ed2444f89a8235e4098a84e2eab0ffc3a64dd58c515a749cf9d8f86610be47a8bb412b3ee5782 + languageName: node + linkType: hard + +"@tiptap/extension-bullet-list@npm:^3.10.1": + version: 3.10.1 + resolution: "@tiptap/extension-bullet-list@npm:3.10.1" + peerDependencies: + "@tiptap/extension-list": ^3.10.1 + checksum: 10/87088c12c4aaa60587a221687437ecf71a1608b690fd1e5cce4b563460cb57ab1d2a6aa24f7a8a27b1cecbf666139c20c49684eccc69546d5eb0b9353336f3c9 + languageName: node + linkType: hard + +"@tiptap/extension-code-block@npm:^3.10.1": + version: 3.10.1 + resolution: "@tiptap/extension-code-block@npm:3.10.1" + peerDependencies: + "@tiptap/core": ^3.10.1 + "@tiptap/pm": ^3.10.1 + checksum: 10/30eded46adeca7a5035993ff8f5494d5f11e6162aa90ca74396bc38399dd1d2106e599553f9497bf29a9a51d08f85a5387873512c4dd272c459e7c78d837cd73 + languageName: node + linkType: hard + +"@tiptap/extension-code@npm:^3.10.1": + version: 3.10.1 + resolution: "@tiptap/extension-code@npm:3.10.1" + peerDependencies: + "@tiptap/core": ^3.10.1 + checksum: 10/58afc409c9ffb6071ab831e9976c4660d2623a5e9d9e57b70ebf32104de79b4d4e66b69647660035291fcd06deb8159fa9bc3c7cf02ca1d50cd96b1e9e414f3f + languageName: node + linkType: hard + +"@tiptap/extension-document@npm:^3.10.1": + version: 3.10.1 + resolution: "@tiptap/extension-document@npm:3.10.1" + peerDependencies: + "@tiptap/core": ^3.10.1 + checksum: 10/5567ddcc2128f31b89cdcd11b1da3982146b50222fda3d011533254a7e4a123c93e94e845d4c2ab54e144566e5b55691684db04dc07947e354d2cb23d7f9c4f3 + languageName: node + linkType: hard + +"@tiptap/extension-dropcursor@npm:^3.10.1": + version: 3.10.1 + resolution: "@tiptap/extension-dropcursor@npm:3.10.1" + peerDependencies: + "@tiptap/extensions": ^3.10.1 + checksum: 10/18ccf2b300eb598ec586cef382a74a2b63f48dcdf9f1e31125644f39494d277a3c3f11b9e02c3a564ae3cca225f7c64ac050984554a361c8a1fcc8657d2ecf20 + languageName: node + linkType: hard + +"@tiptap/extension-floating-menu@npm:^3.10.1": + version: 3.10.1 + resolution: "@tiptap/extension-floating-menu@npm:3.10.1" + peerDependencies: + "@floating-ui/dom": ^1.0.0 + "@tiptap/core": ^3.10.1 + "@tiptap/pm": ^3.10.1 + checksum: 10/417bce0a92542f9ccdd96cac060a344da17cfba0b0134ea83ba273ede34668271a4541d1b8400e46bc79b2baa5f619590e8eb17167f5cf1946c2ea247fbfbdf7 + languageName: node + linkType: hard + +"@tiptap/extension-gapcursor@npm:^3.10.1": + version: 3.10.1 + resolution: "@tiptap/extension-gapcursor@npm:3.10.1" + peerDependencies: + "@tiptap/extensions": ^3.10.1 + checksum: 10/ce5f878f68dface1bb368212811ed842cca1ab81bc4bf0f6b23c8d7f96d195aa3da1c99ee5949f1a6d2aece610553890ae53a03ade0df673e2dc391b0f051bae + languageName: node + linkType: hard + +"@tiptap/extension-hard-break@npm:^3.10.1": + version: 3.10.1 + resolution: "@tiptap/extension-hard-break@npm:3.10.1" + peerDependencies: + "@tiptap/core": ^3.10.1 + checksum: 10/53d9ec81e2e5e6bd5be7cb814808da85d3f25899f58aac615bb43e9464e66fd590e05a8dfbf6ebdb81dc4bd7a9856f14dae6dab340f13996c9ef899e236c8753 + languageName: node + linkType: hard + +"@tiptap/extension-heading@npm:^3.10.1": + version: 3.10.1 + resolution: "@tiptap/extension-heading@npm:3.10.1" + peerDependencies: + "@tiptap/core": ^3.10.1 + checksum: 10/00ece83da05b154c0fc25748ecf07d7507be32c99774b627152f7b853f6cf8cad0e24c42bb3067e4e21769a3041c633deb47a4f7a3f62c90477cd3a16c7b3a93 + languageName: node + linkType: hard + +"@tiptap/extension-highlight@npm:^3.10.1": + version: 3.10.1 + resolution: "@tiptap/extension-highlight@npm:3.10.1" + peerDependencies: + "@tiptap/core": ^3.10.1 + checksum: 10/2c95a490942b17ef9a097bb946658c0ec87e3aceee355b7a65eba69ba9c715bfde532cd63a5c9b187591ac5d80cf3afb730e4a70411429ea221a494a01c4efce + languageName: node + linkType: hard + +"@tiptap/extension-horizontal-rule@npm:^3.10.1": + version: 3.10.1 + resolution: "@tiptap/extension-horizontal-rule@npm:3.10.1" + peerDependencies: + "@tiptap/core": ^3.10.1 + "@tiptap/pm": ^3.10.1 + checksum: 10/56d03fa1d5263250ca16eb6674b9856c1423bec1a6cad210df0432483661e67c908754ddd7235bbf84a0ab1e61e2b0307fd7d6928170001fe109d6eec476f4ff + languageName: node + linkType: hard + +"@tiptap/extension-image@npm:^3.10.1": + version: 3.10.1 + resolution: "@tiptap/extension-image@npm:3.10.1" + peerDependencies: + "@tiptap/core": ^3.10.1 + checksum: 10/657ac0c7c217520bcf751bd129f4c36a188c01cff4ae68668e50fbe9631504ff00ab707a7abfa06c7d125e5309546d467ce231edd1f211739c7187ff0b558c78 + languageName: node + linkType: hard + +"@tiptap/extension-italic@npm:^3.10.1": + version: 3.10.1 + resolution: "@tiptap/extension-italic@npm:3.10.1" + peerDependencies: + "@tiptap/core": ^3.10.1 + checksum: 10/7c67098e115d7bbae14c59fa31b9d2f87c648a97d7b2ca3c99f8623c8b8dc7b93059bb999f29d6affb9855d699aa52a20695879d2bf918ecc1b7cda411c7def4 + languageName: node + linkType: hard + +"@tiptap/extension-link@npm:^3.10.1": + version: 3.10.1 + resolution: "@tiptap/extension-link@npm:3.10.1" + dependencies: + linkifyjs: "npm:^4.3.2" + peerDependencies: + "@tiptap/core": ^3.10.1 + "@tiptap/pm": ^3.10.1 + checksum: 10/d4cf474e4b6111a6d872f741ca6cae2bc44e94a2e422f561834b3b66e4c02fe44004a916528635e5303da61289b5e813fe8b96ade15c47a00637789028f0dc93 + languageName: node + linkType: hard + +"@tiptap/extension-list-item@npm:^3.10.1": + version: 3.10.1 + resolution: "@tiptap/extension-list-item@npm:3.10.1" + peerDependencies: + "@tiptap/extension-list": ^3.10.1 + checksum: 10/4ac6262313ad68ddfbcba9f8dab369583fe4b80708b21a93904cde6d1551e13a549985991ed69c7274b91568913130f21ba5941bc514ad8f4fec98d58271d37e + languageName: node + linkType: hard + +"@tiptap/extension-list-keymap@npm:^3.10.1": + version: 3.10.1 + resolution: "@tiptap/extension-list-keymap@npm:3.10.1" + peerDependencies: + "@tiptap/extension-list": ^3.10.1 + checksum: 10/db02633ee6bad20afa31518a3e7c21d4dde618f0f5d65645a5ee0418d637b327208acda85b3b0f552ceb37ed32f04c65d4962094abe96fbb848eb8098be8b624 + languageName: node + linkType: hard + +"@tiptap/extension-list@npm:^3.10.1": + version: 3.10.1 + resolution: "@tiptap/extension-list@npm:3.10.1" + peerDependencies: + "@tiptap/core": ^3.10.1 + "@tiptap/pm": ^3.10.1 + checksum: 10/04f82f91597351d2a2b6a9ee6705219d52566e4208e8d31f3bf6417c031eaa597d3f33d63ecf86a39dd29661901ea75890eceb942922594156db137ce8bd23d3 + languageName: node + linkType: hard + +"@tiptap/extension-ordered-list@npm:^3.10.1": + version: 3.10.1 + resolution: "@tiptap/extension-ordered-list@npm:3.10.1" + peerDependencies: + "@tiptap/extension-list": ^3.10.1 + checksum: 10/9c8035df46ee93ce2782ab99e2f833130c9c16556c747783a29b9d521b3754f1aa87d4849ef0aea18342f1191fc735368d9168e4d432cad8a6ee483d3ec2e898 + languageName: node + linkType: hard + +"@tiptap/extension-paragraph@npm:^3.10.1": + version: 3.10.1 + resolution: "@tiptap/extension-paragraph@npm:3.10.1" + peerDependencies: + "@tiptap/core": ^3.10.1 + checksum: 10/6b47af60e42a0ab516213d3376b707f38973a9b52990253f8a3178032c473d67fa1b15ecd5b5a7f00db400d81095ca2665573d67a5d0c1a24c0fa0c19324026c + languageName: node + linkType: hard + +"@tiptap/extension-strike@npm:^3.10.1": + version: 3.10.1 + resolution: "@tiptap/extension-strike@npm:3.10.1" + peerDependencies: + "@tiptap/core": ^3.10.1 + checksum: 10/30071c220ed25979eab3478ad2c74df32c7ef290c257b1fc688eabb2e7fdbbbbe4d57c82ed46403f9568e1768a8ca92bd42d5cc48054d1d4e84045133819ddaa + languageName: node + linkType: hard + +"@tiptap/extension-subscript@npm:^3.10.1": + version: 3.10.1 + resolution: "@tiptap/extension-subscript@npm:3.10.1" + peerDependencies: + "@tiptap/core": ^3.10.1 + "@tiptap/pm": ^3.10.1 + checksum: 10/2e248134e505e903177e80b0c1a96dccc2364b407577d2f37ac0cb0378c20541ed63548c4e040c254a3ab3387b67de4cc9c83bf5721f1235af606fcbb7b36f15 + languageName: node + linkType: hard + +"@tiptap/extension-superscript@npm:^3.10.1": + version: 3.10.1 + resolution: "@tiptap/extension-superscript@npm:3.10.1" + peerDependencies: + "@tiptap/core": ^3.10.1 + "@tiptap/pm": ^3.10.1 + checksum: 10/1d07770887bf261efd03d6b745f374fab5acbc94d4d44b6d40599621db2f72672a6b768aacc702df6e3cd3a96fee397c2b2fc83533ef7d59738b51c9e3cd8fbe + languageName: node + linkType: hard + +"@tiptap/extension-text-align@npm:^3.10.1": + version: 3.10.1 + resolution: "@tiptap/extension-text-align@npm:3.10.1" + peerDependencies: + "@tiptap/core": ^3.10.1 + checksum: 10/eef13336388a823f8d94ae618abe049ad42d78ac9c1758ac86756c088220ebe193c12aad092daf879430b9c2b4bc3370f881b245655710a584e06b234c651660 + languageName: node + linkType: hard + +"@tiptap/extension-text@npm:^3.10.1": + version: 3.10.1 + resolution: "@tiptap/extension-text@npm:3.10.1" + peerDependencies: + "@tiptap/core": ^3.10.1 + checksum: 10/de9030f1f70da3dad33451366a65f9738f8890f74b58ba7df9a3e09d2e191b2abbed63ef9577336dfa1fec8b0cde6f6a6fd8579409a4f8070bfc6efb1ede95d5 + languageName: node + linkType: hard + +"@tiptap/extension-typography@npm:^3.10.1": + version: 3.10.1 + resolution: "@tiptap/extension-typography@npm:3.10.1" + peerDependencies: + "@tiptap/core": ^3.10.1 + checksum: 10/fa2973e9cb055302ed09579f2e4f569bc71b2e52f26e9e128587d7a0254434bb21b26599fb46255cd4ad9553150f25ed24eee75ed838b3fa73d18f76b3eaa793 + languageName: node + linkType: hard + +"@tiptap/extension-underline@npm:^3.10.1": + version: 3.10.1 + resolution: "@tiptap/extension-underline@npm:3.10.1" + peerDependencies: + "@tiptap/core": ^3.10.1 + checksum: 10/75beba411ff927ec829ea03437437817c66bb98f87d00afc4584b190318f893ff278e368d4f58da85f686eadb12e6a6b363e9fe03af4c4e53a30ce748c1b75ec + languageName: node + linkType: hard + +"@tiptap/extensions@npm:^3.10.1": + version: 3.10.1 + resolution: "@tiptap/extensions@npm:3.10.1" + peerDependencies: + "@tiptap/core": ^3.10.1 + "@tiptap/pm": ^3.10.1 + checksum: 10/ca7e527bd7701b6608a06326bf6a69b10b1e9edeed8c915cb3835880d8f0cdf921ba3c9b4069a9acddecd6c4e60fff16e02be7f74156f9685ea1f357969f7f48 + languageName: node + linkType: hard + +"@tiptap/markdown@npm:^3.10.1": + version: 3.10.1 + resolution: "@tiptap/markdown@npm:3.10.1" + dependencies: + marked: "npm:^16.1.2" + peerDependencies: + "@tiptap/core": ^3.10.1 + "@tiptap/pm": ^3.10.1 + checksum: 10/cedeb558b1319782b08bf1c1d2e0cd74dfd70ffe0c83f19587cb70f71e692aa6ef7be40dcccd249f4386d79dc7f0ae3f664cf5806e5b1004ac442ac0b5c4ef10 + languageName: node + linkType: hard + +"@tiptap/pm@npm:^3.10.1": + version: 3.10.1 + resolution: "@tiptap/pm@npm:3.10.1" + 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/b7eee9eaaa793b4dbeb5029b575d203f628bf16fe53f2d7de5830a98125cb7a087417f37317d133ffc7db7b99081d1ee80de00640c2d6aefff934ed6f8495941 + languageName: node + linkType: hard + +"@tiptap/react@npm:^3.10.1": + version: 3.10.1 + resolution: "@tiptap/react@npm:3.10.1" + dependencies: + "@tiptap/extension-bubble-menu": "npm:^3.10.1" + "@tiptap/extension-floating-menu": "npm:^3.10.1" + "@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: + "@tiptap/core": ^3.10.1 + "@tiptap/pm": ^3.10.1 + "@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/15c8039e3e2c3e16279e1fc4c033d6709c927ad11c525683a9f8e94f4d82730dc3aaa33c18f0a3b56a1e03f0f0aa8b2a13c3f7e7d6e0746f210c893fbacd1718 + languageName: node + linkType: hard + +"@tiptap/starter-kit@npm:^3.10.1": + version: 3.10.1 + resolution: "@tiptap/starter-kit@npm:3.10.1" + dependencies: + "@tiptap/core": "npm:^3.10.1" + "@tiptap/extension-blockquote": "npm:^3.10.1" + "@tiptap/extension-bold": "npm:^3.10.1" + "@tiptap/extension-bullet-list": "npm:^3.10.1" + "@tiptap/extension-code": "npm:^3.10.1" + "@tiptap/extension-code-block": "npm:^3.10.1" + "@tiptap/extension-document": "npm:^3.10.1" + "@tiptap/extension-dropcursor": "npm:^3.10.1" + "@tiptap/extension-gapcursor": "npm:^3.10.1" + "@tiptap/extension-hard-break": "npm:^3.10.1" + "@tiptap/extension-heading": "npm:^3.10.1" + "@tiptap/extension-horizontal-rule": "npm:^3.10.1" + "@tiptap/extension-italic": "npm:^3.10.1" + "@tiptap/extension-link": "npm:^3.10.1" + "@tiptap/extension-list": "npm:^3.10.1" + "@tiptap/extension-list-item": "npm:^3.10.1" + "@tiptap/extension-list-keymap": "npm:^3.10.1" + "@tiptap/extension-ordered-list": "npm:^3.10.1" + "@tiptap/extension-paragraph": "npm:^3.10.1" + "@tiptap/extension-strike": "npm:^3.10.1" + "@tiptap/extension-text": "npm:^3.10.1" + "@tiptap/extension-underline": "npm:^3.10.1" + "@tiptap/extensions": "npm:^3.10.1" + "@tiptap/pm": "npm:^3.10.1" + checksum: 10/37e1b9de206e3d37d814bde3d567be02ecbf7ef25c1c3c47f8f084befa50d6713f939c4e3baa6b9cba4c895f82643eb013080bfb77ab49729d67846a35b82808 + languageName: node + linkType: hard + "@tootallnate/once@npm:2": version: 2.0.0 resolution: "@tootallnate/once@npm:2.0.0" @@ -6101,6 +7192,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 +7222,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 +7257,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 +7521,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 +8559,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" @@ -8383,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" @@ -8850,6 +10006,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" @@ -9289,6 +10452,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" @@ -9310,6 +10482,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" @@ -9747,6 +10926,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" @@ -11310,6 +12496,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" @@ -12124,6 +13317,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" @@ -13852,6 +15052,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" @@ -13942,6 +15158,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" @@ -14058,19 +15281,36 @@ __metadata: "@emotion/cache": "npm:^11.13.1" "@emotion/styled": "npm:^11.11.0" "@faker-js/faker": "npm:^10.0.0" + "@floating-ui/react": "npm:^0.27.16" "@mitodl/course-search-utils": "npm:^3.5.0" "@mitodl/mitxonline-api-axios": "npm:^2025.10.21" "@mitodl/smoot-design": "npm:^6.17.1" "@next/bundle-analyzer": "npm:^14.2.15" + "@radix-ui/react-dropdown-menu": "npm:^2.1.16" + "@radix-ui/react-popover": "npm:^1.1.15" "@react-pdf/renderer": "npm:^4.3.0" - "@remixicon/react": "npm:^4.2.0" + "@remixicon/react": "npm:^4.7.0" "@sentry/nextjs": "npm:^10.0.0" "@tanstack/react-query": "npm:^5.66" "@testing-library/jest-dom": "npm:^6.4.8" "@testing-library/react": "npm:^16.3.0" "@testing-library/user-event": "npm:^14.5.2" + "@tiptap/extension-highlight": "npm:^3.10.1" + "@tiptap/extension-horizontal-rule": "npm:^3.10.1" + "@tiptap/extension-image": "npm:^3.10.1" + "@tiptap/extension-list": "npm:^3.10.1" + "@tiptap/extension-subscript": "npm:^3.10.1" + "@tiptap/extension-superscript": "npm:^3.10.1" + "@tiptap/extension-text-align": "npm:^3.10.1" + "@tiptap/extension-typography": "npm:^3.10.1" + "@tiptap/extensions": "npm:^3.10.1" + "@tiptap/markdown": "npm:^3.10.1" + "@tiptap/pm": "npm:^3.10.1" + "@tiptap/react": "npm:^3.10.1" + "@tiptap/starter-kit": "npm:^3.10.1" "@types/jest": "npm:^29.5.12" "@types/lodash": "npm:^4.17.7" + "@types/lodash.throttle": "npm:^4.1.9" "@types/node": "npm:^22.0.0" "@types/react": "npm:^19" "@types/react-dom": "npm:^19" @@ -14089,6 +15329,7 @@ __metadata: jest-next-dynamic-ts: "npm:^0.1.1" jsdom: "npm:^27" lodash: "npm:^4.17.21" + lodash.throttle: "npm:^4.1.1" moment: "npm:^2.30.1" next: "npm:^15.5.2" next-nprogress-bar: "npm:^2.4.2" @@ -14099,7 +15340,9 @@ __metadata: posthog-js: "npm:^1.157.2" react: "npm:^19.0.0" react-dom: "npm:^19.0.0" + react-hotkeys-hook: "npm:^5.2.1" react-slick: "npm:^0.30.2" + sass: "npm:^1.93.3" sharp: "npm:0.34.4" slick-carousel: "npm:^1.8.1" tiny-invariant: "npm:^1.3.3" @@ -14171,6 +15414,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" @@ -14187,6 +15446,15 @@ __metadata: languageName: node linkType: hard +"marked@npm:^16.1.2": + version: 16.4.1 + resolution: "marked@npm:16.4.1" + bin: + marked: bin/marked.js + checksum: 10/b5f475dbe297162dc988b7f345b559d03248fde1023822b9f2a68f50cbca0981c78c42f380c3aa5e133b5f5c069a2c6cd683413c12c83710e983a7bc46cdf4a2 + languageName: node + linkType: hard + "material-ui-popup-state@npm:^5.1.0": version: 5.3.1 resolution: "material-ui-popup-state@npm:5.3.1" @@ -14512,6 +15780,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" @@ -15064,7 +16339,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: @@ -15284,6 +16559,7 @@ __metadata: version: 0.0.0-use.local resolution: "mit-learn@workspace:." dependencies: + "@mitodl/smoot-design": "npm:^6.18.2" syncpack: "npm:^13.0.0" languageName: unknown linkType: soft @@ -15489,6 +16765,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" @@ -15988,6 +17273,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" @@ -16762,6 +18054,200 @@ __metadata: 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 + +"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 + +"prosemirror-schema-list@npm:^1.5.0": + version: 1.5.1 + resolution: "prosemirror-schema-list@npm:1.5.1" + dependencies: + 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 + +"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: + 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 + +"prosemirror-tables@npm:^1.6.4": + version: 1.8.1 + resolution: "prosemirror-tables@npm:1.8.1" + dependencies: + 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 + +"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 + +"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 + +"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 + "proxy-addr@npm:~2.0.7": version: 2.0.7 resolution: "proxy-addr@npm:2.0.7" @@ -16800,6 +18286,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" @@ -17010,6 +18503,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" @@ -17074,6 +18577,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" @@ -17122,6 +18660,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" @@ -17212,6 +18766,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" @@ -17758,6 +19319,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" @@ -17851,6 +19419,23 @@ __metadata: languageName: node linkType: hard +"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" @@ -18405,7 +19990,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 @@ -19150,6 +20735,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" @@ -19620,7 +21212,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 @@ -19797,6 +21389,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" @@ -20213,6 +21812,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" @@ -20225,6 +21839,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" @@ -20431,6 +22061,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" From c9bbac3fd4bae21ee679aea9da9f10772d5cff77 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 31 Oct 2025 21:35:43 +0000 Subject: [PATCH 2/8] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../paragraph-node/paragraph-node.scss | 2 +- .../badge/badge-colors.scss | 28 ++++----- .../button/button-colors.scss | 60 +++++++++---------- frontends/main/src/styles/_variables.scss | 20 +++---- 4 files changed, 55 insertions(+), 55 deletions(-) diff --git a/frontends/main/src/components/tiptap-node/paragraph-node/paragraph-node.scss b/frontends/main/src/components/tiptap-node/paragraph-node/paragraph-node.scss index 8b73f9f0f5..809742db3f 100644 --- a/frontends/main/src/components/tiptap-node/paragraph-node/paragraph-node.scss +++ b/frontends/main/src/components/tiptap-node/paragraph-node/paragraph-node.scss @@ -19,7 +19,7 @@ } } -/* Ensure each top-level node has relative positioning +/* Ensure each top-level node has relative positioning so absolutely positioned placeholders work correctly */ .tiptap.ProseMirror > * { position: relative; diff --git a/frontends/main/src/components/tiptap-ui-primitive/badge/badge-colors.scss b/frontends/main/src/components/tiptap-ui-primitive/badge/badge-colors.scss index 8f8a988fbb..2044b94194 100644 --- a/frontends/main/src/components/tiptap-ui-primitive/badge/badge-colors.scss +++ b/frontends/main/src/components/tiptap-ui-primitive/badge/badge-colors.scss @@ -1,6 +1,6 @@ .tiptap-badge { - /************************************************** - Default + /************************************************** + Default **************************************************/ /* Light mode */ @@ -49,8 +49,8 @@ ); //more important badge } - /************************************************** - Ghost + /************************************************** + Ghost **************************************************/ &[data-style="ghost"] { @@ -101,8 +101,8 @@ } } - /************************************************** - Gray + /************************************************** + Gray **************************************************/ &[data-style="gray"] { @@ -153,8 +153,8 @@ } } - /************************************************** - Green + /************************************************** + Green **************************************************/ &[data-style="green"] { @@ -213,8 +213,8 @@ } } - /************************************************** - Yellow + /************************************************** + Yellow **************************************************/ &[data-style="yellow"] { @@ -273,8 +273,8 @@ } } - /************************************************** - Red + /************************************************** + Red **************************************************/ &[data-style="red"] { @@ -333,8 +333,8 @@ } } - /************************************************** - Brand + /************************************************** + Brand **************************************************/ &[data-style="brand"] { diff --git a/frontends/main/src/components/tiptap-ui-primitive/button/button-colors.scss b/frontends/main/src/components/tiptap-ui-primitive/button/button-colors.scss index fc0dd35e44..c9683e4252 100644 --- a/frontends/main/src/components/tiptap-ui-primitive/button/button-colors.scss +++ b/frontends/main/src/components/tiptap-ui-primitive/button/button-colors.scss @@ -1,6 +1,6 @@ .tiptap-button { - /************************************************** - Default button background color + /************************************************** + Default button background color **************************************************/ /* Light mode */ @@ -43,8 +43,8 @@ --tt-button-disabled-bg-color: var(--tt-gray-dark-a-50); } - /************************************************** - Default button text color + /************************************************** + Default button text color **************************************************/ /* Light mode */ @@ -65,8 +65,8 @@ --tt-button-disabled-text-color: var(--tt-gray-dark-a-300); } - /************************************************** - Default button icon color + /************************************************** + Default button icon color **************************************************/ /* Light mode */ @@ -87,8 +87,8 @@ --tt-button-disabled-icon-color: var(--tt-gray-dark-a-400); } - /************************************************** - Default button subicon color + /************************************************** + Default button subicon color **************************************************/ /* Light mode */ @@ -109,8 +109,8 @@ --tt-button-disabled-icon-sub-color: var(--tt-gray-dark-a-100); } - /************************************************** - Default button dropdown / arrows color + /************************************************** + Default button dropdown / arrows color **************************************************/ /* Light mode */ @@ -140,8 +140,8 @@ ---------------------------------------------------------------- */ &[data-style="ghost"] { - /************************************************** - Ghost button background color + /************************************************** + Ghost button background color **************************************************/ /* Light mode */ @@ -184,8 +184,8 @@ --tt-button-disabled-bg-color: var(--transparent); } - /************************************************** - Ghost button text color + /************************************************** + Ghost button text color **************************************************/ /* Light mode */ @@ -206,8 +206,8 @@ --tt-button-disabled-text-color: var(--tt-gray-dark-a-300); } - /************************************************** - Ghost button icon color + /************************************************** + Ghost button icon color **************************************************/ /* Light mode */ @@ -228,8 +228,8 @@ --tt-button-disabled-icon-color: var(--tt-gray-dark-a-400); } - /************************************************** - Ghost button subicon color + /************************************************** + Ghost button subicon color **************************************************/ /* Light mode */ @@ -250,8 +250,8 @@ --tt-button-disabled-icon-sub-color: var(--tt-gray-dark-a-100); } - /************************************************** - Ghost button dropdown / arrows color + /************************************************** + Ghost button dropdown / arrows color **************************************************/ /* Light mode */ @@ -286,8 +286,8 @@ ---------------------------------------------------------------- */ &[data-style="primary"] { - /************************************************** - Primary button background color + /************************************************** + Primary button background color **************************************************/ /* Light mode */ @@ -330,8 +330,8 @@ --tt-button-disabled-bg-color: var(--tt-gray-dark-a-100); } - /************************************************** - Primary button text color + /************************************************** + Primary button text color **************************************************/ /* Light mode */ @@ -352,8 +352,8 @@ --tt-button-disabled-text-color: var(--tt-gray-dark-a-300); } - /************************************************** - Primary button icon color + /************************************************** + Primary button icon color **************************************************/ /* Light mode */ @@ -374,8 +374,8 @@ --tt-button-disabled-icon-color: var(--tt-gray-dark-a-300); } - /************************************************** - Primary button subicon color + /************************************************** + Primary button subicon color **************************************************/ /* Light mode */ @@ -396,8 +396,8 @@ --tt-button-disabled-icon-sub-color: var(--tt-gray-dark-a-100); } - /************************************************** - Primary button dropdown / arrows color + /************************************************** + Primary button dropdown / arrows color **************************************************/ /* Light mode */ diff --git a/frontends/main/src/styles/_variables.scss b/frontends/main/src/styles/_variables.scss index 113a16b404..71aa3ece1e 100644 --- a/frontends/main/src/styles/_variables.scss +++ b/frontends/main/src/styles/_variables.scss @@ -1,5 +1,5 @@ :root { - /****************** + /****************** Basics ******************/ @@ -9,7 +9,7 @@ -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - /****************** + /****************** Colors variables ******************/ @@ -118,7 +118,7 @@ --black: rgba(14, 14, 17, 1); --transparent: rgba(255, 255, 255, 0); - /****************** + /****************** Shadow variables ******************/ @@ -129,8 +129,8 @@ 0px 6px 8px 0px rgba(17, 24, 39, 0.02), 0px 2px 3px 0px rgba(17, 24, 39, 0.02); - /************************************************** - Radius variables + /************************************************** + Radius variables **************************************************/ --tt-radius-xxs: 0.125rem; /* 2px */ @@ -140,8 +140,8 @@ --tt-radius-lg: 0.75rem; /* 12px */ --tt-radius-xl: 1rem; /* 16px */ - /************************************************** - Transition variables + /************************************************** + Transition variables **************************************************/ --tt-transition-duration-short: 0.1s; @@ -153,7 +153,7 @@ --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 ******************/ @@ -172,8 +172,8 @@ } :root { - /************************************************** - Global colors + /************************************************** + Global colors **************************************************/ /* Global colors - Light mode */ From 4346db506bd5ef8289be3ad066158408a8ed17ce Mon Sep 17 00:00:00 2001 From: Jon Kafton <939376+jonkafton@users.noreply.github.com> Date: Fri, 31 Oct 2025 22:51:21 +0100 Subject: [PATCH 3/8] Lints --- frontends/.eslintrc.js | 1 + .../app-pages/ArticlePage/NewArticlePage.tsx | 2 +- .../app-pages/ProductPages/ProductSummary.tsx | 1 - .../image-upload-node-extension.ts | 5 ++--- .../tiptap-templates/simple/simple-editor.tsx | 4 +--- .../tiptap-ui-primitive/tooltip/tooltip.tsx | 20 +++++++++---------- .../blockquote-button/blockquote-button.tsx | 7 +++---- .../code-block-button/code-block-button.tsx | 7 +++---- .../color-highlight-button.tsx | 9 ++++----- .../use-color-highlight.ts | 14 ++++++------- .../color-highlight-popover.tsx | 5 ++--- .../heading-button/heading-button.tsx | 7 +++---- .../heading-dropdown-menu.tsx | 7 +++---- .../image-upload-button.tsx | 7 +++---- .../tiptap-ui/link-popover/link-popover.tsx | 12 +++++------ .../tiptap-ui/list-button/list-button.tsx | 12 ++++++----- .../list-dropdown-menu/list-dropdown-menu.tsx | 1 - .../tiptap-ui/mark-button/mark-button.tsx | 7 +++---- .../text-align-button/text-align-button.tsx | 7 +++---- .../text-align-button/use-text-align.ts | 14 +++++-------- .../undo-redo-button/undo-redo-button.tsx | 7 +++---- 21 files changed, 69 insertions(+), 87 deletions(-) diff --git a/frontends/.eslintrc.js b/frontends/.eslintrc.js index 75b0317415..ec160ea1f9 100644 --- a/frontends/.eslintrc.js +++ b/frontends/.eslintrc.js @@ -33,6 +33,7 @@ module.exports = { }, }, rules: { + "react/react-in-jsx-scope": "off", ...restrictedImports({ paths: [ { diff --git a/frontends/main/src/app-pages/ArticlePage/NewArticlePage.tsx b/frontends/main/src/app-pages/ArticlePage/NewArticlePage.tsx index 07a379f01d..31cdfe7676 100644 --- a/frontends/main/src/app-pages/ArticlePage/NewArticlePage.tsx +++ b/frontends/main/src/app-pages/ArticlePage/NewArticlePage.tsx @@ -1,7 +1,7 @@ "use client" import React, { useState, useRef } from "react" -import { Typography, theme, styled, LearningResourceCard } from "ol-components" +import { theme, styled, LearningResourceCard } from "ol-components" import { SimpleEditor } from "@/components/tiptap-templates/simple/simple-editor" import { EditorContext, diff --git a/frontends/main/src/app-pages/ProductPages/ProductSummary.tsx b/frontends/main/src/app-pages/ProductPages/ProductSummary.tsx index 83294aac70..244c3c380e 100644 --- a/frontends/main/src/app-pages/ProductPages/ProductSummary.tsx +++ b/frontends/main/src/app-pages/ProductPages/ProductSummary.tsx @@ -176,7 +176,6 @@ const LearnMoreDialog: React.FC = ({ rel="noopener noreferrer" color="red" href="" - role="button" onClick={(event) => { event.preventDefault() setOpen(true) diff --git a/frontends/main/src/components/tiptap-node/image-upload-node/image-upload-node-extension.ts b/frontends/main/src/components/tiptap-node/image-upload-node/image-upload-node-extension.ts index c282b1b35c..a0db448b0b 100644 --- a/frontends/main/src/components/tiptap-node/image-upload-node/image-upload-node-extension.ts +++ b/frontends/main/src/components/tiptap-node/image-upload-node/image-upload-node-extension.ts @@ -1,12 +1,11 @@ -import { mergeAttributes, Node } from "@tiptap/react" -import { ReactNodeViewRenderer } from "@tiptap/react" +import { mergeAttributes, Node, ReactNodeViewRenderer } from "@tiptap/react" import { ImageUploadNode as ImageUploadNodeComponent } from "@/components/tiptap-node/image-upload-node/image-upload-node" import type { NodeType } from "@tiptap/pm/model" export type UploadFunction = ( file: File, onProgress?: (event: { progress: number }) => void, - abortSignal?: AbortSignal + abortSignal?: AbortSignal, ) => Promise export interface ImageUploadNodeOptions { diff --git a/frontends/main/src/components/tiptap-templates/simple/simple-editor.tsx b/frontends/main/src/components/tiptap-templates/simple/simple-editor.tsx index 53683bd66e..6e36ca4f08 100644 --- a/frontends/main/src/components/tiptap-templates/simple/simple-editor.tsx +++ b/frontends/main/src/components/tiptap-templates/simple/simple-editor.tsx @@ -1,7 +1,7 @@ "use client" import React, { useEffect, useRef, useState } from "react" -import { EditorContent, EditorContext, useEditor } from "@tiptap/react" +import { EditorContent } from "@tiptap/react" // // --- Tiptap Core Extensions --- // import { StarterKit } from "@tiptap/starter-kit" @@ -272,8 +272,6 @@ export function SimpleEditor({ } }, [isMobile, mobileView]) - console.log("editor", editor) - return ( {/* */} diff --git a/frontends/main/src/components/tiptap-ui-primitive/tooltip/tooltip.tsx b/frontends/main/src/components/tiptap-ui-primitive/tooltip/tooltip.tsx index 59ecc44a80..2f99782df4 100644 --- a/frontends/main/src/components/tiptap-ui-primitive/tooltip/tooltip.tsx +++ b/frontends/main/src/components/tiptap-ui-primitive/tooltip/tooltip.tsx @@ -59,10 +59,10 @@ interface TooltipContextValue extends UseFloatingReturn { open: boolean setOpen: (open: boolean) => void getReferenceProps: ( - userProps?: React.HTMLProps + userProps?: React.HTMLProps, ) => Record getFloatingProps: ( - userProps?: React.HTMLProps + userProps?: React.HTMLProps, ) => Record } @@ -101,13 +101,13 @@ function useTooltip({ mouseOnly: true, move: false, restMs: delay, - enabled: controlledOpen == null, + enabled: controlledOpen === null, delay: { close: closeDelay, }, }) const focus = useFocus(context, { - enabled: controlledOpen == null, + enabled: controlledOpen === null, }) const dismiss = useDismiss(context) const role = useRole(context, { role: "tooltip" }) @@ -121,7 +121,7 @@ function useTooltip({ ...interactions, ...data, }), - [open, setOpen, interactions, data] + [open, setOpen, interactions, data], ) } @@ -130,7 +130,7 @@ const TooltipContext = createContext(null) function useTooltipContext() { const context = useContext(TooltipContext) - if (context == null) { + if (context === null) { throw new Error("Tooltip components must be wrapped in ") } @@ -184,7 +184,7 @@ export const TooltipTrigger = forwardRef( ...props, ...(typeof children.props === "object" ? children.props : {}), ...dataAttributes, - }) + }), ) } @@ -197,13 +197,13 @@ export const TooltipTrigger = forwardRef( {children} ) - } + }, ) export const TooltipContent = forwardRef( function TooltipContent( { style, children, portal = true, portalProps = {}, ...props }, - propRef + propRef, ) { const context = useTooltipContext() const ref = useMergeRefs([context.refs.setFloating, propRef]) @@ -229,7 +229,7 @@ export const TooltipContent = forwardRef( } return content - } + }, ) Tooltip.displayName = "Tooltip" diff --git a/frontends/main/src/components/tiptap-ui/blockquote-button/blockquote-button.tsx b/frontends/main/src/components/tiptap-ui/blockquote-button/blockquote-button.tsx index b2ac501e46..6f9b7f792f 100644 --- a/frontends/main/src/components/tiptap-ui/blockquote-button/blockquote-button.tsx +++ b/frontends/main/src/components/tiptap-ui/blockquote-button/blockquote-button.tsx @@ -62,7 +62,7 @@ export const BlockquoteButton = forwardRef< children, ...buttonProps }, - ref + ref, ) => { const { editor } = useTiptapEditor(providedEditor) const { @@ -85,7 +85,7 @@ export const BlockquoteButton = forwardRef< if (event.defaultPrevented) return handleToggle() }, - [handleToggle, onClick] + [handleToggle, onClick], ) if (!isVisible) { @@ -97,7 +97,6 @@ export const BlockquoteButton = forwardRef< type="button" data-style="ghost" data-active-state={isActive ? "on" : "off"} - role="button" tabIndex={-1} disabled={!canToggle} data-disabled={!canToggle} @@ -119,7 +118,7 @@ export const BlockquoteButton = forwardRef< )} ) - } + }, ) BlockquoteButton.displayName = "BlockquoteButton" diff --git a/frontends/main/src/components/tiptap-ui/code-block-button/code-block-button.tsx b/frontends/main/src/components/tiptap-ui/code-block-button/code-block-button.tsx index 93449bb60b..2125917a8f 100644 --- a/frontends/main/src/components/tiptap-ui/code-block-button/code-block-button.tsx +++ b/frontends/main/src/components/tiptap-ui/code-block-button/code-block-button.tsx @@ -62,7 +62,7 @@ export const CodeBlockButton = forwardRef< children, ...buttonProps }, - ref + ref, ) => { const { editor } = useTiptapEditor(providedEditor) const { @@ -85,7 +85,7 @@ export const CodeBlockButton = forwardRef< if (event.defaultPrevented) return handleToggle() }, - [handleToggle, onClick] + [handleToggle, onClick], ) if (!isVisible) { @@ -97,7 +97,6 @@ export const CodeBlockButton = forwardRef< type="button" data-style="ghost" data-active-state={isActive ? "on" : "off"} - role="button" disabled={!canToggle} data-disabled={!canToggle} tabIndex={-1} @@ -119,7 +118,7 @@ export const CodeBlockButton = forwardRef< )} ) - } + }, ) CodeBlockButton.displayName = "CodeBlockButton" diff --git a/frontends/main/src/components/tiptap-ui/color-highlight-button/color-highlight-button.tsx b/frontends/main/src/components/tiptap-ui/color-highlight-button/color-highlight-button.tsx index cea6b060ad..bb367e3474 100644 --- a/frontends/main/src/components/tiptap-ui/color-highlight-button/color-highlight-button.tsx +++ b/frontends/main/src/components/tiptap-ui/color-highlight-button/color-highlight-button.tsx @@ -91,7 +91,7 @@ export const ColorHighlightButton = forwardRef< style, ...buttonProps }, - ref + ref, ) => { const { editor } = useTiptapEditor(providedEditor) const { @@ -116,7 +116,7 @@ export const ColorHighlightButton = forwardRef< if (event.defaultPrevented) return handleColorHighlight() }, - [handleColorHighlight, onClick] + [handleColorHighlight, onClick], ) const buttonStyle = useMemo( @@ -125,7 +125,7 @@ export const ColorHighlightButton = forwardRef< ...style, "--highlight-color": highlightColor, }) as React.CSSProperties, - [highlightColor, style] + [highlightColor, style], ) if (!isVisible) { @@ -137,7 +137,6 @@ export const ColorHighlightButton = forwardRef< type="button" data-style="ghost" data-active-state={isActive ? "on" : "off"} - role="button" tabIndex={-1} disabled={!canColorHighlight} data-disabled={!canColorHighlight} @@ -165,7 +164,7 @@ export const ColorHighlightButton = forwardRef< )} ) - } + }, ) ColorHighlightButton.displayName = "ColorHighlightButton" diff --git a/frontends/main/src/components/tiptap-ui/color-highlight-button/use-color-highlight.ts b/frontends/main/src/components/tiptap-ui/color-highlight-button/use-color-highlight.ts index b71215e0b8..11cd168f74 100644 --- a/frontends/main/src/components/tiptap-ui/color-highlight-button/use-color-highlight.ts +++ b/frontends/main/src/components/tiptap-ui/color-highlight-button/use-color-highlight.ts @@ -119,7 +119,7 @@ export interface UseColorHighlightConfig { export function pickHighlightColorsByValue(values: string[]) { const colorMap = new Map( - HIGHLIGHT_COLORS.map((color) => [color.value, color]) + HIGHLIGHT_COLORS.map((color) => [color.value, color]), ) return values .map((value) => colorMap.get(value)) @@ -131,7 +131,7 @@ export function pickHighlightColorsByValue(values: string[]) { */ export function canColorHighlight( editor: Editor | null, - mode: HighlightMode = "mark" + mode: HighlightMode = "mark", ): boolean { if (!editor || !editor.isEditable) return false @@ -160,7 +160,7 @@ export function canColorHighlight( export function isColorHighlightActive( editor: Editor | null, highlightColor?: string, - mode: HighlightMode = "mark" + mode: HighlightMode = "mark", ): boolean { if (!editor || !editor.isEditable) return false @@ -194,7 +194,7 @@ export function isColorHighlightActive( */ export function removeHighlight( editor: Editor | null, - mode: HighlightMode = "mark" + mode: HighlightMode = "mark", ): boolean { if (!editor || !editor.isEditable) return false if (!canColorHighlight(editor, mode)) return false @@ -272,7 +272,7 @@ export function useColorHighlight(config: UseColorHighlightConfig) { const highlightMarkType = editor.schema.marks.highlight if (highlightMarkType) { editor.view.dispatch( - editor.state.tr.removeStoredMark(highlightMarkType) + editor.state.tr.removeStoredMark(highlightMarkType), ) } } @@ -322,7 +322,7 @@ export function useColorHighlight(config: UseColorHighlightConfig) { enabled: isVisible && canColorHighlightState, enableOnContentEditable: !isMobile, enableOnFormTags: true, - } + }, ) return { @@ -331,7 +331,7 @@ export function useColorHighlight(config: UseColorHighlightConfig) { handleColorHighlight, handleRemoveHighlight, canColorHighlight: canColorHighlightState, - label: label || `Highlight`, + label: label || "Highlight", shortcutKeys: COLOR_HIGHLIGHT_SHORTCUT_KEY, Icon: HighlighterIcon, mode, diff --git a/frontends/main/src/components/tiptap-ui/color-highlight-popover/color-highlight-popover.tsx b/frontends/main/src/components/tiptap-ui/color-highlight-popover/color-highlight-popover.tsx index 604c7bf502..29b2457f67 100644 --- a/frontends/main/src/components/tiptap-ui/color-highlight-popover/color-highlight-popover.tsx +++ b/frontends/main/src/components/tiptap-ui/color-highlight-popover/color-highlight-popover.tsx @@ -72,7 +72,6 @@ export const ColorHighlightPopoverButton = forwardRef< className={className} data-style="ghost" data-appearance="default" - role="button" tabIndex={-1} aria-label="Highlight text" tooltip="Highlight" @@ -101,7 +100,7 @@ export function ColorHighlightPopoverContent({ const menuItems = useMemo( () => [...colors, { label: "Remove highlight", value: "none" }], - [colors] + [colors], ) const { selectedIndex } = useMenuNavigation({ @@ -111,7 +110,7 @@ export function ColorHighlightPopoverContent({ onSelect: (item) => { if (!containerRef.current) return false const highlightedElement = containerRef.current.querySelector( - '[data-highlighted="true"]' + '[data-highlighted="true"]', ) as HTMLElement if (highlightedElement) highlightedElement.click() if (item.value === "none") handleRemoveHighlight() diff --git a/frontends/main/src/components/tiptap-ui/heading-button/heading-button.tsx b/frontends/main/src/components/tiptap-ui/heading-button/heading-button.tsx index 187c863c97..fb1bd9bd76 100644 --- a/frontends/main/src/components/tiptap-ui/heading-button/heading-button.tsx +++ b/frontends/main/src/components/tiptap-ui/heading-button/heading-button.tsx @@ -63,7 +63,7 @@ export const HeadingButton = forwardRef( children, ...buttonProps }, - ref + ref, ) => { const { editor } = useTiptapEditor(providedEditor) const { @@ -87,7 +87,7 @@ export const HeadingButton = forwardRef( if (event.defaultPrevented) return handleToggle() }, - [handleToggle, onClick] + [handleToggle, onClick], ) if (!isVisible) { @@ -99,7 +99,6 @@ export const HeadingButton = forwardRef( type="button" data-style="ghost" data-active-state={isActive ? "on" : "off"} - role="button" tabIndex={-1} disabled={!canToggle} data-disabled={!canToggle} @@ -121,7 +120,7 @@ export const HeadingButton = forwardRef( )} ) - } + }, ) HeadingButton.displayName = "HeadingButton" diff --git a/frontends/main/src/components/tiptap-ui/heading-dropdown-menu/heading-dropdown-menu.tsx b/frontends/main/src/components/tiptap-ui/heading-dropdown-menu/heading-dropdown-menu.tsx index 7bf1c8f6c6..7d7b48137b 100644 --- a/frontends/main/src/components/tiptap-ui/heading-dropdown-menu/heading-dropdown-menu.tsx +++ b/frontends/main/src/components/tiptap-ui/heading-dropdown-menu/heading-dropdown-menu.tsx @@ -56,7 +56,7 @@ export const HeadingDropdownMenu = forwardRef< onOpenChange, ...buttonProps }, - ref + ref, ) => { const { editor } = useTiptapEditor(providedEditor) const [isOpen, setIsOpen] = useState(false) @@ -72,7 +72,7 @@ export const HeadingDropdownMenu = forwardRef< setIsOpen(open) onOpenChange?.(open) }, - [canToggle, editor, onOpenChange] + [canToggle, editor, onOpenChange], ) if (!isVisible) { @@ -86,7 +86,6 @@ export const HeadingDropdownMenu = forwardRef< type="button" data-style="ghost" data-active-state={isActive ? "on" : "off"} - role="button" tabIndex={-1} disabled={!canToggle} data-disabled={!canToggle} @@ -121,7 +120,7 @@ export const HeadingDropdownMenu = forwardRef< ) - } + }, ) HeadingDropdownMenu.displayName = "HeadingDropdownMenu" diff --git a/frontends/main/src/components/tiptap-ui/image-upload-button/image-upload-button.tsx b/frontends/main/src/components/tiptap-ui/image-upload-button/image-upload-button.tsx index 94f34b34e7..1ab4ff9973 100644 --- a/frontends/main/src/components/tiptap-ui/image-upload-button/image-upload-button.tsx +++ b/frontends/main/src/components/tiptap-ui/image-upload-button/image-upload-button.tsx @@ -70,7 +70,7 @@ export const ImageUploadButton = forwardRef< children, ...buttonProps }, - ref + ref, ) => { const { editor } = useTiptapEditor(providedEditor) const { @@ -93,7 +93,7 @@ export const ImageUploadButton = forwardRef< if (event.defaultPrevented) return handleImage() }, - [handleImage, onClick] + [handleImage, onClick], ) if (!isVisible) { @@ -107,7 +107,6 @@ export const ImageUploadButton = forwardRef< type="button" data-style="ghost" data-active-state={isActive ? "on" : "off"} - role="button" tabIndex={-1} disabled={!canInsert} data-disabled={!canInsert} @@ -127,7 +126,7 @@ export const ImageUploadButton = forwardRef< )} ) - } + }, ) ImageUploadButton.displayName = "ImageUploadButton" diff --git a/frontends/main/src/components/tiptap-ui/link-popover/link-popover.tsx b/frontends/main/src/components/tiptap-ui/link-popover/link-popover.tsx index 4f6151db66..a0353ca36c 100644 --- a/frontends/main/src/components/tiptap-ui/link-popover/link-popover.tsx +++ b/frontends/main/src/components/tiptap-ui/link-popover/link-popover.tsx @@ -84,7 +84,6 @@ export const LinkButton = forwardRef( type="button" className={className} data-style="ghost" - role="button" tabIndex={-1} aria-label="Link" tooltip="Link" @@ -94,7 +93,7 @@ export const LinkButton = forwardRef( {children || } ) - } + }, ) LinkButton.displayName = "LinkButton" @@ -138,7 +137,6 @@ const LinkMain: React.FC = ({ value={url} onChange={(e) => setUrl(e.target.value)} onKeyDown={handleKeyDown} - autoFocus autoComplete="off" autoCorrect="off" autoCapitalize="off" @@ -216,7 +214,7 @@ export const LinkPopover = forwardRef( children, ...buttonProps }, - ref + ref, ) => { const { editor } = useTiptapEditor(providedEditor) const [isOpen, setIsOpen] = useState(false) @@ -243,7 +241,7 @@ export const LinkPopover = forwardRef( setIsOpen(nextIsOpen) onOpenChange?.(nextIsOpen) }, - [onOpenChange] + [onOpenChange], ) const handleSetLink = useCallback(() => { @@ -257,7 +255,7 @@ export const LinkPopover = forwardRef( if (event.defaultPrevented) return setIsOpen(!isOpen) }, - [onClick, isOpen] + [onClick, isOpen], ) useEffect(() => { @@ -299,7 +297,7 @@ export const LinkPopover = forwardRef( ) - } + }, ) LinkPopover.displayName = "LinkPopover" diff --git a/frontends/main/src/components/tiptap-ui/list-button/list-button.tsx b/frontends/main/src/components/tiptap-ui/list-button/list-button.tsx index 0d9c437185..33690af2f5 100644 --- a/frontends/main/src/components/tiptap-ui/list-button/list-button.tsx +++ b/frontends/main/src/components/tiptap-ui/list-button/list-button.tsx @@ -14,7 +14,10 @@ import { Button } from "@/components/tiptap-ui-primitive/button" import { Badge } from "@/components/tiptap-ui-primitive/badge" // --- Tiptap UI --- -import type { ListType, UseListConfig } from "@/components/tiptap-ui/list-button" +import type { + ListType, + UseListConfig, +} from "@/components/tiptap-ui/list-button" import { LIST_SHORTCUT_KEYS, useList } from "@/components/tiptap-ui/list-button" export interface ListButtonProps @@ -59,7 +62,7 @@ export const ListButton = forwardRef( children, ...buttonProps }, - ref + ref, ) => { const { editor } = useTiptapEditor(providedEditor) const { @@ -83,7 +86,7 @@ export const ListButton = forwardRef( if (event.defaultPrevented) return handleToggle() }, - [handleToggle, onClick] + [handleToggle, onClick], ) if (!isVisible) { @@ -95,7 +98,6 @@ export const ListButton = forwardRef( type="button" data-style="ghost" data-active-state={isActive ? "on" : "off"} - role="button" tabIndex={-1} disabled={!canToggle} data-disabled={!canToggle} @@ -117,7 +119,7 @@ export const ListButton = forwardRef( )} ) - } + }, ) ListButton.displayName = "ListButton" diff --git a/frontends/main/src/components/tiptap-ui/list-dropdown-menu/list-dropdown-menu.tsx b/frontends/main/src/components/tiptap-ui/list-dropdown-menu/list-dropdown-menu.tsx index 57dbbeaf1e..2f232de698 100644 --- a/frontends/main/src/components/tiptap-ui/list-dropdown-menu/list-dropdown-menu.tsx +++ b/frontends/main/src/components/tiptap-ui/list-dropdown-menu/list-dropdown-menu.tsx @@ -87,7 +87,6 @@ export function ListDropdownMenu({ type="button" data-style="ghost" data-active-state={isActive ? "on" : "off"} - role="button" tabIndex={-1} disabled={!canToggle} data-disabled={!canToggle} diff --git a/frontends/main/src/components/tiptap-ui/mark-button/mark-button.tsx b/frontends/main/src/components/tiptap-ui/mark-button/mark-button.tsx index 338bae71c0..94d8506c31 100644 --- a/frontends/main/src/components/tiptap-ui/mark-button/mark-button.tsx +++ b/frontends/main/src/components/tiptap-ui/mark-button/mark-button.tsx @@ -59,7 +59,7 @@ export const MarkButton = forwardRef( children, ...buttonProps }, - ref + ref, ) => { const { editor } = useTiptapEditor(providedEditor) const { @@ -83,7 +83,7 @@ export const MarkButton = forwardRef( if (event.defaultPrevented) return handleMark() }, - [handleMark, onClick] + [handleMark, onClick], ) if (!isVisible) { @@ -97,7 +97,6 @@ export const MarkButton = forwardRef( data-style="ghost" data-active-state={isActive ? "on" : "off"} data-disabled={!canToggle} - role="button" tabIndex={-1} aria-label={label} aria-pressed={isActive} @@ -117,7 +116,7 @@ export const MarkButton = forwardRef( )} ) - } + }, ) MarkButton.displayName = "MarkButton" diff --git a/frontends/main/src/components/tiptap-ui/text-align-button/text-align-button.tsx b/frontends/main/src/components/tiptap-ui/text-align-button/text-align-button.tsx index b62598831b..760ea6fa1a 100644 --- a/frontends/main/src/components/tiptap-ui/text-align-button/text-align-button.tsx +++ b/frontends/main/src/components/tiptap-ui/text-align-button/text-align-button.tsx @@ -76,7 +76,7 @@ export const TextAlignButton = forwardRef< children, ...buttonProps }, - ref + ref, ) => { const { editor } = useTiptapEditor(providedEditor) const { @@ -100,7 +100,7 @@ export const TextAlignButton = forwardRef< if (event.defaultPrevented) return handleTextAlign() }, - [handleTextAlign, onClick] + [handleTextAlign, onClick], ) if (!isVisible) { @@ -116,7 +116,6 @@ export const TextAlignButton = forwardRef< data-style="ghost" data-active-state={isActive ? "on" : "off"} data-disabled={!canAlign} - role="button" tabIndex={-1} aria-label={label} aria-pressed={isActive} @@ -139,7 +138,7 @@ export const TextAlignButton = forwardRef< )} ) - } + }, ) TextAlignButton.displayName = "TextAlignButton" diff --git a/frontends/main/src/components/tiptap-ui/text-align-button/use-text-align.ts b/frontends/main/src/components/tiptap-ui/text-align-button/use-text-align.ts index 56ba0b4252..815e213feb 100644 --- a/frontends/main/src/components/tiptap-ui/text-align-button/use-text-align.ts +++ b/frontends/main/src/components/tiptap-ui/text-align-button/use-text-align.ts @@ -1,17 +1,13 @@ "use client" import { useCallback, useEffect, useState } from "react" -import type { ChainedCommands } from "@tiptap/react" -import { type Editor } from "@tiptap/react" +import type { ChainedCommands, Editor } from "@tiptap/react" // --- Hooks --- import { useTiptapEditor } from "@/hooks/use-tiptap-editor" // --- Lib --- -import { - isExtensionAvailable, - isNodeTypeSelected, -} from "@/lib/tiptap-utils" +import { isExtensionAvailable, isNodeTypeSelected } from "@/lib/tiptap-utils" // --- Icons --- import { AlignCenterIcon } from "@/components/tiptap-icons/align-center-icon" @@ -70,7 +66,7 @@ export const textAlignLabels: Record = { */ export function canSetTextAlign( editor: Editor | null, - align: TextAlign + align: TextAlign, ): boolean { if (!editor || !editor.isEditable) return false if ( @@ -83,7 +79,7 @@ export function canSetTextAlign( } export function hasSetTextAlign( - commands: ChainedCommands + commands: ChainedCommands, ): commands is ChainedCommands & { setTextAlign: (align: TextAlign) => ChainedCommands } { @@ -95,7 +91,7 @@ export function hasSetTextAlign( */ export function isTextAlignActive( editor: Editor | null, - align: TextAlign + align: TextAlign, ): boolean { if (!editor || !editor.isEditable) return false return editor.isActive({ textAlign: align }) diff --git a/frontends/main/src/components/tiptap-ui/undo-redo-button/undo-redo-button.tsx b/frontends/main/src/components/tiptap-ui/undo-redo-button/undo-redo-button.tsx index d990b037ef..6a04293bec 100644 --- a/frontends/main/src/components/tiptap-ui/undo-redo-button/undo-redo-button.tsx +++ b/frontends/main/src/components/tiptap-ui/undo-redo-button/undo-redo-button.tsx @@ -68,7 +68,7 @@ export const UndoRedoButton = forwardRef< children, ...buttonProps }, - ref + ref, ) => { const { editor } = useTiptapEditor(providedEditor) const { isVisible, handleAction, label, canExecute, Icon, shortcutKeys } = @@ -85,7 +85,7 @@ export const UndoRedoButton = forwardRef< if (event.defaultPrevented) return handleAction() }, - [handleAction, onClick] + [handleAction, onClick], ) if (!isVisible) { @@ -98,7 +98,6 @@ export const UndoRedoButton = forwardRef< disabled={!canExecute} data-style="ghost" data-disabled={!canExecute} - role="button" tabIndex={-1} aria-label={label} tooltip={label} @@ -120,7 +119,7 @@ export const UndoRedoButton = forwardRef< )} ) - } + }, ) UndoRedoButton.displayName = "UndoRedoButton" From 07a1f2718d9c84547a4c16cf10d4b4ba6b7b78b2 Mon Sep 17 00:00:00 2001 From: Jon Kafton <939376+jonkafton@users.noreply.github.com> Date: Mon, 3 Nov 2025 18:27:15 +0100 Subject: [PATCH 4/8] Type fixes --- .../app-pages/ArticlePage/NewArticlePage.tsx | 13 +++++++++- frontends/main/src/app/simple/page.tsx | 5 ---- .../tiptap-templates/simple/simple-editor.tsx | 2 +- .../use-color-highlight.ts | 26 ++++++++++--------- 4 files changed, 27 insertions(+), 19 deletions(-) delete mode 100644 frontends/main/src/app/simple/page.tsx diff --git a/frontends/main/src/app-pages/ArticlePage/NewArticlePage.tsx b/frontends/main/src/app-pages/ArticlePage/NewArticlePage.tsx index 31cdfe7676..99eaaf0cf1 100644 --- a/frontends/main/src/app-pages/ArticlePage/NewArticlePage.tsx +++ b/frontends/main/src/app-pages/ArticlePage/NewArticlePage.tsx @@ -41,7 +41,13 @@ import { Markdown } from "@tiptap/markdown" const PageContainer = styled.div({ color: theme.custom.colors.darkGray2, display: "flex", - marginTop: "72px", + // position: "fixed", + // top: "72px", + // left: 0, + // right: 0, + // bottom: "140px", + overscrollBehavior: "contain", + overflow: "hidden", }) const EditorContainer = styled.div({ @@ -76,6 +82,7 @@ const MarkdownTextarea = styled.textarea({ const StyledSimpleEditor = styled(SimpleEditor)({ width: "60vw", height: "calc(100vh - 205px)", + overscrollBehavior: "contain", }) const Card = styled.div({ @@ -327,6 +334,10 @@ const NewArticlePage: React.FC = () => { }, 50) } + if (!editor) { + return Loading... + } + return ( diff --git a/frontends/main/src/app/simple/page.tsx b/frontends/main/src/app/simple/page.tsx deleted file mode 100644 index 5bb89be93d..0000000000 --- a/frontends/main/src/app/simple/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { SimpleEditor } from "@/components/tiptap-templates/simple/simple-editor" - -export default function Page() { - return -} diff --git a/frontends/main/src/components/tiptap-templates/simple/simple-editor.tsx b/frontends/main/src/components/tiptap-templates/simple/simple-editor.tsx index 6e36ca4f08..38511e93ec 100644 --- a/frontends/main/src/components/tiptap-templates/simple/simple-editor.tsx +++ b/frontends/main/src/components/tiptap-templates/simple/simple-editor.tsx @@ -1,7 +1,7 @@ "use client" import React, { useEffect, useRef, useState } from "react" -import { EditorContent } from "@tiptap/react" +import { EditorContent, Editor } from "@tiptap/react" // // --- Tiptap Core Extensions --- // import { StarterKit } from "@tiptap/starter-kit" diff --git a/frontends/main/src/components/tiptap-ui/color-highlight-button/use-color-highlight.ts b/frontends/main/src/components/tiptap-ui/color-highlight-button/use-color-highlight.ts index 11cd168f74..1987410b2d 100644 --- a/frontends/main/src/components/tiptap-ui/color-highlight-button/use-color-highlight.ts +++ b/frontends/main/src/components/tiptap-ui/color-highlight-button/use-color-highlight.ts @@ -147,7 +147,8 @@ export function canColorHighlight( if (!isExtensionAvailable(editor, ["nodeBackground"])) return false try { - return editor.can().toggleNodeBackgroundColor("test") + // return editor.can().toggleNodeBackgroundColor("") + return true // TODO: type error on line above } catch { return false } @@ -202,7 +203,8 @@ export function removeHighlight( if (mode === "mark") { return editor.chain().focus().unsetMark("highlight").run() } else { - return editor.chain().focus().unsetNodeBackgroundColor().run() + // return editor.chain().focus().unsetNodeBackgroundColor().run() + return true // TODO: type error on line above } } @@ -291,16 +293,16 @@ export function useColorHighlight(config: UseColorHighlightConfig) { return true } else { - const success = editor - .chain() - .focus() - .toggleNodeBackgroundColor(highlightColor) - .run() - - if (success) { - onApplied?.({ color: highlightColor, label, mode }) - } - return success + // const success = editor.chain().focus() + // .toggleNodeBackgroundColor(highlightColor) + // return true // TODO: type error on line above + // .run() + return true // TODO: type error on line above + + // if (success) { + // onApplied?.({ color: highlightColor, label, mode }) + // } + // return success } }, [canColorHighlightState, highlightColor, editor, label, onApplied, mode]) From b9aad6b7efafaca36004c014c94e2097f258530a Mon Sep 17 00:00:00 2001 From: Jon Kafton <939376+jonkafton@users.noreply.github.com> Date: Wed, 5 Nov 2025 20:57:23 +0100 Subject: [PATCH 5/8] Add slash commands menu --- frontends/main/package.json | 1 + .../app-pages/ArticlePage/NewArticlePage.tsx | 11 + .../node-background-extension.ts | 189 ++++++++++++ .../tiptap-node/slash-commands/README.md | 92 ++++++ .../tiptap-node/slash-commands/index.ts | 10 + .../slash-commands-extension.tsx | 291 ++++++++++++++++++ .../slash-commands/slash-commands-list.tsx | 177 +++++++++++ .../slash-commands/slash-commands.scss | 78 +++++ .../tiptap-templates/simple/data/content.json | 12 + yarn.lock | 11 + 10 files changed, 872 insertions(+) create mode 100644 frontends/main/src/components/tiptap-extension/node-background-extension.ts create mode 100644 frontends/main/src/components/tiptap-node/slash-commands/README.md create mode 100644 frontends/main/src/components/tiptap-node/slash-commands/index.ts create mode 100644 frontends/main/src/components/tiptap-node/slash-commands/slash-commands-extension.tsx create mode 100644 frontends/main/src/components/tiptap-node/slash-commands/slash-commands-list.tsx create mode 100644 frontends/main/src/components/tiptap-node/slash-commands/slash-commands.scss diff --git a/frontends/main/package.json b/frontends/main/package.json index beb2c11138..8baf658413 100644 --- a/frontends/main/package.json +++ b/frontends/main/package.json @@ -37,6 +37,7 @@ "@tiptap/pm": "^3.10.1", "@tiptap/react": "^3.10.1", "@tiptap/starter-kit": "^3.10.1", + "@tiptap/suggestion": "^3.10.2", "api": "workspace:*", "classnames": "^2.5.1", "formik": "^2.4.6", diff --git a/frontends/main/src/app-pages/ArticlePage/NewArticlePage.tsx b/frontends/main/src/app-pages/ArticlePage/NewArticlePage.tsx index 99eaaf0cf1..9dab9de7d5 100644 --- a/frontends/main/src/app-pages/ArticlePage/NewArticlePage.tsx +++ b/frontends/main/src/app-pages/ArticlePage/NewArticlePage.tsx @@ -27,6 +27,11 @@ import { Selection } from "@tiptap/extensions" // --- 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 { + SlashCommands, + renderSlashCommands, + getSuggestionItems, +} from "@/components/tiptap-node/slash-commands/slash-commands-extension" import content from "@/components/tiptap-templates/simple/data/content.json" @@ -262,6 +267,12 @@ const extensions = [ }), ResourceCardExtension, AskTimDrawerButtonExtension, + SlashCommands.configure({ + suggestion: { + items: ({ query }: { query: string }) => getSuggestionItems(query), + render: renderSlashCommands, + }, + }), ] interface ViewerProps { diff --git a/frontends/main/src/components/tiptap-extension/node-background-extension.ts b/frontends/main/src/components/tiptap-extension/node-background-extension.ts new file mode 100644 index 0000000000..d97e8c23ec --- /dev/null +++ b/frontends/main/src/components/tiptap-extension/node-background-extension.ts @@ -0,0 +1,189 @@ +import { Extension } from "@tiptap/core" +import { Plugin, PluginKey } from "@tiptap/pm/state" + +export interface NodeBackgroundOptions { + /** + * The types of nodes that can have background colors applied. + * @default ["paragraph", "heading", "listItem"] + */ + types: string[] +} + +declare module "@tiptap/react" { + interface Commands { + nodeBackground: { + /** + * Toggle a background color on the current node + */ + toggleNodeBackgroundColor: (color: string) => ReturnType + /** + * Set a background color on the current node + */ + setNodeBackgroundColor: (color: string) => ReturnType + /** + * Remove the background color from the current node + */ + unsetNodeBackgroundColor: () => ReturnType + } + } +} + +/** + * A Tiptap extension that adds background color support to block nodes. + * This extension allows setting background colors on paragraphs, headings, and list items. + */ +export const NodeBackground = Extension.create({ + name: "nodeBackground", + + addOptions() { + return { + types: ["paragraph", "heading", "listItem"], + } + }, + + addGlobalAttributes() { + return [ + { + types: this.options.types, + attributes: { + backgroundColor: { + default: null, + parseHTML: (element) => + element.getAttribute("data-background-color") || null, + renderHTML: (attributes) => { + if (!attributes.backgroundColor) { + return {} + } + return { + "data-background-color": attributes.backgroundColor, + style: `background-color: ${attributes.backgroundColor}`, + } + }, + }, + }, + }, + ] + }, + + addCommands() { + return { + toggleNodeBackgroundColor: + (color: string) => + ({ commands, state }) => { + const { selection } = state + const { $from } = selection + + // Find the nearest node that can have background color + for (let depth = $from.depth; depth >= 0; depth--) { + const node = $from.node(depth) + if (this.options.types.includes(node.type.name)) { + const currentBgColor = node.attrs.backgroundColor + + // If the node already has this color, remove it + if (currentBgColor === color) { + return commands.unsetNodeBackgroundColor() + } + + // Otherwise, set the new color + return commands.setNodeBackgroundColor(color) + } + } + + return false + }, + + setNodeBackgroundColor: + (color: string) => + ({ commands, state, chain }) => { + const { selection } = state + const { $from, $to } = selection + + // Handle range selections by updating all nodes in the range + if ($from.pos !== $to.pos) { + return chain() + .command(({ tr }) => { + const { doc } = tr + doc.nodesBetween($from.pos, $to.pos, (node, pos) => { + if (this.options.types.includes(node.type.name)) { + tr.setNodeMarkup(pos, undefined, { + ...node.attrs, + backgroundColor: color, + }) + } + }) + return true + }) + .run() + } + + // Handle cursor position by finding the nearest applicable node + for (let depth = $from.depth; depth >= 0; depth--) { + const node = $from.node(depth) + if (this.options.types.includes(node.type.name)) { + return commands.updateAttributes(node.type.name, { + backgroundColor: color, + }) + } + } + + return false + }, + + unsetNodeBackgroundColor: + () => + ({ commands, state, chain }) => { + const { selection } = state + const { $from, $to } = selection + + // Handle range selections + if ($from.pos !== $to.pos) { + return chain() + .command(({ tr }) => { + const { doc } = tr + doc.nodesBetween($from.pos, $to.pos, (node, pos) => { + if ( + this.options.types.includes(node.type.name) && + node.attrs.backgroundColor + ) { + tr.setNodeMarkup(pos, undefined, { + ...node.attrs, + backgroundColor: null, + }) + } + }) + return true + }) + .run() + } + + // Handle cursor position + for (let depth = $from.depth; depth >= 0; depth--) { + const node = $from.node(depth) + if ( + this.options.types.includes(node.type.name) && + node.attrs.backgroundColor + ) { + return commands.updateAttributes(node.type.name, { + backgroundColor: null, + }) + } + } + + return false + }, + } + }, + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: new PluginKey("nodeBackground"), + props: { + // Add any additional plugin properties if needed + }, + }), + ] + }, +}) + +export default NodeBackground diff --git a/frontends/main/src/components/tiptap-node/slash-commands/README.md b/frontends/main/src/components/tiptap-node/slash-commands/README.md new file mode 100644 index 0000000000..80c638e1b9 --- /dev/null +++ b/frontends/main/src/components/tiptap-node/slash-commands/README.md @@ -0,0 +1,92 @@ +# Slash Commands for Tiptap Editor + +This implements a Notion-style slash commands menu for the Tiptap editor. Type `/` anywhere in the editor to open a searchable command menu. + +## Installation + +First, install the required dependency: + +```bash +npm install @tiptap/suggestion +``` + +## Usage + +The slash commands extension is already configured in `NewArticlePage.tsx`. Here's how it works: + +### Basic Setup + +```typescript +import { + SlashCommands, + renderSlashCommands, + getSuggestionItems, +} from "@/components/tiptap-node/slash-commands" + +const extensions = [ + // ... other extensions + SlashCommands.configure({ + suggestion: { + items: ({ query }) => getSuggestionItems(query), + render: renderSlashCommands, + }, + }), +] +``` + +## Available Commands + +When you type `/` in the editor, you'll see these commands: + +- **Heading 1, 2, 3** - Create section headings +- **Bullet List** - Create a bulleted list +- **Numbered List** - Create a numbered list +- **Task List** - Create a checklist with checkboxes +- **Blockquote** - Add a quote +- **Code Block** - Insert code with syntax highlighting +- **Horizontal Rule** - Add a divider line +- **Resource Card** - Insert a learning resource card +- **Ask TIM Button** - Insert an AI assistant button + +## Keyboard Navigation + +- `↑` / `↓` - Navigate through commands +- `Enter` or `Tab` - Execute selected command +- `Esc` - Close the menu +- Type to filter commands + +## Customization + +### Adding New Commands + +Edit `slash-commands-extension.tsx` and add to the `getSlashCommands()` function: + +```typescript +{ + title: "My Custom Command", + description: "What this command does", + icon: "🎯", + command: ({ editor, range }) => { + editor + .chain() + .focus() + .deleteRange(range) + .insertContent("Custom content") + .run() + }, + aliases: ["custom", "mycmd"], +} +``` + +### Styling + +Edit `slash-commands.scss` to customize the appearance of the command menu. + +## Features + +- ✅ Searchable command menu +- ✅ Keyboard navigation +- ✅ Mouse interaction +- ✅ Command aliases for better discoverability +- ✅ Responsive design +- ✅ TypeScript support diff --git a/frontends/main/src/components/tiptap-node/slash-commands/index.ts b/frontends/main/src/components/tiptap-node/slash-commands/index.ts new file mode 100644 index 0000000000..9bbc518c9b --- /dev/null +++ b/frontends/main/src/components/tiptap-node/slash-commands/index.ts @@ -0,0 +1,10 @@ +export { + SlashCommands, + getSlashCommands, + getSuggestionItems, + renderSlashCommands, + type CommandItem, +} from "./slash-commands-extension" + +export { SlashCommandsList } from "./slash-commands-list" + diff --git a/frontends/main/src/components/tiptap-node/slash-commands/slash-commands-extension.tsx b/frontends/main/src/components/tiptap-node/slash-commands/slash-commands-extension.tsx new file mode 100644 index 0000000000..880bfaf918 --- /dev/null +++ b/frontends/main/src/components/tiptap-node/slash-commands/slash-commands-extension.tsx @@ -0,0 +1,291 @@ +"use client" + +import { Extension } from "@tiptap/core" +import { ReactRenderer } from "@tiptap/react" +import Suggestion, { SuggestionOptions } from "@tiptap/suggestion" +import { Editor } from "@tiptap/react" +import { SlashCommandsList } from "./slash-commands-list" + +export interface CommandItem { + title: string + description?: string + icon?: string + command: ({ editor, range }: { editor: Editor; range: any }) => void + aliases?: string[] +} + +export const SlashCommands = Extension.create({ + name: "slashCommands", + + addOptions() { + return { + suggestion: { + char: "/", + startOfLine: false, + command: ({ + editor, + range, + props, + }: { + editor: Editor + range: any + props: any + }) => { + props.command({ editor, range }) + }, + } as Partial, + } + }, + + addProseMirrorPlugins() { + return [ + Suggestion({ + editor: this.editor, + ...this.options.suggestion, + }), + ] + }, +}) + +export const getSlashCommands = (): CommandItem[] => [ + { + title: "Heading 1", + description: "Large section heading", + icon: "H1", + command: ({ editor, range }) => { + editor + .chain() + .focus() + .deleteRange(range) + .setNode("heading", { level: 1 }) + .run() + }, + aliases: ["h1", "heading1"], + }, + { + title: "Heading 2", + description: "Medium section heading", + icon: "H2", + command: ({ editor, range }) => { + editor + .chain() + .focus() + .deleteRange(range) + .setNode("heading", { level: 2 }) + .run() + }, + aliases: ["h2", "heading2"], + }, + { + title: "Heading 3", + description: "Small section heading", + icon: "H3", + command: ({ editor, range }) => { + editor + .chain() + .focus() + .deleteRange(range) + .setNode("heading", { level: 3 }) + .run() + }, + aliases: ["h3", "heading3"], + }, + { + title: "Bullet List", + description: "Create a simple bullet list", + icon: "•", + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).toggleBulletList().run() + }, + aliases: ["ul", "unordered"], + }, + { + title: "Numbered List", + description: "Create a list with numbering", + icon: "1.", + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).toggleOrderedList().run() + }, + aliases: ["ol", "ordered"], + }, + { + title: "Task List", + description: "Track tasks with checkboxes", + icon: "☑", + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).toggleTaskList().run() + }, + aliases: ["todo", "checkbox"], + }, + { + title: "Blockquote", + description: "Capture a quote", + icon: '"', + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).toggleBlockquote().run() + }, + aliases: ["quote"], + }, + { + title: "Code Block", + description: "Display code with syntax highlighting", + icon: ">", + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).toggleCodeBlock().run() + }, + aliases: ["code", "codeblock"], + }, + { + title: "Horizontal Rule", + description: "Insert a horizontal divider", + icon: "―", + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).setHorizontalRule().run() + }, + aliases: ["hr", "divider", "line"], + }, + { + title: "Resource Card", + description: "Insert a learning resource card", + icon: "📚", + command: ({ editor, range }) => { + editor + .chain() + .focus() + .deleteRange(range) + .insertContent("") + .run() + + // Insert after a brief delay to ensure the deletion completes + setTimeout(() => { + editor + .chain() + .focus() + .insertContent({ + type: "resourceCard", + attrs: { resourceId: "14731" }, + }) + .run() + }, 0) + }, + aliases: ["resource", "card"], + }, + { + title: "Ask TIM Button", + description: "Insert Ask TIM AI button", + icon: "🤖", + command: ({ editor, range }) => { + editor + .chain() + .focus() + .deleteRange(range) + .insertContent("") + .run() + + // Insert after a brief delay to ensure the deletion completes + setTimeout(() => { + editor + .chain() + .focus() + .insertContent({ + type: "askTimDrawerButton", + }) + .run() + }, 0) + }, + aliases: ["asktim", "ai", "tim"], + }, +] + +export const getSuggestionItems = (query: string): CommandItem[] => { + const commands = getSlashCommands() + + if (!query) { + return commands + } + + const searchQuery = query.toLowerCase() + + return commands.filter((item) => { + const titleMatch = item.title.toLowerCase().includes(searchQuery) + const descMatch = item.description?.toLowerCase().includes(searchQuery) + const aliasMatch = item.aliases?.some((alias) => + alias.toLowerCase().includes(searchQuery), + ) + + return titleMatch || descMatch || aliasMatch + }) +} + +export const renderSlashCommands = () => { + let component: ReactRenderer | null = null + let popup: HTMLDivElement | null = null + + return { + onStart: (props: any) => { + console.log("[SlashCommands] onStart", props) + + component = new ReactRenderer(SlashCommandsList, { + props, + editor: props.editor, + }) + + if (!props.clientRect) { + console.log("[SlashCommands] no clientRect") + return + } + + // Create popup element + popup = document.createElement("div") + popup.style.position = "fixed" + popup.style.zIndex = "1000" + document.body.appendChild(popup) + + // Mount the React component + if (component.element) { + popup.appendChild(component.element) + console.log("[SlashCommands] mounted element") + } + + // Position the popup + const rect = props.clientRect() + if (rect) { + popup.style.top = `${rect.bottom + window.scrollY}px` + popup.style.left = `${rect.left + window.scrollX}px` + console.log("[SlashCommands] positioned at", rect.bottom, rect.left) + } + }, + + onUpdate(props: any) { + component?.updateProps(props) + + if (!props.clientRect || !popup) { + return + } + + // Update position + const rect = props.clientRect() + if (rect) { + popup.style.top = `${rect.bottom + window.scrollY}px` + popup.style.left = `${rect.left + window.scrollX}px` + } + }, + + onKeyDown(props: any) { + if (props.event.key === "Escape") { + return true + } + + return component?.ref?.onKeyDown(props) || false + }, + + onExit() { + if (popup && popup.parentNode) { + popup.parentNode.removeChild(popup) + } + popup = null + component?.destroy() + }, + } +} + diff --git a/frontends/main/src/components/tiptap-node/slash-commands/slash-commands-list.tsx b/frontends/main/src/components/tiptap-node/slash-commands/slash-commands-list.tsx new file mode 100644 index 0000000000..cc0f4390f6 --- /dev/null +++ b/frontends/main/src/components/tiptap-node/slash-commands/slash-commands-list.tsx @@ -0,0 +1,177 @@ +"use client" + +import React, { + forwardRef, + useEffect, + useImperativeHandle, + useState, +} from "react" +import { styled } from "ol-components" +import { Editor } from "@tiptap/react" +import { Range } from "@tiptap/core" +import { CommandItem, getSuggestionItems } from "./slash-commands-extension" + +const CommandsMenu = styled.div({ + background: "white", + border: "1px solid #e0e0e0", + borderRadius: "8px", + boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)", + padding: "8px", + minWidth: "280px", + maxHeight: "400px", + overflowY: "auto", +}) + +const StyledCommandItem = styled.div<{ $isSelected: boolean }>( + { + display: "flex", + alignItems: "center", + gap: "12px", + padding: "8px 12px", + borderRadius: "4px", + cursor: "pointer", + transition: "background-color 0.15s", + userSelect: "none", + + "&:hover": { + backgroundColor: "#f5f5f5", + }, + }, + ({ $isSelected }) => + $isSelected && { + backgroundColor: "#e8f0fe", + + "&:hover": { + backgroundColor: "#d2e3fc", + }, + }, +) + +const CommandIcon = styled.div({ + fontSize: "18px", + // eslint-disable-next-line ol-kit/no-manual-font-weight + fontWeight: 600, + color: "#666", + minWidth: "24px", + textAlign: "center", +}) + +const CommandContent = styled.div({ + flex: 1, + minWidth: 0, +}) + +const CommandTitle = styled.div({ + fontSize: "14px", + // eslint-disable-next-line ol-kit/no-manual-font-weight + fontWeight: 500, + color: "#333", +}) + +const CommandDescription = styled.div({ + fontSize: "12px", + color: "#666", + marginTop: "2px", +}) + +interface SlashCommandsListProps { + items: CommandItem[] + command: (item: CommandItem) => void + editor: Editor + range: Range + query: string +} + +export const SlashCommandsList = forwardRef( + (props: SlashCommandsListProps, ref) => { + const [selectedIndex, setSelectedIndex] = useState(0) + + const filteredItems = getSuggestionItems(props.query) + + const selectItem = (index: number) => { + const item = filteredItems[index] + + if (item) { + props.command(item) + } + } + + const upHandler = () => { + setSelectedIndex( + (selectedIndex + filteredItems.length - 1) % filteredItems.length, + ) + } + + const downHandler = () => { + setSelectedIndex((selectedIndex + 1) % filteredItems.length) + } + + const enterHandler = () => { + selectItem(selectedIndex) + } + + useEffect(() => { + setSelectedIndex(0) + }, [filteredItems.length]) + + useImperativeHandle(ref, () => ({ + onKeyDown: ({ event }: { event: KeyboardEvent }) => { + if (event.key === "ArrowUp") { + upHandler() + return true + } + + if (event.key === "ArrowDown") { + downHandler() + return true + } + + if (event.key === "Enter" || event.key === "Tab") { + event.preventDefault() + enterHandler() + return true + } + + return false + }, + })) + + if (filteredItems.length === 0) { + return ( + + + + No results + + Try a different search term + + + + + ) + } + + return ( + + {filteredItems.map((item, index) => ( + selectItem(index)} + onMouseEnter={() => setSelectedIndex(index)} + > + {item.icon} + + {item.title} + {item.description && ( + {item.description} + )} + + + ))} + + ) + }, +) + +SlashCommandsList.displayName = "SlashCommandsList" diff --git a/frontends/main/src/components/tiptap-node/slash-commands/slash-commands.scss b/frontends/main/src/components/tiptap-node/slash-commands/slash-commands.scss new file mode 100644 index 0000000000..0f46a02863 --- /dev/null +++ b/frontends/main/src/components/tiptap-node/slash-commands/slash-commands.scss @@ -0,0 +1,78 @@ +/* Slash Commands Styles */ +.slash-commands-menu { + background: white; + border: 1px solid #e0e0e0; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + padding: 8px; + min-width: 280px; + max-height: 400px; + overflow-y: auto; + z-index: 1000; +} + +.slash-commands-menu::-webkit-scrollbar { + width: 8px; +} + +.slash-commands-menu::-webkit-scrollbar-track { + background: transparent; +} + +.slash-commands-menu::-webkit-scrollbar-thumb { + background: #ccc; + border-radius: 4px; +} + +.slash-commands-menu::-webkit-scrollbar-thumb:hover { + background: #999; +} + +.slash-command-item { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 12px; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.15s; + user-select: none; +} + +.slash-command-item:hover { + background-color: #f5f5f5; +} + +.slash-command-item.selected { + background-color: #e8f0fe; +} + +.slash-command-item.selected:hover { + background-color: #d2e3fc; +} + +.slash-command-icon { + font-size: 18px; + font-weight: 600; + color: #666; + min-width: 24px; + text-align: center; +} + +.slash-command-content { + flex: 1; + min-width: 0; +} + +.slash-command-title { + font-size: 14px; + font-weight: 500; + color: #333; +} + +.slash-command-description { + font-size: 12px; + color: #666; + margin-top: 2px; +} + diff --git a/frontends/main/src/components/tiptap-templates/simple/data/content.json b/frontends/main/src/components/tiptap-templates/simple/data/content.json index d9ff103110..6e025f18be 100644 --- a/frontends/main/src/components/tiptap-templates/simple/data/content.json +++ b/frontends/main/src/components/tiptap-templates/simple/data/content.json @@ -71,6 +71,18 @@ } ] }, + { + "type": "paragraph", + "attrs": { + "textAlign": null + }, + "content": [ + { + "type": "text", + "text": "Type / to open the commands menu." + } + ] + }, { "type": "paragraph", "attrs": { diff --git a/yarn.lock b/yarn.lock index eeae396464..3fc9015179 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6843,6 +6843,16 @@ __metadata: languageName: node linkType: hard +"@tiptap/suggestion@npm:^3.10.2": + version: 3.10.2 + resolution: "@tiptap/suggestion@npm:3.10.2" + peerDependencies: + "@tiptap/core": ^3.10.2 + "@tiptap/pm": ^3.10.2 + checksum: 10/807e1a2453e95de9d4676742d68ecd418262744dde5deecdffab5cb0c956eb5258ac41796bd72c168579e73951532dc550ee040096cbd1ace4d0bad0ceba614d + languageName: node + linkType: hard + "@tootallnate/once@npm:2": version: 2.0.0 resolution: "@tootallnate/once@npm:2.0.0" @@ -15308,6 +15318,7 @@ __metadata: "@tiptap/pm": "npm:^3.10.1" "@tiptap/react": "npm:^3.10.1" "@tiptap/starter-kit": "npm:^3.10.1" + "@tiptap/suggestion": "npm:^3.10.2" "@types/jest": "npm:^29.5.12" "@types/lodash": "npm:^4.17.7" "@types/lodash.throttle": "npm:^4.1.9" From 87a1a5e858873221ad5c8b2b217bfa865d4d8800 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 5 Nov 2025 19:58:06 +0000 Subject: [PATCH 6/8] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../main/src/components/tiptap-node/slash-commands/index.ts | 1 - .../tiptap-node/slash-commands/slash-commands-extension.tsx | 1 - .../components/tiptap-node/slash-commands/slash-commands.scss | 1 - 3 files changed, 3 deletions(-) diff --git a/frontends/main/src/components/tiptap-node/slash-commands/index.ts b/frontends/main/src/components/tiptap-node/slash-commands/index.ts index 9bbc518c9b..dd28b2c43d 100644 --- a/frontends/main/src/components/tiptap-node/slash-commands/index.ts +++ b/frontends/main/src/components/tiptap-node/slash-commands/index.ts @@ -7,4 +7,3 @@ export { } from "./slash-commands-extension" export { SlashCommandsList } from "./slash-commands-list" - diff --git a/frontends/main/src/components/tiptap-node/slash-commands/slash-commands-extension.tsx b/frontends/main/src/components/tiptap-node/slash-commands/slash-commands-extension.tsx index 880bfaf918..bae7bab19b 100644 --- a/frontends/main/src/components/tiptap-node/slash-commands/slash-commands-extension.tsx +++ b/frontends/main/src/components/tiptap-node/slash-commands/slash-commands-extension.tsx @@ -288,4 +288,3 @@ export const renderSlashCommands = () => { }, } } - diff --git a/frontends/main/src/components/tiptap-node/slash-commands/slash-commands.scss b/frontends/main/src/components/tiptap-node/slash-commands/slash-commands.scss index 0f46a02863..7f26c01534 100644 --- a/frontends/main/src/components/tiptap-node/slash-commands/slash-commands.scss +++ b/frontends/main/src/components/tiptap-node/slash-commands/slash-commands.scss @@ -75,4 +75,3 @@ color: #666; margin-top: 2px; } - From 0bdb9ac55dc9893d6b91411aa9f104ca627f3815 Mon Sep 17 00:00:00 2001 From: Jon Kafton <939376+jonkafton@users.noreply.github.com> Date: Thu, 6 Nov 2025 18:27:15 +0100 Subject: [PATCH 7/8] Fixed template experiment. Add slash command menu. Editable JSON schema --- frontends/main/package.json | 2 + .../AskTimDrawerButtonExtension.tsx | 55 +++ .../ArticlePage/DescriptionExtension.tsx | 43 ++ .../app-pages/ArticlePage/NewArticlePage.tsx | 250 +++------- .../ArticlePage/ResourceCardExtension.tsx | 122 +++++ .../ArticlePage/ResourceListCardExtension.tsx | 138 ++++++ frontends/main/src/app/layout.tsx | 4 +- frontends/main/src/app/styled.tsx | 26 +- frontends/main/src/app/tiptap-starter-kit.css | 91 ++++ .../description-node/description-node.scss | 13 + .../slash-commands-extension.tsx | 160 ++++--- .../slash-commands/slash-commands-list.tsx | 4 - .../tiptap-templates/simple/data/content.json | 452 +----------------- .../simple/simple-editor.scss | 2 +- .../tiptap-templates/simple/simple-editor.tsx | 13 + .../link-popover/use-link-popover.ts | 12 +- yarn.lock | 20 + 17 files changed, 693 insertions(+), 714 deletions(-) create mode 100644 frontends/main/src/app-pages/ArticlePage/AskTimDrawerButtonExtension.tsx create mode 100644 frontends/main/src/app-pages/ArticlePage/DescriptionExtension.tsx create mode 100644 frontends/main/src/app-pages/ArticlePage/ResourceCardExtension.tsx create mode 100644 frontends/main/src/app-pages/ArticlePage/ResourceListCardExtension.tsx create mode 100644 frontends/main/src/app/tiptap-starter-kit.css create mode 100644 frontends/main/src/components/tiptap-node/description-node/description-node.scss diff --git a/frontends/main/package.json b/frontends/main/package.json index 8baf658413..dd677ae9f3 100644 --- a/frontends/main/package.json +++ b/frontends/main/package.json @@ -24,6 +24,8 @@ "@remixicon/react": "^4.7.0", "@sentry/nextjs": "^10.0.0", "@tanstack/react-query": "^5.66", + "@tiptap/core": "^3.10.2", + "@tiptap/extension-document": "^3.10.2", "@tiptap/extension-highlight": "^3.10.1", "@tiptap/extension-horizontal-rule": "^3.10.1", "@tiptap/extension-image": "^3.10.1", diff --git a/frontends/main/src/app-pages/ArticlePage/AskTimDrawerButtonExtension.tsx b/frontends/main/src/app-pages/ArticlePage/AskTimDrawerButtonExtension.tsx new file mode 100644 index 0000000000..2c1daa5146 --- /dev/null +++ b/frontends/main/src/app-pages/ArticlePage/AskTimDrawerButtonExtension.tsx @@ -0,0 +1,55 @@ +import { ReactNodeViewRenderer, Node, NodeViewWrapper } from "@tiptap/react" +import AskTimDrawerButton from "@/page-components/AiChat/AskTimDrawerButton" + +const AskTimDrawerButtonWrapper = () => { + return ( + + + + ) +} + +const AskTimDrawerButtonExtension = Node.create({ + name: "askTimDrawerButton", + group: "block", + atom: true, + selectable: true, + + markdownTokenizer: { + name: "askTimDrawerButton", + level: "block", + + start: (src) => { + return src.indexOf("[[asktim]]") + }, + + tokenize: (src, _tokens, _lexer) => { + // Match [[asktim]] + const match = /^\[\[asktim\]\]/.exec(src) + + if (!match) { + return undefined + } + + return { + type: "askTimDrawerButton", + raw: match[0], + } + }, + }, + + parseMarkdown: (_token, _helpers) => { + return { + type: "askTimDrawerButton", + } + }, + + renderMarkdown: (_node, _helpers) => { + return "[[asktim]]\n\n" + }, + + addNodeView() { + return ReactNodeViewRenderer(AskTimDrawerButtonWrapper) + }, +}) +export { AskTimDrawerButtonExtension } diff --git a/frontends/main/src/app-pages/ArticlePage/DescriptionExtension.tsx b/frontends/main/src/app-pages/ArticlePage/DescriptionExtension.tsx new file mode 100644 index 0000000000..11d86d53ef --- /dev/null +++ b/frontends/main/src/app-pages/ArticlePage/DescriptionExtension.tsx @@ -0,0 +1,43 @@ +import { Node, mergeAttributes } from "@tiptap/core" + +export const Description = Node.create({ + name: "description", + + group: "block", + content: "inline*", + + parseHTML() { + return [ + { + tag: "p[data-type='description']", + }, + ] + }, + + renderHTML({ HTMLAttributes }) { + return [ + "p", + mergeAttributes(HTMLAttributes, { "data-type": "description" }), + 0, + ] + }, + + addKeyboardShortcuts() { + return { + // Prevent Enter from creating a new description + Enter: ({ editor }) => { + const { state } = editor + const { $from } = state.selection + + // Check if we're in a description node + if ($from.parent.type.name === "description") { + // Try to move to the next node instead of creating a new line + return editor.commands.focus("end") + } + + return false + }, + } + }, +}) + diff --git a/frontends/main/src/app-pages/ArticlePage/NewArticlePage.tsx b/frontends/main/src/app-pages/ArticlePage/NewArticlePage.tsx index 9dab9de7d5..4622f28454 100644 --- a/frontends/main/src/app-pages/ArticlePage/NewArticlePage.tsx +++ b/frontends/main/src/app-pages/ArticlePage/NewArticlePage.tsx @@ -1,17 +1,9 @@ "use client" import React, { useState, useRef } from "react" -import { theme, styled, LearningResourceCard } from "ol-components" +import { theme, styled } from "ol-components" import { SimpleEditor } from "@/components/tiptap-templates/simple/simple-editor" -import { - EditorContext, - useEditor, - ReactNodeViewRenderer, - Node, - mergeAttributes, - NodeViewWrapper, - type NodeViewProps, -} from "@tiptap/react" +import { EditorContext, useEditor } from "@tiptap/react" import { Superscript } from "@tiptap/extension-superscript" import { Subscript } from "@tiptap/extension-subscript" @@ -20,9 +12,8 @@ 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 as TipTapTypography } from "@tiptap/extension-typography" import { Highlight } from "@tiptap/extension-highlight" -import { Selection } from "@tiptap/extensions" +import { Placeholder, Selection } from "@tiptap/extensions" // --- Tiptap Node --- import { ImageUploadNode } from "@/components/tiptap-node/image-upload-node/image-upload-node-extension" @@ -38,14 +29,17 @@ import content from "@/components/tiptap-templates/simple/data/content.json" // --- Lib --- import { handleImageUpload, MAX_FILE_SIZE } from "@/lib/tiptap-utils" -import { useLearningResourcesDetail } from "api/hooks/learningResources" -import AskTimDrawerButton from "@/page-components/AiChat/AskTimDrawerButton" - import { Markdown } from "@tiptap/markdown" +import Document from "@tiptap/extension-document" +import { ResourceCardExtension } from "./ResourceCardExtension" +import { ResourceListCardExtension } from "./ResourceListCardExtension" +import { AskTimDrawerButtonExtension } from "./AskTimDrawerButtonExtension" +import { Description } from "./DescriptionExtension" const PageContainer = styled.div({ color: theme.custom.colors.darkGray2, display: "flex", + height: "100%", // position: "fixed", // top: "72px", // left: 0, @@ -56,21 +50,25 @@ const PageContainer = styled.div({ }) const EditorContainer = styled.div({ - flex: 6, + flex: 7, + overflow: "auto", + minHeight: 0, }) const PreviewContainer = styled.div({ - flex: 4, + flex: 3, backgroundColor: "white", padding: "16px", display: "flex", flexDirection: "column", + overflow: "hidden", + minHeight: 0, }) -const MarkdownTextarea = styled.textarea({ +const CodeTextarea = styled.textarea({ flex: 1, fontFamily: "monospace", - fontSize: "14px", + fontSize: "12px", padding: "16px", border: `1px solid ${theme.custom.colors.lightGray2}`, borderRadius: "4px", @@ -85,164 +83,51 @@ const MarkdownTextarea = styled.textarea({ }) const StyledSimpleEditor = styled(SimpleEditor)({ - width: "60vw", - height: "calc(100vh - 205px)", + width: "70vw", + height: "100%", overscrollBehavior: "contain", }) -const Card = styled.div({ - borderRadius: "8px", - border: `1px solid ${theme.custom.colors.lightGray2}`, - background: theme.custom.colors.white, - display: "block", - overflow: "hidden", - minWidth: "300px", - maxWidth: "300px", - padding: "16px", +const CustomDocument = Document.extend({ + content: + "heading description resourceListCard* (paragraph | taskList | bulletList | orderedList | codeBlock | horizontalRule | image | imageUpload | resourceCard | askTimDrawerButton)*", }) -const ResourceCardWrapper = (props: NodeViewProps) => { - const { node } = props - - const { data: resource, isLoading } = useLearningResourcesDetail( - node.attrs.resourceId, - ) - if (isLoading) { - return Loading... - } - if (!resource) { - return Resource not found - } - - return ( - - - - ) -} - -const ResourceCardExtension = Node.create({ - name: "resourceCard", - - group: "block", - atom: true, - selectable: true, - - addAttributes() { - return { - resourceId: { - default: null, - }, - } - }, - - parseHTML() { - return [{ tag: "resource-card" }] - }, - - renderHTML({ HTMLAttributes }) { - return ["resource-card", mergeAttributes(HTMLAttributes)] - }, - - markdownTokenizer: { - name: "resourceCard", - level: "block", - - start: (src) => { - return src.indexOf("[[resource-card:") - }, - - tokenize: (src, _tokens, _lexer) => { - // Match [[resource:resourceId]] - const match = /^\[\[resource-card:(\d+)\]\]/.exec(src) - - if (!match) { - return undefined +const extensions = [ + CustomDocument, + Description, + Placeholder.configure({ + placeholder: ({ node }) => { + if (node.type.name === "heading") { + return "Add a title" } - - return { - type: "resourceCard", - raw: match[0], - resourceId: match[1], + if (node.type.name === "description") { + return "Add a description" } - }, - }, - - parseMarkdown: (token, _helpers) => { - return { - type: "resourceCard", - attrs: { - resourceId: token.resourceId || null, - }, - } - }, - - renderMarkdown: (node, _helpers) => { - const resourceId = node.attrs?.resourceId || "" - return `[[resource-card:${resourceId}]]\n\n` - }, - - addNodeView() { - return ReactNodeViewRenderer(ResourceCardWrapper) - }, -}) - -const AskTimDrawerButtonWrapper = () => { - return ( - - - - ) -} - -const AskTimDrawerButtonExtension = Node.create({ - name: "askTimDrawerButton", - group: "block", - atom: true, - selectable: true, - - markdownTokenizer: { - name: "askTimDrawerButton", - level: "block", - - start: (src) => { - return src.indexOf("[[asktim]]") - }, - - tokenize: (src, _tokens, _lexer) => { - // Match [[asktim]] - const match = /^\[\[asktim\]\]/.exec(src) - - if (!match) { - return undefined + if (node.type.name === "paragraph") { + return "Add a paragraph" } - - return { - type: "askTimDrawerButton", - raw: match[0], + if (node.type.name === "resourceListCard") { + return "Add a learning resource list card" + } + if (node.type.name === "resourceCard") { + return "Add a learning resource card" + } + if (node.type.name === "askTimDrawerButton") { + return "Add an AI assistant button" } - }, - }, - - parseMarkdown: (_token, _helpers) => { - return { - type: "askTimDrawerButton", - } - }, - - renderMarkdown: (_node, _helpers) => { - return "[[asktim]]\n\n" - }, - - addNodeView() { - return ReactNodeViewRenderer(AskTimDrawerButtonWrapper) - }, -}) -const extensions = [ + return "" + }, + }), Markdown, StarterKit.configure({ + document: false, // Disable default document to use our CustomDocument horizontalRule: false, + heading: { + levels: [1, 2, 3, 4, 5, 6], + }, + blockquote: false, // Disable automatic blockquote creation link: { openOnClick: false, enableClickSelection: true, @@ -254,7 +139,6 @@ const extensions = [ TaskItem.configure({ nested: true }), Highlight.configure({ multicolor: true }), Image, - TipTapTypography, Superscript, Subscript, Selection, @@ -266,6 +150,7 @@ const extensions = [ onError: (error) => console.error("Upload failed:", error), }), ResourceCardExtension, + ResourceListCardExtension, AskTimDrawerButtonExtension, SlashCommands.configure({ suggestion: { @@ -277,7 +162,7 @@ const extensions = [ interface ViewerProps { content: string - onChange: (markdown: string) => void + onChange: (json: string) => void } const Viewer: React.FC = ({ content, onChange }) => { @@ -286,10 +171,10 @@ const Viewer: React.FC = ({ content, onChange }) => { } return ( - ) @@ -297,7 +182,7 @@ const Viewer: React.FC = ({ content, onChange }) => { const NewArticlePage: React.FC = () => { const [serializedContent, setSerializedContent] = useState("") - const isUpdatingFromMarkdown = useRef(false) + const isUpdatingFromJSON = useRef(false) const editor = useEditor({ immediatelyRender: false, @@ -311,37 +196,38 @@ const NewArticlePage: React.FC = () => { class: "simple-editor", }, }, + // @ts-expect-error - Type conflict between @tiptap/starter-kit bundled @tiptap/core and main @tiptap/core extensions, content, onUpdate: ({ editor: currentEditor }) => { - // Only update markdown if we're not in the middle of updating from markdown - if (!isUpdatingFromMarkdown.current) { - setSerializedContent(currentEditor.getMarkdown()) + // Only update JSON if we're not in the middle of updating from JSON + if (!isUpdatingFromJSON.current) { + const json = currentEditor.getJSON() + setSerializedContent(JSON.stringify(json, null, 2)) } }, }) - const handleMarkdownChange = (markdown: string) => { + const handleJSONChange = (jsonString: string) => { if (!editor) return // Set flag to prevent the editor's onUpdate from firing back - isUpdatingFromMarkdown.current = true + isUpdatingFromJSON.current = true - setSerializedContent(markdown) + setSerializedContent(jsonString) - // Update the editor content from markdown + // Update the editor content from JSON try { - editor.commands.setContent(markdown, { - contentType: "markdown", - }) + const json = JSON.parse(jsonString) + editor.commands.setContent(json) } catch (error) { - console.error("Error parsing markdown:", error) + console.error("Error parsing JSON:", error) } // Reset flag after update completes // Use setTimeout to ensure all updates are processed setTimeout(() => { - isUpdatingFromMarkdown.current = false + isUpdatingFromJSON.current = false }, 50) } @@ -358,7 +244,7 @@ const NewArticlePage: React.FC = () => { {editor && ( - + )} diff --git a/frontends/main/src/app-pages/ArticlePage/ResourceCardExtension.tsx b/frontends/main/src/app-pages/ArticlePage/ResourceCardExtension.tsx new file mode 100644 index 0000000000..28f155eeb6 --- /dev/null +++ b/frontends/main/src/app-pages/ArticlePage/ResourceCardExtension.tsx @@ -0,0 +1,122 @@ +import { + ReactNodeViewRenderer, + Node, + mergeAttributes, + NodeViewWrapper, +} from "@tiptap/react" +import { type NodeViewProps } from "@tiptap/react" +import { useLearningResourcesDetail } from "api/hooks/learningResources" +import { theme, styled, LearningResourceCard } from "ol-components" + +const LoadingCard = styled.div({ + borderRadius: "8px", + border: `1px solid ${theme.custom.colors.lightGray2}`, + background: theme.custom.colors.white, + display: "block", + overflow: "hidden", + minWidth: "300px", + maxWidth: "300px", + padding: "16px", +}) + +const SelectableWrapper = styled.div({ + cursor: "pointer", + transition: "all 0.2s", + display: "inline-block", + "&:hover": { + outline: `2px solid ${theme.custom.colors.lightGray2}`, + outlineOffset: "2px", + }, + '&[data-node-view-selected="true"]': { + outline: `2px solid ${theme.custom.colors.mitRed}`, + outlineOffset: "2px", + }, +}) + +const ResourceCardWrapper = (props: NodeViewProps) => { + const { node, selected } = props + + const { data: resource, isLoading } = useLearningResourcesDetail( + node.attrs.resourceId, + ) + + return ( + + + {isLoading && Loading...} + {!isLoading && !resource && ( + Resource not found + )} + {!isLoading && resource && } + + + ) +} + +const ResourceCardExtension = Node.create({ + name: "resourceCard", + + group: "block", + atom: true, + selectable: true, + + addAttributes() { + return { + resourceId: { + default: null, + }, + } + }, + + parseHTML() { + return [{ tag: "resource-card" }] + }, + + renderHTML({ HTMLAttributes }) { + return ["resource-card", mergeAttributes(HTMLAttributes)] + }, + + markdownTokenizer: { + name: "resourceCard", + level: "block", + + start: (src) => { + return src.indexOf("[[resource-card:") + }, + + tokenize: (src, _tokens, _lexer) => { + // Match [[resource:resourceId]] + const match = /^\[\[resource-card:(\d+)\]\]/.exec(src) + + if (!match) { + return undefined + } + + return { + type: "resourceCard", + raw: match[0], + resourceId: match[1], + } + }, + }, + + parseMarkdown: (token, _helpers) => { + return { + type: "resourceCard", + attrs: { + resourceId: token.resourceId || null, + }, + } + }, + + renderMarkdown: (node, _helpers) => { + const resourceId = node.attrs?.resourceId || "" + return `[[resource-card:${resourceId}]]\n\n` + }, + + addNodeView() { + return ReactNodeViewRenderer(ResourceCardWrapper) + }, +}) + +export { ResourceCardExtension } diff --git a/frontends/main/src/app-pages/ArticlePage/ResourceListCardExtension.tsx b/frontends/main/src/app-pages/ArticlePage/ResourceListCardExtension.tsx new file mode 100644 index 0000000000..dbe5c02d73 --- /dev/null +++ b/frontends/main/src/app-pages/ArticlePage/ResourceListCardExtension.tsx @@ -0,0 +1,138 @@ +import { + ReactNodeViewRenderer, + Node, + mergeAttributes, + NodeViewWrapper, +} from "@tiptap/react" +import { type NodeViewProps } from "@tiptap/react" +import { useLearningResourcesDetail } from "api/hooks/learningResources" +import { theme, styled, LearningResourceListCard } from "ol-components" + +const LoadingCard = styled.div({ + borderRadius: "8px", + border: `1px solid ${theme.custom.colors.lightGray2}`, + background: theme.custom.colors.white, + display: "block", + overflow: "hidden", + height: "183px", + width: "100%", + padding: "16px", + marginTop: "16px", +}) + +const StyledLearningResourceListCard = styled(LearningResourceListCard)({ + marginTop: "16px", +}) + +const SelectableWrapper = styled.div({ + cursor: "pointer", + transition: "all 0.2s", + "&:hover": { + outline: `2px solid ${theme.custom.colors.lightGray2}`, + outlineOffset: "2px", + }, + '&[data-node-view-selected="true"]': { + outline: `2px solid ${theme.custom.colors.mitRed}`, + outlineOffset: "2px", + }, +}) + +const ResourceListCardWrapper = (props: NodeViewProps) => { + const { node, selected } = props + + const { data: resource, isLoading } = useLearningResourcesDetail( + node.attrs.resourceId, + ) + + if (!node.attrs.resourceId) { + return ( + + + Resource ID not set + + + ) + } + + return ( + + + {isLoading && Loading...} + {!isLoading && !resource && ( + Resource not found + )} + {!isLoading && resource && ( + + )} + + + ) +} + +const ResourceListCardExtension = Node.create({ + name: "resourceListCard", + + group: "block", + atom: true, + selectable: true, + + addAttributes() { + return { + resourceId: { + default: null, + }, + } + }, + + parseHTML() { + return [{ tag: "resource-list-card" }] + }, + + renderHTML({ HTMLAttributes }) { + return ["resource-list-card", mergeAttributes(HTMLAttributes)] + }, + + markdownTokenizer: { + name: "resourceListCard", + level: "block", + + start: (src) => { + return src.indexOf("[[resource-list-card:") + }, + + tokenize: (src, _tokens, _lexer) => { + // Match [[resource:resourceId]] + const match = /^\[\[resource-list-card:(\d+)\]\]/.exec(src) + + if (!match) { + return undefined + } + + return { + type: "resourceListCard", + raw: match[0], + resourceId: match[1], + } + }, + }, + + parseMarkdown: (token, _helpers) => { + return { + type: "resourceListCard", + attrs: { + resourceId: token.resourceId || null, + }, + } + }, + + renderMarkdown: (node, _helpers) => { + const resourceId = node.attrs?.resourceId || "" + return `[[resource-list-card:${resourceId}]]\n\n` + }, + + addNodeView() { + return ReactNodeViewRenderer(ResourceListCardWrapper) + }, +}) + +export { ResourceListCardExtension } diff --git a/frontends/main/src/app/layout.tsx b/frontends/main/src/app/layout.tsx index 9db5de12f1..ebe471e8d2 100644 --- a/frontends/main/src/app/layout.tsx +++ b/frontends/main/src/app/layout.tsx @@ -33,7 +33,7 @@ export default function RootLayout({ children: React.ReactNode }>) { return ( - + {/* Font files for Adobe neue haas grotesk. @@ -50,7 +50,7 @@ export default function RootLayout({ content={process.env.NEXT_PUBLIC_VERSION || "unknown"} /> - + diff --git a/frontends/main/src/app/styled.tsx b/frontends/main/src/app/styled.tsx index fe5143a9bb..eecfba7b4e 100644 --- a/frontends/main/src/app/styled.tsx +++ b/frontends/main/src/app/styled.tsx @@ -8,17 +8,25 @@ import { styled, HEADER_HEIGHT, HEADER_HEIGHT_MD } from "ol-components" * Solution for now is to "use client", though I would expect these to be prerendered */ -export const PageWrapper = styled.div(({ theme }) => ({ +export const PageWrapper = styled.div({ display: "flex", flexDirection: "column", - height: `calc(100vh - ${HEADER_HEIGHT}px)`, - marginTop: HEADER_HEIGHT, + height: "100vh", + width: "100%", + overflow: "hidden", + position: "fixed", + top: 0, + left: 0, + right: 0, + bottom: 0, +}) + +export const PageWrapperInner = styled.main(({ theme }) => ({ + flex: "1", + overflow: "auto", + minHeight: 0, + paddingTop: HEADER_HEIGHT, [theme.breakpoints.down("md")]: { - marginTop: HEADER_HEIGHT_MD, - height: `calc(100vh - ${HEADER_HEIGHT_MD}px)`, + paddingTop: HEADER_HEIGHT_MD, }, })) - -export const PageWrapperInner = styled.main({ - flex: "1", -}) diff --git a/frontends/main/src/app/tiptap-starter-kit.css b/frontends/main/src/app/tiptap-starter-kit.css new file mode 100644 index 0000000000..5bf0102685 --- /dev/null +++ b/frontends/main/src/app/tiptap-starter-kit.css @@ -0,0 +1,91 @@ +/* Basic editor styles */ +.tiptap { + :first-child { + margin-top: 0; + } + + /* List styles */ + ul, + ol { + padding: 0 1rem; + margin: 1.25rem 1rem 1.25rem 0.4rem; + + li p { + margin-top: 0.25em; + margin-bottom: 0.25em; + } + } + + /* Heading styles */ + h1, + h2, + h3, + h4, + h5, + h6 { + line-height: 1.1; + margin-top: 2.5rem; + text-wrap: pretty; + } + + h1, + h2 { + margin-top: 3.5rem; + margin-bottom: 1.5rem; + } + + h1 { + font-size: 1.4rem; + } + + h2 { + font-size: 1.2rem; + } + + h3 { + font-size: 1.1rem; + } + + h4, + h5, + h6 { + font-size: 1rem; + } + + /* Code and preformatted text styles */ + code { + background-color: var(--purple-light); + border-radius: 0.4rem; + color: var(--black); + font-size: 0.85rem; + padding: 0.25em 0.3em; + } + + pre { + background: var(--black); + border-radius: 0.5rem; + color: var(--white); + font-family: "JetBrainsMono", monospace; + margin: 1.5rem 0; + padding: 0.75rem 1rem; + + code { + background: none; + color: inherit; + font-size: 0.8rem; + padding: 0; + } + } + + blockquote { + border-left: 3px solid var(--gray-3); + margin: 1.5rem 0; + padding-left: 1rem; + } + + hr { + border: none; + border-top: 1px solid var(--gray-2); + margin: 2rem 0; + } +} diff --git a/frontends/main/src/components/tiptap-node/description-node/description-node.scss b/frontends/main/src/components/tiptap-node/description-node/description-node.scss new file mode 100644 index 0000000000..87fd2fac1d --- /dev/null +++ b/frontends/main/src/components/tiptap-node/description-node/description-node.scss @@ -0,0 +1,13 @@ +.tiptap.ProseMirror { + p[data-type="description"] { + font-size: 1.125rem; + line-height: 1.7; + margin-top: 1rem; + margin-bottom: 2rem; + + &:empty::before { + color: #999; + font-style: italic; + } + } +} diff --git a/frontends/main/src/components/tiptap-node/slash-commands/slash-commands-extension.tsx b/frontends/main/src/components/tiptap-node/slash-commands/slash-commands-extension.tsx index 880bfaf918..040f7b0b33 100644 --- a/frontends/main/src/components/tiptap-node/slash-commands/slash-commands-extension.tsx +++ b/frontends/main/src/components/tiptap-node/slash-commands/slash-commands-extension.tsx @@ -1,9 +1,9 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ "use client" import { Extension } from "@tiptap/core" -import { ReactRenderer } from "@tiptap/react" +import { ReactRenderer, Editor } from "@tiptap/react" import Suggestion, { SuggestionOptions } from "@tiptap/suggestion" -import { Editor } from "@tiptap/react" import { SlashCommandsList } from "./slash-commands-list" export interface CommandItem { @@ -48,6 +48,65 @@ export const SlashCommands = Extension.create({ }) export const getSlashCommands = (): CommandItem[] => [ + { + title: "Resource Card", + description: "Insert a learning resource card", + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).insertContent("").run() + + // Insert after a brief delay to ensure the deletion completes + setTimeout(() => { + editor + .chain() + .focus() + .insertContent({ + type: "resourceCard", + attrs: { resourceId: "14731" }, + }) + .run() + }, 0) + }, + aliases: ["resource", "card"], + }, + { + title: "Resource List Card", + description: "Insert a learning resource list card", + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).insertContent("").run() + + // Insert after a brief delay to ensure the deletion completes + setTimeout(() => { + editor + .chain() + .focus() + .insertContent({ + type: "resourceCard", + attrs: { resourceId: "14731" }, + }) + .run() + }, 0) + }, + aliases: ["resource", "card"], + }, + { + title: "AskTIM Drawer Button", + description: "Insert AskTIM drawer button", + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).insertContent("").run() + + // Insert after a brief delay to ensure the deletion completes + setTimeout(() => { + editor + .chain() + .focus() + .insertContent({ + type: "askTimDrawerButton", + }) + .run() + }, 0) + }, + aliases: ["asktim", "ai", "tim"], + }, { title: "Heading 1", description: "Large section heading", @@ -117,24 +176,24 @@ export const getSlashCommands = (): CommandItem[] => [ }, aliases: ["todo", "checkbox"], }, - { - title: "Blockquote", - description: "Capture a quote", - icon: '"', - command: ({ editor, range }) => { - editor.chain().focus().deleteRange(range).toggleBlockquote().run() - }, - aliases: ["quote"], - }, - { - title: "Code Block", - description: "Display code with syntax highlighting", - icon: ">", - command: ({ editor, range }) => { - editor.chain().focus().deleteRange(range).toggleCodeBlock().run() - }, - aliases: ["code", "codeblock"], - }, + // { + // title: "Blockquote", + // description: "Capture a quote", + // icon: '"', + // command: ({ editor, range }) => { + // editor.chain().focus().deleteRange(range).toggleBlockquote().run() + // }, + // aliases: ["quote"], + // }, + // { + // title: "Code Block", + // description: "Display code with syntax highlighting", + // icon: ">", + // command: ({ editor, range }) => { + // editor.chain().focus().deleteRange(range).toggleCodeBlock().run() + // }, + // aliases: ["code", "codeblock"], + // }, { title: "Horizontal Rule", description: "Insert a horizontal divider", @@ -144,57 +203,6 @@ export const getSlashCommands = (): CommandItem[] => [ }, aliases: ["hr", "divider", "line"], }, - { - title: "Resource Card", - description: "Insert a learning resource card", - icon: "📚", - command: ({ editor, range }) => { - editor - .chain() - .focus() - .deleteRange(range) - .insertContent("") - .run() - - // Insert after a brief delay to ensure the deletion completes - setTimeout(() => { - editor - .chain() - .focus() - .insertContent({ - type: "resourceCard", - attrs: { resourceId: "14731" }, - }) - .run() - }, 0) - }, - aliases: ["resource", "card"], - }, - { - title: "Ask TIM Button", - description: "Insert Ask TIM AI button", - icon: "🤖", - command: ({ editor, range }) => { - editor - .chain() - .focus() - .deleteRange(range) - .insertContent("") - .run() - - // Insert after a brief delay to ensure the deletion completes - setTimeout(() => { - editor - .chain() - .focus() - .insertContent({ - type: "askTimDrawerButton", - }) - .run() - }, 0) - }, - aliases: ["asktim", "ai", "tim"], - }, ] export const getSuggestionItems = (query: string): CommandItem[] => { @@ -276,7 +284,14 @@ export const renderSlashCommands = () => { return true } - return component?.ref?.onKeyDown(props) || false + // Make TypeScript happy: check that onKeyDown exists and is a function before calling + if ( + component?.ref && + typeof (component.ref as any).onKeyDown === "function" + ) { + return (component.ref as any).onKeyDown(props) + } + return false }, onExit() { @@ -288,4 +303,3 @@ export const renderSlashCommands = () => { }, } } - diff --git a/frontends/main/src/components/tiptap-node/slash-commands/slash-commands-list.tsx b/frontends/main/src/components/tiptap-node/slash-commands/slash-commands-list.tsx index cc0f4390f6..b9a944dcd7 100644 --- a/frontends/main/src/components/tiptap-node/slash-commands/slash-commands-list.tsx +++ b/frontends/main/src/components/tiptap-node/slash-commands/slash-commands-list.tsx @@ -49,8 +49,6 @@ const StyledCommandItem = styled.div<{ $isSelected: boolean }>( const CommandIcon = styled.div({ fontSize: "18px", - // eslint-disable-next-line ol-kit/no-manual-font-weight - fontWeight: 600, color: "#666", minWidth: "24px", textAlign: "center", @@ -63,8 +61,6 @@ const CommandContent = styled.div({ const CommandTitle = styled.div({ fontSize: "14px", - // eslint-disable-next-line ol-kit/no-manual-font-weight - fontWeight: 500, color: "#333", }) diff --git a/frontends/main/src/components/tiptap-templates/simple/data/content.json b/frontends/main/src/components/tiptap-templates/simple/data/content.json index 6e025f18be..085d370852 100644 --- a/frontends/main/src/components/tiptap-templates/simple/data/content.json +++ b/frontends/main/src/components/tiptap-templates/simple/data/content.json @@ -10,472 +10,48 @@ "content": [ { "type": "text", - "text": "Getting started" + "text": "New Article Page" } ] }, { - "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": "Type / to open the commands menu." - } - ] - }, - { - "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 - }, + "type": "description", "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. 🪄" - } - ] + "text": "This article must contain a title and a description paragraph, followed by a list of Learning Resource list cards." } ] }, { - "type": "paragraph", + "type": "resourceListCard", "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": "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": "." - } - ] - } - ] - } - ] + "resourceId": 14731 + } }, { - "type": "paragraph", + "type": "resourceListCard", "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" + "resourceId": 14837 + } }, { - "type": "heading", + "type": "resourceListCard", "attrs": { - "textAlign": "left", - "level": 2 - }, - "content": [ - { - "type": "text", - "text": "Make it your own" - } - ] + "resourceId": 17235 + } }, { "type": "paragraph", "attrs": { - "textAlign": "left" + "textAlign": null }, "content": [ { "type": "text", - "text": "Switch between light and dark modes, and tailor the editor's appearance with customizable CSS to match your style." + "text": "Type / to open the commands menu. Any content can be added below except headings." } ] - }, - { - "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/main/src/components/tiptap-templates/simple/simple-editor.scss b/frontends/main/src/components/tiptap-templates/simple/simple-editor.scss index 8faf836440..3fb9d84c64 100644 --- a/frontends/main/src/components/tiptap-templates/simple/simple-editor.scss +++ b/frontends/main/src/components/tiptap-templates/simple/simple-editor.scss @@ -61,7 +61,7 @@ body, } .simple-editor-content { - max-width: 648px; + max-width: 800px; width: 100%; margin: 0 auto; height: 100%; diff --git a/frontends/main/src/components/tiptap-templates/simple/simple-editor.tsx b/frontends/main/src/components/tiptap-templates/simple/simple-editor.tsx index 38511e93ec..8d7901a24e 100644 --- a/frontends/main/src/components/tiptap-templates/simple/simple-editor.tsx +++ b/frontends/main/src/components/tiptap-templates/simple/simple-editor.tsx @@ -33,6 +33,7 @@ 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" +import "@/components/tiptap-node/description-node/description-node.scss" // --- Tiptap UI --- import { HeadingDropdownMenu } from "@/components/tiptap-ui/heading-dropdown-menu" @@ -153,6 +154,18 @@ const MainToolbarContent = ({ > + Resource Card + { + editor.commands.insertContent({ + type: "resourceListCard", + attrs: { resourceId: "14731" }, + }) + }} + > + + Resource List Card + Date: Thu, 6 Nov 2025 17:32:13 +0000 Subject: [PATCH 8/8] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../main/src/app-pages/ArticlePage/DescriptionExtension.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/frontends/main/src/app-pages/ArticlePage/DescriptionExtension.tsx b/frontends/main/src/app-pages/ArticlePage/DescriptionExtension.tsx index 11d86d53ef..0ceb3082b2 100644 --- a/frontends/main/src/app-pages/ArticlePage/DescriptionExtension.tsx +++ b/frontends/main/src/app-pages/ArticlePage/DescriptionExtension.tsx @@ -40,4 +40,3 @@ export const Description = Node.create({ } }, }) -