diff --git a/RELEASE.rst b/RELEASE.rst
index 304cfb70a5..96d0da349b 100644
--- a/RELEASE.rst
+++ b/RELEASE.rst
@@ -1,6 +1,16 @@
Release Notes
=============
+Version 0.47.12
+---------------
+
+- chore(deps): update dependency bpython to ^0.26 (#2668)
+- Initial Tiptap Editor (#2691)
+- fix search error with staleness and incompleteness (#2688)
+- log error if a shard has a failure (#2690)
+- feat: Implement the Article CRUD except listing (#2686)
+- embeddings healthcheck (#2676)
+
Version 0.47.11 (Released November 10, 2025)
---------------
diff --git a/articles/migrations/0003_remove_article_html_article_content.py b/articles/migrations/0003_remove_article_html_article_content.py
new file mode 100644
index 0000000000..54860c8f1e
--- /dev/null
+++ b/articles/migrations/0003_remove_article_html_article_content.py
@@ -0,0 +1,21 @@
+# Generated by Django 4.2.25 on 2025-11-07 11:53
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("articles", "0002_alter_article_created_on"),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name="article",
+ name="html",
+ ),
+ migrations.AddField(
+ model_name="article",
+ name="content",
+ field=models.JSONField(default={}),
+ ),
+ ]
diff --git a/articles/models.py b/articles/models.py
index 8534a7c898..abbdba7d6f 100644
--- a/articles/models.py
+++ b/articles/models.py
@@ -10,5 +10,5 @@ class Article(TimestampedModel):
Stores rich-text content created by staff members.
"""
- html = models.TextField()
+ content = models.JSONField(default={})
title = models.CharField(max_length=255)
diff --git a/articles/serializers.py b/articles/serializers.py
index 1adf4f0aba..464faf039d 100644
--- a/articles/serializers.py
+++ b/articles/serializers.py
@@ -20,9 +20,9 @@ class RichTextArticleSerializer(serializers.ModelSerializer):
Serializer for LearningResourceInstructor model
"""
- html = SanitizedHtmlField()
+ content = serializers.JSONField(default={})
title = serializers.CharField(max_length=255)
class Meta:
model = models.Article
- fields = ["html", "id", "title"]
+ fields = ["content", "id", "title"]
diff --git a/articles/validators.py b/articles/validators.py
index 131f7d5e03..aa7997f340 100644
--- a/articles/validators.py
+++ b/articles/validators.py
@@ -32,10 +32,12 @@
# - On all tags: https://docs.rs/ammonia/latest/ammonia/struct.Builder.html#method.generic_attributes
"attributes": {
"a": {"href", "hreflang"},
- "img": {"alt", "height", "src", "width", "srcset", "sizes"},
- "figure": {"class"},
+ "img": {"alt", "height", "src", "width", "srcset", "sizes", "style"},
+ "figure": {"class", "style"},
"oembed": {"url"},
},
+ # 👇 Allow data: URLs for src attributes
+ "url_schemes": {"data"},
}
diff --git a/articles/views_test.py b/articles/views_test.py
index e6bc5b63cd..748eda8a5e 100644
--- a/articles/views_test.py
+++ b/articles/views_test.py
@@ -13,12 +13,12 @@ def test_article_creation(staff_client, user):
url = reverse("articles:v1:articles-list")
data = {
- "html": "
",
+ "content": {},
"title": "Some title",
}
resp = staff_client.post(url, data)
json = resp.json()
- assert json["html"] == ""
+ assert json["content"] == {}
assert json["title"] == "Some title"
diff --git a/docker-compose.opensearch.base.yml b/docker-compose.opensearch.base.yml
index 0b4c59d0d0..0c8a71727d 100644
--- a/docker-compose.opensearch.base.yml
+++ b/docker-compose.opensearch.base.yml
@@ -1,6 +1,6 @@
services:
opensearch:
- image: opensearchproject/opensearch:2.19.3
+ image: opensearchproject/opensearch:3.1.0
environment:
- "cluster.name=opensearch-cluster"
- "bootstrap.memory_lock=true" # along with the memlock settings below, disables swapping
diff --git a/frontends/.eslintrc.js b/frontends/.eslintrc.js
index 074b4b193e..30fb736252 100644
--- a/frontends/.eslintrc.js
+++ b/frontends/.eslintrc.js
@@ -14,6 +14,10 @@ module.exports = {
"mit-learn",
"github-pages",
"storybook-static",
+ "**/TiptapEditor/components/**/*.tsx",
+ "**/TiptapEditor/components/**/*.ts",
+ "**/TiptapEditor/hooks/**/*.ts",
+ "**/TiptapEditor/lib/**/*.ts",
],
settings: {
"import/resolver": {
diff --git a/frontends/.stylelintrc.yaml b/frontends/.stylelintrc.yaml
index 947b90026b..de27ffb702 100644
--- a/frontends/.stylelintrc.yaml
+++ b/frontends/.stylelintrc.yaml
@@ -6,6 +6,8 @@ rules:
- message: "Expected class selector to be kebab-case"
ignoreFiles:
- "**/*.vendor.css"
+ - "**/TiptapEditor/components/**/*.scss"
+ - "**/TiptapEditor/styles/**/*.scss"
overrides:
- files:
- "**/*.scss"
diff --git a/frontends/api/src/generated/v1/api.ts b/frontends/api/src/generated/v1/api.ts
index ce9ce600b1..a19401073e 100644
--- a/frontends/api/src/generated/v1/api.ts
+++ b/frontends/api/src/generated/v1/api.ts
@@ -4772,10 +4772,10 @@ export interface PatchedLearningResourceRelationshipRequest {
export interface PatchedRichTextArticleRequest {
/**
*
- * @type {string}
+ * @type {any}
* @memberof PatchedRichTextArticleRequest
*/
- html?: string
+ content?: any
/**
*
* @type {string}
@@ -7157,10 +7157,10 @@ export type ResourceTypeEnum =
export interface RichTextArticle {
/**
*
- * @type {string}
+ * @type {any}
* @memberof RichTextArticle
*/
- html: string
+ content?: any
/**
*
* @type {number}
@@ -7182,10 +7182,10 @@ export interface RichTextArticle {
export interface RichTextArticleRequest {
/**
*
- * @type {string}
+ * @type {any}
* @memberof RichTextArticleRequest
*/
- html: string
+ content?: any
/**
*
* @type {string}
diff --git a/frontends/api/src/test-utils/factories/articles.ts b/frontends/api/src/test-utils/factories/articles.ts
index 46afe999ae..7abaeadeaf 100644
--- a/frontends/api/src/test-utils/factories/articles.ts
+++ b/frontends/api/src/test-utils/factories/articles.ts
@@ -6,7 +6,10 @@ import type { RichTextArticle } from "../../generated/v1"
const article: Factory = (overrides = {}) => ({
id: faker.number.int(),
title: faker.lorem.sentence(),
- html: faker.lorem.paragraph(),
+ content: {
+ text: faker.lorem.paragraph(),
+ author: faker.person.fullName(),
+ },
...overrides,
})
diff --git a/frontends/main/jest.config.ts b/frontends/main/jest.config.ts
index b523048ae7..0c1424ee75 100644
--- a/frontends/main/jest.config.ts
+++ b/frontends/main/jest.config.ts
@@ -7,7 +7,9 @@ const config: Config.InitialOptions = {
...baseConfig.setupFilesAfterEnv,
"./test-utils/setupJest.tsx",
],
- transformIgnorePatterns: ["node_modules/(?!@faker-js).+"],
+ transformIgnorePatterns: [
+ "node_modules/(?!(@faker-js|react-hotkeys-hook)).+",
+ ],
moduleNameMapper: {
...baseConfig.moduleNameMapper,
"^@/(.*)$": path.resolve(__dirname, "src/$1"),
diff --git a/frontends/main/next.config.js b/frontends/main/next.config.js
index dac2a8467d..240d63e645 100644
--- a/frontends/main/next.config.js
+++ b/frontends/main/next.config.js
@@ -128,7 +128,7 @@ const nextConfig = {
allowCollectingMemory: true,
})
}
- // Important: return the modified config
+
return config
},
}
diff --git a/frontends/main/src/app-pages/ArticlePage/NewArticlePage.tsx b/frontends/main/src/app-pages/ArticlePage/NewArticlePage.tsx
new file mode 100644
index 0000000000..6832e53b57
--- /dev/null
+++ b/frontends/main/src/app-pages/ArticlePage/NewArticlePage.tsx
@@ -0,0 +1,36 @@
+"use client"
+
+import React from "react"
+import { TiptapEditor, theme, styled, HEADER_HEIGHT } from "ol-components"
+import { Permission } from "api/hooks/user"
+import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute"
+
+const PageContainer = styled.div({
+ color: theme.custom.colors.darkGray2,
+ display: "flex",
+ height: `calc(100vh - ${HEADER_HEIGHT}px - 132px)`,
+})
+
+const EditorContainer = styled.div({
+ minHeight: 0,
+})
+
+const StyledTiptapEditor = styled(TiptapEditor)({
+ width: "70vw",
+ height: `calc(100% - ${HEADER_HEIGHT}px - 132px)`,
+ overscrollBehavior: "contain",
+})
+
+const NewArticlePage: React.FC = () => {
+ return (
+
+
+
+
+
+
+
+ )
+}
+
+export { NewArticlePage }
diff --git a/frontends/main/src/app-pages/Articles/ArticleDetailPage.tsx b/frontends/main/src/app-pages/Articles/ArticleDetailPage.tsx
new file mode 100644
index 0000000000..e8f73d53d8
--- /dev/null
+++ b/frontends/main/src/app-pages/Articles/ArticleDetailPage.tsx
@@ -0,0 +1,65 @@
+"use client"
+
+import React from "react"
+import { useArticleDetail } from "api/hooks/articles"
+import { Container, LoadingSpinner, styled, Typography } from "ol-components"
+import { ButtonLink } from "@mitodl/smoot-design"
+import { notFound } from "next/navigation"
+import { Permission } from "api/hooks/user"
+import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute"
+import { articlesEditView } from "@/common/urls"
+
+const Page = styled(Container)({
+ marginTop: "40px",
+ marginBottom: "40px",
+})
+
+const ControlsContainer = styled.div({
+ display: "flex",
+ justifyContent: "flex-end",
+ margin: "10px",
+})
+const WrapperContainer = styled.div({
+ borderBottom: "1px solid rgb(222, 208, 208)",
+ paddingBottom: "10px",
+})
+
+const PreTag = styled.pre({
+ background: "#f6f6f6",
+ padding: "16px",
+ borderRadius: "8px",
+ fontSize: "14px",
+ overflowX: "auto",
+})
+
+export const ArticleDetailPage = ({ articleId }: { articleId: number }) => {
+ const id = Number(articleId)
+ const { data, isLoading } = useArticleDetail(id)
+
+ const editUrl = articlesEditView(id)
+
+ if (isLoading) {
+ return
+ }
+ if (!data) {
+ return notFound()
+ }
+ return (
+
+
+
+
+ {data?.title}
+
+
+
+
+ Edit
+
+
+
+ {JSON.stringify(data.content, null, 2)}
+
+
+ )
+}
diff --git a/frontends/main/src/app-pages/Articles/ArticleEditPage.test.tsx b/frontends/main/src/app-pages/Articles/ArticleEditPage.test.tsx
new file mode 100644
index 0000000000..2f1bf9b013
--- /dev/null
+++ b/frontends/main/src/app-pages/Articles/ArticleEditPage.test.tsx
@@ -0,0 +1,100 @@
+import React from "react"
+import { screen, renderWithProviders, setMockResponse } from "@/test-utils"
+import { waitFor, fireEvent } from "@testing-library/react"
+import userEvent from "@testing-library/user-event"
+import { factories, urls } from "api/test-utils"
+import { ArticleEditPage } from "./ArticleEditPage"
+
+const pushMock = jest.fn()
+
+jest.mock("next-nprogress-bar", () => ({
+ useRouter: () => ({
+ push: pushMock,
+ }),
+}))
+
+describe("ArticleEditPage", () => {
+ test("renders editor when user has ArticleEditor permission", async () => {
+ const user = factories.user.user({
+ is_authenticated: true,
+ is_article_editor: true,
+ })
+ setMockResponse.get(urls.userMe.get(), user)
+
+ const article = factories.articles.article({
+ id: 42,
+ title: "Existing Title",
+ content: { id: 1, content: "Existing content" },
+ })
+ setMockResponse.get(urls.articles.details(article.id), article)
+
+ renderWithProviders()
+
+ expect(await screen.findByText("Edit Article")).toBeInTheDocument()
+ expect(screen.getByTestId("editor")).toBeInTheDocument()
+ expect(screen.getByDisplayValue("Existing Title")).toBeInTheDocument()
+ })
+
+ test("submits article successfully and redirects", async () => {
+ const user = factories.user.user({
+ is_authenticated: true,
+ is_article_editor: true,
+ })
+ setMockResponse.get(urls.userMe.get(), user)
+
+ const article = factories.articles.article({
+ id: 123,
+ title: "Existing Title",
+ content: { id: 1, content: "Existing content" },
+ })
+ setMockResponse.get(urls.articles.details(article.id), article)
+
+ // ✅ Mock successful update response
+ const updated = { ...article, title: "Updated Title" }
+ setMockResponse.patch(urls.articles.details(article.id), updated)
+
+ renderWithProviders()
+
+ const titleInput = await screen.findByPlaceholderText("Enter article title")
+
+ fireEvent.change(titleInput, { target: { value: "Updated Title" } })
+
+ await waitFor(() => expect(titleInput).toHaveValue("Updated Title"))
+
+ await userEvent.click(screen.getByText(/save article/i))
+
+ // ✅ Wait for redirect after update success
+ await waitFor(() => expect(pushMock).toHaveBeenCalledWith("/articles/123"))
+ })
+
+ test("shows error alert on failure", async () => {
+ const user = factories.user.user({
+ is_authenticated: true,
+ is_article_editor: true,
+ })
+ setMockResponse.get(urls.userMe.get(), user)
+
+ const article = factories.articles.article({
+ id: 7,
+ title: "Old Title",
+ content: { id: 1, content: "Bad content" },
+ })
+ setMockResponse.get(urls.articles.details(article.id), article)
+
+ // ✅ Mock failed update (500)
+ setMockResponse.patch(
+ urls.articles.details(article.id),
+ { detail: "Server Error" },
+ { code: 500 },
+ )
+
+ renderWithProviders()
+
+ const titleInput = await screen.findByPlaceholderText("Enter article title")
+ fireEvent.change(titleInput, { target: { value: "Bad Article" } })
+
+ await userEvent.click(screen.getByText(/save article/i))
+
+ expect(await screen.findByText(/Mock Error/i)).toBeInTheDocument()
+ })
+})
diff --git a/frontends/main/src/app-pages/Articles/ArticleEditPage.tsx b/frontends/main/src/app-pages/Articles/ArticleEditPage.tsx
new file mode 100644
index 0000000000..dc4c8a5a35
--- /dev/null
+++ b/frontends/main/src/app-pages/Articles/ArticleEditPage.tsx
@@ -0,0 +1,138 @@
+"use client"
+import React, { useEffect, useState, ChangeEvent } from "react"
+import { Permission } from "api/hooks/user"
+import { useRouter } from "next-nprogress-bar"
+import { useArticleDetail, useArticlePartialUpdate } from "api/hooks/articles"
+import { Button, Input, Alert } from "@mitodl/smoot-design"
+import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute"
+import { Container, Typography, styled, LoadingSpinner } from "ol-components"
+import { notFound } from "next/navigation"
+import { articlesView } from "@/common/urls"
+
+const SaveButton = styled.div({
+ textAlign: "right",
+ margin: "10px",
+})
+
+const ClientContainer = styled.div({
+ width: "100%",
+ margin: "10px 0",
+})
+
+const TitleInput = styled(Input)({
+ width: "100%",
+ margin: "10px 0",
+})
+
+const ArticleEditPage = ({ articleId }: { articleId: string }) => {
+ const router = useRouter()
+
+ const id = Number(articleId)
+ const { data: article, isLoading } = useArticleDetail(id)
+
+ const [title, setTitle] = useState("")
+ const [text, setText] = useState("")
+ const [json, setJson] = useState({})
+ const [alertText, setAlertText] = useState("")
+
+ const { mutate: updateArticle, isPending } = useArticlePartialUpdate()
+
+ const handleSave = () => {
+ const payload = {
+ id: id,
+ title: title.trim(),
+ content: json,
+ }
+
+ updateArticle(payload, {
+ onSuccess: (article) => {
+ router.push(articlesView(article.id))
+ },
+ onError: (error) => {
+ setAlertText(`❌ ${error.message}`)
+ },
+ })
+ }
+
+ useEffect(() => {
+ if (article && !title) {
+ setTitle(article.title)
+ setText(article.content ? JSON.stringify(article.content, null, 2) : "")
+ setJson(article.content)
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [article])
+
+ if (isLoading) {
+ return
+ }
+ if (!article) {
+ return notFound()
+ }
+
+ const handleChange = (e: ChangeEvent) => {
+ const value = e.target.value
+ setText(value)
+
+ try {
+ const parsed = JSON.parse(value)
+ setJson(parsed)
+ } catch {
+ setJson({})
+ }
+ }
+ return (
+
+
+
+ Edit Article
+
+ {alertText && (
+
+
+ {alertText}
+
+
+ )}
+ {
+ console.log("Title input changed:", e.target.value)
+ setTitle(e.target.value)
+ setAlertText("")
+ }}
+ placeholder="Enter article title"
+ className="input-field"
+ />
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export { ArticleEditPage }
diff --git a/frontends/main/src/app-pages/Articles/ArticleNewPage.tsx b/frontends/main/src/app-pages/Articles/ArticleNewPage.tsx
new file mode 100644
index 0000000000..5bf743af7e
--- /dev/null
+++ b/frontends/main/src/app-pages/Articles/ArticleNewPage.tsx
@@ -0,0 +1,122 @@
+"use client"
+import React, { useState, ChangeEvent } from "react"
+import { Permission } from "api/hooks/user"
+import { useRouter } from "next-nprogress-bar"
+import { useArticleCreate } from "api/hooks/articles"
+import { Button, Input, Alert } from "@mitodl/smoot-design"
+import RestrictedRoute from "@/components/RestrictedRoute/RestrictedRoute"
+import { Container, Typography, styled } from "ol-components"
+import { articlesView } from "@/common/urls"
+
+const SaveButton = styled.div({
+ textAlign: "right",
+ margin: "10px",
+})
+
+const ClientContainer = styled.div({
+ width: "100%",
+ margin: "10px 0",
+})
+
+const TitleInput = styled(Input)({
+ width: "100%",
+ margin: "10px 0",
+})
+
+const ArticleNewPage: React.FC = () => {
+ const router = useRouter()
+
+ const [title, setTitle] = React.useState("")
+ const [text, setText] = useState("")
+ const [json, setJson] = useState({})
+ const [alertText, setAlertText] = React.useState("")
+
+ const { mutate: createArticle, isPending } = useArticleCreate()
+
+ const handleSave = () => {
+ setAlertText("")
+
+ const payload = {
+ title: title.trim(),
+ content: json,
+ }
+
+ createArticle(
+ payload as {
+ content: object
+ title: string
+ },
+ {
+ onSuccess: (article) => {
+ articlesView(article.id)
+ router.push(articlesView(article.id))
+ },
+ onError: (error) => {
+ setAlertText(`❌ ${error?.message}`)
+ },
+ },
+ )
+ }
+ const handleChange = (e: ChangeEvent) => {
+ const value = e.target.value
+ setText(value)
+
+ try {
+ const parsed = JSON.parse(value)
+ setJson(parsed)
+ } catch {
+ setJson({})
+ }
+ }
+ return (
+
+
+ Write Article
+ {alertText && (
+
+
+ {alertText}
+
+
+ )}
+ {
+ setTitle(e.target.value)
+ setAlertText("")
+ }}
+ placeholder="Enter article title"
+ className="input-field"
+ />
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export { ArticleNewPage }
diff --git a/frontends/main/src/app-pages/Articles/NewArticlePage.test.tsx b/frontends/main/src/app-pages/Articles/NewArticlePage.test.tsx
new file mode 100644
index 0000000000..f60b6d7cf7
--- /dev/null
+++ b/frontends/main/src/app-pages/Articles/NewArticlePage.test.tsx
@@ -0,0 +1,109 @@
+import React from "react"
+import {
+ screen,
+ renderWithProviders,
+ setMockResponse,
+ TestingErrorBoundary,
+} from "@/test-utils"
+import { waitFor, fireEvent } from "@testing-library/react"
+import userEvent from "@testing-library/user-event"
+import { factories, urls } from "api/test-utils"
+import { ArticleNewPage } from "./ArticleNewPage"
+
+const pushMock = jest.fn()
+jest.mock("next/navigation", () => ({
+ useRouter: () => ({
+ push: pushMock,
+ }),
+}))
+
+describe("ArticleNewPage", () => {
+ test("throws ForbiddenError when user lacks ArticleEditor permission", async () => {
+ const user = factories.user.user({
+ is_authenticated: true,
+ is_article_editor: false,
+ })
+ setMockResponse.get(urls.userMe.get(), user)
+
+ const consoleSpy = jest.spyOn(console, "error").mockImplementation(() => {})
+ const onError = jest.fn()
+
+ renderWithProviders(
+
+
+ ,
+ )
+
+ await waitFor(() => {
+ expect(onError).toHaveBeenCalled()
+ })
+
+ consoleSpy.mockRestore()
+ })
+
+ test("renders editor when user has ArticleEditor permission", async () => {
+ const user = factories.user.user({
+ is_authenticated: true,
+ is_article_editor: true,
+ })
+ setMockResponse.get(urls.userMe.get(), user)
+
+ renderWithProviders()
+
+ expect(await screen.findByText("Write Article")).toBeInTheDocument()
+ expect(screen.getByTestId("editor")).toBeInTheDocument()
+ })
+
+ test("submits article successfully", async () => {
+ const user = factories.user.user({
+ is_authenticated: true,
+ is_article_editor: true,
+ })
+ setMockResponse.get(urls.userMe.get(), user)
+
+ // Mock article creation API
+ const createdArticle = factories.articles.article({ id: 101 })
+ setMockResponse.post(urls.articles.list(), createdArticle)
+
+ renderWithProviders()
+
+ await screen.findByTestId("editor")
+
+ const titleInput = await screen.findByPlaceholderText("Enter article title")
+ fireEvent.change(titleInput, { target: { value: "My Article" } })
+ await waitFor(() => expect(titleInput).toHaveValue("My Article"))
+
+ await userEvent.click(screen.getByText(/save article/i))
+
+ await waitFor(() =>
+ expect(pushMock).toHaveBeenCalledWith("/articles/101", undefined),
+ )
+ })
+
+ test("shows error on failure", async () => {
+ const user = factories.user.user({
+ is_authenticated: true,
+ is_article_editor: true,
+ })
+ setMockResponse.get(urls.userMe.get(), user)
+
+ // Simulate failed API request (500)
+ setMockResponse.post(
+ urls.articles.list(),
+ { detail: "Server error" },
+ { code: 500 },
+ )
+
+ renderWithProviders()
+
+ await screen.findByPlaceholderText("Enter article title")
+
+ fireEvent.change(screen.getByPlaceholderText("Enter article title"), {
+ target: { value: "My Article" },
+ })
+ await userEvent.click(screen.getByTestId("editor"))
+ await userEvent.click(screen.getByText("Save Article"))
+
+ expect(await screen.findByText(/Mock Error/)).toBeInTheDocument()
+ })
+})
diff --git a/frontends/main/src/app/article/new/page.tsx b/frontends/main/src/app/article/new/page.tsx
new file mode 100644
index 0000000000..d0694149d1
--- /dev/null
+++ b/frontends/main/src/app/article/new/page.tsx
@@ -0,0 +1,15 @@
+import React from "react"
+import { Metadata } from "next"
+import { standardizeMetadata } from "@/common/metadata"
+import { NewArticlePage } from "@/app-pages/ArticlePage/NewArticlePage"
+
+export const metadata: Metadata = standardizeMetadata({
+ title: "New Article",
+ robots: "noindex, nofollow",
+})
+
+const Page: React.FC> = () => {
+ return
+}
+
+export default Page
diff --git a/frontends/main/src/app/articles/[id]/edit/page.tsx b/frontends/main/src/app/articles/[id]/edit/page.tsx
new file mode 100644
index 0000000000..03149399f2
--- /dev/null
+++ b/frontends/main/src/app/articles/[id]/edit/page.tsx
@@ -0,0 +1,9 @@
+import React from "react"
+import { ArticleEditPage } from "@/app-pages/Articles/ArticleEditPage"
+
+const Page: React.FC> = async (props) => {
+ const params = await props.params
+
+ return
+}
+export default Page
diff --git a/frontends/main/src/app/articles/[id]/page.tsx b/frontends/main/src/app/articles/[id]/page.tsx
new file mode 100644
index 0000000000..f550dee6ad
--- /dev/null
+++ b/frontends/main/src/app/articles/[id]/page.tsx
@@ -0,0 +1,15 @@
+import React from "react"
+import { Metadata } from "next"
+import { standardizeMetadata } from "@/common/metadata"
+import { ArticleDetailPage } from "@/app-pages/Articles/ArticleDetailPage"
+
+export const metadata: Metadata = standardizeMetadata({
+ title: "Article Detail",
+})
+
+const Page: React.FC> = async (props) => {
+ const params = await props.params
+
+ return
+}
+export default Page
diff --git a/frontends/main/src/app/articles/new/page.tsx b/frontends/main/src/app/articles/new/page.tsx
new file mode 100644
index 0000000000..52acf8ab88
--- /dev/null
+++ b/frontends/main/src/app/articles/new/page.tsx
@@ -0,0 +1,15 @@
+import React from "react"
+import { Metadata } from "next"
+import { standardizeMetadata } from "@/common/metadata"
+import { ArticleNewPage } from "@/app-pages/Articles/ArticleNewPage"
+
+export const metadata: Metadata = standardizeMetadata({
+ title: "New Article",
+ robots: "noindex, nofollow",
+})
+
+const Page: React.FC> = () => {
+ return
+}
+
+export default Page
diff --git a/frontends/ol-components/package.json b/frontends/ol-components/package.json
index b075dae491..93e48f0b0f 100644
--- a/frontends/ol-components/package.json
+++ b/frontends/ol-components/package.json
@@ -19,13 +19,28 @@
"@dnd-kit/utilities": "^3.2.1",
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
+ "@floating-ui/react": "^0.27.16",
"@mui/base": "5.0.0-beta.70",
"@mui/lab": "6.0.0-dev.240424162023-9968b4889d",
"@mui/material": "^6.4.5",
"@mui/material-nextjs": "^6.4.3",
"@mui/system": "^6.4.3",
+ "@radix-ui/react-dropdown-menu": "^2.1.16",
+ "@radix-ui/react-popover": "^1.1.15",
"@remixicon/react": "^4.2.0",
"@testing-library/dom": "^10.4.0",
+ "@tiptap/extension-highlight": "^3.10.5",
+ "@tiptap/extension-horizontal-rule": "^3.10.5",
+ "@tiptap/extension-image": "^3.10.5",
+ "@tiptap/extension-list": "^3.10.5",
+ "@tiptap/extension-subscript": "^3.10.5",
+ "@tiptap/extension-superscript": "^3.10.5",
+ "@tiptap/extension-text-align": "^3.10.5",
+ "@tiptap/extension-typography": "^3.10.5",
+ "@tiptap/extensions": "^3.10.5",
+ "@tiptap/pm": "^3.10.5",
+ "@tiptap/react": "^3.10.5",
+ "@tiptap/starter-kit": "^3.10.5",
"@types/react-dom": "^19",
"@types/tinycolor2": "^1.4.6",
"api": "workspace:*",
@@ -33,12 +48,14 @@
"embla-carousel-react": "^8.6.0",
"embla-carousel-wheel-gestures": "^8.0.2",
"lodash": "^4.17.21",
+ "lodash.throttle": "^4.1.1",
"material-ui-popup-state": "^5.1.0",
"next": "^15.5.2",
"ol-test-utilities": "0.0.0",
"ol-utilities": "0.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
+ "react-hotkeys-hook": "^5.2.1",
"react-select": "^5.7.7",
"react-share": "^5.0.3",
"react-slick": "^0.30.2",
@@ -65,10 +82,13 @@
"@storybook/types": "^8.2.9",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.5.2",
+ "@types/lodash.throttle": "^4.1.9",
"@types/react-slick": "^0",
"dotenv": "^17.0.0",
"lodash": "^4.17.21",
"prop-types": "^15.8.1",
+ "sass": "^1.93.3",
+ "sass-embedded": "^1.93.3",
"storybook": "^8.2.9",
"typescript": "^5.5.4",
"webpack": "^5.94.0"
diff --git a/frontends/ol-components/src/components/TiptapEditor/TiptapEditor.tsx b/frontends/ol-components/src/components/TiptapEditor/TiptapEditor.tsx
new file mode 100644
index 0000000000..a45ee5d91d
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/TiptapEditor.tsx
@@ -0,0 +1,174 @@
+"use client"
+
+// Based on ./components/tiptap-templates/simple/simple-editor.tsx
+
+import React, { useRef } from "react"
+import { EditorContent, EditorContext, useEditor } from "@tiptap/react"
+
+// --- Tiptap Core Extensions ---
+import { StarterKit } from "@tiptap/starter-kit"
+import { TaskItem, TaskList } from "@tiptap/extension-list"
+import { TextAlign } from "@tiptap/extension-text-align"
+import { Typography } from "@tiptap/extension-typography"
+import { Highlight } from "@tiptap/extension-highlight"
+import { Subscript } from "@tiptap/extension-subscript"
+import { Superscript } from "@tiptap/extension-superscript"
+import { Selection } from "@tiptap/extensions"
+
+// --- UI Primitives ---
+import { Spacer } from "./components/tiptap-ui-primitive/spacer"
+import {
+ Toolbar,
+ ToolbarGroup,
+ ToolbarSeparator,
+} from "./components/tiptap-ui-primitive/toolbar"
+
+// --- Tiptap Node ---
+import { ImageUploadNode } from "./components/tiptap-node/image-upload-node/image-upload-node-extension"
+import { HorizontalRule } from "./components/tiptap-node/horizontal-rule-node/horizontal-rule-node-extension"
+import "./components/tiptap-node/blockquote-node/blockquote-node.scss"
+import "./components/tiptap-node/code-block-node/code-block-node.scss"
+import "./components/tiptap-node/horizontal-rule-node/horizontal-rule-node.scss"
+import "./components/tiptap-node/list-node/list-node.scss"
+import "./components/tiptap-node/image-node/image-node.scss"
+import "./components/tiptap-node/heading-node/heading-node.scss"
+import "./components/tiptap-node/paragraph-node/paragraph-node.scss"
+
+// --- Tiptap UI ---
+import { HeadingDropdownMenu } from "./components/tiptap-ui/heading-dropdown-menu"
+import { ListDropdownMenu } from "./components/tiptap-ui/list-dropdown-menu"
+import { BlockquoteButton } from "./components/tiptap-ui/blockquote-button"
+import { CodeBlockButton } from "./components/tiptap-ui/code-block-button"
+import { ColorHighlightPopover } from "./components/tiptap-ui/color-highlight-popover"
+import { LinkPopover } from "./components/tiptap-ui/link-popover"
+import { MarkButton } from "./components/tiptap-ui/mark-button"
+import { TextAlignButton } from "./components/tiptap-ui/text-align-button"
+import { UndoRedoButton } from "./components/tiptap-ui/undo-redo-button"
+
+// --- Lib ---
+import { handleImageUpload, MAX_FILE_SIZE } from "./lib/tiptap-utils"
+
+// --- Styles ---
+import "./styles/_keyframe-animations.scss"
+import "./styles/_variables.scss"
+import "./components/tiptap-templates/simple/simple-editor.scss"
+
+const MainToolbarContent = () => {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )
+}
+
+export default function SimpleEditor() {
+ const toolbarRef = useRef(null)
+
+ const editor = useEditor({
+ immediatelyRender: false,
+ shouldRerenderOnTransaction: false,
+ editorProps: {
+ attributes: {
+ autocomplete: "off",
+ autocorrect: "off",
+ autocapitalize: "off",
+ "aria-label": "Main content area, start typing to enter text.",
+ class: "simple-editor",
+ },
+ },
+ extensions: [
+ StarterKit.configure({
+ horizontalRule: false,
+ link: {
+ openOnClick: false,
+ enableClickSelection: true,
+ },
+ }),
+ HorizontalRule,
+ TextAlign.configure({ types: ["heading", "paragraph"] }),
+ TaskList,
+ TaskItem.configure({ nested: true }),
+ Highlight.configure({ multicolor: true }),
+ Typography,
+ Superscript,
+ Subscript,
+ Selection,
+ ImageUploadNode.configure({
+ accept: "image/*",
+ maxSize: MAX_FILE_SIZE,
+ limit: 3,
+ upload: handleImageUpload,
+ onError: (error) => console.error("Upload failed:", error),
+ }),
+ ],
+ content: {
+ type: "doc",
+ content: [
+ {
+ type: "paragraph",
+ content: [],
+ },
+ ],
+ },
+ })
+
+ return (
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/align-center-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/align-center-icon.tsx
new file mode 100644
index 0000000000..2b7d2931e8
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/align-center-icon.tsx
@@ -0,0 +1,38 @@
+import React, { memo } from "react"
+
+type SvgProps = React.ComponentPropsWithoutRef<"svg">
+
+export const AlignCenterIcon = memo(({ className, ...props }: SvgProps) => {
+ return (
+
+ )
+})
+
+AlignCenterIcon.displayName = "AlignCenterIcon"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/align-justify-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/align-justify-icon.tsx
new file mode 100644
index 0000000000..d41f74582d
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/align-justify-icon.tsx
@@ -0,0 +1,38 @@
+import React, { memo } from "react"
+
+type SvgProps = React.ComponentPropsWithoutRef<"svg">
+
+export const AlignJustifyIcon = memo(({ className, ...props }: SvgProps) => {
+ return (
+
+ )
+})
+
+AlignJustifyIcon.displayName = "AlignJustifyIcon"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/align-left-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/align-left-icon.tsx
new file mode 100644
index 0000000000..07cd9c88f9
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/align-left-icon.tsx
@@ -0,0 +1,38 @@
+import React, { memo } from "react"
+
+type SvgProps = React.ComponentPropsWithoutRef<"svg">
+
+export const AlignLeftIcon = memo(({ className, ...props }: SvgProps) => {
+ return (
+
+ )
+})
+
+AlignLeftIcon.displayName = "AlignLeftIcon"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/align-right-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/align-right-icon.tsx
new file mode 100644
index 0000000000..354d71aea8
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/align-right-icon.tsx
@@ -0,0 +1,38 @@
+import React, { memo } from "react"
+
+type SvgProps = React.ComponentPropsWithoutRef<"svg">
+
+export const AlignRightIcon = memo(({ className, ...props }: SvgProps) => {
+ return (
+
+ )
+})
+
+AlignRightIcon.displayName = "AlignRightIcon"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/arrow-left-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/arrow-left-icon.tsx
new file mode 100644
index 0000000000..d1325ee4e0
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/arrow-left-icon.tsx
@@ -0,0 +1,24 @@
+import React, { memo } from "react"
+
+type SvgProps = React.ComponentPropsWithoutRef<"svg">
+
+export const ArrowLeftIcon = memo(({ className, ...props }: SvgProps) => {
+ return (
+
+ )
+})
+
+ArrowLeftIcon.displayName = "ArrowLeftIcon"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/ban-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/ban-icon.tsx
new file mode 100644
index 0000000000..fb4e737d73
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/ban-icon.tsx
@@ -0,0 +1,26 @@
+import React, { memo } from "react"
+
+type SvgProps = React.ComponentPropsWithoutRef<"svg">
+
+export const BanIcon = memo(({ className, ...props }: SvgProps) => {
+ return (
+
+ )
+})
+
+BanIcon.displayName = "BanIcon"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/blockquote-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/blockquote-icon.tsx
new file mode 100644
index 0000000000..9ae07f20c1
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/blockquote-icon.tsx
@@ -0,0 +1,44 @@
+import React, { memo } from "react"
+
+type SvgProps = React.ComponentPropsWithoutRef<"svg">
+
+export const BlockquoteIcon = memo(({ className, ...props }: SvgProps) => {
+ return (
+
+ )
+})
+
+BlockquoteIcon.displayName = "BlockquoteIcon"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/bold-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/bold-icon.tsx
new file mode 100644
index 0000000000..f049c5f00b
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/bold-icon.tsx
@@ -0,0 +1,26 @@
+import React, { memo } from "react"
+
+type SvgProps = React.ComponentPropsWithoutRef<"svg">
+
+export const BoldIcon = memo(({ className, ...props }: SvgProps) => {
+ return (
+
+ )
+})
+
+BoldIcon.displayName = "BoldIcon"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/chevron-down-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/chevron-down-icon.tsx
new file mode 100644
index 0000000000..883721ce9e
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/chevron-down-icon.tsx
@@ -0,0 +1,26 @@
+import React, { memo } from "react"
+
+type SvgProps = React.ComponentPropsWithoutRef<"svg">
+
+export const ChevronDownIcon = memo(({ className, ...props }: SvgProps) => {
+ return (
+
+ )
+})
+
+ChevronDownIcon.displayName = "ChevronDownIcon"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/close-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/close-icon.tsx
new file mode 100644
index 0000000000..a5a273e47f
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/close-icon.tsx
@@ -0,0 +1,24 @@
+import React, { memo } from "react"
+
+type SvgProps = React.ComponentPropsWithoutRef<"svg">
+
+export const CloseIcon = memo(({ className, ...props }: SvgProps) => {
+ return (
+
+ )
+})
+
+CloseIcon.displayName = "CloseIcon"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/code-block-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/code-block-icon.tsx
new file mode 100644
index 0000000000..7ef6a7d5d7
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/code-block-icon.tsx
@@ -0,0 +1,38 @@
+import React, { memo } from "react"
+
+type SvgProps = React.ComponentPropsWithoutRef<"svg">
+
+export const CodeBlockIcon = memo(({ className, ...props }: SvgProps) => {
+ return (
+
+ )
+})
+
+CodeBlockIcon.displayName = "CodeBlockIcon"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/code2-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/code2-icon.tsx
new file mode 100644
index 0000000000..f535bf84bb
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/code2-icon.tsx
@@ -0,0 +1,32 @@
+import React, { memo } from "react"
+
+type SvgProps = React.ComponentPropsWithoutRef<"svg">
+
+export const Code2Icon = memo(({ className, ...props }: SvgProps) => {
+ return (
+
+ )
+})
+
+Code2Icon.displayName = "Code2Icon"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/corner-down-left-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/corner-down-left-icon.tsx
new file mode 100644
index 0000000000..e1bd49dc4c
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/corner-down-left-icon.tsx
@@ -0,0 +1,26 @@
+import React, { memo } from "react"
+
+type SvgProps = React.ComponentPropsWithoutRef<"svg">
+
+export const CornerDownLeftIcon = memo(({ className, ...props }: SvgProps) => {
+ return (
+
+ )
+})
+
+CornerDownLeftIcon.displayName = "CornerDownLeftIcon"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/external-link-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/external-link-icon.tsx
new file mode 100644
index 0000000000..8aee572d59
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/external-link-icon.tsx
@@ -0,0 +1,28 @@
+import React, { memo } from "react"
+
+type SvgProps = React.ComponentPropsWithoutRef<"svg">
+
+export const ExternalLinkIcon = memo(({ className, ...props }: SvgProps) => {
+ return (
+
+ )
+})
+
+ExternalLinkIcon.displayName = "ExternalLinkIcon"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/heading-five-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/heading-five-icon.tsx
new file mode 100644
index 0000000000..f9a19927f4
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/heading-five-icon.tsx
@@ -0,0 +1,28 @@
+import React, { memo } from "react"
+
+type SvgProps = React.ComponentPropsWithoutRef<"svg">
+
+export const HeadingFiveIcon = memo(({ className, ...props }: SvgProps) => {
+ return (
+
+ )
+})
+
+HeadingFiveIcon.displayName = "HeadingFiveIcon"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/heading-four-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/heading-four-icon.tsx
new file mode 100644
index 0000000000..1f34db6967
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/heading-four-icon.tsx
@@ -0,0 +1,28 @@
+import React, { memo } from "react"
+
+type SvgProps = React.ComponentPropsWithoutRef<"svg">
+
+export const HeadingFourIcon = memo(({ className, ...props }: SvgProps) => {
+ return (
+
+ )
+})
+
+HeadingFourIcon.displayName = "HeadingFourIcon"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/heading-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/heading-icon.tsx
new file mode 100644
index 0000000000..8ee871acd7
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/heading-icon.tsx
@@ -0,0 +1,24 @@
+import React, { memo } from "react"
+
+type SvgProps = React.ComponentPropsWithoutRef<"svg">
+
+export const HeadingIcon = memo(({ className, ...props }: SvgProps) => {
+ return (
+
+ )
+})
+
+HeadingIcon.displayName = "HeadingIcon"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/heading-one-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/heading-one-icon.tsx
new file mode 100644
index 0000000000..1c3d2830e1
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/heading-one-icon.tsx
@@ -0,0 +1,28 @@
+import React, { memo } from "react"
+
+type SvgProps = React.ComponentPropsWithoutRef<"svg">
+
+export const HeadingOneIcon = memo(({ className, ...props }: SvgProps) => {
+ return (
+
+ )
+})
+
+HeadingOneIcon.displayName = "HeadingOneIcon"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/heading-six-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/heading-six-icon.tsx
new file mode 100644
index 0000000000..3d72d47f85
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/heading-six-icon.tsx
@@ -0,0 +1,30 @@
+import React, { memo } from "react"
+
+type SvgProps = React.ComponentPropsWithoutRef<"svg">
+
+export const HeadingSixIcon = memo(({ className, ...props }: SvgProps) => {
+ return (
+
+ )
+})
+
+HeadingSixIcon.displayName = "HeadingSixIcon"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/heading-three-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/heading-three-icon.tsx
new file mode 100644
index 0000000000..ac5083bb76
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/heading-three-icon.tsx
@@ -0,0 +1,36 @@
+import React, { memo } from "react"
+
+type SvgProps = React.ComponentPropsWithoutRef<"svg">
+
+export const HeadingThreeIcon = memo(({ className, ...props }: SvgProps) => {
+ return (
+
+ )
+})
+
+HeadingThreeIcon.displayName = "HeadingThreeIcon"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/heading-two-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/heading-two-icon.tsx
new file mode 100644
index 0000000000..0a98dab228
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/heading-two-icon.tsx
@@ -0,0 +1,28 @@
+import React, { memo } from "react"
+
+type SvgProps = React.ComponentPropsWithoutRef<"svg">
+
+export const HeadingTwoIcon = memo(({ className, ...props }: SvgProps) => {
+ return (
+
+ )
+})
+
+HeadingTwoIcon.displayName = "HeadingTwoIcon"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/highlighter-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/highlighter-icon.tsx
new file mode 100644
index 0000000000..46e77a8c42
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/highlighter-icon.tsx
@@ -0,0 +1,26 @@
+import React, { memo } from "react"
+
+type SvgProps = React.ComponentPropsWithoutRef<"svg">
+
+export const HighlighterIcon = memo(({ className, ...props }: SvgProps) => {
+ return (
+
+ )
+})
+
+HighlighterIcon.displayName = "HighlighterIcon"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/image-plus-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/image-plus-icon.tsx
new file mode 100644
index 0000000000..774e34685d
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/image-plus-icon.tsx
@@ -0,0 +1,26 @@
+import React, { memo } from "react"
+
+type SvgProps = React.ComponentPropsWithoutRef<"svg">
+
+export const ImagePlusIcon = memo(({ className, ...props }: SvgProps) => {
+ return (
+
+ )
+})
+
+ImagePlusIcon.displayName = "ImagePlusIcon"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/italic-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/italic-icon.tsx
new file mode 100644
index 0000000000..ee95b1fa40
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/italic-icon.tsx
@@ -0,0 +1,24 @@
+import React, { memo } from "react"
+
+type SvgProps = React.ComponentPropsWithoutRef<"svg">
+
+export const ItalicIcon = memo(({ className, ...props }: SvgProps) => {
+ return (
+
+ )
+})
+
+ItalicIcon.displayName = "ItalicIcon"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/link-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/link-icon.tsx
new file mode 100644
index 0000000000..8f2b5d6546
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/link-icon.tsx
@@ -0,0 +1,28 @@
+import React, { memo } from "react"
+
+type SvgProps = React.ComponentPropsWithoutRef<"svg">
+
+export const LinkIcon = memo(({ className, ...props }: SvgProps) => {
+ return (
+
+ )
+})
+
+LinkIcon.displayName = "LinkIcon"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/list-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/list-icon.tsx
new file mode 100644
index 0000000000..9952404f0b
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/list-icon.tsx
@@ -0,0 +1,56 @@
+import React, { memo } from "react"
+
+type SvgProps = React.ComponentPropsWithoutRef<"svg">
+
+export const ListIcon = memo(({ className, ...props }: SvgProps) => {
+ return (
+
+ )
+})
+
+ListIcon.displayName = "ListIcon"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/list-ordered-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/list-ordered-icon.tsx
new file mode 100644
index 0000000000..6d53e8bef4
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/list-ordered-icon.tsx
@@ -0,0 +1,56 @@
+import React, { memo } from "react"
+
+type SvgProps = React.ComponentPropsWithoutRef<"svg">
+
+export const ListOrderedIcon = memo(({ className, ...props }: SvgProps) => {
+ return (
+
+ )
+})
+
+ListOrderedIcon.displayName = "ListOrderedIcon"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/list-todo-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/list-todo-icon.tsx
new file mode 100644
index 0000000000..855ba29195
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/list-todo-icon.tsx
@@ -0,0 +1,50 @@
+import React, { memo } from "react"
+
+type SvgProps = React.ComponentPropsWithoutRef<"svg">
+
+export const ListTodoIcon = memo(({ className, ...props }: SvgProps) => {
+ return (
+
+ )
+})
+
+ListTodoIcon.displayName = "ListTodoIcon"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/moon-star-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/moon-star-icon.tsx
new file mode 100644
index 0000000000..722512b84d
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/moon-star-icon.tsx
@@ -0,0 +1,30 @@
+import React, { memo } from "react"
+
+type SvgProps = React.ComponentPropsWithoutRef<"svg">
+
+export const MoonStarIcon = memo(({ className, ...props }: SvgProps) => {
+ return (
+
+ )
+})
+
+MoonStarIcon.displayName = "MoonStarIcon"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/redo2-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/redo2-icon.tsx
new file mode 100644
index 0000000000..7300743a3b
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/redo2-icon.tsx
@@ -0,0 +1,26 @@
+import React, { memo } from "react"
+
+type SvgProps = React.ComponentPropsWithoutRef<"svg">
+
+export const Redo2Icon = memo(({ className, ...props }: SvgProps) => {
+ return (
+
+ )
+})
+
+Redo2Icon.displayName = "Redo2Icon"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/strike-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/strike-icon.tsx
new file mode 100644
index 0000000000..2df2e778a3
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/strike-icon.tsx
@@ -0,0 +1,28 @@
+import React, { memo } from "react"
+
+type SvgProps = React.ComponentPropsWithoutRef<"svg">
+
+export const StrikeIcon = memo(({ className, ...props }: SvgProps) => {
+ return (
+
+ )
+})
+
+StrikeIcon.displayName = "StrikeIcon"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/subscript-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/subscript-icon.tsx
new file mode 100644
index 0000000000..1b051a9ad4
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/subscript-icon.tsx
@@ -0,0 +1,38 @@
+import React, { memo } from "react"
+
+type SvgProps = React.ComponentPropsWithoutRef<"svg">
+
+export const SubscriptIcon = memo(({ className, ...props }: SvgProps) => {
+ return (
+
+ )
+})
+
+SubscriptIcon.displayName = "SubscriptIcon"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/sun-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/sun-icon.tsx
new file mode 100644
index 0000000000..b2def196f4
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/sun-icon.tsx
@@ -0,0 +1,58 @@
+import React, { memo } from "react"
+
+type SvgProps = React.ComponentPropsWithoutRef<"svg">
+
+export const SunIcon = memo(({ className, ...props }: SvgProps) => {
+ return (
+
+ )
+})
+
+SunIcon.displayName = "SunIcon"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/superscript-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/superscript-icon.tsx
new file mode 100644
index 0000000000..57d0cc5893
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/superscript-icon.tsx
@@ -0,0 +1,38 @@
+import React, { memo } from "react"
+
+type SvgProps = React.ComponentPropsWithoutRef<"svg">
+
+export const SuperscriptIcon = memo(({ className, ...props }: SvgProps) => {
+ return (
+
+ )
+})
+
+SuperscriptIcon.displayName = "SuperscriptIcon"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/trash-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/trash-icon.tsx
new file mode 100644
index 0000000000..85e64a5135
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/trash-icon.tsx
@@ -0,0 +1,26 @@
+import React, { memo } from "react"
+
+type SvgProps = React.ComponentPropsWithoutRef<"svg">
+
+export const TrashIcon = memo(({ className, ...props }: SvgProps) => {
+ return (
+
+ )
+})
+
+TrashIcon.displayName = "TrashIcon"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/underline-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/underline-icon.tsx
new file mode 100644
index 0000000000..387abd338a
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/underline-icon.tsx
@@ -0,0 +1,26 @@
+import React, { memo } from "react"
+
+type SvgProps = React.ComponentPropsWithoutRef<"svg">
+
+export const UnderlineIcon = memo(({ className, ...props }: SvgProps) => {
+ return (
+
+ )
+})
+
+UnderlineIcon.displayName = "UnderlineIcon"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/undo2-icon.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/undo2-icon.tsx
new file mode 100644
index 0000000000..135b57c757
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-icons/undo2-icon.tsx
@@ -0,0 +1,26 @@
+import React, { memo } from "react"
+
+type SvgProps = React.ComponentPropsWithoutRef<"svg">
+
+export const Undo2Icon = memo(({ className, ...props }: SvgProps) => {
+ return (
+
+ )
+})
+
+Undo2Icon.displayName = "Undo2Icon"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/blockquote-node/blockquote-node.scss b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/blockquote-node/blockquote-node.scss
new file mode 100644
index 0000000000..b49c5e11e4
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/blockquote-node/blockquote-node.scss
@@ -0,0 +1,37 @@
+.tiptap.ProseMirror {
+ --blockquote-bg-color: var(--tt-gray-light-900);
+
+ .dark & {
+ --blockquote-bg-color: var(--tt-gray-dark-900);
+ }
+}
+
+/* =====================
+ BLOCKQUOTE
+ ===================== */
+.tiptap.ProseMirror {
+ blockquote {
+ position: relative;
+ padding-left: 1em;
+ padding-top: 0.375em;
+ padding-bottom: 0.375em;
+ margin: 1.5rem 0;
+
+ p {
+ margin-top: 0;
+ }
+
+ &::before,
+ &.is-empty::before {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ top: 0;
+ height: 100%;
+ width: 0.25em;
+ background-color: var(--blockquote-bg-color);
+ content: "";
+ border-radius: 0;
+ }
+ }
+}
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/code-block-node/code-block-node.scss b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/code-block-node/code-block-node.scss
new file mode 100644
index 0000000000..d31b312f6d
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/code-block-node/code-block-node.scss
@@ -0,0 +1,54 @@
+.tiptap.ProseMirror {
+ --tt-inline-code-bg-color: var(--tt-gray-light-a-100);
+ --tt-inline-code-text-color: var(--tt-gray-light-a-700);
+ --tt-inline-code-border-color: var(--tt-gray-light-a-200);
+ --tt-codeblock-bg: var(--tt-gray-light-a-50);
+ --tt-codeblock-text: var(--tt-gray-light-a-800);
+ --tt-codeblock-border: var(--tt-gray-light-a-200);
+
+ .dark & {
+ --tt-inline-code-bg-color: var(--tt-gray-dark-a-100);
+ --tt-inline-code-text-color: var(--tt-gray-dark-a-700);
+ --tt-inline-code-border-color: var(--tt-gray-dark-a-200);
+ --tt-codeblock-bg: var(--tt-gray-dark-a-50);
+ --tt-codeblock-text: var(--tt-gray-dark-a-800);
+ --tt-codeblock-border: var(--tt-gray-dark-a-200);
+ }
+}
+
+/* =====================
+ CODE FORMATTING
+ ===================== */
+.tiptap.ProseMirror {
+ // Inline code
+ code {
+ background-color: var(--tt-inline-code-bg-color);
+ color: var(--tt-inline-code-text-color);
+ border: 1px solid var(--tt-inline-code-border-color);
+ font-family: "JetBrains Mono NL", monospace;
+ font-size: 0.875em;
+ line-height: 1.4;
+ border-radius: 6px/0.375rem;
+ padding: 0.1em 0.2em;
+ }
+
+ // Code blocks
+ pre {
+ background-color: var(--tt-codeblock-bg);
+ color: var(--tt-codeblock-text);
+ border: 1px solid var(--tt-codeblock-border);
+ margin-top: 1.5em;
+ margin-bottom: 1.5em;
+ padding: 1em;
+ font-size: 1rem;
+ border-radius: 6px/0.375rem;
+
+ code {
+ background-color: transparent;
+ border: none;
+ border-radius: 0;
+ -webkit-text-fill-color: inherit;
+ color: inherit;
+ }
+ }
+}
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/heading-node/heading-node.scss b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/heading-node/heading-node.scss
new file mode 100644
index 0000000000..882dda2d3a
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/heading-node/heading-node.scss
@@ -0,0 +1,39 @@
+.tiptap.ProseMirror {
+ h1,
+ h2,
+ h3,
+ h4 {
+ position: relative;
+ color: inherit;
+ font-style: inherit;
+
+ &:first-child,
+ &:first-of-type {
+ margin-top: 0;
+ }
+ }
+
+ h1 {
+ font-size: 1.5em;
+ font-weight: 700;
+ margin-top: 3em;
+ }
+
+ h2 {
+ font-size: 1.25em;
+ font-weight: 700;
+ margin-top: 2.5em;
+ }
+
+ h3 {
+ font-size: 1.125em;
+ font-weight: 600;
+ margin-top: 2em;
+ }
+
+ h4 {
+ font-size: 1em;
+ font-weight: 600;
+ margin-top: 2em;
+ }
+}
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/horizontal-rule-node/horizontal-rule-node-extension.ts b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/horizontal-rule-node/horizontal-rule-node-extension.ts
new file mode 100644
index 0000000000..de28208616
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/horizontal-rule-node/horizontal-rule-node-extension.ts
@@ -0,0 +1,14 @@
+import { mergeAttributes } from "@tiptap/react"
+import TiptapHorizontalRule from "@tiptap/extension-horizontal-rule"
+
+export const HorizontalRule = TiptapHorizontalRule.extend({
+ renderHTML() {
+ return [
+ "div",
+ mergeAttributes(this.options.HTMLAttributes, { "data-type": this.name }),
+ ["hr"],
+ ]
+ },
+})
+
+export default HorizontalRule
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/horizontal-rule-node/horizontal-rule-node.scss b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/horizontal-rule-node/horizontal-rule-node.scss
new file mode 100644
index 0000000000..4626e65889
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/horizontal-rule-node/horizontal-rule-node.scss
@@ -0,0 +1,25 @@
+.tiptap.ProseMirror {
+ --horizontal-rule-color: var(--tt-gray-light-a-200);
+
+ .dark & {
+ --horizontal-rule-color: var(--tt-gray-dark-a-200);
+ }
+}
+
+/* =====================
+ HORIZONTAL RULE
+ ===================== */
+.tiptap.ProseMirror {
+ hr {
+ border: none;
+ height: 1px;
+ background-color: var(--horizontal-rule-color);
+ }
+
+ [data-type="horizontalRule"] {
+ margin-top: 2.25em;
+ margin-bottom: 2.25em;
+ padding-top: 0.75rem;
+ padding-bottom: 0.75rem;
+ }
+}
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/image-node/image-node.scss b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/image-node/image-node.scss
new file mode 100644
index 0000000000..10d4231cac
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/image-node/image-node.scss
@@ -0,0 +1,31 @@
+.tiptap.ProseMirror {
+ img {
+ max-width: 100%;
+ height: auto;
+ display: block;
+ }
+
+ > img:not([data-type="emoji"] img) {
+ margin: 2rem 0;
+ outline: 0.125rem solid transparent;
+ border-radius: var(--tt-radius-xs, 0.25rem);
+ }
+
+ img:not([data-type="emoji"] img).ProseMirror-selectednode {
+ outline-color: var(--tt-brand-color-500);
+ }
+
+ // Thread image handling
+ .tiptap-thread:has(> img) {
+ margin: 2rem 0;
+
+ img {
+ outline: 0.125rem solid transparent;
+ border-radius: var(--tt-radius-xs, 0.25rem);
+ }
+ }
+
+ .tiptap-thread img {
+ margin: 0;
+ }
+}
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/image-upload-node/image-upload-node-extension.ts b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/image-upload-node/image-upload-node-extension.ts
new file mode 100644
index 0000000000..deceed64ee
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/image-upload-node/image-upload-node-extension.ts
@@ -0,0 +1,162 @@
+import { mergeAttributes, Node } from "@tiptap/react"
+import { ReactNodeViewRenderer } from "@tiptap/react"
+import { ImageUploadNode as ImageUploadNodeComponent } from "./image-upload-node"
+import type { NodeType } from "@tiptap/pm/model"
+
+export type UploadFunction = (
+ file: File,
+ onProgress?: (event: { progress: number }) => void,
+ abortSignal?: AbortSignal,
+) => Promise
+
+export interface ImageUploadNodeOptions {
+ /**
+ * The type of the node.
+ * @default 'image'
+ */
+ type?: string | NodeType | undefined
+ /**
+ * Acceptable file types for upload.
+ * @default 'image/*'
+ */
+ accept?: string
+ /**
+ * Maximum number of files that can be uploaded.
+ * @default 1
+ */
+ limit?: number
+ /**
+ * Maximum file size in bytes (0 for unlimited).
+ * @default 0
+ */
+ maxSize?: number
+ /**
+ * Function to handle the upload process.
+ */
+ upload?: UploadFunction
+ /**
+ * Callback for upload errors.
+ */
+ onError?: (error: Error) => void
+ /**
+ * Callback for successful uploads.
+ */
+ onSuccess?: (url: string) => void
+ /**
+ * HTML attributes to add to the image element.
+ * @default {}
+ * @example { class: 'foo' }
+ */
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ HTMLAttributes: Record
+}
+
+declare module "@tiptap/react" {
+ interface Commands {
+ imageUpload: {
+ setImageUploadNode: (options?: ImageUploadNodeOptions) => ReturnType
+ }
+ }
+}
+
+/**
+ * A Tiptap node extension that creates an image upload component.
+ * @see registry/tiptap-node/image-upload-node/image-upload-node
+ */
+export const ImageUploadNode = Node.create({
+ name: "imageUpload",
+
+ group: "block",
+
+ draggable: true,
+
+ selectable: true,
+
+ atom: true,
+
+ addOptions() {
+ return {
+ type: "image",
+ accept: "image/*",
+ limit: 1,
+ maxSize: 0,
+ upload: undefined,
+ onError: undefined,
+ onSuccess: undefined,
+ HTMLAttributes: {},
+ }
+ },
+
+ addAttributes() {
+ return {
+ accept: {
+ default: this.options.accept,
+ },
+ limit: {
+ default: this.options.limit,
+ },
+ maxSize: {
+ default: this.options.maxSize,
+ },
+ }
+ },
+
+ parseHTML() {
+ return [{ tag: 'div[data-type="image-upload"]' }]
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return [
+ "div",
+ mergeAttributes({ "data-type": "image-upload" }, HTMLAttributes),
+ ]
+ },
+
+ addNodeView() {
+ return ReactNodeViewRenderer(ImageUploadNodeComponent)
+ },
+
+ addCommands() {
+ return {
+ setImageUploadNode:
+ (options) =>
+ ({ commands }) => {
+ return commands.insertContent({
+ type: this.name,
+ attrs: options,
+ })
+ },
+ }
+ },
+
+ /**
+ * Adds Enter key handler to trigger the upload component when it's selected.
+ */
+ addKeyboardShortcuts() {
+ return {
+ Enter: ({ editor }) => {
+ const { selection } = editor.state
+ const { nodeAfter } = selection.$from
+
+ if (
+ nodeAfter &&
+ nodeAfter.type.name === "imageUpload" &&
+ editor.isActive("imageUpload")
+ ) {
+ const nodeEl = editor.view.nodeDOM(selection.$from.pos)
+ if (nodeEl && nodeEl instanceof HTMLElement) {
+ // Since NodeViewWrapper is wrapped with a div, we need to click the first child
+ const firstChild = nodeEl.firstChild
+ if (firstChild && firstChild instanceof HTMLElement) {
+ firstChild.click()
+ return true
+ }
+ }
+ }
+ return false
+ },
+ }
+ },
+})
+
+export default ImageUploadNode
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/image-upload-node/image-upload-node.scss b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/image-upload-node/image-upload-node.scss
new file mode 100644
index 0000000000..b85e1e33f1
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/image-upload-node/image-upload-node.scss
@@ -0,0 +1,249 @@
+:root {
+ --tiptap-image-upload-active: var(--tt-brand-color-500);
+ --tiptap-image-upload-progress-bg: var(--tt-brand-color-50);
+ --tiptap-image-upload-icon-bg: var(--tt-brand-color-500);
+
+ --tiptap-image-upload-text-color: var(--tt-gray-light-a-700);
+ --tiptap-image-upload-subtext-color: var(--tt-gray-light-a-400);
+ --tiptap-image-upload-border: var(--tt-gray-light-a-300);
+ --tiptap-image-upload-border-hover: var(--tt-gray-light-a-400);
+ --tiptap-image-upload-border-active: var(--tt-brand-color-500);
+
+ --tiptap-image-upload-icon-doc-bg: var(--tt-gray-light-a-200);
+ --tiptap-image-upload-icon-doc-border: var(--tt-gray-light-300);
+ --tiptap-image-upload-icon-color: var(--white);
+}
+
+.dark {
+ --tiptap-image-upload-active: var(--tt-brand-color-400);
+ --tiptap-image-upload-progress-bg: var(--tt-brand-color-900);
+ --tiptap-image-upload-icon-bg: var(--tt-brand-color-400);
+
+ --tiptap-image-upload-text-color: var(--tt-gray-dark-a-700);
+ --tiptap-image-upload-subtext-color: var(--tt-gray-dark-a-400);
+ --tiptap-image-upload-border: var(--tt-gray-dark-a-300);
+ --tiptap-image-upload-border-hover: var(--tt-gray-dark-a-400);
+ --tiptap-image-upload-border-active: var(--tt-brand-color-400);
+
+ --tiptap-image-upload-icon-doc-bg: var(--tt-gray-dark-a-200);
+ --tiptap-image-upload-icon-doc-border: var(--tt-gray-dark-300);
+ --tiptap-image-upload-icon-color: var(--black);
+}
+
+.tiptap-image-upload {
+ margin: 2rem 0;
+
+ input[type="file"] {
+ display: none;
+ }
+
+ .tiptap-image-upload-dropzone {
+ position: relative;
+ width: 3.125rem;
+ height: 3.75rem;
+ display: inline-flex;
+ align-items: flex-start;
+ justify-content: center;
+ -webkit-user-select: none; /* Safari */
+ -ms-user-select: none; /* IE 10 and IE 11 */
+ user-select: none;
+ }
+
+ .tiptap-image-upload-icon-container {
+ position: absolute;
+ width: 1.75rem;
+ height: 1.75rem;
+ bottom: 0;
+ right: 0;
+ background-color: var(--tiptap-image-upload-icon-bg);
+ border-radius: var(--tt-radius-lg, 0.75rem);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+
+ .tiptap-image-upload-icon {
+ width: 0.875rem;
+ height: 0.875rem;
+ color: var(--tiptap-image-upload-icon-color);
+ }
+
+ .tiptap-image-upload-dropzone-rect-primary {
+ color: var(--tiptap-image-upload-icon-doc-bg);
+ position: absolute;
+ }
+
+ .tiptap-image-upload-dropzone-rect-secondary {
+ position: absolute;
+ top: 0;
+ right: 0.25rem;
+ bottom: 0;
+ color: var(--tiptap-image-upload-icon-doc-border);
+ }
+
+ .tiptap-image-upload-text {
+ color: var(--tiptap-image-upload-text-color);
+ font-weight: 500;
+ font-size: 0.875rem;
+ line-height: normal;
+
+ em {
+ font-style: normal;
+ text-decoration: underline;
+ }
+ }
+
+ .tiptap-image-upload-subtext {
+ color: var(--tiptap-image-upload-subtext-color);
+ font-weight: 600;
+ line-height: normal;
+ font-size: 0.75rem;
+ }
+
+ .tiptap-image-upload-drag-area {
+ padding: 2rem 1.5rem;
+ border: 1.5px dashed var(--tiptap-image-upload-border);
+ border-radius: var(--tt-radius-md, 0.5rem);
+ text-align: center;
+ cursor: pointer;
+ position: relative;
+ overflow: hidden;
+ transition: all 0.2s ease;
+
+ &:hover {
+ border-color: var(--tiptap-image-upload-border-hover);
+ }
+
+ &.drag-active {
+ border-color: var(--tiptap-image-upload-border-active);
+ background-color: rgba(
+ var(--tiptap-image-upload-active-rgb, 0, 123, 255),
+ 0.05
+ );
+ }
+
+ &.drag-over {
+ border-color: var(--tiptap-image-upload-border-active);
+ background-color: rgba(
+ var(--tiptap-image-upload-active-rgb, 0, 123, 255),
+ 0.1
+ );
+ }
+ }
+
+ .tiptap-image-upload-content {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-direction: column;
+ gap: 0.25rem;
+ -webkit-user-select: none; /* Safari */
+ -ms-user-select: none; /* IE 10 and IE 11 */
+ user-select: none;
+ }
+
+ .tiptap-image-upload-previews {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ }
+
+ .tiptap-image-upload-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.5rem 0;
+ border-bottom: 1px solid var(--tiptap-image-upload-border);
+ margin-bottom: 0.5rem;
+
+ span {
+ font-size: 0.875rem;
+ font-weight: 500;
+ color: var(--tiptap-image-upload-text-color);
+ }
+ }
+
+ // === Individual File Preview Styles ===
+ .tiptap-image-upload-preview {
+ position: relative;
+ border-radius: var(--tt-radius-md, 0.5rem);
+ overflow: hidden;
+
+ .tiptap-image-upload-progress {
+ position: absolute;
+ inset: 0;
+ background-color: var(--tiptap-image-upload-progress-bg);
+ transition: all 300ms ease-out;
+ }
+
+ .tiptap-image-upload-preview-content {
+ position: relative;
+ border: 1px solid var(--tiptap-image-upload-border);
+ border-radius: var(--tt-radius-md, 0.5rem);
+ padding: 1rem;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ }
+
+ .tiptap-image-upload-file-info {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ height: 2rem;
+
+ .tiptap-image-upload-file-icon {
+ padding: 0.5rem;
+ background-color: var(--tiptap-image-upload-icon-bg);
+ border-radius: var(--tt-radius-lg, 0.75rem);
+
+ svg {
+ width: 0.875rem;
+ height: 0.875rem;
+ color: var(--tiptap-image-upload-icon-color);
+ }
+ }
+ }
+
+ .tiptap-image-upload-details {
+ display: flex;
+ flex-direction: column;
+ }
+
+ .tiptap-image-upload-actions {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+
+ .tiptap-image-upload-progress-text {
+ font-size: 0.75rem;
+ color: var(--tiptap-image-upload-border-active);
+ font-weight: 600;
+ }
+ }
+ }
+}
+
+.tiptap.ProseMirror.ProseMirror-focused {
+ .ProseMirror-selectednode .tiptap-image-upload-drag-area {
+ border-color: var(--tiptap-image-upload-active);
+ }
+}
+
+@media (max-width: 480px) {
+ .tiptap-image-upload {
+ .tiptap-image-upload-drag-area {
+ padding: 1.5rem 1rem;
+ }
+
+ .tiptap-image-upload-header {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.5rem;
+ }
+
+ .tiptap-image-upload-preview-content {
+ padding: 0.75rem;
+ }
+ }
+}
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/image-upload-node/image-upload-node.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/image-upload-node/image-upload-node.tsx
new file mode 100644
index 0000000000..c5b5ead26d
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/image-upload-node/image-upload-node.tsx
@@ -0,0 +1,554 @@
+"use client"
+
+import React, { useRef, useState } from "react"
+import type { NodeViewProps } from "@tiptap/react"
+import { NodeViewWrapper } from "@tiptap/react"
+import { Button } from "../../tiptap-ui-primitive/button"
+import { CloseIcon } from "../../tiptap-icons/close-icon"
+import "./image-upload-node.scss"
+import { focusNextNode, isValidPosition } from "../../../lib/tiptap-utils"
+
+export interface FileItem {
+ /**
+ * Unique identifier for the file item
+ */
+ id: string
+ /**
+ * The actual File object being uploaded
+ */
+ file: File
+ /**
+ * Current upload progress as a percentage (0-100)
+ */
+ progress: number
+ /**
+ * Current status of the file upload process
+ * @default "uploading"
+ */
+ status: "uploading" | "success" | "error"
+
+ /**
+ * URL to the uploaded file, available after successful upload
+ * @optional
+ */
+ url?: string
+ /**
+ * Controller that can be used to abort the upload process
+ * @optional
+ */
+ abortController?: AbortController
+}
+
+export interface UploadOptions {
+ /**
+ * Maximum allowed file size in bytes
+ */
+ maxSize: number
+ /**
+ * Maximum number of files that can be uploaded
+ */
+ limit: number
+ /**
+ * String specifying acceptable file types (MIME types or extensions)
+ * @example ".jpg,.png,image/jpeg" or "image/*"
+ */
+ accept: string
+ /**
+ * Function that handles the actual file upload process
+ * @param {File} file - The file to be uploaded
+ * @param {Function} onProgress - Callback function to report upload progress
+ * @param {AbortSignal} signal - Signal that can be used to abort the upload
+ * @returns {Promise} Promise resolving to the URL of the uploaded file
+ */
+ upload: (
+ file: File,
+ onProgress: (event: { progress: number }) => void,
+ signal: AbortSignal,
+ ) => Promise
+ /**
+ * Callback triggered when a file is uploaded successfully
+ * @param {string} url - URL of the successfully uploaded file
+ * @optional
+ */
+ onSuccess?: (url: string) => void
+ /**
+ * Callback triggered when an error occurs during upload
+ * @param {Error} error - The error that occurred
+ * @optional
+ */
+ onError?: (error: Error) => void
+}
+
+/**
+ * Custom hook for managing multiple file uploads with progress tracking and cancellation
+ */
+function useFileUpload(options: UploadOptions) {
+ const [fileItems, setFileItems] = useState([])
+
+ const uploadFile = async (file: File): Promise => {
+ if (file.size > options.maxSize) {
+ const error = new Error(
+ `File size exceeds maximum allowed (${options.maxSize / 1024 / 1024}MB)`,
+ )
+ options.onError?.(error)
+ return null
+ }
+
+ const abortController = new AbortController()
+ const fileId = crypto.randomUUID()
+
+ const newFileItem: FileItem = {
+ id: fileId,
+ file,
+ progress: 0,
+ status: "uploading",
+ abortController,
+ }
+
+ setFileItems((prev) => [...prev, newFileItem])
+
+ try {
+ if (!options.upload) {
+ throw new Error("Upload function is not defined")
+ }
+
+ const url = await options.upload(
+ file,
+ (event: { progress: number }) => {
+ setFileItems((prev) =>
+ prev.map((item) =>
+ item.id === fileId ? { ...item, progress: event.progress } : item,
+ ),
+ )
+ },
+ abortController.signal,
+ )
+
+ if (!url) throw new Error("Upload failed: No URL returned")
+
+ if (!abortController.signal.aborted) {
+ setFileItems((prev) =>
+ prev.map((item) =>
+ item.id === fileId
+ ? { ...item, status: "success", url, progress: 100 }
+ : item,
+ ),
+ )
+ options.onSuccess?.(url)
+ return url
+ }
+
+ return null
+ } catch (error) {
+ if (!abortController.signal.aborted) {
+ setFileItems((prev) =>
+ prev.map((item) =>
+ item.id === fileId
+ ? { ...item, status: "error", progress: 0 }
+ : item,
+ ),
+ )
+ options.onError?.(
+ error instanceof Error ? error : new Error("Upload failed"),
+ )
+ }
+ return null
+ }
+ }
+
+ const uploadFiles = async (files: File[]): Promise => {
+ if (!files || files.length === 0) {
+ options.onError?.(new Error("No files to upload"))
+ return []
+ }
+
+ if (options.limit && files.length > options.limit) {
+ options.onError?.(
+ new Error(
+ `Maximum ${options.limit} file${options.limit === 1 ? "" : "s"} allowed`,
+ ),
+ )
+ return []
+ }
+
+ // Upload all files concurrently
+ const uploadPromises = files.map((file) => uploadFile(file))
+ const results = await Promise.all(uploadPromises)
+
+ // Filter out null results (failed uploads)
+ return results.filter((url): url is string => url !== null)
+ }
+
+ const removeFileItem = (fileId: string) => {
+ setFileItems((prev) => {
+ const fileToRemove = prev.find((item) => item.id === fileId)
+ if (fileToRemove?.abortController) {
+ fileToRemove.abortController.abort()
+ }
+ if (fileToRemove?.url) {
+ URL.revokeObjectURL(fileToRemove.url)
+ }
+ return prev.filter((item) => item.id !== fileId)
+ })
+ }
+
+ const clearAllFiles = () => {
+ fileItems.forEach((item) => {
+ if (item.abortController) {
+ item.abortController.abort()
+ }
+ if (item.url) {
+ URL.revokeObjectURL(item.url)
+ }
+ })
+ setFileItems([])
+ }
+
+ return {
+ fileItems,
+ uploadFiles,
+ removeFileItem,
+ clearAllFiles,
+ }
+}
+
+const CloudUploadIcon: React.FC = () => (
+
+)
+
+const FileIcon: React.FC = () => (
+
+)
+
+const FileCornerIcon: React.FC = () => (
+
+)
+
+interface ImageUploadDragAreaProps {
+ /**
+ * Callback function triggered when files are dropped or selected
+ * @param {File[]} files - Array of File objects that were dropped or selected
+ */
+ onFile: (files: File[]) => void
+ /**
+ * Optional child elements to render inside the drag area
+ * @optional
+ * @default undefined
+ */
+ children?: React.ReactNode
+}
+
+/**
+ * A component that creates a drag-and-drop area for image uploads
+ */
+const ImageUploadDragArea: React.FC = ({
+ onFile,
+ children,
+}) => {
+ const [isDragOver, setIsDragOver] = useState(false)
+ const [isDragActive, setIsDragActive] = useState(false)
+
+ const handleDragEnter = (e: React.DragEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+ setIsDragActive(true)
+ }
+
+ const handleDragLeave = (e: React.DragEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+ if (!e.currentTarget.contains(e.relatedTarget as Node)) {
+ setIsDragActive(false)
+ setIsDragOver(false)
+ }
+ }
+
+ const handleDragOver = (e: React.DragEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+ setIsDragOver(true)
+ }
+
+ const handleDrop = (e: React.DragEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+ setIsDragActive(false)
+ setIsDragOver(false)
+
+ const files = Array.from(e.dataTransfer.files)
+ if (files.length > 0) {
+ onFile(files)
+ }
+ }
+
+ return (
+
+ {children}
+
+ )
+}
+
+interface ImageUploadPreviewProps {
+ /**
+ * The file item to preview
+ */
+ fileItem: FileItem
+ /**
+ * Callback to remove this file from upload queue
+ */
+ onRemove: () => void
+}
+
+/**
+ * Component that displays a preview of an uploading file with progress
+ */
+const ImageUploadPreview: React.FC = ({
+ fileItem,
+ onRemove,
+}) => {
+ const formatFileSize = (bytes: number) => {
+ if (bytes === 0) return "0 Bytes"
+ const k = 1024
+ const sizes = ["Bytes", "KB", "MB", "GB"]
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`
+ }
+
+ return (
+
+ {fileItem.status === "uploading" && (
+
+ )}
+
+
+
+
+
+
+
+
+ {fileItem.file.name}
+
+
+ {formatFileSize(fileItem.file.size)}
+
+
+
+
+ {fileItem.status === "uploading" && (
+
+ {fileItem.progress}%
+
+ )}
+
+
+
+
+ )
+}
+
+const DropZoneContent: React.FC<{ maxSize: number; limit: number }> = ({
+ maxSize,
+ limit,
+}) => (
+ <>
+
+
+
+
+ Click to upload or drag and drop
+
+
+ Maximum {limit} file{limit === 1 ? "" : "s"}, {maxSize / 1024 / 1024}MB
+ each.
+
+
+ >
+)
+
+export const ImageUploadNode: React.FC = (props) => {
+ const { accept, limit, maxSize } = props.node.attrs
+ const inputRef = useRef(null)
+ const extension = props.extension
+
+ const uploadOptions: UploadOptions = {
+ maxSize,
+ limit,
+ accept,
+ upload: extension.options.upload,
+ onSuccess: extension.options.onSuccess,
+ onError: extension.options.onError,
+ }
+
+ const { fileItems, uploadFiles, removeFileItem, clearAllFiles } =
+ useFileUpload(uploadOptions)
+
+ const handleUpload = async (files: File[]) => {
+ const urls = await uploadFiles(files)
+
+ if (urls.length > 0) {
+ const pos = props.getPos()
+
+ if (isValidPosition(pos)) {
+ const imageNodes = urls.map((url, index) => {
+ const filename =
+ files[index]?.name.replace(/\.[^/.]+$/, "") || "unknown"
+ return {
+ type: extension.options.type,
+ attrs: {
+ ...extension.options,
+ src: url,
+ alt: filename,
+ title: filename,
+ },
+ }
+ })
+
+ props.editor
+ .chain()
+ .focus()
+ .deleteRange({ from: pos, to: pos + props.node.nodeSize })
+ .insertContentAt(pos, imageNodes)
+ .run()
+
+ focusNextNode(props.editor)
+ }
+ }
+ }
+
+ const handleChange = (e: React.ChangeEvent) => {
+ const files = e.target.files
+ if (!files || files.length === 0) {
+ extension.options.onError?.(new Error("No file selected"))
+ return
+ }
+ handleUpload(Array.from(files))
+ }
+
+ const handleClick = () => {
+ if (inputRef.current && fileItems.length === 0) {
+ inputRef.current.value = ""
+ inputRef.current.click()
+ }
+ }
+
+ const hasFiles = fileItems.length > 0
+
+ return (
+
+ {!hasFiles && (
+
+
+
+ )}
+
+ {hasFiles && (
+
+ {fileItems.length > 1 && (
+
+ Uploading {fileItems.length} files
+
+
+ )}
+ {fileItems.map((fileItem) => (
+
removeFileItem(fileItem.id)}
+ />
+ ))}
+
+ )}
+
+ 1}
+ onChange={handleChange}
+ onClick={(e: React.MouseEvent) => e.stopPropagation()}
+ />
+
+ )
+}
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/image-upload-node/index.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/image-upload-node/index.tsx
new file mode 100644
index 0000000000..2510a62fae
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/image-upload-node/index.tsx
@@ -0,0 +1 @@
+export * from "./image-upload-node-extension"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/list-node/list-node.scss b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/list-node/list-node.scss
new file mode 100644
index 0000000000..d0fe5c8f25
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/list-node/list-node.scss
@@ -0,0 +1,160 @@
+.tiptap.ProseMirror {
+ --tt-checklist-bg-color: var(--tt-gray-light-a-100);
+ --tt-checklist-bg-active-color: var(--tt-gray-light-a-900);
+ --tt-checklist-border-color: var(--tt-gray-light-a-200);
+ --tt-checklist-border-active-color: var(--tt-gray-light-a-900);
+ --tt-checklist-check-icon-color: var(--white);
+ --tt-checklist-text-active: var(--tt-gray-light-a-500);
+
+ .dark & {
+ --tt-checklist-bg-color: var(--tt-gray-dark-a-100);
+ --tt-checklist-bg-active-color: var(--tt-gray-dark-a-900);
+ --tt-checklist-border-color: var(--tt-gray-dark-a-200);
+ --tt-checklist-border-active-color: var(--tt-gray-dark-a-900);
+ --tt-checklist-check-icon-color: var(--black);
+ --tt-checklist-text-active: var(--tt-gray-dark-a-500);
+ }
+}
+
+/* =====================
+ LISTS
+ ===================== */
+.tiptap.ProseMirror {
+ // Common list styles
+ ol,
+ ul {
+ margin-top: 1.5em;
+ margin-bottom: 1.5em;
+ padding-left: 1.5em;
+
+ &:first-child {
+ margin-top: 0;
+ }
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+
+ ol,
+ ul {
+ margin-top: 0;
+ margin-bottom: 0;
+ }
+ }
+
+ li {
+ p {
+ margin-top: 0;
+ line-height: 1.6;
+ }
+ }
+
+ // Ordered lists
+ ol {
+ list-style: decimal;
+
+ ol {
+ list-style: lower-alpha;
+
+ ol {
+ list-style: lower-roman;
+ }
+ }
+ }
+
+ // Unordered lists
+ ul:not([data-type="taskList"]) {
+ list-style: disc;
+
+ ul {
+ list-style: circle;
+
+ ul {
+ list-style: square;
+ }
+ }
+ }
+
+ // Task lists
+ ul[data-type="taskList"] {
+ padding-left: 0.25em;
+
+ li {
+ display: flex;
+ flex-direction: row;
+ align-items: flex-start;
+
+ &:not(:has(> p:first-child)) {
+ list-style-type: none;
+ }
+
+ &[data-checked="true"] {
+ > div > p {
+ opacity: 0.5;
+ text-decoration: line-through;
+ }
+
+ > div > p span {
+ text-decoration: line-through;
+ }
+ }
+
+ label {
+ position: relative;
+ padding-top: 0.375rem;
+ padding-right: 0.5rem;
+
+ input[type="checkbox"] {
+ position: absolute;
+ opacity: 0;
+ width: 0;
+ height: 0;
+ }
+
+ span {
+ display: block;
+ width: 1em;
+ height: 1em;
+ border: 1px solid var(--tt-checklist-border-color);
+ border-radius: var(--tt-radius-xs, 0.25rem);
+ position: relative;
+ cursor: pointer;
+ background-color: var(--tt-checklist-bg-color);
+ transition:
+ background-color 80ms ease-out,
+ border-color 80ms ease-out;
+
+ &::before {
+ content: "";
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ width: 0.75em;
+ height: 0.75em;
+ background-color: var(--tt-checklist-check-icon-color);
+ opacity: 0;
+ -webkit-mask: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22currentColor%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M21.4142%204.58579C22.1953%205.36683%2022.1953%206.63317%2021.4142%207.41421L10.4142%2018.4142C9.63317%2019.1953%208.36684%2019.1953%207.58579%2018.4142L2.58579%2013.4142C1.80474%2012.6332%201.80474%2011.3668%202.58579%2010.5858C3.36683%209.80474%204.63317%209.80474%205.41421%2010.5858L9%2014.1716L18.5858%204.58579C19.3668%203.80474%2020.6332%203.80474%2021.4142%204.58579Z%22%20fill%3D%22currentColor%22%2F%3E%3C%2Fsvg%3E")
+ center/contain no-repeat;
+ mask: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22currentColor%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M21.4142%204.58579C22.1953%205.36683%2022.1953%206.63317%2021.4142%207.41421L10.4142%2018.4142C9.63317%2019.1953%208.36684%2019.1953%207.58579%2018.4142L2.58579%2013.4142C1.80474%2012.6332%201.80474%2011.3668%202.58579%2010.5858C3.36683%209.80474%204.63317%209.80474%205.41421%2010.5858L9%2014.1716L18.5858%204.58579C19.3668%203.80474%2020.6332%203.80474%2021.4142%204.58579Z%22%20fill%3D%22currentColor%22%2F%3E%3C%2Fsvg%3E")
+ center/contain no-repeat;
+ }
+ }
+
+ input[type="checkbox"]:checked + span {
+ background: var(--tt-checklist-bg-active-color);
+ border-color: var(--tt-checklist-border-active-color);
+
+ &::before {
+ opacity: 1;
+ }
+ }
+ }
+
+ div {
+ flex: 1 1 0%;
+ min-width: 0;
+ }
+ }
+ }
+}
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/paragraph-node/paragraph-node.scss b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/paragraph-node/paragraph-node.scss
new file mode 100644
index 0000000000..e5ff0b965d
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-node/paragraph-node/paragraph-node.scss
@@ -0,0 +1,273 @@
+.tiptap.ProseMirror {
+ --tt-collaboration-carets-label: var(--tt-gray-light-900);
+ --link-text-color: var(--tt-brand-color-500);
+ --thread-text: var(--tt-gray-light-900);
+ --placeholder-color: var(--tt-gray-light-a-400);
+ --thread-bg-color: var(--tt-color-yellow-inc-2);
+
+ // ai
+ --tiptap-ai-insertion-color: var(--tt-brand-color-600);
+
+ .dark & {
+ --tt-collaboration-carets-label: var(--tt-gray-dark-100);
+ --link-text-color: var(--tt-brand-color-400);
+ --thread-text: var(--tt-gray-dark-900);
+ --placeholder-color: var(--tt-gray-dark-a-400);
+ --thread-bg-color: var(--tt-color-yellow-dec-2);
+
+ --tiptap-ai-insertion-color: var(--tt-brand-color-400);
+ }
+}
+
+/* Ensure each top-level node has relative positioning
+ so absolutely positioned placeholders work correctly */
+.tiptap.ProseMirror > * {
+ position: relative;
+}
+
+/* =====================
+ CORE EDITOR STYLES
+ ===================== */
+.tiptap.ProseMirror {
+ white-space: pre-wrap;
+ outline: none;
+ caret-color: var(--tt-cursor-color);
+
+ // Paragraph spacing
+ p:not(:first-child):not(td p):not(th p) {
+ font-size: 1rem;
+ line-height: 1.6;
+ font-weight: normal;
+ margin-top: 20px;
+ }
+
+ // Selection styles
+ &:not(.readonly):not(.ProseMirror-hideselection) {
+ ::selection {
+ background-color: var(--tt-selection-color);
+ }
+
+ .selection::selection {
+ background: transparent;
+ }
+ }
+
+ .selection {
+ display: inline;
+ background-color: var(--tt-selection-color);
+ }
+
+ // Selected node styles
+ .ProseMirror-selectednode:not(img):not(pre):not(.react-renderer) {
+ border-radius: var(--tt-radius-md);
+ background-color: var(--tt-selection-color);
+ }
+
+ .ProseMirror-hideselection {
+ caret-color: transparent;
+ }
+
+ // Resize cursor
+ &.resize-cursor {
+ cursor: ew-resize;
+ cursor: col-resize;
+ }
+}
+
+/* =====================
+ TEXT DECORATION
+ ===================== */
+.tiptap.ProseMirror {
+ // Text decoration inheritance for spans
+ a span {
+ text-decoration: underline;
+ }
+
+ s span {
+ text-decoration: line-through;
+ }
+
+ u span {
+ text-decoration: underline;
+ }
+
+ .tiptap-ai-insertion {
+ color: var(--tiptap-ai-insertion-color);
+ }
+}
+
+/* =====================
+ COLLABORATION
+ ===================== */
+.tiptap.ProseMirror {
+ .collaboration-carets {
+ &__caret {
+ border-right: 1px solid transparent;
+ border-left: 1px solid transparent;
+ pointer-events: none;
+ margin-left: -1px;
+ margin-right: -1px;
+ position: relative;
+ word-break: normal;
+ }
+
+ &__label {
+ color: var(--tt-collaboration-carets-label);
+ border-radius: 0.25rem;
+ border-bottom-left-radius: 0;
+ font-size: 0.75rem;
+ font-weight: 600;
+ left: -1px;
+ line-height: 1;
+ padding: 0.125rem 0.375rem;
+ position: absolute;
+ top: -1.3em;
+ user-select: none;
+ white-space: nowrap;
+ }
+ }
+}
+
+/* =====================
+ EMOJI
+ ===================== */
+.tiptap.ProseMirror [data-type="emoji"] img {
+ display: inline-block;
+ width: 1.25em;
+ height: 1.25em;
+ cursor: text;
+}
+
+/* =====================
+ LINKS
+ ===================== */
+.tiptap.ProseMirror {
+ a {
+ color: var(--link-text-color);
+ text-decoration: underline;
+ }
+}
+
+/* =====================
+ MENTION
+ ===================== */
+.tiptap.ProseMirror {
+ [data-type="mention"] {
+ display: inline-block;
+ color: var(--tt-brand-color-500);
+ }
+}
+
+/* =====================
+ THREADS
+ ===================== */
+.tiptap.ProseMirror {
+ // Base styles for inline threads
+ .tiptap-thread.tiptap-thread--unresolved.tiptap-thread--inline {
+ transition:
+ color 0.2s ease-in-out,
+ background-color 0.2s ease-in-out;
+ color: var(--thread-text);
+ border-bottom: 2px dashed var(--tt-color-yellow-base);
+ font-weight: 600;
+
+ &.tiptap-thread--selected,
+ &.tiptap-thread--hovered {
+ background-color: var(--thread-bg-color);
+ border-bottom-color: transparent;
+ }
+ }
+
+ // Block thread styles with images
+ .tiptap-thread.tiptap-thread--unresolved.tiptap-thread--block {
+ &:has(img) {
+ outline: 0.125rem solid var(--tt-color-yellow-base);
+ border-radius: var(--tt-radius-xs, 0.25rem);
+ overflow: hidden;
+ width: fit-content;
+
+ &.tiptap-thread--selected {
+ outline-width: 0.25rem;
+ outline-color: var(--tt-color-yellow-base);
+ }
+
+ &.tiptap-thread--hovered {
+ outline-width: 0.25rem;
+ }
+ }
+
+ // Block thread styles without images
+ &:not(:has(img)) {
+ border-radius: 0.25rem;
+ border-bottom: 0.125rem dashed var(--tt-color-yellow-base);
+ border-top: 0.125rem dashed var(--tt-color-yellow-base);
+ // padding-bottom: 0.5rem;
+ outline: 0.25rem solid transparent;
+
+ &.tiptap-thread--hovered,
+ &.tiptap-thread--selected {
+ background-color: var(--tt-color-yellow-base);
+ outline-color: var(--tt-color-yellow-base);
+ }
+ }
+ }
+
+ // Resolved thread styles
+ .tiptap-thread.tiptap-thread--resolved.tiptap-thread--inline.tiptap-thread--selected {
+ background-color: var(--tt-color-yellow-base);
+ border-color: transparent;
+ opacity: 0.5;
+ }
+
+ // React renderer specific styles
+ .tiptap-thread.tiptap-thread--block:has(.react-renderer) {
+ margin-top: 3rem;
+ margin-bottom: 3rem;
+ }
+}
+
+/* =====================
+ PLACEHOLDER
+ ===================== */
+.is-empty:not(.with-slash)[data-placeholder]:has(
+ > .ProseMirror-trailingBreak:only-child
+ )::before {
+ content: attr(data-placeholder);
+}
+
+.is-empty.with-slash[data-placeholder]:has(
+ > .ProseMirror-trailingBreak:only-child
+ )::before {
+ content: "Write, type '/' for commands…";
+ font-style: italic;
+}
+
+.is-empty[data-placeholder]:has(
+ > .ProseMirror-trailingBreak:only-child
+ ):before {
+ pointer-events: none;
+ height: 0;
+ position: absolute;
+ width: 100%;
+ text-align: inherit;
+ left: 0;
+ right: 0;
+}
+
+.is-empty[data-placeholder]:has(> .ProseMirror-trailingBreak):before {
+ color: var(--placeholder-color);
+}
+
+/* =====================
+ DROPCURSOR
+ ===================== */
+.prosemirror-dropcursor-block,
+.prosemirror-dropcursor-inline {
+ background: var(--tt-brand-color-400) !important;
+ border-radius: 0.25rem;
+ margin-left: -1px;
+ margin-right: -1px;
+ width: 100%;
+ height: 0.188rem;
+ cursor: grabbing;
+}
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-templates/simple/data/content.json b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-templates/simple/data/content.json
new file mode 100644
index 0000000000..4a3c0e8617
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-templates/simple/data/content.json
@@ -0,0 +1,477 @@
+{
+ "type": "doc",
+ "content": [
+ {
+ "type": "heading",
+ "attrs": {
+ "textAlign": null,
+ "level": 1
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Getting started"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Welcome to the "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ },
+ {
+ "type": "highlight",
+ "attrs": {
+ "color": "var(--tt-color-highlight-yellow)"
+ }
+ }
+ ],
+ "text": "Simple Editor"
+ },
+ {
+ "type": "text",
+ "text": " template! This template integrates "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "open source"
+ },
+ {
+ "type": "text",
+ "text": " UI components and Tiptap extensions licensed under "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "MIT"
+ },
+ {
+ "type": "text",
+ "text": "."
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Integrate it by following the "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "https://tiptap.dev/docs/ui-components/templates/simple-editor",
+ "target": "_blank",
+ "rel": "noopener noreferrer nofollow",
+ "class": null
+ }
+ }
+ ],
+ "text": "Tiptap UI Components docs"
+ },
+ {
+ "type": "text",
+ "text": " or using our CLI tool."
+ }
+ ]
+ },
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "npx @tiptap/cli init"
+ }
+ ]
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "textAlign": null,
+ "level": 2
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Features"
+ }
+ ]
+ },
+ {
+ "type": "blockquote",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "A fully responsive rich text editor with built-in support for common formatting and layout tools. Type markdown "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "**"
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": " or use keyboard shortcuts "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "⌘+B"
+ },
+ {
+ "type": "text",
+ "text": " for "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "strike"
+ }
+ ],
+ "text": "most"
+ },
+ {
+ "type": "text",
+ "text": " all common markdown marks. 🪄"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "textAlign": "left"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Add images, customize alignment, and apply "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "highlight",
+ "attrs": {
+ "color": "var(--tt-color-highlight-blue)"
+ }
+ }
+ ],
+ "text": "advanced formatting"
+ },
+ {
+ "type": "text",
+ "text": " to make your writing more engaging and professional."
+ }
+ ]
+ },
+ {
+ "type": "image",
+ "attrs": {
+ "src": "/images/tiptap-ui-placeholder-image.jpg",
+ "alt": "placeholder-image",
+ "title": "placeholder-image"
+ }
+ },
+ {
+ "type": "bulletList",
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "textAlign": "left"
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "Superscript"
+ },
+ {
+ "type": "text",
+ "text": " (x"
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "superscript"
+ }
+ ],
+ "text": "2"
+ },
+ {
+ "type": "text",
+ "text": ") and "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "Subscript"
+ },
+ {
+ "type": "text",
+ "text": " (H"
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "subscript"
+ }
+ ],
+ "text": "2"
+ },
+ {
+ "type": "text",
+ "text": "O) for precision."
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "textAlign": "left"
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "Typographic conversion"
+ },
+ {
+ "type": "text",
+ "text": ": automatically convert to "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "->"
+ },
+ {
+ "type": "text",
+ "text": " an arrow "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "→"
+ },
+ {
+ "type": "text",
+ "text": "."
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "textAlign": "left"
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "→ "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "https://tiptap.dev/docs/ui-components/templates/simple-editor#features",
+ "target": "_blank",
+ "rel": "noopener noreferrer nofollow",
+ "class": null
+ }
+ }
+ ],
+ "text": "Learn more"
+ }
+ ]
+ },
+ {
+ "type": "horizontalRule"
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "textAlign": "left",
+ "level": 2
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Make it your own"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "textAlign": "left"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Switch between light and dark modes, and tailor the editor's appearance with customizable CSS to match your style."
+ }
+ ]
+ },
+ {
+ "type": "taskList",
+ "content": [
+ {
+ "type": "taskItem",
+ "attrs": {
+ "checked": true
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "textAlign": "left"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Test template"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "taskItem",
+ "attrs": {
+ "checked": false
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "textAlign": "left"
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "https://tiptap.dev/docs/ui-components/templates/simple-editor",
+ "target": "_blank",
+ "rel": "noopener noreferrer nofollow",
+ "class": null
+ }
+ }
+ ],
+ "text": "Integrate the free template"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "textAlign": "left"
+ }
+ }
+ ]
+}
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-templates/simple/simple-editor.scss b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-templates/simple/simple-editor.scss
new file mode 100644
index 0000000000..8faf836440
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-templates/simple/simple-editor.scss
@@ -0,0 +1,82 @@
+@import url("https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap");
+
+body {
+ --tt-toolbar-height: 44px;
+ --tt-theme-text: var(--tt-gray-light-900);
+
+ .dark & {
+ --tt-theme-text: var(--tt-gray-dark-900);
+ }
+}
+
+body {
+ font-family: "Inter", sans-serif;
+ color: var(--tt-theme-text);
+ font-optical-sizing: auto;
+ font-weight: 400;
+ font-style: normal;
+ padding: 0;
+ overscroll-behavior-y: none;
+}
+
+html,
+body {
+ overscroll-behavior-x: none;
+}
+
+html,
+body,
+#root,
+#app {
+ height: 100%;
+ background-color: var(--tt-bg-color);
+}
+
+::-webkit-scrollbar {
+ width: 0.25rem;
+}
+
+* {
+ scrollbar-width: thin;
+ scrollbar-color: var(--tt-scrollbar-color) transparent;
+}
+
+::-webkit-scrollbar-thumb {
+ background-color: var(--tt-scrollbar-color);
+ border-radius: 9999px;
+}
+
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.tiptap.ProseMirror {
+ font-family: "DM Sans", sans-serif;
+}
+
+.simple-editor-wrapper {
+ width: 100vw;
+ height: 100vh;
+ overflow: auto;
+}
+
+.simple-editor-content {
+ max-width: 648px;
+ width: 100%;
+ margin: 0 auto;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+}
+
+.simple-editor-content .tiptap.ProseMirror.simple-editor {
+ flex: 1;
+ padding: 3rem 3rem 30vh;
+}
+
+@media screen and (max-width: 480px) {
+ .simple-editor-content .tiptap.ProseMirror.simple-editor {
+ padding: 1rem 1.5rem 30vh;
+ }
+}
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-templates/simple/simple-editor.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-templates/simple/simple-editor.tsx
new file mode 100644
index 0000000000..5be12cc8a7
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-templates/simple/simple-editor.tsx
@@ -0,0 +1,290 @@
+// File not in use. This is the base Simple Editor file, https://tiptap.dev/docs/ui-components/templates/simple-editor.
+// Selectively used in ol-components/src/components/TiptapEditor/TiptapEditor.tsx. Left in project for reference.
+
+"use client"
+
+import React, { useEffect, useRef, useState } from "react"
+import { EditorContent, EditorContext, useEditor } from "@tiptap/react"
+
+// --- Tiptap Core Extensions ---
+import { StarterKit } from "@tiptap/starter-kit"
+import { Image } from "@tiptap/extension-image"
+import { TaskItem, TaskList } from "@tiptap/extension-list"
+import { TextAlign } from "@tiptap/extension-text-align"
+import { Typography } from "@tiptap/extension-typography"
+import { Highlight } from "@tiptap/extension-highlight"
+import { Subscript } from "@tiptap/extension-subscript"
+import { Superscript } from "@tiptap/extension-superscript"
+import { Selection } from "@tiptap/extensions"
+
+// --- UI Primitives ---
+import { Button } from "../../tiptap-ui-primitive/button"
+import { Spacer } from "../../tiptap-ui-primitive/spacer"
+import {
+ Toolbar,
+ ToolbarGroup,
+ ToolbarSeparator,
+} from "../../tiptap-ui-primitive/toolbar"
+
+// --- Tiptap Node ---
+import { ImageUploadNode } from "../../tiptap-node/image-upload-node/image-upload-node-extension"
+import { HorizontalRule } from "../../tiptap-node/horizontal-rule-node/horizontal-rule-node-extension"
+import "../../tiptap-node/blockquote-node/blockquote-node.scss"
+import "../../tiptap-node/code-block-node/code-block-node.scss"
+import "../../tiptap-node/horizontal-rule-node/horizontal-rule-node.scss"
+import "../../tiptap-node/list-node/list-node.scss"
+import "../../tiptap-node/image-node/image-node.scss"
+import "../../tiptap-node/heading-node/heading-node.scss"
+import "../../tiptap-node/paragraph-node/paragraph-node.scss"
+
+// --- Tiptap UI ---
+import { HeadingDropdownMenu } from "../../tiptap-ui/heading-dropdown-menu"
+import { ImageUploadButton } from "../../tiptap-ui/image-upload-button"
+import { ListDropdownMenu } from "../../tiptap-ui/list-dropdown-menu"
+import { BlockquoteButton } from "../../tiptap-ui/blockquote-button"
+import { CodeBlockButton } from "../../tiptap-ui/code-block-button"
+import {
+ ColorHighlightPopover,
+ ColorHighlightPopoverContent,
+ ColorHighlightPopoverButton,
+} from "../../tiptap-ui/color-highlight-popover"
+import {
+ LinkPopover,
+ LinkContent,
+ LinkButton,
+} from "../../tiptap-ui/link-popover"
+import { MarkButton } from "../../tiptap-ui/mark-button"
+import { TextAlignButton } from "../../tiptap-ui/text-align-button"
+import { UndoRedoButton } from "../../tiptap-ui/undo-redo-button"
+
+// --- Icons ---
+import { ArrowLeftIcon } from "../../tiptap-icons/arrow-left-icon"
+import { HighlighterIcon } from "../../tiptap-icons/highlighter-icon"
+import { LinkIcon } from "../../tiptap-icons/link-icon"
+
+// --- Hooks ---
+import { useIsMobile } from "../../../hooks/use-mobile"
+import { useWindowSize } from "../../../hooks/use-window-size"
+import { useCursorVisibility } from "../../../hooks/use-cursor-visibility"
+
+// --- Components ---
+import { ThemeToggle } from "./theme-toggle"
+
+// --- Lib ---
+import { handleImageUpload, MAX_FILE_SIZE } from "../../../lib/tiptap-utils"
+
+// --- Styles ---
+import "./simple-editor.scss"
+
+const MainToolbarContent = ({
+ onHighlighterClick,
+ onLinkClick,
+ isMobile,
+}: {
+ onHighlighterClick: () => void
+ onLinkClick: () => void
+ isMobile: boolean
+}) => {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {!isMobile ? (
+
+ ) : (
+
+ )}
+ {!isMobile ? : }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {isMobile && }
+
+
+
+
+ >
+ )
+}
+
+const MobileToolbarContent = ({
+ type,
+ onBack,
+}: {
+ type: "highlighter" | "link"
+ onBack: () => void
+}) => (
+ <>
+
+
+
+
+
+
+ {type === "highlighter" ? (
+
+ ) : (
+
+ )}
+ >
+)
+
+export function SimpleEditor() {
+ const isMobile = useIsMobile()
+ const { height } = useWindowSize()
+ const [mobileView, setMobileView] = useState<"main" | "highlighter" | "link">(
+ "main",
+ )
+ const toolbarRef = useRef(null)
+
+ const editor = useEditor({
+ immediatelyRender: false,
+ shouldRerenderOnTransaction: false,
+ editorProps: {
+ attributes: {
+ autocomplete: "off",
+ autocorrect: "off",
+ autocapitalize: "off",
+ "aria-label": "Main content area, start typing to enter text.",
+ class: "simple-editor",
+ },
+ },
+ extensions: [
+ StarterKit.configure({
+ horizontalRule: false,
+ link: {
+ openOnClick: false,
+ enableClickSelection: true,
+ },
+ }),
+ HorizontalRule,
+ TextAlign.configure({ types: ["heading", "paragraph"] }),
+ TaskList,
+ TaskItem.configure({ nested: true }),
+ Highlight.configure({ multicolor: true }),
+ Image,
+ Typography,
+ Superscript,
+ Subscript,
+ Selection,
+ ImageUploadNode.configure({
+ accept: "image/*",
+ maxSize: MAX_FILE_SIZE,
+ limit: 3,
+ upload: handleImageUpload,
+ onError: (error) => console.error("Upload failed:", error),
+ }),
+ ],
+ content: {
+ type: "doc",
+ content: [
+ {
+ type: "paragraph",
+ content: [],
+ },
+ ],
+ },
+ })
+
+ const rect = useCursorVisibility({
+ editor,
+ overlayHeight: toolbarRef.current?.getBoundingClientRect().height ?? 0,
+ })
+
+ useEffect(() => {
+ if (!isMobile && mobileView !== "main") {
+ setMobileView("main")
+ }
+ }, [isMobile, mobileView])
+
+ return (
+
+
+
+ {mobileView === "main" ? (
+ setMobileView("highlighter")}
+ onLinkClick={() => setMobileView("link")}
+ isMobile={isMobile}
+ />
+ ) : (
+ setMobileView("main")}
+ />
+ )}
+
+
+
+
+
+ )
+}
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-templates/simple/theme-toggle.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-templates/simple/theme-toggle.tsx
new file mode 100644
index 0000000000..473e37ec40
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-templates/simple/theme-toggle.tsx
@@ -0,0 +1,44 @@
+import { Button } from "../../tiptap-ui-primitive/button"
+
+// --- Icons ---
+import { MoonStarIcon } from "../../tiptap-icons/moon-star-icon"
+import { SunIcon } from "../../tiptap-icons/sun-icon"
+import React, { useEffect, useState } from "react"
+
+export function ThemeToggle() {
+ const [isDarkMode, setIsDarkMode] = useState(false)
+
+ useEffect(() => {
+ const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
+ const handleChange = () => setIsDarkMode(mediaQuery.matches)
+ mediaQuery.addEventListener("change", handleChange)
+ return () => mediaQuery.removeEventListener("change", handleChange)
+ }, [])
+
+ useEffect(() => {
+ const initialDarkMode =
+ !!document.querySelector('meta[name="color-scheme"][content="dark"]') ||
+ window.matchMedia("(prefers-color-scheme: dark)").matches
+ setIsDarkMode(initialDarkMode)
+ }, [])
+
+ useEffect(() => {
+ document.documentElement.classList.toggle("dark", isDarkMode)
+ }, [isDarkMode])
+
+ const toggleDarkMode = () => setIsDarkMode((isDark) => !isDark)
+
+ return (
+
+ )
+}
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/badge/badge-colors.scss b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/badge/badge-colors.scss
new file mode 100644
index 0000000000..2044b94194
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/badge/badge-colors.scss
@@ -0,0 +1,395 @@
+.tiptap-badge {
+ /**************************************************
+ Default
+ **************************************************/
+
+ /* Light mode */
+ --tt-badge-border-color: var(--tt-gray-light-a-200);
+ --tt-badge-border-color-subdued: var(--tt-gray-light-a-200);
+ --tt-badge-border-color-emphasized: var(--tt-gray-light-a-600);
+ --tt-badge-text-color: var(--tt-gray-light-a-500);
+ --tt-badge-text-color-subdued: var(
+ --tt-gray-light-a-400
+ ); //less important badge
+ --tt-badge-text-color-emphasized: var(
+ --tt-gray-light-a-600
+ ); //more important badge
+ --tt-badge-bg-color: var(--white);
+ --tt-badge-bg-color-subdued: var(--white); //less important badge
+ --tt-badge-bg-color-emphasized: var(--white); //more important badge
+ --tt-badge-icon-color: var(--tt-gray-light-a-500);
+ --tt-badge-icon-color-subdued: var(
+ --tt-gray-light-a-400
+ ); //less important badge
+ --tt-badge-icon-color-emphasized: var(
+ --tt-brand-color-600
+ ); //more important badge
+
+ /* Dark mode */
+ .dark & {
+ --tt-badge-border-color: var(--tt-gray-dark-a-200);
+ --tt-badge-border-color-subdued: var(--tt-gray-dark-a-200);
+ --tt-badge-border-color-emphasized: var(--tt-gray-dark-a-500);
+ --tt-badge-text-color: var(--tt-gray-dark-a-500);
+ --tt-badge-text-color-subdued: var(
+ --tt-gray-dark-a-400
+ ); //less important badge
+ --tt-badge-text-color-emphasized: var(
+ --tt-gray-dark-a-600
+ ); //more important badge
+ --tt-badge-bg-color: var(--black);
+ --tt-badge-bg-color-subdued: var(--black); //less important badge
+ --tt-badge-bg-color-emphasized: var(--black); //more important badge
+ --tt-badge-icon-color: var(--tt-gray-dark-a-500);
+ --tt-badge-icon-color-subdued: var(
+ --tt-gray-dark-a-400
+ ); //less important badge
+ --tt-badge-icon-color-emphasized: var(
+ --tt-brand-color-400
+ ); //more important badge
+ }
+
+ /**************************************************
+ Ghost
+ **************************************************/
+
+ &[data-style="ghost"] {
+ /* Light mode */
+ --tt-badge-border-color: var(--tt-gray-light-a-200);
+ --tt-badge-border-color-subdued: var(--tt-gray-light-a-200);
+ --tt-badge-border-color-emphasized: var(--tt-gray-light-a-600);
+ --tt-badge-text-color: var(--tt-gray-light-a-500);
+ --tt-badge-text-color-subdued: var(
+ --tt-gray-light-a-400
+ ); //less important badge
+ --tt-badge-text-color-emphasized: var(
+ --tt-gray-light-a-600
+ ); //more important badge
+ --tt-badge-bg-color: var(--transparent);
+ --tt-badge-bg-color-subdued: var(--transparent); //less important badge
+ --tt-badge-bg-color-emphasized: var(--transparent); //more important badge
+ --tt-badge-icon-color: var(--tt-gray-light-a-500);
+ --tt-badge-icon-color-subdued: var(
+ --tt-gray-light-a-400
+ ); //less important badge
+ --tt-badge-icon-color-emphasized: var(
+ --tt-brand-color-600
+ ); //more important badge
+
+ /* Dark mode */
+ .dark & {
+ --tt-badge-border-color: var(--tt-gray-dark-a-200);
+ --tt-badge-border-color-subdued: var(--tt-gray-dark-a-200);
+ --tt-badge-border-color-emphasized: var(--tt-gray-dark-a-500);
+ --tt-badge-text-color: var(--tt-gray-dark-a-500);
+ --tt-badge-text-color-subdued: var(
+ --tt-gray-dark-a-400
+ ); //less important badge
+ --tt-badge-text-color-emphasized: var(
+ --tt-gray-dark-a-600
+ ); //more important badge
+ --tt-badge-bg-color: var(--transparent);
+ --tt-badge-bg-color-subdued: var(--transparent); //less important badge
+ --tt-badge-bg-color-emphasized: var(--transparent); //more important badge
+ --tt-badge-icon-color: var(--tt-gray-dark-a-500);
+ --tt-badge-icon-color-subdued: var(
+ --tt-gray-dark-a-400
+ ); //less important badge
+ --tt-badge-icon-color-emphasized: var(
+ --tt-brand-color-400
+ ); //more important badge
+ }
+ }
+
+ /**************************************************
+ Gray
+ **************************************************/
+
+ &[data-style="gray"] {
+ /* Light mode */
+ --tt-badge-border-color: var(--tt-gray-light-a-200);
+ --tt-badge-border-color-subdued: var(--tt-gray-light-a-200);
+ --tt-badge-border-color-emphasized: var(--tt-gray-light-a-500);
+ --tt-badge-text-color: var(--tt-gray-light-a-500);
+ --tt-badge-text-color-subdued: var(
+ --tt-gray-light-a-400
+ ); //less important badge
+ --tt-badge-text-color-emphasized: var(--white); //more important badge
+ --tt-badge-bg-color: var(--tt-gray-light-a-100);
+ --tt-badge-bg-color-subdued: var(
+ --tt-gray-light-a-50
+ ); //less important badge
+ --tt-badge-bg-color-emphasized: var(
+ --tt-gray-light-a-700
+ ); //more important badge
+ --tt-badge-icon-color: var(--tt-gray-light-a-500);
+ --tt-badge-icon-color-subdued: var(
+ --tt-gray-light-a-400
+ ); //less important badge
+ --tt-badge-icon-color-emphasized: var(--white); //more important badge
+
+ /* Dark mode */
+ .dark & {
+ --tt-badge-border-color: var(--tt-gray-dark-a-200);
+ --tt-badge-border-color-subdued: var(--tt-gray-dark-a-200);
+ --tt-badge-border-color-emphasized: var(--tt-gray-dark-a-500);
+ --tt-badge-text-color: var(--tt-gray-dark-a-500);
+ --tt-badge-text-color-subdued: var(
+ --tt-gray-dark-a-400
+ ); //less important badge
+ --tt-badge-text-color-emphasized: var(--black); //more important badge
+ --tt-badge-bg-color: var(--tt-gray-dark-a-100);
+ --tt-badge-bg-color-subdued: var(
+ --tt-gray-dark-a-50
+ ); //less important badge
+ --tt-badge-bg-color-emphasized: var(
+ --tt-gray-dark-a-800
+ ); //more important badge
+ --tt-badge-icon-color: var(--tt-gray-dark-a-500);
+ --tt-badge-icon-color-subdued: var(
+ --tt-gray-dark-a-400
+ ); //less important badge
+ --tt-badge-icon-color-emphasized: var(--black); //more important badge
+ }
+ }
+
+ /**************************************************
+ Green
+ **************************************************/
+
+ &[data-style="green"] {
+ /* Light mode */
+ --tt-badge-border-color: var(--tt-color-green-inc-2);
+ --tt-badge-border-color-subdued: var(--tt-color-green-inc-3);
+ --tt-badge-border-color-emphasized: var(--tt-color-green-dec-2);
+ --tt-badge-text-color: var(--tt-color-green-dec-3);
+ --tt-badge-text-color-subdued: var(
+ --tt-color-green-dec-2
+ ); //less important badge
+ --tt-badge-text-color-emphasized: var(
+ --tt-color-green-inc-5
+ ); //more important badge
+ --tt-badge-bg-color: var(--tt-color-green-inc-4);
+ --tt-badge-bg-color-subdued: var(
+ --tt-color-green-inc-5
+ ); //less important badge
+ --tt-badge-bg-color-emphasized: var(
+ --tt-color-green-dec-1
+ ); //more important badge
+ --tt-badge-icon-color: var(--tt-color-green-dec-3);
+ --tt-badge-icon-color-subdued: var(
+ --tt-color-green-dec-2
+ ); //less important badge
+ --tt-badge-icon-color-emphasized: var(
+ --tt-color-green-inc-5
+ ); //more important badge
+
+ /* Dark mode */
+ .dark & {
+ --tt-badge-border-color: var(--tt-color-green-dec-2);
+ --tt-badge-border-color-subdued: var(--tt-color-green-dec-3);
+ --tt-badge-border-color-emphasized: var(--tt-color-green-base);
+ --tt-badge-text-color: var(--tt-color-green-inc-3);
+ --tt-badge-text-color-subdued: var(
+ --tt-color-green-inc-2
+ ); //less important badge
+ --tt-badge-text-color-emphasized: var(
+ --tt-color-green-dec-5
+ ); //more important badge
+ --tt-badge-bg-color: var(--tt-color-green-dec-4);
+ --tt-badge-bg-color-subdued: var(
+ --tt-color-green-dec-5
+ ); //less important badge
+ --tt-badge-bg-color-emphasized: var(
+ --tt-color-green-inc-1
+ ); //more important badge
+ --tt-badge-icon-color: var(--tt-color-green-inc-3);
+ --tt-badge-icon-color-subdued: var(
+ --tt-color-green-inc-2
+ ); //less important badge
+ --tt-badge-icon-color-emphasized: var(
+ --tt-color-green-dec-5
+ ); //more important badge
+ }
+ }
+
+ /**************************************************
+ Yellow
+ **************************************************/
+
+ &[data-style="yellow"] {
+ /* Light mode */
+ --tt-badge-border-color: var(--tt-color-yellow-inc-2);
+ --tt-badge-border-color-subdued: var(--tt-color-yellow-inc-3);
+ --tt-badge-border-color-emphasized: var(--tt-color-yellow-dec-1);
+ --tt-badge-text-color: var(--tt-color-yellow-dec-3);
+ --tt-badge-text-color-subdued: var(
+ --tt-color-yellow-dec-2
+ ); //less important badge
+ --tt-badge-text-color-emphasized: var(
+ --tt-color-yellow-dec-3
+ ); //more important badge
+ --tt-badge-bg-color: var(--tt-color-yellow-inc-4);
+ --tt-badge-bg-color-subdued: var(
+ --tt-color-yellow-inc-5
+ ); //less important badge
+ --tt-badge-bg-color-emphasized: var(
+ --tt-color-yellow-base
+ ); //more important badge
+ --tt-badge-icon-color: var(--tt-color-yellow-dec-3);
+ --tt-badge-icon-color-subdued: var(
+ --tt-color-yellow-dec-2
+ ); //less important badge
+ --tt-badge-icon-color-emphasized: var(
+ --tt-color-yellow-dec-3
+ ); //more important badge
+
+ /* Dark mode */
+ .dark & {
+ --tt-badge-border-color: var(--tt-color-yellow-dec-2);
+ --tt-badge-border-color-subdued: var(--tt-color-yellow-dec-3);
+ --tt-badge-border-color-emphasized: var(--tt-color-yellow-inc-1);
+ --tt-badge-text-color: var(--tt-color-yellow-inc-3);
+ --tt-badge-text-color-subdued: var(
+ --tt-color-yellow-inc-2
+ ); //less important badge
+ --tt-badge-text-color-emphasized: var(
+ --tt-color-yellow-dec-3
+ ); //more important badge
+ --tt-badge-bg-color: var(--tt-color-yellow-dec-4);
+ --tt-badge-bg-color-subdued: var(
+ --tt-color-yellow-dec-5
+ ); //less important badge
+ --tt-badge-bg-color-emphasized: var(
+ --tt-color-yellow-base
+ ); //more important badge
+ --tt-badge-icon-color: var(--tt-color-yellow-inc-3);
+ --tt-badge-icon-color-subdued: var(
+ --tt-color-yellow-inc-2
+ ); //less important badge
+ --tt-badge-icon-color-emphasized: var(
+ --tt-color-yellow-dec-3
+ ); //more important badge
+ }
+ }
+
+ /**************************************************
+ Red
+ **************************************************/
+
+ &[data-style="red"] {
+ /* Light mode */
+ --tt-badge-border-color: var(--tt-color-red-inc-2);
+ --tt-badge-border-color-subdued: var(--tt-color-red-inc-3);
+ --tt-badge-border-color-emphasized: var(--tt-color-red-dec-2);
+ --tt-badge-text-color: var(--tt-color-red-dec-3);
+ --tt-badge-text-color-subdued: var(
+ --tt-color-red-dec-2
+ ); //less important badge
+ --tt-badge-text-color-emphasized: var(
+ --tt-color-red-inc-5
+ ); //more important badge
+ --tt-badge-bg-color: var(--tt-color-red-inc-4);
+ --tt-badge-bg-color-subdued: var(
+ --tt-color-red-inc-5
+ ); //less important badge
+ --tt-badge-bg-color-emphasized: var(
+ --tt-color-red-dec-1
+ ); //more important badge
+ --tt-badge-icon-color: var(--tt-color-red-dec-3);
+ --tt-badge-icon-color-subdued: var(
+ --tt-color-red-dec-2
+ ); //less important badge
+ --tt-badge-icon-color-emphasized: var(
+ --tt-color-red-inc-5
+ ); //more important badge
+
+ /* Dark mode */
+ .dark & {
+ --tt-badge-border-color: var(--tt-color-red-dec-2);
+ --tt-badge-border-color-subdued: var(--tt-color-red-dec-3);
+ --tt-badge-border-color-emphasized: var(--tt-color-red-base);
+ --tt-badge-text-color: var(--tt-color-red-inc-3);
+ --tt-badge-text-color-subdued: var(
+ --tt-color-red-inc-2
+ ); //less important badge
+ --tt-badge-text-color-emphasized: var(
+ --tt-color-red-dec-5
+ ); //more important badge
+ --tt-badge-bg-color: var(--tt-color-red-dec-4);
+ --tt-badge-bg-color-subdued: var(
+ --tt-color-red-dec-5
+ ); //less important badge
+ --tt-badge-bg-color-emphasized: var(
+ --tt-color-red-inc-1
+ ); //more important badge
+ --tt-badge-icon-color: var(--tt-color-red-inc-3);
+ --tt-badge-icon-color-subdued: var(
+ --tt-color-red-inc-2
+ ); //less important badge
+ --tt-badge-icon-color-emphasized: var(
+ --tt-color-red-dec-5
+ ); //more important badge
+ }
+ }
+
+ /**************************************************
+ Brand
+ **************************************************/
+
+ &[data-style="brand"] {
+ /* Light mode */
+ --tt-badge-border-color: var(--tt-brand-color-300);
+ --tt-badge-border-color-subdued: var(--tt-brand-color-200);
+ --tt-badge-border-color-emphasized: var(--tt-brand-color-600);
+ --tt-badge-text-color: var(--tt-brand-color-800);
+ --tt-badge-text-color-subdued: var(
+ --tt-brand-color-700
+ ); //less important badge
+ --tt-badge-text-color-emphasized: var(
+ --tt-brand-color-50
+ ); //more important badge
+ --tt-badge-bg-color: var(--tt-brand-color-100);
+ --tt-badge-bg-color-subdued: var(
+ --tt-brand-color-50
+ ); //less important badge
+ --tt-badge-bg-color-emphasized: var(
+ --tt-brand-color-600
+ ); //more important badge
+ --tt-badge-icon-color: var(--tt-brand-color-800);
+ --tt-badge-icon-color-subdued: var(
+ --tt-brand-color-700
+ ); //less important badge
+ --tt-badge-icon-color-emphasized: var(
+ --tt-brand-color-100
+ ); //more important badge
+
+ /* Dark mode */
+ .dark & {
+ --tt-badge-border-color: var(--tt-brand-color-700);
+ --tt-badge-border-color-subdued: var(--tt-brand-color-800);
+ --tt-badge-border-color-emphasized: var(--tt-brand-color-400);
+ --tt-badge-text-color: var(--tt-brand-color-200);
+ --tt-badge-text-color-subdued: var(
+ --tt-brand-color-300
+ ); //less important badge
+ --tt-badge-text-color-emphasized: var(
+ --tt-brand-color-950
+ ); //more important badge
+ --tt-badge-bg-color: var(--tt-brand-color-900);
+ --tt-badge-bg-color-subdued: var(
+ --tt-brand-color-950
+ ); //less important badge
+ --tt-badge-bg-color-emphasized: var(
+ --tt-brand-color-400
+ ); //more important badge
+ --tt-badge-icon-color: var(--tt-brand-color-200);
+ --tt-badge-icon-color-subdued: var(
+ --tt-brand-color-300
+ ); //less important badge
+ --tt-badge-icon-color-emphasized: var(
+ --tt-brand-color-900
+ ); //more important badge
+ }
+ }
+}
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/badge/badge-group.scss b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/badge/badge-group.scss
new file mode 100644
index 0000000000..91bd45b10e
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/badge/badge-group.scss
@@ -0,0 +1,16 @@
+.tiptap-badge-group {
+ align-items: center;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.25rem;
+}
+
+.tiptap-badge-group {
+ [data-orientation="vertical"] {
+ flex-direction: column;
+ }
+
+ [data-orientation="horizontal"] {
+ flex-direction: row;
+ }
+}
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/badge/badge.scss b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/badge/badge.scss
new file mode 100644
index 0000000000..b2ca9a8829
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/badge/badge.scss
@@ -0,0 +1,99 @@
+.tiptap-badge {
+ font-size: 0.625rem;
+ font-weight: 700;
+ font-feature-settings:
+ "salt" on,
+ "cv01" on;
+ line-height: 1.15;
+ height: 1.25rem;
+ min-width: 1.25rem;
+ padding: 0.25rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: solid 1px;
+ border-radius: var(--tt-radius-sm, 0.375rem);
+ transition-property: background, color, opacity;
+ transition-duration: var(--tt-transition-duration-default);
+ transition-timing-function: var(--tt-transition-easing-default);
+
+ /* button size large */
+ &[data-size="large"] {
+ font-size: 0.75rem;
+ height: 1.5rem;
+ min-width: 1.5rem;
+ padding: 0.375rem;
+ border-radius: var(--tt-radius-md, 0.375rem);
+ }
+
+ /* button size small */
+ &[data-size="small"] {
+ height: 1rem;
+ min-width: 1rem;
+ padding: 0.125rem;
+ border-radius: var(--tt-radius-xs, 0.25rem);
+ }
+
+ /* trim / expand text of the button */
+ .tiptap-badge-text {
+ padding: 0 0.125rem;
+ flex-grow: 1;
+ text-align: left;
+ }
+
+ &[data-text-trim="on"] {
+ .tiptap-badge-text {
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+ }
+
+ /* standard icon, what is used */
+ .tiptap-badge-icon {
+ pointer-events: none;
+ flex-shrink: 0;
+ width: 0.625rem;
+ height: 0.625rem;
+ }
+
+ &[data-size="large"] .tiptap-badge-icon {
+ width: 0.75rem;
+ height: 0.75rem;
+ }
+}
+
+/* --------------------------------------------
+----------- BADGE COLOR SETTINGS -------------
+-------------------------------------------- */
+
+.tiptap-badge {
+ background-color: var(--tt-badge-bg-color);
+ border-color: var(--tt-badge-border-color);
+ color: var(--tt-badge-text-color);
+
+ .tiptap-badge-icon {
+ color: var(--tt-badge-icon-color);
+ }
+
+ /* Emphasized */
+ &[data-appearance="emphasized"] {
+ background-color: var(--tt-badge-bg-color-emphasized);
+ border-color: var(--tt-badge-border-color-emphasized);
+ color: var(--tt-badge-text-color-emphasized);
+
+ .tiptap-badge-icon {
+ color: var(--tt-badge-icon-color-emphasized);
+ }
+ }
+
+ /* Subdued */
+ &[data-appearance="subdued"] {
+ background-color: var(--tt-badge-bg-color-subdued);
+ border-color: var(--tt-badge-border-color-subdued);
+ color: var(--tt-badge-text-color-subdued);
+
+ .tiptap-badge-icon {
+ color: var(--tt-badge-icon-color-subdued);
+ }
+ }
+}
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/badge/badge.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/badge/badge.tsx
new file mode 100644
index 0000000000..13b7ba1f6f
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/badge/badge.tsx
@@ -0,0 +1,44 @@
+import React, { forwardRef } from "react"
+import "./badge-colors.scss"
+import "./badge-group.scss"
+import "./badge.scss"
+
+export interface BadgeProps extends React.HTMLAttributes {
+ variant?: "ghost" | "white" | "gray" | "green" | "default"
+ size?: "default" | "small"
+ appearance?: "default" | "subdued" | "emphasized"
+ trimText?: boolean
+}
+
+export const Badge = forwardRef(
+ (
+ {
+ variant,
+ size = "default",
+ appearance = "default",
+ trimText = false,
+ className,
+ children,
+ ...props
+ },
+ ref,
+ ) => {
+ return (
+
+ {children}
+
+ )
+ },
+)
+
+Badge.displayName = "Badge"
+
+export default Badge
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/badge/index.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/badge/index.tsx
new file mode 100644
index 0000000000..051fa6ea23
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/badge/index.tsx
@@ -0,0 +1 @@
+export * from "./badge"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/button/button-colors.scss b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/button/button-colors.scss
new file mode 100644
index 0000000000..c9683e4252
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/button/button-colors.scss
@@ -0,0 +1,429 @@
+.tiptap-button {
+ /**************************************************
+ Default button background color
+ **************************************************/
+
+ /* Light mode */
+ --tt-button-default-bg-color: var(--tt-gray-light-a-100);
+ --tt-button-hover-bg-color: var(--tt-gray-light-200);
+ --tt-button-active-bg-color: var(--tt-gray-light-a-200);
+ --tt-button-active-bg-color-emphasized: var(
+ --tt-brand-color-100
+ ); //more important active state
+ --tt-button-active-bg-color-subdued: var(
+ --tt-gray-light-a-200
+ ); //less important active state
+ --tt-button-active-hover-bg-color: var(--tt-gray-light-300);
+ --tt-button-active-hover-bg-color-emphasized: var(
+ --tt-brand-color-200
+ ); //more important active state hover
+ --tt-button-active-hover-bg-color-subdued: var(
+ --tt-gray-light-a-300
+ ); //less important active state hover
+ --tt-button-disabled-bg-color: var(--tt-gray-light-a-50);
+
+ /* Dark mode */
+ .dark & {
+ --tt-button-default-bg-color: var(--tt-gray-dark-a-100);
+ --tt-button-hover-bg-color: var(--tt-gray-dark-200);
+ --tt-button-active-bg-color: var(--tt-gray-dark-a-200);
+ --tt-button-active-bg-color-emphasized: var(
+ --tt-brand-color-900
+ ); //more important active state
+ --tt-button-active-bg-color-subdued: var(
+ --tt-gray-dark-a-200
+ ); //less important active state
+ --tt-button-active-hover-bg-color: var(--tt-gray-dark-300);
+ --tt-button-active-hover-bg-color-emphasized: var(
+ --tt-brand-color-800
+ ); //more important active state hover
+ --tt-button-active-hover-bg-color-subdued: var(
+ --tt-gray-dark-a-300
+ ); //less important active state hover
+ --tt-button-disabled-bg-color: var(--tt-gray-dark-a-50);
+ }
+
+ /**************************************************
+ Default button text color
+ **************************************************/
+
+ /* Light mode */
+ --tt-button-default-text-color: var(--tt-gray-light-a-600);
+ --tt-button-hover-text-color: var(--tt-gray-light-a-900);
+ --tt-button-active-text-color: var(--tt-gray-light-a-900);
+ --tt-button-active-text-color-emphasized: var(--tt-gray-light-a-900);
+ --tt-button-active-text-color-subdued: var(--tt-gray-light-a-900);
+ --tt-button-disabled-text-color: var(--tt-gray-light-a-400);
+
+ /* Dark mode */
+ .dark & {
+ --tt-button-default-text-color: var(--tt-gray-dark-a-600);
+ --tt-button-hover-text-color: var(--tt-gray-dark-a-900);
+ --tt-button-active-text-color: var(--tt-gray-dark-a-900);
+ --tt-button-active-text-color-emphasized: var(--tt-gray-dark-a-900);
+ --tt-button-active-text-color-subdued: var(--tt-gray-dark-a-900);
+ --tt-button-disabled-text-color: var(--tt-gray-dark-a-300);
+ }
+
+ /**************************************************
+ Default button icon color
+ **************************************************/
+
+ /* Light mode */
+ --tt-button-default-icon-color: var(--tt-gray-light-a-600);
+ --tt-button-hover-icon-color: var(--tt-gray-light-a-900);
+ --tt-button-active-icon-color: var(--tt-brand-color-500);
+ --tt-button-active-icon-color-emphasized: var(--tt-brand-color-600);
+ --tt-button-active-icon-color-subdued: var(--tt-gray-light-a-900);
+ --tt-button-disabled-icon-color: var(--tt-gray-light-a-400);
+
+ /* Dark mode */
+ .dark & {
+ --tt-button-default-icon-color: var(--tt-gray-dark-a-600);
+ --tt-button-hover-icon-color: var(--tt-gray-dark-a-900);
+ --tt-button-active-icon-color: var(--tt-brand-color-400);
+ --tt-button-active-icon-color-emphasized: var(--tt-brand-color-400);
+ --tt-button-active-icon-color-subdued: var(--tt-gray-dark-a-900);
+ --tt-button-disabled-icon-color: var(--tt-gray-dark-a-400);
+ }
+
+ /**************************************************
+ Default button subicon color
+ **************************************************/
+
+ /* Light mode */
+ --tt-button-default-icon-sub-color: var(--tt-gray-light-a-400);
+ --tt-button-hover-icon-sub-color: var(--tt-gray-light-a-500);
+ --tt-button-active-icon-sub-color: var(--tt-gray-light-a-400);
+ --tt-button-active-icon-sub-color-emphasized: var(--tt-gray-light-a-500);
+ --tt-button-active-icon-sub-color-subdued: var(--tt-gray-light-a-400);
+ --tt-button-disabled-icon-sub-color: var(--tt-gray-light-a-100);
+
+ /* Dark mode */
+ .dark & {
+ --tt-button-default-icon-sub-color: var(--tt-gray-dark-a-300);
+ --tt-button-hover-icon-sub-color: var(--tt-gray-dark-a-400);
+ --tt-button-active-icon-sub-color: var(--tt-gray-dark-a-300);
+ --tt-button-active-icon-sub-color-emphasized: var(--tt-gray-dark-a-400);
+ --tt-button-active-icon-sub-color-subdued: var(--tt-gray-dark-a-300);
+ --tt-button-disabled-icon-sub-color: var(--tt-gray-dark-a-100);
+ }
+
+ /**************************************************
+ Default button dropdown / arrows color
+ **************************************************/
+
+ /* Light mode */
+ --tt-button-default-dropdown-arrows-color: var(--tt-gray-light-a-600);
+ --tt-button-hover-dropdown-arrows-color: var(--tt-gray-light-a-700);
+ --tt-button-active-dropdown-arrows-color: var(--tt-gray-light-a-600);
+ --tt-button-active-dropdown-arrows-color-emphasized: var(
+ --tt-gray-light-a-700
+ );
+ --tt-button-active-dropdown-arrows-color-subdued: var(--tt-gray-light-a-600);
+ --tt-button-disabled-dropdown-arrows-color: var(--tt-gray-light-a-400);
+
+ /* Dark mode */
+ .dark & {
+ --tt-button-default-dropdown-arrows-color: var(--tt-gray-dark-a-600);
+ --tt-button-hover-dropdown-arrows-color: var(--tt-gray-dark-a-700);
+ --tt-button-active-dropdown-arrows-color: var(--tt-gray-dark-a-600);
+ --tt-button-active-dropdown-arrows-color-emphasized: var(
+ --tt-gray-dark-a-700
+ );
+ --tt-button-active-dropdown-arrows-color-subdued: var(--tt-gray-dark-a-600);
+ --tt-button-disabled-dropdown-arrows-color: var(--tt-gray-dark-a-400);
+ }
+
+ /* ----------------------------------------------------------------
+ --------------------------- GHOST BUTTON --------------------------
+ ---------------------------------------------------------------- */
+
+ &[data-style="ghost"] {
+ /**************************************************
+ Ghost button background color
+ **************************************************/
+
+ /* Light mode */
+ --tt-button-default-bg-color: var(--transparent);
+ --tt-button-hover-bg-color: var(--tt-gray-light-200);
+ --tt-button-active-bg-color: var(--tt-gray-light-a-100);
+ --tt-button-active-bg-color-emphasized: var(
+ --tt-brand-color-100
+ ); //more important active state
+ --tt-button-active-bg-color-subdued: var(
+ --tt-gray-light-a-100
+ ); //less important active state
+ --tt-button-active-hover-bg-color: var(--tt-gray-light-200);
+ --tt-button-active-hover-bg-color-emphasized: var(
+ --tt-brand-color-200
+ ); //more important active state hover
+ --tt-button-active-hover-bg-color-subdued: var(
+ --tt-gray-light-a-200
+ ); //less important active state hover
+ --tt-button-disabled-bg-color: var(--transparent);
+
+ /* Dark mode */
+ .dark & {
+ --tt-button-default-bg-color: var(--transparent);
+ --tt-button-hover-bg-color: var(--tt-gray-dark-200);
+ --tt-button-active-bg-color: var(--tt-gray-dark-a-100);
+ --tt-button-active-bg-color-emphasized: var(
+ --tt-brand-color-900
+ ); //more important active state
+ --tt-button-active-bg-color-subdued: var(
+ --tt-gray-dark-a-100
+ ); //less important active state
+ --tt-button-active-hover-bg-color: var(--tt-gray-dark-200);
+ --tt-button-active-hover-bg-color-emphasized: var(
+ --tt-brand-color-800
+ ); //more important active state hover
+ --tt-button-active-hover-bg-color-subdued: var(
+ --tt-gray-dark-a-200
+ ); //less important active state hover
+ --tt-button-disabled-bg-color: var(--transparent);
+ }
+
+ /**************************************************
+ Ghost button text color
+ **************************************************/
+
+ /* Light mode */
+ --tt-button-default-text-color: var(--tt-gray-light-a-600);
+ --tt-button-hover-text-color: var(--tt-gray-light-a-900);
+ --tt-button-active-text-color: var(--tt-gray-light-a-900);
+ --tt-button-active-text-color-emphasized: var(--tt-gray-light-a-900);
+ --tt-button-active-text-color-subdued: var(--tt-gray-light-a-900);
+ --tt-button-disabled-text-color: var(--tt-gray-light-a-400);
+
+ /* Dark mode */
+ .dark & {
+ --tt-button-default-text-color: var(--tt-gray-dark-a-600);
+ --tt-button-hover-text-color: var(--tt-gray-dark-a-900);
+ --tt-button-active-text-color: var(--tt-gray-dark-a-900);
+ --tt-button-active-text-color-emphasized: var(--tt-gray-dark-a-900);
+ --tt-button-active-text-color-subdued: var(--tt-gray-dark-a-900);
+ --tt-button-disabled-text-color: var(--tt-gray-dark-a-300);
+ }
+
+ /**************************************************
+ Ghost button icon color
+ **************************************************/
+
+ /* Light mode */
+ --tt-button-default-icon-color: var(--tt-gray-light-a-600);
+ --tt-button-hover-icon-color: var(--tt-gray-light-a-900);
+ --tt-button-active-icon-color: var(--tt-brand-color-500);
+ --tt-button-active-icon-color-emphasized: var(--tt-brand-color-600);
+ --tt-button-active-icon-color-subdued: var(--tt-gray-light-a-900);
+ --tt-button-disabled-icon-color: var(--tt-gray-light-a-400);
+
+ /* Dark mode */
+ .dark & {
+ --tt-button-default-icon-color: var(--tt-gray-dark-a-600);
+ --tt-button-hover-icon-color: var(--tt-gray-dark-a-900);
+ --tt-button-active-icon-color: var(--tt-brand-color-400);
+ --tt-button-active-icon-color-emphasized: var(--tt-brand-color-300);
+ --tt-button-active-icon-color-subdued: var(--tt-gray-dark-a-900);
+ --tt-button-disabled-icon-color: var(--tt-gray-dark-a-400);
+ }
+
+ /**************************************************
+ Ghost button subicon color
+ **************************************************/
+
+ /* Light mode */
+ --tt-button-default-icon-sub-color: var(--tt-gray-light-a-400);
+ --tt-button-hover-icon-sub-color: var(--tt-gray-light-a-500);
+ --tt-button-active-icon-sub-color: var(--tt-gray-light-a-400);
+ --tt-button-active-icon-sub-color-emphasized: var(--tt-gray-light-a-500);
+ --tt-button-active-icon-sub-color-subdued: var(--tt-gray-light-a-400);
+ --tt-button-disabled-icon-sub-color: var(--tt-gray-light-a-100);
+
+ /* Dark mode */
+ .dark & {
+ --tt-button-default-icon-sub-color: var(--tt-gray-dark-a-300);
+ --tt-button-hover-icon-sub-color: var(--tt-gray-dark-a-400);
+ --tt-button-active-icon-sub-color: var(--tt-gray-dark-a-300);
+ --tt-button-active-icon-sub-color-emphasized: var(--tt-gray-dark-a-400);
+ --tt-button-active-icon-sub-color-subdued: var(--tt-gray-dark-a-300);
+ --tt-button-disabled-icon-sub-color: var(--tt-gray-dark-a-100);
+ }
+
+ /**************************************************
+ Ghost button dropdown / arrows color
+ **************************************************/
+
+ /* Light mode */
+ --tt-button-default-dropdown-arrows-color: var(--tt-gray-light-a-600);
+ --tt-button-hover-dropdown-arrows-color: var(--tt-gray-light-a-700);
+ --tt-button-active-dropdown-arrows-color: var(--tt-gray-light-a-600);
+ --tt-button-active-dropdown-arrows-color-emphasized: var(
+ --tt-gray-light-a-700
+ );
+ --tt-button-active-dropdown-arrows-color-subdued: var(
+ --tt-gray-light-a-600
+ );
+ --tt-button-disabled-dropdown-arrows-color: var(--tt-gray-light-a-400);
+
+ /* Dark mode */
+ .dark & {
+ --tt-button-default-dropdown-arrows-color: var(--tt-gray-dark-a-600);
+ --tt-button-hover-dropdown-arrows-color: var(--tt-gray-dark-a-700);
+ --tt-button-active-dropdown-arrows-color: var(--tt-gray-dark-a-600);
+ --tt-button-active-dropdown-arrows-color-emphasized: var(
+ --tt-gray-dark-a-700
+ );
+ --tt-button-active-dropdown-arrows-color-subdued: var(
+ --tt-gray-dark-a-600
+ );
+ --tt-button-disabled-dropdown-arrows-color: var(--tt-gray-dark-a-400);
+ }
+ }
+
+ /* ----------------------------------------------------------------
+ -------------------------- PRIMARY BUTTON -------------------------
+ ---------------------------------------------------------------- */
+
+ &[data-style="primary"] {
+ /**************************************************
+ Primary button background color
+ **************************************************/
+
+ /* Light mode */
+ --tt-button-default-bg-color: var(--tt-brand-color-500);
+ --tt-button-hover-bg-color: var(--tt-brand-color-600);
+ --tt-button-active-bg-color: var(--tt-brand-color-100);
+ --tt-button-active-bg-color-emphasized: var(
+ --tt-brand-color-100
+ ); //more important active state
+ --tt-button-active-bg-color-subdued: var(
+ --tt-brand-color-100
+ ); //less important active state
+ --tt-button-active-hover-bg-color: var(--tt-brand-color-200);
+ --tt-button-active-hover-bg-color-emphasized: var(
+ --tt-brand-color-200
+ ); //more important active state hover
+ --tt-button-active-hover-bg-color-subdued: var(
+ --tt-brand-color-200
+ ); //less important active state hover
+ --tt-button-disabled-bg-color: var(--tt-gray-light-a-100);
+
+ /* Dark mode */
+ .dark & {
+ --tt-button-default-bg-color: var(--tt-brand-color-500);
+ --tt-button-hover-bg-color: var(--tt-brand-color-600);
+ --tt-button-active-bg-color: var(--tt-brand-color-900);
+ --tt-button-active-bg-color-emphasized: var(
+ --tt-brand-color-900
+ ); //more important active state
+ --tt-button-active-bg-color-subdued: var(
+ --tt-brand-color-900
+ ); //less important active state
+ --tt-button-active-hover-bg-color: var(--tt-brand-color-800);
+ --tt-button-active-hover-bg-color-emphasized: var(
+ --tt-brand-color-800
+ ); //more important active state hover
+ --tt-button-active-hover-bg-color-subdued: var(
+ --tt-brand-color-800
+ ); //less important active state hover
+ --tt-button-disabled-bg-color: var(--tt-gray-dark-a-100);
+ }
+
+ /**************************************************
+ Primary button text color
+ **************************************************/
+
+ /* Light mode */
+ --tt-button-default-text-color: var(--white);
+ --tt-button-hover-text-color: var(--white);
+ --tt-button-active-text-color: var(--tt-gray-light-a-900);
+ --tt-button-active-text-color-emphasized: var(--tt-gray-light-a-900);
+ --tt-button-active-text-color-subdued: var(--tt-gray-light-a-900);
+ --tt-button-disabled-text-color: var(--tt-gray-light-a-400);
+
+ /* Dark mode */
+ .dark & {
+ --tt-button-default-text-color: var(--white);
+ --tt-button-hover-text-color: var(--white);
+ --tt-button-active-text-color: var(--tt-gray-dark-a-900);
+ --tt-button-active-text-color-emphasized: var(--tt-gray-dark-a-900);
+ --tt-button-active-text-color-subdued: var(--tt-gray-dark-a-900);
+ --tt-button-disabled-text-color: var(--tt-gray-dark-a-300);
+ }
+
+ /**************************************************
+ Primary button icon color
+ **************************************************/
+
+ /* Light mode */
+ --tt-button-default-icon-color: var(--white);
+ --tt-button-hover-icon-color: var(--white);
+ --tt-button-active-icon-color: var(--tt-brand-color-600);
+ --tt-button-active-icon-color-emphasized: var(--tt-brand-color-600);
+ --tt-button-active-icon-color-subdued: var(--tt-brand-color-600);
+ --tt-button-disabled-icon-color: var(--tt-gray-light-a-400);
+
+ /* Dark mode */
+ .dark & {
+ --tt-button-default-icon-color: var(--white);
+ --tt-button-hover-icon-color: var(--white);
+ --tt-button-active-icon-color: var(--tt-brand-color-400);
+ --tt-button-active-icon-color-emphasized: var(--tt-brand-color-400);
+ --tt-button-active-icon-color-subdued: var(--tt-brand-color-400);
+ --tt-button-disabled-icon-color: var(--tt-gray-dark-a-300);
+ }
+
+ /**************************************************
+ Primary button subicon color
+ **************************************************/
+
+ /* Light mode */
+ --tt-button-default-icon-sub-color: var(--tt-gray-dark-a-500);
+ --tt-button-hover-icon-sub-color: var(--tt-gray-dark-a-500);
+ --tt-button-active-icon-sub-color: var(--tt-gray-light-a-500);
+ --tt-button-active-icon-sub-color-emphasized: var(--tt-gray-light-a-500);
+ --tt-button-active-icon-sub-color-subdued: var(--tt-gray-light-a-500);
+ --tt-button-disabled-icon-sub-color: var(--tt-gray-light-a-100);
+
+ /* Dark mode */
+ .dark & {
+ --tt-button-default-icon-sub-color: var(--tt-gray-dark-a-400);
+ --tt-button-hover-icon-sub-color: var(--tt-gray-dark-a-500);
+ --tt-button-active-icon-sub-color: var(--tt-gray-dark-a-300);
+ --tt-button-active-icon-sub-color-emphasized: var(--tt-gray-dark-a-400);
+ --tt-button-active-icon-sub-color-subdued: var(--tt-gray-dark-a-300);
+ --tt-button-disabled-icon-sub-color: var(--tt-gray-dark-a-100);
+ }
+
+ /**************************************************
+ Primary button dropdown / arrows color
+ **************************************************/
+
+ /* Light mode */
+ --tt-button-default-dropdown-arrows-color: var(--white);
+ --tt-button-hover-dropdown-arrows-color: var(--white);
+ --tt-button-active-dropdown-arrows-color: var(--tt-gray-light-a-700);
+ --tt-button-active-dropdown-arrows-color-emphasized: var(
+ --tt-gray-light-a-700
+ );
+ --tt-button-active-dropdown-arrows-color-subdued: var(
+ --tt-gray-light-a-700
+ );
+ --tt-button-disabled-dropdown-arrows-color: var(--tt-gray-light-a-400);
+
+ /* Dark mode */
+ .dark & {
+ --tt-button-default-dropdown-arrows-color: var(--white);
+ --tt-button-hover-dropdown-arrows-color: var(--white);
+ --tt-button-active-dropdown-arrows-color: var(--tt-gray-dark-a-600);
+ --tt-button-active-dropdown-arrows-color-emphasized: var(
+ --tt-gray-dark-a-600
+ );
+ --tt-button-active-dropdown-arrows-color-subdued: var(
+ --tt-gray-dark-a-600
+ );
+ --tt-button-disabled-dropdown-arrows-color: var(--tt-gray-dark-a-400);
+ }
+ }
+}
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/button/button-group.scss b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/button/button-group.scss
new file mode 100644
index 0000000000..59fd2561df
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/button/button-group.scss
@@ -0,0 +1,22 @@
+.tiptap-button-group {
+ position: relative;
+ display: flex;
+ vertical-align: middle;
+
+ &[data-orientation="vertical"] {
+ flex-direction: column;
+ align-items: flex-start;
+ justify-content: center;
+ min-width: max-content;
+
+ > .tiptap-button {
+ width: 100%;
+ }
+ }
+
+ &[data-orientation="horizontal"] {
+ gap: 0.125rem;
+ flex-direction: row;
+ align-items: center;
+ }
+}
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/button/button.scss b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/button/button.scss
new file mode 100644
index 0000000000..32d1499b3c
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/button/button.scss
@@ -0,0 +1,314 @@
+.tiptap-button {
+ font-size: 0.875rem;
+ font-weight: 500;
+ font-feature-settings:
+ "salt" on,
+ "cv01" on;
+ line-height: 1.15;
+ height: 2rem;
+ min-width: 2rem;
+ border: none;
+ padding: 0.5rem;
+ gap: 0.25rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: var(--tt-radius-lg, 0.75rem);
+ transition-property: background, color, opacity;
+ transition-duration: var(--tt-transition-duration-default);
+ transition-timing-function: var(--tt-transition-easing-default);
+
+ // focus-visible
+ &:focus-visible {
+ outline: none;
+ }
+
+ &[data-highlighted="true"],
+ &[data-focus-visible="true"] {
+ background-color: var(--tt-button-hover-bg-color);
+ color: var(--tt-button-hover-text-color);
+ // outline: 2px solid var(--tt-button-active-icon-color);
+ }
+
+ &[data-weight="small"] {
+ width: 1.5rem;
+ min-width: 1.5rem;
+ padding-right: 0;
+ padding-left: 0;
+ }
+
+ /* button size large */
+ &[data-size="large"] {
+ font-size: 0.9375rem;
+ height: 2.375rem;
+ min-width: 2.375rem;
+ padding: 0.625rem;
+ }
+
+ /* button size small */
+ &[data-size="small"] {
+ font-size: 0.75rem;
+ line-height: 1.2;
+ height: 1.5rem;
+ min-width: 1.5rem;
+ padding: 0.3125rem;
+ border-radius: var(--tt-radius-md, 0.5rem);
+ }
+
+ /* trim / expand text of the button */
+ .tiptap-button-text {
+ padding: 0 0.125rem;
+ flex-grow: 1;
+ text-align: left;
+ line-height: 1.5rem;
+ }
+
+ &[data-text-trim="on"] {
+ .tiptap-button-text {
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+ }
+
+ /* global icon settings */
+ .tiptap-button-icon,
+ .tiptap-button-icon-sub,
+ .tiptap-button-dropdown-arrows,
+ .tiptap-button-dropdown-small {
+ flex-shrink: 0;
+ }
+
+ /* standard icon, what is used */
+ .tiptap-button-icon {
+ width: 1rem;
+ height: 1rem;
+ }
+
+ &[data-size="large"] .tiptap-button-icon {
+ width: 1.125rem;
+ height: 1.125rem;
+ }
+
+ &[data-size="small"] .tiptap-button-icon {
+ width: 0.875rem;
+ height: 0.875rem;
+ }
+
+ /* if 2 icons are used and this icon should be more subtle */
+ .tiptap-button-icon-sub {
+ width: 1rem;
+ height: 1rem;
+ }
+
+ &[data-size="large"] .tiptap-button-icon-sub {
+ width: 1.125rem;
+ height: 1.125rem;
+ }
+
+ &[data-size="small"] .tiptap-button-icon-sub {
+ width: 0.875rem;
+ height: 0.875rem;
+ }
+
+ /* dropdown menus or arrows that are slightly smaller */
+ .tiptap-button-dropdown-arrows {
+ width: 0.75rem;
+ height: 0.75rem;
+ }
+
+ &[data-size="large"] .tiptap-button-dropdown-arrows {
+ width: 0.875rem;
+ height: 0.875rem;
+ }
+
+ &[data-size="small"] .tiptap-button-dropdown-arrows {
+ width: 0.625rem;
+ height: 0.625rem;
+ }
+
+ /* dropdown menu for icon buttons only */
+ .tiptap-button-dropdown-small {
+ width: 0.625rem;
+ height: 0.625rem;
+ }
+
+ &[data-size="large"] .tiptap-button-dropdown-small {
+ width: 0.75rem;
+ height: 0.75rem;
+ }
+
+ &[data-size="small"] .tiptap-button-dropdown-small {
+ width: 0.5rem;
+ height: 0.5rem;
+ }
+
+ /* button only has icons */
+ &:has(> svg):not(:has(> :not(svg))) {
+ gap: 0.125rem;
+
+ &[data-size="large"],
+ &[data-size="small"] {
+ gap: 0.125rem;
+ }
+ }
+
+ /* button only has 2 icons and one of them is dropdown small */
+ &:has(> svg:nth-of-type(2)):has(> .tiptap-button-dropdown-small):not(
+ :has(> svg:nth-of-type(3))
+ ):not(:has(> .tiptap-button-text)) {
+ gap: 0;
+ padding-right: 0.25rem;
+
+ &[data-size="large"] {
+ padding-right: 0.375rem;
+ }
+
+ &[data-size="small"] {
+ padding-right: 0.25rem;
+ }
+ }
+
+ /* Emoji is used in a button */
+ .tiptap-button-emoji {
+ width: 1rem;
+ display: flex;
+ justify-content: center;
+ }
+
+ &[data-size="large"] .tiptap-button-emoji {
+ width: 1.125rem;
+ }
+
+ &[data-size="small"] .tiptap-button-emoji {
+ width: 0.875rem;
+ }
+}
+
+/* --------------------------------------------
+----------- BUTTON COLOR SETTINGS -------------
+-------------------------------------------- */
+
+.tiptap-button {
+ background-color: var(--tt-button-default-bg-color);
+ color: var(--tt-button-default-text-color);
+
+ .tiptap-button-icon {
+ color: var(--tt-button-default-icon-color);
+ }
+
+ .tiptap-button-icon-sub {
+ color: var(--tt-button-default-icon-sub-color);
+ }
+
+ .tiptap-button-dropdown-arrows {
+ color: var(--tt-button-default-dropdown-arrows-color);
+ }
+
+ .tiptap-button-dropdown-small {
+ color: var(--tt-button-default-dropdown-arrows-color);
+ }
+
+ /* hover state of a button */
+ &:hover:not([data-active-item="true"]):not([disabled]),
+ &[data-active-item="true"]:not([disabled]),
+ &[data-highlighted]:not([disabled]):not([data-highlighted="false"]) {
+ background-color: var(--tt-button-hover-bg-color);
+ color: var(--tt-button-hover-text-color);
+
+ .tiptap-button-icon {
+ color: var(--tt-button-hover-icon-color);
+ }
+
+ .tiptap-button-icon-sub {
+ color: var(--tt-button-hover-icon-sub-color);
+ }
+
+ .tiptap-button-dropdown-arrows,
+ .tiptap-button-dropdown-small {
+ color: var(--tt-button-hover-dropdown-arrows-color);
+ }
+ }
+
+ /* Active state of a button */
+ &[data-active-state="on"]:not([disabled]),
+ &[data-state="open"]:not([disabled]) {
+ background-color: var(--tt-button-active-bg-color);
+ color: var(--tt-button-active-text-color);
+
+ .tiptap-button-icon {
+ color: var(--tt-button-active-icon-color);
+ }
+
+ .tiptap-button-icon-sub {
+ color: var(--tt-button-active-icon-sub-color);
+ }
+
+ .tiptap-button-dropdown-arrows,
+ .tiptap-button-dropdown-small {
+ color: var(--tt-button-active-dropdown-arrows-color);
+ }
+
+ &:hover {
+ background-color: var(--tt-button-active-hover-bg-color);
+ }
+
+ /* Emphasized */
+ &[data-appearance="emphasized"] {
+ background-color: var(--tt-button-active-bg-color-emphasized);
+ color: var(--tt-button-active-text-color-emphasized);
+
+ .tiptap-button-icon {
+ color: var(--tt-button-active-icon-color-emphasized);
+ }
+
+ .tiptap-button-icon-sub {
+ color: var(--tt-button-active-icon-sub-color-emphasized);
+ }
+
+ .tiptap-button-dropdown-arrows,
+ .tiptap-button-dropdown-small {
+ color: var(--tt-button-active-dropdown-arrows-color-emphasized);
+ }
+
+ &:hover {
+ background-color: var(--tt-button-active-hover-bg-color-emphasized);
+ }
+ }
+
+ /* Subdued */
+ &[data-appearance="subdued"] {
+ background-color: var(--tt-button-active-bg-color-subdued);
+ color: var(--tt-button-active-text-color-subdued);
+
+ .tiptap-button-icon {
+ color: var(--tt-button-active-icon-color-subdued);
+ }
+
+ .tiptap-button-icon-sub {
+ color: var(--tt-button-active-icon-sub-color-subdued);
+ }
+
+ .tiptap-button-dropdown-arrows,
+ .tiptap-button-dropdown-small {
+ color: var(--tt-button-active-dropdown-arrows-color-subdued);
+ }
+
+ &:hover {
+ background-color: var(--tt-button-active-hover-bg-color-subdued);
+
+ .tiptap-button-icon {
+ color: var(--tt-button-active-icon-color-subdued);
+ }
+ }
+ }
+ }
+
+ &:disabled {
+ background-color: var(--tt-button-disabled-bg-color);
+ color: var(--tt-button-disabled-text-color);
+
+ .tiptap-button-icon {
+ color: var(--tt-button-disabled-icon-color);
+ }
+ }
+}
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/button/button.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/button/button.tsx
new file mode 100644
index 0000000000..0cb6506c2b
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/button/button.tsx
@@ -0,0 +1,110 @@
+import React, { forwardRef, Fragment, useMemo } from "react"
+
+// --- Tiptap UI Primitive ---
+import { Tooltip, TooltipContent, TooltipTrigger } from "../tooltip"
+
+// --- Lib ---
+import { cn, parseShortcutKeys } from "../../../lib/tiptap-utils"
+
+import "./button-colors.scss"
+import "./button-group.scss"
+import "./button.scss"
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes {
+ className?: string
+ showTooltip?: boolean
+ tooltip?: React.ReactNode
+ shortcutKeys?: string
+}
+
+export const ShortcutDisplay: React.FC<{ shortcuts: string[] }> = ({
+ shortcuts,
+}) => {
+ if (shortcuts.length === 0) return null
+
+ return (
+
+ {shortcuts.map((key, index) => (
+
+ {index > 0 && +}
+ {key}
+
+ ))}
+
+ )
+}
+
+export const Button = forwardRef(
+ (
+ {
+ className,
+ children,
+ tooltip,
+ showTooltip = true,
+ shortcutKeys,
+ "aria-label": ariaLabel,
+ ...props
+ },
+ ref,
+ ) => {
+ const shortcuts = useMemo(
+ () => parseShortcutKeys({ shortcutKeys }),
+ [shortcutKeys],
+ )
+
+ if (!tooltip || !showTooltip) {
+ return (
+
+ )
+ }
+
+ return (
+
+
+ {children}
+
+
+ {tooltip}
+
+
+
+ )
+ },
+)
+
+Button.displayName = "Button"
+
+export const ButtonGroup = forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<"div"> & {
+ orientation?: "horizontal" | "vertical"
+ }
+>(({ className, children, orientation = "vertical", ...props }, ref) => {
+ return (
+
+ {children}
+
+ )
+})
+ButtonGroup.displayName = "ButtonGroup"
+
+export default Button
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/button/index.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/button/index.tsx
new file mode 100644
index 0000000000..e93d26f6b0
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/button/index.tsx
@@ -0,0 +1 @@
+export * from "./button"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/card/card.scss b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/card/card.scss
new file mode 100644
index 0000000000..97b757e045
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/card/card.scss
@@ -0,0 +1,77 @@
+:root {
+ --tiptap-card-bg-color: var(--white);
+ --tiptap-card-border-color: var(--tt-gray-light-a-100);
+ --tiptap-card-group-label-color: var(--tt-gray-light-a-800);
+}
+
+.dark {
+ --tiptap-card-bg-color: var(--tt-gray-dark-50);
+ --tiptap-card-border-color: var(--tt-gray-dark-a-100);
+ --tiptap-card-group-label-color: var(--tt-gray-dark-a-800);
+}
+
+.tiptap-card {
+ --padding: 0.375rem;
+ --border-width: 1px;
+
+ border-radius: calc(var(--padding) + var(--tt-radius-lg));
+ box-shadow: var(--tt-shadow-elevated-md);
+ background-color: var(--tiptap-card-bg-color);
+ border: 1px solid var(--tiptap-card-border-color);
+ display: flex;
+ flex-direction: column;
+ outline: none;
+ align-items: center;
+
+ position: relative;
+ min-width: 0;
+ word-wrap: break-word;
+ background-clip: border-box;
+}
+
+.tiptap-card-header {
+ padding: 0.375rem;
+ flex: 0 0 auto;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+ border-bottom: var(--border-width) solid var(--tiptap-card-border-color);
+}
+
+.tiptap-card-body {
+ padding: 0.375rem;
+ flex: 1 1 auto;
+ overflow-y: auto;
+}
+
+.tiptap-card-item-group {
+ position: relative;
+ display: flex;
+ vertical-align: middle;
+ min-width: max-content;
+
+ &[data-orientation="vertical"] {
+ flex-direction: column;
+ justify-content: center;
+ }
+
+ &[data-orientation="horizontal"] {
+ gap: 0.25rem;
+ flex-direction: row;
+ align-items: center;
+ }
+}
+
+.tiptap-card-group-label {
+ padding-top: 0.75rem;
+ padding-left: 0.5rem;
+ padding-right: 0.5rem;
+ padding-bottom: 0.25rem;
+ line-height: normal;
+ font-size: 0.75rem;
+ font-weight: 600;
+ line-height: normal;
+ text-transform: capitalize;
+ color: var(--tiptap-card-group-label-color);
+}
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/card/card.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/card/card.tsx
new file mode 100644
index 0000000000..75c53b3391
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/card/card.tsx
@@ -0,0 +1,79 @@
+"use client"
+
+import React, { forwardRef } from "react"
+import { cn } from "../../../lib/tiptap-utils"
+import "./card.scss"
+
+const Card = forwardRef>(
+ ({ className, ...props }, ref) => {
+ return
+ },
+)
+Card.displayName = "Card"
+
+const CardHeader = forwardRef>(
+ ({ className, ...props }, ref) => {
+ return (
+
+ )
+ },
+)
+CardHeader.displayName = "CardHeader"
+
+const CardBody = forwardRef>(
+ ({ className, ...props }, ref) => {
+ return (
+
+ )
+ },
+)
+CardBody.displayName = "CardBody"
+
+const CardItemGroup = forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<"div"> & {
+ orientation?: "horizontal" | "vertical"
+ }
+>(({ className, orientation = "vertical", ...props }, ref) => {
+ return (
+
+ )
+})
+CardItemGroup.displayName = "CardItemGroup"
+
+const CardGroupLabel = forwardRef>(
+ ({ className, ...props }, ref) => {
+ return (
+
+ )
+ },
+)
+CardGroupLabel.displayName = "CardGroupLabel"
+
+const CardFooter = forwardRef>(
+ ({ className, ...props }, ref) => {
+ return (
+
+ )
+ },
+)
+CardFooter.displayName = "CardFooter"
+
+export { Card, CardHeader, CardFooter, CardBody, CardItemGroup, CardGroupLabel }
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/card/index.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/card/index.tsx
new file mode 100644
index 0000000000..288c75f729
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/card/index.tsx
@@ -0,0 +1 @@
+export * from "./card"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.scss b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.scss
new file mode 100644
index 0000000000..03b47e8631
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.scss
@@ -0,0 +1,63 @@
+.tiptap-dropdown-menu {
+ --tt-dropdown-menu-bg-color: var(--white);
+ --tt-dropdown-menu-border-color: var(--tt-gray-light-a-100);
+ --tt-dropdown-menu-text-color: var(--tt-gray-light-a-600);
+
+ .dark & {
+ --tt-dropdown-menu-border-color: var(--tt-gray-dark-a-50);
+ --tt-dropdown-menu-bg-color: var(--tt-gray-dark-50);
+ --tt-dropdown-menu-text-color: var(--tt-gray-dark-a-600);
+ }
+}
+
+/* --------------------------------------------
+ --------- DROPDOWN MENU STYLING SETTINGS -----------
+ -------------------------------------------- */
+.tiptap-dropdown-menu {
+ z-index: 50;
+ outline: none;
+ transform-origin: var(--radix-dropdown-menu-content-transform-origin);
+ max-height: var(--radix-dropdown-menu-content-available-height);
+
+ > * {
+ max-height: var(--radix-dropdown-menu-content-available-height);
+ }
+
+ /* Animation states */
+ &[data-state="open"] {
+ animation:
+ fadeIn 150ms cubic-bezier(0.16, 1, 0.3, 1),
+ zoomIn 150ms cubic-bezier(0.16, 1, 0.3, 1);
+ }
+
+ &[data-state="closed"] {
+ animation:
+ fadeOut 150ms cubic-bezier(0.16, 1, 0.3, 1),
+ zoomOut 150ms cubic-bezier(0.16, 1, 0.3, 1);
+ }
+
+ /* Position-based animations */
+ &[data-side="top"],
+ &[data-side="top-start"],
+ &[data-side="top-end"] {
+ animation: slideFromBottom 150ms cubic-bezier(0.16, 1, 0.3, 1);
+ }
+
+ &[data-side="right"],
+ &[data-side="right-start"],
+ &[data-side="right-end"] {
+ animation: slideFromLeft 150ms cubic-bezier(0.16, 1, 0.3, 1);
+ }
+
+ &[data-side="bottom"],
+ &[data-side="bottom-start"],
+ &[data-side="bottom-end"] {
+ animation: slideFromTop 150ms cubic-bezier(0.16, 1, 0.3, 1);
+ }
+
+ &[data-side="left"],
+ &[data-side="left-start"],
+ &[data-side="left-end"] {
+ animation: slideFromRight 150ms cubic-bezier(0.16, 1, 0.3, 1);
+ }
+}
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.tsx
new file mode 100644
index 0000000000..44aee418fd
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.tsx
@@ -0,0 +1,96 @@
+import React, { forwardRef } from "react"
+import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
+import { cn } from "../../../lib/tiptap-utils"
+import "./dropdown-menu.scss"
+
+function DropdownMenu({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DropdownMenuPortal({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+const DropdownMenuTrigger = forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ ...props }, ref) => )
+DropdownMenuTrigger.displayName = DropdownMenuPrimitive.Trigger.displayName
+
+const DropdownMenuGroup = DropdownMenuPrimitive.Group
+
+const DropdownMenuSub = DropdownMenuPrimitive.Sub
+
+const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
+
+const DropdownMenuItem = DropdownMenuPrimitive.Item
+
+const DropdownMenuSubTrigger = DropdownMenuPrimitive.SubTrigger
+
+const DropdownMenuSubContent = forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef & {
+ portal?: boolean | React.ComponentProps
+ }
+>(({ className, portal = true, ...props }, ref) => {
+ const content = (
+
+ )
+
+ return portal ? (
+
+ {content}
+
+ ) : (
+ content
+ )
+})
+DropdownMenuSubContent.displayName =
+ DropdownMenuPrimitive.SubContent.displayName
+
+const DropdownMenuContent = forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef & {
+ portal?: boolean
+ }
+>(({ className, sideOffset = 4, portal = false, ...props }, ref) => {
+ const content = (
+ e.preventDefault()}
+ className={cn("tiptap-dropdown-menu", className)}
+ {...props}
+ />
+ )
+
+ return portal ? (
+
+ {content}
+
+ ) : (
+ content
+ )
+})
+DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
+
+export {
+ DropdownMenu,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuGroup,
+ DropdownMenuSub,
+ DropdownMenuPortal,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuRadioGroup,
+}
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/dropdown-menu/index.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/dropdown-menu/index.tsx
new file mode 100644
index 0000000000..c4adeceeee
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/dropdown-menu/index.tsx
@@ -0,0 +1 @@
+export * from "./dropdown-menu"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/input/index.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/input/index.tsx
new file mode 100644
index 0000000000..be91c8ec4b
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/input/index.tsx
@@ -0,0 +1 @@
+export * from "./input"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/input/input.scss b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/input/input.scss
new file mode 100644
index 0000000000..b9f777cffe
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/input/input.scss
@@ -0,0 +1,45 @@
+:root {
+ --tiptap-input-placeholder: var(--tt-gray-light-a-400);
+}
+
+.dark {
+ --tiptap-input-placeholder: var(--tt-gray-dark-a-400);
+}
+
+.tiptap-input {
+ display: block;
+ width: 100%;
+ height: 2rem;
+ font-size: 0.875rem;
+ font-weight: 400;
+ line-height: 1.5;
+ padding: 0.375rem 0.5rem;
+ border-radius: 0.375rem;
+ background: none;
+ appearance: none;
+ outline: none;
+
+ &::placeholder {
+ color: var(--tiptap-input-placeholder);
+ }
+}
+
+.tiptap-input-clamp {
+ min-width: 12rem;
+ padding-right: 0;
+
+ text-overflow: ellipsis;
+ white-space: nowrap;
+
+ &:focus {
+ text-overflow: clip;
+ overflow: visible;
+ }
+}
+
+.tiptap-input-group {
+ position: relative;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: stretch;
+}
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/input/input.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/input/input.tsx
new file mode 100644
index 0000000000..4ed77cede6
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/input/input.tsx
@@ -0,0 +1,23 @@
+import React from "react"
+import { cn } from "../../../lib/tiptap-utils"
+import "./input.scss"
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+ return (
+
+ )
+}
+
+function InputGroup({
+ className,
+ children,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ {children}
+
+ )
+}
+
+export { Input, InputGroup }
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/popover/index.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/popover/index.tsx
new file mode 100644
index 0000000000..137ef5d362
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/popover/index.tsx
@@ -0,0 +1 @@
+export * from "./popover"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/popover/popover.scss b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/popover/popover.scss
new file mode 100644
index 0000000000..07fb0e57bd
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/popover/popover.scss
@@ -0,0 +1,63 @@
+.tiptap-popover {
+ --tt-popover-bg-color: var(--white);
+ --tt-popover-border-color: var(--tt-gray-light-a-100);
+ --tt-popover-text-color: var(--tt-gray-light-a-600);
+
+ .dark & {
+ --tt-popover-border-color: var(--tt-gray-dark-a-50);
+ --tt-popover-bg-color: var(--tt-gray-dark-50);
+ --tt-popover-text-color: var(--tt-gray-dark-a-600);
+ }
+}
+
+/* --------------------------------------------
+ --------- POPOVER STYLING SETTINGS -----------
+ -------------------------------------------- */
+.tiptap-popover {
+ z-index: 50;
+ outline: none;
+ transform-origin: var(--radix-popover-content-transform-origin);
+ max-height: var(--radix-popover-content-available-height);
+
+ > * {
+ max-height: var(--radix-popover-content-available-height);
+ }
+
+ /* Animation states */
+ &[data-state="open"] {
+ animation:
+ fadeIn 150ms cubic-bezier(0.16, 1, 0.3, 1),
+ zoomIn 150ms cubic-bezier(0.16, 1, 0.3, 1);
+ }
+
+ &[data-state="closed"] {
+ animation:
+ fadeOut 150ms cubic-bezier(0.16, 1, 0.3, 1),
+ zoomOut 150ms cubic-bezier(0.16, 1, 0.3, 1);
+ }
+
+ /* Position-based animations */
+ &[data-side="top"],
+ &[data-side="top-start"],
+ &[data-side="top-end"] {
+ animation: slideFromBottom 150ms cubic-bezier(0.16, 1, 0.3, 1);
+ }
+
+ &[data-side="right"],
+ &[data-side="right-start"],
+ &[data-side="right-end"] {
+ animation: slideFromLeft 150ms cubic-bezier(0.16, 1, 0.3, 1);
+ }
+
+ &[data-side="bottom"],
+ &[data-side="bottom-start"],
+ &[data-side="bottom-end"] {
+ animation: slideFromTop 150ms cubic-bezier(0.16, 1, 0.3, 1);
+ }
+
+ &[data-side="left"],
+ &[data-side="left-start"],
+ &[data-side="left-end"] {
+ animation: slideFromRight 150ms cubic-bezier(0.16, 1, 0.3, 1);
+ }
+}
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/popover/popover.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/popover/popover.tsx
new file mode 100644
index 0000000000..8fc8a2f602
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/popover/popover.tsx
@@ -0,0 +1,36 @@
+import React from "react"
+import * as PopoverPrimitive from "@radix-ui/react-popover"
+import { cn } from "../../../lib/tiptap-utils"
+import "./popover.scss"
+
+function Popover({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function PopoverTrigger({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function PopoverContent({
+ className,
+ align = "center",
+ sideOffset = 4,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export { Popover, PopoverTrigger, PopoverContent }
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/separator/index.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/separator/index.tsx
new file mode 100644
index 0000000000..068cfa8369
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/separator/index.tsx
@@ -0,0 +1 @@
+export * from "./separator"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/separator/separator.scss b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/separator/separator.scss
new file mode 100644
index 0000000000..78ec9ac6c4
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/separator/separator.scss
@@ -0,0 +1,23 @@
+.tiptap-separator {
+ --tt-link-border-color: var(--tt-gray-light-a-200);
+
+ .dark & {
+ --tt-link-border-color: var(--tt-gray-dark-a-200);
+ }
+}
+
+.tiptap-separator {
+ flex-shrink: 0;
+ background-color: var(--tt-link-border-color);
+
+ &[data-orientation="horizontal"] {
+ height: 1px;
+ width: 100%;
+ margin: 0.5rem 0;
+ }
+
+ &[data-orientation="vertical"] {
+ height: 1.5rem;
+ width: 1px;
+ }
+}
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/separator/separator.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/separator/separator.tsx
new file mode 100644
index 0000000000..090a41f32e
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/separator/separator.tsx
@@ -0,0 +1,31 @@
+import React, { forwardRef } from "react"
+import "./separator.scss"
+import { cn } from "../../../lib/tiptap-utils"
+
+export type Orientation = "horizontal" | "vertical"
+
+export interface SeparatorProps extends React.HTMLAttributes {
+ orientation?: Orientation
+ decorative?: boolean
+}
+
+export const Separator = forwardRef(
+ ({ decorative, orientation = "vertical", className, ...divProps }, ref) => {
+ const ariaOrientation = orientation === "vertical" ? orientation : undefined
+ const semanticProps = decorative
+ ? { role: "none" }
+ : { "aria-orientation": ariaOrientation, role: "separator" }
+
+ return (
+
+ )
+ },
+)
+
+Separator.displayName = "Separator"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/spacer/index.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/spacer/index.tsx
new file mode 100644
index 0000000000..b0789bf135
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/spacer/index.tsx
@@ -0,0 +1 @@
+export * from "./spacer"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/spacer/spacer.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/spacer/spacer.tsx
new file mode 100644
index 0000000000..bc7eae7105
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/spacer/spacer.tsx
@@ -0,0 +1,28 @@
+"use client"
+
+import React from "react"
+
+export type SpacerOrientation = "horizontal" | "vertical"
+
+export interface SpacerProps extends React.HTMLAttributes {
+ orientation?: SpacerOrientation
+ size?: string | number
+}
+
+export function Spacer({
+ orientation = "horizontal",
+ size,
+ style = {},
+ ...props
+}: SpacerProps) {
+ const computedStyle = {
+ ...style,
+ ...(orientation === "horizontal" && !size && { flex: 1 }),
+ ...(size && {
+ width: orientation === "vertical" ? "1px" : size,
+ height: orientation === "horizontal" ? "1px" : size,
+ }),
+ }
+
+ return
+}
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/toolbar/index.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/toolbar/index.tsx
new file mode 100644
index 0000000000..94b181962f
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/toolbar/index.tsx
@@ -0,0 +1 @@
+export * from "./toolbar"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/toolbar/toolbar.scss b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/toolbar/toolbar.scss
new file mode 100644
index 0000000000..3ce1862beb
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/toolbar/toolbar.scss
@@ -0,0 +1,98 @@
+:root {
+ --tt-toolbar-height: 2.75rem;
+ --tt-safe-area-bottom: env(safe-area-inset-bottom, 0px);
+ --tt-toolbar-bg-color: var(--white);
+ --tt-toolbar-border-color: var(--tt-gray-light-a-100);
+}
+
+.dark {
+ --tt-toolbar-bg-color: var(--black);
+ --tt-toolbar-border-color: var(--tt-gray-dark-a-50);
+}
+
+.tiptap-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+
+ &-group {
+ display: flex;
+ align-items: center;
+ gap: 0.125rem;
+
+ &:empty {
+ display: none;
+ }
+
+ &:empty + .tiptap-separator,
+ .tiptap-separator + &:empty {
+ display: none;
+ }
+ }
+
+ &[data-variant="fixed"] {
+ position: sticky;
+ top: 0;
+ z-index: 10;
+ width: 100%;
+ min-height: var(--tt-toolbar-height);
+ background: var(--tt-toolbar-bg-color);
+ border-bottom: 1px solid var(--tt-toolbar-border-color);
+ padding: 0 0.5rem;
+ overflow-x: auto;
+ overscroll-behavior-x: contain;
+ -webkit-overflow-scrolling: touch;
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+
+ &::-webkit-scrollbar {
+ display: none;
+ }
+
+ @media (max-width: 480px) {
+ position: absolute;
+ top: auto;
+ height: calc(var(--tt-toolbar-height) + var(--tt-safe-area-bottom));
+ border-top: 1px solid var(--tt-toolbar-border-color);
+ border-bottom: none;
+ padding: 0 0.5rem var(--tt-safe-area-bottom);
+ flex-wrap: nowrap;
+ justify-content: flex-start;
+
+ .tiptap-toolbar-group {
+ flex: 0 0 auto;
+ }
+ }
+ }
+
+ &[data-variant="floating"] {
+ --tt-toolbar-padding: 0.125rem;
+ --tt-toolbar-border-width: 1px;
+
+ padding: 0.188rem;
+ border-radius: calc(
+ var(--tt-toolbar-padding) + var(--tt-radius-lg) +
+ var(--tt-toolbar-border-width)
+ );
+ border: var(--tt-toolbar-border-width) solid var(--tt-toolbar-border-color);
+ background-color: var(--tt-toolbar-bg-color);
+ box-shadow: var(--tt-shadow-elevated-md);
+ outline: none;
+ overflow: hidden;
+
+ &[data-plain="true"] {
+ padding: 0;
+ border-radius: 0;
+ border: none;
+ box-shadow: none;
+ background-color: transparent;
+ }
+
+ @media screen and (max-width: 480px) {
+ width: 100%;
+ border-radius: 0;
+ border: none;
+ box-shadow: none;
+ }
+ }
+}
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/toolbar/toolbar.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/toolbar/toolbar.tsx
new file mode 100644
index 0000000000..2dc9f3a7b7
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/toolbar/toolbar.tsx
@@ -0,0 +1,127 @@
+import React, {
+ forwardRef,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+} from "react"
+import { Separator } from "../separator"
+import "./toolbar.scss"
+import { cn } from "../../../lib/tiptap-utils"
+import { useMenuNavigation } from "../../../hooks/use-menu-navigation"
+import { useComposedRef } from "../../../hooks/use-composed-ref"
+
+type BaseProps = React.HTMLAttributes
+
+interface ToolbarProps extends BaseProps {
+ variant?: "floating" | "fixed"
+}
+
+const useToolbarNavigation = (
+ toolbarRef: React.RefObject,
+) => {
+ const [items, setItems] = useState([])
+
+ const collectItems = useCallback(() => {
+ if (!toolbarRef.current) return []
+ return Array.from(
+ toolbarRef.current.querySelectorAll(
+ 'button:not([disabled]), [role="button"]:not([disabled]), [tabindex="0"]:not([disabled])',
+ ),
+ )
+ }, [toolbarRef])
+
+ useEffect(() => {
+ const toolbar = toolbarRef.current
+ if (!toolbar) return
+
+ const updateItems = () => setItems(collectItems())
+
+ updateItems()
+ const observer = new MutationObserver(updateItems)
+ observer.observe(toolbar, { childList: true, subtree: true })
+
+ return () => observer.disconnect()
+ }, [collectItems, toolbarRef])
+
+ const { selectedIndex } = useMenuNavigation({
+ containerRef: toolbarRef,
+ items,
+ orientation: "horizontal",
+ onSelect: (el) => el.click(),
+ autoSelectFirstItem: false,
+ })
+
+ useEffect(() => {
+ const toolbar = toolbarRef.current
+ if (!toolbar) return
+
+ const handleFocus = (e: FocusEvent) => {
+ const target = e.target as HTMLElement
+ if (toolbar.contains(target))
+ target.setAttribute("data-focus-visible", "true")
+ }
+
+ const handleBlur = (e: FocusEvent) => {
+ const target = e.target as HTMLElement
+ if (toolbar.contains(target)) target.removeAttribute("data-focus-visible")
+ }
+
+ toolbar.addEventListener("focus", handleFocus, true)
+ toolbar.addEventListener("blur", handleBlur, true)
+
+ return () => {
+ toolbar.removeEventListener("focus", handleFocus, true)
+ toolbar.removeEventListener("blur", handleBlur, true)
+ }
+ }, [toolbarRef])
+
+ useEffect(() => {
+ if (selectedIndex !== undefined && items[selectedIndex]) {
+ items[selectedIndex].focus()
+ }
+ }, [selectedIndex, items])
+}
+
+export const Toolbar = forwardRef(
+ ({ children, className, variant = "fixed", ...props }, ref) => {
+ const toolbarRef = useRef(null)
+ const composedRef = useComposedRef(toolbarRef, ref)
+ useToolbarNavigation(toolbarRef)
+
+ return (
+
+ {children}
+
+ )
+ },
+)
+Toolbar.displayName = "Toolbar"
+
+export const ToolbarGroup = forwardRef(
+ ({ children, className, ...props }, ref) => (
+
+ {children}
+
+ ),
+)
+ToolbarGroup.displayName = "ToolbarGroup"
+
+export const ToolbarSeparator = forwardRef(
+ ({ ...props }, ref) => (
+
+ ),
+)
+ToolbarSeparator.displayName = "ToolbarSeparator"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/tooltip/index.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/tooltip/index.tsx
new file mode 100644
index 0000000000..e12712a782
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/tooltip/index.tsx
@@ -0,0 +1 @@
+export * from "./tooltip"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/tooltip/tooltip.scss b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/tooltip/tooltip.scss
new file mode 100644
index 0000000000..d717757fa0
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/tooltip/tooltip.scss
@@ -0,0 +1,43 @@
+.tiptap-tooltip {
+ --tt-tooltip-bg: var(--tt-gray-light-900);
+ --tt-tooltip-text: var(--white);
+ --tt-kbd: var(--tt-gray-dark-a-400);
+
+ .dark & {
+ --tt-tooltip-bg: var(--white);
+ --tt-tooltip-text: var(--tt-gray-light-600);
+ --tt-kbd: var(--tt-gray-light-a-400);
+ }
+}
+
+.tiptap-tooltip {
+ z-index: 200;
+ overflow: hidden;
+ border-radius: var(--tt-radius-md, 0.375rem);
+ background-color: var(--tt-tooltip-bg);
+ padding: 0.375rem 0.5rem;
+ font-size: 0.75rem;
+ font-weight: 500;
+ color: var(--tt-tooltip-text);
+ box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
+ text-align: center;
+
+ kbd {
+ display: inline-block;
+ text-align: center;
+ vertical-align: baseline;
+ font-family:
+ ui-sans-serif,
+ system-ui,
+ -apple-system,
+ BlinkMacSystemFont,
+ "Segoe UI",
+ Roboto,
+ "Helvetica Neue",
+ Arial,
+ "Noto Sans",
+ sans-serif;
+ text-transform: capitalize;
+ color: var(--tt-kbd);
+ }
+}
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/tooltip/tooltip.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/tooltip/tooltip.tsx
new file mode 100644
index 0000000000..d3fdea69d6
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui-primitive/tooltip/tooltip.tsx
@@ -0,0 +1,237 @@
+"use client"
+
+import React, {
+ cloneElement,
+ createContext,
+ forwardRef,
+ isValidElement,
+ useContext,
+ useMemo,
+ useState,
+ version,
+} from "react"
+import {
+ useFloating,
+ autoUpdate,
+ offset,
+ flip,
+ shift,
+ useHover,
+ useFocus,
+ useDismiss,
+ useRole,
+ useInteractions,
+ useMergeRefs,
+ FloatingPortal,
+ type Placement,
+ type UseFloatingReturn,
+ type ReferenceType,
+ FloatingDelayGroup,
+} from "@floating-ui/react"
+import "./tooltip.scss"
+
+interface TooltipProviderProps {
+ children: React.ReactNode
+ initialOpen?: boolean
+ placement?: Placement
+ open?: boolean
+ onOpenChange?: (open: boolean) => void
+ delay?: number
+ closeDelay?: number
+ timeout?: number
+ useDelayGroup?: boolean
+}
+
+interface TooltipTriggerProps
+ extends Omit, "ref"> {
+ asChild?: boolean
+ children: React.ReactNode
+}
+
+interface TooltipContentProps
+ extends Omit, "ref"> {
+ children?: React.ReactNode
+ portal?: boolean
+ portalProps?: Omit, "children">
+}
+
+interface TooltipContextValue extends UseFloatingReturn {
+ open: boolean
+ setOpen: (open: boolean) => void
+ getReferenceProps: (
+ userProps?: React.HTMLProps,
+ ) => Record
+ getFloatingProps: (
+ userProps?: React.HTMLProps,
+ ) => Record
+}
+
+function useTooltip({
+ initialOpen = false,
+ placement = "top",
+ open: controlledOpen,
+ onOpenChange: setControlledOpen,
+ delay = 600,
+ closeDelay = 0,
+}: Omit = {}) {
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(initialOpen)
+
+ const open = controlledOpen ?? uncontrolledOpen
+ const setOpen = setControlledOpen ?? setUncontrolledOpen
+
+ const data = useFloating({
+ placement,
+ open,
+ onOpenChange: setOpen,
+ whileElementsMounted: autoUpdate,
+ middleware: [
+ offset(4),
+ flip({
+ crossAxis: placement.includes("-"),
+ fallbackAxisSideDirection: "start",
+ padding: 4,
+ }),
+ shift({ padding: 4 }),
+ ],
+ })
+
+ const context = data.context
+
+ const hover = useHover(context, {
+ mouseOnly: true,
+ move: false,
+ restMs: delay,
+ enabled: controlledOpen == null,
+ delay: {
+ close: closeDelay,
+ },
+ })
+ const focus = useFocus(context, {
+ enabled: controlledOpen == null,
+ })
+ const dismiss = useDismiss(context)
+ const role = useRole(context, { role: "tooltip" })
+
+ const interactions = useInteractions([hover, focus, dismiss, role])
+
+ return useMemo(
+ () => ({
+ open,
+ setOpen,
+ ...interactions,
+ ...data,
+ }),
+ [open, setOpen, interactions, data],
+ )
+}
+
+const TooltipContext = createContext(null)
+
+function useTooltipContext() {
+ const context = useContext(TooltipContext)
+
+ if (context == null) {
+ throw new Error("Tooltip components must be wrapped in ")
+ }
+
+ return context
+}
+
+export function Tooltip({ children, ...props }: TooltipProviderProps) {
+ const tooltip = useTooltip(props)
+
+ if (!props.useDelayGroup) {
+ return (
+
+ {children}
+
+ )
+ }
+
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+export const TooltipTrigger = forwardRef(
+ function TooltipTrigger({ children, asChild = false, ...props }, propRef) {
+ const context = useTooltipContext()
+ const childrenRef = isValidElement(children)
+ ? parseInt(version, 10) >= 19
+ ? // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (children as { props: { ref?: React.Ref } }).props.ref
+ : // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (children as any).ref
+ : undefined
+ const ref = useMergeRefs([context.refs.setReference, propRef, childrenRef])
+
+ if (asChild && isValidElement(children)) {
+ const dataAttributes = {
+ "data-tooltip-state": context.open ? "open" : "closed",
+ }
+
+ return cloneElement(
+ children,
+ context.getReferenceProps({
+ ref,
+ ...props,
+ ...(typeof children.props === "object" ? children.props : {}),
+ ...dataAttributes,
+ }),
+ )
+ }
+
+ return (
+
+ )
+ },
+)
+
+export const TooltipContent = forwardRef(
+ function TooltipContent(
+ { style, children, portal = true, portalProps = {}, ...props },
+ propRef,
+ ) {
+ const context = useTooltipContext()
+ const ref = useMergeRefs([context.refs.setFloating, propRef])
+
+ if (!context.open) return null
+
+ const content = (
+
+ {children}
+
+ )
+
+ if (portal) {
+ return {content}
+ }
+
+ return content
+ },
+)
+
+Tooltip.displayName = "Tooltip"
+TooltipTrigger.displayName = "TooltipTrigger"
+TooltipContent.displayName = "TooltipContent"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/blockquote-button/blockquote-button.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/blockquote-button/blockquote-button.tsx
new file mode 100644
index 0000000000..1a1812e350
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/blockquote-button/blockquote-button.tsx
@@ -0,0 +1,120 @@
+import React, { forwardRef, useCallback } from "react"
+
+// --- Tiptap UI ---
+import type { UseBlockquoteConfig } from "./"
+import { BLOCKQUOTE_SHORTCUT_KEY, useBlockquote } from "./"
+
+// --- Hooks ---
+import { useTiptapEditor } from "../../../hooks/use-tiptap-editor"
+
+// --- Lib ---
+import { parseShortcutKeys } from "../../../lib/tiptap-utils"
+
+// --- UI Primitives ---
+import type { ButtonProps } from "../../tiptap-ui-primitive/button"
+import { Button } from "../../tiptap-ui-primitive/button"
+import { Badge } from "../../tiptap-ui-primitive/badge"
+
+export interface BlockquoteButtonProps
+ extends Omit,
+ UseBlockquoteConfig {
+ /**
+ * Optional text to display alongside the icon.
+ */
+ text?: string
+ /**
+ * Optional show shortcut keys in the button.
+ * @default false
+ */
+ showShortcut?: boolean
+}
+
+export function BlockquoteShortcutBadge({
+ shortcutKeys = BLOCKQUOTE_SHORTCUT_KEY,
+}: {
+ shortcutKeys?: string
+}) {
+ return {parseShortcutKeys({ shortcutKeys })}
+}
+
+/**
+ * Button component for toggling blockquote in a Tiptap editor.
+ *
+ * For custom button implementations, use the `useBlockquote` hook instead.
+ */
+export const BlockquoteButton = forwardRef<
+ HTMLButtonElement,
+ BlockquoteButtonProps
+>(
+ (
+ {
+ editor: providedEditor,
+ text,
+ hideWhenUnavailable = false,
+ onToggled,
+ showShortcut = false,
+ onClick,
+ children,
+ ...buttonProps
+ },
+ ref,
+ ) => {
+ const { editor } = useTiptapEditor(providedEditor)
+ const {
+ isVisible,
+ canToggle,
+ isActive,
+ handleToggle,
+ label,
+ shortcutKeys,
+ Icon,
+ } = useBlockquote({
+ editor,
+ hideWhenUnavailable,
+ onToggled,
+ })
+
+ const handleClick = useCallback(
+ (event: React.MouseEvent) => {
+ onClick?.(event)
+ if (event.defaultPrevented) return
+ handleToggle()
+ },
+ [handleToggle, onClick],
+ )
+
+ if (!isVisible) {
+ return null
+ }
+
+ return (
+
+ )
+ },
+)
+
+BlockquoteButton.displayName = "BlockquoteButton"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/blockquote-button/index.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/blockquote-button/index.tsx
new file mode 100644
index 0000000000..0b46edfc32
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/blockquote-button/index.tsx
@@ -0,0 +1,2 @@
+export * from "./blockquote-button"
+export * from "./use-blockquote"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/blockquote-button/use-blockquote.ts b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/blockquote-button/use-blockquote.ts
new file mode 100644
index 0000000000..81161fc61b
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/blockquote-button/use-blockquote.ts
@@ -0,0 +1,246 @@
+"use client"
+
+import { useCallback, useEffect, useState } from "react"
+import type { Editor } from "@tiptap/react"
+import { NodeSelection, TextSelection } from "@tiptap/pm/state"
+
+// --- Hooks ---
+import { useTiptapEditor } from "../../../hooks/use-tiptap-editor"
+
+// --- Icons ---
+import { BlockquoteIcon } from "../../tiptap-icons/blockquote-icon"
+
+// --- UI Utils ---
+import {
+ findNodePosition,
+ isNodeInSchema,
+ isNodeTypeSelected,
+ isValidPosition,
+ selectionWithinConvertibleTypes,
+} from "../../../lib/tiptap-utils"
+
+export const BLOCKQUOTE_SHORTCUT_KEY = "mod+shift+b"
+
+/**
+ * Configuration for the blockquote functionality
+ */
+export interface UseBlockquoteConfig {
+ /**
+ * The Tiptap editor instance.
+ */
+ editor?: Editor | null
+ /**
+ * Whether the button should hide when blockquote is not available.
+ * @default false
+ */
+ hideWhenUnavailable?: boolean
+ /**
+ * Callback function called after a successful toggle.
+ */
+ onToggled?: () => void
+}
+
+/**
+ * Checks if blockquote can be toggled in the current editor state
+ */
+export function canToggleBlockquote(
+ editor: Editor | null,
+ turnInto: boolean = true,
+): boolean {
+ if (!editor || !editor.isEditable) return false
+ if (
+ !isNodeInSchema("blockquote", editor) ||
+ isNodeTypeSelected(editor, ["image"])
+ )
+ return false
+
+ if (!turnInto) {
+ return editor.can().toggleWrap("blockquote")
+ }
+
+ // Ensure selection is in nodes we're allowed to convert
+ if (
+ !selectionWithinConvertibleTypes(editor, [
+ "paragraph",
+ "heading",
+ "bulletList",
+ "orderedList",
+ "taskList",
+ "blockquote",
+ "codeBlock",
+ ])
+ )
+ return false
+
+ // Either we can wrap in blockquote directly on the selection,
+ // or we can clear formatting/nodes to arrive at a blockquote.
+ return editor.can().toggleWrap("blockquote") || editor.can().clearNodes()
+}
+
+/**
+ * Toggles blockquote formatting for a specific node or the current selection
+ */
+export function toggleBlockquote(editor: Editor | null): boolean {
+ if (!editor || !editor.isEditable) return false
+ if (!canToggleBlockquote(editor)) return false
+
+ try {
+ const view = editor.view
+ let state = view.state
+ let tr = state.tr
+
+ // No selection, find the the cursor position
+ if (state.selection.empty || state.selection instanceof TextSelection) {
+ const pos = findNodePosition({
+ editor,
+ node: state.selection.$anchor.node(1),
+ })?.pos
+ if (!isValidPosition(pos)) return false
+
+ tr = tr.setSelection(NodeSelection.create(state.doc, pos))
+ view.dispatch(tr)
+ state = view.state
+ }
+
+ const selection = state.selection
+
+ let chain = editor.chain().focus()
+
+ // Handle NodeSelection
+ if (selection instanceof NodeSelection) {
+ const firstChild = selection.node.firstChild?.firstChild
+ const lastChild = selection.node.lastChild?.lastChild
+
+ const from = firstChild
+ ? selection.from + firstChild.nodeSize
+ : selection.from + 1
+
+ const to = lastChild
+ ? selection.to - lastChild.nodeSize
+ : selection.to - 1
+
+ const resolvedFrom = state.doc.resolve(from)
+ const resolvedTo = state.doc.resolve(to)
+
+ chain = chain
+ .setTextSelection(TextSelection.between(resolvedFrom, resolvedTo))
+ .clearNodes()
+ }
+
+ const toggle = editor.isActive("blockquote")
+ ? chain.lift("blockquote")
+ : chain.wrapIn("blockquote")
+
+ toggle.run()
+
+ editor.chain().focus().selectTextblockEnd().run()
+
+ return true
+ } catch {
+ return false
+ }
+}
+
+/**
+ * Determines if the blockquote button should be shown
+ */
+export function shouldShowButton(props: {
+ editor: Editor | null
+ hideWhenUnavailable: boolean
+}): boolean {
+ const { editor, hideWhenUnavailable } = props
+
+ if (!editor || !editor.isEditable) return false
+ if (!isNodeInSchema("blockquote", editor)) return false
+
+ if (hideWhenUnavailable && !editor.isActive("code")) {
+ return canToggleBlockquote(editor)
+ }
+
+ return true
+}
+
+/**
+ * Custom hook that provides blockquote functionality for Tiptap editor
+ *
+ * @example
+ * ```tsx
+ * // Simple usage - no params needed
+ * function MySimpleBlockquoteButton() {
+ * const { isVisible, handleToggle, isActive } = useBlockquote()
+ *
+ * if (!isVisible) return null
+ *
+ * return
+ * }
+ *
+ * // Advanced usage with configuration
+ * function MyAdvancedBlockquoteButton() {
+ * const { isVisible, handleToggle, label, isActive } = useBlockquote({
+ * editor: myEditor,
+ * hideWhenUnavailable: true,
+ * onToggled: () => console.log('Blockquote toggled!')
+ * })
+ *
+ * if (!isVisible) return null
+ *
+ * return (
+ *
+ * Toggle Blockquote
+ *
+ * )
+ * }
+ * ```
+ */
+export function useBlockquote(config?: UseBlockquoteConfig) {
+ const {
+ editor: providedEditor,
+ hideWhenUnavailable = false,
+ onToggled,
+ } = config || {}
+
+ const { editor } = useTiptapEditor(providedEditor)
+ const [isVisible, setIsVisible] = useState(true)
+ const canToggle = canToggleBlockquote(editor)
+ const isActive = editor?.isActive("blockquote") || false
+
+ useEffect(() => {
+ if (!editor) return
+
+ const handleSelectionUpdate = () => {
+ setIsVisible(shouldShowButton({ editor, hideWhenUnavailable }))
+ }
+
+ handleSelectionUpdate()
+
+ editor.on("selectionUpdate", handleSelectionUpdate)
+
+ return () => {
+ editor.off("selectionUpdate", handleSelectionUpdate)
+ }
+ }, [editor, hideWhenUnavailable])
+
+ const handleToggle = useCallback(() => {
+ if (!editor) return false
+
+ const success = toggleBlockquote(editor)
+ if (success) {
+ onToggled?.()
+ }
+ return success
+ }, [editor, onToggled])
+
+ return {
+ isVisible,
+ isActive,
+ handleToggle,
+ canToggle,
+ label: "Blockquote",
+ shortcutKeys: BLOCKQUOTE_SHORTCUT_KEY,
+ Icon: BlockquoteIcon,
+ }
+}
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/code-block-button/code-block-button.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/code-block-button/code-block-button.tsx
new file mode 100644
index 0000000000..fb90f647be
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/code-block-button/code-block-button.tsx
@@ -0,0 +1,120 @@
+import React, { forwardRef, useCallback } from "react"
+
+// --- Hooks ---
+import { useTiptapEditor } from "../../../hooks/use-tiptap-editor"
+
+// --- Lib ---
+import { parseShortcutKeys } from "../../../lib/tiptap-utils"
+
+// --- Tiptap UI ---
+import type { UseCodeBlockConfig } from "./"
+import { CODE_BLOCK_SHORTCUT_KEY, useCodeBlock } from "./"
+
+// --- UI Primitives ---
+import type { ButtonProps } from "../../tiptap-ui-primitive/button"
+import { Button } from "../../tiptap-ui-primitive/button"
+import { Badge } from "../../tiptap-ui-primitive/badge"
+
+export interface CodeBlockButtonProps
+ extends Omit,
+ UseCodeBlockConfig {
+ /**
+ * Optional text to display alongside the icon.
+ */
+ text?: string
+ /**
+ * Optional show shortcut keys in the button.
+ * @default false
+ */
+ showShortcut?: boolean
+}
+
+export function CodeBlockShortcutBadge({
+ shortcutKeys = CODE_BLOCK_SHORTCUT_KEY,
+}: {
+ shortcutKeys?: string
+}) {
+ return {parseShortcutKeys({ shortcutKeys })}
+}
+
+/**
+ * Button component for toggling code block in a Tiptap editor.
+ *
+ * For custom button implementations, use the `useCodeBlock` hook instead.
+ */
+export const CodeBlockButton = forwardRef<
+ HTMLButtonElement,
+ CodeBlockButtonProps
+>(
+ (
+ {
+ editor: providedEditor,
+ text,
+ hideWhenUnavailable = false,
+ onToggled,
+ showShortcut = false,
+ onClick,
+ children,
+ ...buttonProps
+ },
+ ref,
+ ) => {
+ const { editor } = useTiptapEditor(providedEditor)
+ const {
+ isVisible,
+ canToggle,
+ isActive,
+ handleToggle,
+ label,
+ shortcutKeys,
+ Icon,
+ } = useCodeBlock({
+ editor,
+ hideWhenUnavailable,
+ onToggled,
+ })
+
+ const handleClick = useCallback(
+ (event: React.MouseEvent) => {
+ onClick?.(event)
+ if (event.defaultPrevented) return
+ handleToggle()
+ },
+ [handleToggle, onClick],
+ )
+
+ if (!isVisible) {
+ return null
+ }
+
+ return (
+
+ )
+ },
+)
+
+CodeBlockButton.displayName = "CodeBlockButton"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/code-block-button/index.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/code-block-button/index.tsx
new file mode 100644
index 0000000000..77d541f9c4
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/code-block-button/index.tsx
@@ -0,0 +1,2 @@
+export * from "./code-block-button"
+export * from "./use-code-block"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/code-block-button/use-code-block.ts b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/code-block-button/use-code-block.ts
new file mode 100644
index 0000000000..167860a625
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/code-block-button/use-code-block.ts
@@ -0,0 +1,256 @@
+"use client"
+
+import { useCallback, useEffect, useState } from "react"
+import { type Editor } from "@tiptap/react"
+import { NodeSelection, TextSelection } from "@tiptap/pm/state"
+
+// --- Hooks ---
+import { useTiptapEditor } from "../../../hooks/use-tiptap-editor"
+
+// --- Lib ---
+import {
+ findNodePosition,
+ isNodeInSchema,
+ isNodeTypeSelected,
+ isValidPosition,
+ selectionWithinConvertibleTypes,
+} from "../../../lib/tiptap-utils"
+
+// --- Icons ---
+import { CodeBlockIcon } from "../../tiptap-icons/code-block-icon"
+
+export const CODE_BLOCK_SHORTCUT_KEY = "mod+alt+c"
+
+/**
+ * Configuration for the code block functionality
+ */
+export interface UseCodeBlockConfig {
+ /**
+ * The Tiptap editor instance.
+ */
+ editor?: Editor | null
+ /**
+ * Whether the button should hide when code block is not available.
+ * @default false
+ */
+ hideWhenUnavailable?: boolean
+ /**
+ * Callback function called after a successful code block toggle.
+ */
+ onToggled?: () => void
+}
+
+/**
+ * Checks if code block can be toggled in the current editor state
+ */
+export function canToggle(
+ editor: Editor | null,
+ turnInto: boolean = true,
+): boolean {
+ if (!editor || !editor.isEditable) return false
+ if (
+ !isNodeInSchema("codeBlock", editor) ||
+ isNodeTypeSelected(editor, ["image"])
+ )
+ return false
+
+ if (!turnInto) {
+ return editor.can().toggleNode("codeBlock", "paragraph")
+ }
+
+ // Ensure selection is in nodes we're allowed to convert
+ if (
+ !selectionWithinConvertibleTypes(editor, [
+ "paragraph",
+ "heading",
+ "bulletList",
+ "orderedList",
+ "taskList",
+ "blockquote",
+ "codeBlock",
+ ])
+ )
+ return false
+
+ // Either we can toggle code block directly on the selection,
+ // or we can clear formatting/nodes to arrive at a code block.
+ return (
+ editor.can().toggleNode("codeBlock", "paragraph") ||
+ editor.can().clearNodes()
+ )
+}
+
+/**
+ * Toggles code block in the editor
+ */
+export function toggleCodeBlock(editor: Editor | null): boolean {
+ if (!editor || !editor.isEditable) return false
+ if (!canToggle(editor)) return false
+
+ try {
+ const view = editor.view
+ let state = view.state
+ let tr = state.tr
+
+ // No selection, find the the cursor position
+ if (state.selection.empty || state.selection instanceof TextSelection) {
+ const pos = findNodePosition({
+ editor,
+ node: state.selection.$anchor.node(1),
+ })?.pos
+ if (!isValidPosition(pos)) return false
+
+ tr = tr.setSelection(NodeSelection.create(state.doc, pos))
+ view.dispatch(tr)
+ state = view.state
+ }
+
+ const selection = state.selection
+
+ let chain = editor.chain().focus()
+
+ // Handle NodeSelection
+ if (selection instanceof NodeSelection) {
+ const firstChild = selection.node.firstChild?.firstChild
+ const lastChild = selection.node.lastChild?.lastChild
+
+ const from = firstChild
+ ? selection.from + firstChild.nodeSize
+ : selection.from + 1
+
+ const to = lastChild
+ ? selection.to - lastChild.nodeSize
+ : selection.to - 1
+
+ const resolvedFrom = state.doc.resolve(from)
+ const resolvedTo = state.doc.resolve(to)
+
+ chain = chain
+ .setTextSelection(TextSelection.between(resolvedFrom, resolvedTo))
+ .clearNodes()
+ }
+
+ const toggle = editor.isActive("codeBlock")
+ ? chain.setNode("paragraph")
+ : chain.toggleNode("codeBlock", "paragraph")
+
+ toggle.run()
+
+ editor.chain().focus().selectTextblockEnd().run()
+
+ return true
+ } catch {
+ return false
+ }
+}
+
+/**
+ * Determines if the code block button should be shown
+ */
+export function shouldShowButton(props: {
+ editor: Editor | null
+ hideWhenUnavailable: boolean
+}): boolean {
+ const { editor, hideWhenUnavailable } = props
+
+ if (!editor || !editor.isEditable) return false
+ if (!isNodeInSchema("codeBlock", editor)) return false
+
+ if (hideWhenUnavailable && !editor.isActive("code")) {
+ return canToggle(editor)
+ }
+
+ return true
+}
+
+/**
+ * Custom hook that provides code block functionality for Tiptap editor
+ *
+ * @example
+ * ```tsx
+ * // Simple usage - no params needed
+ * function MySimpleCodeBlockButton() {
+ * const { isVisible, isActive, handleToggle } = useCodeBlock()
+ *
+ * if (!isVisible) return null
+ *
+ * return (
+ *
+ * )
+ * }
+ *
+ * // Advanced usage with configuration
+ * function MyAdvancedCodeBlockButton() {
+ * const { isVisible, isActive, handleToggle, label } = useCodeBlock({
+ * editor: myEditor,
+ * hideWhenUnavailable: true,
+ * onToggled: (isActive) => console.log('Code block toggled:', isActive)
+ * })
+ *
+ * if (!isVisible) return null
+ *
+ * return (
+ *
+ * Toggle Code Block
+ *
+ * )
+ * }
+ * ```
+ */
+export function useCodeBlock(config?: UseCodeBlockConfig) {
+ const {
+ editor: providedEditor,
+ hideWhenUnavailable = false,
+ onToggled,
+ } = config || {}
+
+ const { editor } = useTiptapEditor(providedEditor)
+ const [isVisible, setIsVisible] = useState(true)
+ const canToggleState = canToggle(editor)
+ const isActive = editor?.isActive("codeBlock") || false
+
+ useEffect(() => {
+ if (!editor) return
+
+ const handleSelectionUpdate = () => {
+ setIsVisible(shouldShowButton({ editor, hideWhenUnavailable }))
+ }
+
+ handleSelectionUpdate()
+
+ editor.on("selectionUpdate", handleSelectionUpdate)
+
+ return () => {
+ editor.off("selectionUpdate", handleSelectionUpdate)
+ }
+ }, [editor, hideWhenUnavailable])
+
+ const handleToggle = useCallback(() => {
+ if (!editor) return false
+
+ const success = toggleCodeBlock(editor)
+ if (success) {
+ onToggled?.()
+ }
+ return success
+ }, [editor, onToggled])
+
+ return {
+ isVisible,
+ isActive,
+ handleToggle,
+ canToggle: canToggleState,
+ label: "Code Block",
+ shortcutKeys: CODE_BLOCK_SHORTCUT_KEY,
+ Icon: CodeBlockIcon,
+ }
+}
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/color-highlight-button/color-highlight-button.scss b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/color-highlight-button/color-highlight-button.scss
new file mode 100644
index 0000000000..2c6f387732
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/color-highlight-button/color-highlight-button.scss
@@ -0,0 +1,49 @@
+.tiptap-button-highlight {
+ position: relative;
+ width: 1.25rem;
+ height: 1.25rem;
+ margin: 0 -0.175rem;
+ border-radius: var(--tt-radius-xl);
+ background-color: var(--highlight-color);
+ transition: transform 0.2s ease;
+
+ &::after {
+ content: "";
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ left: 0;
+ top: 0;
+ border-radius: inherit;
+ box-sizing: border-box;
+ border: 1px solid var(--highlight-color);
+ filter: brightness(95%);
+ mix-blend-mode: multiply;
+
+ .dark & {
+ filter: brightness(140%);
+ mix-blend-mode: lighten;
+ }
+ }
+}
+
+.tiptap-button {
+ &[data-active-state="on"] {
+ .tiptap-button-highlight {
+ &::after {
+ filter: brightness(80%);
+ }
+ }
+ }
+
+ .dark & {
+ &[data-active-state="on"] {
+ .tiptap-button-highlight {
+ &::after {
+ // Andere Eigenschaft für .dark Kontext
+ filter: brightness(180%);
+ }
+ }
+ }
+ }
+}
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/color-highlight-button/color-highlight-button.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/color-highlight-button/color-highlight-button.tsx
new file mode 100644
index 0000000000..b064d97bb8
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/color-highlight-button/color-highlight-button.tsx
@@ -0,0 +1,166 @@
+import React, { forwardRef, useCallback, useMemo } from "react"
+
+// --- Lib ---
+import { parseShortcutKeys } from "../../../lib/tiptap-utils"
+
+// --- Hooks ---
+import { useTiptapEditor } from "../../../hooks/use-tiptap-editor"
+
+// --- Tiptap UI ---
+import type { UseColorHighlightConfig } from "./"
+import { COLOR_HIGHLIGHT_SHORTCUT_KEY, useColorHighlight } from "./"
+
+// --- UI Primitives ---
+import type { ButtonProps } from "../../tiptap-ui-primitive/button"
+import { Button } from "../../tiptap-ui-primitive/button"
+import { Badge } from "../../tiptap-ui-primitive/badge"
+
+// --- Styles ---
+import "./color-highlight-button.scss"
+
+export interface ColorHighlightButtonProps
+ extends Omit,
+ UseColorHighlightConfig {
+ /**
+ * Optional text to display alongside the icon.
+ */
+ text?: string
+ /**
+ * Optional show shortcut keys in the button.
+ * @default false
+ */
+ showShortcut?: boolean
+}
+
+export function ColorHighlightShortcutBadge({
+ shortcutKeys = COLOR_HIGHLIGHT_SHORTCUT_KEY,
+}: {
+ shortcutKeys?: string
+}) {
+ return {parseShortcutKeys({ shortcutKeys })}
+}
+
+/**
+ * Button component for applying color highlights in a Tiptap editor.
+ *
+ * Supports two highlighting modes:
+ * - "mark": Uses the highlight mark extension (default)
+ * - "node": Uses the node background extension
+ *
+ * For custom button implementations, use the `useColorHighlight` hook instead.
+ *
+ * @example
+ * ```tsx
+ * // Mark-based highlighting (default)
+ *
+ *
+ * // Node-based background coloring
+ *
+ *
+ * // With custom callback
+ * console.log(`Applied ${color} in ${mode} mode`)}
+ * />
+ * ```
+ */
+export const ColorHighlightButton = forwardRef<
+ HTMLButtonElement,
+ ColorHighlightButtonProps
+>(
+ (
+ {
+ editor: providedEditor,
+ highlightColor,
+ text,
+ hideWhenUnavailable = false,
+ mode = "mark",
+ onApplied,
+ showShortcut = false,
+ onClick,
+ children,
+ style,
+ ...buttonProps
+ },
+ ref,
+ ) => {
+ const { editor } = useTiptapEditor(providedEditor)
+ const {
+ isVisible,
+ canColorHighlight,
+ isActive,
+ handleColorHighlight,
+ label,
+ shortcutKeys,
+ } = useColorHighlight({
+ editor,
+ highlightColor,
+ label: text || `Toggle highlight (${highlightColor})`,
+ hideWhenUnavailable,
+ mode,
+ onApplied,
+ })
+
+ const handleClick = useCallback(
+ (event: React.MouseEvent) => {
+ onClick?.(event)
+ if (event.defaultPrevented) return
+ handleColorHighlight()
+ },
+ [handleColorHighlight, onClick],
+ )
+
+ const buttonStyle = useMemo(
+ () =>
+ ({
+ ...style,
+ "--highlight-color": highlightColor,
+ }) as React.CSSProperties,
+ [highlightColor, style],
+ )
+
+ if (!isVisible) {
+ return null
+ }
+
+ return (
+
+ )
+ },
+)
+
+ColorHighlightButton.displayName = "ColorHighlightButton"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/color-highlight-button/index.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/color-highlight-button/index.tsx
new file mode 100644
index 0000000000..c517648273
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/color-highlight-button/index.tsx
@@ -0,0 +1,2 @@
+export * from "./color-highlight-button"
+export * from "./use-color-highlight"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/color-highlight-button/use-color-highlight.ts b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/color-highlight-button/use-color-highlight.ts
new file mode 100644
index 0000000000..3b4cfe61db
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/color-highlight-button/use-color-highlight.ts
@@ -0,0 +1,355 @@
+"use client"
+
+import { useCallback, useEffect, useState } from "react"
+import { type Editor } from "@tiptap/react"
+import { useHotkeys } from "react-hotkeys-hook"
+
+// --- Hooks ---
+import { useTiptapEditor } from "../../../hooks/use-tiptap-editor"
+import { useIsMobile } from "../../../hooks/use-mobile"
+
+// --- Lib ---
+import {
+ isMarkInSchema,
+ isNodeTypeSelected,
+ isExtensionAvailable,
+} from "../../../lib/tiptap-utils"
+
+// --- Icons ---
+import { HighlighterIcon } from "../../tiptap-icons/highlighter-icon"
+
+export const COLOR_HIGHLIGHT_SHORTCUT_KEY = "mod+shift+h"
+export const HIGHLIGHT_COLORS = [
+ {
+ label: "Default background",
+ value: "var(--tt-bg-color)",
+ border: "var(--tt-bg-color-contrast)",
+ },
+ {
+ label: "Gray background",
+ value: "var(--tt-color-highlight-gray)",
+ border: "var(--tt-color-highlight-gray-contrast)",
+ },
+ {
+ label: "Brown background",
+ value: "var(--tt-color-highlight-brown)",
+ border: "var(--tt-color-highlight-brown-contrast)",
+ },
+ {
+ label: "Orange background",
+ value: "var(--tt-color-highlight-orange)",
+ border: "var(--tt-color-highlight-orange-contrast)",
+ },
+ {
+ label: "Yellow background",
+ value: "var(--tt-color-highlight-yellow)",
+ border: "var(--tt-color-highlight-yellow-contrast)",
+ },
+ {
+ label: "Green background",
+ value: "var(--tt-color-highlight-green)",
+ border: "var(--tt-color-highlight-green-contrast)",
+ },
+ {
+ label: "Blue background",
+ value: "var(--tt-color-highlight-blue)",
+ border: "var(--tt-color-highlight-blue-contrast)",
+ },
+ {
+ label: "Purple background",
+ value: "var(--tt-color-highlight-purple)",
+ border: "var(--tt-color-highlight-purple-contrast)",
+ },
+ {
+ label: "Pink background",
+ value: "var(--tt-color-highlight-pink)",
+ border: "var(--tt-color-highlight-pink-contrast)",
+ },
+ {
+ label: "Red background",
+ value: "var(--tt-color-highlight-red)",
+ border: "var(--tt-color-highlight-red-contrast)",
+ },
+]
+export type HighlightColor = (typeof HIGHLIGHT_COLORS)[number]
+
+export type HighlightMode = "mark" | "node"
+
+/**
+ * Configuration for the color highlight functionality
+ */
+export interface UseColorHighlightConfig {
+ /**
+ * The Tiptap editor instance.
+ */
+ editor?: Editor | null
+ /**
+ * The color to apply when toggling the highlight.
+ */
+ highlightColor?: string
+ /**
+ * Optional label to display alongside the icon.
+ */
+ label?: string
+ /**
+ * Whether the button should hide when the mark is not available.
+ * @default false
+ */
+ hideWhenUnavailable?: boolean
+ /**
+ * The highlighting mode to use.
+ * - "mark": Uses the highlight mark extension (default)
+ * - "node": Uses the node background extension
+ * @default "mark"
+ */
+ mode?: HighlightMode
+ /**
+ * Called when the highlight is applied.
+ */
+ onApplied?: ({
+ color,
+ label,
+ mode,
+ }: {
+ color: string
+ label: string
+ mode: HighlightMode
+ }) => void
+}
+
+export function pickHighlightColorsByValue(values: string[]) {
+ const colorMap = new Map(
+ HIGHLIGHT_COLORS.map((color) => [color.value, color]),
+ )
+ return values
+ .map((value) => colorMap.get(value))
+ .filter((color): color is (typeof HIGHLIGHT_COLORS)[number] => !!color)
+}
+
+/**
+ * Checks if highlight can be applied based on the mode and current editor state
+ */
+export function canColorHighlight(
+ editor: Editor | null,
+ mode: HighlightMode = "mark",
+): boolean {
+ if (!editor || !editor.isEditable) return false
+
+ if (mode === "mark") {
+ if (
+ !isMarkInSchema("highlight", editor) ||
+ isNodeTypeSelected(editor, ["image"])
+ )
+ return false
+
+ return editor.can().setMark("highlight")
+ } else {
+ if (!isExtensionAvailable(editor, ["nodeBackground"])) return false
+
+ try {
+ // Some editor instances may not have this command,
+ // so check for its existence before trying to call it.
+ const canCommands = editor.can?.()
+ if (
+ canCommands &&
+ typeof (canCommands as any).toggleNodeBackgroundColor === "function"
+ ) {
+ return (canCommands as any).toggleNodeBackgroundColor("test")
+ }
+ return false
+ } catch {
+ return false
+ }
+ }
+}
+
+/**
+ * Checks if highlight is currently active
+ */
+export function isColorHighlightActive(
+ editor: Editor | null,
+ highlightColor?: string,
+ mode: HighlightMode = "mark",
+): boolean {
+ if (!editor || !editor.isEditable) return false
+
+ if (mode === "mark") {
+ return highlightColor
+ ? editor.isActive("highlight", { color: highlightColor })
+ : editor.isActive("highlight")
+ } else {
+ if (!highlightColor) return false
+
+ try {
+ const { state } = editor
+ const { selection } = state
+
+ const $pos = selection.$anchor
+ for (let depth = $pos.depth; depth >= 0; depth--) {
+ const node = $pos.node(depth)
+ if (node && node.attrs?.backgroundColor === highlightColor) {
+ return true
+ }
+ }
+ return false
+ } catch {
+ return false
+ }
+ }
+}
+
+/**
+ * Removes highlight based on the mode
+ */
+export function removeHighlight(
+ editor: Editor | null,
+ mode: HighlightMode = "mark",
+): boolean {
+ if (!editor || !editor.isEditable) return false
+ if (!canColorHighlight(editor, mode)) return false
+
+ if (mode === "mark") {
+ return editor.chain().focus().unsetMark("highlight").run()
+ } else {
+ // The chained command `unsetNodeBackgroundColor` does not exist.
+ // We'll fallback to a helper if available, or do nothing (return false).
+ if (
+ typeof (editor as any).commands?.unsetNodeBackgroundColor === "function"
+ ) {
+ ;(editor as any).commands.unsetNodeBackgroundColor()
+ return true
+ }
+ return false
+ }
+}
+/**
+ * Determines if the highlight button should be shown
+ */
+export function shouldShowButton(props: {
+ editor: Editor | null
+ hideWhenUnavailable: boolean
+ mode: HighlightMode
+}): boolean {
+ const { editor, hideWhenUnavailable, mode } = props
+
+ if (!editor || !editor.isEditable) return false
+
+ if (mode === "mark") {
+ if (!isMarkInSchema("highlight", editor)) return false
+ } else {
+ if (!isExtensionAvailable(editor, ["nodeBackground"])) return false
+ }
+
+ if (hideWhenUnavailable && !editor.isActive("code")) {
+ return canColorHighlight(editor, mode)
+ }
+
+ return true
+}
+
+export function useColorHighlight(config: UseColorHighlightConfig) {
+ const {
+ editor: providedEditor,
+ label,
+ highlightColor,
+ hideWhenUnavailable = false,
+ mode = "mark",
+ onApplied,
+ } = config
+
+ const { editor } = useTiptapEditor(providedEditor)
+ const isMobile = useIsMobile()
+ const [isVisible, setIsVisible] = useState(true)
+ const canColorHighlightState = canColorHighlight(editor, mode)
+ const isActive = isColorHighlightActive(editor, highlightColor, mode)
+
+ useEffect(() => {
+ if (!editor) return
+
+ const handleSelectionUpdate = () => {
+ setIsVisible(shouldShowButton({ editor, hideWhenUnavailable, mode }))
+ }
+
+ handleSelectionUpdate()
+
+ editor.on("selectionUpdate", handleSelectionUpdate)
+
+ return () => {
+ editor.off("selectionUpdate", handleSelectionUpdate)
+ }
+ }, [editor, hideWhenUnavailable, mode])
+
+ const handleColorHighlight = useCallback(() => {
+ if (!editor || !canColorHighlightState || !highlightColor || !label)
+ return false
+
+ if (mode === "mark") {
+ if (editor.state.storedMarks) {
+ const highlightMarkType = editor.schema.marks.highlight
+ if (highlightMarkType) {
+ editor.view.dispatch(
+ editor.state.tr.removeStoredMark(highlightMarkType),
+ )
+ }
+ }
+
+ setTimeout(() => {
+ const success = editor
+ .chain()
+ .focus()
+ .toggleMark("highlight", { color: highlightColor })
+ .run()
+ if (success) {
+ onApplied?.({ color: highlightColor, label, mode })
+ }
+ return success
+ }, 0)
+
+ return true
+ } else {
+ const success = editor
+ .chain()
+ .focus()
+ .setHighlight({ color: highlightColor })
+ .run()
+
+ if (success) {
+ onApplied?.({ color: highlightColor, label, mode })
+ }
+ return success
+ }
+ }, [canColorHighlightState, highlightColor, editor, label, onApplied, mode])
+
+ const handleRemoveHighlight = useCallback(() => {
+ const success = removeHighlight(editor, mode)
+ if (success) {
+ onApplied?.({ color: "", label: "Remove highlight", mode })
+ }
+ return success
+ }, [editor, onApplied, mode])
+
+ useHotkeys(
+ COLOR_HIGHLIGHT_SHORTCUT_KEY,
+ (event) => {
+ event.preventDefault()
+ handleColorHighlight()
+ },
+ {
+ enabled: isVisible && canColorHighlightState,
+ enableOnContentEditable: !isMobile,
+ enableOnFormTags: true,
+ },
+ )
+
+ return {
+ isVisible,
+ isActive,
+ handleColorHighlight,
+ handleRemoveHighlight,
+ canColorHighlight: canColorHighlightState,
+ label: label || `Highlight`,
+ shortcutKeys: COLOR_HIGHLIGHT_SHORTCUT_KEY,
+ Icon: HighlighterIcon,
+ mode,
+ }
+}
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/color-highlight-popover/color-highlight-popover.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/color-highlight-popover/color-highlight-popover.tsx
new file mode 100644
index 0000000000..fd2fe7c790
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/color-highlight-popover/color-highlight-popover.tsx
@@ -0,0 +1,205 @@
+import React, { forwardRef, useMemo, useRef, useState } from "react"
+import { type Editor } from "@tiptap/react"
+
+// --- Hooks ---
+import { useMenuNavigation } from "../../../hooks/use-menu-navigation"
+import { useIsMobile } from "../../../hooks/use-mobile"
+import { useTiptapEditor } from "../../../hooks/use-tiptap-editor"
+
+// --- Icons ---
+import { BanIcon } from "../../tiptap-icons/ban-icon"
+import { HighlighterIcon } from "../../tiptap-icons/highlighter-icon"
+
+// --- UI Primitives ---
+import type { ButtonProps } from "../../tiptap-ui-primitive/button"
+import { Button, ButtonGroup } from "../../tiptap-ui-primitive/button"
+import {
+ Popover,
+ PopoverTrigger,
+ PopoverContent,
+} from "../../tiptap-ui-primitive/popover"
+import { Separator } from "../../tiptap-ui-primitive/separator"
+import { Card, CardBody, CardItemGroup } from "../../tiptap-ui-primitive/card"
+
+// --- Tiptap UI ---
+import type {
+ HighlightColor,
+ UseColorHighlightConfig,
+} from "../color-highlight-button"
+import {
+ ColorHighlightButton,
+ pickHighlightColorsByValue,
+ useColorHighlight,
+} from "../color-highlight-button"
+
+export interface ColorHighlightPopoverContentProps {
+ /**
+ * The Tiptap editor instance.
+ */
+ editor?: Editor | null
+ /**
+ * Optional colors to use in the highlight popover.
+ * If not provided, defaults to a predefined set of colors.
+ */
+ colors?: HighlightColor[]
+}
+
+export interface ColorHighlightPopoverProps
+ extends Omit,
+ Pick<
+ UseColorHighlightConfig,
+ "editor" | "hideWhenUnavailable" | "onApplied"
+ > {
+ /**
+ * Optional colors to use in the highlight popover.
+ * If not provided, defaults to a predefined set of colors.
+ */
+ colors?: HighlightColor[]
+}
+
+export const ColorHighlightPopoverButton = forwardRef<
+ HTMLButtonElement,
+ ButtonProps
+>(({ className, children, ...props }, ref) => (
+
+))
+
+ColorHighlightPopoverButton.displayName = "ColorHighlightPopoverButton"
+
+export function ColorHighlightPopoverContent({
+ editor,
+ colors = pickHighlightColorsByValue([
+ "var(--tt-color-highlight-green)",
+ "var(--tt-color-highlight-blue)",
+ "var(--tt-color-highlight-red)",
+ "var(--tt-color-highlight-purple)",
+ "var(--tt-color-highlight-yellow)",
+ ]),
+}: ColorHighlightPopoverContentProps) {
+ const { handleRemoveHighlight } = useColorHighlight({ editor })
+ const isMobile = useIsMobile()
+ const containerRef = useRef(null)
+
+ const menuItems = useMemo(
+ () => [...colors, { label: "Remove highlight", value: "none" }],
+ [colors],
+ )
+
+ const { selectedIndex } = useMenuNavigation({
+ containerRef,
+ items: menuItems,
+ orientation: "both",
+ onSelect: (item) => {
+ if (!containerRef.current) return false
+ const highlightedElement = containerRef.current.querySelector(
+ '[data-highlighted="true"]',
+ ) as HTMLElement
+ if (highlightedElement) highlightedElement.click()
+ if (item.value === "none") handleRemoveHighlight()
+ return true
+ },
+ autoSelectFirstItem: false,
+ })
+
+ return (
+
+
+
+
+ {colors.map((color, index) => (
+
+ ))}
+
+
+
+
+
+
+
+
+ )
+}
+
+export function ColorHighlightPopover({
+ editor: providedEditor,
+ colors = pickHighlightColorsByValue([
+ "var(--tt-color-highlight-green)",
+ "var(--tt-color-highlight-blue)",
+ "var(--tt-color-highlight-red)",
+ "var(--tt-color-highlight-purple)",
+ "var(--tt-color-highlight-yellow)",
+ ]),
+ hideWhenUnavailable = false,
+ onApplied,
+ ...props
+}: ColorHighlightPopoverProps) {
+ const { editor } = useTiptapEditor(providedEditor)
+ const [isOpen, setIsOpen] = useState(false)
+ const { isVisible, canColorHighlight, isActive, label, Icon } =
+ useColorHighlight({
+ editor,
+ hideWhenUnavailable,
+ onApplied,
+ })
+
+ if (!isVisible) return null
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default ColorHighlightPopover
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/color-highlight-popover/index.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/color-highlight-popover/index.tsx
new file mode 100644
index 0000000000..626b81f6e7
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/color-highlight-popover/index.tsx
@@ -0,0 +1 @@
+export * from "./color-highlight-popover"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/heading-button/heading-button.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/heading-button/heading-button.tsx
new file mode 100644
index 0000000000..7dedde4d25
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/heading-button/heading-button.tsx
@@ -0,0 +1,119 @@
+import React, { forwardRef, useCallback } from "react"
+
+// --- Lib ---
+import { parseShortcutKeys } from "../../../lib/tiptap-utils"
+
+// --- Tiptap UI ---
+import type { Level, UseHeadingConfig } from "./"
+import { HEADING_SHORTCUT_KEYS, useHeading } from "./"
+
+// --- UI Primitives ---
+import type { ButtonProps } from "../../tiptap-ui-primitive/button"
+import { Button } from "../../tiptap-ui-primitive/button"
+import { Badge } from "../../tiptap-ui-primitive/badge"
+import { useTiptapEditor } from "../../../hooks/use-tiptap-editor"
+
+export interface HeadingButtonProps
+ extends Omit,
+ UseHeadingConfig {
+ /**
+ * Optional text to display alongside the icon.
+ */
+ text?: string
+ /**
+ * Optional show shortcut keys in the button.
+ * @default false
+ */
+ showShortcut?: boolean
+}
+
+export function HeadingShortcutBadge({
+ level,
+ shortcutKeys = HEADING_SHORTCUT_KEYS[level],
+}: {
+ level: Level
+ shortcutKeys?: string
+}) {
+ return {parseShortcutKeys({ shortcutKeys })}
+}
+
+/**
+ * Button component for toggling heading in a Tiptap editor.
+ *
+ * For custom button implementations, use the `useHeading` hook instead.
+ */
+export const HeadingButton = forwardRef(
+ (
+ {
+ editor: providedEditor,
+ level,
+ text,
+ hideWhenUnavailable = false,
+ onToggled,
+ showShortcut = false,
+ onClick,
+ children,
+ ...buttonProps
+ },
+ ref,
+ ) => {
+ const { editor } = useTiptapEditor(providedEditor)
+ const {
+ isVisible,
+ canToggle,
+ isActive,
+ handleToggle,
+ label,
+ Icon,
+ shortcutKeys,
+ } = useHeading({
+ editor,
+ level,
+ hideWhenUnavailable,
+ onToggled,
+ })
+
+ const handleClick = useCallback(
+ (event: React.MouseEvent) => {
+ onClick?.(event)
+ if (event.defaultPrevented) return
+ handleToggle()
+ },
+ [handleToggle, onClick],
+ )
+
+ if (!isVisible) {
+ return null
+ }
+
+ return (
+
+ )
+ },
+)
+
+HeadingButton.displayName = "HeadingButton"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/heading-button/index.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/heading-button/index.tsx
new file mode 100644
index 0000000000..009a7005b6
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/heading-button/index.tsx
@@ -0,0 +1,2 @@
+export * from "./heading-button"
+export * from "./use-heading"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/heading-button/use-heading.ts b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/heading-button/use-heading.ts
new file mode 100644
index 0000000000..f6a647b801
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/heading-button/use-heading.ts
@@ -0,0 +1,321 @@
+"use client"
+
+import { useCallback, useEffect, useState } from "react"
+import { type Editor } from "@tiptap/react"
+import { NodeSelection, TextSelection } from "@tiptap/pm/state"
+
+// --- Hooks ---
+import { useTiptapEditor } from "../../../hooks/use-tiptap-editor"
+
+// --- Lib ---
+import {
+ findNodePosition,
+ isNodeInSchema,
+ isNodeTypeSelected,
+ isValidPosition,
+ selectionWithinConvertibleTypes,
+} from "../../../lib/tiptap-utils"
+
+// --- Icons ---
+import { HeadingOneIcon } from "../../tiptap-icons/heading-one-icon"
+import { HeadingTwoIcon } from "../../tiptap-icons/heading-two-icon"
+import { HeadingThreeIcon } from "../../tiptap-icons/heading-three-icon"
+import { HeadingFourIcon } from "../../tiptap-icons/heading-four-icon"
+import { HeadingFiveIcon } from "../../tiptap-icons/heading-five-icon"
+import { HeadingSixIcon } from "../../tiptap-icons/heading-six-icon"
+
+export type Level = 1 | 2 | 3 | 4 | 5 | 6
+
+/**
+ * Configuration for the heading functionality
+ */
+export interface UseHeadingConfig {
+ /**
+ * The Tiptap editor instance.
+ */
+ editor?: Editor | null
+ /**
+ * The heading level.
+ */
+ level: Level
+ /**
+ * Whether the button should hide when heading is not available.
+ * @default false
+ */
+ hideWhenUnavailable?: boolean
+ /**
+ * Callback function called after a successful heading toggle.
+ */
+ onToggled?: () => void
+}
+
+export const headingIcons = {
+ 1: HeadingOneIcon,
+ 2: HeadingTwoIcon,
+ 3: HeadingThreeIcon,
+ 4: HeadingFourIcon,
+ 5: HeadingFiveIcon,
+ 6: HeadingSixIcon,
+}
+
+export const HEADING_SHORTCUT_KEYS: Record = {
+ 1: "ctrl+alt+1",
+ 2: "ctrl+alt+2",
+ 3: "ctrl+alt+3",
+ 4: "ctrl+alt+4",
+ 5: "ctrl+alt+5",
+ 6: "ctrl+alt+6",
+}
+
+/**
+ * Checks if heading can be toggled in the current editor state
+ */
+export function canToggle(
+ editor: Editor | null,
+ level?: Level,
+ turnInto: boolean = true,
+): boolean {
+ if (!editor || !editor.isEditable) return false
+ if (
+ !isNodeInSchema("heading", editor) ||
+ isNodeTypeSelected(editor, ["image"])
+ )
+ return false
+
+ if (!turnInto) {
+ return level
+ ? editor.can().setNode("heading", { level })
+ : editor.can().setNode("heading")
+ }
+
+ // Ensure selection is in nodes we're allowed to convert
+ if (
+ !selectionWithinConvertibleTypes(editor, [
+ "paragraph",
+ "heading",
+ "bulletList",
+ "orderedList",
+ "taskList",
+ "blockquote",
+ "codeBlock",
+ ])
+ )
+ return false
+
+ // Either we can set heading directly on the selection,
+ // or we can clear formatting/nodes to arrive at a heading.
+ return level
+ ? editor.can().setNode("heading", { level }) || editor.can().clearNodes()
+ : editor.can().setNode("heading") || editor.can().clearNodes()
+}
+
+/**
+ * Checks if heading is currently active
+ */
+export function isHeadingActive(
+ editor: Editor | null,
+ level?: Level | Level[],
+): boolean {
+ if (!editor || !editor.isEditable) return false
+
+ if (Array.isArray(level)) {
+ return level.some((l) => editor.isActive("heading", { level: l }))
+ }
+
+ return level
+ ? editor.isActive("heading", { level })
+ : editor.isActive("heading")
+}
+
+/**
+ * Toggles heading in the editor
+ */
+export function toggleHeading(
+ editor: Editor | null,
+ level: Level | Level[],
+): boolean {
+ if (!editor || !editor.isEditable) return false
+
+ const levels = Array.isArray(level) ? level : [level]
+ const toggleLevel = levels.find((l) => canToggle(editor, l))
+
+ if (!toggleLevel) return false
+
+ try {
+ const view = editor.view
+ let state = view.state
+ let tr = state.tr
+
+ // No selection, find the cursor position
+ if (state.selection.empty || state.selection instanceof TextSelection) {
+ const pos = findNodePosition({
+ editor,
+ node: state.selection.$anchor.node(1),
+ })?.pos
+ if (!isValidPosition(pos)) return false
+
+ tr = tr.setSelection(NodeSelection.create(state.doc, pos))
+ view.dispatch(tr)
+ state = view.state
+ }
+
+ const selection = state.selection
+ let chain = editor.chain().focus()
+
+ // Handle NodeSelection
+ if (selection instanceof NodeSelection) {
+ const firstChild = selection.node.firstChild?.firstChild
+ const lastChild = selection.node.lastChild?.lastChild
+
+ const from = firstChild
+ ? selection.from + firstChild.nodeSize
+ : selection.from + 1
+
+ const to = lastChild
+ ? selection.to - lastChild.nodeSize
+ : selection.to - 1
+
+ const resolvedFrom = state.doc.resolve(from)
+ const resolvedTo = state.doc.resolve(to)
+
+ chain = chain
+ .setTextSelection(TextSelection.between(resolvedFrom, resolvedTo))
+ .clearNodes()
+ }
+
+ const isActive = levels.some((l) =>
+ editor.isActive("heading", { level: l }),
+ )
+
+ const toggle = isActive
+ ? chain.setNode("paragraph")
+ : chain.setNode("heading", { level: toggleLevel })
+
+ toggle.run()
+
+ editor.chain().focus().selectTextblockEnd().run()
+
+ return true
+ } catch {
+ return false
+ }
+}
+
+/**
+ * Determines if the heading button should be shown
+ */
+export function shouldShowButton(props: {
+ editor: Editor | null
+ level?: Level | Level[]
+ hideWhenUnavailable: boolean
+}): boolean {
+ const { editor, level, hideWhenUnavailable } = props
+
+ if (!editor || !editor.isEditable) return false
+ if (!isNodeInSchema("heading", editor)) return false
+
+ if (hideWhenUnavailable && !editor.isActive("code")) {
+ if (Array.isArray(level)) {
+ return level.some((l) => canToggle(editor, l))
+ }
+ return canToggle(editor, level)
+ }
+
+ return true
+}
+
+/**
+ * Custom hook that provides heading functionality for Tiptap editor
+ *
+ * @example
+ * ```tsx
+ * // Simple usage
+ * function MySimpleHeadingButton() {
+ * const { isVisible, isActive, handleToggle, Icon } = useHeading({ level: 1 })
+ *
+ * if (!isVisible) return null
+ *
+ * return (
+ *
+ * )
+ * }
+ *
+ * // Advanced usage with configuration
+ * function MyAdvancedHeadingButton() {
+ * const { isVisible, isActive, handleToggle, label, Icon } = useHeading({
+ * level: 2,
+ * editor: myEditor,
+ * hideWhenUnavailable: true,
+ * onToggled: (isActive) => console.log('Heading toggled:', isActive)
+ * })
+ *
+ * if (!isVisible) return null
+ *
+ * return (
+ *
+ *
+ * Toggle Heading 2
+ *
+ * )
+ * }
+ * ```
+ */
+export function useHeading(config: UseHeadingConfig) {
+ const {
+ editor: providedEditor,
+ level,
+ hideWhenUnavailable = false,
+ onToggled,
+ } = config
+
+ const { editor } = useTiptapEditor(providedEditor)
+ const [isVisible, setIsVisible] = useState(true)
+ const canToggleState = canToggle(editor, level)
+ const isActive = isHeadingActive(editor, level)
+
+ useEffect(() => {
+ if (!editor) return
+
+ const handleSelectionUpdate = () => {
+ setIsVisible(shouldShowButton({ editor, level, hideWhenUnavailable }))
+ }
+
+ handleSelectionUpdate()
+
+ editor.on("selectionUpdate", handleSelectionUpdate)
+
+ return () => {
+ editor.off("selectionUpdate", handleSelectionUpdate)
+ }
+ }, [editor, level, hideWhenUnavailable])
+
+ const handleToggle = useCallback(() => {
+ if (!editor) return false
+
+ const success = toggleHeading(editor, level)
+ if (success) {
+ onToggled?.()
+ }
+ return success
+ }, [editor, level, onToggled])
+
+ return {
+ isVisible,
+ isActive,
+ handleToggle,
+ canToggle: canToggleState,
+ label: `Heading ${level}`,
+ shortcutKeys: HEADING_SHORTCUT_KEYS[level],
+ Icon: headingIcons[level],
+ }
+}
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/heading-dropdown-menu/heading-dropdown-menu.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/heading-dropdown-menu/heading-dropdown-menu.tsx
new file mode 100644
index 0000000000..22a9d099a4
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/heading-dropdown-menu/heading-dropdown-menu.tsx
@@ -0,0 +1,127 @@
+import React, { forwardRef, useCallback, useState } from "react"
+
+// --- Icons ---
+import { ChevronDownIcon } from "../../tiptap-icons/chevron-down-icon"
+
+// --- Hooks ---
+import { useTiptapEditor } from "../../../hooks/use-tiptap-editor"
+
+// --- Tiptap UI ---
+import { HeadingButton } from "../heading-button"
+import type { UseHeadingDropdownMenuConfig } from "./"
+import { useHeadingDropdownMenu } from "./"
+
+// --- UI Primitives ---
+import type { ButtonProps } from "../../tiptap-ui-primitive/button"
+import { Button, ButtonGroup } from "../../tiptap-ui-primitive/button"
+import {
+ DropdownMenu,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuItem,
+} from "../../tiptap-ui-primitive/dropdown-menu"
+import { Card, CardBody } from "../../tiptap-ui-primitive/card"
+
+export interface HeadingDropdownMenuProps
+ extends Omit,
+ UseHeadingDropdownMenuConfig {
+ /**
+ * Whether to render the dropdown menu in a portal
+ * @default false
+ */
+ portal?: boolean
+ /**
+ * Callback for when the dropdown opens or closes
+ */
+ onOpenChange?: (isOpen: boolean) => void
+}
+
+/**
+ * Dropdown menu component for selecting heading levels in a Tiptap editor.
+ *
+ * For custom dropdown implementations, use the `useHeadingDropdownMenu` hook instead.
+ */
+export const HeadingDropdownMenu = forwardRef<
+ HTMLButtonElement,
+ HeadingDropdownMenuProps
+>(
+ (
+ {
+ editor: providedEditor,
+ levels = [1, 2, 3, 4, 5, 6],
+ hideWhenUnavailable = false,
+ portal = false,
+ onOpenChange,
+ ...buttonProps
+ },
+ ref,
+ ) => {
+ const { editor } = useTiptapEditor(providedEditor)
+ const [isOpen, setIsOpen] = useState(false)
+ const { isVisible, isActive, canToggle, Icon } = useHeadingDropdownMenu({
+ editor,
+ levels,
+ hideWhenUnavailable,
+ })
+
+ const handleOpenChange = useCallback(
+ (open: boolean) => {
+ if (!editor || !canToggle) return
+ setIsOpen(open)
+ onOpenChange?.(open)
+ },
+ [canToggle, editor, onOpenChange],
+ )
+
+ if (!isVisible) {
+ return null
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ {levels.map((level) => (
+
+
+
+ ))}
+
+
+
+
+
+ )
+ },
+)
+
+HeadingDropdownMenu.displayName = "HeadingDropdownMenu"
+
+export default HeadingDropdownMenu
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/heading-dropdown-menu/index.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/heading-dropdown-menu/index.tsx
new file mode 100644
index 0000000000..33b9679900
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/heading-dropdown-menu/index.tsx
@@ -0,0 +1,2 @@
+export * from "./heading-dropdown-menu"
+export * from "./use-heading-dropdown-menu"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/heading-dropdown-menu/use-heading-dropdown-menu.ts b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/heading-dropdown-menu/use-heading-dropdown-menu.ts
new file mode 100644
index 0000000000..597109ad00
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/heading-dropdown-menu/use-heading-dropdown-menu.ts
@@ -0,0 +1,132 @@
+"use client"
+
+import { useEffect, useState } from "react"
+import type { Editor } from "@tiptap/react"
+
+// --- Hooks ---
+import { useTiptapEditor } from "../../../hooks/use-tiptap-editor"
+
+// --- Icons ---
+import { HeadingIcon } from "../../tiptap-icons/heading-icon"
+
+// --- Tiptap UI ---
+import {
+ headingIcons,
+ type Level,
+ isHeadingActive,
+ canToggle,
+ shouldShowButton,
+} from "../heading-button"
+
+/**
+ * Configuration for the heading dropdown menu functionality
+ */
+export interface UseHeadingDropdownMenuConfig {
+ /**
+ * The Tiptap editor instance.
+ */
+ editor?: Editor | null
+ /**
+ * Available heading levels to show in the dropdown
+ * @default [1, 2, 3, 4, 5, 6]
+ */
+ levels?: Level[]
+ /**
+ * Whether the dropdown should hide when headings are not available.
+ * @default false
+ */
+ hideWhenUnavailable?: boolean
+}
+
+/**
+ * Gets the currently active heading level from the available levels
+ */
+export function getActiveHeadingLevel(
+ editor: Editor | null,
+ levels: Level[] = [1, 2, 3, 4, 5, 6],
+): Level | undefined {
+ if (!editor || !editor.isEditable) return undefined
+ return levels.find((level) => isHeadingActive(editor, level))
+}
+
+/**
+ * Custom hook that provides heading dropdown menu functionality for Tiptap editor
+ *
+ * @example
+ * ```tsx
+ * // Simple usage
+ * function MyHeadingDropdown() {
+ * const {
+ * isVisible,
+ * activeLevel,
+ * isAnyHeadingActive,
+ * canToggle,
+ * levels,
+ * } = useHeadingDropdownMenu()
+ *
+ * if (!isVisible) return null
+ *
+ * return (
+ *
+ * // dropdown content
+ *
+ * )
+ * }
+ *
+ * // Advanced usage with configuration
+ * function MyAdvancedHeadingDropdown() {
+ * const {
+ * isVisible,
+ * activeLevel,
+ * } = useHeadingDropdownMenu({
+ * editor: myEditor,
+ * levels: [1, 2, 3],
+ * hideWhenUnavailable: true,
+ * })
+ *
+ * // component implementation
+ * }
+ * ```
+ */
+export function useHeadingDropdownMenu(config?: UseHeadingDropdownMenuConfig) {
+ const {
+ editor: providedEditor,
+ levels = [1, 2, 3, 4, 5, 6],
+ hideWhenUnavailable = false,
+ } = config || {}
+
+ const { editor } = useTiptapEditor(providedEditor)
+ const [isVisible, setIsVisible] = useState(true)
+
+ const activeLevel = getActiveHeadingLevel(editor, levels)
+ const isActive = isHeadingActive(editor)
+ const canToggleState = canToggle(editor)
+
+ useEffect(() => {
+ if (!editor) return
+
+ const handleSelectionUpdate = () => {
+ setIsVisible(
+ shouldShowButton({ editor, hideWhenUnavailable, level: levels }),
+ )
+ }
+
+ handleSelectionUpdate()
+
+ editor.on("selectionUpdate", handleSelectionUpdate)
+
+ return () => {
+ editor.off("selectionUpdate", handleSelectionUpdate)
+ }
+ }, [editor, hideWhenUnavailable, levels])
+
+ return {
+ isVisible,
+ activeLevel,
+ isActive,
+ canToggle: canToggleState,
+ levels,
+ label: "Heading",
+ Icon: activeLevel ? headingIcons[activeLevel] : HeadingIcon,
+ }
+}
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/image-upload-button/image-upload-button.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/image-upload-button/image-upload-button.tsx
new file mode 100644
index 0000000000..f349d1865d
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/image-upload-button/image-upload-button.tsx
@@ -0,0 +1,128 @@
+import React, { forwardRef, useCallback } from "react"
+
+// --- Lib ---
+import { parseShortcutKeys } from "../../../lib/tiptap-utils"
+
+// --- Hooks ---
+import { useTiptapEditor } from "../../../hooks/use-tiptap-editor"
+
+// --- Tiptap UI ---
+import type { UseImageUploadConfig } from "./"
+import { IMAGE_UPLOAD_SHORTCUT_KEY, useImageUpload } from "./"
+
+// --- UI Primitives ---
+import type { ButtonProps } from "../../tiptap-ui-primitive/button"
+import { Button } from "../../tiptap-ui-primitive/button"
+import { Badge } from "../../tiptap-ui-primitive/badge"
+
+type IconProps = React.SVGProps
+type IconComponent = ({ className, ...props }: IconProps) => React.ReactElement
+
+export interface ImageUploadButtonProps
+ extends Omit,
+ UseImageUploadConfig {
+ /**
+ * Optional text to display alongside the icon.
+ */
+ text?: string
+ /**
+ * Optional show shortcut keys in the button.
+ * @default false
+ */
+ showShortcut?: boolean
+ /**
+ * Optional custom icon component to render instead of the default.
+ */
+ icon?: React.MemoExoticComponent | React.FC
+}
+
+export function ImageShortcutBadge({
+ shortcutKeys = IMAGE_UPLOAD_SHORTCUT_KEY,
+}: {
+ shortcutKeys?: string
+}) {
+ return {parseShortcutKeys({ shortcutKeys })}
+}
+
+/**
+ * Button component for uploading/inserting images in a Tiptap editor.
+ *
+ * For custom button implementations, use the `useImage` hook instead.
+ */
+export const ImageUploadButton = forwardRef<
+ HTMLButtonElement,
+ ImageUploadButtonProps
+>(
+ (
+ {
+ editor: providedEditor,
+ text,
+ hideWhenUnavailable = false,
+ onInserted,
+ showShortcut = false,
+ onClick,
+ icon: CustomIcon,
+ children,
+ ...buttonProps
+ },
+ ref,
+ ) => {
+ const { editor } = useTiptapEditor(providedEditor)
+ const {
+ isVisible,
+ canInsert,
+ handleImage,
+ label,
+ isActive,
+ shortcutKeys,
+ Icon,
+ } = useImageUpload({
+ editor,
+ hideWhenUnavailable,
+ onInserted,
+ })
+
+ const handleClick = useCallback(
+ (event: React.MouseEvent) => {
+ onClick?.(event)
+ if (event.defaultPrevented) return
+ handleImage()
+ },
+ [handleImage, onClick],
+ )
+
+ if (!isVisible) {
+ return null
+ }
+
+ const RenderIcon = CustomIcon ?? Icon
+
+ return (
+
+ )
+ },
+)
+
+ImageUploadButton.displayName = "ImageUploadButton"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/image-upload-button/index.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/image-upload-button/index.tsx
new file mode 100644
index 0000000000..815d5bb5ef
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/image-upload-button/index.tsx
@@ -0,0 +1,2 @@
+export * from "./image-upload-button"
+export * from "./use-image-upload"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/image-upload-button/use-image-upload.ts b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/image-upload-button/use-image-upload.ts
new file mode 100644
index 0000000000..156bf0649b
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/image-upload-button/use-image-upload.ts
@@ -0,0 +1,192 @@
+"use client"
+
+import { useCallback, useEffect, useState } from "react"
+import { useHotkeys } from "react-hotkeys-hook"
+import { type Editor } from "@tiptap/react"
+
+// --- Hooks ---
+import { useTiptapEditor } from "../../../hooks/use-tiptap-editor"
+import { useIsMobile } from "../../../hooks/use-mobile"
+
+// --- Lib ---
+import { isExtensionAvailable } from "../../../lib/tiptap-utils"
+
+// --- Icons ---
+import { ImagePlusIcon } from "../../tiptap-icons/image-plus-icon"
+
+export const IMAGE_UPLOAD_SHORTCUT_KEY = "mod+shift+i"
+
+/**
+ * Configuration for the image upload functionality
+ */
+export interface UseImageUploadConfig {
+ /**
+ * The Tiptap editor instance.
+ */
+ editor?: Editor | null
+ /**
+ * Whether the button should hide when insertion is not available.
+ * @default false
+ */
+ hideWhenUnavailable?: boolean
+ /**
+ * Callback function called after a successful image insertion.
+ */
+ onInserted?: () => void
+}
+
+/**
+ * Checks if image can be inserted in the current editor state
+ */
+export function canInsertImage(editor: Editor | null): boolean {
+ if (!editor || !editor.isEditable) return false
+ if (!isExtensionAvailable(editor, "imageUpload")) return false
+
+ return editor.can().insertContent({ type: "imageUpload" })
+}
+
+/**
+ * Checks if image is currently active
+ */
+export function isImageActive(editor: Editor | null): boolean {
+ if (!editor || !editor.isEditable) return false
+ return editor.isActive("imageUpload")
+}
+
+/**
+ * Inserts an image in the editor
+ */
+export function insertImage(editor: Editor | null): boolean {
+ if (!editor || !editor.isEditable) return false
+ if (!canInsertImage(editor)) return false
+
+ try {
+ return editor
+ .chain()
+ .focus()
+ .insertContent({
+ type: "imageUpload",
+ })
+ .run()
+ } catch {
+ return false
+ }
+}
+
+/**
+ * Determines if the image button should be shown
+ */
+export function shouldShowButton(props: {
+ editor: Editor | null
+ hideWhenUnavailable: boolean
+}): boolean {
+ const { editor, hideWhenUnavailable } = props
+
+ if (!editor || !editor.isEditable) return false
+ if (!isExtensionAvailable(editor, "imageUpload")) return false
+
+ if (hideWhenUnavailable && !editor.isActive("code")) {
+ return canInsertImage(editor)
+ }
+
+ return true
+}
+
+/**
+ * Custom hook that provides image functionality for Tiptap editor
+ *
+ * @example
+ * ```tsx
+ * // Simple usage - no params needed
+ * function MySimpleImageButton() {
+ * const { isVisible, handleImage } = useImage()
+ *
+ * if (!isVisible) return null
+ *
+ * return
+ * }
+ *
+ * // Advanced usage with configuration
+ * function MyAdvancedImageButton() {
+ * const { isVisible, handleImage, label, isActive } = useImage({
+ * editor: myEditor,
+ * hideWhenUnavailable: true,
+ * onInserted: () => console.log('Image inserted!')
+ * })
+ *
+ * if (!isVisible) return null
+ *
+ * return (
+ *
+ * Add Image
+ *
+ * )
+ * }
+ * ```
+ */
+export function useImageUpload(config?: UseImageUploadConfig) {
+ const {
+ editor: providedEditor,
+ hideWhenUnavailable = false,
+ onInserted,
+ } = config || {}
+
+ const { editor } = useTiptapEditor(providedEditor)
+ const isMobile = useIsMobile()
+ const [isVisible, setIsVisible] = useState(true)
+ const canInsert = canInsertImage(editor)
+ const isActive = isImageActive(editor)
+
+ useEffect(() => {
+ if (!editor) return
+
+ const handleSelectionUpdate = () => {
+ setIsVisible(shouldShowButton({ editor, hideWhenUnavailable }))
+ }
+
+ handleSelectionUpdate()
+
+ editor.on("selectionUpdate", handleSelectionUpdate)
+
+ return () => {
+ editor.off("selectionUpdate", handleSelectionUpdate)
+ }
+ }, [editor, hideWhenUnavailable])
+
+ const handleImage = useCallback(() => {
+ if (!editor) return false
+
+ const success = insertImage(editor)
+ if (success) {
+ onInserted?.()
+ }
+ return success
+ }, [editor, onInserted])
+
+ useHotkeys(
+ IMAGE_UPLOAD_SHORTCUT_KEY,
+ (event) => {
+ event.preventDefault()
+ handleImage()
+ },
+ {
+ enabled: isVisible && canInsert,
+ enableOnContentEditable: !isMobile,
+ enableOnFormTags: true,
+ },
+ )
+
+ return {
+ isVisible,
+ isActive,
+ handleImage,
+ canInsert,
+ label: "Add image",
+ shortcutKeys: IMAGE_UPLOAD_SHORTCUT_KEY,
+ Icon: ImagePlusIcon,
+ }
+}
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/link-popover/index.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/link-popover/index.tsx
new file mode 100644
index 0000000000..e725ea83ae
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/link-popover/index.tsx
@@ -0,0 +1,2 @@
+export * from "./link-popover"
+export * from "./use-link-popover"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/link-popover/link-popover.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/link-popover/link-popover.tsx
new file mode 100644
index 0000000000..e1d6696177
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/link-popover/link-popover.tsx
@@ -0,0 +1,303 @@
+"use client"
+
+import React, { forwardRef, useCallback, useEffect, useState } from "react"
+import type { Editor } from "@tiptap/react"
+
+// --- Hooks ---
+import { useIsMobile } from "../../../hooks/use-mobile"
+import { useTiptapEditor } from "../../../hooks/use-tiptap-editor"
+
+// --- Icons ---
+import { CornerDownLeftIcon } from "../../tiptap-icons/corner-down-left-icon"
+import { ExternalLinkIcon } from "../../tiptap-icons/external-link-icon"
+import { LinkIcon } from "../../tiptap-icons/link-icon"
+import { TrashIcon } from "../../tiptap-icons/trash-icon"
+
+// --- Tiptap UI ---
+import type { UseLinkPopoverConfig } from "./"
+import { useLinkPopover } from "./"
+
+// --- UI Primitives ---
+import type { ButtonProps } from "../../tiptap-ui-primitive/button"
+import { Button, ButtonGroup } from "../../tiptap-ui-primitive/button"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "../../tiptap-ui-primitive/popover"
+import { Separator } from "../../tiptap-ui-primitive/separator"
+import { Card, CardBody, CardItemGroup } from "../../tiptap-ui-primitive/card"
+import { Input, InputGroup } from "../../tiptap-ui-primitive/input"
+
+export interface LinkMainProps {
+ /**
+ * The URL to set for the link.
+ */
+ url: string
+ /**
+ * Function to update the URL state.
+ */
+ setUrl: React.Dispatch>
+ /**
+ * Function to set the link in the editor.
+ */
+ setLink: () => void
+ /**
+ * Function to remove the link from the editor.
+ */
+ removeLink: () => void
+ /**
+ * Function to open the link.
+ */
+ openLink: () => void
+ /**
+ * Whether the link is currently active in the editor.
+ */
+ isActive: boolean
+}
+
+export interface LinkPopoverProps
+ extends Omit,
+ UseLinkPopoverConfig {
+ /**
+ * Callback for when the popover opens or closes.
+ */
+ onOpenChange?: (isOpen: boolean) => void
+ /**
+ * Whether to automatically open the popover when a link is active.
+ * @default true
+ */
+ autoOpenOnLinkActive?: boolean
+}
+
+/**
+ * Link button component for triggering the link popover
+ */
+export const LinkButton = forwardRef(
+ ({ className, children, ...props }, ref) => {
+ return (
+
+ )
+ },
+)
+
+LinkButton.displayName = "LinkButton"
+
+/**
+ * Main content component for the link popover
+ */
+const LinkMain: React.FC = ({
+ url,
+ setUrl,
+ setLink,
+ removeLink,
+ openLink,
+ isActive,
+}) => {
+ const isMobile = useIsMobile()
+
+ const handleKeyDown = (event: React.KeyboardEvent) => {
+ if (event.key === "Enter") {
+ event.preventDefault()
+ setLink()
+ }
+ }
+
+ return (
+
+
+
+
+ setUrl(e.target.value)}
+ onKeyDown={handleKeyDown}
+ autoFocus
+ autoComplete="off"
+ autoCorrect="off"
+ autoCapitalize="off"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+/**
+ * Link content component for standalone use
+ */
+export const LinkContent: React.FC<{
+ editor?: Editor | null
+}> = ({ editor }) => {
+ const linkPopover = useLinkPopover({
+ editor,
+ })
+
+ return
+}
+
+/**
+ * Link popover component for Tiptap editors.
+ *
+ * For custom popover implementations, use the `useLinkPopover` hook instead.
+ */
+export const LinkPopover = forwardRef(
+ (
+ {
+ editor: providedEditor,
+ hideWhenUnavailable = false,
+ onSetLink,
+ onOpenChange,
+ autoOpenOnLinkActive = true,
+ onClick,
+ children,
+ ...buttonProps
+ },
+ ref,
+ ) => {
+ const { editor } = useTiptapEditor(providedEditor)
+ const [isOpen, setIsOpen] = useState(false)
+
+ const {
+ isVisible,
+ canSet,
+ isActive,
+ url,
+ setUrl,
+ setLink,
+ removeLink,
+ openLink,
+ label,
+ Icon,
+ } = useLinkPopover({
+ editor,
+ hideWhenUnavailable,
+ onSetLink,
+ })
+
+ const handleOnOpenChange = useCallback(
+ (nextIsOpen: boolean) => {
+ setIsOpen(nextIsOpen)
+ onOpenChange?.(nextIsOpen)
+ },
+ [onOpenChange],
+ )
+
+ const handleSetLink = useCallback(() => {
+ setLink()
+ setIsOpen(false)
+ }, [setLink])
+
+ const handleClick = useCallback(
+ (event: React.MouseEvent) => {
+ onClick?.(event)
+ if (event.defaultPrevented) return
+ setIsOpen(!isOpen)
+ },
+ [onClick, isOpen],
+ )
+
+ useEffect(() => {
+ if (autoOpenOnLinkActive && isActive) {
+ setIsOpen(true)
+ }
+ }, [autoOpenOnLinkActive, isActive])
+
+ if (!isVisible) {
+ return null
+ }
+
+ return (
+
+
+
+ {children ?? }
+
+
+
+
+
+
+
+ )
+ },
+)
+
+LinkPopover.displayName = "LinkPopover"
+
+export default LinkPopover
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/link-popover/use-link-popover.ts b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/link-popover/use-link-popover.ts
new file mode 100644
index 0000000000..3b1d8abeb5
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/link-popover/use-link-popover.ts
@@ -0,0 +1,284 @@
+import { useCallback, useEffect, useState } from "react"
+import type { Editor } from "@tiptap/react"
+
+// --- Hooks ---
+import { useTiptapEditor } from "../../../hooks/use-tiptap-editor"
+
+// --- Icons ---
+import { LinkIcon } from "../../tiptap-icons/link-icon"
+
+// --- Lib ---
+import {
+ isMarkInSchema,
+ isNodeTypeSelected,
+ sanitizeUrl,
+} from "../../../lib/tiptap-utils"
+
+/**
+ * Configuration for the link popover functionality
+ */
+export interface UseLinkPopoverConfig {
+ /**
+ * The Tiptap editor instance.
+ */
+ editor?: Editor | null
+ /**
+ * Whether to hide the link popover when not available.
+ * @default false
+ */
+ hideWhenUnavailable?: boolean
+ /**
+ * Callback function called when the link is set.
+ */
+ onSetLink?: () => void
+}
+
+/**
+ * Configuration for the link handler functionality
+ */
+export interface LinkHandlerProps {
+ /**
+ * The Tiptap editor instance.
+ */
+ editor: Editor | null
+ /**
+ * Callback function called when the link is set.
+ */
+ onSetLink?: () => void
+}
+
+/**
+ * Checks if a link can be set in the current editor state
+ */
+export function canSetLink(editor: Editor | null): boolean {
+ if (!editor || !editor.isEditable) return false
+
+ // The third argument 'true' checks whether the current selection is inside an image caption, and prevents setting a link there
+ // If the selection is inside an image caption, we can't set a link
+ if (isNodeTypeSelected(editor, ["image"], true)) return false
+ return editor.can().setMark("link")
+}
+
+/**
+ * Checks if a link is currently active in the editor
+ */
+export function isLinkActive(editor: Editor | null): boolean {
+ if (!editor || !editor.isEditable) return false
+ return editor.isActive("link")
+}
+
+/**
+ * Determines if the link button should be shown
+ */
+export function shouldShowLinkButton(props: {
+ editor: Editor | null
+ hideWhenUnavailable: boolean
+}): boolean {
+ const { editor, hideWhenUnavailable } = props
+
+ const linkInSchema = isMarkInSchema("link", editor)
+
+ if (!linkInSchema || !editor) {
+ return false
+ }
+
+ if (hideWhenUnavailable && !editor.isActive("code")) {
+ return canSetLink(editor)
+ }
+
+ return true
+}
+
+/**
+ * Custom hook for handling link operations in a Tiptap editor
+ */
+export function useLinkHandler(props: LinkHandlerProps) {
+ const { editor, onSetLink } = props
+ const [url, setUrl] = useState(null)
+
+ useEffect(() => {
+ if (!editor) return
+
+ // Get URL immediately on mount
+ const { href } = editor.getAttributes("link")
+
+ if (isLinkActive(editor) && url === null) {
+ setUrl(href || "")
+ }
+ }, [editor, url])
+
+ useEffect(() => {
+ if (!editor) return
+
+ const updateLinkState = () => {
+ const { href } = editor.getAttributes("link")
+ setUrl(href || "")
+ }
+
+ editor.on("selectionUpdate", updateLinkState)
+ return () => {
+ editor.off("selectionUpdate", updateLinkState)
+ }
+ }, [editor])
+
+ const setLink = useCallback(() => {
+ if (!url || !editor) return
+
+ const { selection } = editor.state
+ const isEmpty = selection.empty
+
+ let chain = editor.chain().focus()
+
+ chain = chain.extendMarkRange("link").setLink({ href: url })
+
+ if (isEmpty) {
+ chain = chain.insertContent({ type: "text", text: url })
+ }
+
+ chain.run()
+
+ setUrl(null)
+
+ onSetLink?.()
+ }, [editor, onSetLink, url])
+
+ const removeLink = useCallback(() => {
+ if (!editor) return
+ editor
+ .chain()
+ .focus()
+ .extendMarkRange("link")
+ .unsetLink()
+ .setMeta("preventAutolink", true)
+ .run()
+ setUrl("")
+ }, [editor])
+
+ const openLink = useCallback(
+ (target: string = "_blank", features: string = "noopener,noreferrer") => {
+ if (!url) return
+
+ const safeUrl = sanitizeUrl(url, window.location.href)
+ if (safeUrl !== "#") {
+ window.open(safeUrl, target, features)
+ }
+ },
+ [url],
+ )
+
+ return {
+ url: url || "",
+ setUrl,
+ setLink,
+ removeLink,
+ openLink,
+ }
+}
+
+/**
+ * Custom hook for link popover state management
+ */
+export function useLinkState(props: {
+ editor: Editor | null
+ hideWhenUnavailable: boolean
+}) {
+ const { editor, hideWhenUnavailable = false } = props
+
+ const canSet = canSetLink(editor)
+ const isActive = isLinkActive(editor)
+
+ const [isVisible, setIsVisible] = useState(false)
+
+ useEffect(() => {
+ if (!editor) return
+
+ const handleSelectionUpdate = () => {
+ setIsVisible(
+ shouldShowLinkButton({
+ editor,
+ hideWhenUnavailable,
+ }),
+ )
+ }
+
+ handleSelectionUpdate()
+
+ editor.on("selectionUpdate", handleSelectionUpdate)
+
+ return () => {
+ editor.off("selectionUpdate", handleSelectionUpdate)
+ }
+ }, [editor, hideWhenUnavailable])
+
+ return {
+ isVisible,
+ canSet,
+ isActive,
+ }
+}
+
+/**
+ * Main hook that provides link popover functionality for Tiptap editor
+ *
+ * @example
+ * ```tsx
+ * // Simple usage
+ * function MyLinkButton() {
+ * const { isVisible, canSet, isActive, Icon, label } = useLinkPopover()
+ *
+ * if (!isVisible) return null
+ *
+ * return
+ * }
+ *
+ * // Advanced usage with configuration
+ * function MyAdvancedLinkButton() {
+ * const { isVisible, canSet, isActive, Icon, label } = useLinkPopover({
+ * editor: myEditor,
+ * hideWhenUnavailable: true,
+ * onSetLink: () => console.log('Link set!')
+ * })
+ *
+ * if (!isVisible) return null
+ *
+ * return (
+ *
+ *
+ * {label}
+ *
+ * )
+ * }
+ * ```
+ */
+export function useLinkPopover(config?: UseLinkPopoverConfig) {
+ const {
+ editor: providedEditor,
+ hideWhenUnavailable = false,
+ onSetLink,
+ } = config || {}
+
+ const { editor } = useTiptapEditor(providedEditor)
+
+ const { isVisible, canSet, isActive } = useLinkState({
+ editor,
+ hideWhenUnavailable,
+ })
+
+ const linkHandler = useLinkHandler({
+ editor,
+ onSetLink,
+ })
+
+ return {
+ isVisible,
+ canSet,
+ isActive,
+ label: "Link",
+ Icon: LinkIcon,
+ ...linkHandler,
+ }
+}
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/list-button/index.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/list-button/index.tsx
new file mode 100644
index 0000000000..9f3d066656
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/list-button/index.tsx
@@ -0,0 +1,2 @@
+export * from "./list-button"
+export * from "./use-list"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/list-button/list-button.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/list-button/list-button.tsx
new file mode 100644
index 0000000000..225e22225d
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/list-button/list-button.tsx
@@ -0,0 +1,121 @@
+import React, { forwardRef, useCallback } from "react"
+
+// --- Lib ---
+import { parseShortcutKeys } from "../../../lib/tiptap-utils"
+
+// --- Hooks ---
+import { useTiptapEditor } from "../../../hooks/use-tiptap-editor"
+
+// --- UI Primitives ---
+import type { ButtonProps } from "../../tiptap-ui-primitive/button"
+import { Button } from "../../tiptap-ui-primitive/button"
+import { Badge } from "../../tiptap-ui-primitive/badge"
+
+// --- Tiptap UI ---
+import type { ListType, UseListConfig } from "./"
+import { LIST_SHORTCUT_KEYS, useList } from "./"
+
+export interface ListButtonProps
+ extends Omit,
+ UseListConfig {
+ /**
+ * Optional text to display alongside the icon.
+ */
+ text?: string
+ /**
+ * Optional show shortcut keys in the button.
+ * @default false
+ */
+ showShortcut?: boolean
+}
+
+export function ListShortcutBadge({
+ type,
+ shortcutKeys = LIST_SHORTCUT_KEYS[type],
+}: {
+ type: ListType
+ shortcutKeys?: string
+}) {
+ return {parseShortcutKeys({ shortcutKeys })}
+}
+
+/**
+ * Button component for toggling lists in a Tiptap editor.
+ *
+ * For custom button implementations, use the `useList` hook instead.
+ */
+export const ListButton = forwardRef(
+ (
+ {
+ editor: providedEditor,
+ type,
+ text,
+ hideWhenUnavailable = false,
+ onToggled,
+ showShortcut = false,
+ onClick,
+ children,
+ ...buttonProps
+ },
+ ref,
+ ) => {
+ const { editor } = useTiptapEditor(providedEditor)
+ const {
+ isVisible,
+ canToggle,
+ isActive,
+ handleToggle,
+ label,
+ shortcutKeys,
+ Icon,
+ } = useList({
+ editor,
+ type,
+ hideWhenUnavailable,
+ onToggled,
+ })
+
+ const handleClick = useCallback(
+ (event: React.MouseEvent) => {
+ onClick?.(event)
+ if (event.defaultPrevented) return
+ handleToggle()
+ },
+ [handleToggle, onClick],
+ )
+
+ if (!isVisible) {
+ return null
+ }
+
+ return (
+
+ )
+ },
+)
+
+ListButton.displayName = "ListButton"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/list-button/use-list.ts b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/list-button/use-list.ts
new file mode 100644
index 0000000000..2309f55439
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/list-button/use-list.ts
@@ -0,0 +1,326 @@
+"use client"
+
+import { useCallback, useEffect, useState } from "react"
+import { type Editor } from "@tiptap/react"
+import { NodeSelection, TextSelection } from "@tiptap/pm/state"
+
+// --- Hooks ---
+import { useTiptapEditor } from "../../../hooks/use-tiptap-editor"
+
+// --- Icons ---
+import { ListIcon } from "../../tiptap-icons/list-icon"
+import { ListOrderedIcon } from "../../tiptap-icons/list-ordered-icon"
+import { ListTodoIcon } from "../../tiptap-icons/list-todo-icon"
+
+// --- Lib ---
+import {
+ findNodePosition,
+ isNodeInSchema,
+ isNodeTypeSelected,
+ isValidPosition,
+ selectionWithinConvertibleTypes,
+} from "../../../lib/tiptap-utils"
+
+export type ListType = "bulletList" | "orderedList" | "taskList"
+
+/**
+ * Configuration for the list functionality
+ */
+export interface UseListConfig {
+ /**
+ * The Tiptap editor instance.
+ */
+ editor?: Editor | null
+ /**
+ * The type of list to toggle.
+ */
+ type: ListType
+ /**
+ * Whether the button should hide when list is not available.
+ * @default false
+ */
+ hideWhenUnavailable?: boolean
+ /**
+ * Callback function called after a successful toggle.
+ */
+ onToggled?: () => void
+}
+
+export const listIcons = {
+ bulletList: ListIcon,
+ orderedList: ListOrderedIcon,
+ taskList: ListTodoIcon,
+}
+
+export const listLabels: Record = {
+ bulletList: "Bullet List",
+ orderedList: "Ordered List",
+ taskList: "Task List",
+}
+
+export const LIST_SHORTCUT_KEYS: Record = {
+ bulletList: "mod+shift+8",
+ orderedList: "mod+shift+7",
+ taskList: "mod+shift+9",
+}
+
+/**
+ * Checks if a list can be toggled in the current editor state
+ */
+export function canToggleList(
+ editor: Editor | null,
+ type: ListType,
+ turnInto: boolean = true,
+): boolean {
+ if (!editor || !editor.isEditable) return false
+ if (!isNodeInSchema(type, editor) || isNodeTypeSelected(editor, ["image"]))
+ return false
+
+ if (!turnInto) {
+ switch (type) {
+ case "bulletList":
+ return editor.can().toggleBulletList()
+ case "orderedList":
+ return editor.can().toggleOrderedList()
+ case "taskList":
+ return editor.can().toggleList("taskList", "taskItem")
+ default:
+ return false
+ }
+ }
+
+ // Ensure selection is in nodes we're allowed to convert
+ if (
+ !selectionWithinConvertibleTypes(editor, [
+ "paragraph",
+ "heading",
+ "bulletList",
+ "orderedList",
+ "taskList",
+ "blockquote",
+ "codeBlock",
+ ])
+ )
+ return false
+
+ // Either we can set list directly on the selection,
+ // or we can clear formatting/nodes to arrive at a list.
+ switch (type) {
+ case "bulletList":
+ return editor.can().toggleBulletList() || editor.can().clearNodes()
+ case "orderedList":
+ return editor.can().toggleOrderedList() || editor.can().clearNodes()
+ case "taskList":
+ return (
+ editor.can().toggleList("taskList", "taskItem") ||
+ editor.can().clearNodes()
+ )
+ default:
+ return false
+ }
+}
+
+/**
+ * Checks if list is currently active
+ */
+export function isListActive(editor: Editor | null, type: ListType): boolean {
+ if (!editor || !editor.isEditable) return false
+
+ switch (type) {
+ case "bulletList":
+ return editor.isActive("bulletList")
+ case "orderedList":
+ return editor.isActive("orderedList")
+ case "taskList":
+ return editor.isActive("taskList")
+ default:
+ return false
+ }
+}
+
+/**
+ * Toggles list in the editor
+ */
+export function toggleList(editor: Editor | null, type: ListType): boolean {
+ if (!editor || !editor.isEditable) return false
+ if (!canToggleList(editor, type)) return false
+
+ try {
+ const view = editor.view
+ let state = view.state
+ let tr = state.tr
+
+ // No selection, find the the cursor position
+ if (state.selection.empty || state.selection instanceof TextSelection) {
+ const pos = findNodePosition({
+ editor,
+ node: state.selection.$anchor.node(1),
+ })?.pos
+ if (!isValidPosition(pos)) return false
+
+ tr = tr.setSelection(NodeSelection.create(state.doc, pos))
+ view.dispatch(tr)
+ state = view.state
+ }
+
+ const selection = state.selection
+
+ let chain = editor.chain().focus()
+
+ // Handle NodeSelection
+ if (selection instanceof NodeSelection) {
+ const firstChild = selection.node.firstChild?.firstChild
+ const lastChild = selection.node.lastChild?.lastChild
+
+ const from = firstChild
+ ? selection.from + firstChild.nodeSize
+ : selection.from + 1
+
+ const to = lastChild
+ ? selection.to - lastChild.nodeSize
+ : selection.to - 1
+
+ const resolvedFrom = state.doc.resolve(from)
+ const resolvedTo = state.doc.resolve(to)
+
+ chain = chain
+ .setTextSelection(TextSelection.between(resolvedFrom, resolvedTo))
+ .clearNodes()
+ }
+
+ if (editor.isActive(type)) {
+ // Unwrap list
+ chain
+ .liftListItem("listItem")
+ .lift("bulletList")
+ .lift("orderedList")
+ .lift("taskList")
+ .run()
+ } else {
+ // Wrap in specific list type
+ const toggleMap: Record typeof chain> = {
+ bulletList: () => chain.toggleBulletList(),
+ orderedList: () => chain.toggleOrderedList(),
+ taskList: () => chain.toggleList("taskList", "taskItem"),
+ }
+
+ const toggle = toggleMap[type]
+ if (!toggle) return false
+
+ toggle().run()
+ }
+
+ editor.chain().focus().selectTextblockEnd().run()
+
+ return true
+ } catch {
+ return false
+ }
+}
+
+/**
+ * Determines if the list button should be shown
+ */
+export function shouldShowButton(props: {
+ editor: Editor | null
+ type: ListType
+ hideWhenUnavailable: boolean
+}): boolean {
+ const { editor, type, hideWhenUnavailable } = props
+
+ if (!editor || !editor.isEditable) return false
+ if (!isNodeInSchema(type, editor)) return false
+
+ if (hideWhenUnavailable && !editor.isActive("code")) {
+ return canToggleList(editor, type)
+ }
+
+ return true
+}
+
+/**
+ * Custom hook that provides list functionality for Tiptap editor
+ *
+ * @example
+ * ```tsx
+ * // Simple usage
+ * function MySimpleListButton() {
+ * const { isVisible, handleToggle, isActive } = useList({ type: "bulletList" })
+ *
+ * if (!isVisible) return null
+ *
+ * return
+ * }
+ *
+ * // Advanced usage with configuration
+ * function MyAdvancedListButton() {
+ * const { isVisible, handleToggle, label, isActive } = useList({
+ * type: "orderedList",
+ * editor: myEditor,
+ * hideWhenUnavailable: true,
+ * onToggled: () => console.log('List toggled!')
+ * })
+ *
+ * if (!isVisible) return null
+ *
+ * return (
+ *
+ * Toggle List
+ *
+ * )
+ * }
+ * ```
+ */
+export function useList(config: UseListConfig) {
+ const {
+ editor: providedEditor,
+ type,
+ hideWhenUnavailable = false,
+ onToggled,
+ } = config
+
+ const { editor } = useTiptapEditor(providedEditor)
+ const [isVisible, setIsVisible] = useState(true)
+ const canToggle = canToggleList(editor, type)
+ const isActive = isListActive(editor, type)
+
+ useEffect(() => {
+ if (!editor) return
+
+ const handleSelectionUpdate = () => {
+ setIsVisible(shouldShowButton({ editor, type, hideWhenUnavailable }))
+ }
+
+ handleSelectionUpdate()
+
+ editor.on("selectionUpdate", handleSelectionUpdate)
+
+ return () => {
+ editor.off("selectionUpdate", handleSelectionUpdate)
+ }
+ }, [editor, type, hideWhenUnavailable])
+
+ const handleToggle = useCallback(() => {
+ if (!editor) return false
+
+ const success = toggleList(editor, type)
+ if (success) {
+ onToggled?.()
+ }
+ return success
+ }, [editor, type, onToggled])
+
+ return {
+ isVisible,
+ isActive,
+ handleToggle,
+ canToggle,
+ label: listLabels[type],
+ shortcutKeys: LIST_SHORTCUT_KEYS[type],
+ Icon: listIcons[type],
+ }
+}
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/list-dropdown-menu/index.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/list-dropdown-menu/index.tsx
new file mode 100644
index 0000000000..9a215b8016
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/list-dropdown-menu/index.tsx
@@ -0,0 +1 @@
+export * from "./list-dropdown-menu"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/list-dropdown-menu/list-dropdown-menu.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/list-dropdown-menu/list-dropdown-menu.tsx
new file mode 100644
index 0000000000..d143b9929f
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/list-dropdown-menu/list-dropdown-menu.tsx
@@ -0,0 +1,123 @@
+import React, { useCallback, useState } from "react"
+import { type Editor } from "@tiptap/react"
+
+// --- Hooks ---
+import { useTiptapEditor } from "../../../hooks/use-tiptap-editor"
+
+// --- Icons ---
+import { ChevronDownIcon } from "../../tiptap-icons/chevron-down-icon"
+
+// --- Tiptap UI ---
+import { ListButton, type ListType } from "../list-button"
+
+import { useListDropdownMenu } from "./use-list-dropdown-menu"
+
+// --- UI Primitives ---
+import type { ButtonProps } from "../../tiptap-ui-primitive/button"
+import { Button, ButtonGroup } from "../../tiptap-ui-primitive/button"
+import {
+ DropdownMenu,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuItem,
+} from "../../tiptap-ui-primitive/dropdown-menu"
+import { Card, CardBody } from "../../tiptap-ui-primitive/card"
+
+export interface ListDropdownMenuProps extends Omit {
+ /**
+ * The Tiptap editor instance.
+ */
+ editor?: Editor
+ /**
+ * The list types to display in the dropdown.
+ */
+ types?: ListType[]
+ /**
+ * Whether the dropdown should be hidden when no list types are available
+ * @default false
+ */
+ hideWhenUnavailable?: boolean
+ /**
+ * Callback for when the dropdown opens or closes
+ */
+ onOpenChange?: (isOpen: boolean) => void
+ /**
+ * Whether to render the dropdown menu in a portal
+ * @default false
+ */
+ portal?: boolean
+}
+
+export function ListDropdownMenu({
+ editor: providedEditor,
+ types = ["bulletList", "orderedList", "taskList"],
+ hideWhenUnavailable = false,
+ onOpenChange,
+ portal = false,
+ ...props
+}: ListDropdownMenuProps) {
+ const { editor } = useTiptapEditor(providedEditor)
+ const [isOpen, setIsOpen] = useState(false)
+
+ const { filteredLists, canToggle, isActive, isVisible, Icon } =
+ useListDropdownMenu({
+ editor,
+ types,
+ hideWhenUnavailable,
+ })
+
+ const handleOnOpenChange = useCallback(
+ (open: boolean) => {
+ setIsOpen(open)
+ onOpenChange?.(open)
+ },
+ [onOpenChange],
+ )
+
+ if (!isVisible || !editor || !editor.isEditable) {
+ return null
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ {filteredLists.map((option) => (
+
+
+
+ ))}
+
+
+
+
+
+ )
+}
+
+export default ListDropdownMenu
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/list-dropdown-menu/use-list-dropdown-menu.ts b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/list-dropdown-menu/use-list-dropdown-menu.ts
new file mode 100644
index 0000000000..21affaa15b
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/list-dropdown-menu/use-list-dropdown-menu.ts
@@ -0,0 +1,216 @@
+"use client"
+
+import { useEffect, useMemo, useState } from "react"
+import type { Editor } from "@tiptap/react"
+
+// --- Hooks ---
+import { useTiptapEditor } from "../../../hooks/use-tiptap-editor"
+
+// --- Icons ---
+import { ListIcon } from "../../tiptap-icons/list-icon"
+import { ListOrderedIcon } from "../../tiptap-icons/list-ordered-icon"
+import { ListTodoIcon } from "../../tiptap-icons/list-todo-icon"
+
+// --- Lib ---
+import { isNodeInSchema } from "../../../lib/tiptap-utils"
+
+// --- Tiptap UI ---
+import {
+ canToggleList,
+ isListActive,
+ listIcons,
+ type ListType,
+} from "../list-button"
+
+/**
+ * Configuration for the list dropdown menu functionality
+ */
+export interface UseListDropdownMenuConfig {
+ /**
+ * The Tiptap editor instance.
+ */
+ editor?: Editor | null
+ /**
+ * The list types to display in the dropdown.
+ * @default ["bulletList", "orderedList", "taskList"]
+ */
+ types?: ListType[]
+ /**
+ * Whether the dropdown should be hidden when no list types are available
+ * @default false
+ */
+ hideWhenUnavailable?: boolean
+}
+
+export interface ListOption {
+ label: string
+ type: ListType
+ icon: React.ElementType
+}
+
+export const listOptions: ListOption[] = [
+ {
+ label: "Bullet List",
+ type: "bulletList",
+ icon: ListIcon,
+ },
+ {
+ label: "Ordered List",
+ type: "orderedList",
+ icon: ListOrderedIcon,
+ },
+ {
+ label: "Task List",
+ type: "taskList",
+ icon: ListTodoIcon,
+ },
+]
+
+export function canToggleAnyList(
+ editor: Editor | null,
+ listTypes: ListType[],
+): boolean {
+ if (!editor || !editor.isEditable) return false
+ return listTypes.some((type) => canToggleList(editor, type))
+}
+
+export function isAnyListActive(
+ editor: Editor | null,
+ listTypes: ListType[],
+): boolean {
+ if (!editor || !editor.isEditable) return false
+ return listTypes.some((type) => isListActive(editor, type))
+}
+
+export function getFilteredListOptions(
+ availableTypes: ListType[],
+): typeof listOptions {
+ return listOptions.filter(
+ (option) => !option.type || availableTypes.includes(option.type),
+ )
+}
+
+export function shouldShowListDropdown(params: {
+ editor: Editor | null
+ listTypes: ListType[]
+ hideWhenUnavailable: boolean
+ listInSchema: boolean
+ canToggleAny: boolean
+}): boolean {
+ const { editor, hideWhenUnavailable, listInSchema, canToggleAny } = params
+
+ if (!listInSchema || !editor) {
+ return false
+ }
+
+ if (hideWhenUnavailable && !editor.isActive("code")) {
+ return canToggleAny
+ }
+
+ return true
+}
+
+/**
+ * Gets the currently active list type from the available types
+ */
+export function getActiveListType(
+ editor: Editor | null,
+ availableTypes: ListType[],
+): ListType | undefined {
+ if (!editor || !editor.isEditable) return undefined
+ return availableTypes.find((type) => isListActive(editor, type))
+}
+
+/**
+ * Custom hook that provides list dropdown menu functionality for Tiptap editor
+ *
+ * @example
+ * ```tsx
+ * // Simple usage
+ * function MyListDropdown() {
+ * const {
+ * isVisible,
+ * activeType,
+ * isAnyActive,
+ * canToggleAny,
+ * filteredLists,
+ * } = useListDropdownMenu()
+ *
+ * if (!isVisible) return null
+ *
+ * return (
+ *
+ * // dropdown content
+ *
+ * )
+ * }
+ *
+ * // Advanced usage with configuration
+ * function MyAdvancedListDropdown() {
+ * const {
+ * isVisible,
+ * activeType,
+ * } = useListDropdownMenu({
+ * editor: myEditor,
+ * types: ["bulletList", "orderedList"],
+ * hideWhenUnavailable: true,
+ * })
+ *
+ * // component implementation
+ * }
+ * ```
+ */
+export function useListDropdownMenu(config?: UseListDropdownMenuConfig) {
+ const {
+ editor: providedEditor,
+ types = ["bulletList", "orderedList", "taskList"],
+ hideWhenUnavailable = false,
+ } = config || {}
+
+ const { editor } = useTiptapEditor(providedEditor)
+ const [isVisible, setIsVisible] = useState(false)
+
+ const listInSchema = types.some((type) => isNodeInSchema(type, editor))
+
+ const filteredLists = useMemo(() => getFilteredListOptions(types), [types])
+
+ const canToggleAny = canToggleAnyList(editor, types)
+ const isAnyActive = isAnyListActive(editor, types)
+ const activeType = getActiveListType(editor, types)
+ const activeList = filteredLists.find((option) => option.type === activeType)
+
+ useEffect(() => {
+ if (!editor) return
+
+ const handleSelectionUpdate = () => {
+ setIsVisible(
+ shouldShowListDropdown({
+ editor,
+ listTypes: types,
+ hideWhenUnavailable,
+ listInSchema,
+ canToggleAny,
+ }),
+ )
+ }
+
+ handleSelectionUpdate()
+
+ editor.on("selectionUpdate", handleSelectionUpdate)
+
+ return () => {
+ editor.off("selectionUpdate", handleSelectionUpdate)
+ }
+ }, [canToggleAny, editor, hideWhenUnavailable, listInSchema, types])
+
+ return {
+ isVisible,
+ activeType,
+ isActive: isAnyActive,
+ canToggle: canToggleAny,
+ types,
+ filteredLists,
+ label: "List",
+ Icon: activeList ? listIcons[activeList.type] : ListIcon,
+ }
+}
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/mark-button/index.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/mark-button/index.tsx
new file mode 100644
index 0000000000..32e85b9c7b
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/mark-button/index.tsx
@@ -0,0 +1,2 @@
+export * from "./mark-button"
+export * from "./use-mark"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/mark-button/mark-button.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/mark-button/mark-button.tsx
new file mode 100644
index 0000000000..b6bb47073c
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/mark-button/mark-button.tsx
@@ -0,0 +1,123 @@
+"use client"
+
+import React, { forwardRef, useCallback } from "react"
+
+// --- Lib ---
+import { parseShortcutKeys } from "../../../lib/tiptap-utils"
+
+// --- Hooks ---
+import { useTiptapEditor } from "../../../hooks/use-tiptap-editor"
+
+// --- Tiptap UI ---
+import type { Mark, UseMarkConfig } from "./"
+import { MARK_SHORTCUT_KEYS, useMark } from "./"
+
+// --- UI Primitives ---
+import type { ButtonProps } from "../../tiptap-ui-primitive/button"
+import { Button } from "../../tiptap-ui-primitive/button"
+import { Badge } from "../../tiptap-ui-primitive/badge"
+
+export interface MarkButtonProps
+ extends Omit,
+ UseMarkConfig {
+ /**
+ * Optional text to display alongside the icon.
+ */
+ text?: string
+ /**
+ * Optional show shortcut keys in the button.
+ * @default false
+ */
+ showShortcut?: boolean
+}
+
+export function MarkShortcutBadge({
+ type,
+ shortcutKeys = MARK_SHORTCUT_KEYS[type],
+}: {
+ type: Mark
+ shortcutKeys?: string
+}) {
+ return {parseShortcutKeys({ shortcutKeys })}
+}
+
+/**
+ * Button component for toggling marks in a Tiptap editor.
+ *
+ * For custom button implementations, use the `useMark` hook instead.
+ */
+export const MarkButton = forwardRef(
+ (
+ {
+ editor: providedEditor,
+ type,
+ text,
+ hideWhenUnavailable = false,
+ onToggled,
+ showShortcut = false,
+ onClick,
+ children,
+ ...buttonProps
+ },
+ ref,
+ ) => {
+ const { editor } = useTiptapEditor(providedEditor)
+ const {
+ isVisible,
+ handleMark,
+ label,
+ canToggle,
+ isActive,
+ Icon,
+ shortcutKeys,
+ } = useMark({
+ editor,
+ type,
+ hideWhenUnavailable,
+ onToggled,
+ })
+
+ const handleClick = useCallback(
+ (event: React.MouseEvent) => {
+ onClick?.(event)
+ if (event.defaultPrevented) return
+ handleMark()
+ },
+ [handleMark, onClick],
+ )
+
+ if (!isVisible) {
+ return null
+ }
+
+ return (
+
+ )
+ },
+)
+
+MarkButton.displayName = "MarkButton"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/mark-button/use-mark.ts b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/mark-button/use-mark.ts
new file mode 100644
index 0000000000..c67352618e
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/mark-button/use-mark.ts
@@ -0,0 +1,212 @@
+import { useCallback, useEffect, useState } from "react"
+import type { Editor } from "@tiptap/react"
+
+// --- Hooks ---
+import { useTiptapEditor } from "../../../hooks/use-tiptap-editor"
+
+// --- Lib ---
+import { isMarkInSchema, isNodeTypeSelected } from "../../../lib/tiptap-utils"
+
+// --- Icons ---
+import { BoldIcon } from "../../tiptap-icons/bold-icon"
+import { Code2Icon } from "../../tiptap-icons/code2-icon"
+import { ItalicIcon } from "../../tiptap-icons/italic-icon"
+import { StrikeIcon } from "../../tiptap-icons/strike-icon"
+import { SubscriptIcon } from "../../tiptap-icons/subscript-icon"
+import { SuperscriptIcon } from "../../tiptap-icons/superscript-icon"
+import { UnderlineIcon } from "../../tiptap-icons/underline-icon"
+
+export type Mark =
+ | "bold"
+ | "italic"
+ | "strike"
+ | "code"
+ | "underline"
+ | "superscript"
+ | "subscript"
+
+/**
+ * Configuration for the mark functionality
+ */
+export interface UseMarkConfig {
+ /**
+ * The Tiptap editor instance.
+ */
+ editor?: Editor | null
+ /**
+ * The type of mark to toggle
+ */
+ type: Mark
+ /**
+ * Whether the button should hide when mark is not available.
+ * @default false
+ */
+ hideWhenUnavailable?: boolean
+ /**
+ * Callback function called after a successful mark toggle.
+ */
+ onToggled?: () => void
+}
+
+export const markIcons = {
+ bold: BoldIcon,
+ italic: ItalicIcon,
+ underline: UnderlineIcon,
+ strike: StrikeIcon,
+ code: Code2Icon,
+ superscript: SuperscriptIcon,
+ subscript: SubscriptIcon,
+}
+
+export const MARK_SHORTCUT_KEYS: Record = {
+ bold: "mod+b",
+ italic: "mod+i",
+ underline: "mod+u",
+ strike: "mod+shift+s",
+ code: "mod+e",
+ superscript: "mod+.",
+ subscript: "mod+,",
+}
+
+/**
+ * Checks if a mark can be toggled in the current editor state
+ */
+export function canToggleMark(editor: Editor | null, type: Mark): boolean {
+ if (!editor || !editor.isEditable) return false
+ if (!isMarkInSchema(type, editor) || isNodeTypeSelected(editor, ["image"]))
+ return false
+
+ return editor.can().toggleMark(type)
+}
+
+/**
+ * Checks if a mark is currently active
+ */
+export function isMarkActive(editor: Editor | null, type: Mark): boolean {
+ if (!editor || !editor.isEditable) return false
+ return editor.isActive(type)
+}
+
+/**
+ * Toggles a mark in the editor
+ */
+export function toggleMark(editor: Editor | null, type: Mark): boolean {
+ if (!editor || !editor.isEditable) return false
+ if (!canToggleMark(editor, type)) return false
+
+ return editor.chain().focus().toggleMark(type).run()
+}
+
+/**
+ * Determines if the mark button should be shown
+ */
+export function shouldShowButton(props: {
+ editor: Editor | null
+ type: Mark
+ hideWhenUnavailable: boolean
+}): boolean {
+ const { editor, type, hideWhenUnavailable } = props
+
+ if (!editor || !editor.isEditable) return false
+ if (!isMarkInSchema(type, editor)) return false
+
+ if (hideWhenUnavailable && !editor.isActive("code")) {
+ return canToggleMark(editor, type)
+ }
+
+ return true
+}
+
+/**
+ * Gets the formatted mark name
+ */
+export function getFormattedMarkName(type: Mark): string {
+ return type.charAt(0).toUpperCase() + type.slice(1)
+}
+
+/**
+ * Custom hook that provides mark functionality for Tiptap editor
+ *
+ * @example
+ * ```tsx
+ * // Simple usage
+ * function MySimpleBoldButton() {
+ * const { isVisible, handleMark } = useMark({ type: "bold" })
+ *
+ * if (!isVisible) return null
+ *
+ * return
+ * }
+ *
+ * // Advanced usage with configuration
+ * function MyAdvancedItalicButton() {
+ * const { isVisible, handleMark, label, isActive } = useMark({
+ * editor: myEditor,
+ * type: "italic",
+ * hideWhenUnavailable: true,
+ * onToggled: () => console.log('Mark toggled!')
+ * })
+ *
+ * if (!isVisible) return null
+ *
+ * return (
+ *
+ * Italic
+ *
+ * )
+ * }
+ * ```
+ */
+export function useMark(config: UseMarkConfig) {
+ const {
+ editor: providedEditor,
+ type,
+ hideWhenUnavailable = false,
+ onToggled,
+ } = config
+
+ const { editor } = useTiptapEditor(providedEditor)
+ const [isVisible, setIsVisible] = useState(true)
+ const canToggle = canToggleMark(editor, type)
+ const isActive = isMarkActive(editor, type)
+
+ useEffect(() => {
+ if (!editor) return
+
+ const handleSelectionUpdate = () => {
+ setIsVisible(shouldShowButton({ editor, type, hideWhenUnavailable }))
+ }
+
+ handleSelectionUpdate()
+
+ editor.on("selectionUpdate", handleSelectionUpdate)
+
+ return () => {
+ editor.off("selectionUpdate", handleSelectionUpdate)
+ }
+ }, [editor, type, hideWhenUnavailable])
+
+ const handleMark = useCallback(() => {
+ if (!editor) return false
+
+ const success = toggleMark(editor, type)
+ if (success) {
+ onToggled?.()
+ }
+ return success
+ }, [editor, type, onToggled])
+
+ return {
+ isVisible,
+ isActive,
+ handleMark,
+ canToggle,
+ label: getFormattedMarkName(type),
+ shortcutKeys: MARK_SHORTCUT_KEYS[type],
+ Icon: markIcons[type],
+ }
+}
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/text-align-button/index.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/text-align-button/index.tsx
new file mode 100644
index 0000000000..d19f95cf02
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/text-align-button/index.tsx
@@ -0,0 +1,2 @@
+export * from "./text-align-button"
+export * from "./use-text-align"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/text-align-button/text-align-button.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/text-align-button/text-align-button.tsx
new file mode 100644
index 0000000000..4e6c0db077
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/text-align-button/text-align-button.tsx
@@ -0,0 +1,139 @@
+"use client"
+
+import React, { forwardRef, useCallback } from "react"
+
+// --- Lib ---
+import { parseShortcutKeys } from "../../../lib/tiptap-utils"
+
+// --- Hooks ---
+import { useTiptapEditor } from "../../../hooks/use-tiptap-editor"
+
+// --- Tiptap UI ---
+import type { TextAlign, UseTextAlignConfig } from "./"
+import { TEXT_ALIGN_SHORTCUT_KEYS, useTextAlign } from "./"
+
+// --- UI Primitives ---
+import type { ButtonProps } from "../../tiptap-ui-primitive/button"
+import { Button } from "../../tiptap-ui-primitive/button"
+import { Badge } from "../../tiptap-ui-primitive/badge"
+
+type IconProps = React.SVGProps
+type IconComponent = ({ className, ...props }: IconProps) => React.ReactElement
+
+export interface TextAlignButtonProps
+ extends Omit,
+ UseTextAlignConfig {
+ /**
+ * Optional text to display alongside the icon.
+ */
+ text?: string
+ /**
+ * Optional show shortcut keys in the button.
+ * @default false
+ */
+ showShortcut?: boolean
+ /**
+ * Optional custom icon component to render instead of the default.
+ */
+ icon?: React.MemoExoticComponent | React.FC
+}
+
+export function TextAlignShortcutBadge({
+ align,
+ shortcutKeys = TEXT_ALIGN_SHORTCUT_KEYS[align],
+}: {
+ align: TextAlign
+ shortcutKeys?: string
+}) {
+ return {parseShortcutKeys({ shortcutKeys })}
+}
+
+/**
+ * Button component for setting text alignment in a Tiptap editor.
+ *
+ * For custom button implementations, use the `useTextAlign` hook instead.
+ */
+export const TextAlignButton = forwardRef<
+ HTMLButtonElement,
+ TextAlignButtonProps
+>(
+ (
+ {
+ editor: providedEditor,
+ align,
+ text,
+ hideWhenUnavailable = false,
+ onAligned,
+ showShortcut = false,
+ onClick,
+ icon: CustomIcon,
+ children,
+ ...buttonProps
+ },
+ ref,
+ ) => {
+ const { editor } = useTiptapEditor(providedEditor)
+ const {
+ isVisible,
+ handleTextAlign,
+ label,
+ canAlign,
+ isActive,
+ Icon,
+ shortcutKeys,
+ } = useTextAlign({
+ editor,
+ align,
+ hideWhenUnavailable,
+ onAligned,
+ })
+
+ const handleClick = useCallback(
+ (event: React.MouseEvent) => {
+ onClick?.(event)
+ if (event.defaultPrevented) return
+ handleTextAlign()
+ },
+ [handleTextAlign, onClick],
+ )
+
+ if (!isVisible) {
+ return null
+ }
+
+ const RenderIcon = CustomIcon ?? Icon
+
+ return (
+
+ )
+ },
+)
+
+TextAlignButton.displayName = "TextAlignButton"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/text-align-button/use-text-align.ts b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/text-align-button/use-text-align.ts
new file mode 100644
index 0000000000..d52c948404
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/text-align-button/use-text-align.ts
@@ -0,0 +1,222 @@
+import { useCallback, useEffect, useState } from "react"
+import type { ChainedCommands } from "@tiptap/react"
+import { type Editor } from "@tiptap/react"
+
+// --- Hooks ---
+import { useTiptapEditor } from "../../../hooks/use-tiptap-editor"
+
+// --- Lib ---
+import {
+ isExtensionAvailable,
+ isNodeTypeSelected,
+} from "../../../lib/tiptap-utils"
+
+// --- Icons ---
+import { AlignCenterIcon } from "../../tiptap-icons/align-center-icon"
+import { AlignJustifyIcon } from "../../tiptap-icons/align-justify-icon"
+import { AlignLeftIcon } from "../../tiptap-icons/align-left-icon"
+import { AlignRightIcon } from "../../tiptap-icons/align-right-icon"
+
+export type TextAlign = "left" | "center" | "right" | "justify"
+
+/**
+ * Configuration for the text align functionality
+ */
+export interface UseTextAlignConfig {
+ /**
+ * The Tiptap editor instance.
+ */
+ editor?: Editor | null
+ /**
+ * The text alignment to apply.
+ */
+ align: TextAlign
+ /**
+ * Whether the button should hide when alignment is not available.
+ * @default false
+ */
+ hideWhenUnavailable?: boolean
+ /**
+ * Callback function called after a successful alignment change.
+ */
+ onAligned?: () => void
+}
+
+export const TEXT_ALIGN_SHORTCUT_KEYS: Record = {
+ left: "mod+shift+l",
+ center: "mod+shift+e",
+ right: "mod+shift+r",
+ justify: "mod+shift+j",
+}
+
+export const textAlignIcons = {
+ left: AlignLeftIcon,
+ center: AlignCenterIcon,
+ right: AlignRightIcon,
+ justify: AlignJustifyIcon,
+}
+
+export const textAlignLabels: Record = {
+ left: "Align left",
+ center: "Align center",
+ right: "Align right",
+ justify: "Align justify",
+}
+
+/**
+ * Checks if text alignment can be performed in the current editor state
+ */
+export function canSetTextAlign(
+ editor: Editor | null,
+ align: TextAlign,
+): boolean {
+ if (!editor || !editor.isEditable) return false
+ if (
+ !isExtensionAvailable(editor, "textAlign") ||
+ isNodeTypeSelected(editor, ["image", "horizontalRule"])
+ )
+ return false
+
+ return editor.can().setTextAlign(align)
+}
+
+export function hasSetTextAlign(
+ commands: ChainedCommands,
+): commands is ChainedCommands & {
+ setTextAlign: (align: TextAlign) => ChainedCommands
+} {
+ return "setTextAlign" in commands
+}
+
+/**
+ * Checks if the text alignment is currently active
+ */
+export function isTextAlignActive(
+ editor: Editor | null,
+ align: TextAlign,
+): boolean {
+ if (!editor || !editor.isEditable) return false
+ return editor.isActive({ textAlign: align })
+}
+
+/**
+ * Sets text alignment in the editor
+ */
+export function setTextAlign(editor: Editor | null, align: TextAlign): boolean {
+ if (!editor || !editor.isEditable) return false
+ if (!canSetTextAlign(editor, align)) return false
+
+ const chain = editor.chain().focus()
+ if (hasSetTextAlign(chain)) {
+ return chain.setTextAlign(align).run()
+ }
+
+ return false
+}
+
+/**
+ * Determines if the text align button should be shown
+ */
+export function shouldShowButton(props: {
+ editor: Editor | null
+ hideWhenUnavailable: boolean
+ align: TextAlign
+}): boolean {
+ const { editor, hideWhenUnavailable, align } = props
+
+ if (!editor || !editor.isEditable) return false
+ if (!isExtensionAvailable(editor, "textAlign")) return false
+
+ if (hideWhenUnavailable && !editor.isActive("code")) {
+ return canSetTextAlign(editor, align)
+ }
+
+ return true
+}
+
+/**
+ * Custom hook that provides text align functionality for Tiptap editor
+ *
+ * @example
+ * ```tsx
+ * // Simple usage
+ * function MySimpleAlignButton() {
+ * const { isVisible, handleTextAlign } = useTextAlign({ align: "center" })
+ *
+ * if (!isVisible) return null
+ *
+ * return
+ * }
+ *
+ * // Advanced usage with configuration
+ * function MyAdvancedAlignButton() {
+ * const { isVisible, handleTextAlign, label, isActive } = useTextAlign({
+ * editor: myEditor,
+ * align: "right",
+ * hideWhenUnavailable: true,
+ * onAligned: () => console.log('Text aligned!')
+ * })
+ *
+ * if (!isVisible) return null
+ *
+ * return (
+ *
+ * Align Right
+ *
+ * )
+ * }
+ * ```
+ */
+export function useTextAlign(config: UseTextAlignConfig) {
+ const {
+ editor: providedEditor,
+ align,
+ hideWhenUnavailable = false,
+ onAligned,
+ } = config
+
+ const { editor } = useTiptapEditor(providedEditor)
+ const [isVisible, setIsVisible] = useState(true)
+ const canAlign = canSetTextAlign(editor, align)
+ const isActive = isTextAlignActive(editor, align)
+
+ useEffect(() => {
+ if (!editor) return
+
+ const handleSelectionUpdate = () => {
+ setIsVisible(shouldShowButton({ editor, align, hideWhenUnavailable }))
+ }
+
+ handleSelectionUpdate()
+
+ editor.on("selectionUpdate", handleSelectionUpdate)
+
+ return () => {
+ editor.off("selectionUpdate", handleSelectionUpdate)
+ }
+ }, [editor, hideWhenUnavailable, align])
+
+ const handleTextAlign = useCallback(() => {
+ if (!editor) return false
+
+ const success = setTextAlign(editor, align)
+ if (success) {
+ onAligned?.()
+ }
+ return success
+ }, [editor, align, onAligned])
+
+ return {
+ isVisible,
+ isActive,
+ handleTextAlign,
+ canAlign,
+ label: textAlignLabels[align],
+ shortcutKeys: TEXT_ALIGN_SHORTCUT_KEYS[align],
+ Icon: textAlignIcons[align],
+ }
+}
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/undo-redo-button/index.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/undo-redo-button/index.tsx
new file mode 100644
index 0000000000..fa0fdbeb08
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/undo-redo-button/index.tsx
@@ -0,0 +1,2 @@
+export * from "./undo-redo-button"
+export * from "./use-undo-redo"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/undo-redo-button/undo-redo-button.tsx b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/undo-redo-button/undo-redo-button.tsx
new file mode 100644
index 0000000000..5743e02410
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/undo-redo-button/undo-redo-button.tsx
@@ -0,0 +1,120 @@
+"use client"
+
+import React, { forwardRef, useCallback } from "react"
+
+// --- Lib ---
+import { parseShortcutKeys } from "../../../lib/tiptap-utils"
+
+// --- Hooks ---
+import { useTiptapEditor } from "../../../hooks/use-tiptap-editor"
+
+// --- Tiptap UI ---
+import type { UndoRedoAction, UseUndoRedoConfig } from "./"
+import { UNDO_REDO_SHORTCUT_KEYS, useUndoRedo } from "./"
+
+// --- UI Primitives ---
+import type { ButtonProps } from "../../tiptap-ui-primitive/button"
+import { Button } from "../../tiptap-ui-primitive/button"
+import { Badge } from "../../tiptap-ui-primitive/badge"
+
+export interface UndoRedoButtonProps
+ extends Omit,
+ UseUndoRedoConfig {
+ /**
+ * Optional text to display alongside the icon.
+ */
+ text?: string
+ /**
+ * Optional show shortcut keys in the button.
+ * @default false
+ */
+ showShortcut?: boolean
+}
+
+export function HistoryShortcutBadge({
+ action,
+ shortcutKeys = UNDO_REDO_SHORTCUT_KEYS[action],
+}: {
+ action: UndoRedoAction
+ shortcutKeys?: string
+}) {
+ return {parseShortcutKeys({ shortcutKeys })}
+}
+
+/**
+ * Button component for triggering undo/redo actions in a Tiptap editor.
+ *
+ * For custom button implementations, use the `useHistory` hook instead.
+ */
+export const UndoRedoButton = forwardRef<
+ HTMLButtonElement,
+ UndoRedoButtonProps
+>(
+ (
+ {
+ editor: providedEditor,
+ action,
+ text,
+ hideWhenUnavailable = false,
+ onExecuted,
+ showShortcut = false,
+ onClick,
+ children,
+ ...buttonProps
+ },
+ ref,
+ ) => {
+ const { editor } = useTiptapEditor(providedEditor)
+ const { isVisible, handleAction, label, canExecute, Icon, shortcutKeys } =
+ useUndoRedo({
+ editor,
+ action,
+ hideWhenUnavailable,
+ onExecuted,
+ })
+
+ const handleClick = useCallback(
+ (event: React.MouseEvent) => {
+ onClick?.(event)
+ if (event.defaultPrevented) return
+ handleAction()
+ },
+ [handleAction, onClick],
+ )
+
+ if (!isVisible) {
+ return null
+ }
+
+ return (
+
+ )
+ },
+)
+
+UndoRedoButton.displayName = "UndoRedoButton"
diff --git a/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/undo-redo-button/use-undo-redo.ts b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/undo-redo-button/use-undo-redo.ts
new file mode 100644
index 0000000000..3add2c48e9
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/components/tiptap-ui/undo-redo-button/use-undo-redo.ts
@@ -0,0 +1,182 @@
+import { useCallback, useEffect, useState } from "react"
+import { type Editor } from "@tiptap/react"
+
+// --- Hooks ---
+import { useTiptapEditor } from "../../../hooks/use-tiptap-editor"
+
+// --- Lib ---
+import { isNodeTypeSelected } from "../../../lib/tiptap-utils"
+
+// --- Icons ---
+import { Redo2Icon } from "../../tiptap-icons/redo2-icon"
+import { Undo2Icon } from "../../tiptap-icons/undo2-icon"
+
+export type UndoRedoAction = "undo" | "redo"
+
+/**
+ * Configuration for the history functionality
+ */
+export interface UseUndoRedoConfig {
+ /**
+ * The Tiptap editor instance.
+ */
+ editor?: Editor | null
+ /**
+ * The history action to perform (undo or redo).
+ */
+ action: UndoRedoAction
+ /**
+ * Whether the button should hide when action is not available.
+ * @default false
+ */
+ hideWhenUnavailable?: boolean
+ /**
+ * Callback function called after a successful action execution.
+ */
+ onExecuted?: () => void
+}
+
+export const UNDO_REDO_SHORTCUT_KEYS: Record = {
+ undo: "mod+z",
+ redo: "mod+shift+z",
+}
+
+export const historyActionLabels: Record = {
+ undo: "Undo",
+ redo: "Redo",
+}
+
+export const historyIcons = {
+ undo: Undo2Icon,
+ redo: Redo2Icon,
+}
+
+/**
+ * Checks if a history action can be executed
+ */
+export function canExecuteUndoRedoAction(
+ editor: Editor | null,
+ action: UndoRedoAction,
+): boolean {
+ if (!editor || !editor.isEditable) return false
+ if (isNodeTypeSelected(editor, ["image"])) return false
+
+ return action === "undo" ? editor.can().undo() : editor.can().redo()
+}
+
+/**
+ * Executes a history action on the editor
+ */
+export function executeUndoRedoAction(
+ editor: Editor | null,
+ action: UndoRedoAction,
+): boolean {
+ if (!editor || !editor.isEditable) return false
+ if (!canExecuteUndoRedoAction(editor, action)) return false
+
+ const chain = editor.chain().focus()
+ return action === "undo" ? chain.undo().run() : chain.redo().run()
+}
+
+/**
+ * Determines if the history button should be shown
+ */
+export function shouldShowButton(props: {
+ editor: Editor | null
+ hideWhenUnavailable: boolean
+ action: UndoRedoAction
+}): boolean {
+ const { editor, hideWhenUnavailable, action } = props
+
+ if (!editor || !editor.isEditable) return false
+
+ if (hideWhenUnavailable && !editor.isActive("code")) {
+ return canExecuteUndoRedoAction(editor, action)
+ }
+
+ return true
+}
+
+/**
+ * Custom hook that provides history functionality for Tiptap editor
+ *
+ * @example
+ * ```tsx
+ * // Simple usage
+ * function MySimpleUndoButton() {
+ * const { isVisible, handleAction } = useHistory({ action: "undo" })
+ *
+ * if (!isVisible) return null
+ *
+ * return
+ * }
+ *
+ * // Advanced usage with configuration
+ * function MyAdvancedRedoButton() {
+ * const { isVisible, handleAction, label } = useHistory({
+ * editor: myEditor,
+ * action: "redo",
+ * hideWhenUnavailable: true,
+ * onExecuted: () => console.log('Action executed!')
+ * })
+ *
+ * if (!isVisible) return null
+ *
+ * return (
+ *
+ * Redo
+ *
+ * )
+ * }
+ * ```
+ */
+export function useUndoRedo(config: UseUndoRedoConfig) {
+ const {
+ editor: providedEditor,
+ action,
+ hideWhenUnavailable = false,
+ onExecuted,
+ } = config
+
+ const { editor } = useTiptapEditor(providedEditor)
+ const [isVisible, setIsVisible] = useState(true)
+ const canExecute = canExecuteUndoRedoAction(editor, action)
+
+ useEffect(() => {
+ if (!editor) return
+
+ const handleUpdate = () => {
+ setIsVisible(shouldShowButton({ editor, hideWhenUnavailable, action }))
+ }
+
+ handleUpdate()
+
+ editor.on("transaction", handleUpdate)
+
+ return () => {
+ editor.off("transaction", handleUpdate)
+ }
+ }, [editor, hideWhenUnavailable, action])
+
+ const handleAction = useCallback(() => {
+ if (!editor) return false
+
+ const success = executeUndoRedoAction(editor, action)
+ if (success) {
+ onExecuted?.()
+ }
+ return success
+ }, [editor, action, onExecuted])
+
+ return {
+ isVisible,
+ handleAction,
+ canExecute,
+ label: historyActionLabels[action],
+ shortcutKeys: UNDO_REDO_SHORTCUT_KEYS[action],
+ Icon: historyIcons[action],
+ }
+}
diff --git a/frontends/ol-components/src/components/TiptapEditor/hooks/use-composed-ref.ts b/frontends/ol-components/src/components/TiptapEditor/hooks/use-composed-ref.ts
new file mode 100644
index 0000000000..7b6d8aa0dc
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/hooks/use-composed-ref.ts
@@ -0,0 +1,47 @@
+"use client"
+
+import { useCallback, useRef } from "react"
+
+// basically Exclude["ref"], string>
+type UserRef =
+ | ((instance: T | null) => void)
+ | React.RefObject
+ | null
+ | undefined
+
+const updateRef = (ref: NonNullable>, value: T | null) => {
+ if (typeof ref === "function") {
+ ref(value)
+ } else if (ref && typeof ref === "object" && "current" in ref) {
+ // Safe assignment without MutableRefObject
+ ;(ref as { current: T | null }).current = value
+ }
+}
+
+export const useComposedRef = (
+ libRef: React.RefObject,
+ userRef: UserRef,
+) => {
+ const prevUserRef = useRef>(null)
+
+ return useCallback(
+ (instance: T | null) => {
+ if (libRef && "current" in libRef) {
+ ;(libRef as { current: T | null }).current = instance
+ }
+
+ if (prevUserRef.current) {
+ updateRef(prevUserRef.current, null)
+ }
+
+ prevUserRef.current = userRef
+
+ if (userRef) {
+ updateRef(userRef, instance)
+ }
+ },
+ [libRef, userRef],
+ )
+}
+
+export default useComposedRef
diff --git a/frontends/ol-components/src/components/TiptapEditor/hooks/use-cursor-visibility.ts b/frontends/ol-components/src/components/TiptapEditor/hooks/use-cursor-visibility.ts
new file mode 100644
index 0000000000..5146bea8b1
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/hooks/use-cursor-visibility.ts
@@ -0,0 +1,69 @@
+import type { Editor } from "@tiptap/react"
+import { useWindowSize } from "./use-window-size"
+import { useBodyRect } from "./use-element-rect"
+import { useEffect } from "react"
+
+export interface CursorVisibilityOptions {
+ /**
+ * The Tiptap editor instance
+ */
+ editor?: Editor | null
+ /**
+ * Reference to the toolbar element that may obscure the cursor
+ */
+ overlayHeight?: number
+}
+
+/**
+ * Custom hook that ensures the cursor remains visible when typing in a Tiptap editor.
+ * Automatically scrolls the window when the cursor would be hidden by the toolbar.
+ *
+ * @param options.editor The Tiptap editor instance
+ * @param options.overlayHeight Toolbar height to account for
+ * @returns The bounding rect of the body
+ */
+export function useCursorVisibility({
+ editor,
+ overlayHeight = 0,
+}: CursorVisibilityOptions) {
+ const { height: windowHeight } = useWindowSize()
+ const rect = useBodyRect({
+ enabled: true,
+ throttleMs: 100,
+ useResizeObserver: true,
+ })
+
+ useEffect(() => {
+ const ensureCursorVisibility = () => {
+ if (!editor) return
+
+ const { state, view } = editor
+ if (!view.hasFocus()) return
+
+ // Get current cursor position coordinates
+ const { from } = state.selection
+ const cursorCoords = view.coordsAtPos(from)
+
+ if (windowHeight < rect.height && cursorCoords) {
+ const availableSpace = windowHeight - cursorCoords.top
+
+ // If the cursor is hidden behind the overlay or offscreen, scroll it into view
+ if (availableSpace < overlayHeight) {
+ const targetCursorY = Math.max(windowHeight / 2, overlayHeight)
+ const currentScrollY = window.scrollY
+ const cursorAbsoluteY = cursorCoords.top + currentScrollY
+ const newScrollY = cursorAbsoluteY - targetCursorY
+
+ window.scrollTo({
+ top: Math.max(0, newScrollY),
+ behavior: "smooth",
+ })
+ }
+ }
+ }
+
+ ensureCursorVisibility()
+ }, [editor, overlayHeight, windowHeight, rect.height])
+
+ return rect
+}
diff --git a/frontends/ol-components/src/components/TiptapEditor/hooks/use-element-rect.ts b/frontends/ol-components/src/components/TiptapEditor/hooks/use-element-rect.ts
new file mode 100644
index 0000000000..eacb84ac29
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/hooks/use-element-rect.ts
@@ -0,0 +1,166 @@
+"use client"
+
+import { useCallback, useEffect, useState } from "react"
+import { useThrottledCallback } from "./use-throttled-callback"
+
+export type RectState = Omit
+
+export interface ElementRectOptions {
+ /**
+ * The element to track. Can be an Element, ref, or selector string.
+ * Defaults to document.body if not provided.
+ */
+ element?: Element | React.RefObject | string | null
+ /**
+ * Whether to enable rect tracking
+ */
+ enabled?: boolean
+ /**
+ * Throttle delay in milliseconds for rect updates
+ */
+ throttleMs?: number
+ /**
+ * Whether to use ResizeObserver for more accurate tracking
+ */
+ useResizeObserver?: boolean
+}
+
+const initialRect: RectState = {
+ x: 0,
+ y: 0,
+ width: 0,
+ height: 0,
+ top: 0,
+ right: 0,
+ bottom: 0,
+ left: 0,
+}
+
+const isSSR = typeof window === "undefined"
+const hasResizeObserver = !isSSR && typeof ResizeObserver !== "undefined"
+
+/**
+ * Helper function to check if code is running on client side
+ */
+const isClientSide = (): boolean => !isSSR
+
+/**
+ * Custom hook that tracks an element's bounding rectangle and updates on resize, scroll, etc.
+ *
+ * @param options Configuration options for element rect tracking
+ * @returns The current bounding rectangle of the element
+ */
+export function useElementRect({
+ element,
+ enabled = true,
+ throttleMs = 100,
+ useResizeObserver = true,
+}: ElementRectOptions = {}): RectState {
+ const [rect, setRect] = useState(initialRect)
+
+ const getTargetElement = useCallback((): Element | null => {
+ if (!enabled || !isClientSide()) return null
+
+ if (!element) {
+ return document.body
+ }
+
+ if (typeof element === "string") {
+ return document.querySelector(element)
+ }
+
+ if ("current" in element) {
+ return element.current
+ }
+
+ return element
+ }, [element, enabled])
+
+ const updateRect = useThrottledCallback(
+ () => {
+ if (!enabled || !isClientSide()) return
+
+ const targetElement = getTargetElement()
+ if (!targetElement) {
+ setRect(initialRect)
+ return
+ }
+
+ const newRect = targetElement.getBoundingClientRect()
+ setRect({
+ x: newRect.x,
+ y: newRect.y,
+ width: newRect.width,
+ height: newRect.height,
+ top: newRect.top,
+ right: newRect.right,
+ bottom: newRect.bottom,
+ left: newRect.left,
+ })
+ },
+ throttleMs,
+ [enabled, getTargetElement],
+ { leading: true, trailing: true },
+ )
+
+ useEffect(() => {
+ if (!enabled || !isClientSide()) {
+ setRect(initialRect)
+ return
+ }
+
+ const targetElement = getTargetElement()
+ if (!targetElement) return
+
+ updateRect()
+
+ const cleanup: (() => void)[] = []
+
+ if (useResizeObserver && hasResizeObserver) {
+ const resizeObserver = new ResizeObserver(() => {
+ window.requestAnimationFrame(updateRect)
+ })
+ resizeObserver.observe(targetElement)
+ cleanup.push(() => resizeObserver.disconnect())
+ }
+
+ const handleUpdate = () => updateRect()
+
+ window.addEventListener("scroll", handleUpdate, true)
+ window.addEventListener("resize", handleUpdate, true)
+
+ cleanup.push(() => {
+ window.removeEventListener("scroll", handleUpdate)
+ window.removeEventListener("resize", handleUpdate)
+ })
+
+ return () => {
+ cleanup.forEach((fn) => fn())
+ setRect(initialRect)
+ }
+ }, [enabled, getTargetElement, updateRect, useResizeObserver])
+
+ return rect
+}
+
+/**
+ * Convenience hook for tracking document.body rect
+ */
+export function useBodyRect(
+ options: Omit = {},
+): RectState {
+ return useElementRect({
+ ...options,
+ element: isClientSide() ? document.body : null,
+ })
+}
+
+/**
+ * Convenience hook for tracking a ref element's rect
+ */
+export function useRefRect(
+ ref: React.RefObject,
+ options: Omit = {},
+): RectState {
+ return useElementRect({ ...options, element: ref })
+}
diff --git a/frontends/ol-components/src/components/TiptapEditor/hooks/use-menu-navigation.ts b/frontends/ol-components/src/components/TiptapEditor/hooks/use-menu-navigation.ts
new file mode 100644
index 0000000000..9080453885
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/hooks/use-menu-navigation.ts
@@ -0,0 +1,194 @@
+import type { Editor } from "@tiptap/react"
+import { useEffect, useState } from "react"
+
+type Orientation = "horizontal" | "vertical" | "both"
+
+interface MenuNavigationOptions {
+ /**
+ * The Tiptap editor instance, if using with a Tiptap editor.
+ */
+ editor?: Editor | null
+ /**
+ * Reference to the container element for handling keyboard events.
+ */
+ containerRef?: React.RefObject
+ /**
+ * Search query that affects the selected item.
+ */
+ query?: string
+ /**
+ * Array of items to navigate through.
+ */
+ items: T[]
+ /**
+ * Callback fired when an item is selected.
+ */
+ onSelect?: (item: T) => void
+ /**
+ * Callback fired when the menu should close.
+ */
+ onClose?: () => void
+ /**
+ * The navigation orientation of the menu.
+ * @default "vertical"
+ */
+ orientation?: Orientation
+ /**
+ * Whether to automatically select the first item when the menu opens.
+ * @default true
+ */
+ autoSelectFirstItem?: boolean
+}
+
+/**
+ * Hook that implements keyboard navigation for dropdown menus and command palettes.
+ *
+ * Handles arrow keys, tab, home/end, enter for selection, and escape to close.
+ * Works with both Tiptap editors and regular DOM elements.
+ *
+ * @param options - Configuration options for the menu navigation
+ * @returns Object containing the selected index and a setter function
+ */
+export function useMenuNavigation({
+ editor,
+ containerRef,
+ query,
+ items,
+ onSelect,
+ onClose,
+ orientation = "vertical",
+ autoSelectFirstItem = true,
+}: MenuNavigationOptions) {
+ const [selectedIndex, setSelectedIndex] = useState(
+ autoSelectFirstItem ? 0 : -1,
+ )
+
+ useEffect(() => {
+ const handleKeyboardNavigation = (event: KeyboardEvent) => {
+ if (!items.length) return false
+
+ const moveNext = () =>
+ setSelectedIndex((currentIndex) => {
+ if (currentIndex === -1) return 0
+ return (currentIndex + 1) % items.length
+ })
+
+ const movePrev = () =>
+ setSelectedIndex((currentIndex) => {
+ if (currentIndex === -1) return items.length - 1
+ return (currentIndex - 1 + items.length) % items.length
+ })
+
+ switch (event.key) {
+ case "ArrowUp": {
+ if (orientation === "horizontal") return false
+ event.preventDefault()
+ movePrev()
+ return true
+ }
+
+ case "ArrowDown": {
+ if (orientation === "horizontal") return false
+ event.preventDefault()
+ moveNext()
+ return true
+ }
+
+ case "ArrowLeft": {
+ if (orientation === "vertical") return false
+ event.preventDefault()
+ movePrev()
+ return true
+ }
+
+ case "ArrowRight": {
+ if (orientation === "vertical") return false
+ event.preventDefault()
+ moveNext()
+ return true
+ }
+
+ case "Tab": {
+ event.preventDefault()
+ if (event.shiftKey) {
+ movePrev()
+ } else {
+ moveNext()
+ }
+ return true
+ }
+
+ case "Home": {
+ event.preventDefault()
+ setSelectedIndex(0)
+ return true
+ }
+
+ case "End": {
+ event.preventDefault()
+ setSelectedIndex(items.length - 1)
+ return true
+ }
+
+ case "Enter": {
+ if (event.isComposing) return false
+ event.preventDefault()
+ if (selectedIndex !== -1 && items[selectedIndex]) {
+ onSelect?.(items[selectedIndex])
+ }
+ return true
+ }
+
+ case "Escape": {
+ event.preventDefault()
+ onClose?.()
+ return true
+ }
+
+ default:
+ return false
+ }
+ }
+
+ let targetElement: HTMLElement | null = null
+
+ if (editor) {
+ targetElement = editor.view.dom
+ } else if (containerRef?.current) {
+ targetElement = containerRef.current
+ }
+
+ if (targetElement) {
+ targetElement.addEventListener("keydown", handleKeyboardNavigation, true)
+
+ return () => {
+ targetElement?.removeEventListener(
+ "keydown",
+ handleKeyboardNavigation,
+ true,
+ )
+ }
+ }
+
+ return undefined
+ }, [
+ editor,
+ containerRef,
+ items,
+ selectedIndex,
+ onSelect,
+ onClose,
+ orientation,
+ ])
+
+ useEffect(() => {
+ if (query) {
+ setSelectedIndex(autoSelectFirstItem ? 0 : -1)
+ }
+ }, [query, autoSelectFirstItem])
+
+ return {
+ selectedIndex: items.length ? selectedIndex : undefined,
+ setSelectedIndex,
+ }
+}
diff --git a/frontends/ol-components/src/components/TiptapEditor/hooks/use-mobile.ts b/frontends/ol-components/src/components/TiptapEditor/hooks/use-mobile.ts
new file mode 100644
index 0000000000..8088e59bb9
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/hooks/use-mobile.ts
@@ -0,0 +1,17 @@
+import { useEffect, useState } from "react"
+
+export function useIsMobile(breakpoint = 768) {
+ const [isMobile, setIsMobile] = useState(undefined)
+
+ useEffect(() => {
+ const mql = window.matchMedia(`(max-width: ${breakpoint - 1}px)`)
+ const onChange = () => {
+ setIsMobile(window.innerWidth < breakpoint)
+ }
+ mql.addEventListener("change", onChange)
+ setIsMobile(window.innerWidth < breakpoint)
+ return () => mql.removeEventListener("change", onChange)
+ }, [breakpoint])
+
+ return !!isMobile
+}
diff --git a/frontends/ol-components/src/components/TiptapEditor/hooks/use-scrolling.ts b/frontends/ol-components/src/components/TiptapEditor/hooks/use-scrolling.ts
new file mode 100644
index 0000000000..0c4bcfcefe
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/hooks/use-scrolling.ts
@@ -0,0 +1,75 @@
+import type { RefObject } from "react"
+import { useEffect, useState } from "react"
+
+type ScrollTarget = RefObject | Window | null | undefined
+type EventTargetWithScroll = Window | HTMLElement | Document
+
+interface UseScrollingOptions {
+ debounce?: number
+ fallbackToDocument?: boolean
+}
+
+export function useScrolling(
+ target?: ScrollTarget,
+ options: UseScrollingOptions = {},
+): boolean {
+ const { debounce = 150, fallbackToDocument = true } = options
+ const [isScrolling, setIsScrolling] = useState(false)
+
+ useEffect(() => {
+ // Resolve element or window
+ const element: EventTargetWithScroll =
+ target && typeof Window !== "undefined" && target instanceof Window
+ ? target
+ : ((target as RefObject)?.current ?? window)
+
+ // Mobile: fallback to document when using window
+ const eventTarget: EventTargetWithScroll =
+ fallbackToDocument &&
+ element === window &&
+ typeof document !== "undefined"
+ ? document
+ : element
+
+ const on = (
+ el: EventTargetWithScroll,
+ event: string,
+ handler: EventListener,
+ ) => el.addEventListener(event, handler, true)
+
+ const off = (
+ el: EventTargetWithScroll,
+ event: string,
+ handler: EventListener,
+ ) => el.removeEventListener(event, handler)
+
+ let timeout: ReturnType
+ const supportsScrollEnd = element === window && "onscrollend" in window
+
+ const handleScroll: EventListener = () => {
+ if (!isScrolling) setIsScrolling(true)
+
+ if (!supportsScrollEnd) {
+ clearTimeout(timeout)
+ timeout = setTimeout(() => setIsScrolling(false), debounce)
+ }
+ }
+
+ const handleScrollEnd: EventListener = () => setIsScrolling(false)
+
+ on(eventTarget, "scroll", handleScroll)
+ if (supportsScrollEnd) {
+ on(eventTarget, "scrollend", handleScrollEnd)
+ }
+
+ return () => {
+ off(eventTarget, "scroll", handleScroll)
+ if (supportsScrollEnd) {
+ off(eventTarget, "scrollend", handleScrollEnd)
+ }
+ clearTimeout(timeout)
+ }
+ }, [target, debounce, fallbackToDocument, isScrolling])
+
+ return isScrolling
+}
diff --git a/frontends/ol-components/src/components/TiptapEditor/hooks/use-throttled-callback.ts b/frontends/ol-components/src/components/TiptapEditor/hooks/use-throttled-callback.ts
new file mode 100644
index 0000000000..dadabd8fdf
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/hooks/use-throttled-callback.ts
@@ -0,0 +1,48 @@
+import throttle from "lodash.throttle"
+
+import { useUnmount } from "./use-unmount"
+import { useMemo } from "react"
+
+interface ThrottleSettings {
+ leading?: boolean | undefined
+ trailing?: boolean | undefined
+}
+
+const defaultOptions: ThrottleSettings = {
+ leading: false,
+ trailing: true,
+}
+
+/**
+ * A hook that returns a throttled callback function.
+ *
+ * @param fn The function to throttle
+ * @param wait The time in ms to wait before calling the function
+ * @param dependencies The dependencies to watch for changes
+ * @param options The throttle options
+ */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function useThrottledCallback any>(
+ fn: T,
+ wait = 250,
+ dependencies: React.DependencyList = [],
+ options: ThrottleSettings = defaultOptions,
+): {
+ (this: ThisParameterType, ...args: Parameters): ReturnType
+ cancel: () => void
+ flush: () => void
+} {
+ const handler = useMemo(
+ () => throttle(fn, wait, options),
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ dependencies,
+ )
+
+ useUnmount(() => {
+ handler.cancel()
+ })
+
+ return handler
+}
+
+export default useThrottledCallback
diff --git a/frontends/ol-components/src/components/TiptapEditor/hooks/use-tiptap-editor.ts b/frontends/ol-components/src/components/TiptapEditor/hooks/use-tiptap-editor.ts
new file mode 100644
index 0000000000..056159dada
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/hooks/use-tiptap-editor.ts
@@ -0,0 +1,47 @@
+import type { Editor } from "@tiptap/react"
+import { useCurrentEditor, useEditorState } from "@tiptap/react"
+import { useMemo } from "react"
+
+/**
+ * Hook that provides access to a Tiptap editor instance.
+ *
+ * Accepts an optional editor instance directly, or falls back to retrieving
+ * the editor from the Tiptap context if available. This allows components
+ * to work both when given an editor directly and when used within a Tiptap
+ * editor context.
+ *
+ * @param providedEditor - Optional editor instance to use instead of the context editor
+ * @returns The provided editor or the editor from context, whichever is available
+ */
+export function useTiptapEditor(providedEditor?: Editor | null): {
+ editor: Editor | null
+ editorState?: Editor["state"]
+ canCommand?: Editor["can"]
+} {
+ const { editor: coreEditor } = useCurrentEditor()
+ const mainEditor = useMemo(
+ () => providedEditor || coreEditor,
+ [providedEditor, coreEditor],
+ )
+
+ const editorState = useEditorState({
+ editor: mainEditor,
+ selector(context) {
+ if (!context.editor) {
+ return {
+ editor: null,
+ editorState: undefined,
+ canCommand: undefined,
+ }
+ }
+
+ return {
+ editor: context.editor,
+ editorState: context.editor.state,
+ canCommand: context.editor.can,
+ }
+ },
+ })
+
+ return editorState || { editor: null }
+}
diff --git a/frontends/ol-components/src/components/TiptapEditor/hooks/use-unmount.ts b/frontends/ol-components/src/components/TiptapEditor/hooks/use-unmount.ts
new file mode 100644
index 0000000000..bd229255e9
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/hooks/use-unmount.ts
@@ -0,0 +1,21 @@
+import { useRef, useEffect } from "react"
+
+/**
+ * Hook that executes a callback when the component unmounts.
+ *
+ * @param callback Function to be called on component unmount
+ */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export const useUnmount = (callback: (...args: Array) => any) => {
+ const ref = useRef(callback)
+ ref.current = callback
+
+ useEffect(
+ () => () => {
+ ref.current()
+ },
+ [],
+ )
+}
+
+export default useUnmount
diff --git a/frontends/ol-components/src/components/TiptapEditor/hooks/use-window-size.ts b/frontends/ol-components/src/components/TiptapEditor/hooks/use-window-size.ts
new file mode 100644
index 0000000000..6df968b353
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/hooks/use-window-size.ts
@@ -0,0 +1,93 @@
+"use client"
+
+import { useEffect, useState } from "react"
+import { useThrottledCallback } from "./use-throttled-callback"
+
+export interface WindowSizeState {
+ /**
+ * The width of the window's visual viewport in pixels.
+ */
+ width: number
+ /**
+ * The height of the window's visual viewport in pixels.
+ */
+ height: number
+ /**
+ * The distance from the top of the visual viewport to the top of the layout viewport.
+ * Particularly useful for handling mobile keyboard appearance.
+ */
+ offsetTop: number
+ /**
+ * The distance from the left of the visual viewport to the left of the layout viewport.
+ */
+ offsetLeft: number
+ /**
+ * The scale factor of the visual viewport.
+ * This is useful for scaling elements based on the current zoom level.
+ */
+ scale: number
+}
+
+/**
+ * Hook that tracks the window's visual viewport dimensions, position, and provides
+ * a CSS transform for positioning elements.
+ *
+ * Uses the Visual Viewport API to get accurate measurements, especially important
+ * for mobile devices where virtual keyboards can change the visible area.
+ * Only updates state when values actually change to optimize performance.
+ *
+ * @returns An object containing viewport properties and a CSS transform string
+ */
+export function useWindowSize(): WindowSizeState {
+ const [windowSize, setWindowSize] = useState({
+ width: 0,
+ height: 0,
+ offsetTop: 0,
+ offsetLeft: 0,
+ scale: 0,
+ })
+
+ const handleViewportChange = useThrottledCallback(() => {
+ if (typeof window === "undefined") return
+
+ const vp = window.visualViewport
+ if (!vp) return
+
+ const {
+ width = 0,
+ height = 0,
+ offsetTop = 0,
+ offsetLeft = 0,
+ scale = 0,
+ } = vp
+
+ setWindowSize((prevState) => {
+ if (
+ width === prevState.width &&
+ height === prevState.height &&
+ offsetTop === prevState.offsetTop &&
+ offsetLeft === prevState.offsetLeft &&
+ scale === prevState.scale
+ ) {
+ return prevState
+ }
+
+ return { width, height, offsetTop, offsetLeft, scale }
+ })
+ }, 200)
+
+ useEffect(() => {
+ const visualViewport = window.visualViewport
+ if (!visualViewport) return
+
+ visualViewport.addEventListener("resize", handleViewportChange)
+
+ handleViewportChange()
+
+ return () => {
+ visualViewport.removeEventListener("resize", handleViewportChange)
+ }
+ }, [handleViewportChange])
+
+ return windowSize
+}
diff --git a/frontends/ol-components/src/components/TiptapEditor/lib/tiptap-utils.ts b/frontends/ol-components/src/components/TiptapEditor/lib/tiptap-utils.ts
new file mode 100644
index 0000000000..2c0f1bb813
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/lib/tiptap-utils.ts
@@ -0,0 +1,555 @@
+import type { Node as TiptapNode } from "@tiptap/pm/model"
+import type { Transaction } from "@tiptap/pm/state"
+import {
+ AllSelection,
+ NodeSelection,
+ Selection,
+ TextSelection,
+} from "@tiptap/pm/state"
+import type { Editor, NodeWithPos } from "@tiptap/react"
+
+export const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
+
+export const MAC_SYMBOLS: Record = {
+ mod: "⌘",
+ command: "⌘",
+ meta: "⌘",
+ ctrl: "⌃",
+ control: "⌃",
+ alt: "⌥",
+ option: "⌥",
+ shift: "⇧",
+ backspace: "Del",
+ delete: "⌦",
+ enter: "⏎",
+ escape: "⎋",
+ capslock: "⇪",
+} as const
+
+export const SR_ONLY = {
+ position: "absolute",
+ width: "1px",
+ height: "1px",
+ padding: 0,
+ margin: "-1px",
+ overflow: "hidden",
+ clip: "rect(0, 0, 0, 0)",
+ whiteSpace: "nowrap",
+ borderWidth: 0,
+} as const
+
+export function cn(
+ ...classes: (string | boolean | undefined | null)[]
+): string {
+ return classes.filter(Boolean).join(" ")
+}
+
+/**
+ * Determines if the current platform is macOS
+ * @returns boolean indicating if the current platform is Mac
+ */
+export function isMac(): boolean {
+ return (
+ typeof navigator !== "undefined" &&
+ navigator.platform.toLowerCase().includes("mac")
+ )
+}
+
+/**
+ * Formats a shortcut key based on the platform (Mac or non-Mac)
+ * @param key - The key to format (e.g., "ctrl", "alt", "shift")
+ * @param isMac - Boolean indicating if the platform is Mac
+ * @param capitalize - Whether to capitalize the key (default: true)
+ * @returns Formatted shortcut key symbol
+ */
+export const formatShortcutKey = (
+ key: string,
+ isMac: boolean,
+ capitalize: boolean = true,
+) => {
+ if (isMac) {
+ const lowerKey = key.toLowerCase()
+ return MAC_SYMBOLS[lowerKey] || (capitalize ? key.toUpperCase() : key)
+ }
+
+ return capitalize ? key.charAt(0).toUpperCase() + key.slice(1) : key
+}
+
+/**
+ * Parses a shortcut key string into an array of formatted key symbols
+ * @param shortcutKeys - The string of shortcut keys (e.g., "ctrl-alt-shift")
+ * @param delimiter - The delimiter used to split the keys (default: "-")
+ * @param capitalize - Whether to capitalize the keys (default: true)
+ * @returns Array of formatted shortcut key symbols
+ */
+export const parseShortcutKeys = (props: {
+ shortcutKeys: string | undefined
+ delimiter?: string
+ capitalize?: boolean
+}) => {
+ const { shortcutKeys, delimiter = "+", capitalize = true } = props
+
+ if (!shortcutKeys) return []
+
+ return shortcutKeys
+ .split(delimiter)
+ .map((key) => key.trim())
+ .map((key) => formatShortcutKey(key, isMac(), capitalize))
+}
+
+/**
+ * Checks if a mark exists in the editor schema
+ * @param markName - The name of the mark to check
+ * @param editor - The editor instance
+ * @returns boolean indicating if the mark exists in the schema
+ */
+export const isMarkInSchema = (
+ markName: string,
+ editor: Editor | null,
+): boolean => {
+ if (!editor?.schema) return false
+ return editor.schema.spec.marks.get(markName) !== undefined
+}
+
+/**
+ * Checks if a node exists in the editor schema
+ * @param nodeName - The name of the node to check
+ * @param editor - The editor instance
+ * @returns boolean indicating if the node exists in the schema
+ */
+export const isNodeInSchema = (
+ nodeName: string,
+ editor: Editor | null,
+): boolean => {
+ if (!editor?.schema) return false
+ return editor.schema.spec.nodes.get(nodeName) !== undefined
+}
+
+/**
+ * Moves the focus to the next node in the editor
+ * @param editor - The editor instance
+ * @returns boolean indicating if the focus was moved
+ */
+export function focusNextNode(editor: Editor) {
+ const { state, view } = editor
+ const { doc, selection } = state
+
+ const nextSel = Selection.findFrom(selection.$to, 1, true)
+ if (nextSel) {
+ view.dispatch(state.tr.setSelection(nextSel).scrollIntoView())
+ return true
+ }
+
+ const paragraphType = state.schema.nodes.paragraph
+ if (!paragraphType) {
+ console.warn("No paragraph node type found in schema.")
+ return false
+ }
+
+ const end = doc.content.size
+ const para = paragraphType.create()
+ let tr = state.tr.insert(end, para)
+
+ // Place the selection inside the new paragraph
+ const $inside = tr.doc.resolve(end + 1)
+ tr = tr.setSelection(TextSelection.near($inside)).scrollIntoView()
+ view.dispatch(tr)
+ return true
+}
+
+/**
+ * Checks if a value is a valid number (not null, undefined, or NaN)
+ * @param value - The value to check
+ * @returns boolean indicating if the value is a valid number
+ */
+export function isValidPosition(pos: number | null | undefined): pos is number {
+ return typeof pos === "number" && pos >= 0
+}
+
+/**
+ * Checks if one or more extensions are registered in the Tiptap editor.
+ * @param editor - The Tiptap editor instance
+ * @param extensionNames - A single extension name or an array of names to check
+ * @returns True if at least one of the extensions is available, false otherwise
+ */
+export function isExtensionAvailable(
+ editor: Editor | null,
+ extensionNames: string | string[],
+): boolean {
+ if (!editor) return false
+
+ const names = Array.isArray(extensionNames)
+ ? extensionNames
+ : [extensionNames]
+
+ const found = names.some((name) =>
+ editor.extensionManager.extensions.some((ext) => ext.name === name),
+ )
+
+ if (!found) {
+ console.warn(
+ `None of the extensions [${names.join(", ")}] were found in the editor schema. Ensure they are included in the editor configuration.`,
+ )
+ }
+
+ return found
+}
+
+/**
+ * Finds a node at the specified position with error handling
+ * @param editor The Tiptap editor instance
+ * @param position The position in the document to find the node
+ * @returns The node at the specified position, or null if not found
+ */
+export function findNodeAtPosition(editor: Editor, position: number) {
+ try {
+ const node = editor.state.doc.nodeAt(position)
+ if (!node) {
+ console.warn(`No node found at position ${position}`)
+ return null
+ }
+ return node
+ } catch (error) {
+ console.error(`Error getting node at position ${position}:`, error)
+ return null
+ }
+}
+
+/**
+ * Finds the position and instance of a node in the document
+ * @param props Object containing editor, node (optional), and nodePos (optional)
+ * @param props.editor The Tiptap editor instance
+ * @param props.node The node to find (optional if nodePos is provided)
+ * @param props.nodePos The position of the node to find (optional if node is provided)
+ * @returns An object with the position and node, or null if not found
+ */
+export function findNodePosition(props: {
+ editor: Editor | null
+ node?: TiptapNode | null
+ nodePos?: number | null
+}): { pos: number; node: TiptapNode } | null {
+ const { editor, node, nodePos } = props
+
+ if (!editor || !editor.state?.doc) return null
+
+ // Zero is valid position
+ const hasValidNode = node !== undefined && node !== null
+ const hasValidPos = isValidPosition(nodePos)
+
+ if (!hasValidNode && !hasValidPos) {
+ return null
+ }
+
+ // First search for the node in the document if we have a node
+ if (hasValidNode) {
+ let foundPos = -1
+ let foundNode: TiptapNode | null = null
+
+ editor.state.doc.descendants((currentNode, pos) => {
+ // TODO: Needed?
+ // if (currentNode.type && currentNode.type.name === node!.type.name) {
+ if (currentNode === node) {
+ foundPos = pos
+ foundNode = currentNode
+ return false
+ }
+ return true
+ })
+
+ if (foundPos !== -1 && foundNode !== null) {
+ return { pos: foundPos, node: foundNode }
+ }
+ }
+
+ // If we have a valid position, use findNodeAtPosition
+ if (hasValidPos) {
+ const nodeAtPos = findNodeAtPosition(editor, nodePos!)
+ if (nodeAtPos) {
+ return { pos: nodePos!, node: nodeAtPos }
+ }
+ }
+
+ return null
+}
+
+/**
+ * Determines whether the current selection contains a node whose type matches
+ * any of the provided node type names.
+ * @param editor Tiptap editor instance
+ * @param nodeTypeNames List of node type names to match against
+ * @param checkAncestorNodes Whether to check ancestor node types up the depth chain
+ */
+export function isNodeTypeSelected(
+ editor: Editor | null,
+ nodeTypeNames: string[] = [],
+ checkAncestorNodes: boolean = false,
+): boolean {
+ if (!editor || !editor.state.selection) return false
+
+ const { selection } = editor.state
+ if (selection.empty) return false
+
+ // Direct node selection check
+ if (selection instanceof NodeSelection) {
+ const selectedNode = selection.node
+ return selectedNode ? nodeTypeNames.includes(selectedNode.type.name) : false
+ }
+
+ // Depth-based ancestor node check
+ if (checkAncestorNodes) {
+ const { $from } = selection
+ for (let depth = $from.depth; depth > 0; depth--) {
+ const ancestorNode = $from.node(depth)
+ if (nodeTypeNames.includes(ancestorNode.type.name)) {
+ return true
+ }
+ }
+ }
+
+ return false
+}
+
+/**
+ * Check whether the current selection is fully within nodes
+ * whose type names are in the provided `types` list.
+ *
+ * - NodeSelection → checks the selected node.
+ * - Text/AllSelection → ensures all textblocks within [from, to) are allowed.
+ */
+export function selectionWithinConvertibleTypes(
+ editor: Editor,
+ types: string[] = [],
+): boolean {
+ if (!editor || types.length === 0) return false
+
+ const { state } = editor
+ const { selection } = state
+ const allowed = new Set(types)
+
+ if (selection instanceof NodeSelection) {
+ const nodeType = selection.node?.type?.name
+ return !!nodeType && allowed.has(nodeType)
+ }
+
+ if (selection instanceof TextSelection || selection instanceof AllSelection) {
+ let valid = true
+ state.doc.nodesBetween(selection.from, selection.to, (node) => {
+ if (node.isTextblock && !allowed.has(node.type.name)) {
+ valid = false
+ return false // stop early
+ }
+ return valid
+ })
+ return valid
+ }
+
+ return false
+}
+
+/**
+ * Handles image upload with progress tracking and abort capability
+ * @param file The file to upload
+ * @param onProgress Optional callback for tracking upload progress
+ * @param abortSignal Optional AbortSignal for cancelling the upload
+ * @returns Promise resolving to the URL of the uploaded image
+ */
+export const handleImageUpload = async (
+ file: File,
+ onProgress?: (event: { progress: number }) => void,
+ abortSignal?: AbortSignal,
+): Promise => {
+ // Validate file
+ if (!file) {
+ throw new Error("No file provided")
+ }
+
+ if (file.size > MAX_FILE_SIZE) {
+ throw new Error(
+ `File size exceeds maximum allowed (${MAX_FILE_SIZE / (1024 * 1024)}MB)`,
+ )
+ }
+
+ // For demo/testing: Simulate upload progress. In production, replace the following code
+ // with your own upload implementation.
+ for (let progress = 0; progress <= 100; progress += 10) {
+ if (abortSignal?.aborted) {
+ throw new Error("Upload cancelled")
+ }
+ await new Promise((resolve) => setTimeout(resolve, 500))
+ onProgress?.({ progress })
+ }
+
+ return "/images/tiptap-ui-placeholder-image.jpg"
+}
+
+type ProtocolOptions = {
+ /**
+ * The protocol scheme to be registered.
+ * @default '''
+ * @example 'ftp'
+ * @example 'git'
+ */
+ scheme: string
+
+ /**
+ * If enabled, it allows optional slashes after the protocol.
+ * @default false
+ * @example true
+ */
+ optionalSlashes?: boolean
+}
+
+type ProtocolConfig = Array
+
+const ATTR_WHITESPACE =
+ // eslint-disable-next-line no-control-regex
+ /[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g
+
+export function isAllowedUri(
+ uri: string | undefined,
+ protocols?: ProtocolConfig,
+) {
+ const allowedProtocols: string[] = [
+ "http",
+ "https",
+ "ftp",
+ "ftps",
+ "mailto",
+ "tel",
+ "callto",
+ "sms",
+ "cid",
+ "xmpp",
+ ]
+
+ if (protocols) {
+ protocols.forEach((protocol) => {
+ const nextProtocol =
+ typeof protocol === "string" ? protocol : protocol.scheme
+
+ if (nextProtocol) {
+ allowedProtocols.push(nextProtocol)
+ }
+ })
+ }
+
+ return (
+ !uri ||
+ uri
+ .replace(ATTR_WHITESPACE, "")
+ .match(
+ new RegExp(
+ `^(?:(?:${allowedProtocols.join("|")}):|[^a-z]|[a-z0-9+.-]+(?:[^a-z+.:-]|$))`,
+ "i",
+ ),
+ )
+ )
+}
+
+export function sanitizeUrl(
+ inputUrl: string,
+ baseUrl: string,
+ protocols?: ProtocolConfig,
+): string {
+ try {
+ const url = new URL(inputUrl, baseUrl)
+
+ if (isAllowedUri(url.href, protocols)) {
+ return url.href
+ }
+ } catch {
+ // If URL creation fails, it's considered invalid
+ }
+ return "#"
+}
+
+/**
+ * Update a single attribute on multiple nodes.
+ *
+ * @param tr - The transaction to mutate
+ * @param targets - Array of { node, pos }
+ * @param attrName - Attribute key to update
+ * @param next - New value OR updater function receiving previous value
+ * Pass `undefined` to remove the attribute.
+ * @returns true if at least one node was updated, false otherwise
+ */
+export function updateNodesAttr(
+ tr: Transaction,
+ targets: readonly NodeWithPos[],
+ attrName: A,
+ next: V | ((prev: V | undefined) => V | undefined),
+): boolean {
+ if (!targets.length) return false
+
+ let changed = false
+
+ for (const { pos } of targets) {
+ // Always re-read from the transaction's current doc
+ const currentNode = tr.doc.nodeAt(pos)
+ if (!currentNode) continue
+
+ const prevValue = (currentNode.attrs as Record)[
+ attrName
+ ] as V | undefined
+ const resolvedNext =
+ typeof next === "function"
+ ? (next as (p: V | undefined) => V | undefined)(prevValue)
+ : next
+
+ if (prevValue === resolvedNext) continue
+
+ const nextAttrs: Record = { ...currentNode.attrs }
+ if (resolvedNext === undefined) {
+ // Remove the key entirely instead of setting null
+ delete nextAttrs[attrName]
+ } else {
+ nextAttrs[attrName] = resolvedNext
+ }
+
+ tr.setNodeMarkup(pos, undefined, nextAttrs)
+ changed = true
+ }
+
+ return changed
+}
+
+/**
+ * Selects the entire content of the current block node if the selection is empty.
+ * If the selection is not empty, it does nothing.
+ * @param editor The Tiptap editor instance
+ */
+export function selectCurrentBlockContent(editor: Editor) {
+ const { selection, doc } = editor.state
+
+ if (!selection.empty) return
+
+ const $pos = selection.$from
+ let blockNode = null
+ let blockPos = -1
+
+ for (let depth = $pos.depth; depth >= 0; depth--) {
+ const node = $pos.node(depth)
+ const pos = $pos.start(depth)
+
+ if (node.isBlock && node.textContent.trim()) {
+ blockNode = node
+ blockPos = pos
+ break
+ }
+ }
+
+ if (blockNode && blockPos >= 0) {
+ const from = blockPos
+ const to = blockPos + blockNode.nodeSize - 2 // -2 to exclude the closing tag
+
+ if (from < to) {
+ const $from = doc.resolve(from)
+ const $to = doc.resolve(to)
+ const newSelection = TextSelection.between($from, $to, 1)
+
+ if (newSelection && !selection.eq(newSelection)) {
+ editor.view.dispatch(editor.state.tr.setSelection(newSelection))
+ }
+ }
+ }
+}
diff --git a/frontends/ol-components/src/components/TiptapEditor/styles/_keyframe-animations.scss b/frontends/ol-components/src/components/TiptapEditor/styles/_keyframe-animations.scss
new file mode 100644
index 0000000000..dd98b7cbc6
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/styles/_keyframe-animations.scss
@@ -0,0 +1,91 @@
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes fadeOut {
+ from {
+ opacity: 1;
+ }
+ to {
+ opacity: 0;
+ }
+}
+
+@keyframes zoomIn {
+ from {
+ transform: scale(0.95);
+ }
+ to {
+ transform: scale(1);
+ }
+}
+
+@keyframes zoomOut {
+ from {
+ transform: scale(1);
+ }
+ to {
+ transform: scale(0.95);
+ }
+}
+
+@keyframes zoom {
+ 0% {
+ opacity: 0;
+ transform: scale(0.95);
+ }
+ 100% {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
+
+@keyframes slideFromTop {
+ from {
+ transform: translateY(-0.5rem);
+ }
+ to {
+ transform: translateY(0);
+ }
+}
+
+@keyframes slideFromRight {
+ from {
+ transform: translateX(0.5rem);
+ }
+ to {
+ transform: translateX(0);
+ }
+}
+
+@keyframes slideFromLeft {
+ from {
+ transform: translateX(-0.5rem);
+ }
+ to {
+ transform: translateX(0);
+ }
+}
+
+@keyframes slideFromBottom {
+ from {
+ transform: translateY(0.5rem);
+ }
+ to {
+ transform: translateY(0);
+ }
+}
+
+@keyframes spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
diff --git a/frontends/ol-components/src/components/TiptapEditor/styles/_variables.scss b/frontends/ol-components/src/components/TiptapEditor/styles/_variables.scss
new file mode 100644
index 0000000000..aaf40caa36
--- /dev/null
+++ b/frontends/ol-components/src/components/TiptapEditor/styles/_variables.scss
@@ -0,0 +1,295 @@
+:root {
+ /******************
+ Basics
+ ******************/
+
+ overflow-wrap: break-word;
+ text-size-adjust: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+
+ /******************
+ Colors variables
+ ******************/
+
+ /* Gray alpha (light mode) */
+ --tt-gray-light-a-50: rgba(56, 56, 56, 0.04);
+ --tt-gray-light-a-100: rgba(15, 22, 36, 0.05);
+ --tt-gray-light-a-200: rgba(37, 39, 45, 0.1);
+ --tt-gray-light-a-300: rgba(47, 50, 55, 0.2);
+ --tt-gray-light-a-400: rgba(40, 44, 51, 0.42);
+ --tt-gray-light-a-500: rgba(52, 55, 60, 0.64);
+ --tt-gray-light-a-600: rgba(36, 39, 46, 0.78);
+ --tt-gray-light-a-700: rgba(35, 37, 42, 0.87);
+ --tt-gray-light-a-800: rgba(30, 32, 36, 0.95);
+ --tt-gray-light-a-900: rgba(29, 30, 32, 0.98);
+
+ /* Gray (light mode) */
+ --tt-gray-light-50: rgba(250, 250, 250, 1);
+ --tt-gray-light-100: rgba(244, 244, 245, 1);
+ --tt-gray-light-200: rgba(234, 234, 235, 1);
+ --tt-gray-light-300: rgba(213, 214, 215, 1);
+ --tt-gray-light-400: rgba(166, 167, 171, 1);
+ --tt-gray-light-500: rgba(125, 127, 130, 1);
+ --tt-gray-light-600: rgba(83, 86, 90, 1);
+ --tt-gray-light-700: rgba(64, 65, 69, 1);
+ --tt-gray-light-800: rgba(44, 45, 48, 1);
+ --tt-gray-light-900: rgba(34, 35, 37, 1);
+
+ /* Gray alpha (dark mode) */
+ --tt-gray-dark-a-50: rgba(232, 232, 253, 0.05);
+ --tt-gray-dark-a-100: rgba(231, 231, 243, 0.07);
+ --tt-gray-dark-a-200: rgba(238, 238, 246, 0.11);
+ --tt-gray-dark-a-300: rgba(239, 239, 245, 0.22);
+ --tt-gray-dark-a-400: rgba(244, 244, 255, 0.37);
+ --tt-gray-dark-a-500: rgba(236, 238, 253, 0.5);
+ --tt-gray-dark-a-600: rgba(247, 247, 253, 0.64);
+ --tt-gray-dark-a-700: rgba(251, 251, 254, 0.75);
+ --tt-gray-dark-a-800: rgba(253, 253, 253, 0.88);
+ --tt-gray-dark-a-900: rgba(255, 255, 255, 0.96);
+
+ /* Gray (dark mode) */
+ --tt-gray-dark-50: rgba(25, 25, 26, 1);
+ --tt-gray-dark-100: rgba(32, 32, 34, 1);
+ --tt-gray-dark-200: rgba(45, 45, 47, 1);
+ --tt-gray-dark-300: rgba(70, 70, 73, 1);
+ --tt-gray-dark-400: rgba(99, 99, 105, 1);
+ --tt-gray-dark-500: rgba(124, 124, 131, 1);
+ --tt-gray-dark-600: rgba(163, 163, 168, 1);
+ --tt-gray-dark-700: rgba(192, 192, 195, 1);
+ --tt-gray-dark-800: rgba(224, 224, 225, 1);
+ --tt-gray-dark-900: rgba(245, 245, 245, 1);
+
+ /* Brand colors */
+ --tt-brand-color-50: rgba(239, 238, 255, 1);
+ --tt-brand-color-100: rgba(222, 219, 255, 1);
+ --tt-brand-color-200: rgba(195, 189, 255, 1);
+ --tt-brand-color-300: rgba(157, 138, 255, 1);
+ --tt-brand-color-400: rgba(122, 82, 255, 1);
+ --tt-brand-color-500: rgba(98, 41, 255, 1);
+ --tt-brand-color-600: rgba(84, 0, 229, 1);
+ --tt-brand-color-700: rgba(75, 0, 204, 1);
+ --tt-brand-color-800: rgba(56, 0, 153, 1);
+ --tt-brand-color-900: rgba(43, 25, 102, 1);
+ --tt-brand-color-950: hsla(257, 100%, 9%, 1);
+
+ /* Green */
+ --tt-color-green-inc-5: hsla(129, 100%, 97%, 1);
+ --tt-color-green-inc-4: hsla(129, 100%, 92%, 1);
+ --tt-color-green-inc-3: hsla(131, 100%, 86%, 1);
+ --tt-color-green-inc-2: hsla(133, 98%, 78%, 1);
+ --tt-color-green-inc-1: hsla(137, 99%, 70%, 1);
+ --tt-color-green-base: hsla(147, 99%, 50%, 1);
+ --tt-color-green-dec-1: hsla(147, 97%, 41%, 1);
+ --tt-color-green-dec-2: hsla(146, 98%, 32%, 1);
+ --tt-color-green-dec-3: hsla(146, 100%, 24%, 1);
+ --tt-color-green-dec-4: hsla(144, 100%, 16%, 1);
+ --tt-color-green-dec-5: hsla(140, 100%, 9%, 1);
+
+ /* Yellow */
+ --tt-color-yellow-inc-5: hsla(50, 100%, 97%, 1);
+ --tt-color-yellow-inc-4: hsla(50, 100%, 91%, 1);
+ --tt-color-yellow-inc-3: hsla(50, 100%, 84%, 1);
+ --tt-color-yellow-inc-2: hsla(50, 100%, 77%, 1);
+ --tt-color-yellow-inc-1: hsla(50, 100%, 68%, 1);
+ --tt-color-yellow-base: hsla(52, 100%, 50%, 1);
+ --tt-color-yellow-dec-1: hsla(52, 100%, 41%, 1);
+ --tt-color-yellow-dec-2: hsla(52, 100%, 32%, 1);
+ --tt-color-yellow-dec-3: hsla(52, 100%, 24%, 1);
+ --tt-color-yellow-dec-4: hsla(51, 100%, 16%, 1);
+ --tt-color-yellow-dec-5: hsla(50, 100%, 9%, 1);
+
+ /* Red */
+ --tt-color-red-inc-5: hsla(11, 100%, 96%, 1);
+ --tt-color-red-inc-4: hsla(11, 100%, 88%, 1);
+ --tt-color-red-inc-3: hsla(10, 100%, 80%, 1);
+ --tt-color-red-inc-2: hsla(9, 100%, 73%, 1);
+ --tt-color-red-inc-1: hsla(7, 100%, 64%, 1);
+ --tt-color-red-base: hsla(7, 100%, 54%, 1);
+ --tt-color-red-dec-1: hsla(7, 100%, 41%, 1);
+ --tt-color-red-dec-2: hsla(5, 100%, 32%, 1);
+ --tt-color-red-dec-3: hsla(4, 100%, 24%, 1);
+ --tt-color-red-dec-4: hsla(3, 100%, 16%, 1);
+ --tt-color-red-dec-5: hsla(1, 100%, 9%, 1);
+
+ /* Basic colors */
+ --white: rgba(255, 255, 255, 1);
+ --black: rgba(14, 14, 17, 1);
+ --transparent: rgba(255, 255, 255, 0);
+
+ /******************
+ Shadow variables
+ ******************/
+
+ /* Shadows Light */
+ --tt-shadow-elevated-md: 0px 16px 48px 0px rgba(17, 24, 39, 0.04),
+ 0px 12px 24px 0px rgba(17, 24, 39, 0.04),
+ 0px 6px 8px 0px rgba(17, 24, 39, 0.02),
+ 0px 2px 3px 0px rgba(17, 24, 39, 0.02);
+
+ /**************************************************
+ Radius variables
+ **************************************************/
+
+ --tt-radius-xxs: 0.125rem; /* 2px */
+ --tt-radius-xs: 0.25rem; /* 4px */
+ --tt-radius-sm: 0.375rem; /* 6px */
+ --tt-radius-md: 0.5rem; /* 8px */
+ --tt-radius-lg: 0.75rem; /* 12px */
+ --tt-radius-xl: 1rem; /* 16px */
+
+ /**************************************************
+ Transition variables
+ **************************************************/
+
+ --tt-transition-duration-short: 0.1s;
+ --tt-transition-duration-default: 0.2s;
+ --tt-transition-duration-long: 0.64s;
+ --tt-transition-easing-default: cubic-bezier(0.46, 0.03, 0.52, 0.96);
+ --tt-transition-easing-cubic: cubic-bezier(0.65, 0.05, 0.36, 1);
+ --tt-transition-easing-quart: cubic-bezier(0.77, 0, 0.18, 1);
+ --tt-transition-easing-circ: cubic-bezier(0.79, 0.14, 0.15, 0.86);
+ --tt-transition-easing-back: cubic-bezier(0.68, -0.55, 0.27, 1.55);
+
+ /******************
+ Contrast variables
+ ******************/
+
+ --tt-accent-contrast: 8%;
+ --tt-destructive-contrast: 8%;
+ --tt-foreground-contrast: 8%;
+
+ &,
+ *,
+ ::before,
+ ::after {
+ box-sizing: border-box;
+ transition: none var(--tt-transition-duration-default)
+ var(--tt-transition-easing-default);
+ }
+}
+
+:root {
+ /**************************************************
+ Global colors
+ **************************************************/
+
+ /* Global colors - Light mode */
+ --tt-bg-color: var(--white);
+ --tt-border-color: var(--tt-gray-light-a-200);
+ --tt-border-color-tint: var(--tt-gray-light-a-100);
+ --tt-sidebar-bg-color: var(--tt-gray-light-100);
+ --tt-scrollbar-color: var(--tt-gray-light-a-200);
+ --tt-cursor-color: var(--tt-brand-color-500);
+ --tt-selection-color: rgba(157, 138, 255, 0.2);
+ --tt-card-bg-color: var(--white);
+ --tt-card-border-color: var(--tt-gray-light-a-100);
+}
+
+/* Global colors - Dark mode */
+.dark {
+ --tt-bg-color: var(--black);
+ --tt-border-color: var(--tt-gray-dark-a-200);
+ --tt-border-color-tint: var(--tt-gray-dark-a-100);
+ --tt-sidebar-bg-color: var(--tt-gray-dark-100);
+ --tt-scrollbar-color: var(--tt-gray-dark-a-200);
+ --tt-cursor-color: var(--tt-brand-color-400);
+ --tt-selection-color: rgba(122, 82, 255, 0.2);
+ --tt-card-bg-color: var(--tt-gray-dark-50);
+ --tt-card-border-color: var(--tt-gray-dark-a-50);
+
+ --tt-shadow-elevated-md: 0px 16px 48px 0px rgba(0, 0, 0, 0.5),
+ 0px 12px 24px 0px rgba(0, 0, 0, 0.24), 0px 6px 8px 0px rgba(0, 0, 0, 0.22),
+ 0px 2px 3px 0px rgba(0, 0, 0, 0.12);
+}
+
+/* Text colors */
+:root {
+ --tt-color-text-gray: hsl(45, 2%, 46%);
+ --tt-color-text-brown: hsl(19, 31%, 47%);
+ --tt-color-text-orange: hsl(30, 89%, 45%);
+ --tt-color-text-yellow: hsl(38, 62%, 49%);
+ --tt-color-text-green: hsl(148, 32%, 39%);
+ --tt-color-text-blue: hsl(202, 54%, 43%);
+ --tt-color-text-purple: hsl(274, 32%, 54%);
+ --tt-color-text-pink: hsl(328, 49%, 53%);
+ --tt-color-text-red: hsl(2, 62%, 55%);
+
+ --tt-color-text-gray-contrast: hsla(39, 26%, 26%, 0.15);
+ --tt-color-text-brown-contrast: hsla(18, 43%, 69%, 0.35);
+ --tt-color-text-orange-contrast: hsla(24, 73%, 55%, 0.27);
+ --tt-color-text-yellow-contrast: hsla(44, 82%, 59%, 0.39);
+ --tt-color-text-green-contrast: hsla(126, 29%, 60%, 0.27);
+ --tt-color-text-blue-contrast: hsla(202, 54%, 59%, 0.27);
+ --tt-color-text-purple-contrast: hsla(274, 37%, 64%, 0.27);
+ --tt-color-text-pink-contrast: hsla(331, 60%, 71%, 0.27);
+ --tt-color-text-red-contrast: hsla(8, 79%, 79%, 0.4);
+}
+
+.dark {
+ --tt-color-text-gray: hsl(0, 0%, 61%);
+ --tt-color-text-brown: hsl(18, 35%, 58%);
+ --tt-color-text-orange: hsl(25, 53%, 53%);
+ --tt-color-text-yellow: hsl(36, 54%, 55%);
+ --tt-color-text-green: hsl(145, 32%, 47%);
+ --tt-color-text-blue: hsl(202, 64%, 52%);
+ --tt-color-text-purple: hsl(270, 55%, 62%);
+ --tt-color-text-pink: hsl(329, 57%, 58%);
+ --tt-color-text-red: hsl(1, 69%, 60%);
+
+ --tt-color-text-gray-contrast: hsla(0, 0%, 100%, 0.09);
+ --tt-color-text-brown-contrast: hsla(17, 45%, 50%, 0.25);
+ --tt-color-text-orange-contrast: hsla(27, 82%, 53%, 0.2);
+ --tt-color-text-yellow-contrast: hsla(35, 49%, 47%, 0.2);
+ --tt-color-text-green-contrast: hsla(151, 55%, 39%, 0.2);
+ --tt-color-text-blue-contrast: hsla(202, 54%, 43%, 0.2);
+ --tt-color-text-purple-contrast: hsla(271, 56%, 60%, 0.18);
+ --tt-color-text-pink-contrast: hsla(331, 67%, 58%, 0.22);
+ --tt-color-text-red-contrast: hsla(0, 67%, 60%, 0.25);
+}
+
+/* Highlight colors */
+:root {
+ --tt-color-highlight-yellow: #fef9c3;
+ --tt-color-highlight-green: #dcfce7;
+ --tt-color-highlight-blue: #e0f2fe;
+ --tt-color-highlight-purple: #f3e8ff;
+ --tt-color-highlight-red: #ffe4e6;
+ --tt-color-highlight-gray: rgb(248, 248, 247);
+ --tt-color-highlight-brown: rgb(244, 238, 238);
+ --tt-color-highlight-orange: rgb(251, 236, 221);
+ --tt-color-highlight-pink: rgb(252, 241, 246);
+
+ --tt-color-highlight-yellow-contrast: #fbe604;
+ --tt-color-highlight-green-contrast: #c7fad8;
+ --tt-color-highlight-blue-contrast: #ceeafd;
+ --tt-color-highlight-purple-contrast: #e4ccff;
+ --tt-color-highlight-red-contrast: #ffccd0;
+ --tt-color-highlight-gray-contrast: rgba(84, 72, 49, 0.15);
+ --tt-color-highlight-brown-contrast: rgba(210, 162, 141, 0.35);
+ --tt-color-highlight-orange-contrast: rgba(224, 124, 57, 0.27);
+ --tt-color-highlight-pink-contrast: rgba(225, 136, 179, 0.27);
+}
+
+.dark {
+ --tt-color-highlight-yellow: #6b6524;
+ --tt-color-highlight-green: #509568;
+ --tt-color-highlight-blue: #6e92aa;
+ --tt-color-highlight-purple: #583e74;
+ --tt-color-highlight-red: #743e42;
+ --tt-color-highlight-gray: rgb(47, 47, 47);
+ --tt-color-highlight-brown: rgb(74, 50, 40);
+ --tt-color-highlight-orange: rgb(92, 59, 35);
+ --tt-color-highlight-pink: rgb(78, 44, 60);
+
+ --tt-color-highlight-yellow-contrast: #58531e;
+ --tt-color-highlight-green-contrast: #47855d;
+ --tt-color-highlight-blue-contrast: #5e86a1;
+ --tt-color-highlight-purple-contrast: #4c3564;
+ --tt-color-highlight-red-contrast: #643539;
+ --tt-color-highlight-gray-contrast: rgba(255, 255, 255, 0.094);
+ --tt-color-highlight-brown-contrast: rgba(184, 101, 69, 0.25);
+ --tt-color-highlight-orange-contrast: rgba(233, 126, 37, 0.2);
+ --tt-color-highlight-pink-contrast: rgba(220, 76, 145, 0.22);
+}
diff --git a/frontends/ol-components/src/index.ts b/frontends/ol-components/src/index.ts
index fddd94de71..a04bded224 100644
--- a/frontends/ol-components/src/index.ts
+++ b/frontends/ol-components/src/index.ts
@@ -171,6 +171,8 @@ export * from "./components/ThemeProvider/MITLearnGlobalStyles"
export { AppRouterCacheProvider as NextJsAppRouterCacheProvider } from "@mui/material-nextjs/v15-appRouter"
+export { default as TiptapEditor } from "./components/TiptapEditor/TiptapEditor"
+
// /**
// * @deprecated Please use component from @mitodl/smoot-design instead
// */
diff --git a/frontends/ol-components/tsconfig.json b/frontends/ol-components/tsconfig.json
index 746eeeb926..d319d7d722 100644
--- a/frontends/ol-components/tsconfig.json
+++ b/frontends/ol-components/tsconfig.json
@@ -4,5 +4,6 @@
"outDir": "./build",
"rootDir": "./src"
},
- "include": ["./src/**/*.ts", "./src/**/*.tsx"]
+ "include": ["./src/**/*.ts", "./src/**/*.tsx"],
+ "aliases": {}
}
diff --git a/learning_resources_search/api.py b/learning_resources_search/api.py
index b6108f04fa..77394639f9 100644
--- a/learning_resources_search/api.py
+++ b/learning_resources_search/api.py
@@ -592,38 +592,37 @@ def add_text_query_to_search(search, text, search_params, query_type_query):
if yearly_decay_percent or max_incompleteness_penalty:
script_query = {
- "script_score": {
+ "function_score": {
"query": {"bool": {"must": [text_query], "filter": query_type_query}}
}
}
- completeness_term = (
- "(doc['completeness'].value * params.max_incompleteness_penalty + "
- "(1-params.max_incompleteness_penalty))"
- )
-
- staleness_term = (
- "(doc['resource_age_date'].size() == 0 ? 1 : "
- "decayDateLinear(params.origin, params.scale, params.offset, params.decay, "
- "doc['resource_age_date'].value))"
- )
-
- source = "_score"
+ source = []
params = {}
if max_incompleteness_penalty:
- source = f"{source} * {completeness_term}"
+ completeness_term = (
+ "(doc['completeness'].value * params.max_incompleteness_penalty + "
+ "(1-params.max_incompleteness_penalty))"
+ )
+ source.append(completeness_term)
params["max_incompleteness_penalty"] = max_incompleteness_penalty
if yearly_decay_percent:
- source = f"{source} * {staleness_term}"
+ staleness_term = (
+ "(doc['resource_age_date'].size() == 0 ? 1 : "
+ "decayDateLinear(params.origin, params.scale, params.offset, "
+ "params.decay, doc['resource_age_date'].value))"
+ )
+ source.append(staleness_term)
params["decay"] = 1 - (yearly_decay_percent / 100)
params["offset"] = "0"
params["scale"] = "365d"
params["origin"] = datetime.now(tz=UTC).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
- script_query["script_score"]["script"] = {
- "source": source,
+ script_query["function_score"]["script_score"] = {}
+ script_query["function_score"]["script_score"]["script"] = {
+ "source": "*".join(source),
"params": params,
}
@@ -739,7 +738,13 @@ def execute_learn_search(search_params):
settings.DEFAULT_SEARCH_MAX_INCOMPLETENESS_PENALTY
)
search = construct_search(search_params)
- return search.execute().to_dict()
+ results = search.execute().to_dict()
+ if results.get("_shards", {}).get("failures"):
+ log.error(
+ "Search encountered shard failures: %s",
+ results.get("_shards").get("failures"),
+ )
+ return results
def subscribe_user_to_search_query(
diff --git a/learning_resources_search/api_test.py b/learning_resources_search/api_test.py
index 25f5117ef2..1ca59b7721 100644
--- a/learning_resources_search/api_test.py
+++ b/learning_resources_search/api_test.py
@@ -2000,8 +2000,8 @@ def test_execute_learn_search_with_script_score(
if yearly_decay_percent > 0 and max_incompleteness_penalty > 0:
source = (
- "_score * (doc['completeness'].value * params.max_incompleteness_penalty + "
- "(1-params.max_incompleteness_penalty)) * (doc['resource_age_date'].size() == 0 ? "
+ "(doc['completeness'].value * params.max_incompleteness_penalty + "
+ "(1-params.max_incompleteness_penalty))*(doc['resource_age_date'].size() == 0 ? "
"1 : decayDateLinear(params.origin, params.scale, params.offset, params.decay, "
"doc['resource_age_date'].value))"
)
@@ -2014,7 +2014,7 @@ def test_execute_learn_search_with_script_score(
}
elif yearly_decay_percent > 0:
source = (
- "_score * (doc['resource_age_date'].size() == 0 ? "
+ "(doc['resource_age_date'].size() == 0 ? "
"1 : decayDateLinear(params.origin, params.scale, params.offset, params.decay, "
"doc['resource_age_date'].value))"
)
@@ -2027,7 +2027,7 @@ def test_execute_learn_search_with_script_score(
}
else:
source = (
- "_score * (doc['completeness'].value * params.max_incompleteness_penalty +"
+ "(doc['completeness'].value * params.max_incompleteness_penalty +"
" (1-params.max_incompleteness_penalty))"
)
params = {"max_incompleteness_penalty": 0.25}
@@ -2047,7 +2047,17 @@ def test_execute_learn_search_with_script_score(
query = {
"query": {
- "script_score": {
+ "function_score": {
+ "functions": [
+ {
+ "script_score": {
+ "script": {
+ "params": params,
+ "source": source,
+ },
+ },
+ },
+ ],
"query": {
"bool": {
"must": [
@@ -2301,10 +2311,6 @@ def test_execute_learn_search_with_script_score(
"filter": [{"exists": {"field": "resource_type"}}],
}
},
- "script": {
- "source": source,
- "params": params,
- },
}
},
"post_filter": {
diff --git a/main/settings.py b/main/settings.py
index e5f08bcce4..6f6e2e664d 100644
--- a/main/settings.py
+++ b/main/settings.py
@@ -34,7 +34,7 @@
from main.settings_pluggy import * # noqa: F403
from openapi.settings_spectacular import open_spectacular_settings
-VERSION = "0.47.11"
+VERSION = "0.47.12"
log = logging.getLogger()
diff --git a/main/settings_celery.py b/main/settings_celery.py
index cd909c801f..adbe644cb0 100644
--- a/main/settings_celery.py
+++ b/main/settings_celery.py
@@ -184,6 +184,12 @@
"task": "vector_search.tasks.sync_topics",
"schedule": crontab(minute=0, hour="6,18,23"), # 2am 2pm and 7pm EST
},
+ "weekly_check_missing_embeddings": {
+ "task": "vector_search.tasks.embeddings_healthcheck",
+ "schedule": crontab(
+ minute=0, hour=6, day_of_week=6
+ ), # 2:00am EST on Friday
+ },
}
)
diff --git a/openapi/specs/v1.yaml b/openapi/specs/v1.yaml
index a48bc94032..5d11aa2aab 100644
--- a/openapi/specs/v1.yaml
+++ b/openapi/specs/v1.yaml
@@ -13010,8 +13010,8 @@ components:
type: object
description: Serializer for LearningResourceInstructor model
properties:
- html:
- type: string
+ content:
+ default: {}
title:
type: string
minLength: 1
@@ -14876,8 +14876,8 @@ components:
type: object
description: Serializer for LearningResourceInstructor model
properties:
- html:
- type: string
+ content:
+ default: {}
id:
type: integer
readOnly: true
@@ -14885,21 +14885,19 @@ components:
type: string
maxLength: 255
required:
- - html
- id
- title
RichTextArticleRequest:
type: object
description: Serializer for LearningResourceInstructor model
properties:
- html:
- type: string
+ content:
+ default: {}
title:
type: string
minLength: 1
maxLength: 255
required:
- - html
- title
SearchModeEnum:
enum:
diff --git a/poetry.lock b/poetry.lock
index ebca7b93cd..7490e12498 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -452,14 +452,14 @@ crt = ["awscrt (==0.23.8)"]
[[package]]
name = "bpython"
-version = "0.25"
-description = ""
+version = "0.26"
+description = "A fancy curses interface to the Python interactive interpreter"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
- {file = "bpython-0.25-py3-none-any.whl", hash = "sha256:28fd86008ca5ef6100ead407c9743aa60c51293a18ba5b18fcacea7f5b7f2257"},
- {file = "bpython-0.25.tar.gz", hash = "sha256:c246fc909ef6dcc26e9d8cb4615b0e6b1613f3543d12269b19ffd0782166c65b"},
+ {file = "bpython-0.26-py3-none-any.whl", hash = "sha256:91bdbbe667078677dc6b236493fc03e47a04cd099630a32ca3f72d6d49b71e20"},
+ {file = "bpython-0.26.tar.gz", hash = "sha256:f79083e1e3723be9b49c9994ad1dd3a19ccb4d0d4f9a6f5b3a73bef8bc327433"},
]
[package.dependencies]
@@ -473,7 +473,7 @@ requests = "*"
[package.extras]
clipboard = ["pyperclip"]
jedi = ["jedi (>=0.16)"]
-urwid = ["urwid"]
+urwid = ["urwid (<3.0)"]
watch = ["watchdog"]
[[package]]
@@ -9255,4 +9255,4 @@ cffi = ["cffi (>=1.11)"]
[metadata]
lock-version = "2.1"
python-versions = "~3.12"
-content-hash = "ceab94db56105439c94cf4b8895b8c50d21deb17b0d59445e7cc813782f6a129"
+content-hash = "2aa8bbab597a9404503908bf4680ef08bbe1254c277ba98e37ed13ba9c1526fb"
diff --git a/pyproject.toml b/pyproject.toml
index 80cb94e57c..953a353ef1 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -119,7 +119,7 @@ django-zeal = "^2.0.4"
[tool.poetry.group.dev.dependencies]
-bpython = "^0.25"
+bpython = "^0.26"
ddt = "^1.6.0"
factory_boy = "^3.3.0"
faker = "^37.0.0"
diff --git a/vector_search/tasks.py b/vector_search/tasks.py
index 3e497a3d9e..b9e724e695 100644
--- a/vector_search/tasks.py
+++ b/vector_search/tasks.py
@@ -2,10 +2,12 @@
import logging
import celery
+import sentry_sdk
from celery.exceptions import Ignore
from django.conf import settings
from django.db.models import Q
+from learning_resources.content_summarizer import ContentSummarizer
from learning_resources.models import (
ContentFile,
Course,
@@ -32,10 +34,16 @@
chunks,
now_in_utc,
)
+from vector_search.constants import (
+ CONTENT_FILES_COLLECTION_NAME,
+ RESOURCES_COLLECTION_NAME,
+)
from vector_search.utils import (
embed_learning_resources,
embed_topics,
+ filter_existing_qdrant_points_by_ids,
remove_qdrant_records,
+ vector_point_id,
)
log = logging.getLogger(__name__)
@@ -369,6 +377,139 @@ def remove_run_content_files(run_id):
@app.task
+def embeddings_healthcheck():
+ """
+ Check for missing embeddings and summaries in Qdrant and log warnings to Sentry
+ """
+ remaining_content_files = []
+ remaining_resources = []
+ resource_point_ids = {}
+ all_resources = LearningResource.objects.filter(
+ Q(published=True) | Q(test_mode=True)
+ )
+
+ for lr in all_resources:
+ run = (
+ lr.best_run
+ if lr.best_run
+ else lr.runs.filter(published=True).order_by("-start_date").first()
+ )
+ point_id = vector_point_id(lr.readable_id)
+ resource_point_ids[point_id] = {"resource_id": lr.readable_id, "id": lr.id}
+ content_file_point_ids = {}
+ if run:
+ for cf in run.content_files.filter(published=True):
+ if cf and cf.content:
+ point_id = vector_point_id(
+ f"{lr.readable_id}.{run.run_id}.{cf.key}.0"
+ )
+ content_file_point_ids[point_id] = {"key": cf.key, "id": cf.id}
+ for batch in chunks(content_file_point_ids.keys(), chunk_size=200):
+ remaining_content_files.extend(
+ filter_existing_qdrant_points_by_ids(
+ batch, collection_name=CONTENT_FILES_COLLECTION_NAME
+ )
+ )
+
+ for batch in chunks(
+ all_resources.values_list("readable_id", flat=True),
+ chunk_size=200,
+ ):
+ remaining_resources.extend(
+ filter_existing_qdrant_points_by_ids(
+ [vector_point_id(pid) for pid in batch],
+ collection_name=RESOURCES_COLLECTION_NAME,
+ )
+ )
+
+ remaining_content_file_ids = [
+ content_file_point_ids.get(p, {}).get("id") for p in remaining_content_files
+ ]
+ remaining_resource_ids = [
+ resource_point_ids.get(p, {}).get("id") for p in remaining_resources
+ ]
+ missing_summaries = _missing_summaries()
+ log.info(
+ "Embeddings healthcheck found %d missing content file embeddings",
+ len(remaining_content_files),
+ )
+ log.info(
+ "Embeddings healthcheck found %d missing resource embeddings",
+ len(remaining_resources),
+ )
+ log.info(
+ "Embeddings healthcheck found %d missing summaries and flashcards",
+ len(missing_summaries),
+ )
+
+ if len(remaining_content_files) > 0:
+ _sentry_healthcheck_log(
+ "embeddings",
+ "missing_content_file_embeddings",
+ {
+ "count": len(remaining_content_files),
+ "ids": remaining_content_file_ids,
+ "run_ids": set(
+ ContentFile.objects.filter(
+ id__in=remaining_content_file_ids
+ ).values_list("run__run_id", flat=True)[:100]
+ ),
+ },
+ f"Warning: {len(remaining_content_files)} missing content file "
+ "embeddings detected",
+ )
+
+ if len(remaining_resources) > 0:
+ _sentry_healthcheck_log(
+ "embeddings",
+ "missing_learning_resource_embeddings",
+ {
+ "count": len(remaining_resource_ids),
+ "ids": remaining_resource_ids,
+ "titles": list(
+ LearningResource.objects.filter(
+ id__in=remaining_resource_ids
+ ).values_list("title", flat=True)
+ ),
+ },
+ f"Warning: {len(remaining_resource_ids)} missing learning resource "
+ "embeddings detected",
+ )
+ if len(missing_summaries) > 0:
+ _sentry_healthcheck_log(
+ "embeddings",
+ "missing_content_file_summaries",
+ {
+ "count": len(missing_summaries),
+ "ids": missing_summaries,
+ "run_ids": set(
+ ContentFile.objects.filter(id__in=missing_summaries).values_list(
+ "run__run_id", flat=True
+ )[:100]
+ ),
+ },
+ f"Warning: {len(missing_summaries)} missing content file summaries "
+ "detected",
+ )
+
+
+def _missing_summaries():
+ summarizer = ContentSummarizer()
+ return summarizer.get_unprocessed_content_file_ids(
+ LearningResource.objects.filter(require_summaries=True)
+ .filter(Q(published=True) | Q(test_mode=True))
+ .values_list("id", flat=True)
+ )
+
+
+def _sentry_healthcheck_log(healthcheck, alert_type, context, message):
+ with sentry_sdk.new_scope() as scope:
+ scope.set_tag("healthcheck", healthcheck)
+ scope.set_tag("alert_type", alert_type)
+ scope.set_context("missing_content_file_embeddings", context)
+ sentry_sdk.capture_message(message)
+
+
def sync_topics():
"""
Sync topics to the Qdrant collection
diff --git a/vector_search/tasks_test.py b/vector_search/tasks_test.py
index 86135bc07e..1da0c61f65 100644
--- a/vector_search/tasks_test.py
+++ b/vector_search/tasks_test.py
@@ -10,8 +10,10 @@
)
from learning_resources.factories import (
ContentFileFactory,
+ ContentSummarizerConfigurationFactory,
CourseFactory,
LearningResourceFactory,
+ LearningResourcePlatformFactory,
LearningResourceRunFactory,
ProgramFactory,
)
@@ -24,8 +26,10 @@
embed_learning_resources_by_id,
embed_new_content_files,
embed_new_learning_resources,
+ embeddings_healthcheck,
start_embed_resources,
)
+from vector_search.utils import vector_point_id
pytestmark = pytest.mark.django_db
@@ -388,3 +392,90 @@ def test_embed_new_content_files_without_runs(mocker, mocked_celery):
embedded_ids = generate_embeddings_mock.si.mock_calls[0].args[0]
for contentfile_id in content_files_without_run:
assert contentfile_id in embedded_ids
+
+
+def test_embeddings_healthcheck_no_missing_embeddings(mocker):
+ """
+ Test embeddings_healthcheck when there are no missing embeddings
+ """
+ lr = LearningResourceFactory.create(published=True)
+ LearningResourceRunFactory.create(published=True, learning_resource=lr)
+ ContentFileFactory.create(run=lr.runs.first(), content="test", published=True)
+ mock_sentry = mocker.patch("vector_search.tasks.sentry_sdk", autospec=True)
+ mocker.patch(
+ "vector_search.tasks.filter_existing_qdrant_points_by_ids", return_value=[]
+ )
+
+ embeddings_healthcheck()
+ assert mock_sentry.capture_message.call_count == 0
+
+
+def test_embeddings_healthcheck_missing_both(mocker):
+ """
+ Test embeddings_healthcheck when there are missing content files and learning resources
+ """
+ lr = LearningResourceFactory.create(published=True)
+ LearningResourceRunFactory.create(published=True, learning_resource=lr)
+ cf = ContentFileFactory.create(run=lr.runs.first(), content="test", published=True)
+ mocker.patch(
+ "vector_search.tasks.filter_existing_qdrant_points_by_ids",
+ side_effect=[
+ [vector_point_id(lr.readable_id)],
+ [
+ vector_point_id(
+ f"{cf.run.learning_resource.id}.{cf.run.run_id}.{cf.key}.0"
+ )
+ ],
+ ],
+ )
+ mock_sentry = mocker.patch("vector_search.tasks.sentry_sdk.capture_message")
+
+ embeddings_healthcheck()
+
+ assert mock_sentry.call_count == 2
+
+
+def test_embeddings_healthcheck_missing_summaries(mocker):
+ """
+ Test embeddings_healthcheck for missing contentfile summaries/flashcards
+ """
+ content_extension = [".srt"]
+ content_type = ["file"]
+ platform = LearningResourcePlatformFactory.create()
+ ContentSummarizerConfigurationFactory.create(
+ allowed_extensions=content_extension,
+ allowed_content_types=content_type,
+ is_active=True,
+ llm_model="test",
+ platform__code=platform.code,
+ )
+ resource = LearningResourceFactory.create(
+ published=True, require_summaries=True, platform=platform
+ )
+ resource.runs.all().delete()
+ learning_resource_run = LearningResourceRunFactory.create(
+ published=True,
+ learning_resource=resource,
+ )
+ learning_resource_run.learning_resource = resource
+ learning_resource_run.save()
+
+ ContentFileFactory.create(
+ published=True,
+ content="test",
+ file_extension=content_extension[0],
+ summary="",
+ content_type=content_type[0],
+ run=learning_resource_run,
+ )
+ mocker.patch(
+ "vector_search.tasks.filter_existing_qdrant_points_by_ids",
+ )
+ mock_sentry = mocker.patch("vector_search.tasks.sentry_sdk.capture_message")
+
+ embeddings_healthcheck()
+ assert mock_sentry.call_count == 1
+ assert (
+ mock_sentry.mock_calls[0].args[0]
+ == "Warning: 1 missing content file summaries detected"
+ )
diff --git a/yarn.lock b/yarn.lock
index 889357270f..56d8d1401f 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1526,6 +1526,13 @@ __metadata:
languageName: node
linkType: hard
+"@bufbuild/protobuf@npm:^2.5.0":
+ version: 2.10.0
+ resolution: "@bufbuild/protobuf@npm:2.10.0"
+ checksum: 10/58a213899a34d6a742da6ce6f7405583b390a23243287799d869afb21a7a7778475529b50f5bdbf2acbf50cce740dba1292aa7b548c576514f8b55e139ca90c4
+ languageName: node
+ linkType: hard
+
"@chromatic-com/storybook@npm:^3.2.7":
version: 3.2.7
resolution: "@chromatic-com/storybook@npm:3.2.7"
@@ -2117,6 +2124,15 @@ __metadata:
languageName: node
linkType: hard
+"@floating-ui/core@npm:^1.7.3":
+ version: 1.7.3
+ resolution: "@floating-ui/core@npm:1.7.3"
+ dependencies:
+ "@floating-ui/utils": "npm:^0.2.10"
+ checksum: 10/a8952ff2673ddf28f12feeb86d90c54949e45bcb1af5758b7672850ac0dadb36d4bd61aa45dad1b6a35ba40d4756d3573afac6610b90502639d7266b91e0864e
+ languageName: node
+ linkType: hard
+
"@floating-ui/dom@npm:^1.0.0, @floating-ui/dom@npm:^1.0.1":
version: 1.6.11
resolution: "@floating-ui/dom@npm:1.6.11"
@@ -2127,6 +2143,28 @@ __metadata:
languageName: node
linkType: hard
+"@floating-ui/dom@npm:^1.7.4":
+ version: 1.7.4
+ resolution: "@floating-ui/dom@npm:1.7.4"
+ dependencies:
+ "@floating-ui/core": "npm:^1.7.3"
+ "@floating-ui/utils": "npm:^0.2.10"
+ checksum: 10/d3d6a23e7b9804ba56338c7c666590258683af14b6026270d32afc1202f72b5b82cca359004bdc7830bf2463a045da6c7bd4e7d5351218cf270ff94206197971
+ languageName: node
+ linkType: hard
+
+"@floating-ui/react-dom@npm:^2.0.0, @floating-ui/react-dom@npm:^2.1.6":
+ version: 2.1.6
+ resolution: "@floating-ui/react-dom@npm:2.1.6"
+ dependencies:
+ "@floating-ui/dom": "npm:^1.7.4"
+ peerDependencies:
+ react: ">=16.8.0"
+ react-dom: ">=16.8.0"
+ checksum: 10/fbfd3319b42edb9c156e4e872f500d2edb112bc9cfd1b45892bff16ccf21c2484ddc9c416f7631c2aaaadec1b2f98b205db8a3f89eb78ca870905fcfe3917c35
+ languageName: node
+ linkType: hard
+
"@floating-ui/react-dom@npm:^2.0.8, @floating-ui/react-dom@npm:^2.1.1":
version: 2.1.2
resolution: "@floating-ui/react-dom@npm:2.1.2"
@@ -2139,6 +2177,27 @@ __metadata:
languageName: node
linkType: hard
+"@floating-ui/react@npm:^0.27.16":
+ version: 0.27.16
+ resolution: "@floating-ui/react@npm:0.27.16"
+ dependencies:
+ "@floating-ui/react-dom": "npm:^2.1.6"
+ "@floating-ui/utils": "npm:^0.2.10"
+ tabbable: "npm:^6.0.0"
+ peerDependencies:
+ react: ">=17.0.0"
+ react-dom: ">=17.0.0"
+ checksum: 10/b9baedee124035323a8f74794ec782678faf52af1c88731ce7d2641b7e7c97748fda1e711a3c4db007a0153d93158d867f4726ee632d713d3de76ec4bdfd84e1
+ languageName: node
+ linkType: hard
+
+"@floating-ui/utils@npm:^0.2.10":
+ version: 0.2.10
+ resolution: "@floating-ui/utils@npm:0.2.10"
+ checksum: 10/b635ea865a8be2484b608b7157f5abf9ed439f351011a74b7e988439e2898199a9a8b790f52291e05bdcf119088160dc782d98cff45cc98c5a271bc6f51327ae
+ languageName: node
+ linkType: hard
+
"@floating-ui/utils@npm:^0.2.8":
version: 0.2.8
resolution: "@floating-ui/utils@npm:0.2.8"
@@ -4134,6 +4193,150 @@ __metadata:
languageName: node
linkType: hard
+"@parcel/watcher-android-arm64@npm:2.5.1":
+ version: 2.5.1
+ resolution: "@parcel/watcher-android-arm64@npm:2.5.1"
+ conditions: os=android & cpu=arm64
+ languageName: node
+ linkType: hard
+
+"@parcel/watcher-darwin-arm64@npm:2.5.1":
+ version: 2.5.1
+ resolution: "@parcel/watcher-darwin-arm64@npm:2.5.1"
+ conditions: os=darwin & cpu=arm64
+ languageName: node
+ linkType: hard
+
+"@parcel/watcher-darwin-x64@npm:2.5.1":
+ version: 2.5.1
+ resolution: "@parcel/watcher-darwin-x64@npm:2.5.1"
+ conditions: os=darwin & cpu=x64
+ languageName: node
+ linkType: hard
+
+"@parcel/watcher-freebsd-x64@npm:2.5.1":
+ version: 2.5.1
+ resolution: "@parcel/watcher-freebsd-x64@npm:2.5.1"
+ conditions: os=freebsd & cpu=x64
+ languageName: node
+ linkType: hard
+
+"@parcel/watcher-linux-arm-glibc@npm:2.5.1":
+ version: 2.5.1
+ resolution: "@parcel/watcher-linux-arm-glibc@npm:2.5.1"
+ conditions: os=linux & cpu=arm & libc=glibc
+ languageName: node
+ linkType: hard
+
+"@parcel/watcher-linux-arm-musl@npm:2.5.1":
+ version: 2.5.1
+ resolution: "@parcel/watcher-linux-arm-musl@npm:2.5.1"
+ conditions: os=linux & cpu=arm & libc=musl
+ languageName: node
+ linkType: hard
+
+"@parcel/watcher-linux-arm64-glibc@npm:2.5.1":
+ version: 2.5.1
+ resolution: "@parcel/watcher-linux-arm64-glibc@npm:2.5.1"
+ conditions: os=linux & cpu=arm64 & libc=glibc
+ languageName: node
+ linkType: hard
+
+"@parcel/watcher-linux-arm64-musl@npm:2.5.1":
+ version: 2.5.1
+ resolution: "@parcel/watcher-linux-arm64-musl@npm:2.5.1"
+ conditions: os=linux & cpu=arm64 & libc=musl
+ languageName: node
+ linkType: hard
+
+"@parcel/watcher-linux-x64-glibc@npm:2.5.1":
+ version: 2.5.1
+ resolution: "@parcel/watcher-linux-x64-glibc@npm:2.5.1"
+ conditions: os=linux & cpu=x64 & libc=glibc
+ languageName: node
+ linkType: hard
+
+"@parcel/watcher-linux-x64-musl@npm:2.5.1":
+ version: 2.5.1
+ resolution: "@parcel/watcher-linux-x64-musl@npm:2.5.1"
+ conditions: os=linux & cpu=x64 & libc=musl
+ languageName: node
+ linkType: hard
+
+"@parcel/watcher-win32-arm64@npm:2.5.1":
+ version: 2.5.1
+ resolution: "@parcel/watcher-win32-arm64@npm:2.5.1"
+ conditions: os=win32 & cpu=arm64
+ languageName: node
+ linkType: hard
+
+"@parcel/watcher-win32-ia32@npm:2.5.1":
+ version: 2.5.1
+ resolution: "@parcel/watcher-win32-ia32@npm:2.5.1"
+ conditions: os=win32 & cpu=ia32
+ languageName: node
+ linkType: hard
+
+"@parcel/watcher-win32-x64@npm:2.5.1":
+ version: 2.5.1
+ resolution: "@parcel/watcher-win32-x64@npm:2.5.1"
+ conditions: os=win32 & cpu=x64
+ languageName: node
+ linkType: hard
+
+"@parcel/watcher@npm:^2.4.1":
+ version: 2.5.1
+ resolution: "@parcel/watcher@npm:2.5.1"
+ dependencies:
+ "@parcel/watcher-android-arm64": "npm:2.5.1"
+ "@parcel/watcher-darwin-arm64": "npm:2.5.1"
+ "@parcel/watcher-darwin-x64": "npm:2.5.1"
+ "@parcel/watcher-freebsd-x64": "npm:2.5.1"
+ "@parcel/watcher-linux-arm-glibc": "npm:2.5.1"
+ "@parcel/watcher-linux-arm-musl": "npm:2.5.1"
+ "@parcel/watcher-linux-arm64-glibc": "npm:2.5.1"
+ "@parcel/watcher-linux-arm64-musl": "npm:2.5.1"
+ "@parcel/watcher-linux-x64-glibc": "npm:2.5.1"
+ "@parcel/watcher-linux-x64-musl": "npm:2.5.1"
+ "@parcel/watcher-win32-arm64": "npm:2.5.1"
+ "@parcel/watcher-win32-ia32": "npm:2.5.1"
+ "@parcel/watcher-win32-x64": "npm:2.5.1"
+ detect-libc: "npm:^1.0.3"
+ is-glob: "npm:^4.0.3"
+ micromatch: "npm:^4.0.5"
+ node-addon-api: "npm:^7.0.0"
+ node-gyp: "npm:latest"
+ dependenciesMeta:
+ "@parcel/watcher-android-arm64":
+ optional: true
+ "@parcel/watcher-darwin-arm64":
+ optional: true
+ "@parcel/watcher-darwin-x64":
+ optional: true
+ "@parcel/watcher-freebsd-x64":
+ optional: true
+ "@parcel/watcher-linux-arm-glibc":
+ optional: true
+ "@parcel/watcher-linux-arm-musl":
+ optional: true
+ "@parcel/watcher-linux-arm64-glibc":
+ optional: true
+ "@parcel/watcher-linux-arm64-musl":
+ optional: true
+ "@parcel/watcher-linux-x64-glibc":
+ optional: true
+ "@parcel/watcher-linux-x64-musl":
+ optional: true
+ "@parcel/watcher-win32-arm64":
+ optional: true
+ "@parcel/watcher-win32-ia32":
+ optional: true
+ "@parcel/watcher-win32-x64":
+ optional: true
+ checksum: 10/2cc1405166fb3016b34508661902ab08b6dec59513708165c633c84a4696fff64f9b99ea116e747c121215e09619f1decab6f0350d1cb26c9210b98eb28a6a56
+ languageName: node
+ linkType: hard
+
"@pkgjs/parseargs@npm:^0.11.0":
version: 0.11.0
resolution: "@pkgjs/parseargs@npm:0.11.0"
@@ -4210,124 +4413,615 @@ __metadata:
languageName: node
linkType: hard
-"@react-pdf/fns@npm:3.1.2":
- version: 3.1.2
- resolution: "@react-pdf/fns@npm:3.1.2"
- checksum: 10/b4b48167ae454587e2513b07166f44a21d908817b5e59dd72693f762c647038ecf6b4f18eecb468566613338ad1d5b4e16ade68d39ae852c9ef91ab54820224b
+"@radix-ui/primitive@npm:1.1.3":
+ version: 1.1.3
+ resolution: "@radix-ui/primitive@npm:1.1.3"
+ checksum: 10/ee27abbff0d6d305816e9314655eb35e72478ba47416bc9d5cb0581728be35e3408cfc0748313837561d635f0cb7dfaae26e61831f0e16c0fd7d669a612f2cb0
languageName: node
linkType: hard
-"@react-pdf/font@npm:^4.0.2":
- version: 4.0.2
- resolution: "@react-pdf/font@npm:4.0.2"
+"@radix-ui/react-arrow@npm:1.1.7":
+ version: 1.1.7
+ resolution: "@radix-ui/react-arrow@npm:1.1.7"
dependencies:
- "@react-pdf/pdfkit": "npm:^4.0.3"
- "@react-pdf/types": "npm:^2.9.0"
- fontkit: "npm:^2.0.2"
- is-url: "npm:^1.2.4"
- checksum: 10/7b0504a11b96a08aec60558b0cf14be2374c2e12fbe164be63fb21ebb4356a52ff9cdcbf94c3143643d37f1f0794acb6315c1cf681f65f569b0c05ccf1a6477d
+ "@radix-ui/react-primitive": "npm:2.1.3"
+ peerDependencies:
+ "@types/react": "*"
+ "@types/react-dom": "*"
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ "@types/react-dom":
+ optional: true
+ checksum: 10/6cdf74f06090f8994cdf6d3935a44ea3ac309163a4f59c476482c4907e8e0775f224045030abf10fa4f9e1cb7743db034429249b9e59354988e247eeb0f4fdcf
languageName: node
linkType: hard
-"@react-pdf/image@npm:^3.0.3":
- version: 3.0.3
- resolution: "@react-pdf/image@npm:3.0.3"
+"@radix-ui/react-collection@npm:1.1.7":
+ version: 1.1.7
+ resolution: "@radix-ui/react-collection@npm:1.1.7"
dependencies:
- "@react-pdf/png-js": "npm:^3.0.0"
- jay-peg: "npm:^1.1.1"
- checksum: 10/1d72f36dcfe1b5da5a67c9a905e4796ad2f0a9d96df1dea4998363759814712ca46f9877519dfb26653d6ba92705d2821984e09611b43582790fb534de3e7eb3
+ "@radix-ui/react-compose-refs": "npm:1.1.2"
+ "@radix-ui/react-context": "npm:1.1.2"
+ "@radix-ui/react-primitive": "npm:2.1.3"
+ "@radix-ui/react-slot": "npm:1.2.3"
+ peerDependencies:
+ "@types/react": "*"
+ "@types/react-dom": "*"
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ "@types/react-dom":
+ optional: true
+ checksum: 10/cd53e2a2be82be7bc4014164cac0b42948401a203e5d0294d3947a5193f1d56bd23eb60e878a98dba50d08283254e79c3b873de5f935276b849686a868d51dd5
languageName: node
linkType: hard
-"@react-pdf/layout@npm:^4.4.0":
- version: 4.4.0
- resolution: "@react-pdf/layout@npm:4.4.0"
- dependencies:
- "@react-pdf/fns": "npm:3.1.2"
- "@react-pdf/image": "npm:^3.0.3"
- "@react-pdf/primitives": "npm:^4.1.1"
- "@react-pdf/stylesheet": "npm:^6.1.0"
- "@react-pdf/textkit": "npm:^6.0.0"
- "@react-pdf/types": "npm:^2.9.0"
- emoji-regex: "npm:^10.3.0"
- queue: "npm:^6.0.1"
- yoga-layout: "npm:^3.2.1"
- checksum: 10/f40c4e9028ed169ee77b49b197acf4bb0b75dacbbdaf55b36f7a9953d7152e377e7bf62756c0efed40fed403b84ee1752120826851fbb670aa8fafb267d0d4f7
+"@radix-ui/react-compose-refs@npm:1.1.2":
+ version: 1.1.2
+ resolution: "@radix-ui/react-compose-refs@npm:1.1.2"
+ peerDependencies:
+ "@types/react": "*"
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ checksum: 10/9a91f0213014ffa40c5b8aae4debb993be5654217e504e35aa7422887eb2d114486d37e53c482d0fffb00cd44f51b5269fcdf397b280c71666fa11b7f32f165d
languageName: node
linkType: hard
-"@react-pdf/pdfkit@npm:^4.0.3":
- version: 4.0.3
- resolution: "@react-pdf/pdfkit@npm:4.0.3"
- dependencies:
- "@babel/runtime": "npm:^7.20.13"
- "@react-pdf/png-js": "npm:^3.0.0"
- browserify-zlib: "npm:^0.2.0"
- crypto-js: "npm:^4.2.0"
- fontkit: "npm:^2.0.2"
- jay-peg: "npm:^1.1.1"
- linebreak: "npm:^1.1.0"
- vite-compatible-readable-stream: "npm:^3.6.1"
- checksum: 10/7571931928b1514917e210a0a6e167e7b0ef8a107a5accedf13d461fa4829ebb04844a5d5626f7c78c4410c53b69e30095008a8b3b588666581970b4ea7a5abe
+"@radix-ui/react-context@npm:1.1.2":
+ version: 1.1.2
+ resolution: "@radix-ui/react-context@npm:1.1.2"
+ peerDependencies:
+ "@types/react": "*"
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ checksum: 10/156088367de42afa3c7e3acf5f0ba7cad6b359f3d17485585e80c2418434a6ed7cac2602eb73bca265d0091a1ad380f9405c069f103983e53497097ff35ba8f2
languageName: node
linkType: hard
-"@react-pdf/png-js@npm:^3.0.0":
- version: 3.0.0
- resolution: "@react-pdf/png-js@npm:3.0.0"
- dependencies:
- browserify-zlib: "npm:^0.2.0"
- checksum: 10/5e972302a5d93f67d3dc1438bb4cb8ee708802fb6d513b651aa0857909a185f06e5288b59e82cffa9fe5587289c97de7f5c5401101f50faebc32bef50f398ee5
+"@radix-ui/react-direction@npm:1.1.1":
+ version: 1.1.1
+ resolution: "@radix-ui/react-direction@npm:1.1.1"
+ peerDependencies:
+ "@types/react": "*"
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ checksum: 10/8cc330285f1d06829568042ca9aabd3295be4690ae93683033fc8632b5c4dfc60f5c1312f6e2cae27c196189c719de3cfbcf792ff74800f9ccae0ab4abc1bc92
languageName: node
linkType: hard
-"@react-pdf/primitives@npm:^4.1.1":
- version: 4.1.1
- resolution: "@react-pdf/primitives@npm:4.1.1"
- checksum: 10/adadff1996daeca693aa59844ab613e597fdb674fce9f2c03f52573b593982ef49ff47d861290235861d02462ffbc87b7ed3da0d71af0d61c9226ce61b94ada8
+"@radix-ui/react-dismissable-layer@npm:1.1.11":
+ version: 1.1.11
+ resolution: "@radix-ui/react-dismissable-layer@npm:1.1.11"
+ dependencies:
+ "@radix-ui/primitive": "npm:1.1.3"
+ "@radix-ui/react-compose-refs": "npm:1.1.2"
+ "@radix-ui/react-primitive": "npm:2.1.3"
+ "@radix-ui/react-use-callback-ref": "npm:1.1.1"
+ "@radix-ui/react-use-escape-keydown": "npm:1.1.1"
+ peerDependencies:
+ "@types/react": "*"
+ "@types/react-dom": "*"
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ "@types/react-dom":
+ optional: true
+ checksum: 10/c20772588423379dee47fbe1d45c238c45a3bbe612eaf64a86576bf81821975e256d92ac71f9151e91b94a73068656143a11da9a3e77de7564d2a9926468e37a
languageName: node
linkType: hard
-"@react-pdf/reconciler@npm:^1.1.4":
- version: 1.1.4
- resolution: "@react-pdf/reconciler@npm:1.1.4"
+"@radix-ui/react-dropdown-menu@npm:^2.1.16":
+ version: 2.1.16
+ resolution: "@radix-ui/react-dropdown-menu@npm:2.1.16"
dependencies:
- object-assign: "npm:^4.1.1"
- scheduler: "npm:0.25.0-rc-603e6108-20241029"
+ "@radix-ui/primitive": "npm:1.1.3"
+ "@radix-ui/react-compose-refs": "npm:1.1.2"
+ "@radix-ui/react-context": "npm:1.1.2"
+ "@radix-ui/react-id": "npm:1.1.1"
+ "@radix-ui/react-menu": "npm:2.1.16"
+ "@radix-ui/react-primitive": "npm:2.1.3"
+ "@radix-ui/react-use-controllable-state": "npm:1.2.2"
peerDependencies:
- react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
- checksum: 10/6562961c6ead2b4392a32506d761c84d8c74f43378616cdab84dbdcbfd64e83b18e6e2eeeb962de7e3f3ab672ae335c3560b7f9e0cd275515de2ee55489e5425
+ "@types/react": "*"
+ "@types/react-dom": "*"
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ "@types/react-dom":
+ optional: true
+ checksum: 10/da215196b5dde5619cdb424b1b5236159e4bb949974b7f4ffbf047d467c55116229a8f9cf07eae6457afefb4a2b07888bb30542f303045e05d90a4b072941ae2
languageName: node
linkType: hard
-"@react-pdf/render@npm:^4.3.0":
- version: 4.3.0
- resolution: "@react-pdf/render@npm:4.3.0"
- dependencies:
- "@babel/runtime": "npm:^7.20.13"
- "@react-pdf/fns": "npm:3.1.2"
- "@react-pdf/primitives": "npm:^4.1.1"
- "@react-pdf/textkit": "npm:^6.0.0"
- "@react-pdf/types": "npm:^2.9.0"
- abs-svg-path: "npm:^0.1.1"
- color-string: "npm:^1.9.1"
- normalize-svg-path: "npm:^1.1.0"
- parse-svg-path: "npm:^0.1.2"
- svg-arc-to-cubic-bezier: "npm:^3.2.0"
- checksum: 10/7d1465e9411c1c4f170ef741226b5008b1236ec7eee2ed3b87248aeec7a42ecd5826fe7a351a0069826abbf4b4051f98e76fc1708d4cc5d5542f8be1058da978
+"@radix-ui/react-focus-guards@npm:1.1.3":
+ version: 1.1.3
+ resolution: "@radix-ui/react-focus-guards@npm:1.1.3"
+ peerDependencies:
+ "@types/react": "*"
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ checksum: 10/b57878f6cf0ebc3e8d7c5c6bbaad44598daac19c921551ca541c104201048a9a902f3d69196e7a09995fd46e998c309aab64dc30fa184b3609d67d187a6a9c24
languageName: node
linkType: hard
-"@react-pdf/renderer@npm:^4.3.0":
- version: 4.3.0
- resolution: "@react-pdf/renderer@npm:4.3.0"
+"@radix-ui/react-focus-scope@npm:1.1.7":
+ version: 1.1.7
+ resolution: "@radix-ui/react-focus-scope@npm:1.1.7"
dependencies:
- "@babel/runtime": "npm:^7.20.13"
- "@react-pdf/fns": "npm:3.1.2"
- "@react-pdf/font": "npm:^4.0.2"
- "@react-pdf/layout": "npm:^4.4.0"
- "@react-pdf/pdfkit": "npm:^4.0.3"
- "@react-pdf/primitives": "npm:^4.1.1"
+ "@radix-ui/react-compose-refs": "npm:1.1.2"
+ "@radix-ui/react-primitive": "npm:2.1.3"
+ "@radix-ui/react-use-callback-ref": "npm:1.1.1"
+ peerDependencies:
+ "@types/react": "*"
+ "@types/react-dom": "*"
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ "@types/react-dom":
+ optional: true
+ checksum: 10/2a7cd00e39e01756999ebf0bdb3401d6a8efa489a7b19e6b629b40bad3022b7b1f616555ccb4b0505bc0ba53e13a1fb51be905db138b16ec39c4fe319fe701d3
+ languageName: node
+ linkType: hard
+
+"@radix-ui/react-id@npm:1.1.1":
+ version: 1.1.1
+ resolution: "@radix-ui/react-id@npm:1.1.1"
+ dependencies:
+ "@radix-ui/react-use-layout-effect": "npm:1.1.1"
+ peerDependencies:
+ "@types/react": "*"
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ checksum: 10/8d68e200778eb3038906870fc869b3d881f4a46715fb20cddd9c76cba42fdaaa4810a3365b6ec2daf0f185b9201fc99d009167f59c7921bc3a139722c2e976db
+ languageName: node
+ linkType: hard
+
+"@radix-ui/react-menu@npm:2.1.16":
+ version: 2.1.16
+ resolution: "@radix-ui/react-menu@npm:2.1.16"
+ dependencies:
+ "@radix-ui/primitive": "npm:1.1.3"
+ "@radix-ui/react-collection": "npm:1.1.7"
+ "@radix-ui/react-compose-refs": "npm:1.1.2"
+ "@radix-ui/react-context": "npm:1.1.2"
+ "@radix-ui/react-direction": "npm:1.1.1"
+ "@radix-ui/react-dismissable-layer": "npm:1.1.11"
+ "@radix-ui/react-focus-guards": "npm:1.1.3"
+ "@radix-ui/react-focus-scope": "npm:1.1.7"
+ "@radix-ui/react-id": "npm:1.1.1"
+ "@radix-ui/react-popper": "npm:1.2.8"
+ "@radix-ui/react-portal": "npm:1.1.9"
+ "@radix-ui/react-presence": "npm:1.1.5"
+ "@radix-ui/react-primitive": "npm:2.1.3"
+ "@radix-ui/react-roving-focus": "npm:1.1.11"
+ "@radix-ui/react-slot": "npm:1.2.3"
+ "@radix-ui/react-use-callback-ref": "npm:1.1.1"
+ aria-hidden: "npm:^1.2.4"
+ react-remove-scroll: "npm:^2.6.3"
+ peerDependencies:
+ "@types/react": "*"
+ "@types/react-dom": "*"
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ "@types/react-dom":
+ optional: true
+ checksum: 10/2ffdfa08822c8c4ffc265d02d16c83d725114f9c0e9b510e73e431306dedddd507ef2861ccd67ec8c0d21cb24cd6401e42f16f3e65b30be627c7e22159151e40
+ languageName: node
+ linkType: hard
+
+"@radix-ui/react-popover@npm:^1.1.15":
+ version: 1.1.15
+ resolution: "@radix-ui/react-popover@npm:1.1.15"
+ dependencies:
+ "@radix-ui/primitive": "npm:1.1.3"
+ "@radix-ui/react-compose-refs": "npm:1.1.2"
+ "@radix-ui/react-context": "npm:1.1.2"
+ "@radix-ui/react-dismissable-layer": "npm:1.1.11"
+ "@radix-ui/react-focus-guards": "npm:1.1.3"
+ "@radix-ui/react-focus-scope": "npm:1.1.7"
+ "@radix-ui/react-id": "npm:1.1.1"
+ "@radix-ui/react-popper": "npm:1.2.8"
+ "@radix-ui/react-portal": "npm:1.1.9"
+ "@radix-ui/react-presence": "npm:1.1.5"
+ "@radix-ui/react-primitive": "npm:2.1.3"
+ "@radix-ui/react-slot": "npm:1.2.3"
+ "@radix-ui/react-use-controllable-state": "npm:1.2.2"
+ aria-hidden: "npm:^1.2.4"
+ react-remove-scroll: "npm:^2.6.3"
+ peerDependencies:
+ "@types/react": "*"
+ "@types/react-dom": "*"
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ "@types/react-dom":
+ optional: true
+ checksum: 10/0ea7c8bb827e44d5c02b3f7193d9ac8085c71a01bf601b1afeb2bb0ec0124756e03db3471606e89e4d014e4de7c7066c8e2e9b81bb4b31ea321890ec33421f31
+ languageName: node
+ linkType: hard
+
+"@radix-ui/react-popper@npm:1.2.8":
+ version: 1.2.8
+ resolution: "@radix-ui/react-popper@npm:1.2.8"
+ dependencies:
+ "@floating-ui/react-dom": "npm:^2.0.0"
+ "@radix-ui/react-arrow": "npm:1.1.7"
+ "@radix-ui/react-compose-refs": "npm:1.1.2"
+ "@radix-ui/react-context": "npm:1.1.2"
+ "@radix-ui/react-primitive": "npm:2.1.3"
+ "@radix-ui/react-use-callback-ref": "npm:1.1.1"
+ "@radix-ui/react-use-layout-effect": "npm:1.1.1"
+ "@radix-ui/react-use-rect": "npm:1.1.1"
+ "@radix-ui/react-use-size": "npm:1.1.1"
+ "@radix-ui/rect": "npm:1.1.1"
+ peerDependencies:
+ "@types/react": "*"
+ "@types/react-dom": "*"
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ "@types/react-dom":
+ optional: true
+ checksum: 10/01366054e1e63dd9394f77afb9da3367709478a5adf4436c080fc5bbe9456170192ff9d1425d9fae5b246e1ba95173848f84b6f2a06b21b47d966367ec7cb997
+ languageName: node
+ linkType: hard
+
+"@radix-ui/react-portal@npm:1.1.9":
+ version: 1.1.9
+ resolution: "@radix-ui/react-portal@npm:1.1.9"
+ dependencies:
+ "@radix-ui/react-primitive": "npm:2.1.3"
+ "@radix-ui/react-use-layout-effect": "npm:1.1.1"
+ peerDependencies:
+ "@types/react": "*"
+ "@types/react-dom": "*"
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ "@types/react-dom":
+ optional: true
+ checksum: 10/bd6be39bf021d5c917e2474ecba411e2625171f7ef96862b9af04bbd68833bb3662a7f1fbdeb5a7a237111b10e811e76d2cd03e957dadd6e668ef16541bfbd68
+ languageName: node
+ linkType: hard
+
+"@radix-ui/react-presence@npm:1.1.5":
+ version: 1.1.5
+ resolution: "@radix-ui/react-presence@npm:1.1.5"
+ dependencies:
+ "@radix-ui/react-compose-refs": "npm:1.1.2"
+ "@radix-ui/react-use-layout-effect": "npm:1.1.1"
+ peerDependencies:
+ "@types/react": "*"
+ "@types/react-dom": "*"
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ "@types/react-dom":
+ optional: true
+ checksum: 10/4cdb05844c18877efb4b9739b46b7e5850b81d7ede994e75b5d62e8153a43c6e16b3ff9e55ff716e20b74b99b9415a94e97fd636bcb8698d5bbf7ab7b8663f9b
+ languageName: node
+ linkType: hard
+
+"@radix-ui/react-primitive@npm:2.1.3":
+ version: 2.1.3
+ resolution: "@radix-ui/react-primitive@npm:2.1.3"
+ dependencies:
+ "@radix-ui/react-slot": "npm:1.2.3"
+ peerDependencies:
+ "@types/react": "*"
+ "@types/react-dom": "*"
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ "@types/react-dom":
+ optional: true
+ checksum: 10/1dbbf932a3527f4e62f210bb72944eff605c3e38c8d3275ed5a5c570c02820ab156169756a65ad9a638d2089a828a04a7903795377384e98c87d0ca456303253
+ languageName: node
+ linkType: hard
+
+"@radix-ui/react-roving-focus@npm:1.1.11":
+ version: 1.1.11
+ resolution: "@radix-ui/react-roving-focus@npm:1.1.11"
+ dependencies:
+ "@radix-ui/primitive": "npm:1.1.3"
+ "@radix-ui/react-collection": "npm:1.1.7"
+ "@radix-ui/react-compose-refs": "npm:1.1.2"
+ "@radix-ui/react-context": "npm:1.1.2"
+ "@radix-ui/react-direction": "npm:1.1.1"
+ "@radix-ui/react-id": "npm:1.1.1"
+ "@radix-ui/react-primitive": "npm:2.1.3"
+ "@radix-ui/react-use-callback-ref": "npm:1.1.1"
+ "@radix-ui/react-use-controllable-state": "npm:1.2.2"
+ peerDependencies:
+ "@types/react": "*"
+ "@types/react-dom": "*"
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ "@types/react-dom":
+ optional: true
+ checksum: 10/0eddafa942332c95622ab8b53cce2fa25fd0dcaf4797218e9e6725da0734a81a438852cdcb3f588521018f68d38c6c5e50c64fda78c655f4e69dd45681ecc5e7
+ languageName: node
+ linkType: hard
+
+"@radix-ui/react-slot@npm:1.2.3":
+ version: 1.2.3
+ resolution: "@radix-ui/react-slot@npm:1.2.3"
+ dependencies:
+ "@radix-ui/react-compose-refs": "npm:1.1.2"
+ peerDependencies:
+ "@types/react": "*"
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ checksum: 10/fe484c2741e31d9c20a8fb53c5790a73c0664e2bea35e27f4d484a90c42135fcfffe11a08abfcacb7a8ee2faf013471f0e856818f3ddac8ac51ceb8869e0fd08
+ languageName: node
+ linkType: hard
+
+"@radix-ui/react-use-callback-ref@npm:1.1.1":
+ version: 1.1.1
+ resolution: "@radix-ui/react-use-callback-ref@npm:1.1.1"
+ peerDependencies:
+ "@types/react": "*"
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ checksum: 10/cde8c40f1d4e79e6e71470218163a746858304bad03758ac84dc1f94247a046478e8e397518350c8d6609c84b7e78565441d7505bb3ed573afce82cfdcd19faf
+ languageName: node
+ linkType: hard
+
+"@radix-ui/react-use-controllable-state@npm:1.2.2":
+ version: 1.2.2
+ resolution: "@radix-ui/react-use-controllable-state@npm:1.2.2"
+ dependencies:
+ "@radix-ui/react-use-effect-event": "npm:0.0.2"
+ "@radix-ui/react-use-layout-effect": "npm:1.1.1"
+ peerDependencies:
+ "@types/react": "*"
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ checksum: 10/a100bff3ddecb753dab17444147273c9f70046c5949712c52174b259622eaef12acbf7ebcf289bae4e714eb84d0a7317c1aa44064cd997f327d77b62bc732a7c
+ languageName: node
+ linkType: hard
+
+"@radix-ui/react-use-effect-event@npm:0.0.2":
+ version: 0.0.2
+ resolution: "@radix-ui/react-use-effect-event@npm:0.0.2"
+ dependencies:
+ "@radix-ui/react-use-layout-effect": "npm:1.1.1"
+ peerDependencies:
+ "@types/react": "*"
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ checksum: 10/5a1950a30a399ea7e4b98154da9f536737a610de80189b7aacd4f064a89a3cd0d2a48571d527435227252e72e872bdb544ff6ffcfbdd02de2efd011be4aaa902
+ languageName: node
+ linkType: hard
+
+"@radix-ui/react-use-escape-keydown@npm:1.1.1":
+ version: 1.1.1
+ resolution: "@radix-ui/react-use-escape-keydown@npm:1.1.1"
+ dependencies:
+ "@radix-ui/react-use-callback-ref": "npm:1.1.1"
+ peerDependencies:
+ "@types/react": "*"
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ checksum: 10/0eb0756c2c55ddcde9ff01446ab01c085ab2bf799173e97db7ef5f85126f9e8600225570801a1f64740e6d14c39ffe8eed7c14d29737345a5797f4622ac96f6f
+ languageName: node
+ linkType: hard
+
+"@radix-ui/react-use-layout-effect@npm:1.1.1":
+ version: 1.1.1
+ resolution: "@radix-ui/react-use-layout-effect@npm:1.1.1"
+ peerDependencies:
+ "@types/react": "*"
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ checksum: 10/bad2ba4f206e6255263582bedfb7868773c400836f9a1b423c0b464ffe4a17e13d3f306d1ce19cf7a19a492e9d0e49747464f2656451bb7c6a99f5a57bd34de2
+ languageName: node
+ linkType: hard
+
+"@radix-ui/react-use-rect@npm:1.1.1":
+ version: 1.1.1
+ resolution: "@radix-ui/react-use-rect@npm:1.1.1"
+ dependencies:
+ "@radix-ui/rect": "npm:1.1.1"
+ peerDependencies:
+ "@types/react": "*"
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ checksum: 10/116461bebc49472f7497e66a9bd413541181b3d00c5e0aaeef45d790dc1fbd7c8dcea80b169ea273306228b9a3c2b70067e902d1fd5004b3057e3bbe35b9d55d
+ languageName: node
+ linkType: hard
+
+"@radix-ui/react-use-size@npm:1.1.1":
+ version: 1.1.1
+ resolution: "@radix-ui/react-use-size@npm:1.1.1"
+ dependencies:
+ "@radix-ui/react-use-layout-effect": "npm:1.1.1"
+ peerDependencies:
+ "@types/react": "*"
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ checksum: 10/64e61f65feb67ffc80e1fc4a8d5e32480fb6d68475e2640377e021178dead101568cba5f936c9c33e6c142c7cf2fb5d76ad7b23ef80e556ba142d56cf306147b
+ languageName: node
+ linkType: hard
+
+"@radix-ui/rect@npm:1.1.1":
+ version: 1.1.1
+ resolution: "@radix-ui/rect@npm:1.1.1"
+ checksum: 10/b6c5eb787640775b53dd52fa47218a089f0a0d8220d3ebff079c0b754e1fb82d89b6bdf08a82fd0d59549bdeb52678c0cca091c302da49dcf74c3c989cb55678
+ languageName: node
+ linkType: hard
+
+"@react-pdf/fns@npm:3.1.2":
+ version: 3.1.2
+ resolution: "@react-pdf/fns@npm:3.1.2"
+ checksum: 10/b4b48167ae454587e2513b07166f44a21d908817b5e59dd72693f762c647038ecf6b4f18eecb468566613338ad1d5b4e16ade68d39ae852c9ef91ab54820224b
+ languageName: node
+ linkType: hard
+
+"@react-pdf/font@npm:^4.0.2":
+ version: 4.0.2
+ resolution: "@react-pdf/font@npm:4.0.2"
+ dependencies:
+ "@react-pdf/pdfkit": "npm:^4.0.3"
+ "@react-pdf/types": "npm:^2.9.0"
+ fontkit: "npm:^2.0.2"
+ is-url: "npm:^1.2.4"
+ checksum: 10/7b0504a11b96a08aec60558b0cf14be2374c2e12fbe164be63fb21ebb4356a52ff9cdcbf94c3143643d37f1f0794acb6315c1cf681f65f569b0c05ccf1a6477d
+ languageName: node
+ linkType: hard
+
+"@react-pdf/image@npm:^3.0.3":
+ version: 3.0.3
+ resolution: "@react-pdf/image@npm:3.0.3"
+ dependencies:
+ "@react-pdf/png-js": "npm:^3.0.0"
+ jay-peg: "npm:^1.1.1"
+ checksum: 10/1d72f36dcfe1b5da5a67c9a905e4796ad2f0a9d96df1dea4998363759814712ca46f9877519dfb26653d6ba92705d2821984e09611b43582790fb534de3e7eb3
+ languageName: node
+ linkType: hard
+
+"@react-pdf/layout@npm:^4.4.0":
+ version: 4.4.0
+ resolution: "@react-pdf/layout@npm:4.4.0"
+ dependencies:
+ "@react-pdf/fns": "npm:3.1.2"
+ "@react-pdf/image": "npm:^3.0.3"
+ "@react-pdf/primitives": "npm:^4.1.1"
+ "@react-pdf/stylesheet": "npm:^6.1.0"
+ "@react-pdf/textkit": "npm:^6.0.0"
+ "@react-pdf/types": "npm:^2.9.0"
+ emoji-regex: "npm:^10.3.0"
+ queue: "npm:^6.0.1"
+ yoga-layout: "npm:^3.2.1"
+ checksum: 10/f40c4e9028ed169ee77b49b197acf4bb0b75dacbbdaf55b36f7a9953d7152e377e7bf62756c0efed40fed403b84ee1752120826851fbb670aa8fafb267d0d4f7
+ languageName: node
+ linkType: hard
+
+"@react-pdf/pdfkit@npm:^4.0.3":
+ version: 4.0.3
+ resolution: "@react-pdf/pdfkit@npm:4.0.3"
+ dependencies:
+ "@babel/runtime": "npm:^7.20.13"
+ "@react-pdf/png-js": "npm:^3.0.0"
+ browserify-zlib: "npm:^0.2.0"
+ crypto-js: "npm:^4.2.0"
+ fontkit: "npm:^2.0.2"
+ jay-peg: "npm:^1.1.1"
+ linebreak: "npm:^1.1.0"
+ vite-compatible-readable-stream: "npm:^3.6.1"
+ checksum: 10/7571931928b1514917e210a0a6e167e7b0ef8a107a5accedf13d461fa4829ebb04844a5d5626f7c78c4410c53b69e30095008a8b3b588666581970b4ea7a5abe
+ languageName: node
+ linkType: hard
+
+"@react-pdf/png-js@npm:^3.0.0":
+ version: 3.0.0
+ resolution: "@react-pdf/png-js@npm:3.0.0"
+ dependencies:
+ browserify-zlib: "npm:^0.2.0"
+ checksum: 10/5e972302a5d93f67d3dc1438bb4cb8ee708802fb6d513b651aa0857909a185f06e5288b59e82cffa9fe5587289c97de7f5c5401101f50faebc32bef50f398ee5
+ languageName: node
+ linkType: hard
+
+"@react-pdf/primitives@npm:^4.1.1":
+ version: 4.1.1
+ resolution: "@react-pdf/primitives@npm:4.1.1"
+ checksum: 10/adadff1996daeca693aa59844ab613e597fdb674fce9f2c03f52573b593982ef49ff47d861290235861d02462ffbc87b7ed3da0d71af0d61c9226ce61b94ada8
+ languageName: node
+ linkType: hard
+
+"@react-pdf/reconciler@npm:^1.1.4":
+ version: 1.1.4
+ resolution: "@react-pdf/reconciler@npm:1.1.4"
+ dependencies:
+ object-assign: "npm:^4.1.1"
+ scheduler: "npm:0.25.0-rc-603e6108-20241029"
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ checksum: 10/6562961c6ead2b4392a32506d761c84d8c74f43378616cdab84dbdcbfd64e83b18e6e2eeeb962de7e3f3ab672ae335c3560b7f9e0cd275515de2ee55489e5425
+ languageName: node
+ linkType: hard
+
+"@react-pdf/render@npm:^4.3.0":
+ version: 4.3.0
+ resolution: "@react-pdf/render@npm:4.3.0"
+ dependencies:
+ "@babel/runtime": "npm:^7.20.13"
+ "@react-pdf/fns": "npm:3.1.2"
+ "@react-pdf/primitives": "npm:^4.1.1"
+ "@react-pdf/textkit": "npm:^6.0.0"
+ "@react-pdf/types": "npm:^2.9.0"
+ abs-svg-path: "npm:^0.1.1"
+ color-string: "npm:^1.9.1"
+ normalize-svg-path: "npm:^1.1.0"
+ parse-svg-path: "npm:^0.1.2"
+ svg-arc-to-cubic-bezier: "npm:^3.2.0"
+ checksum: 10/7d1465e9411c1c4f170ef741226b5008b1236ec7eee2ed3b87248aeec7a42ecd5826fe7a351a0069826abbf4b4051f98e76fc1708d4cc5d5542f8be1058da978
+ languageName: node
+ linkType: hard
+
+"@react-pdf/renderer@npm:^4.3.0":
+ version: 4.3.0
+ resolution: "@react-pdf/renderer@npm:4.3.0"
+ dependencies:
+ "@babel/runtime": "npm:^7.20.13"
+ "@react-pdf/fns": "npm:3.1.2"
+ "@react-pdf/font": "npm:^4.0.2"
+ "@react-pdf/layout": "npm:^4.4.0"
+ "@react-pdf/pdfkit": "npm:^4.0.3"
+ "@react-pdf/primitives": "npm:^4.1.1"
"@react-pdf/reconciler": "npm:^1.1.4"
"@react-pdf/render": "npm:^4.3.0"
"@react-pdf/types": "npm:^2.9.0"
@@ -4378,6 +5072,13 @@ __metadata:
languageName: node
linkType: hard
+"@remirror/core-constants@npm:3.0.0":
+ version: 3.0.0
+ resolution: "@remirror/core-constants@npm:3.0.0"
+ checksum: 10/de15b1df099a7646739e5fb6bb55195618a8ac4fa938db7c719e867eefd72ebc5a05865591788ade449613141619cc1002fb6c0f824de4468dfefa951fbf19a2
+ languageName: node
+ linkType: hard
+
"@remixicon/react@npm:^4.2.0":
version: 4.2.0
resolution: "@remixicon/react@npm:4.2.0"
@@ -5708,47 +6409,423 @@ __metadata:
languageName: node
linkType: hard
-"@testing-library/jest-dom@npm:6.5.0, @testing-library/jest-dom@npm:^6.4.2, @testing-library/jest-dom@npm:^6.4.8":
- version: 6.5.0
- resolution: "@testing-library/jest-dom@npm:6.5.0"
- dependencies:
- "@adobe/css-tools": "npm:^4.4.0"
- aria-query: "npm:^5.0.0"
- chalk: "npm:^3.0.0"
- css.escape: "npm:^1.5.1"
- dom-accessibility-api: "npm:^0.6.3"
- lodash: "npm:^4.17.21"
- redent: "npm:^3.0.0"
- checksum: 10/3d2080888af5fd7306f57448beb5a23f55d965e265b5e53394fffc112dfb0678d616a5274ff0200c46c7618f293520f86fc8562eecd8bdbc0dbb3294d63ec431
+"@testing-library/jest-dom@npm:6.5.0, @testing-library/jest-dom@npm:^6.4.2, @testing-library/jest-dom@npm:^6.4.8":
+ version: 6.5.0
+ resolution: "@testing-library/jest-dom@npm:6.5.0"
+ dependencies:
+ "@adobe/css-tools": "npm:^4.4.0"
+ aria-query: "npm:^5.0.0"
+ chalk: "npm:^3.0.0"
+ css.escape: "npm:^1.5.1"
+ dom-accessibility-api: "npm:^0.6.3"
+ lodash: "npm:^4.17.21"
+ redent: "npm:^3.0.0"
+ checksum: 10/3d2080888af5fd7306f57448beb5a23f55d965e265b5e53394fffc112dfb0678d616a5274ff0200c46c7618f293520f86fc8562eecd8bdbc0dbb3294d63ec431
+ languageName: node
+ linkType: hard
+
+"@testing-library/react@npm:^16.3.0":
+ version: 16.3.0
+ resolution: "@testing-library/react@npm:16.3.0"
+ dependencies:
+ "@babel/runtime": "npm:^7.12.5"
+ peerDependencies:
+ "@testing-library/dom": ^10.0.0
+ "@types/react": ^18.0.0 || ^19.0.0
+ "@types/react-dom": ^18.0.0 || ^19.0.0
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ "@types/react-dom":
+ optional: true
+ checksum: 10/0ee9e31dd0d2396a924682d0e61a4ecc6bfab8eaff23dbf8a72c3c2ce22c116fa578148baeb4de75b968ef99d22e6e6aa0a00dba40286f71184918bb6bb5b06a
+ languageName: node
+ linkType: hard
+
+"@testing-library/user-event@npm:14.5.2, @testing-library/user-event@npm:^14.5.2":
+ version: 14.5.2
+ resolution: "@testing-library/user-event@npm:14.5.2"
+ peerDependencies:
+ "@testing-library/dom": ">=7.21.4"
+ checksum: 10/49821459d81c6bc435d97128d6386ca24f1e4b3ba8e46cb5a96fe3643efa6e002d88c1b02b7f2ec58da593e805c59b78d7fdf0db565c1f02ba782f63ee984040
+ languageName: node
+ linkType: hard
+
+"@tiptap/core@npm:^3.10.5":
+ version: 3.10.5
+ resolution: "@tiptap/core@npm:3.10.5"
+ peerDependencies:
+ "@tiptap/pm": ^3.10.5
+ checksum: 10/17a00eb0962537406c303a29034a8ac06646c53d0e0e7b77384d7b930a873f09d21680d2a376a5f2a3eaa32e9928a7abf782112bf7373a6ca56474d718757256
+ languageName: node
+ linkType: hard
+
+"@tiptap/extension-blockquote@npm:^3.10.5":
+ version: 3.10.5
+ resolution: "@tiptap/extension-blockquote@npm:3.10.5"
+ peerDependencies:
+ "@tiptap/core": ^3.10.5
+ checksum: 10/e24cceb69c37cbbd3aa13190733d73bdf6a3118ee2830b19e2acf4a8b74d20628cb3baefc464d15109c8ba84bed4f1804d2e32740c7f071c7ac0184cf02a30b1
+ languageName: node
+ linkType: hard
+
+"@tiptap/extension-bold@npm:^3.10.5":
+ version: 3.10.5
+ resolution: "@tiptap/extension-bold@npm:3.10.5"
+ peerDependencies:
+ "@tiptap/core": ^3.10.5
+ checksum: 10/5c2e389a42981e04dcf43112e5329822bd30327dc14f17acb3967565b26ec706cb85e6f3a1ef5eea4149bcd9f0b5dee5e9f0c2f3dfbef706a1f5d0f1e9f930ec
+ languageName: node
+ linkType: hard
+
+"@tiptap/extension-bubble-menu@npm:^3.10.5":
+ version: 3.10.5
+ resolution: "@tiptap/extension-bubble-menu@npm:3.10.5"
+ dependencies:
+ "@floating-ui/dom": "npm:^1.0.0"
+ peerDependencies:
+ "@tiptap/core": ^3.10.5
+ "@tiptap/pm": ^3.10.5
+ checksum: 10/f95dcfc771402179a96f6eb05089c1c281d54559b25c0efeccf19ba46bd33ec506a1b1a97923c7215513d4daada13d23c1194e14eaf0e58c35be773ce36009df
+ languageName: node
+ linkType: hard
+
+"@tiptap/extension-bullet-list@npm:^3.10.5":
+ version: 3.10.5
+ resolution: "@tiptap/extension-bullet-list@npm:3.10.5"
+ peerDependencies:
+ "@tiptap/extension-list": ^3.10.5
+ checksum: 10/9220ee3030d2e98a5caac21a8eae2968979756c038d5010105ec920fdbdeaaf37509d5e7b716dbcc28f2029ab5fe74fe12d7656ab94658a1a00c1a5c53531e4c
+ languageName: node
+ linkType: hard
+
+"@tiptap/extension-code-block@npm:^3.10.5":
+ version: 3.10.5
+ resolution: "@tiptap/extension-code-block@npm:3.10.5"
+ peerDependencies:
+ "@tiptap/core": ^3.10.5
+ "@tiptap/pm": ^3.10.5
+ checksum: 10/76ce92c76a7be22ffacbba9faf71731b34cff765e9117843c8dd451d5c9b7dc0c83a5d12d8a62cca37eb50de97cf38793d4904d90e72ac2aa4c8657f1312198c
+ languageName: node
+ linkType: hard
+
+"@tiptap/extension-code@npm:^3.10.5":
+ version: 3.10.5
+ resolution: "@tiptap/extension-code@npm:3.10.5"
+ peerDependencies:
+ "@tiptap/core": ^3.10.5
+ checksum: 10/0554e61969d0a8afd1485ba54380a4e231fc7373f3a29d32aa252d4b55237ebec80f0f06df9dec0df5c16399fd35d1b745c33783aba4914115db32149461b9df
+ languageName: node
+ linkType: hard
+
+"@tiptap/extension-document@npm:^3.10.5":
+ version: 3.10.5
+ resolution: "@tiptap/extension-document@npm:3.10.5"
+ peerDependencies:
+ "@tiptap/core": ^3.10.5
+ checksum: 10/89254ad1791cd4cc744b62e514aaf82364bad4009981c30d78092aa6d77017719e385d4d0996ef511ab49041bb22ffb6b408beb504009c2b404dbf4a0b93d1a1
+ languageName: node
+ linkType: hard
+
+"@tiptap/extension-dropcursor@npm:^3.10.5":
+ version: 3.10.5
+ resolution: "@tiptap/extension-dropcursor@npm:3.10.5"
+ peerDependencies:
+ "@tiptap/extensions": ^3.10.5
+ checksum: 10/ea87a3065e4f5576b28eb81e306ac5b2e59c20f34b7509a492dcfe6699b0a9d1c7111028e34fc5a20fc25b68b2c17aed203314029103631ed1cfe3599eefc50f
+ languageName: node
+ linkType: hard
+
+"@tiptap/extension-floating-menu@npm:^3.10.5":
+ version: 3.10.5
+ resolution: "@tiptap/extension-floating-menu@npm:3.10.5"
+ peerDependencies:
+ "@floating-ui/dom": ^1.0.0
+ "@tiptap/core": ^3.10.5
+ "@tiptap/pm": ^3.10.5
+ checksum: 10/e1a4aec62b381bd7acb2abd633d9bec89446aa19a7cfc1b16fe383db31be0cb9ffcdc872bc96b4744cced64e83fea6c65efe207adfe4860fb80778588ab95a9a
+ languageName: node
+ linkType: hard
+
+"@tiptap/extension-gapcursor@npm:^3.10.5":
+ version: 3.10.5
+ resolution: "@tiptap/extension-gapcursor@npm:3.10.5"
+ peerDependencies:
+ "@tiptap/extensions": ^3.10.5
+ checksum: 10/11ae46f2f2978f2c1f883fcac7c8d9fc212c6210648074c2f338a874d3b423d48d89caab57c5d7d890e670cdd63968f1f5caa206208afff8c56ade1f7c501bf4
+ languageName: node
+ linkType: hard
+
+"@tiptap/extension-hard-break@npm:^3.10.5":
+ version: 3.10.5
+ resolution: "@tiptap/extension-hard-break@npm:3.10.5"
+ peerDependencies:
+ "@tiptap/core": ^3.10.5
+ checksum: 10/5d36703f3fb0aa5ffcbd1e9b402e5a725dbe2ffc39d5d71f163ce51205e9573742417746f0d680fbfd770ac4f3101b9e1b9673b81c9cf25249532ea7332a145c
+ languageName: node
+ linkType: hard
+
+"@tiptap/extension-heading@npm:^3.10.5":
+ version: 3.10.5
+ resolution: "@tiptap/extension-heading@npm:3.10.5"
+ peerDependencies:
+ "@tiptap/core": ^3.10.5
+ checksum: 10/a2aa19acabc0ceda669121af7df4f17bfc00eee9725ecad151c7743955d6d1a8efe6b026695bf021ae1089764771763dcdee4f507e7938fe5c168b2687db23ff
+ languageName: node
+ linkType: hard
+
+"@tiptap/extension-highlight@npm:^3.10.5":
+ version: 3.10.5
+ resolution: "@tiptap/extension-highlight@npm:3.10.5"
+ peerDependencies:
+ "@tiptap/core": ^3.10.5
+ checksum: 10/c5010058072f84a962cc9efcabf17fea74748e025d9778f13352a1ac19f69147adc632940e6e094defd8eabeeae770b9a84acd88b391b1a27401b8469ab5d24f
+ languageName: node
+ linkType: hard
+
+"@tiptap/extension-horizontal-rule@npm:^3.10.5":
+ version: 3.10.5
+ resolution: "@tiptap/extension-horizontal-rule@npm:3.10.5"
+ peerDependencies:
+ "@tiptap/core": ^3.10.5
+ "@tiptap/pm": ^3.10.5
+ checksum: 10/0836bab3c41217053c60244f162a7a7e751c429d38b4d5134a7d7c7a76d0acaceef6b9ffcb976a9d49bf972232b4563aa1d71cf22a24fd94a0fd61f57f7987fc
+ languageName: node
+ linkType: hard
+
+"@tiptap/extension-image@npm:^3.10.5":
+ version: 3.10.5
+ resolution: "@tiptap/extension-image@npm:3.10.5"
+ peerDependencies:
+ "@tiptap/core": ^3.10.5
+ checksum: 10/2a34e75684e9289addee39fae6980b19b6618c0dfbbb79ea40748f43e8b0998d6e87bab0c5fac43328fd94bb0c32dfb12fadd898a50e5531d0a7c6f1f56a7310
+ languageName: node
+ linkType: hard
+
+"@tiptap/extension-italic@npm:^3.10.5":
+ version: 3.10.5
+ resolution: "@tiptap/extension-italic@npm:3.10.5"
+ peerDependencies:
+ "@tiptap/core": ^3.10.5
+ checksum: 10/b442d903de338af99e718484e9b021dc371fbeb838a2e73638f5b295b230dacb16414218e3a55e22446dd4240065da770e1a634354d4848b3aee052e60a771e3
+ languageName: node
+ linkType: hard
+
+"@tiptap/extension-link@npm:^3.10.5":
+ version: 3.10.5
+ resolution: "@tiptap/extension-link@npm:3.10.5"
+ dependencies:
+ linkifyjs: "npm:^4.3.2"
+ peerDependencies:
+ "@tiptap/core": ^3.10.5
+ "@tiptap/pm": ^3.10.5
+ checksum: 10/09f09ba9ce65dfc3e88cbf7a2d690a331e89011d58c57d44686f9c9a98faf9220c03d4b73bf8a87c754a7c822c537b288db94af740b0344f3b14e1f1f3ed4580
+ languageName: node
+ linkType: hard
+
+"@tiptap/extension-list-item@npm:^3.10.5":
+ version: 3.10.5
+ resolution: "@tiptap/extension-list-item@npm:3.10.5"
+ peerDependencies:
+ "@tiptap/extension-list": ^3.10.5
+ checksum: 10/15fa06dbaccd9b5e26835889a75ede3911c5581c9886235fac65c8ed6b4ef456da7ecf9f8aa6880cda346f7e9968047526e97db7991fc137b43077a3c8df21a4
+ languageName: node
+ linkType: hard
+
+"@tiptap/extension-list-keymap@npm:^3.10.5":
+ version: 3.10.5
+ resolution: "@tiptap/extension-list-keymap@npm:3.10.5"
+ peerDependencies:
+ "@tiptap/extension-list": ^3.10.5
+ checksum: 10/1e00ebd89a6ee622170c93174a44abbd22cc8a92a5daed4dad71e685cc40a3825ae7edf979b169d33bfb1270412c9d16f9c3e4cc7159ecd5113b50928b0424e4
+ languageName: node
+ linkType: hard
+
+"@tiptap/extension-list@npm:^3.10.5":
+ version: 3.10.5
+ resolution: "@tiptap/extension-list@npm:3.10.5"
+ peerDependencies:
+ "@tiptap/core": ^3.10.5
+ "@tiptap/pm": ^3.10.5
+ checksum: 10/7a52d7abae3f26a03a9be576e7916d8ec4c23c7206be03948c822722ee20e7ab404cb2ac9d29d6999a3387475f8fb3dc1316dfd84c0f083f2c577ca28c2a7e29
+ languageName: node
+ linkType: hard
+
+"@tiptap/extension-ordered-list@npm:^3.10.5":
+ version: 3.10.5
+ resolution: "@tiptap/extension-ordered-list@npm:3.10.5"
+ peerDependencies:
+ "@tiptap/extension-list": ^3.10.5
+ checksum: 10/17c8cc6c4a3113e4a3bc2e189b6a19c2c027af92b1c6ec41380671258390aa2fac46671195d29def576e85d17c6fdd4465c4f70cefe262a6a0a637e59009d7b7
+ languageName: node
+ linkType: hard
+
+"@tiptap/extension-paragraph@npm:^3.10.5":
+ version: 3.10.5
+ resolution: "@tiptap/extension-paragraph@npm:3.10.5"
+ peerDependencies:
+ "@tiptap/core": ^3.10.5
+ checksum: 10/ef0b5dad615ffd30fcdfa28f5079feea442ba9776c385ce491567a7552b1b59de8e674b5063e5390f184db897f6eb82b53cdb30ca900a3607afa24d9d5d68840
+ languageName: node
+ linkType: hard
+
+"@tiptap/extension-strike@npm:^3.10.5":
+ version: 3.10.5
+ resolution: "@tiptap/extension-strike@npm:3.10.5"
+ peerDependencies:
+ "@tiptap/core": ^3.10.5
+ checksum: 10/54d8ea0b1f8c103ce2e9e35fedaf18036bb39d941dc6360855e67165d027ec0b0eece5fe2488acdb248534871a43dd4d99b18e046ea020ac8f2a3eb42ee61776
+ languageName: node
+ linkType: hard
+
+"@tiptap/extension-subscript@npm:^3.10.5":
+ version: 3.10.5
+ resolution: "@tiptap/extension-subscript@npm:3.10.5"
+ peerDependencies:
+ "@tiptap/core": ^3.10.5
+ "@tiptap/pm": ^3.10.5
+ checksum: 10/7f862cef396054c0943fd36f7855d8cf755db6d7ca588ffdd55229f08d14ffea3cb5705d62f96b9da80e519912b63aa62c9a12ed31bfc7846208c3bdd5948837
+ languageName: node
+ linkType: hard
+
+"@tiptap/extension-superscript@npm:^3.10.5":
+ version: 3.10.5
+ resolution: "@tiptap/extension-superscript@npm:3.10.5"
+ peerDependencies:
+ "@tiptap/core": ^3.10.5
+ "@tiptap/pm": ^3.10.5
+ checksum: 10/503232e20ef5cac9e5ab71c598256086aa43f30c1bba2e575d1f8941ac716cc6e16d572409fd4e87092dd09bd29f52cbee2d115f69174a9435baf7c0b43336f9
+ languageName: node
+ linkType: hard
+
+"@tiptap/extension-text-align@npm:^3.10.5":
+ version: 3.10.5
+ resolution: "@tiptap/extension-text-align@npm:3.10.5"
+ peerDependencies:
+ "@tiptap/core": ^3.10.5
+ checksum: 10/8288cbf18e9831725b99e3749a60eff19a758697d1373c6e89b0f4ac1446768f6289b00294799b18a2aa66300dbd9b67ae7d66c332c47cb8f08f4855d89d04ce
+ languageName: node
+ linkType: hard
+
+"@tiptap/extension-text@npm:^3.10.5":
+ version: 3.10.5
+ resolution: "@tiptap/extension-text@npm:3.10.5"
+ peerDependencies:
+ "@tiptap/core": ^3.10.5
+ checksum: 10/1fc3eee565eb15c2ce3fae378c7edb8e8055d306093bc15486c709ea0850ea869596b2f5eaef9fc2a5d6a1138df2037164dbe5d17b02956302939e5600433e76
+ languageName: node
+ linkType: hard
+
+"@tiptap/extension-typography@npm:^3.10.5":
+ version: 3.10.5
+ resolution: "@tiptap/extension-typography@npm:3.10.5"
+ peerDependencies:
+ "@tiptap/core": ^3.10.5
+ checksum: 10/4c059575647a8848224704488cdfaa3b0181b9e385f7a30568c6b68c349f1fb9ba04dcd0e9b2b3bf4e0eabefab8b14f306f5f11922d2fb8185db1050e06ca891
+ languageName: node
+ linkType: hard
+
+"@tiptap/extension-underline@npm:^3.10.5":
+ version: 3.10.5
+ resolution: "@tiptap/extension-underline@npm:3.10.5"
+ peerDependencies:
+ "@tiptap/core": ^3.10.5
+ checksum: 10/d949a1dd93490c983f54762a6892b48707f13934a9a17490ec832ae748240416e319c24485f84ac3c34256ef924baa436392c61438bbc4b0a2607623764abe58
languageName: node
linkType: hard
-"@testing-library/react@npm:^16.3.0":
- version: 16.3.0
- resolution: "@testing-library/react@npm:16.3.0"
- dependencies:
- "@babel/runtime": "npm:^7.12.5"
+"@tiptap/extensions@npm:^3.10.5":
+ version: 3.10.5
+ resolution: "@tiptap/extensions@npm:3.10.5"
peerDependencies:
- "@testing-library/dom": ^10.0.0
- "@types/react": ^18.0.0 || ^19.0.0
- "@types/react-dom": ^18.0.0 || ^19.0.0
- react: ^18.0.0 || ^19.0.0
- react-dom: ^18.0.0 || ^19.0.0
- peerDependenciesMeta:
- "@types/react":
- optional: true
- "@types/react-dom":
- optional: true
- checksum: 10/0ee9e31dd0d2396a924682d0e61a4ecc6bfab8eaff23dbf8a72c3c2ce22c116fa578148baeb4de75b968ef99d22e6e6aa0a00dba40286f71184918bb6bb5b06a
+ "@tiptap/core": ^3.10.5
+ "@tiptap/pm": ^3.10.5
+ checksum: 10/a3def19cbebbb10d721322173265ab90498376917bf7dd1182c6047f689dbc1d0577418a51f985b4261357de20498235d49196951238eaea5f6581cf478a232d
languageName: node
linkType: hard
-"@testing-library/user-event@npm:14.5.2, @testing-library/user-event@npm:^14.5.2":
- version: 14.5.2
- resolution: "@testing-library/user-event@npm:14.5.2"
+"@tiptap/pm@npm:^3.10.5":
+ version: 3.10.5
+ resolution: "@tiptap/pm@npm:3.10.5"
+ dependencies:
+ prosemirror-changeset: "npm:^2.3.0"
+ prosemirror-collab: "npm:^1.3.1"
+ prosemirror-commands: "npm:^1.6.2"
+ prosemirror-dropcursor: "npm:^1.8.1"
+ prosemirror-gapcursor: "npm:^1.3.2"
+ prosemirror-history: "npm:^1.4.1"
+ prosemirror-inputrules: "npm:^1.4.0"
+ prosemirror-keymap: "npm:^1.2.2"
+ prosemirror-markdown: "npm:^1.13.1"
+ prosemirror-menu: "npm:^1.2.4"
+ prosemirror-model: "npm:^1.24.1"
+ prosemirror-schema-basic: "npm:^1.2.3"
+ prosemirror-schema-list: "npm:^1.5.0"
+ prosemirror-state: "npm:^1.4.3"
+ prosemirror-tables: "npm:^1.6.4"
+ prosemirror-trailing-node: "npm:^3.0.0"
+ prosemirror-transform: "npm:^1.10.2"
+ prosemirror-view: "npm:^1.38.1"
+ checksum: 10/c73fedb51906fc24fdb429d8dce04973e1d14c1eade990f3d8d31df14d70f6d0a28b02c502bf49f807536952cd72a99d9cef531ff7cb146368a7493862f1d37f
+ languageName: node
+ linkType: hard
+
+"@tiptap/react@npm:^3.10.5":
+ version: 3.10.5
+ resolution: "@tiptap/react@npm:3.10.5"
+ dependencies:
+ "@tiptap/extension-bubble-menu": "npm:^3.10.5"
+ "@tiptap/extension-floating-menu": "npm:^3.10.5"
+ "@types/use-sync-external-store": "npm:^0.0.6"
+ fast-deep-equal: "npm:^3.1.3"
+ use-sync-external-store: "npm:^1.4.0"
peerDependencies:
- "@testing-library/dom": ">=7.21.4"
- checksum: 10/49821459d81c6bc435d97128d6386ca24f1e4b3ba8e46cb5a96fe3643efa6e002d88c1b02b7f2ec58da593e805c59b78d7fdf0db565c1f02ba782f63ee984040
+ "@tiptap/core": ^3.10.5
+ "@tiptap/pm": ^3.10.5
+ "@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0
+ "@types/react-dom": ^17.0.0 || ^18.0.0 || ^19.0.0
+ react: ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0
+ dependenciesMeta:
+ "@tiptap/extension-bubble-menu":
+ optional: true
+ "@tiptap/extension-floating-menu":
+ optional: true
+ checksum: 10/409b283ad78b923524e37d1cd95570509ed0eaf3da4bd47066f8c434406afb39712f58e5c9f81db1611fad359e7b065652f6056fb11c286592916c12b0d7bee6
+ languageName: node
+ linkType: hard
+
+"@tiptap/starter-kit@npm:^3.10.5":
+ version: 3.10.5
+ resolution: "@tiptap/starter-kit@npm:3.10.5"
+ dependencies:
+ "@tiptap/core": "npm:^3.10.5"
+ "@tiptap/extension-blockquote": "npm:^3.10.5"
+ "@tiptap/extension-bold": "npm:^3.10.5"
+ "@tiptap/extension-bullet-list": "npm:^3.10.5"
+ "@tiptap/extension-code": "npm:^3.10.5"
+ "@tiptap/extension-code-block": "npm:^3.10.5"
+ "@tiptap/extension-document": "npm:^3.10.5"
+ "@tiptap/extension-dropcursor": "npm:^3.10.5"
+ "@tiptap/extension-gapcursor": "npm:^3.10.5"
+ "@tiptap/extension-hard-break": "npm:^3.10.5"
+ "@tiptap/extension-heading": "npm:^3.10.5"
+ "@tiptap/extension-horizontal-rule": "npm:^3.10.5"
+ "@tiptap/extension-italic": "npm:^3.10.5"
+ "@tiptap/extension-link": "npm:^3.10.5"
+ "@tiptap/extension-list": "npm:^3.10.5"
+ "@tiptap/extension-list-item": "npm:^3.10.5"
+ "@tiptap/extension-list-keymap": "npm:^3.10.5"
+ "@tiptap/extension-ordered-list": "npm:^3.10.5"
+ "@tiptap/extension-paragraph": "npm:^3.10.5"
+ "@tiptap/extension-strike": "npm:^3.10.5"
+ "@tiptap/extension-text": "npm:^3.10.5"
+ "@tiptap/extension-underline": "npm:^3.10.5"
+ "@tiptap/extensions": "npm:^3.10.5"
+ "@tiptap/pm": "npm:^3.10.5"
+ checksum: 10/3005464bc831cd7fb3cb083528c2000951625b0242467a6ed35c46736e5d359408859ef519cd30f3cae2bbca43c326576205a1b504fb0ebd47d53a31fd17a8d0
languageName: node
linkType: hard
@@ -6101,6 +7178,29 @@ __metadata:
languageName: node
linkType: hard
+"@types/linkify-it@npm:^5":
+ version: 5.0.0
+ resolution: "@types/linkify-it@npm:5.0.0"
+ checksum: 10/c3919044d4876f9d71d037e861745cd2485c95ac8c36a4fa67b132d4e60eb1d067e123cc7965c9cf5110eea351517d767f0d306af5e9147d6d0af87bc374ddcf
+ languageName: node
+ linkType: hard
+
+"@types/lodash.throttle@npm:^4.1.9":
+ version: 4.1.9
+ resolution: "@types/lodash.throttle@npm:4.1.9"
+ dependencies:
+ "@types/lodash": "npm:*"
+ checksum: 10/6d330072387f062d408747f0dbe62869820ee3f3fbec43965f703ce9c9083e4ff9082faa4fe92aea000d6367b7645955e9c8db6a4e04e6bd769697fdd19c12b1
+ languageName: node
+ linkType: hard
+
+"@types/lodash@npm:*":
+ version: 4.17.20
+ resolution: "@types/lodash@npm:4.17.20"
+ checksum: 10/8cd8ad3bd78d2e06a93ae8d6c9907981d5673655fec7cb274a4d9a59549aab5bb5b3017361280773b8990ddfccf363e14d1b37c97af8a9fe363de677f9a61524
+ languageName: node
+ linkType: hard
+
"@types/lodash@npm:^4.14.167, @types/lodash@npm:^4.17.7":
version: 4.17.10
resolution: "@types/lodash@npm:4.17.10"
@@ -6108,6 +7208,16 @@ __metadata:
languageName: node
linkType: hard
+"@types/markdown-it@npm:^14.0.0":
+ version: 14.1.2
+ resolution: "@types/markdown-it@npm:14.1.2"
+ dependencies:
+ "@types/linkify-it": "npm:^5"
+ "@types/mdurl": "npm:^2"
+ checksum: 10/ca2f239c8d59610b9f936fd40261a6ccf2fa1ae27a21816c031e5712542dcf9ee01e2fe29b31118df90716e11ade54e47d92a498e9b6488800e77ca8827255a2
+ languageName: node
+ linkType: hard
+
"@types/mathjax@npm:^0.0.40":
version: 0.0.40
resolution: "@types/mathjax@npm:0.0.40"
@@ -6133,6 +7243,13 @@ __metadata:
languageName: node
linkType: hard
+"@types/mdurl@npm:^2":
+ version: 2.0.0
+ resolution: "@types/mdurl@npm:2.0.0"
+ checksum: 10/78746e96c655ceed63db06382da466fd52c7e9dc54d60b12973dfdd110cae06b9439c4b90e17bb8d4461109184b3ea9f3e9f96b3e4bf4aa9fe18b6ac35f283c8
+ languageName: node
+ linkType: hard
+
"@types/mdx@npm:^2.0.0":
version: 2.0.13
resolution: "@types/mdx@npm:2.0.13"
@@ -6390,6 +7507,13 @@ __metadata:
languageName: node
linkType: hard
+"@types/use-sync-external-store@npm:^0.0.6":
+ version: 0.0.6
+ resolution: "@types/use-sync-external-store@npm:0.0.6"
+ checksum: 10/a95ce330668501ad9b1c5b7f2b14872ad201e552a0e567787b8f1588b22c7040c7c3d80f142cbb9f92d13c4ea41c46af57a20f2af4edf27f224d352abcfe4049
+ languageName: node
+ linkType: hard
+
"@types/uuid@npm:^9.0.1":
version: 9.0.8
resolution: "@types/uuid@npm:9.0.8"
@@ -7421,6 +8545,15 @@ __metadata:
languageName: node
linkType: hard
+"aria-hidden@npm:^1.2.4":
+ version: 1.2.6
+ resolution: "aria-hidden@npm:1.2.6"
+ dependencies:
+ tslib: "npm:^2.0.0"
+ checksum: 10/1914e5a36225dccdb29f0b88cc891eeca736cdc5b0c905ab1437b90b28b5286263ed3a221c75b7dc788f25b942367be0044b2ac8ccf073a72e07a50b1d964202
+ languageName: node
+ linkType: hard
+
"aria-query@npm:5.3.0":
version: 5.3.0
resolution: "aria-query@npm:5.3.0"
@@ -8097,6 +9230,13 @@ __metadata:
languageName: node
linkType: hard
+"buffer-builder@npm:^0.2.0":
+ version: 0.2.0
+ resolution: "buffer-builder@npm:0.2.0"
+ checksum: 10/16bd9eb8ac6630a05441bcb56522e956ae6a0724371ecc49b9a6bc10d35690489140df73573d0577e1e85c875737e560a4e2e67521fddd14714ddf4e0097d0ec
+ languageName: node
+ linkType: hard
+
"buffer-from@npm:^1.0.0":
version: 1.1.2
resolution: "buffer-from@npm:1.1.2"
@@ -8390,6 +9530,15 @@ __metadata:
languageName: node
linkType: hard
+"chokidar@npm:^4.0.0":
+ version: 4.0.3
+ resolution: "chokidar@npm:4.0.3"
+ dependencies:
+ readdirp: "npm:^4.0.1"
+ checksum: 10/bf2a575ea5596000e88f5db95461a9d59ad2047e939d5a4aac59dd472d126be8f1c1ff3c7654b477cf532d18f42a97279ef80ee847972fd2a25410bf00b80b59
+ languageName: node
+ linkType: hard
+
"chownr@npm:^2.0.0":
version: 2.0.0
resolution: "chownr@npm:2.0.0"
@@ -8589,6 +9738,13 @@ __metadata:
languageName: node
linkType: hard
+"colorjs.io@npm:^0.5.0":
+ version: 0.5.2
+ resolution: "colorjs.io@npm:0.5.2"
+ checksum: 10/a6f6345865b177d19481008cb299c46ec9ff1fd206f472cd9ef69ddbca65832c81237b19fdcd24f3f9540c3e6343a22eb486cd800f5eab9815ce7c98c16a0f0e
+ languageName: node
+ linkType: hard
+
"combined-stream@npm:^1.0.8":
version: 1.0.8
resolution: "combined-stream@npm:1.0.8"
@@ -8857,6 +10013,13 @@ __metadata:
languageName: node
linkType: hard
+"crelt@npm:^1.0.0":
+ version: 1.0.6
+ resolution: "crelt@npm:1.0.6"
+ checksum: 10/5ed326ca6bd243b1dba6b943f665b21c2c04be03271824bc48f20dba324b0f8233e221f8c67312526d24af2b1243c023dc05a41bd8bd05d1a479fd2c72fb39c3
+ languageName: node
+ linkType: hard
+
"cross-fetch@npm:^4.0.0":
version: 4.0.0
resolution: "cross-fetch@npm:4.0.0"
@@ -9296,6 +10459,15 @@ __metadata:
languageName: node
linkType: hard
+"detect-libc@npm:^1.0.3":
+ version: 1.0.3
+ resolution: "detect-libc@npm:1.0.3"
+ bin:
+ detect-libc: ./bin/detect-libc.js
+ checksum: 10/3849fe7720feb153e4ac9407086956e073f1ce1704488290ef0ca8aab9430a8d48c8a9f8351889e7cdc64e5b1128589501e4fef48f3a4a49ba92cd6d112d0757
+ languageName: node
+ linkType: hard
+
"detect-libc@npm:^2.0.3, detect-libc@npm:^2.0.4":
version: 2.0.4
resolution: "detect-libc@npm:2.0.4"
@@ -9317,6 +10489,13 @@ __metadata:
languageName: node
linkType: hard
+"detect-node-es@npm:^1.1.0":
+ version: 1.1.0
+ resolution: "detect-node-es@npm:1.1.0"
+ checksum: 10/e46307d7264644975b71c104b9f028ed1d3d34b83a15b8a22373640ce5ea630e5640b1078b8ea15f202b54641da71e4aa7597093bd4b91f113db520a26a37449
+ languageName: node
+ linkType: hard
+
"devlop@npm:^1.0.0, devlop@npm:^1.1.0":
version: 1.1.0
resolution: "devlop@npm:1.1.0"
@@ -9754,6 +10933,13 @@ __metadata:
languageName: node
linkType: hard
+"entities@npm:^4.4.0":
+ version: 4.5.0
+ resolution: "entities@npm:4.5.0"
+ checksum: 10/ede2a35c9bce1aeccd055a1b445d41c75a14a2bb1cd22e242f20cf04d236cdcd7f9c859eb83f76885327bfae0c25bf03303665ee1ce3d47c5927b98b0e3e3d48
+ languageName: node
+ linkType: hard
+
"entities@npm:^6.0.0":
version: 6.0.1
resolution: "entities@npm:6.0.1"
@@ -11317,6 +12503,13 @@ __metadata:
languageName: node
linkType: hard
+"get-nonce@npm:^1.0.0":
+ version: 1.0.1
+ resolution: "get-nonce@npm:1.0.1"
+ checksum: 10/ad5104871d114a694ecc506a2d406e2331beccb961fe1e110dc25556b38bcdbf399a823a8a375976cd8889668156a9561e12ebe3fa6a4c6ba169c8466c2ff868
+ languageName: node
+ linkType: hard
+
"get-package-type@npm:^0.1.0":
version: 0.1.0
resolution: "get-package-type@npm:0.1.0"
@@ -12131,6 +13324,13 @@ __metadata:
languageName: node
linkType: hard
+"immutable@npm:^5.0.2":
+ version: 5.1.4
+ resolution: "immutable@npm:5.1.4"
+ checksum: 10/0655b33af249ff99c7a56f9e6d7aee632af2dc25758710ddf224bda645f66dd2dd98119c0d86986895ea52cc889b6c5127a848c6fba21aadabdc4c5ead04be2b
+ languageName: node
+ linkType: hard
+
"import-fresh@npm:^3.2.1, import-fresh@npm:^3.3.0":
version: 3.3.0
resolution: "import-fresh@npm:3.3.0"
@@ -13859,6 +15059,22 @@ __metadata:
languageName: node
linkType: hard
+"linkify-it@npm:^5.0.0":
+ version: 5.0.0
+ resolution: "linkify-it@npm:5.0.0"
+ dependencies:
+ uc.micro: "npm:^2.0.0"
+ checksum: 10/ef3b7609dda6ec0c0be8a7b879cea195f0d36387b0011660cd6711bba0ad82137f59b458b7e703ec74f11d88e7c1328e2ad9b855a8500c0ded67461a8c4519e6
+ languageName: node
+ linkType: hard
+
+"linkifyjs@npm:^4.3.2":
+ version: 4.3.2
+ resolution: "linkifyjs@npm:4.3.2"
+ checksum: 10/b03477486658d1e5531bf65ee1fdc0f79423594e689184c67b8a63c75d9f35d1cd0344edd97d5799502cde4f3163d620e2cbd9e72ad718c6a95084177c004386
+ languageName: node
+ linkType: hard
+
"load-plugin@npm:^6.0.0":
version: 6.0.3
resolution: "load-plugin@npm:6.0.3"
@@ -13949,6 +15165,13 @@ __metadata:
languageName: node
linkType: hard
+"lodash.throttle@npm:^4.1.1":
+ version: 4.1.1
+ resolution: "lodash.throttle@npm:4.1.1"
+ checksum: 10/9be9fb2ffd686c20543167883305542f4564062a5f712a40e8c6f2f0d9fd8254a6e9d801c2470b1b24e0cdf2ae83c1277b55aa0fb4799a2db6daf545f53820e1
+ languageName: node
+ linkType: hard
+
"lodash.truncate@npm:^4.4.2":
version: 4.4.2
resolution: "lodash.truncate@npm:4.4.2"
@@ -14179,6 +15402,22 @@ __metadata:
languageName: node
linkType: hard
+"markdown-it@npm:^14.0.0":
+ version: 14.1.0
+ resolution: "markdown-it@npm:14.1.0"
+ dependencies:
+ argparse: "npm:^2.0.1"
+ entities: "npm:^4.4.0"
+ linkify-it: "npm:^5.0.0"
+ mdurl: "npm:^2.0.0"
+ punycode.js: "npm:^2.3.1"
+ uc.micro: "npm:^2.1.0"
+ bin:
+ markdown-it: bin/markdown-it.mjs
+ checksum: 10/f34f921be178ed0607ba9e3e27c733642be445e9bb6b1dba88da7aafe8ba1bc5d2f1c3aa8f3fc33b49a902da4e4c08c2feadfafb290b8c7dda766208bb6483a9
+ languageName: node
+ linkType: hard
+
"markdown-table@npm:^3.0.0":
version: 3.0.3
resolution: "markdown-table@npm:3.0.3"
@@ -14520,6 +15759,13 @@ __metadata:
languageName: node
linkType: hard
+"mdurl@npm:^2.0.0":
+ version: 2.0.0
+ resolution: "mdurl@npm:2.0.0"
+ checksum: 10/1720349d4a53e401aa993241368e35c0ad13d816ad0b28388928c58ca9faa0cf755fa45f18ccbf64f4ce54a845a50ddce5c84e4016897b513096a68dac4b0158
+ languageName: node
+ linkType: hard
+
"media-engine@npm:^1.0.3":
version: 1.0.3
resolution: "media-engine@npm:1.0.3"
@@ -15072,7 +16318,7 @@ __metadata:
languageName: node
linkType: hard
-"micromatch@npm:^4.0.2, micromatch@npm:^4.0.4, micromatch@npm:^4.0.8":
+"micromatch@npm:^4.0.2, micromatch@npm:^4.0.4, micromatch@npm:^4.0.5, micromatch@npm:^4.0.8":
version: 4.0.8
resolution: "micromatch@npm:4.0.8"
dependencies:
@@ -15497,6 +16743,15 @@ __metadata:
languageName: node
linkType: hard
+"node-addon-api@npm:^7.0.0":
+ version: 7.1.1
+ resolution: "node-addon-api@npm:7.1.1"
+ dependencies:
+ node-gyp: "npm:latest"
+ checksum: 10/ee1e1ed6284a2f8cd1d59ac6175ecbabf8978dcf570345e9a8095a9d0a2b9ced591074ae77f9009287b00c402352b38aa9322a34f2199cdc9f567b842a636b94
+ languageName: node
+ linkType: hard
+
"node-fetch@npm:^2.6.12, node-fetch@npm:^2.6.7":
version: 2.7.0
resolution: "node-fetch@npm:2.7.0"
@@ -15808,11 +17063,14 @@ __metadata:
"@emotion/react": "npm:^11.11.1"
"@emotion/styled": "npm:^11.11.0"
"@faker-js/faker": "npm:^10.0.0"
+ "@floating-ui/react": "npm:^0.27.16"
"@mui/base": "npm:5.0.0-beta.70"
"@mui/lab": "npm:6.0.0-dev.240424162023-9968b4889d"
"@mui/material": "npm:^6.4.5"
"@mui/material-nextjs": "npm:^6.4.3"
"@mui/system": "npm:^6.4.3"
+ "@radix-ui/react-dropdown-menu": "npm:^2.1.16"
+ "@radix-ui/react-popover": "npm:^1.1.15"
"@remixicon/react": "npm:^4.2.0"
"@storybook/addon-actions": "npm:^8.2.9"
"@storybook/addon-essentials": "npm:^8.2.9"
@@ -15830,6 +17088,19 @@ __metadata:
"@testing-library/dom": "npm:^10.4.0"
"@testing-library/react": "npm:^16.3.0"
"@testing-library/user-event": "npm:^14.5.2"
+ "@tiptap/extension-highlight": "npm:^3.10.5"
+ "@tiptap/extension-horizontal-rule": "npm:^3.10.5"
+ "@tiptap/extension-image": "npm:^3.10.5"
+ "@tiptap/extension-list": "npm:^3.10.5"
+ "@tiptap/extension-subscript": "npm:^3.10.5"
+ "@tiptap/extension-superscript": "npm:^3.10.5"
+ "@tiptap/extension-text-align": "npm:^3.10.5"
+ "@tiptap/extension-typography": "npm:^3.10.5"
+ "@tiptap/extensions": "npm:^3.10.5"
+ "@tiptap/pm": "npm:^3.10.5"
+ "@tiptap/react": "npm:^3.10.5"
+ "@tiptap/starter-kit": "npm:^3.10.5"
+ "@types/lodash.throttle": "npm:^4.1.9"
"@types/react-dom": "npm:^19"
"@types/react-slick": "npm:^0"
"@types/tinycolor2": "npm:^1.4.6"
@@ -15839,6 +17110,7 @@ __metadata:
embla-carousel-react: "npm:^8.6.0"
embla-carousel-wheel-gestures: "npm:^8.0.2"
lodash: "npm:^4.17.21"
+ lodash.throttle: "npm:^4.1.1"
material-ui-popup-state: "npm:^5.1.0"
next: "npm:^15.5.2"
ol-test-utilities: "npm:0.0.0"
@@ -15846,9 +17118,12 @@ __metadata:
prop-types: "npm:^15.8.1"
react: "npm:^19.0.0"
react-dom: "npm:^19.0.0"
+ react-hotkeys-hook: "npm:^5.2.1"
react-select: "npm:^5.7.7"
react-share: "npm:^5.0.3"
react-slick: "npm:^0.30.2"
+ sass: "npm:^1.93.3"
+ sass-embedded: "npm:^1.93.3"
storybook: "npm:^8.2.9"
tiny-invariant: "npm:^1.3.1"
tinycolor2: "npm:^1.6.0"
@@ -15996,6 +17271,13 @@ __metadata:
languageName: node
linkType: hard
+"orderedmap@npm:^2.0.0":
+ version: 2.1.1
+ resolution: "orderedmap@npm:2.1.1"
+ checksum: 10/082cf970b0b66d1c5a904b07880534092ce8a2f2eea7a52cf111f6c956210fa88226c13866aef4d22a3abe56924f21ead12f7ee8c1dfaf2f63d897a4e7c23328
+ languageName: node
+ linkType: hard
+
"os-browserify@npm:^0.3.0":
version: 0.3.0
resolution: "os-browserify@npm:0.3.0"
@@ -16704,69 +17986,263 @@ __metadata:
languageName: node
linkType: hard
-"progress@npm:^2.0.3":
- version: 2.0.3
- resolution: "progress@npm:2.0.3"
- checksum: 10/e6f0bcb71f716eee9dfac0fe8a2606e3704d6a64dd93baaf49fbadbc8499989a610fe14cf1bc6f61b6d6653c49408d94f4a94e124538084efd8e4cf525e0293d
+"progress@npm:^2.0.3":
+ version: 2.0.3
+ resolution: "progress@npm:2.0.3"
+ checksum: 10/e6f0bcb71f716eee9dfac0fe8a2606e3704d6a64dd93baaf49fbadbc8499989a610fe14cf1bc6f61b6d6653c49408d94f4a94e124538084efd8e4cf525e0293d
+ languageName: node
+ linkType: hard
+
+"promise-inflight@npm:^1.0.1":
+ version: 1.0.1
+ resolution: "promise-inflight@npm:1.0.1"
+ checksum: 10/1560d413ea20c5a74f3631d39ba8cbd1972b9228072a755d01e1f5ca5110382d9af76a1582d889445adc6e75bb5ac4886b56dc4b6eae51b30145d7bb1ac7505b
+ languageName: node
+ linkType: hard
+
+"promise-retry@npm:^2.0.1":
+ version: 2.0.1
+ resolution: "promise-retry@npm:2.0.1"
+ dependencies:
+ err-code: "npm:^2.0.2"
+ retry: "npm:^0.12.0"
+ checksum: 10/96e1a82453c6c96eef53a37a1d6134c9f2482f94068f98a59145d0986ca4e497bf110a410adf73857e588165eab3899f0ebcf7b3890c1b3ce802abc0d65967d4
+ languageName: node
+ linkType: hard
+
+"prompts@npm:2.4.2, prompts@npm:^2.0.1":
+ version: 2.4.2
+ resolution: "prompts@npm:2.4.2"
+ dependencies:
+ kleur: "npm:^3.0.3"
+ sisteransi: "npm:^1.0.5"
+ checksum: 10/c52536521a4d21eff4f2f2aa4572446cad227464066365a7167e52ccf8d9839c099f9afec1aba0eed3d5a2514b3e79e0b3e7a1dc326b9acde6b75d27ed74b1a9
+ languageName: node
+ linkType: hard
+
+"prop-types@npm:^15.6.0, prop-types@npm:^15.6.2, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1":
+ version: 15.8.1
+ resolution: "prop-types@npm:15.8.1"
+ dependencies:
+ loose-envify: "npm:^1.4.0"
+ object-assign: "npm:^4.1.1"
+ react-is: "npm:^16.13.1"
+ checksum: 10/7d959caec002bc964c86cdc461ec93108b27337dabe6192fb97d69e16a0c799a03462713868b40749bfc1caf5f57ef80ac3e4ffad3effa636ee667582a75e2c0
+ languageName: node
+ linkType: hard
+
+"property-expr@npm:^2.0.5":
+ version: 2.0.6
+ resolution: "property-expr@npm:2.0.6"
+ checksum: 10/89977f4bb230736c1876f460dd7ca9328034502fd92e738deb40516d16564b850c0bbc4e052c3df88b5b8cd58e51c93b46a94bea049a3f23f4a022c038864cab
+ languageName: node
+ linkType: hard
+
+"property-information@npm:^6.0.0":
+ version: 6.5.0
+ resolution: "property-information@npm:6.5.0"
+ checksum: 10/fced94f3a09bf651ad1824d1bdc8980428e3e480e6d01e98df6babe2cc9d45a1c52eee9a7736d2006958f9b394eb5964dedd37e23038086ddc143fc2fd5e426c
+ languageName: node
+ linkType: hard
+
+"property-information@npm:^7.0.0":
+ version: 7.0.0
+ resolution: "property-information@npm:7.0.0"
+ checksum: 10/55f443088456cddc2fe499d6f5895e68cbd465e39dc318ecc63a0d2432d1b918f51fb6d13f8b1adf8a78337bc4e608baa6e46afbe0c6d50d2e38588b2c409f86
+ languageName: node
+ linkType: hard
+
+"prosemirror-changeset@npm:^2.3.0":
+ version: 2.3.1
+ resolution: "prosemirror-changeset@npm:2.3.1"
+ dependencies:
+ prosemirror-transform: "npm:^1.0.0"
+ checksum: 10/a951daee431b9ff6a2aa24ce1eb755ef3c1fba72191760289ed9f91abbccb8e98a5c24697598b93df496662509bfd0345a52ed9a63d9535190ba3a1802a53c10
+ languageName: node
+ linkType: hard
+
+"prosemirror-collab@npm:^1.3.1":
+ version: 1.3.1
+ resolution: "prosemirror-collab@npm:1.3.1"
+ dependencies:
+ prosemirror-state: "npm:^1.0.0"
+ checksum: 10/6b1ccc52841fbb62a39ef0fb8da2d731381030609ea7a0ba7d533b1937d56fe4b91344e79c023e790bed5392efe9f917c41c8434e0a379dc1dc842ba83594e34
+ languageName: node
+ linkType: hard
+
+"prosemirror-commands@npm:^1.0.0, prosemirror-commands@npm:^1.6.2":
+ version: 1.7.1
+ resolution: "prosemirror-commands@npm:1.7.1"
+ dependencies:
+ prosemirror-model: "npm:^1.0.0"
+ prosemirror-state: "npm:^1.0.0"
+ prosemirror-transform: "npm:^1.10.2"
+ checksum: 10/60efe77a9a6b9f06d66442980946f49a4804c24a7aca06ee4d55333a6c55d0f1e3189613bc27f31d16496133969a454f29f071caa8ef2d38126aefbc36f81c4a
+ languageName: node
+ linkType: hard
+
+"prosemirror-dropcursor@npm:^1.8.1":
+ version: 1.8.2
+ resolution: "prosemirror-dropcursor@npm:1.8.2"
+ dependencies:
+ prosemirror-state: "npm:^1.0.0"
+ prosemirror-transform: "npm:^1.1.0"
+ prosemirror-view: "npm:^1.1.0"
+ checksum: 10/02349c56152d0261c61d462b07684bb8179ab0ea488ab333dadcc9181b9ec8d5aa625feb2c54088090bd8f4e540321219938a0642d8ed043fea8a11f371ef058
+ languageName: node
+ linkType: hard
+
+"prosemirror-gapcursor@npm:^1.3.2":
+ version: 1.4.0
+ resolution: "prosemirror-gapcursor@npm:1.4.0"
+ dependencies:
+ prosemirror-keymap: "npm:^1.0.0"
+ prosemirror-model: "npm:^1.0.0"
+ prosemirror-state: "npm:^1.0.0"
+ prosemirror-view: "npm:^1.0.0"
+ checksum: 10/ec17d7ca4d9b134d8db04180a9d399a0552373d6a7491fbf1720c15b5ca6d6f617485dcabed774961cc9b1ce01de99796505b30a0fcb8372f9cdff8966b09a7f
+ languageName: node
+ linkType: hard
+
+"prosemirror-history@npm:^1.0.0, prosemirror-history@npm:^1.4.1":
+ version: 1.4.1
+ resolution: "prosemirror-history@npm:1.4.1"
+ dependencies:
+ prosemirror-state: "npm:^1.2.2"
+ prosemirror-transform: "npm:^1.0.0"
+ prosemirror-view: "npm:^1.31.0"
+ rope-sequence: "npm:^1.3.0"
+ checksum: 10/7ac68fc8233dcd159bb15c2aaf542fd9aa0524b50523b24de6c8209b1f5eae9545f7fa82d584c93e68b1e910bcae5e07bee1085094aca4c565c607cf737c39b8
+ languageName: node
+ linkType: hard
+
+"prosemirror-inputrules@npm:^1.4.0":
+ version: 1.5.1
+ resolution: "prosemirror-inputrules@npm:1.5.1"
+ dependencies:
+ prosemirror-state: "npm:^1.0.0"
+ prosemirror-transform: "npm:^1.0.0"
+ checksum: 10/81f08415a39ad795a2a097c8667eb86e4473eca1389c793139d6bacbb69be740732e8ff8f21b2808130bacd7f53a1b0b7621b48a22ff44d902995b41654e4b80
+ languageName: node
+ linkType: hard
+
+"prosemirror-keymap@npm:^1.0.0, prosemirror-keymap@npm:^1.2.2":
+ version: 1.2.3
+ resolution: "prosemirror-keymap@npm:1.2.3"
+ dependencies:
+ prosemirror-state: "npm:^1.0.0"
+ w3c-keyname: "npm:^2.2.0"
+ checksum: 10/acb251b03f57920282342bf404755de6ff68e150aaf252aca9bbeb63bbba8f53e03091095d399def979c95db0649620a22a47a332418c3a2408a0f43737b18d9
+ languageName: node
+ linkType: hard
+
+"prosemirror-markdown@npm:^1.13.1":
+ version: 1.13.2
+ resolution: "prosemirror-markdown@npm:1.13.2"
+ dependencies:
+ "@types/markdown-it": "npm:^14.0.0"
+ markdown-it: "npm:^14.0.0"
+ prosemirror-model: "npm:^1.25.0"
+ checksum: 10/805f5b5b246250ebd14aedb3de5b683c637c58e0ecf13b6a6fedcbec7e761700e1d9c1371d5a24b10577cd574db5a240a883175b80a87fec24165c16c47ca9aa
+ languageName: node
+ linkType: hard
+
+"prosemirror-menu@npm:^1.2.4":
+ version: 1.2.5
+ resolution: "prosemirror-menu@npm:1.2.5"
+ dependencies:
+ crelt: "npm:^1.0.0"
+ prosemirror-commands: "npm:^1.0.0"
+ prosemirror-history: "npm:^1.0.0"
+ prosemirror-state: "npm:^1.0.0"
+ checksum: 10/68ccff3793906a70ef9e64d953027cf231c045119e62ef37a4631f012066c546bee96454ca98103382bf8699e9040e2e6551c518259543ffb149d4d6609114b5
+ languageName: node
+ linkType: hard
+
+"prosemirror-model@npm:^1.0.0, prosemirror-model@npm:^1.20.0, prosemirror-model@npm:^1.21.0, prosemirror-model@npm:^1.24.1, prosemirror-model@npm:^1.25.0":
+ version: 1.25.4
+ resolution: "prosemirror-model@npm:1.25.4"
+ dependencies:
+ orderedmap: "npm:^2.0.0"
+ checksum: 10/63c5d6dd3b70e42650f07b4a2ed87e7442291b1f95a9930bf4ff2f7c6a1228e95db0e996cdabed667f4cbaf67c3ee290e19b91a31dd199da95477ac4d1b868e3
languageName: node
linkType: hard
-"promise-inflight@npm:^1.0.1":
- version: 1.0.1
- resolution: "promise-inflight@npm:1.0.1"
- checksum: 10/1560d413ea20c5a74f3631d39ba8cbd1972b9228072a755d01e1f5ca5110382d9af76a1582d889445adc6e75bb5ac4886b56dc4b6eae51b30145d7bb1ac7505b
+"prosemirror-schema-basic@npm:^1.2.3":
+ version: 1.2.4
+ resolution: "prosemirror-schema-basic@npm:1.2.4"
+ dependencies:
+ prosemirror-model: "npm:^1.25.0"
+ checksum: 10/51972732657b7eca6d0fc294d980f7ef0079acee962451280ef79e59790717a1f65e9227364de8780caa8e1e5c7d48a57b3b58488d24e23b8b035ea7ec0ec37a
languageName: node
linkType: hard
-"promise-retry@npm:^2.0.1":
- version: 2.0.1
- resolution: "promise-retry@npm:2.0.1"
+"prosemirror-schema-list@npm:^1.5.0":
+ version: 1.5.1
+ resolution: "prosemirror-schema-list@npm:1.5.1"
dependencies:
- err-code: "npm:^2.0.2"
- retry: "npm:^0.12.0"
- checksum: 10/96e1a82453c6c96eef53a37a1d6134c9f2482f94068f98a59145d0986ca4e497bf110a410adf73857e588165eab3899f0ebcf7b3890c1b3ce802abc0d65967d4
+ prosemirror-model: "npm:^1.0.0"
+ prosemirror-state: "npm:^1.0.0"
+ prosemirror-transform: "npm:^1.7.3"
+ checksum: 10/eaf308093ecbce7fc7d0e3c3653faa120afb3679bdb20c99f79c75c90b4f57d61c6580ea162dd0af274013e98c5d4529ecbe6b9bd7e66e7d6d6b5be6aae0f052
languageName: node
linkType: hard
-"prompts@npm:2.4.2, prompts@npm:^2.0.1":
- version: 2.4.2
- resolution: "prompts@npm:2.4.2"
+"prosemirror-state@npm:^1.0.0, prosemirror-state@npm:^1.2.2, prosemirror-state@npm:^1.4.3":
+ version: 1.4.4
+ resolution: "prosemirror-state@npm:1.4.4"
dependencies:
- kleur: "npm:^3.0.3"
- sisteransi: "npm:^1.0.5"
- checksum: 10/c52536521a4d21eff4f2f2aa4572446cad227464066365a7167e52ccf8d9839c099f9afec1aba0eed3d5a2514b3e79e0b3e7a1dc326b9acde6b75d27ed74b1a9
+ prosemirror-model: "npm:^1.0.0"
+ prosemirror-transform: "npm:^1.0.0"
+ prosemirror-view: "npm:^1.27.0"
+ checksum: 10/90e66cbc49f2eceeb174f5184ed94df432a8b83866ab74ef602d21555dd151eb1ff9a0794b12d98384981601ebaa885d930f6c667165ff969874944a2f9e2488
languageName: node
linkType: hard
-"prop-types@npm:^15.6.0, prop-types@npm:^15.6.2, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1":
- version: 15.8.1
- resolution: "prop-types@npm:15.8.1"
+"prosemirror-tables@npm:^1.6.4":
+ version: 1.8.1
+ resolution: "prosemirror-tables@npm:1.8.1"
dependencies:
- loose-envify: "npm:^1.4.0"
- object-assign: "npm:^4.1.1"
- react-is: "npm:^16.13.1"
- checksum: 10/7d959caec002bc964c86cdc461ec93108b27337dabe6192fb97d69e16a0c799a03462713868b40749bfc1caf5f57ef80ac3e4ffad3effa636ee667582a75e2c0
+ prosemirror-keymap: "npm:^1.2.2"
+ prosemirror-model: "npm:^1.25.0"
+ prosemirror-state: "npm:^1.4.3"
+ prosemirror-transform: "npm:^1.10.3"
+ prosemirror-view: "npm:^1.39.1"
+ checksum: 10/d2bc4cd5e17cf51d5a822b1a5db318a5e0e7784a76ae54a87eb43a50359dda8f181b9bee54c59c72b29b7d37ec417a70c16085093810752ee03296b989eb546b
languageName: node
linkType: hard
-"property-expr@npm:^2.0.5":
- version: 2.0.6
- resolution: "property-expr@npm:2.0.6"
- checksum: 10/89977f4bb230736c1876f460dd7ca9328034502fd92e738deb40516d16564b850c0bbc4e052c3df88b5b8cd58e51c93b46a94bea049a3f23f4a022c038864cab
+"prosemirror-trailing-node@npm:^3.0.0":
+ version: 3.0.0
+ resolution: "prosemirror-trailing-node@npm:3.0.0"
+ dependencies:
+ "@remirror/core-constants": "npm:3.0.0"
+ escape-string-regexp: "npm:^4.0.0"
+ peerDependencies:
+ prosemirror-model: ^1.22.1
+ prosemirror-state: ^1.4.2
+ prosemirror-view: ^1.33.8
+ checksum: 10/044b199b8001373c1bd4c1573876597840df89e66c1f02497a8bb4f2885ebe830faa9764e1269ed6c24bf2fde06ad5f40322afde648ae331d4663f531000adaa
languageName: node
linkType: hard
-"property-information@npm:^6.0.0":
- version: 6.5.0
- resolution: "property-information@npm:6.5.0"
- checksum: 10/fced94f3a09bf651ad1824d1bdc8980428e3e480e6d01e98df6babe2cc9d45a1c52eee9a7736d2006958f9b394eb5964dedd37e23038086ddc143fc2fd5e426c
+"prosemirror-transform@npm:^1.0.0, prosemirror-transform@npm:^1.1.0, prosemirror-transform@npm:^1.10.2, prosemirror-transform@npm:^1.10.3, prosemirror-transform@npm:^1.7.3":
+ version: 1.10.4
+ resolution: "prosemirror-transform@npm:1.10.4"
+ dependencies:
+ prosemirror-model: "npm:^1.21.0"
+ checksum: 10/a5835bdd7e66e455f52115d63b48a1b9c6a0741fdefa277894c7ed4f5baf3570b226a135b11036abaa7fe7c5e98de466692779d356e65302b8e929a13a1c4391
languageName: node
linkType: hard
-"property-information@npm:^7.0.0":
- version: 7.0.0
- resolution: "property-information@npm:7.0.0"
- checksum: 10/55f443088456cddc2fe499d6f5895e68cbd465e39dc318ecc63a0d2432d1b918f51fb6d13f8b1adf8a78337bc4e608baa6e46afbe0c6d50d2e38588b2c409f86
+"prosemirror-view@npm:^1.0.0, prosemirror-view@npm:^1.1.0, prosemirror-view@npm:^1.27.0, prosemirror-view@npm:^1.31.0, prosemirror-view@npm:^1.38.1, prosemirror-view@npm:^1.39.1":
+ version: 1.41.3
+ resolution: "prosemirror-view@npm:1.41.3"
+ dependencies:
+ prosemirror-model: "npm:^1.20.0"
+ prosemirror-state: "npm:^1.0.0"
+ prosemirror-transform: "npm:^1.1.0"
+ checksum: 10/2cd8c29c28f6061f31cac371667baa47a7f20705897f31b8ba4fb6d2dc226f9ce38a91068e3faf8b40c7f76727c3953df293223dc6aee0079b5dc3a44ccb7dca
languageName: node
linkType: hard
@@ -16808,6 +18284,13 @@ __metadata:
languageName: node
linkType: hard
+"punycode.js@npm:^2.3.1":
+ version: 2.3.1
+ resolution: "punycode.js@npm:2.3.1"
+ checksum: 10/f0e946d1edf063f9e3d30a32ca86d8ff90ed13ca40dad9c75d37510a04473340cfc98db23a905cc1e517b1e9deb0f6021dce6f422ace235c60d3c9ac47c5a16a
+ languageName: node
+ linkType: hard
+
"punycode@npm:^1.4.1":
version: 1.4.1
resolution: "punycode@npm:1.4.1"
@@ -17018,6 +18501,16 @@ __metadata:
languageName: node
linkType: hard
+"react-hotkeys-hook@npm:^5.2.1":
+ version: 5.2.1
+ resolution: "react-hotkeys-hook@npm:5.2.1"
+ peerDependencies:
+ react: ">=16.8.0"
+ react-dom: ">=16.8.0"
+ checksum: 10/4581b6bc2496954b5bd3c41924aba28f0d2e4a996476f38cce80e777b6a63f51e024992a11c2d7d4b4abe4815e0bba04fa692a0ed28aeb5f58df7852a77aab70
+ languageName: node
+ linkType: hard
+
"react-is@npm:18.1.0":
version: 18.1.0
resolution: "react-is@npm:18.1.0"
@@ -17082,6 +18575,41 @@ __metadata:
languageName: node
linkType: hard
+"react-remove-scroll-bar@npm:^2.3.7":
+ version: 2.3.8
+ resolution: "react-remove-scroll-bar@npm:2.3.8"
+ dependencies:
+ react-style-singleton: "npm:^2.2.2"
+ tslib: "npm:^2.0.0"
+ peerDependencies:
+ "@types/react": "*"
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ checksum: 10/6c0f8cff98b9f49a4ee2263f1eedf12926dced5ce220fbe83bd93544460e2a7ec8ec39b35d1b2a75d2fced0b2d64afeb8e66f830431ca896e05a20585f9fc350
+ languageName: node
+ linkType: hard
+
+"react-remove-scroll@npm:^2.6.3":
+ version: 2.7.1
+ resolution: "react-remove-scroll@npm:2.7.1"
+ dependencies:
+ react-remove-scroll-bar: "npm:^2.3.7"
+ react-style-singleton: "npm:^2.2.3"
+ tslib: "npm:^2.1.0"
+ use-callback-ref: "npm:^1.3.3"
+ use-sidecar: "npm:^1.1.3"
+ peerDependencies:
+ "@types/react": "*"
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ checksum: 10/5e571ba35ba527047c54c9c4a271363167770556fb85ee45ead8310673197719425cc8f7a2b7f672abf530294c41c8c34bdae325a571994cc1e694b664b52734
+ languageName: node
+ linkType: hard
+
"react-select@npm:^5.7.7":
version: 5.8.1
resolution: "react-select@npm:5.8.1"
@@ -17130,6 +18658,22 @@ __metadata:
languageName: node
linkType: hard
+"react-style-singleton@npm:^2.2.2, react-style-singleton@npm:^2.2.3":
+ version: 2.2.3
+ resolution: "react-style-singleton@npm:2.2.3"
+ dependencies:
+ get-nonce: "npm:^1.0.0"
+ tslib: "npm:^2.0.0"
+ peerDependencies:
+ "@types/react": "*"
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ checksum: 10/62498094ff3877a37f351b29e6cad9e38b2eb1ac3c0cb27ebf80aee96554f80b35e17bdb552bcd7ac8b7cb9904fea93ea5668f2057c73d38f90b5d46bb9b27ab
+ languageName: node
+ linkType: hard
+
"react-transition-group@npm:^4.3.0, react-transition-group@npm:^4.4.5":
version: 4.4.5
resolution: "react-transition-group@npm:4.4.5"
@@ -17220,6 +18764,13 @@ __metadata:
languageName: node
linkType: hard
+"readdirp@npm:^4.0.1":
+ version: 4.1.2
+ resolution: "readdirp@npm:4.1.2"
+ checksum: 10/7b817c265940dba90bb9c94d82920d76c3a35ea2d67f9f9d8bd936adcfe02d50c802b14be3dd2e725e002dddbe2cc1c7a0edfb1bc3a365c9dfd5a61e612eea1e
+ languageName: node
+ linkType: hard
+
"readdirp@npm:~3.6.0":
version: 3.6.0
resolution: "readdirp@npm:3.6.0"
@@ -17766,6 +19317,13 @@ __metadata:
languageName: node
linkType: hard
+"rope-sequence@npm:^1.3.0":
+ version: 1.3.4
+ resolution: "rope-sequence@npm:1.3.4"
+ checksum: 10/57b5dd8c28ece05bb5f33eea6ea56facb00d4893269bb83aa8656f69065c1bc0707ec9bb816bce0e5f4d489d88942c7f0f0a1c3655773753ef158c9dd0e9456d
+ languageName: node
+ linkType: hard
+
"rrweb-cssom@npm:^0.8.0":
version: 0.8.0
resolution: "rrweb-cssom@npm:0.8.0"
@@ -17782,6 +19340,15 @@ __metadata:
languageName: node
linkType: hard
+"rxjs@npm:^7.4.0":
+ version: 7.8.2
+ resolution: "rxjs@npm:7.8.2"
+ dependencies:
+ tslib: "npm:^2.1.0"
+ checksum: 10/03dff09191356b2b87d94fbc1e97c4e9eb3c09d4452399dddd451b09c2f1ba8d56925a40af114282d7bc0c6fe7514a2236ca09f903cf70e4bbf156650dddb49d
+ languageName: node
+ linkType: hard
+
"sade@npm:^1.7.3":
version: 1.8.1
resolution: "sade@npm:1.8.1"
@@ -17835,6 +19402,209 @@ __metadata:
languageName: node
linkType: hard
+"sass-embedded-all-unknown@npm:1.93.3":
+ version: 1.93.3
+ resolution: "sass-embedded-all-unknown@npm:1.93.3"
+ dependencies:
+ sass: "npm:1.93.3"
+ conditions: (!cpu=arm | !cpu=arm64 | !cpu=riscv64 | !cpu=x64)
+ languageName: node
+ linkType: hard
+
+"sass-embedded-android-arm64@npm:1.93.3":
+ version: 1.93.3
+ resolution: "sass-embedded-android-arm64@npm:1.93.3"
+ conditions: os=android & cpu=arm64
+ languageName: node
+ linkType: hard
+
+"sass-embedded-android-arm@npm:1.93.3":
+ version: 1.93.3
+ resolution: "sass-embedded-android-arm@npm:1.93.3"
+ conditions: os=android & cpu=arm
+ languageName: node
+ linkType: hard
+
+"sass-embedded-android-riscv64@npm:1.93.3":
+ version: 1.93.3
+ resolution: "sass-embedded-android-riscv64@npm:1.93.3"
+ conditions: os=android & cpu=riscv64
+ languageName: node
+ linkType: hard
+
+"sass-embedded-android-x64@npm:1.93.3":
+ version: 1.93.3
+ resolution: "sass-embedded-android-x64@npm:1.93.3"
+ conditions: os=android & cpu=x64
+ languageName: node
+ linkType: hard
+
+"sass-embedded-darwin-arm64@npm:1.93.3":
+ version: 1.93.3
+ resolution: "sass-embedded-darwin-arm64@npm:1.93.3"
+ conditions: os=darwin & cpu=arm64
+ languageName: node
+ linkType: hard
+
+"sass-embedded-darwin-x64@npm:1.93.3":
+ version: 1.93.3
+ resolution: "sass-embedded-darwin-x64@npm:1.93.3"
+ conditions: os=darwin & cpu=x64
+ languageName: node
+ linkType: hard
+
+"sass-embedded-linux-arm64@npm:1.93.3":
+ version: 1.93.3
+ resolution: "sass-embedded-linux-arm64@npm:1.93.3"
+ conditions: os=linux & cpu=arm64
+ languageName: node
+ linkType: hard
+
+"sass-embedded-linux-arm@npm:1.93.3":
+ version: 1.93.3
+ resolution: "sass-embedded-linux-arm@npm:1.93.3"
+ conditions: os=linux & cpu=arm
+ languageName: node
+ linkType: hard
+
+"sass-embedded-linux-musl-arm64@npm:1.93.3":
+ version: 1.93.3
+ resolution: "sass-embedded-linux-musl-arm64@npm:1.93.3"
+ conditions: os=linux & cpu=arm64
+ languageName: node
+ linkType: hard
+
+"sass-embedded-linux-musl-arm@npm:1.93.3":
+ version: 1.93.3
+ resolution: "sass-embedded-linux-musl-arm@npm:1.93.3"
+ conditions: os=linux & cpu=arm
+ languageName: node
+ linkType: hard
+
+"sass-embedded-linux-musl-riscv64@npm:1.93.3":
+ version: 1.93.3
+ resolution: "sass-embedded-linux-musl-riscv64@npm:1.93.3"
+ conditions: os=linux & cpu=riscv64
+ languageName: node
+ linkType: hard
+
+"sass-embedded-linux-musl-x64@npm:1.93.3":
+ version: 1.93.3
+ resolution: "sass-embedded-linux-musl-x64@npm:1.93.3"
+ conditions: os=linux & cpu=x64
+ languageName: node
+ linkType: hard
+
+"sass-embedded-linux-riscv64@npm:1.93.3":
+ version: 1.93.3
+ resolution: "sass-embedded-linux-riscv64@npm:1.93.3"
+ conditions: os=linux & cpu=riscv64
+ languageName: node
+ linkType: hard
+
+"sass-embedded-linux-x64@npm:1.93.3":
+ version: 1.93.3
+ resolution: "sass-embedded-linux-x64@npm:1.93.3"
+ conditions: os=linux & cpu=x64
+ languageName: node
+ linkType: hard
+
+"sass-embedded-unknown-all@npm:1.93.3":
+ version: 1.93.3
+ resolution: "sass-embedded-unknown-all@npm:1.93.3"
+ dependencies:
+ sass: "npm:1.93.3"
+ conditions: (!os=android | !os=darwin | !os=linux | !os=win32)
+ languageName: node
+ linkType: hard
+
+"sass-embedded-win32-arm64@npm:1.93.3":
+ version: 1.93.3
+ resolution: "sass-embedded-win32-arm64@npm:1.93.3"
+ conditions: os=win32 & cpu=arm64
+ languageName: node
+ linkType: hard
+
+"sass-embedded-win32-x64@npm:1.93.3":
+ version: 1.93.3
+ resolution: "sass-embedded-win32-x64@npm:1.93.3"
+ conditions: os=win32 & cpu=x64
+ languageName: node
+ linkType: hard
+
+"sass-embedded@npm:^1.93.3":
+ version: 1.93.3
+ resolution: "sass-embedded@npm:1.93.3"
+ dependencies:
+ "@bufbuild/protobuf": "npm:^2.5.0"
+ buffer-builder: "npm:^0.2.0"
+ colorjs.io: "npm:^0.5.0"
+ immutable: "npm:^5.0.2"
+ rxjs: "npm:^7.4.0"
+ sass-embedded-all-unknown: "npm:1.93.3"
+ sass-embedded-android-arm: "npm:1.93.3"
+ sass-embedded-android-arm64: "npm:1.93.3"
+ sass-embedded-android-riscv64: "npm:1.93.3"
+ sass-embedded-android-x64: "npm:1.93.3"
+ sass-embedded-darwin-arm64: "npm:1.93.3"
+ sass-embedded-darwin-x64: "npm:1.93.3"
+ sass-embedded-linux-arm: "npm:1.93.3"
+ sass-embedded-linux-arm64: "npm:1.93.3"
+ sass-embedded-linux-musl-arm: "npm:1.93.3"
+ sass-embedded-linux-musl-arm64: "npm:1.93.3"
+ sass-embedded-linux-musl-riscv64: "npm:1.93.3"
+ sass-embedded-linux-musl-x64: "npm:1.93.3"
+ sass-embedded-linux-riscv64: "npm:1.93.3"
+ sass-embedded-linux-x64: "npm:1.93.3"
+ sass-embedded-unknown-all: "npm:1.93.3"
+ sass-embedded-win32-arm64: "npm:1.93.3"
+ sass-embedded-win32-x64: "npm:1.93.3"
+ supports-color: "npm:^8.1.1"
+ sync-child-process: "npm:^1.0.2"
+ varint: "npm:^6.0.0"
+ dependenciesMeta:
+ sass-embedded-all-unknown:
+ optional: true
+ sass-embedded-android-arm:
+ optional: true
+ sass-embedded-android-arm64:
+ optional: true
+ sass-embedded-android-riscv64:
+ optional: true
+ sass-embedded-android-x64:
+ optional: true
+ sass-embedded-darwin-arm64:
+ optional: true
+ sass-embedded-darwin-x64:
+ optional: true
+ sass-embedded-linux-arm:
+ optional: true
+ sass-embedded-linux-arm64:
+ optional: true
+ sass-embedded-linux-musl-arm:
+ optional: true
+ sass-embedded-linux-musl-arm64:
+ optional: true
+ sass-embedded-linux-musl-riscv64:
+ optional: true
+ sass-embedded-linux-musl-x64:
+ optional: true
+ sass-embedded-linux-riscv64:
+ optional: true
+ sass-embedded-linux-x64:
+ optional: true
+ sass-embedded-unknown-all:
+ optional: true
+ sass-embedded-win32-arm64:
+ optional: true
+ sass-embedded-win32-x64:
+ optional: true
+ bin:
+ sass: dist/bin/sass.js
+ checksum: 10/e2a1d6a31da76ce94df75f690a434ecd6467209eca6333951f1008a17d54693643e8a7cf2e82e0514f07f98b6de17dcf2b2fdadd5ad8abdac113c40c350fe154
+ languageName: node
+ linkType: hard
+
"sass-loader@npm:^13.2.0":
version: 13.3.3
resolution: "sass-loader@npm:13.3.3"
@@ -17859,6 +19629,23 @@ __metadata:
languageName: node
linkType: hard
+"sass@npm:1.93.3, sass@npm:^1.93.3":
+ version: 1.93.3
+ resolution: "sass@npm:1.93.3"
+ dependencies:
+ "@parcel/watcher": "npm:^2.4.1"
+ chokidar: "npm:^4.0.0"
+ immutable: "npm:^5.0.2"
+ source-map-js: "npm:>=0.6.2 <2.0.0"
+ dependenciesMeta:
+ "@parcel/watcher":
+ optional: true
+ bin:
+ sass: sass.js
+ checksum: 10/41f23b10bb203ee46b82b880e566edc3264cd00b0424bb7293c6aedb66fd4d6b9b7a217e91f98fb4653eee6538150bbe1a663abde03ad69cd5172beebf108ae0
+ languageName: node
+ linkType: hard
+
"saxes@npm:^6.0.0":
version: 6.0.0
resolution: "saxes@npm:6.0.0"
@@ -18413,7 +20200,7 @@ __metadata:
languageName: node
linkType: hard
-"source-map-js@npm:^1.0.1, source-map-js@npm:^1.0.2, source-map-js@npm:^1.2.1":
+"source-map-js@npm:>=0.6.2 <2.0.0, source-map-js@npm:^1.0.1, source-map-js@npm:^1.0.2, source-map-js@npm:^1.2.1":
version: 1.2.1
resolution: "source-map-js@npm:1.2.1"
checksum: 10/ff9d8c8bf096d534a5b7707e0382ef827b4dd360a577d3f34d2b9f48e12c9d230b5747974ee7c607f0df65113732711bb701fe9ece3c7edbd43cb2294d707df3
@@ -19033,7 +20820,7 @@ __metadata:
languageName: node
linkType: hard
-"supports-color@npm:^8.0.0":
+"supports-color@npm:^8.0.0, supports-color@npm:^8.1.1":
version: 8.1.1
resolution: "supports-color@npm:8.1.1"
dependencies:
@@ -19111,6 +20898,22 @@ __metadata:
languageName: node
linkType: hard
+"sync-child-process@npm:^1.0.2":
+ version: 1.0.2
+ resolution: "sync-child-process@npm:1.0.2"
+ dependencies:
+ sync-message-port: "npm:^1.0.0"
+ checksum: 10/6fbdbb7b6f5730a1966d6a77cdbfe7f5cb8d1a582dab955c62c32b56dc6c432ccdbfc68027265486f8f4b1a998cc4d7ee21856e8125748bef70b8874aaedb21c
+ languageName: node
+ linkType: hard
+
+"sync-message-port@npm:^1.0.0":
+ version: 1.1.3
+ resolution: "sync-message-port@npm:1.1.3"
+ checksum: 10/a84b681afd678f28af4498074c4bc5cd5c763395fbf169f1bc9777c2e01aa8d41a3046dcca43a41e81102a7fd697713dfc03e155d1c662fec88af9481b249b8a
+ languageName: node
+ linkType: hard
+
"synckit@npm:^0.9.0":
version: 0.9.2
resolution: "synckit@npm:0.9.2"
@@ -19158,6 +20961,13 @@ __metadata:
languageName: node
linkType: hard
+"tabbable@npm:^6.0.0":
+ version: 6.3.0
+ resolution: "tabbable@npm:6.3.0"
+ checksum: 10/3e54a0b770d26bc20c3de5837652be19f5efa8bfa869f580af24bcf60de934506e9401a577213186b5e86ebcf6b5290a5429d354cc3041471815f5095e44e51a
+ languageName: node
+ linkType: hard
+
"table@npm:^6.9.0":
version: 6.9.0
resolution: "table@npm:6.9.0"
@@ -19628,7 +21438,7 @@ __metadata:
languageName: node
linkType: hard
-"tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.0.3, tslib@npm:^2.4.0, tslib@npm:^2.6.2, tslib@npm:^2.8.0":
+"tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.0.3, tslib@npm:^2.1.0, tslib@npm:^2.4.0, tslib@npm:^2.6.2, tslib@npm:^2.8.0":
version: 2.8.1
resolution: "tslib@npm:2.8.1"
checksum: 10/3e2e043d5c2316461cb54e5c7fe02c30ef6dccb3384717ca22ae5c6b5bc95232a6241df19c622d9c73b809bea33b187f6dbc73030963e29950c2141bc32a79f7
@@ -19805,6 +21615,13 @@ __metadata:
languageName: node
linkType: hard
+"uc.micro@npm:^2.0.0, uc.micro@npm:^2.1.0":
+ version: 2.1.0
+ resolution: "uc.micro@npm:2.1.0"
+ checksum: 10/37197358242eb9afe367502d4638ac8c5838b78792ab218eafe48287b0ed28aaca268ec0392cc5729f6c90266744de32c06ae938549aee041fc93b0f9672d6b2
+ languageName: node
+ linkType: hard
+
"unbox-primitive@npm:^1.0.2":
version: 1.0.2
resolution: "unbox-primitive@npm:1.0.2"
@@ -20221,6 +22038,21 @@ __metadata:
languageName: node
linkType: hard
+"use-callback-ref@npm:^1.3.3":
+ version: 1.3.3
+ resolution: "use-callback-ref@npm:1.3.3"
+ dependencies:
+ tslib: "npm:^2.0.0"
+ peerDependencies:
+ "@types/react": "*"
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ checksum: 10/adf06a7b6a27d3651c325ac9b66d2b82ccacaed7450b85b211d123e91d9a23cb5a587fcc6db5b4fd07ac7233e5abf024d30cf02ddc2ec46bca712151c0836151
+ languageName: node
+ linkType: hard
+
"use-isomorphic-layout-effect@npm:^1.1.2":
version: 1.1.2
resolution: "use-isomorphic-layout-effect@npm:1.1.2"
@@ -20233,6 +22065,22 @@ __metadata:
languageName: node
linkType: hard
+"use-sidecar@npm:^1.1.3":
+ version: 1.1.3
+ resolution: "use-sidecar@npm:1.1.3"
+ dependencies:
+ detect-node-es: "npm:^1.1.0"
+ tslib: "npm:^2.0.0"
+ peerDependencies:
+ "@types/react": "*"
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ checksum: 10/2fec05eb851cdfc4a4657b1dfb434e686f346c3265ffc9db8a974bb58f8128bd4a708a3cc00e8f51655fccf81822ed4419ebed42f41610589e3aab0cf2492edb
+ languageName: node
+ linkType: hard
+
"use-sync-external-store@npm:^1.4.0":
version: 1.4.0
resolution: "use-sync-external-store@npm:1.4.0"
@@ -20348,6 +22196,13 @@ __metadata:
languageName: node
linkType: hard
+"varint@npm:^6.0.0":
+ version: 6.0.0
+ resolution: "varint@npm:6.0.0"
+ checksum: 10/7684113c9d497c01e40396e50169c502eb2176203219b96e1c5ac965a3e15b4892bd22b7e48d87148e10fffe638130516b6dbeedd0efde2b2d0395aa1772eea7
+ languageName: node
+ linkType: hard
+
"vary@npm:~1.1.2":
version: 1.1.2
resolution: "vary@npm:1.1.2"
@@ -20439,6 +22294,13 @@ __metadata:
languageName: node
linkType: hard
+"w3c-keyname@npm:^2.2.0":
+ version: 2.2.8
+ resolution: "w3c-keyname@npm:2.2.8"
+ checksum: 10/95bafa4c04fa2f685a86ca1000069c1ec43ace1f8776c10f226a73296caeddd83f893db885c2c220ebeb6c52d424e3b54d7c0c1e963bbf204038ff1a944fbb07
+ languageName: node
+ linkType: hard
+
"w3c-xmlserializer@npm:^4.0.0":
version: 4.0.0
resolution: "w3c-xmlserializer@npm:4.0.0"