From bfbf2a870379de4222d8b5e239bff1be88f2c7c2 Mon Sep 17 00:00:00 2001 From: Marc Bouchenoire Date: Wed, 23 Jul 2025 09:12:37 +0200 Subject: [PATCH 1/2] Release: 3.2 (#2529) Co-authored-by: Chris Nicholas Co-authored-by: Florent Lefebvre --- CHANGELOG.md | 22 + .../api-reference/liveblocks-react-ui.mdx | 137 ++- packages/liveblocks-core/src/types/ai.ts | 2 +- packages/liveblocks-node/src/client.ts | 27 +- .../src/__tests__/index.test.tsx | 67 +- .../liveblocks-react-ui/src/_private/index.ts | 4 + .../src/components/AiChat.tsx | 495 ++++++-- .../src/components/Composer.tsx | 7 + .../src/components/Thread.tsx | 7 + .../internal/AiChatAssistantMessage.tsx | 76 +- .../components/internal/AiChatUserMessage.tsx | 3 +- .../src/components/internal/Prose.tsx | 32 +- .../src/primitives/Composer/index.tsx | 8 +- .../src/primitives/Composer/types.ts | 5 + .../src/primitives/Composer/utils.ts | 3 +- .../src/primitives/Markdown.tsx | 1015 +++++++++-------- .../primitives/__tests__/Markdown.test.tsx | 992 ++++++++++++++++ .../primitives/__tests__/Timestamp.test.tsx | 27 + .../src/primitives/__tests__/_utils.ts | 59 + .../src/styles/constants.css | 4 +- .../liveblocks-react-ui/src/styles/index.css | 15 +- .../src/utils/use-latest.ts | 18 - .../src/utils/use-observable.ts | 3 +- .../src/utils/use-visible.ts | 10 +- packages/liveblocks-react/src/_private.ts | 1 + 25 files changed, 2329 insertions(+), 710 deletions(-) create mode 100644 packages/liveblocks-react-ui/src/primitives/__tests__/Markdown.test.tsx create mode 100644 packages/liveblocks-react-ui/src/primitives/__tests__/Timestamp.test.tsx create mode 100644 packages/liveblocks-react-ui/src/primitives/__tests__/_utils.ts delete mode 100644 packages/liveblocks-react-ui/src/utils/use-latest.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e4cf34d8a51..8bea3cc92b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ ## vNEXT (not yet published) +## v3.2.0 + +### `@liveblocks/react-ui` + +- Improve `AiChat`'s scroll behavior when sending new messages: the chat will + now scroll new messages to the top and leave enough space for responses. +- Expose Markdown components in `AiChat`’s `components` prop to customize the + rendering of Markdown content. +- Add `blurOnSubmit` prop to `Composer` (also available on the `Composer.Form` + primitive and as `blurComposerOnSubmit` on `Thread`) to control whether a + composer should lose focus after being submitted. + +### `@liveblocks/react` + +- `useErrorListener` now receives `"LARGE_MESSAGE_ERROR"` errors when the + `largeMessageStrategy` option isn’t configured and a message couldn’t be sent + because it was too large for WebSocket. + +### `@liveblocks/node` + +- Add `tenantId` to `identifyUser` method as an optional parameter. + ## v3.1.4 ### `@liveblocks/react-ui` diff --git a/docs/pages/api-reference/liveblocks-react-ui.mdx b/docs/pages/api-reference/liveblocks-react-ui.mdx index 40d12e3a417..65bb9dde8ae 100644 --- a/docs/pages/api-reference/liveblocks-react-ui.mdx +++ b/docs/pages/api-reference/liveblocks-react-ui.mdx @@ -380,6 +380,39 @@ function Chat() { } ``` +##### Customize how Markdown is rendered [#AiChat-markdown] + +You can customize how Markdown is rendered in messages by passing components to +the `components` prop. A full list is [available here](#AiChat-components). + +```tsx +

{children}

, + + // Example: Use an existing component for quotes + Blockquote: ({ children }) => {children}, + + // Example: Use `next/link` instead of default `` tag + Link: ({ children, href }) => {children}, + + // Example: Use an external library to add syntax highlighting to code blocks + CodeBlock: ({ language, code }) => ( + {code} + ), + + // `Heading`, `Inline`, `List`, `Table`, `Image`, `Separator`, etc. + // ... + }, + // +++ + }} +/> +``` + ##### Props [#AiChat-props] @@ -414,7 +447,7 @@ function Chat() { Custom components to override specific parts of the chat UI, such as the - `Empty` placeholder component. + `Empty` placeholder component or Markdown components. CSS class name to apply to the chat container. @@ -425,6 +458,85 @@ function Chat() { +###### components [#AiChat-components] + +Override specific parts of `AiChat` with custom components. + + + + The component used to render the empty state of the chat. Defaults to + nothing. + + + The component used to render the loading state of the chat. Defaults to a + loading spinner. + + + The components used to render Markdown content. + + + The component used to render paragraphs. + + ReactNode`} + > + The component used to render inline elements (bold, italic, strikethrough, + and inline code). + + ReactNode`} + > + The component used to render links. + + ReactNode`} + > + The component used to render headings. + + + The component used to render blockquotes. + + ReactNode`} + > + The component used to render code blocks. + + ReactNode`} + > + The component used to render images. + + ReactNode`} + > + The component used to render lists. + + ReactNode`} + > + The component used to render tables. + + + The component used to render separators. + + + #### AiTool Displays [AI tool calls](/docs/ready-made-features/ai-copilots/features#tools) @@ -814,6 +926,13 @@ to update the property. > Whether to show the composer’s formatting controls. + + Whether to blur the composer editor when the composer is submitted. + The event handler called when the composer is submitted. + + Whether to blur the composer editor when the composer is submitted. + The composer’s initial value. @@ -1459,6 +1581,9 @@ occurs when the composer is submitted. You must create your own mutations within unsaved changes with Liveblocks, but not for this composer, you can opt-out this composer instance by setting this prop to `false`. + + Whether to blur the editor when the form is submitted. + + + The component used to display text mention notifications. Defaults to + `InboxNotification.TextMention`. + (array: T[], item: T) { @@ -240,66 +239,26 @@ const thread: ThreadData = { resolved: false, }; -describe("Components", () => { - describe("Thread", () => { - test("should render", () => { - const { container } = render(); +describe("Thread", () => { + test("should render", () => { + const { container } = render(); - expect(container).not.toBeEmptyDOMElement(); - }); - - test.todo( - "should not render deleted comments, unless the showDeletedComments prop is set" - ); - - test.todo("should support overrides"); - }); - - describe("Comment", () => { - test("should render", () => { - const { container } = render(); - - expect(container).not.toBeEmptyDOMElement(); - }); - - test.todo( - "should not render if deleted, unless the showDeleted prop is set" - ); - - test.todo("should support overrides"); + expect(container).not.toBeEmptyDOMElement(); }); +}); - describe("Composer", () => { - test("should render", () => { - const { container } = render(); - - expect(container).not.toBeEmptyDOMElement(); - }); +describe("Comment", () => { + test("should render", () => { + const { container } = render(); - test.todo("should support overrides"); + expect(container).not.toBeEmptyDOMElement(); }); }); -describe("Primitives", () => { - describe("Timestamp", () => { - const now = Date.now(); - - test("should render", () => { - const { container } = render(); - - expect(container).not.toBeEmptyDOMElement(); - }); - - test.todo("should have a datetime attribute"); - - test.todo("should rerender at an interval"); - - test.todo("should support changing the rerender interval"); - - test.todo("should support a title string"); - - test.todo("should support a title function"); +describe("Composer", () => { + test("should render", () => { + const { container } = render(); - test.todo("should support a children function"); + expect(container).not.toBeEmptyDOMElement(); }); }); diff --git a/packages/liveblocks-react-ui/src/_private/index.ts b/packages/liveblocks-react-ui/src/_private/index.ts index 04b6d81b472..4c61d5742fd 100644 --- a/packages/liveblocks-react-ui/src/_private/index.ts +++ b/packages/liveblocks-react-ui/src/_private/index.ts @@ -38,7 +38,11 @@ export type { MarkdownComponentsCodeBlockProps, MarkdownComponentsHeadingProps, MarkdownComponentsImageProps, + MarkdownComponentsInlineProps, MarkdownComponentsLinkProps, + MarkdownComponentsListProps, + MarkdownComponentsParagraphProps, + MarkdownComponentsTableProps, MarkdownProps, } from "../primitives/Markdown"; export { Markdown } from "../primitives/Markdown"; diff --git a/packages/liveblocks-react-ui/src/components/AiChat.tsx b/packages/liveblocks-react-ui/src/components/AiChat.tsx index 7686a8e3f4f..ab60b8f8c7a 100644 --- a/packages/liveblocks-react-ui/src/components/AiChat.tsx +++ b/packages/liveblocks-react-ui/src/components/AiChat.tsx @@ -9,10 +9,12 @@ import { RegisterAiTool, useAiChatMessages, } from "@liveblocks/react"; +import { useLatest } from "@liveblocks/react/_private"; import { type ComponentProps, type ComponentType, forwardRef, + type MutableRefObject, useEffect, useImperativeHandle, useRef, @@ -29,69 +31,82 @@ import { type GlobalOverrides, useOverrides, } from "../overrides"; +import type { MarkdownComponents } from "../primitives/Markdown"; import { cn } from "../utils/cn"; -import { useVisible } from "../utils/use-visible"; +import { useIntersectionCallback } from "../utils/use-visible"; import { AiChatAssistantMessage } from "./internal/AiChatAssistantMessage"; import { AiChatComposer } from "./internal/AiChatComposer"; import { AiChatUserMessage } from "./internal/AiChatUserMessage"; +/** + * The minimum number of pixels from the bottom of the scrollable area + * before showing the scroll to bottom indicator. + */ +const MIN_DISTANCE_BOTTOM_SCROLL_INDICATOR = 60; + export type AiChatComponentsEmptyProps = { /** - * The chat id provided to the `AiChat` component. + * The chat ID provided to the `AiChat` component. */ chatId: string; + /** - * The copilot id provided to the `AiChat` component. + * The copilot ID provided to the `AiChat` component. */ copilotId?: string; }; export type AiChatComponentsLoadingProps = Record; -// TODO: Add Markdown components export type AiChatComponents = { /** * The component used to render the empty state of the chat. */ Empty: ComponentType; + /** * The component used to render the loading state of the chat. */ Loading: ComponentType; -}; -/** - * The minimum number of pixels from the bottom of the scrollable area - * before showing the scroll to bottom indicator. - */ -const MIN_DISTANCE_BOTTOM_SCROLL_INDICATOR = 50; + /** + * The components used to render Markdown content. + */ + markdown?: Partial; +}; export interface AiChatProps extends ComponentProps<"div"> { /** - * The id of the chat the composer belongs to. + * The ID of the chat the composer belongs to. */ chatId: string; + /** * Whether to focus the chat composer on mount. */ autoFocus?: boolean; + /** - * The id of the copilot to use to send the message. + * The ID of the copilot to use to send the message. */ copilotId?: string; + /** * The contextual knowledge to include in the chat. May be used by the assistant when generating responses. * Any knowledge you provide via this prop will be added to any already globally registered knowledge via . */ knowledge?: AiKnowledgeSource[]; + /** * Tool definitions to make available within this chat. May be used by the assistant when generating responses. */ tools?: Record; + /** * The layout of the chat and its composer. */ layout?: "inset" | "compact"; + /** * Override the component's strings. */ @@ -101,12 +116,32 @@ export interface AiChatProps extends ComponentProps<"div"> { AiChatComposerOverrides & AiChatOverrides >; + /** * Override the component's components. */ components?: Partial; } +interface AiChatMessagesProps extends ComponentProps<"div"> { + messages: NonNullable["messages"]>; + copilotId: AiChatProps["copilotId"]; + overrides: AiChatProps["overrides"]; + components: AiChatProps["components"]; + lastSentMessageId: MessageId | null; + scrollToBottom: MutableRefObject< + (behavior: "instant" | "smooth", includeTrailingSpace?: boolean) => void + >; + onScrollAtBottomChange: MutableRefObject< + (isScrollAtBottom: boolean | null) => void + >; + containerRef: MutableRefObject; + footerRef: MutableRefObject; + messagesRef: MutableRefObject; + bottomTrailingMarkerRef: MutableRefObject; + trailingSpacerRef: MutableRefObject; +} + const defaultComponents: AiChatComponents = { Empty: () => null, Loading: () => ( @@ -116,6 +151,266 @@ const defaultComponents: AiChatComponents = { ), }; +const AiChatMessages = forwardRef( + ( + { + messages, + copilotId, + overrides, + components, + lastSentMessageId, + scrollToBottom, + onScrollAtBottomChange, + containerRef, + footerRef, + messagesRef, + bottomTrailingMarkerRef, + trailingSpacerRef, + className, + ...props + }, + forwardedRef + ) => { + const hasLastSentMessage = lastSentMessageId !== null; + + /** + * Every time the container, footer, or messages list change size, + * we calculate the trailing space that would allow the penultimate + * message to be at the top of the viewport, and apply it. + * + * ┌─────────────────────────────────────────┐▲ A = The `scroll-margin-top` + * │ ┌─────────────────────────┐ │▼▲ value of the penultimate message + * │ │ The penultimate message │ │ │ + * │ └─────────────────────────┘ │ │ B = The height from the top of + * │ │ │ the penultimate message to the + * │ ┌─────────────────────────┐ │ │ bottom of the messages list, + * │ │ The last message │ │ │ including the messages' heights, + * │ └─────────────────────────┘ │ │ and any padding, gap, etc + * │ │ │ + * ├ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┤▲▼ + * │ ││ The trailing space needed to + * │ = container height - (A + B + C) ││ allow the penultimate message + * │ ││ to be at the top of the viewport + * ├ ┬─────────────────────────────────────┬ ┤▼▲ + * │ │ │ │ │ + * │ │ │ │ │ C = The footer's height, + * │ │ │ │ │ including any padding + * │ └─────────────────────────────────────┘ │ │ + * └─────────────────────────────────────────┘ ▼ + */ + useEffect( + () => { + if (!hasLastSentMessage) { + return; + } + + const container = containerRef.current; + const footer = footerRef.current; + const messages = messagesRef.current; + + if (!container || !footer || !messages) { + return; + } + + const trailingSpacer = trailingSpacerRef.current; + const bottomTrailingMarker = bottomTrailingMarkerRef.current; + + let containerHeight: number | null = null; + let footerHeight: number | null = null; + let messagesHeight: number | null = null; + + const resetTrailingSpace = () => { + trailingSpacer?.style.removeProperty("height"); + bottomTrailingMarker?.style.removeProperty("top"); + }; + + const resizeObserver = new ResizeObserver((entries) => { + if (!trailingSpacer || !bottomTrailingMarker) { + return; + } + + const lastMessage = messages.lastElementChild; + const penultimateMessage = lastMessage?.previousElementSibling; + + // If there's no last pair of messages, there's no need for any trailing space. + if (!lastMessage || !penultimateMessage) { + resetTrailingSpace(); + return; + } + + // If the container's height is based on its content, the container isn't scrollable and there's no need for any trailing space. + if (container.scrollHeight === container.clientHeight) { + resetTrailingSpace(); + return; + } + + let updatedContainerHeight: number | null = containerHeight; + let updatedFooterHeight: number | null = footerHeight; + let updatedMessagesHeight: number | null = messagesHeight; + + for (const entry of entries) { + const entryHeight = + entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height; + + if (entry.target === container) { + updatedContainerHeight = entryHeight ?? null; + } else if (entry.target === footer) { + updatedFooterHeight = entryHeight ?? null; + } else if (entry.target === messages) { + updatedMessagesHeight = entryHeight ?? null; + } + } + + // If we don't have all the heights, we can't compute the trailing space. + if ( + updatedContainerHeight === null || + updatedFooterHeight === null || + updatedMessagesHeight === null + ) { + resetTrailingSpace(); + return; + } + + // If none of the heights have changed, we don't need to do anything. + if ( + updatedContainerHeight === containerHeight && + updatedFooterHeight === footerHeight && + updatedMessagesHeight === messagesHeight + ) { + return; + } + + // Now that we have compared them, we can update the heights. + containerHeight = updatedContainerHeight; + footerHeight = updatedFooterHeight; + messagesHeight = updatedMessagesHeight; + + // A + const penultimateMessageScrollMarginTop = Number.parseFloat( + getComputedStyle(penultimateMessage as HTMLElement).scrollMarginTop + ); + + // B + const messagesRect = messages.getBoundingClientRect(); + const penultimateMessageRect = + penultimateMessage.getBoundingClientRect(); + const heightFromPenultimateMessageTopToMessagesListBottom = + messagesRect.bottom - penultimateMessageRect.top; + + // A + B + C + const differenceHeight = + penultimateMessageScrollMarginTop + + heightFromPenultimateMessageTopToMessagesListBottom + + (footerHeight ?? 0); + + // = container height - (A + B + C) + const trailingSpace = Math.max(containerHeight - differenceHeight, 0); + + // Update the trailing space. + trailingSpacer.style.height = `${trailingSpace}px`; + + // Offset what "the bottom" is to the "scroll at the bottom" detection logic, + // so that it doesn't include the trailing space. + bottomTrailingMarker.style.top = `${-trailingSpace}px`; + }); + + resizeObserver.observe(container); + resizeObserver.observe(footer); + resizeObserver.observe(messages); + + return () => { + resizeObserver.disconnect(); + resetTrailingSpace(); + }; + }, + // This effect only uses stable refs. + [hasLastSentMessage] // eslint-disable-line react-hooks/exhaustive-deps + ); + + /** + * Update the "scroll at bottom" state when needed. + */ + useIntersectionCallback( + bottomTrailingMarkerRef, + (isIntersecting) => { + onScrollAtBottomChange.current(isIntersecting); + }, + { root: containerRef, rootMargin: MIN_DISTANCE_BOTTOM_SCROLL_INDICATOR } + ); + + /** + * Instantly scroll to the bottom for the initial state. + */ + useEffect( + () => { + scrollToBottom.current("instant"); + }, + // `scrollToBottom` is a stable ref containing the callback. + [] // eslint-disable-line react-hooks/exhaustive-deps + ); + + /** + * Scroll to new messages when sending them. + */ + useEffect( + () => { + if (lastSentMessageId) { + scrollToBottom.current("smooth", true); + } + }, + // `scrollToBottom` is a stable ref containing the callback. + [lastSentMessageId] // eslint-disable-line react-hooks/exhaustive-deps + ); + + /** + * Reset the "scroll at bottom" state when the component unmounts. + */ + useEffect( + () => { + const onScrollAtBottomChangeCallback = onScrollAtBottomChange.current; + + return () => { + onScrollAtBottomChangeCallback(null); + }; + }, + // `onScrollAtBottomChange` is a stable ref containing the callback. + [] // eslint-disable-line react-hooks/exhaustive-deps + ); + + return ( +
+ {messages.map((message) => { + if (message.role === "user") { + return ( + + ); + } else if (message.role === "assistant") { + return ( + + ); + } else { + return null; + } + })} +
+ ); + } +); + export const AiChat = forwardRef( ( { @@ -133,26 +428,27 @@ export const AiChat = forwardRef( forwardedRef ) => { const { messages, isLoading, error } = useAiChatMessages(chatId); + const [lastSentMessageId, setLastSentMessageId] = + useState(null); + const $ = useOverrides(overrides); const Empty = components?.Empty ?? defaultComponents.Empty; const Loading = components?.Loading ?? defaultComponents.Loading; const containerRef = useRef(null); - const containerBottomRef = useRef(null); - const isScrollIndicatorEnabled = !isLoading && !error; - const isScrollAtBottom = useVisible(containerBottomRef, { - enabled: isScrollIndicatorEnabled, - root: containerRef, - rootMargin: MIN_DISTANCE_BOTTOM_SCROLL_INDICATOR, - initialValue: null, - }); - const isScrollIndicatorVisible = - isScrollIndicatorEnabled && isScrollAtBottom !== null - ? !isScrollAtBottom - : false; + const messagesRef = useRef(null); + const footerRef = useRef(null); + const bottomMarkerRef = useRef(null); + const bottomTrailingMarkerRef = useRef(null); + const trailingSpacerRef = useRef(null); - const [lastSentMessageId, setLastSentMessageId] = - useState(null); + const [isScrollAtBottom, setScrollAtBottom] = useState( + null + ); + // `useState`'s setter is stable but this is for clarity in the places it's used. + const onScrollAtBottomChange = useLatest(setScrollAtBottom); + const isScrollIndicatorVisible = + messages && isScrollAtBottom !== null ? !isScrollAtBottom : false; useImperativeHandle( forwardedRef, @@ -160,22 +456,28 @@ export const AiChat = forwardRef( [] ); - const scrollToBottomCallbackRef = - useRef<(behavior: "instant" | "smooth") => void>(undefined); - if (scrollToBottomCallbackRef.current === undefined) { - scrollToBottomCallbackRef.current = function ( - behavior: "instant" | "smooth" - ) { - const container = containerRef.current; - if (container === null) return; - - container.scrollTo({ - top: container.scrollHeight, - behavior, - }); - }; - } - const scrollToBottom = scrollToBottomCallbackRef.current; + const scrollToBottom = useLatest( + (behavior: "instant" | "smooth", includeTrailingSpace = false) => { + if (includeTrailingSpace) { + // Scroll to the bottom marker to include the trailing space, + // and wait for a frame in case the trailing space hasn't + // been updated yet. (e.g. when sending a new message) + requestAnimationFrame(() => { + bottomMarkerRef.current?.scrollIntoView({ + behavior, + block: "end", + }); + }); + } else { + // Scroll to the trailing space marker to only scroll to the + // bottom of the messages, without including the trailing space. + bottomTrailingMarkerRef.current?.scrollIntoView({ + behavior, + block: "end", + }); + } + } + ); return (
( ) : ( <> - + + {/** + * This trailing spacer is used to extend the scrollable area beyond its actual + * content, to allow messages to appear at the top of the viewport for example. + */} +
-
- {messages.map((message) => { - if (message.role === "user") { - return ( - - ); - } else if (message.role === "assistant") { - return ( - - ); - } else { - return null; - } - })} -
)}
-
+
( className="lb-ai-chat-scroll-indicator-button" tabIndex={isScrollIndicatorVisible ? 0 : -1} aria-hidden={!isScrollIndicatorVisible} - onClick={() => scrollToBottom("smooth")} + onClick={() => scrollToBottom.current("smooth")} > @@ -280,41 +581,37 @@ export const AiChat = forwardRef( } />
- {/* This invisible element is a trick which allows us to use IntersectionObserver to detect when the + + {/** + * This invisible marker is a trick which allows us to use IntersectionObserver to detect when the * scrollable area is fully scrolled to the bottom instead of manually tracking the scroll position * and having to deal with resizes, etc. * * It's positioned at the bottom of the scrollable area and reliably only becomes "visible" to the - * IntersectionObserver when the scrollable area is fully scrolled. + * IntersectionObserver when the scrollable area is scrolled to the bottom. */} -
+ {messages && messages.length > 0 ? ( +
+ {/** + * This inner marker is absolutely offset by the same distance as the trailing space so its + * visibility means the scrollable area is at the bottom of the messages, not the full bottom. + */} +
+
+ ) : null}
); } ); - -function AutoScrollHandler({ - lastSentMessageId, - scrollToBottom, -}: { - lastSentMessageId: MessageId | null; - scrollToBottom: (behavior: "instant" | "smooth") => void; -}) { - // Scroll to bottom when the component first mounts - useEffect(() => { - scrollToBottom("instant"); - }, [scrollToBottom]); - - // Scroll to bottom when sending a new message - useEffect(() => { - if (lastSentMessageId === null) return; - scrollToBottom("smooth"); - }, [lastSentMessageId, scrollToBottom]); - - return null; -} diff --git a/packages/liveblocks-react-ui/src/components/Composer.tsx b/packages/liveblocks-react-ui/src/components/Composer.tsx index 2285894631a..9b18fa4fd83 100644 --- a/packages/liveblocks-react-ui/src/components/Composer.tsx +++ b/packages/liveblocks-react-ui/src/components/Composer.tsx @@ -186,6 +186,11 @@ export type ComposerProps = Omit< */ autoFocus?: ComposerEditorProps["autoFocus"]; + /** + * Whether to blur the composer editor when the composer is submitted. + */ + blurOnSubmit?: boolean; + /** * Override the component's strings. */ @@ -652,6 +657,7 @@ export const Composer = forwardRef( onFocus, autoFocus, disabled, + blurOnSubmit = true, showAttachments = true, showFormattingControls = true, showAttribution, @@ -815,6 +821,7 @@ export const Composer = forwardRef( defaultAttachments={defaultAttachments} pasteFilesAsAttachments={showAttachments} preventUnsavedChanges={preventUnsavedComposerChanges} + blurOnSubmit={blurOnSubmit} roomId={roomId} > */ showComposerFormattingControls?: ComposerProps["showFormattingControls"]; + /** + * Whether to blur the composer editor when the composer is submitted. + */ + blurComposerOnSubmit?: ComposerProps["blurOnSubmit"]; + /** * Whether to indent the comments' content. */ @@ -184,6 +189,7 @@ export const Thread = forwardRef( onMentionClick, onAttachmentClick, onComposerSubmit, + blurComposerOnSubmit, overrides, components, className, @@ -436,6 +442,7 @@ export const Thread = forwardRef( showAttachments={showAttachments} showFormattingControls={showComposerFormattingControls} onComposerSubmit={onComposerSubmit} + blurOnSubmit={blurComposerOnSubmit} overrides={{ COMPOSER_PLACEHOLDER: $.THREAD_COMPOSER_PLACEHOLDER, COMPOSER_SEND: $.THREAD_COMPOSER_SEND, diff --git a/packages/liveblocks-react-ui/src/components/internal/AiChatAssistantMessage.tsx b/packages/liveblocks-react-ui/src/components/internal/AiChatAssistantMessage.tsx index 373426422b1..c9202d761fe 100644 --- a/packages/liveblocks-react-ui/src/components/internal/AiChatAssistantMessage.tsx +++ b/packages/liveblocks-react-ui/src/components/internal/AiChatAssistantMessage.tsx @@ -8,7 +8,7 @@ import { useState, } from "react"; -import { ComponentsProvider, type GlobalComponents } from "../../components"; +import { type GlobalComponents } from "../../components"; import { ChevronRightIcon } from "../../icons/ChevronRight"; import { WarningIcon } from "../../icons/Warning"; import { @@ -25,12 +25,20 @@ import type { AiMessageContentToolInvocationPartProps, } from "../../primitives/AiMessage/types"; import * as Collapsible from "../../primitives/Collapsible"; +import type { MarkdownComponents } from "../../primitives/Markdown"; import { cn } from "../../utils/cn"; import { ErrorBoundary } from "../../utils/ErrorBoundary"; import { Prose } from "./Prose"; type UiAssistantMessage = WithNavigation; +type AiChatAssistantMessageComponents = { + /** + * The components used to render Markdown content. + */ + markdown?: Partial; +}; + /* ------------------------------------------------------------------------------------------------- * AiChatAssistantMessage * -----------------------------------------------------------------------------------------------*/ @@ -45,16 +53,24 @@ export interface AiChatAssistantMessageProps extends ComponentProps<"div"> { */ overrides?: Partial; - /** - * Override the component's components. - */ - components?: Partial; - /** * @internal * The id of the copilot to use to set tool call result. */ copilotId?: string; + + /** + * Override the component's components. + */ + components?: Partial; +} + +interface TextPartProps extends AiMessageContentTextPartProps { + components?: Partial; +} + +interface ReasoningPartProps extends AiMessageContentReasoningPartProps { + components?: Partial; } export const AiChatAssistantMessage = memo( @@ -85,24 +101,37 @@ export const AiChatAssistantMessage = memo( ); } else { children = ( - + ); } } else if (message.status === "completed") { children = ( - + ); } else if (message.status === "failed") { // Do not include the error message if the user aborted the request. if (message.errorReason === "Aborted by user") { children = ( - + ); } else { children = ( <> @@ -127,9 +156,7 @@ export const AiChatAssistantMessage = memo( ref={forwardedRef} > - - {children} - + {children}
); @@ -139,17 +166,21 @@ export const AiChatAssistantMessage = memo( function AssistantMessageContent({ message, + components, copilotId, }: { message: UiAssistantMessage; + components?: Partial; copilotId?: string; }) { return ( , + ReasoningPart: (props) => ( + + ), ToolInvocationPart, }} copilotId={copilotId} @@ -161,17 +192,20 @@ function AssistantMessageContent({ /* ------------------------------------------------------------------------------------------------- * TextPart * -----------------------------------------------------------------------------------------------*/ -function TextPart({ part }: AiMessageContentTextPartProps) { - return ; +function TextPart({ part, components }: TextPartProps) { + return ( + + ); } /* ------------------------------------------------------------------------------------------------- * ReasoningPart * -----------------------------------------------------------------------------------------------*/ -function ReasoningPart({ - part, - isStreaming, -}: AiMessageContentReasoningPartProps) { +function ReasoningPart({ part, isStreaming, components }: ReasoningPartProps) { // Start collapsed if reasoning is already done. const [isOpen, setIsOpen] = useState(isStreaming); const $ = useOverrides(); @@ -204,7 +238,7 @@ function ReasoningPart({ - + ); diff --git a/packages/liveblocks-react-ui/src/components/internal/AiChatUserMessage.tsx b/packages/liveblocks-react-ui/src/components/internal/AiChatUserMessage.tsx index 4b7b8cd0d8b..289e7b1efc2 100644 --- a/packages/liveblocks-react-ui/src/components/internal/AiChatUserMessage.tsx +++ b/packages/liveblocks-react-ui/src/components/internal/AiChatUserMessage.tsx @@ -36,7 +36,7 @@ function PlainTextPart({ part }: PlainTextPartProps) { export const AiChatUserMessage = memo( forwardRef( - ({ message, className, overrides }, forwardedRef) => { + ({ message, className, overrides, ...props }, forwardedRef) => { const $ = useOverrides(overrides); return (
{message.deletedAt !== undefined ? (
diff --git a/packages/liveblocks-react-ui/src/components/internal/Prose.tsx b/packages/liveblocks-react-ui/src/components/internal/Prose.tsx index 1c63532f193..a70d1362fc0 100644 --- a/packages/liveblocks-react-ui/src/components/internal/Prose.tsx +++ b/packages/liveblocks-react-ui/src/components/internal/Prose.tsx @@ -1,6 +1,10 @@ import type { ComponentProps } from "react"; -import { useComponents } from "../../components"; +import { + ComponentsProvider, + type GlobalComponents, + useComponents, +} from "../../components"; import type { MarkdownComponents, MarkdownComponentsCodeBlockProps, @@ -12,6 +16,9 @@ import { CodeBlock as DefaultCodeBlock } from "./CodeBlock"; interface ProseProps extends ComponentProps<"div"> { content: string; + components?: Partial< + GlobalComponents & { markdown?: Partial } + >; } function Link({ href, title, children }: MarkdownComponentsLinkProps) { @@ -28,7 +35,7 @@ function CodeBlock({ language, code }: MarkdownComponentsCodeBlockProps) { return ; } -const markdownComponents: Partial = { +const defaultMarkdownComponents: Partial = { Link, CodeBlock, }; @@ -37,13 +44,20 @@ const markdownComponents: Partial = { * This component renders Markdown content with `lb-prose` * styles and custom components (code blocks, etc) */ -export function Prose({ content, className, ...props }: ProseProps) { +export function Prose({ + content, + components, + className, + ...props +}: ProseProps) { return ( - + + + ); } diff --git a/packages/liveblocks-react-ui/src/primitives/Composer/index.tsx b/packages/liveblocks-react-ui/src/primitives/Composer/index.tsx index 8cd3856b23f..ccfabed26f7 100644 --- a/packages/liveblocks-react-ui/src/primitives/Composer/index.tsx +++ b/packages/liveblocks-react-ui/src/primitives/Composer/index.tsx @@ -1247,6 +1247,7 @@ const ComposerForm = forwardRef( onComposerSubmit, defaultAttachments = [], pasteFilesAsAttachments, + blurOnSubmit = true, preventUnsavedChanges = true, disabled, asChild, @@ -1460,10 +1461,13 @@ const ComposerForm = forwardRef( const onSubmitEnd = useCallback(() => { clear(); - blur(); clearAttachments(); setSubmitting(false); - }, [blur, clear, clearAttachments]); + + if (blurOnSubmit) { + blur(); + } + }, [blur, blurOnSubmit, clear, clearAttachments]); const handleSubmit = useCallback( (event: FormEvent) => { diff --git a/packages/liveblocks-react-ui/src/primitives/Composer/types.ts b/packages/liveblocks-react-ui/src/primitives/Composer/types.ts index 2ac1f071121..6616fc26a88 100644 --- a/packages/liveblocks-react-ui/src/primitives/Composer/types.ts +++ b/packages/liveblocks-react-ui/src/primitives/Composer/types.ts @@ -165,6 +165,11 @@ export interface ComposerFormProps extends ComponentPropsWithSlot<"form"> { */ pasteFilesAsAttachments?: boolean; + /** + * Whether to blur the editor when the form is submitted. + */ + blurOnSubmit?: boolean; + /** * When `preventUnsavedChanges` is set on your Liveblocks client (or set on * ), then closing a browser tab will be prevented when diff --git a/packages/liveblocks-react-ui/src/primitives/Composer/utils.ts b/packages/liveblocks-react-ui/src/primitives/Composer/utils.ts index d3f7b6de2ba..368efa95c74 100644 --- a/packages/liveblocks-react-ui/src/primitives/Composer/utils.ts +++ b/packages/liveblocks-react-ui/src/primitives/Composer/utils.ts @@ -32,7 +32,7 @@ import { makeEventSource, } from "@liveblocks/core"; import { useClient } from "@liveblocks/react"; -import { useLayoutEffect } from "@liveblocks/react/_private"; +import { useLatest, useLayoutEffect } from "@liveblocks/react/_private"; import type { DragEvent } from "react"; import { useCallback, @@ -57,7 +57,6 @@ import type { import { getFiles } from "../../utils/data-transfer"; import { exists } from "../../utils/exists"; import { useInitial } from "../../utils/use-initial"; -import { useLatest } from "../../utils/use-latest"; import { isText } from "../slate/utils/is-text"; import { useComposer, useComposerAttachmentsContext } from "./contexts"; import { isComposerBodyAutoLink } from "./slate/plugins/auto-links"; diff --git a/packages/liveblocks-react-ui/src/primitives/Markdown.tsx b/packages/liveblocks-react-ui/src/primitives/Markdown.tsx index a88ff67c353..fbf9ea73223 100644 --- a/packages/liveblocks-react-ui/src/primitives/Markdown.tsx +++ b/packages/liveblocks-react-ui/src/primitives/Markdown.tsx @@ -1,6 +1,6 @@ -import { sanitizeUrl } from "@liveblocks/core"; +import { assertNever, sanitizeUrl } from "@liveblocks/core"; import { Slot } from "@radix-ui/react-slot"; -import { Lexer, type Tokens } from "marked"; +import { Lexer, type MarkedToken, type Token, type Tokens } from "marked"; import { type ComponentType, forwardRef, @@ -12,19 +12,256 @@ import { import type { ComponentPropsWithSlot } from "../types"; export type MarkdownComponents = { - CodeBlock: ComponentType; + /** + * The component used to render paragraphs. + * + * @example + * ```md + * A paragraph. + * + * Another paragraph. + * ``` + * ```tsx + *

{children}

+ * }} + * /> + * ``` + */ + Paragraph: ComponentType; + + /** + * The component used to render inline elements (bold, italic, strikethrough, and inline code). + * + * @example + * ```md + * **Bold**, _italic_, ~~strikethrough~~, and `inline code`. + * ``` + * ```tsx + * { + * const Component = type; + * return {children}; + * } + * }} + * /> + * ``` + */ + Inline: ComponentType; + + /** + * The component used to render links. + * + * @example + * ```md + * A [link](https://liveblocks.io). + * ``` + * ```tsx + *
{children} + * }} + * /> + * ``` + */ Link: ComponentType; + + /** + * The component used to render headings. + * + * @example + * ```md + * # Heading 1 + * ## Heading 2 + * ### Heading 3 + * ``` + * ```tsx + * { + * const Heading = `h${level}` as const; + * return {children}; + * } + * }} + * /> + * ``` + */ Heading: ComponentType; - Image: ComponentType; + + /** + * The component used to render blockquotes. + * + * @example + * ```md + * > A blockquote. + * ``` + * ```tsx + *
{children}
+ * }} + * /> + * ``` + */ Blockquote: ComponentType; - // Paragraph - // Inline (text, strong, em, code, del) - // Table - // List - // Separator (hr) + /** + * The component used to render code blocks. + * + * @example + * ```md + * `⁠`⁠`javascript + * const a = 1; + * `⁠`⁠` + * ``` + * ```tsx + * ( + *
+   *         {code}
+   *       
+ * ) + * }} + * /> + * ``` + */ + CodeBlock: ComponentType; + + /** + * The component used to render images. + * + * @example + * ```md + * ![An image](https://liveblocks.io/logo.svg) + * ``` + * ```tsx + * {alt} + * }} + * /> + * ``` + */ + Image: ComponentType; + + /** + * The component used to render lists. + * + * @example + * ```md + * 1. An ordered list item + * - An unordered list item + * - [x] A checked list item + * ``` + * ```tsx + * { + * const List = type === "ordered" ? "ol" : "ul"; + * return ( + * + * {items.map((item, index) => ( + *
  • + * {item.checked !== undefined && ( + * {" "} + * )} + * {item.children} + *
  • + * ))} + *
    + * ); + * } + * }} + * /> + * ``` + */ + List: ComponentType; + + /** + * The component used to render tables. + * + * @example + * ```md + * | Heading 1 | Heading 2 | + * |-----------|-----------| + * | Cell 1 | Cell 2 | + * | Cell 3 | Cell 4 | + * ``` + * ```tsx + * ( + * + * + * + * {headings.map(({ children }, index) => ( + * + * ))} + * + * + * + * {rows.map((row, index) => ( + * + * {row.map(({ children }, index) => ( + * + * ))} + * + * ))} + * + *
    {children}
    {children}
    + * ) + * }} + * /> + * ``` + */ + Table: ComponentType; + + /** + * The component used to render separators. + * + * @example + * ```md + * --- + * ``` + * ```tsx + *
    }} /> + * ``` + */ + Separator: ComponentType; }; +export interface MarkdownComponentsInlineProps { + type: "strong" | "em" | "code" | "del"; + children: ReactNode; +} + +export interface MarkdownComponentsParagraphProps { + children: ReactNode; +} + +interface MarkdownComponentsTableCell { + align?: "left" | "center" | "right"; + children: ReactNode; +} + +export interface MarkdownComponentsTableProps { + headings: MarkdownComponentsTableCell[]; + rows: MarkdownComponentsTableCell[][]; +} + +interface MarkdownComponentsListItem { + checked?: boolean; + children: ReactNode; +} + +export interface MarkdownComponentsListProps { + type: "ordered" | "unordered"; + items: MarkdownComponentsListItem[]; +} + export interface MarkdownComponentsBlockquoteProps { children: ReactNode; } @@ -56,52 +293,24 @@ export interface MarkdownProps extends ComponentPropsWithSlot<"div"> { components?: Partial; } -/** - * Block level tokens include: - * - space - * - code - * - blockquote - * - html - * - heading - * - hr - * - list - * - paragraph - * - table - */ -export type BlockToken = - | Tokens.Space - | Tokens.Code - | Tokens.Blockquote - | Tokens.HTML - | Tokens.Heading - | Tokens.Hr - | Tokens.List - | Tokens.Paragraph - | Tokens.Table; - -/** - * Inline tokens include: - * - strong - * - em - * - codespan - * - br - * - del - * - link - * - image - * - text - */ -type InlineToken = - | Tokens.Strong - | Tokens.Em - | Tokens.Codespan - | Tokens.Br - | Tokens.Del - | Tokens.Link - | Tokens.Image - | Tokens.Text - | Tokens.Escape; - const defaultComponents: MarkdownComponents = { + Paragraph: ({ children }) => { + return

    {children}

    ; + }, + Inline: ({ type, children }) => { + switch (type) { + case "strong": + return {children}; + case "em": + return {children}; + case "code": + return {children}; + case "del": + return {children}; + default: + assertNever(type, "Unknown inline type"); + } + }, CodeBlock: ({ language, code }) => { return (
    @@ -127,6 +336,59 @@ const defaultComponents: MarkdownComponents = {
       Blockquote: ({ children }) => {
         return 
    {children}
    ; }, + Table: ({ headings, rows }) => { + return ( + + + + {headings.map((heading, index) => { + return ( + + ); + })} + + + + {rows.map((row, index) => { + return ( + + {row.map((cell, index) => { + return ( + + ); + })} + + ); + })} + +
    + {heading.children} +
    + {cell.children} +
    + ); + }, + List: ({ type, items }) => { + const List = type === "ordered" ? "ol" : "ul"; + + return ( + + {items.map((item, index) => ( +
  • + {item.checked !== undefined && ( + <> + {" "} + + )} + {item.children} +
  • + ))} +
    + ); + }, + Separator: () => { + return
    ; + }, }; export const Markdown = forwardRef( @@ -140,8 +402,8 @@ export const Markdown = forwardRef( {tokens.map((token, index) => { return ( - @@ -152,481 +414,206 @@ export const Markdown = forwardRef( } ); -const MemoizedMarkdownBlockToken = memo( +const MemoizedMarkdownToken = memo( ({ token, components, }: { - token: BlockToken; + token: Token; components?: Partial; }) => { - return ; + return ; }, - (prevProps, nextProps) => { - const prevToken = prevProps.token; + (previousProps, nextProps) => { + const previousToken = previousProps.token; const nextToken = nextProps.token; - if (prevToken.raw.length !== nextToken.raw.length) { + + if (previousToken.raw.length !== nextToken.raw.length) { return false; } - if (prevToken.type !== nextToken.type) { + + if (previousToken.type !== nextToken.type) { return false; } - return prevToken.raw === nextToken.raw; + + return previousToken.raw === nextToken.raw; } ); -export function MarkdownBlockToken({ +export function MarkdownToken({ token, components, }: { - token: BlockToken; + token: Token; components: Partial | undefined; }) { + // Marked.js supports generic tokens, but we don't use them. + if (!isMarkedToken(token)) { + return null; + } + switch (token.type) { + case "escape": { + return token.text; + } + case "space": { return null; } - case "code": { - let language: string | undefined = undefined; - if (token.lang !== undefined) { - language = token.lang.match(/^\S*/)?.[0] ?? undefined; - } - const CodeBlock = components?.CodeBlock ?? defaultComponents.CodeBlock; + case "text": { + if (token.tokens !== undefined) { + return ; + } else { + return parseHtmlEntities(token.text); + } + } - return ; + case "br": { + return
    ; } - case "blockquote": { - const tokens: BlockToken[] = []; - for (let i = 0; i < token.tokens.length; i++) { - switch (token.tokens[i]!.type) { - case "space": - case "code": - case "blockquote": - case "html": - case "heading": - case "hr": - case "list": - case "paragraph": - case "table": { - tokens.push(token.tokens[i] as BlockToken); - break; - } - case "text": { - const texts: Tokens.Text[] = [token.tokens[i] as Tokens.Text]; - while ( - i + 1 < token.tokens.length && - token.tokens[i + 1]!.type === "text" - ) { - i++; - texts.push(token.tokens[i] as Tokens.Text); - } - tokens.push({ - type: "paragraph", - tokens: texts, - raw: texts.map((text) => text.raw).join(""), - text: texts.map((text) => text.text).join(""), - } satisfies Tokens.Paragraph); - break; - } - default: { - continue; - } - } - } - const Blockquote = components?.Blockquote ?? defaultComponents.Blockquote; + case "paragraph": { + const Paragraph = components?.Paragraph ?? defaultComponents.Paragraph; return ( -
    - {tokens.map((token, index) => { - return ( - - ); - })} -
    + + + ); } - case "html": { - return token.text; - } + case "heading": { const Heading = components?.Heading ?? defaultComponents.Heading; return ( - {token.tokens.map((token, index) => ( - - ))} + ); } - case "hr": { - return
    ; - } - case "list": { - const ListTag = token.ordered ? "ol" : "ul"; - return ( - - {token.items.map((item, index) => { - // A 'loose' list item in Markdown is one where the content is wrapped in a paragraph (or potentially other block) token - if (item.loose) { - // If the list item is a task list item, we need to add a checkbox to the start of the token - if (item.task) { - const tokens = [...item.tokens]; - if (tokens[0]?.type === "paragraph") { - const token = tokens[0] as Tokens.Paragraph; - token.tokens.unshift( - { - type: "checkbox", - checked: item.checked, - raw: "", - }, - { - type: "text", - text: " ", - raw: " ", - escaped: false, - } - ); - } else { - tokens.unshift( - { - type: "checkbox", - checked: item.checked, - raw: "", - }, - { - type: "text", - text: " ", - raw: " ", - escaped: false, - } - ); - } - - const items: BlockToken[] = []; - for (let i = 0; i < tokens.length; i++) { - switch (tokens[i]!.type) { - case "space": - case "code": - case "blockquote": - case "html": - case "heading": - case "hr": - case "list": - case "paragraph": - case "table": { - items.push(tokens[i] as BlockToken); - break; - } - case "text": - case "checkbox": { - const texts: ( - | Tokens.Text - | { - type: "checkbox"; - checked: boolean; - raw: string; - text: string; - } - )[] = [ - tokens[i] as - | Tokens.Text - | { - type: "checkbox"; - checked: boolean; - raw: string; - text: string; - }, - ]; - while ( - i + 1 < tokens.length && - tokens[i + 1]!.type === "text" - ) { - i++; - texts.push(tokens[i] as Tokens.Text); - } - items.push({ - type: "paragraph", - tokens: texts, - raw: texts.map((text) => text.raw).join(""), - text: texts.map((text) => text.text).join(""), - } satisfies Tokens.Paragraph); - break; - } - default: { - continue; - } - } - } - - return ( -
  • - {items.map((token, index) => { - return ( - - ); - })} -
  • - ); - } else { - const tokens: BlockToken[] = []; - for (let i = 0; i < item.tokens.length; i++) { - switch (item.tokens[i]!.type) { - case "space": - case "code": - case "blockquote": - case "html": - case "heading": - case "hr": - case "list": - case "paragraph": - case "table": { - tokens.push(item.tokens[i] as BlockToken); - break; - } - case "text": { - const texts: Tokens.Text[] = [ - item.tokens[i] as Tokens.Text, - ]; - while ( - i + 1 < item.tokens.length && - item.tokens[i + 1]!.type === "text" - ) { - i++; - texts.push(item.tokens[i] as Tokens.Text); - } - tokens.push({ - type: "paragraph", - tokens: texts, - raw: texts.map((text) => text.raw).join(""), - text: texts.map((text) => text.text).join(""), - } satisfies Tokens.Paragraph); - break; - } - default: { - continue; - } - } - } - - return ( -
  • - {tokens.map((token, index) => { - return ( - - ); - })} -
  • - ); - } - } else { - const Items: ReactNode = item.tokens.map((token, index) => { - switch (token.type) { - case "space": - case "code": - case "blockquote": - case "html": - case "heading": - case "hr": - case "list": - case "paragraph": - case "table": { - return ( - - ); - } - case "text": { - return ( - - ); - } - default: { - return null; - } - } - }); - if (item.task) { - return ( -
  • - {" "} - {Items} -
  • - ); - } else { - return
  • {Items}
  • ; - } - } - })} -
    - ); - } - case "paragraph": { - return ( -

    - {token.tokens.map((token, index) => ( - - ))} -

    - ); - } - case "table": { - return ( - - - - {token.header.map((cell, index) => { - return ( - - ); - })} - - - - {token.rows.map((row, index) => { - return ( - - {row.map((cell, index) => { - return ( - - ); - })} - - ); - })} - -
    - {cell.tokens.map((token, index) => ( - - ))} -
    - {cell.tokens.map((token, index) => ( - - ))} -
    - ); - } - } -} - -function MarkdownInlineToken({ - token, - components, -}: { - token: InlineToken | { type: "checkbox"; checked: boolean }; - components: Partial | undefined; -}) { - switch (token.type) { case "strong": { + const Inline = components?.Inline ?? defaultComponents.Inline; + return ( - - {token.tokens.map((token, index) => ( - - ))} - + + + ); } + case "em": { + const Inline = components?.Inline ?? defaultComponents.Inline; + return ( - - {token.tokens.map((token, index) => ( - - ))} - + + + ); } + case "codespan": { - return {parseHtmlEntities(token.text)}; - } - case "br": { - return
    ; + const Inline = components?.Inline ?? defaultComponents.Inline; + + return {parseHtmlEntities(token.text)}; } + case "del": { + const Inline = components?.Inline ?? defaultComponents.Inline; + return ( - - {token.tokens.map((token, index) => ( - - ))} - + + + ); } + case "link": { const href = sanitizeUrl(token.href); if (href === null) { - return token.tokens.map((token, index) => ( - - )); + return ; } const Link = components?.Link ?? defaultComponents.Link; return ( - {token.tokens.map((token, index) => ( - + + ); + } + + case "code": { + let language: string | undefined = undefined; + if (token.lang !== undefined) { + language = token.lang.match(/^\S*/)?.[0] ?? undefined; + } + + const CodeBlock = components?.CodeBlock ?? defaultComponents.CodeBlock; + + return ; + } + + case "blockquote": { + const Blockquote = components?.Blockquote ?? defaultComponents.Blockquote; + + return ( +
    + +
    + ); + } + + case "list": { + const List = components?.List ?? defaultComponents.List; + const items: MarkdownComponentsListItem[] = token.items.map((item) => { + return { + checked: item.task ? item.checked : undefined, + children: ( + - ))} - + ), + }; + }); + + return ( + ); } + + case "table": { + const Table = components?.Table ?? defaultComponents.Table; + const headings: MarkdownComponentsTableCell[] = token.header.map( + (cell) => ({ + align: cell.align ?? undefined, + children: ( + + ), + }) + ); + + const rows: MarkdownComponentsTableCell[][] = token.rows.map((row) => + row.map((cell) => ({ + align: cell.align ?? undefined, + children: ( + + ), + })) + ); + + return ; + } + case "image": { const href = sanitizeUrl(token.href); @@ -640,31 +627,99 @@ function MarkdownInlineToken({ ); } - case "text": { - if (token.tokens !== undefined) { - return token.tokens.map((token, index) => ( - - )); - } else { - return parseHtmlEntities(token.text); - } - } - case "escape": { - return token.text; - } - case "checkbox": { - return ; + + case "hr": { + const Separator = components?.Separator ?? defaultComponents.Separator; + + return ; } + + // HTML elements/tokens are not supported (yet). + case "html": default: { return null; } } } +function MarkdownTokens({ + tokens, + components, + normalizeToBlockTokens = false, +}: { + tokens: Token[]; + components: Partial | undefined; + normalizeToBlockTokens?: boolean; +}) { + const normalizedTokens: Token[] = []; + + if (normalizeToBlockTokens) { + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]!; + + switch (token.type) { + case "text": { + // Wrap consecutive text tokens into a paragraph + const texts: Tokens.Text[] = [token as Tokens.Text]; + while (i + 1 < tokens.length && tokens[i + 1]!.type === "text") { + i++; + texts.push(tokens[i] as Tokens.Text); + } + + normalizedTokens.push({ + type: "paragraph", + tokens: texts, + raw: texts.map((text) => text.raw).join(""), + text: texts.map((text) => text.text).join(""), + } satisfies Tokens.Paragraph); + + break; + } + + default: { + normalizedTokens.push(token); + } + } + } + } + + return tokens.map((token, index) => ( + + )); +} + +const markedTokenTypes = [ + "blockquote", + "br", + "code", + "codespan", + "def", + "del", + "em", + "escape", + "heading", + "hr", + "html", + "image", + "link", + "list", + "list_item", + "paragraph", + "space", + "strong", + "table", + "text", +] as const satisfies MarkedToken["type"][]; + +function isMarkedToken(token: unknown): token is MarkedToken { + return ( + typeof token === "object" && + token !== null && + "type" in token && + markedTokenTypes.includes(token.type as MarkedToken["type"]) + ); +} + function parseHtmlEntities(input: string) { const document = new DOMParser().parseFromString( `${input}`, diff --git a/packages/liveblocks-react-ui/src/primitives/__tests__/Markdown.test.tsx b/packages/liveblocks-react-ui/src/primitives/__tests__/Markdown.test.tsx new file mode 100644 index 00000000000..3998c1687e5 --- /dev/null +++ b/packages/liveblocks-react-ui/src/primitives/__tests__/Markdown.test.tsx @@ -0,0 +1,992 @@ +import "@testing-library/jest-dom"; + +import { render } from "@testing-library/react"; + +import { Markdown, type MarkdownComponents } from "../Markdown"; +import { dedent } from "./_utils"; + +describe("Markdown", () => { + test.each([ + { + description: "paragraphs", + content: dedent` + A paragraph. + + Another paragraph which + spans multiple lines. + `, + assertions: (element) => { + const paragraphs = element.querySelectorAll("p"); + + expect(paragraphs).toHaveLength(2); + expect(paragraphs[0]).toHaveTextContent("A paragraph."); + expect(paragraphs[1]).toHaveTextContent( + "Another paragraph which spans multiple lines." + ); + }, + }, + { + description: "headings", + content: dedent` + # Heading 1 + + ## Heading 2 + + ### Heading 3 + + #### Heading 4 + + ##### Heading 5 + + ###### Heading 6 + + Alternate heading 1 + =============== + + Alternate heading 2 + -------------- + `, + assertions: (element) => { + const headings = element.querySelectorAll("h1, h2, h3, h4, h5, h6"); + + expect(headings).toHaveLength(8); + expect(headings[0]).toHaveTextContent("Heading 1"); + expect(headings[1]).toHaveTextContent("Heading 2"); + expect(headings[2]).toHaveTextContent("Heading 3"); + expect(headings[3]).toHaveTextContent("Heading 4"); + expect(headings[4]).toHaveTextContent("Heading 5"); + expect(headings[5]).toHaveTextContent("Heading 6"); + expect(headings[6]).toHaveTextContent("Alternate heading 1"); + expect(headings[7]).toHaveTextContent("Alternate heading 2"); + }, + }, + { + description: "bold text", + content: dedent` + **Bold** and __bold__. + `, + assertions: (element) => { + const strongs = element.querySelectorAll("strong"); + + expect(strongs).toHaveLength(2); + expect(strongs[0]).toHaveTextContent("Bold"); + expect(strongs[1]).toHaveTextContent("bold"); + }, + }, + { + description: "italic text", + content: dedent` + *Italic* and _italic_. + `, + assertions: (element) => { + const ems = element.querySelectorAll("em"); + + expect(ems).toHaveLength(2); + expect(ems[0]).toHaveTextContent("Italic"); + expect(ems[1]).toHaveTextContent("italic"); + }, + }, + { + description: "strikethrough text", + content: dedent` + ~~Strikethrough~~. + `, + assertions: (element) => { + const dels = element.querySelectorAll("del"); + + expect(dels).toHaveLength(1); + expect(dels[0]).toHaveTextContent("Strikethrough"); + }, + }, + { + description: "inline code", + content: dedent` + Inline \`code\`. + `, + assertions: (element) => { + const codes = element.querySelectorAll("code"); + + expect(codes).toHaveLength(1); + expect(codes[0]).toHaveTextContent("code"); + }, + }, + { + description: "links", + content: dedent` + A [link](https://www.liveblocks.io), [another one](/docs "With a title"), + https://www.liveblocks.io, and . + `, + assertions: (element) => { + const links = element.querySelectorAll("a"); + + expect(links).toHaveLength(4); + expect(links[0]).toHaveAttribute("href", "https://www.liveblocks.io"); + expect(links[1]).toHaveAttribute("href", "/docs"); + expect(links[2]).toHaveAttribute("href", "https://www.liveblocks.io"); + expect(links[3]).toHaveAttribute("href", "https://www.liveblocks.io"); + }, + }, + { + description: "ordered lists", + content: dedent` + 1. A list item + 2. Another list item + 3. Yet another list item + `, + assertions: (element) => { + const list = element.querySelector("ol"); + const listItems = list?.querySelectorAll("li"); + + expect(list).not.toBeNull(); + expect(listItems).toHaveLength(3); + expect(listItems?.[0]).toHaveTextContent("A list item"); + expect(listItems?.[1]).toHaveTextContent("Another list item"); + expect(listItems?.[2]).toHaveTextContent("Yet another list item"); + }, + }, + { + description: "unordered lists", + content: dedent` + - A list item + - Another list item + - Yet another list item + + * A list item + * Another list item + * Yet another list item + + + A list item + + Another list item + + Yet another list item + `, + assertions: (element) => { + const lists = element.querySelectorAll("ul"); + + lists.forEach((list) => { + const listItems = list.querySelectorAll("li"); + + expect(listItems).toHaveLength(3); + expect(listItems?.[0]).toHaveTextContent("A list item"); + expect(listItems?.[1]).toHaveTextContent("Another list item"); + expect(listItems?.[2]).toHaveTextContent("Yet another list item"); + }); + }, + }, + { + description: "task lists", + content: dedent` + - [ ] A list item + - [x] Another list item + - [ ] Yet another list item + `, + assertions: (element) => { + const list = element.querySelector("ul"); + const listItems = list?.querySelectorAll("li"); + + expect(list).not.toBeNull(); + expect(listItems).toHaveLength(3); + expect(listItems?.[0]).toHaveTextContent("A list item"); + expect( + listItems?.[0]?.querySelector("input[type='checkbox']") + ).not.toBeChecked(); + expect(listItems?.[1]).toHaveTextContent("Another list item"); + expect( + listItems?.[1]?.querySelector("input[type='checkbox']") + ).toBeChecked(); + expect(listItems?.[2]).toHaveTextContent("Yet another list item"); + expect( + listItems?.[2]?.querySelector("input[type='checkbox']") + ).not.toBeChecked(); + }, + }, + { + description: "mixed lists", + content: dedent` + - A list item + 1. A nested list item + - Another nested list item + - [ ] A deeply nested list item + - Another list item + 1. A nested list item + 2. [x] Another nested list item + `, + assertions: (element) => { + const rootList = element.querySelector(":scope > ul"); + expect(rootList).toBeInTheDocument(); + + const rootListItems = element.querySelectorAll(":scope > ul > li"); + expect(rootListItems).toHaveLength(2); + + // - A list item + // 1. A nested list item + // - Another nested list item + // - [ ] A deeply nested list item + const firstNestedLists = element?.querySelectorAll( + ":scope > ul > li:nth-child(1) > :is(ul, ol)" + ); + expect(firstNestedLists).toHaveLength(2); + + // 1. A nested list item + const firstNestedFirstList = firstNestedLists?.[0]; + expect(firstNestedFirstList?.tagName).toBe("OL"); + expect(firstNestedFirstList?.childNodes).toHaveLength(1); + expect(firstNestedFirstList?.childNodes[0]?.textContent).toBe( + "A nested list item" + ); + + // - Another nested list item + // - [ ] A deeply nested list item + const firstNestedSecondList = firstNestedLists?.[1]; + expect(firstNestedSecondList?.tagName).toBe("UL"); + expect(firstNestedSecondList?.childNodes).toHaveLength(1); + expect( + firstNestedSecondList?.childNodes[0]?.childNodes[0] + ).toHaveTextContent("Another nested list item"); + + // - [ ] A deeply nested list item + const firstNestedSecondListFirstNestedList = firstNestedSecondList + ?.childNodes[0]?.childNodes[1] as HTMLElement | null; + expect(firstNestedSecondListFirstNestedList).toHaveTextContent( + "A deeply nested list item" + ); + expect(firstNestedSecondListFirstNestedList?.tagName).toBe("UL"); + expect( + firstNestedSecondListFirstNestedList?.querySelector( + "input[type='checkbox']" + ) + ).not.toBeChecked(); + + // - Another list item + // 1. A nested list item + // 2. [x] Another nested list item + const secondNestedList = element?.querySelector( + ":scope > ul > li:nth-child(2) > :is(ul, ol)" + ); + expect(secondNestedList?.tagName).toBe("OL"); + expect(secondNestedList?.childNodes).toHaveLength(2); + expect(secondNestedList?.childNodes[0]).toHaveTextContent( + "A nested list item" + ); + expect(secondNestedList?.childNodes[1]).toHaveTextContent( + "Another nested list item" + ); + expect( + ( + secondNestedList?.childNodes[1] as HTMLElement | null + )?.querySelector("input[type='checkbox']") + ).toBeChecked(); + }, + }, + { + description: "loose lists", + content: dedent` + - A list item + + - Another list item with + + multiple paragraphs. + + - [x] A task list item with + + > a quote and a code block + + \`\`\` + const a = 2; + \`\`\` + `, + assertions: (element) => { + const list = element.querySelector("ul"); + expect(list).toBeInTheDocument(); + + const listItems = list?.querySelectorAll("li"); + expect(listItems).toHaveLength(3); + + expect(listItems?.[0]).toHaveTextContent("A list item"); + + expect(listItems?.[1]).toHaveTextContent( + "Another list item withmultiple paragraphs." + ); + + expect(listItems?.[2]).toHaveTextContent("A task list item with"); + expect( + listItems?.[2]?.querySelector("input[type='checkbox']") + ).toBeChecked(); + expect(listItems?.[2]?.querySelector("blockquote")).toHaveTextContent( + "a quote and a code block" + ); + expect(listItems?.[2]?.querySelector("pre")).toHaveTextContent( + "const a = 2;" + ); + }, + }, + { + description: "blockquotes", + content: dedent` + > A blockquote. + + > Another one which spans + > + > multiple paragraphs. + + > Yet another which + > + > > is nested. + `, + assertions: (element) => { + const blockquotes = element.querySelectorAll(":scope >blockquote"); + + expect(blockquotes).toHaveLength(3); + expect(blockquotes[0]).toHaveTextContent("A blockquote."); + + const blockquote1Paragraphs = blockquotes[1]?.querySelectorAll("p"); + expect(blockquote1Paragraphs).toHaveLength(2); + expect(blockquote1Paragraphs?.[0]).toHaveTextContent( + "Another one which spans" + ); + expect(blockquote1Paragraphs?.[1]).toHaveTextContent( + "multiple paragraphs." + ); + + expect(blockquotes[2]?.childNodes[0]).toHaveTextContent( + "Yet another which" + ); + + const blockquote2NestedBlockquote = blockquotes[2] + ?.childNodes[1] as HTMLElement | null; + expect(blockquote2NestedBlockquote?.tagName).toBe("BLOCKQUOTE"); + expect(blockquote2NestedBlockquote?.childNodes[0]).toHaveTextContent( + "is nested." + ); + }, + }, + { + description: "code blocks", + content: dedent` + \`\`\` + p { + color: #000; + } + \`\`\` + + \`\`\`javascript + const a = 2; + \`\`\` + `, + assertions: (element) => { + const codeBlocks = element.querySelectorAll("pre"); + expect(codeBlocks).toHaveLength(2); + + expect(codeBlocks[0]?.textContent).toBe("p {\n color: #000;\n}"); + expect(codeBlocks[1]?.textContent).toBe("const a = 2;"); + }, + }, + { + description: "images", + content: dedent` + ![An image](https://www.liveblocks.io/favicon.png) + `, + assertions: (element) => { + const images = element.querySelectorAll("img"); + + expect(images).toHaveLength(1); + expect(images[0]).toHaveAttribute( + "src", + "https://www.liveblocks.io/favicon.png" + ); + }, + }, + { + description: "tables", + content: dedent` + | A column heading | Another column heading | + |------------------|------------------------| + | A cell | Another cell | + | A cell | Another cell | + `, + assertions: (element) => { + const table = element.querySelector("table"); + expect(table).toBeInTheDocument(); + + const tableHeadings = table?.querySelectorAll("th"); + expect(tableHeadings).toHaveLength(2); + expect(tableHeadings?.[0]).toHaveTextContent("A column heading"); + expect(tableHeadings?.[1]).toHaveTextContent("Another column heading"); + + const tableRows = table?.querySelectorAll("tbody tr"); + expect(tableRows).toHaveLength(2); + + tableRows?.forEach((row) => { + const cells = row.querySelectorAll("td"); + + expect(cells).toHaveLength(2); + expect(cells?.[0]).toHaveTextContent("A cell"); + expect(cells?.[1]).toHaveTextContent("Another cell"); + }); + }, + }, + { + description: "horizontal rules", + content: dedent` + *** + + --- + + _____ + `, + assertions: (element) => { + const horizontalRules = element.querySelectorAll("hr"); + + expect(horizontalRules).toHaveLength(3); + }, + }, + { + description: "escaped characters", + content: dedent` + \\*Not italic\\* and \\[not a link\\]\\(https://liveblocks.io\). + `, + assertions: (element) => { + expect(element).toHaveTextContent( + "*Not italic* and [not a link](https://liveblocks.io)." + ); + }, + }, + { + description: "HTML entities", + content: dedent` + <p> & + + > " ' + + - © ™ + `, + assertions: (element) => { + expect(element.querySelector("p")).toHaveTextContent("

    &"); + expect(element.querySelector("blockquote")).toHaveTextContent("\" '"); + expect(element.querySelector("li")).toHaveTextContent("© ™"); + }, + }, + { + description: "HTML elements (as plain text)", + content: dedent` + The abbreviation for HyperText Markup Language is HTML. + + Press Ctrl + C to copy. + + This is highlighted text. + + E = mc2 + `, + assertions: (element) => { + const paragraphs = element.querySelectorAll("p"); + expect(paragraphs).toHaveLength(4); + + expect(paragraphs[0]).toHaveTextContent( + "The abbreviation for HyperText Markup Language is HTML." + ); + expect(paragraphs[1]).toHaveTextContent("Press Ctrl + C to copy."); + expect(paragraphs[2]).toHaveTextContent("This is highlighted text."); + expect(paragraphs[3]).toHaveTextContent("E = mc2"); + }, + }, + ] satisfies { + description: string; + content: string; + assertions: (element: HTMLElement) => void; + }[])("should render $description", ({ content, assertions }) => { + const { getByTestId } = render( + + ); + + assertions(getByTestId("markdown")); + }); + + test("should rerender when the content changes", () => { + const { getByTestId, rerender } = render( + + ); + + const element = getByTestId("markdown"); + + expect(element).toHaveTextContent("This is a [link](ht"); + expect(element.querySelector("a")).not.toBeInTheDocument(); + + rerender( + + ); + + expect(element).toHaveTextContent("This is a link."); + expect(element.querySelector("a")).toHaveAttribute( + "href", + "https://liveblocks.io" + ); + }); + + test.each([ + { + description: "paragraphs", + content: dedent`A paragraph.`, + components: { + Paragraph: ({ children }) =>

    {children}

    , + }, + assertions: (element) => { + const paragraph = element.querySelector("p"); + + expect(paragraph).toBeInTheDocument(); + expect(paragraph).toHaveTextContent("A paragraph."); + }, + }, + { + description: "headings", + content: dedent` + # Heading 1 + + ## Heading 2 + + ### Heading 3 + + #### Heading 4 + + ##### Heading 5 + + ###### Heading 6 + `, + components: { + Heading: ({ children, level }) => { + const Heading = `h${level}` as const; + + return {children}; + }, + }, + assertions: (element) => { + const headings = element.querySelectorAll("h1, h2, h3, h4, h5, h6"); + expect(headings).toHaveLength(6); + + headings.forEach((heading, index) => { + expect(heading).toHaveAttribute("data-heading", String(index + 1)); + expect(heading).toHaveTextContent(`Heading ${index + 1}`); + }); + }, + }, + { + description: "inline elements", + content: dedent` + This is **bold text**, _italic text_, **_bold and italic_**, ~~strikethrough~~, \`inline code\`, and **\`bold inline code\`**. + `, + components: { + Inline: ({ children, type }) => { + const Inline = type; + + return {children}; + }, + }, + assertions: (element) => { + const strongs = element.querySelectorAll("strong"); + expect(strongs).toHaveLength(3); + expect(strongs[0]).toHaveTextContent("bold text"); + expect(strongs[0]).toHaveAttribute("data-inline", "strong"); + expect(strongs[1]).toHaveTextContent("bold and italic"); + expect(strongs[1]).toHaveAttribute("data-inline", "strong"); + expect(strongs[2]).toHaveTextContent("bold inline code"); + expect(strongs[2]).toHaveAttribute("data-inline", "strong"); + + const italics = element.querySelectorAll("em"); + expect(italics).toHaveLength(2); + expect(italics[0]).toHaveTextContent("italic text"); + expect(italics[0]).toHaveAttribute("data-inline", "em"); + expect(italics[1]).toHaveTextContent("bold and italic"); + expect(italics[1]).toHaveAttribute("data-inline", "em"); + + const strikethroughs = element.querySelectorAll("del"); + expect(strikethroughs).toHaveLength(1); + expect(strikethroughs[0]).toHaveTextContent("strikethrough"); + expect(strikethroughs[0]).toHaveAttribute("data-inline", "del"); + + const codes = element.querySelectorAll("code"); + expect(codes).toHaveLength(2); + expect(codes[0]).toHaveTextContent("inline code"); + expect(codes[0]).toHaveAttribute("data-inline", "code"); + expect(codes[1]).toHaveTextContent("bold inline code"); + expect(codes[1]).toHaveAttribute("data-inline", "code"); + }, + }, + { + description: "links", + content: dedent` + A [link](https://www.liveblocks.io), [another one](/docs "With a title"), + https://www.liveblocks.io, and . + `, + components: { + Link: ({ href, title, children }) => ( + + {children} + + ), + }, + assertions: (element) => { + const links = element.querySelectorAll("a"); + expect(links).toHaveLength(4); + + expect(links[0]).toHaveAttribute("data-link"); + expect(links[0]).toHaveAttribute("href", "https://www.liveblocks.io"); + expect(links[0]).toHaveTextContent("link"); + + expect(links[1]).toHaveAttribute("data-link"); + expect(links[1]).toHaveAttribute("href", "/docs"); + expect(links[1]).toHaveTextContent("another one"); + expect(links[1]).toHaveAttribute("title", "With a title"); + + expect(links[2]).toHaveAttribute("data-link"); + expect(links[2]).toHaveAttribute("href", "https://www.liveblocks.io"); + expect(links[2]).toHaveTextContent("https://www.liveblocks.io"); + + expect(links[3]).toHaveAttribute("data-link"); + expect(links[3]).toHaveAttribute("href", "https://www.liveblocks.io"); + expect(links[3]).toHaveTextContent("https://www.liveblocks.io"); + }, + }, + { + description: "lists", + content: dedent` + - A list item + 1. A list item + 2. Another list item + 3. Yet another list item + - Another list item + - [ ] A task list item + - [x] A completed task list item + - [x] Another completed task list item + - Yet another list item + 1. A list item + * Another list item + + [x] Yet another list item + `, + components: { + List: ({ items, type }) => { + const List = type === "ordered" ? "ol" : "ul"; + + return ( + + {items.map((item, index) => ( +
  • + {item.checked !== undefined && ( + <> + {" "} + + )} + {item.children} +
  • + ))} +
    + ); + }, + }, + assertions: (element) => { + const rootList = element.querySelector(":scope > ul"); + expect(rootList).toHaveAttribute("data-list"); + + const rootListItems = element.querySelectorAll(":scope > ul > li"); + expect(rootListItems).toHaveLength(3); + + expect(rootListItems[0]).toHaveTextContent("A list item"); + + const firstNestedList = rootListItems[0]?.querySelector("ol"); + expect(firstNestedList).toHaveAttribute("data-list"); + + const firstNestedListItems = firstNestedList?.querySelectorAll("li"); + expect(firstNestedListItems).toHaveLength(3); + + expect(firstNestedListItems?.[0]).toHaveTextContent("A list item"); + expect(firstNestedListItems?.[1]).toHaveTextContent( + "Another list item" + ); + expect(firstNestedListItems?.[2]).toHaveTextContent( + "Yet another list item" + ); + + const secondRootListItem = rootListItems[1]; + expect(secondRootListItem).toHaveTextContent("Another list item"); + + const secondNestedList = secondRootListItem?.querySelector("ul"); + expect(secondNestedList).toHaveAttribute("data-list"); + + const secondNestedListItems = secondNestedList?.querySelectorAll("li"); + expect(secondNestedListItems).toHaveLength(3); + + expect(secondNestedListItems?.[0]).toHaveTextContent( + "A task list item" + ); + expect( + secondNestedListItems?.[0]?.querySelector("input[type='checkbox']") + ).not.toBeChecked(); + expect(secondNestedListItems?.[1]).toHaveTextContent( + "A completed task list item" + ); + expect( + secondNestedListItems?.[1]?.querySelector("input[type='checkbox']") + ).toBeChecked(); + expect(secondNestedListItems?.[2]).toHaveTextContent( + "Another completed task list item" + ); + expect( + secondNestedListItems?.[2]?.querySelector("input[type='checkbox']") + ).toBeChecked(); + + const thirdRootListItem = rootListItems[2]; + expect(thirdRootListItem).toHaveTextContent("Yet another list item"); + + const thirdNestedLists = thirdRootListItem?.querySelectorAll("ol, ul"); + expect(thirdNestedLists).toHaveLength(3); + + expect(thirdNestedLists?.[0]).toHaveAttribute("data-list", "ordered"); + expect(thirdNestedLists?.[0]?.querySelector("li")).toHaveTextContent( + "A list item" + ); + + expect(thirdNestedLists?.[1]).toHaveAttribute("data-list", "unordered"); + expect(thirdNestedLists?.[1]?.querySelector("li")).toHaveTextContent( + "Another list item" + ); + + expect(thirdNestedLists?.[2]).toHaveAttribute("data-list", "unordered"); + expect(thirdNestedLists?.[2]?.querySelector("li")).toHaveTextContent( + "Yet another list item" + ); + expect( + thirdNestedLists?.[2]?.querySelector("input[type='checkbox']") + ).toBeChecked(); + }, + }, + { + description: "blockquotes", + content: dedent` + > A blockquote. + + > Another one which spans + > + > multiple paragraphs. + + > Yet another which + > + > > is nested. + `, + components: { + Blockquote: ({ children }) => ( +
    {children}
    + ), + }, + assertions: (element) => { + const blockquotes = element.querySelectorAll("blockquote"); + expect(blockquotes).toHaveLength(4); + + expect(blockquotes[0]).toHaveTextContent("A blockquote."); + expect(blockquotes[0]).toHaveAttribute("data-blockquote"); + expect(blockquotes[1]).toHaveTextContent( + "Another one which spansmultiple paragraphs." + ); + expect(blockquotes[1]).toHaveAttribute("data-blockquote"); + expect(blockquotes[2]).toHaveTextContent("Yet another whichis nested."); + expect(blockquotes[2]).toHaveAttribute("data-blockquote"); + expect(blockquotes[3]).toHaveTextContent("is nested."); + expect(blockquotes[3]).toHaveAttribute("data-blockquote"); + }, + }, + { + description: "code blocks", + content: dedent` + \`\`\` + p { + color: #000; + } + \`\`\` + + \`\`\`javascript + const a = 2; + \`\`\` + `, + components: { + CodeBlock: ({ code, language }) => ( +
    +            {code}
    +          
    + ), + }, + assertions: (element) => { + const codeBlocks = element.querySelectorAll("pre"); + expect(codeBlocks).toHaveLength(2); + + expect(codeBlocks[0]).toHaveTextContent("p { color: #000; }"); + expect(codeBlocks[0]).toHaveAttribute("data-code-block"); + expect(codeBlocks[1]).toHaveTextContent("const a = 2;"); + expect(codeBlocks[1]).toHaveAttribute("data-code-block"); + expect(codeBlocks[1]?.querySelector("code")).toHaveAttribute( + "data-language", + "javascript" + ); + }, + }, + { + description: "images", + content: dedent` + ![An image](https://www.liveblocks.io/favicon.png) + `, + components: { + Image: ({ src, alt }) => {alt}, + }, + assertions: (element) => { + const image = element.querySelector("img"); + + expect(image).toBeInTheDocument(); + expect(image).toHaveAttribute( + "src", + "https://www.liveblocks.io/favicon.png" + ); + expect(image).toHaveAttribute("alt", "An image"); + }, + }, + { + description: "tables", + content: dedent` + | Feature | Example | Notes | + | ------------- | ------------------------------------ | ------------------------- | + | Link | [Liveblocks](https://liveblocks.io/) | External link | + | Inline code | \`const a = 2;\` | Code inside table | + | Bold text | **Important** | Styling test | + | Italic text | _Emphasis_ | Test italic inside tables | + | Strikethrough | ~~Deprecated~~ | Show removal | + `, + components: { + Table: ({ headings, rows }) => ( +
    + + + {headings.map((heading, index) => ( + + ))} + + + + {rows.map((row, index) => ( + + {row.map((cell, index) => ( + + ))} + + ))} + +
    {heading.children}
    {cell.children}
    + ), + }, + assertions: (element) => { + const table = element.querySelector("table"); + + expect(table).toBeInTheDocument(); + expect(table).toHaveAttribute("data-table"); + + const headings = table?.querySelectorAll("th"); + expect(headings).toHaveLength(3); + + expect(headings?.[0]).toHaveTextContent("Feature"); + expect(headings?.[1]).toHaveTextContent("Example"); + expect(headings?.[2]).toHaveTextContent("Notes"); + + const rows = table?.querySelectorAll("tbody tr"); + expect(rows).toHaveLength(5); + + const firstRowCells = rows?.[0]?.querySelectorAll("td"); + expect(firstRowCells).toHaveLength(3); + + expect(firstRowCells?.[0]).toHaveTextContent("Link"); + expect(firstRowCells?.[1]).toHaveTextContent("Liveblocks"); + expect(firstRowCells?.[1]?.querySelector("a")).toHaveAttribute( + "href", + "https://liveblocks.io/" + ); + expect(firstRowCells?.[2]).toHaveTextContent("External link"); + + const secondRowCells = rows?.[1]?.querySelectorAll("td"); + expect(secondRowCells).toHaveLength(3); + + expect(secondRowCells?.[0]).toHaveTextContent("Inline code"); + expect(secondRowCells?.[1]).toHaveTextContent("const a = 2;"); + expect(secondRowCells?.[1]?.querySelector("code")).toHaveTextContent( + "const a = 2;" + ); + expect(secondRowCells?.[2]).toHaveTextContent("Code inside table"); + + const thirdRowCells = rows?.[2]?.querySelectorAll("td"); + expect(thirdRowCells).toHaveLength(3); + + expect(thirdRowCells?.[0]).toHaveTextContent("Bold text"); + expect(thirdRowCells?.[1]).toHaveTextContent("Important"); + expect(thirdRowCells?.[1]?.querySelector("strong")).toHaveTextContent( + "Important" + ); + expect(thirdRowCells?.[2]).toHaveTextContent("Styling test"); + + const fourthRowCells = rows?.[3]?.querySelectorAll("td"); + expect(fourthRowCells).toHaveLength(3); + + expect(fourthRowCells?.[0]).toHaveTextContent("Italic text"); + expect(fourthRowCells?.[1]).toHaveTextContent("Emphasis"); + expect(fourthRowCells?.[1]?.querySelector("em")).toHaveTextContent( + "Emphasis" + ); + expect(fourthRowCells?.[2]).toHaveTextContent( + "Test italic inside tables" + ); + + const fifthRowCells = rows?.[4]?.querySelectorAll("td"); + expect(fifthRowCells).toHaveLength(3); + + expect(fifthRowCells?.[0]).toHaveTextContent("Strikethrough"); + expect(fifthRowCells?.[1]).toHaveTextContent("Deprecated"); + expect(fifthRowCells?.[1]?.querySelector("del")).toHaveTextContent( + "Deprecated" + ); + expect(fifthRowCells?.[2]).toHaveTextContent("Show removal"); + }, + }, + { + description: "separators", + content: dedent` + *** + + --- + + _____ + `, + components: { + Separator: () =>
    , + }, + assertions: (element) => { + const separators = element.querySelectorAll("hr"); + expect(separators).toHaveLength(3); + + separators.forEach((separator) => { + expect(separator).toHaveAttribute("data-separator"); + }); + }, + }, + ] satisfies { + description: string; + content: string; + components: Partial; + assertions: (element: HTMLElement) => void; + }[])( + "should support overriding $description", + ({ content, components, assertions }) => { + const { getByTestId } = render( + + ); + + assertions(getByTestId("markdown")); + } + ); +}); diff --git a/packages/liveblocks-react-ui/src/primitives/__tests__/Timestamp.test.tsx b/packages/liveblocks-react-ui/src/primitives/__tests__/Timestamp.test.tsx new file mode 100644 index 00000000000..cd99ee94e03 --- /dev/null +++ b/packages/liveblocks-react-ui/src/primitives/__tests__/Timestamp.test.tsx @@ -0,0 +1,27 @@ +import "@testing-library/jest-dom"; + +import { render } from "@testing-library/react"; + +import { Timestamp } from "../Timestamp"; + +describe("Timestamp", () => { + const now = Date.now(); + + test("should render", () => { + const { container } = render(); + + expect(container).not.toBeEmptyDOMElement(); + }); + + test.todo("should have a datetime attribute"); + + test.todo("should rerender at an interval"); + + test.todo("should support changing the rerender interval"); + + test.todo("should support a title string"); + + test.todo("should support a title function"); + + test.todo("should support a children function"); +}); diff --git a/packages/liveblocks-react-ui/src/primitives/__tests__/_utils.ts b/packages/liveblocks-react-ui/src/primitives/__tests__/_utils.ts new file mode 100644 index 00000000000..899fdaa1097 --- /dev/null +++ b/packages/liveblocks-react-ui/src/primitives/__tests__/_utils.ts @@ -0,0 +1,59 @@ +export function dedent(strings: TemplateStringsArray, ...values: any[]): string; +export function dedent(string: string): string; +export function dedent( + stringOrTemplate: TemplateStringsArray | string, + ...values: any[] +): string { + let string: string; + + if (Array.isArray(stringOrTemplate) && "raw" in stringOrTemplate) { + string = stringOrTemplate.reduce( + (result, string, index) => result + string + (values[index] || ""), + "" + ); + } else { + string = stringOrTemplate as string; + } + + let lines = string.split("\n"); + + const firstNonEmptyLine = lines.findIndex((line) => line.trim().length > 0); + + if (firstNonEmptyLine === -1) { + return ""; + } + + const lastNonEmptyLine = [...lines] + .reverse() + .findIndex((line) => line.trim().length > 0); + + lines = lines.slice(firstNonEmptyLine, lines.length - lastNonEmptyLine); + + let minIndent = Infinity; + + for (const line of lines) { + const lineIndent = line.search(/\S/); + + if (lineIndent !== -1) { + minIndent = Math.min(minIndent, lineIndent); + } + } + + if (minIndent === Infinity) { + minIndent = 0; + } + + return lines + .map((line) => { + if (line.trim().length === 0) { + return ""; + } + + if (line.length < minIndent) { + return line; + } + + return line.substring(minIndent); + }) + .join("\n"); +} diff --git a/packages/liveblocks-react-ui/src/styles/constants.css b/packages/liveblocks-react-ui/src/styles/constants.css index 7042c69e7b4..86af959e4a9 100644 --- a/packages/liveblocks-react-ui/src/styles/constants.css +++ b/packages/liveblocks-react-ui/src/styles/constants.css @@ -12,8 +12,8 @@ $lb-elevation-shadow: 0 8px 26px rgb(0 0 0 / 12%); $lb-elevation-shadow-moderate: 0 0 0 1px rgb(0 0 0 / 4%), - 0 2px 6px rgb(0 0 0 / 6%), - 0 8px 26px rgb(0 0 0 / 8%); + 0 2px 6px rgb(0 0 0 / 5%), + 0 8px 26px rgb(0 0 0 / 6%); $lb-tooltip-shadow: 0 2px 4px rgb(0 0 0 / 8%), 0 4px 12px rgb(0 0 0 / 12%); diff --git a/packages/liveblocks-react-ui/src/styles/index.css b/packages/liveblocks-react-ui/src/styles/index.css index b509ab3b749..d3033b4afc0 100644 --- a/packages/liveblocks-react-ui/src/styles/index.css +++ b/packages/liveblocks-react-ui/src/styles/index.css @@ -626,6 +626,18 @@ > :where(ol, ul) { margin-block-start: 0.25em; } + + > :where(p) { + margin-block: 0.25em; + } + + > :where(p:first-child) { + margin-block-start: 0; + } + + > :where(p:last-child) { + margin-block-end: 0; + } } :where(a) { @@ -2492,6 +2504,7 @@ flex-direction: column; gap: var(--lb-spacing); color: var(--lb-foreground); + scroll-margin-block-start: calc(1.5 * var(--lb-spacing)); } .lb-ai-chat-user-message { @@ -2568,7 +2581,7 @@ .lb-ai-chat-messages { display: flex; flex-direction: column; - gap: var(--lb-spacing); + gap: calc(1.5 * var(--lb-spacing)); inline-size: 100%; max-inline-size: min( calc(var(--lb-ai-chat-container-width) - var(--lb-spacing)), diff --git a/packages/liveblocks-react-ui/src/utils/use-latest.ts b/packages/liveblocks-react-ui/src/utils/use-latest.ts deleted file mode 100644 index a5db55a6d00..00000000000 --- a/packages/liveblocks-react-ui/src/utils/use-latest.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { MutableRefObject } from "react"; -import { useEffect, useRef } from "react"; - -/** - * Keeps a ref in sync with a given value that may or may not change on - * every render. - * - * The purpose of this hook is to return a stable ref that can be passed - * to a callback function so the callback can be registered but still can - * access the latest value at a later point in time. - */ -export function useLatest(value: T): MutableRefObject { - const ref = useRef(value); - useEffect(() => { - ref.current = value; - }, [value]); - return ref; -} diff --git a/packages/liveblocks-react-ui/src/utils/use-observable.ts b/packages/liveblocks-react-ui/src/utils/use-observable.ts index eac42dbca7b..7ededd68c21 100644 --- a/packages/liveblocks-react-ui/src/utils/use-observable.ts +++ b/packages/liveblocks-react-ui/src/utils/use-observable.ts @@ -1,8 +1,7 @@ import type { Observable } from "@liveblocks/core"; +import { useLatest } from "@liveblocks/react/_private"; import { useEffect } from "react"; -import { useLatest } from "./use-latest"; - export function useObservable( observable: Observable, callback: () => void diff --git a/packages/liveblocks-react-ui/src/utils/use-visible.ts b/packages/liveblocks-react-ui/src/utils/use-visible.ts index bc5f291ae2e..f8f13c86b3c 100644 --- a/packages/liveblocks-react-ui/src/utils/use-visible.ts +++ b/packages/liveblocks-react-ui/src/utils/use-visible.ts @@ -1,10 +1,10 @@ +import { useLatest } from "@liveblocks/react/_private"; import { type RefObject, useEffect, useState } from "react"; -import { useLatest } from "./use-latest"; - interface ObserveOptions { rootMargin?: string | number; root?: RefObject; + threshold?: number | number[]; } interface Options extends ObserveOptions { @@ -62,6 +62,7 @@ function observe( typeof options.rootMargin === "number" ? `${options.rootMargin}px` : options.rootMargin, + threshold: options.threshold, } ); @@ -94,7 +95,7 @@ export function useIntersectionCallback( ) { const enabled = options?.enabled ?? true; const latestCallback = useLatest(callback); - const { root, rootMargin } = options ?? {}; + const { root, rootMargin, threshold } = options ?? {}; useEffect(() => { const element = ref.current; @@ -106,6 +107,7 @@ export function useIntersectionCallback( const observeOptions: ObserveOptions = { root, rootMargin, + threshold, }; if (enabled) { @@ -125,7 +127,7 @@ export function useIntersectionCallback( return () => { unobserve(element, observeOptions); }; - }, [ref, enabled, latestCallback, root, rootMargin]); + }, [ref, enabled, latestCallback, root, rootMargin, threshold]); } /** diff --git a/packages/liveblocks-react/src/_private.ts b/packages/liveblocks-react/src/_private.ts index e417d6e7e9d..f7d7116f4f3 100644 --- a/packages/liveblocks-react/src/_private.ts +++ b/packages/liveblocks-react/src/_private.ts @@ -1,6 +1,7 @@ // Private APIs export { useClientOrNull } from "./contexts"; +export { useLatest } from "./lib/use-latest"; export { useLayoutEffect } from "./lib/use-layout-effect"; export { getUmbrellaStoreForClient } from "./liveblocks"; export { From 7902be88a9778493c9961808dd6266e7ee669d76 Mon Sep 17 00:00:00 2001 From: GitHub Actions Bot <> Date: Wed, 23 Jul 2025 07:21:05 +0000 Subject: [PATCH 2/2] Bump to 3.2.0 --- package-lock.json | 98 +++++++++---------- packages/liveblocks-client/package.json | 4 +- packages/liveblocks-core/package.json | 2 +- packages/liveblocks-emails/package.json | 6 +- packages/liveblocks-node-lexical/package.json | 6 +- .../liveblocks-node-prosemirror/package.json | 6 +- packages/liveblocks-node/package.json | 4 +- .../liveblocks-react-blocknote/package.json | 14 +-- .../liveblocks-react-lexical/package.json | 12 +-- packages/liveblocks-react-tiptap/package.json | 12 +-- packages/liveblocks-react-ui/package.json | 8 +- packages/liveblocks-react/package.json | 6 +- packages/liveblocks-redux/package.json | 6 +- packages/liveblocks-yjs/package.json | 6 +- packages/liveblocks-zustand/package.json | 6 +- 15 files changed, 98 insertions(+), 98 deletions(-) diff --git a/package-lock.json b/package-lock.json index a9379161e69..b37713af5b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37305,10 +37305,10 @@ }, "packages/liveblocks-client": { "name": "@liveblocks/client", - "version": "3.1.4", + "version": "3.2.0", "license": "Apache-2.0", "dependencies": { - "@liveblocks/core": "3.1.4" + "@liveblocks/core": "3.2.0" }, "devDependencies": { "@liveblocks/eslint-config": "*", @@ -37317,7 +37317,7 @@ }, "packages/liveblocks-core": { "name": "@liveblocks/core", - "version": "3.1.4", + "version": "3.2.0", "license": "Apache-2.0", "devDependencies": { "@liveblocks/eslint-config": "*", @@ -37335,11 +37335,11 @@ }, "packages/liveblocks-emails": { "name": "@liveblocks/emails", - "version": "3.1.4", + "version": "3.2.0", "license": "Apache-2.0", "dependencies": { - "@liveblocks/core": "3.1.4", - "@liveblocks/node": "3.1.4" + "@liveblocks/core": "3.2.0", + "@liveblocks/node": "3.2.0" }, "devDependencies": { "@liveblocks/eslint-config": "*", @@ -37453,10 +37453,10 @@ }, "packages/liveblocks-node": { "name": "@liveblocks/node", - "version": "3.1.4", + "version": "3.2.0", "license": "Apache-2.0", "dependencies": { - "@liveblocks/core": "3.1.4", + "@liveblocks/core": "3.2.0", "@stablelib/base64": "^1.0.1", "fast-sha256": "^1.3.0", "node-fetch": "^2.6.1" @@ -37471,11 +37471,11 @@ }, "packages/liveblocks-node-lexical": { "name": "@liveblocks/node-lexical", - "version": "3.1.4", + "version": "3.2.0", "license": "Apache-2.0", "dependencies": { - "@liveblocks/core": "3.1.4", - "@liveblocks/node": "3.1.4", + "@liveblocks/core": "3.2.0", + "@liveblocks/node": "3.2.0", "yjs": "^13.6.18" }, "devDependencies": { @@ -37492,11 +37492,11 @@ }, "packages/liveblocks-node-prosemirror": { "name": "@liveblocks/node-prosemirror", - "version": "3.1.4", + "version": "3.2.0", "license": "Apache-2.0", "dependencies": { - "@liveblocks/core": "3.1.4", - "@liveblocks/node": "3.1.4", + "@liveblocks/core": "3.2.0", + "@liveblocks/node": "3.2.0", "yjs": "^13.6.20" }, "devDependencies": { @@ -37706,11 +37706,11 @@ }, "packages/liveblocks-react": { "name": "@liveblocks/react", - "version": "3.1.4", + "version": "3.2.0", "license": "Apache-2.0", "dependencies": { - "@liveblocks/client": "3.1.4", - "@liveblocks/core": "3.1.4" + "@liveblocks/client": "3.2.0", + "@liveblocks/core": "3.2.0" }, "devDependencies": { "@liveblocks/eslint-config": "*", @@ -37740,15 +37740,15 @@ }, "packages/liveblocks-react-blocknote": { "name": "@liveblocks/react-blocknote", - "version": "3.1.4", + "version": "3.2.0", "license": "Apache-2.0", "dependencies": { - "@liveblocks/client": "3.1.4", - "@liveblocks/core": "3.1.4", - "@liveblocks/react": "3.1.4", - "@liveblocks/react-tiptap": "3.1.4", - "@liveblocks/react-ui": "3.1.4", - "@liveblocks/yjs": "3.1.4", + "@liveblocks/client": "3.2.0", + "@liveblocks/core": "3.2.0", + "@liveblocks/react": "3.2.0", + "@liveblocks/react-tiptap": "3.2.0", + "@liveblocks/react-ui": "3.2.0", + "@liveblocks/yjs": "3.2.0", "@tiptap/core": "^2.7.2", "vitest-tsconfig-paths": "^3.4.1" }, @@ -37785,15 +37785,15 @@ }, "packages/liveblocks-react-lexical": { "name": "@liveblocks/react-lexical", - "version": "3.1.4", + "version": "3.2.0", "license": "Apache-2.0", "dependencies": { "@floating-ui/react-dom": "^2.1.1", - "@liveblocks/client": "3.1.4", - "@liveblocks/core": "3.1.4", - "@liveblocks/react": "3.1.4", - "@liveblocks/react-ui": "3.1.4", - "@liveblocks/yjs": "3.1.4", + "@liveblocks/client": "3.2.0", + "@liveblocks/core": "3.2.0", + "@liveblocks/react": "3.2.0", + "@liveblocks/react-ui": "3.2.0", + "@liveblocks/yjs": "3.2.0", "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-toggle": "^1.1.0", "yjs": "^13.6.18" @@ -38322,15 +38322,15 @@ }, "packages/liveblocks-react-tiptap": { "name": "@liveblocks/react-tiptap", - "version": "3.1.4", + "version": "3.2.0", "license": "Apache-2.0", "dependencies": { "@floating-ui/react-dom": "^2.1.2", - "@liveblocks/client": "3.1.4", - "@liveblocks/core": "3.1.4", - "@liveblocks/react": "3.1.4", - "@liveblocks/react-ui": "3.1.4", - "@liveblocks/yjs": "3.1.4", + "@liveblocks/client": "3.2.0", + "@liveblocks/core": "3.2.0", + "@liveblocks/react": "3.2.0", + "@liveblocks/react-ui": "3.2.0", + "@liveblocks/yjs": "3.2.0", "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-toggle": "^1.1.0", "@tiptap/core": "^2.7.2", @@ -38855,13 +38855,13 @@ }, "packages/liveblocks-react-ui": { "name": "@liveblocks/react-ui", - "version": "3.1.4", + "version": "3.2.0", "license": "Apache-2.0", "dependencies": { "@floating-ui/react-dom": "^2.1.2", - "@liveblocks/client": "3.1.4", - "@liveblocks/core": "3.1.4", - "@liveblocks/react": "3.1.4", + "@liveblocks/client": "3.2.0", + "@liveblocks/core": "3.2.0", + "@liveblocks/react": "3.2.0", "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-popover": "^1.1.2", "@radix-ui/react-slot": "^1.1.0", @@ -40036,11 +40036,11 @@ }, "packages/liveblocks-redux": { "name": "@liveblocks/redux", - "version": "3.1.4", + "version": "3.2.0", "license": "Apache-2.0", "dependencies": { - "@liveblocks/client": "3.1.4", - "@liveblocks/core": "3.1.4" + "@liveblocks/client": "3.2.0", + "@liveblocks/core": "3.2.0" }, "devDependencies": { "@liveblocks/eslint-config": "*", @@ -40167,10 +40167,10 @@ }, "packages/liveblocks-yjs": { "name": "@liveblocks/yjs", - "version": "3.1.4", + "version": "3.2.0", "dependencies": { - "@liveblocks/client": "3.1.4", - "@liveblocks/core": "3.1.4", + "@liveblocks/client": "3.2.0", + "@liveblocks/core": "3.2.0", "js-base64": "^3.7.7", "y-indexeddb": "^9.0.12" }, @@ -40247,11 +40247,11 @@ }, "packages/liveblocks-zustand": { "name": "@liveblocks/zustand", - "version": "3.1.4", + "version": "3.2.0", "license": "Apache-2.0", "dependencies": { - "@liveblocks/client": "3.1.4", - "@liveblocks/core": "3.1.4" + "@liveblocks/client": "3.2.0", + "@liveblocks/core": "3.2.0" }, "devDependencies": { "@liveblocks/eslint-config": "*", diff --git a/packages/liveblocks-client/package.json b/packages/liveblocks-client/package.json index 9e8cc180d91..48547788f53 100644 --- a/packages/liveblocks-client/package.json +++ b/packages/liveblocks-client/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/client", - "version": "3.1.4", + "version": "3.2.0", "description": "A client that lets you interact with Liveblocks servers. Liveblocks is the all-in-one toolkit to build collaborative products like Figma, Notion, and more.", "license": "Apache-2.0", "type": "module", @@ -34,7 +34,7 @@ "test:watch": "jest --silent --verbose --color=always --passWithNoTests --watch" }, "dependencies": { - "@liveblocks/core": "3.1.4" + "@liveblocks/core": "3.2.0" }, "devDependencies": { "@liveblocks/eslint-config": "*", diff --git a/packages/liveblocks-core/package.json b/packages/liveblocks-core/package.json index 0866c43dddd..0e03cf10cf0 100644 --- a/packages/liveblocks-core/package.json +++ b/packages/liveblocks-core/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/core", - "version": "3.1.4", + "version": "3.2.0", "description": "Private internals for Liveblocks. DO NOT import directly from this package!", "type": "module", "main": "./dist/index.cjs", diff --git a/packages/liveblocks-emails/package.json b/packages/liveblocks-emails/package.json index a5c7a6518f4..19e7c4e75c9 100644 --- a/packages/liveblocks-emails/package.json +++ b/packages/liveblocks-emails/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/emails", - "version": "3.1.4", + "version": "3.2.0", "description": "A set of functions and utilities to make sending emails based on Liveblocks notification events easy. Liveblocks is the all-in-one toolkit to build collaborative products like Figma, Notion, and more.", "license": "Apache-2.0", "type": "module", @@ -35,8 +35,8 @@ "test:watch": "jest --silent --verbose --color=always --watch" }, "dependencies": { - "@liveblocks/core": "3.1.4", - "@liveblocks/node": "3.1.4" + "@liveblocks/core": "3.2.0", + "@liveblocks/node": "3.2.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc" diff --git a/packages/liveblocks-node-lexical/package.json b/packages/liveblocks-node-lexical/package.json index 43c6ae9bcf6..863d4c1e85d 100644 --- a/packages/liveblocks-node-lexical/package.json +++ b/packages/liveblocks-node-lexical/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/node-lexical", - "version": "3.1.4", + "version": "3.2.0", "description": "A server-side utility that lets you modify lexical documents hosted in Liveblocks.", "license": "Apache-2.0", "type": "module", @@ -34,8 +34,8 @@ "test:watch": "jest --silent --verbose --color=always --watch" }, "dependencies": { - "@liveblocks/core": "3.1.4", - "@liveblocks/node": "3.1.4", + "@liveblocks/core": "3.2.0", + "@liveblocks/node": "3.2.0", "yjs": "^13.6.18" }, "peerDependencies": { diff --git a/packages/liveblocks-node-prosemirror/package.json b/packages/liveblocks-node-prosemirror/package.json index 6be2962c1b2..4f05a658bab 100644 --- a/packages/liveblocks-node-prosemirror/package.json +++ b/packages/liveblocks-node-prosemirror/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/node-prosemirror", - "version": "3.1.4", + "version": "3.2.0", "description": "A server-side utility that lets you modify prosemirror and tiptap documents hosted in Liveblocks.", "license": "Apache-2.0", "type": "module", @@ -34,8 +34,8 @@ "test:watch": "jest --silent --verbose --color=always --watch" }, "dependencies": { - "@liveblocks/core": "3.1.4", - "@liveblocks/node": "3.1.4", + "@liveblocks/core": "3.2.0", + "@liveblocks/node": "3.2.0", "yjs": "^13.6.20" }, "peerDependencies": { diff --git a/packages/liveblocks-node/package.json b/packages/liveblocks-node/package.json index 02408a0fa35..1b9325236a4 100644 --- a/packages/liveblocks-node/package.json +++ b/packages/liveblocks-node/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/node", - "version": "3.1.4", + "version": "3.2.0", "description": "A server-side utility that lets you set up a Liveblocks authentication endpoint. Liveblocks is the all-in-one toolkit to build collaborative products like Figma, Notion, and more.", "license": "Apache-2.0", "type": "module", @@ -34,7 +34,7 @@ "test:watch": "jest --silent --verbose --color=always --watch" }, "dependencies": { - "@liveblocks/core": "3.1.4", + "@liveblocks/core": "3.2.0", "@stablelib/base64": "^1.0.1", "fast-sha256": "^1.3.0", "node-fetch": "^2.6.1" diff --git a/packages/liveblocks-react-blocknote/package.json b/packages/liveblocks-react-blocknote/package.json index 50448dbdd36..daeb4a7404c 100644 --- a/packages/liveblocks-react-blocknote/package.json +++ b/packages/liveblocks-react-blocknote/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/react-blocknote", - "version": "3.1.4", + "version": "3.2.0", "description": "An integration of BlockNote + React to enable collaboration, comments, live cursors, and more with Liveblocks.", "license": "Apache-2.0", "type": "module", @@ -42,12 +42,12 @@ "test:watch": "vitest" }, "dependencies": { - "@liveblocks/client": "3.1.4", - "@liveblocks/core": "3.1.4", - "@liveblocks/react": "3.1.4", - "@liveblocks/react-tiptap": "3.1.4", - "@liveblocks/react-ui": "3.1.4", - "@liveblocks/yjs": "3.1.4", + "@liveblocks/client": "3.2.0", + "@liveblocks/core": "3.2.0", + "@liveblocks/react": "3.2.0", + "@liveblocks/react-tiptap": "3.2.0", + "@liveblocks/react-ui": "3.2.0", + "@liveblocks/yjs": "3.2.0", "@tiptap/core": "^2.7.2", "vitest-tsconfig-paths": "^3.4.1" }, diff --git a/packages/liveblocks-react-lexical/package.json b/packages/liveblocks-react-lexical/package.json index e73e111196d..6088798fca9 100644 --- a/packages/liveblocks-react-lexical/package.json +++ b/packages/liveblocks-react-lexical/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/react-lexical", - "version": "3.1.4", + "version": "3.2.0", "description": "An integration of Lexical + React to enable collaboration, comments, live cursors, and more with Liveblocks.", "license": "Apache-2.0", "type": "module", @@ -43,11 +43,11 @@ }, "dependencies": { "@floating-ui/react-dom": "^2.1.1", - "@liveblocks/client": "3.1.4", - "@liveblocks/core": "3.1.4", - "@liveblocks/react": "3.1.4", - "@liveblocks/react-ui": "3.1.4", - "@liveblocks/yjs": "3.1.4", + "@liveblocks/client": "3.2.0", + "@liveblocks/core": "3.2.0", + "@liveblocks/react": "3.2.0", + "@liveblocks/react-ui": "3.2.0", + "@liveblocks/yjs": "3.2.0", "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-toggle": "^1.1.0", "yjs": "^13.6.18" diff --git a/packages/liveblocks-react-tiptap/package.json b/packages/liveblocks-react-tiptap/package.json index 2c3561c3040..4d3a2d3686c 100644 --- a/packages/liveblocks-react-tiptap/package.json +++ b/packages/liveblocks-react-tiptap/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/react-tiptap", - "version": "3.1.4", + "version": "3.2.0", "description": "An integration of TipTap + React to enable collaboration, comments, live cursors, and more with Liveblocks.", "license": "Apache-2.0", "type": "module", @@ -43,11 +43,11 @@ }, "dependencies": { "@floating-ui/react-dom": "^2.1.2", - "@liveblocks/client": "3.1.4", - "@liveblocks/core": "3.1.4", - "@liveblocks/react": "3.1.4", - "@liveblocks/react-ui": "3.1.4", - "@liveblocks/yjs": "3.1.4", + "@liveblocks/client": "3.2.0", + "@liveblocks/core": "3.2.0", + "@liveblocks/react": "3.2.0", + "@liveblocks/react-ui": "3.2.0", + "@liveblocks/yjs": "3.2.0", "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-toggle": "^1.1.0", "@tiptap/core": "^2.7.2", diff --git a/packages/liveblocks-react-ui/package.json b/packages/liveblocks-react-ui/package.json index f08083ae153..2832c86d6f3 100644 --- a/packages/liveblocks-react-ui/package.json +++ b/packages/liveblocks-react-ui/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/react-ui", - "version": "3.1.4", + "version": "3.2.0", "description": "A set of React pre-built components for the Liveblocks products. Liveblocks is the all-in-one toolkit to build collaborative products like Figma, Notion, and more.", "license": "Apache-2.0", "type": "module", @@ -76,9 +76,9 @@ }, "dependencies": { "@floating-ui/react-dom": "^2.1.2", - "@liveblocks/client": "3.1.4", - "@liveblocks/core": "3.1.4", - "@liveblocks/react": "3.1.4", + "@liveblocks/client": "3.2.0", + "@liveblocks/core": "3.2.0", + "@liveblocks/react": "3.2.0", "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-popover": "^1.1.2", "@radix-ui/react-slot": "^1.1.0", diff --git a/packages/liveblocks-react/package.json b/packages/liveblocks-react/package.json index f6e7c07d15c..064051c73dc 100644 --- a/packages/liveblocks-react/package.json +++ b/packages/liveblocks-react/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/react", - "version": "3.1.4", + "version": "3.2.0", "description": "A set of React hooks and providers to use Liveblocks declaratively. Liveblocks is the all-in-one toolkit to build collaborative products like Figma, Notion, and more.", "license": "Apache-2.0", "type": "module", @@ -61,8 +61,8 @@ "showdeps": "depcruise src --include-only '^src' --exclude='__tests__' --output-type dot | dot -T svg > /tmp/dependency-graph.svg && open /tmp/dependency-graph.svg" }, "dependencies": { - "@liveblocks/client": "3.1.4", - "@liveblocks/core": "3.1.4" + "@liveblocks/client": "3.2.0", + "@liveblocks/core": "3.2.0" }, "peerDependencies": { "@types/react": "*", diff --git a/packages/liveblocks-redux/package.json b/packages/liveblocks-redux/package.json index 14126bf2fb1..7a1e38e516f 100644 --- a/packages/liveblocks-redux/package.json +++ b/packages/liveblocks-redux/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/redux", - "version": "3.1.4", + "version": "3.2.0", "description": "A store enhancer to integrate Liveblocks into Redux stores. Liveblocks is the all-in-one toolkit to build collaborative products like Figma, Notion, and more.", "license": "Apache-2.0", "type": "module", @@ -33,8 +33,8 @@ "test:watch": "jest --silent --verbose --color=always --watch" }, "dependencies": { - "@liveblocks/client": "3.1.4", - "@liveblocks/core": "3.1.4" + "@liveblocks/client": "3.2.0", + "@liveblocks/core": "3.2.0" }, "peerDependencies": { "redux": "^4 || ^5" diff --git a/packages/liveblocks-yjs/package.json b/packages/liveblocks-yjs/package.json index b4ef822e529..4d3ad742085 100644 --- a/packages/liveblocks-yjs/package.json +++ b/packages/liveblocks-yjs/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/yjs", - "version": "3.1.4", + "version": "3.2.0", "description": "Integrate your existing or new Yjs documents with Liveblocks.", "icense": "Apache-2.0", "type": "module", @@ -33,8 +33,8 @@ "test:watch": "jest --silent --verbose --color=always --watch" }, "dependencies": { - "@liveblocks/client": "3.1.4", - "@liveblocks/core": "3.1.4", + "@liveblocks/client": "3.2.0", + "@liveblocks/core": "3.2.0", "js-base64": "^3.7.7", "y-indexeddb": "^9.0.12" }, diff --git a/packages/liveblocks-zustand/package.json b/packages/liveblocks-zustand/package.json index 49275acaf09..cfec9ce9fb6 100644 --- a/packages/liveblocks-zustand/package.json +++ b/packages/liveblocks-zustand/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/zustand", - "version": "3.1.4", + "version": "3.2.0", "description": "A middleware for Zustand to automatically synchronize your stores with Liveblocks. Liveblocks is the all-in-one toolkit to build collaborative products like Figma, Notion, and more.", "license": "Apache-2.0", "type": "module", @@ -34,8 +34,8 @@ "test:watch": "jest --silent --verbose --color=always --watch" }, "dependencies": { - "@liveblocks/client": "3.1.4", - "@liveblocks/core": "3.1.4" + "@liveblocks/client": "3.2.0", + "@liveblocks/core": "3.2.0" }, "peerDependencies": { "zustand": "^5.0.1"