Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tiptap-composer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: minor
---

Add experimental Tiptap-based message composer behind a settings toggle.
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-query-devtools": "^5.91.3",
"@tanstack/react-virtual": "^3.13.19",
"@tiptap/core": "3.23.4",
"@tiptap/extension-link": "^3.23.4",
"@tiptap/extension-mention": "^3.23.4",
"@tiptap/extension-placeholder": "^3.23.4",
"@tiptap/react": "^3.23.4",
"@tiptap/starter-kit": "^3.23.4",
"@use-gesture/react": "10.3.1",
"@vanilla-extract/css": "^1.18.0",
"@vanilla-extract/recipes": "^0.5.7",
Expand Down
530 changes: 530 additions & 0 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

122 changes: 122 additions & 0 deletions src/app/components/editor-tiptap/TiptapEditor.css.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { style, globalStyle } from '@vanilla-extract/css';
import { color, config, DefaultReset, toRem } from 'folds';

export const TiptapEditorRoot = style([
DefaultReset,
{
backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
boxShadow: `inset 0 0 0 ${config.borderWidth.B300} ${color.SurfaceVariant.ContainerLine}`,
borderRadius: config.radii.R400,
overflow: 'hidden',
width: '100%',
},
]);

export const TiptapEditorRow = style({
gridTemplateColumns: 'auto 1fr auto',
alignItems: 'center',
});

export const TiptapEditorRowMultiline = style({
gridTemplateColumns: 'auto 1fr',
gridTemplateAreas: `
"before textarea"
"before after"
`,
alignItems: 'start',
});

export const TiptapEditorOptions = style([
DefaultReset,
{
padding: config.space.S200,
},
]);

export const TiptapEditorOptionsMultiline = style({
gridArea: 'before',
alignSelf: 'end',
});

export const TiptapEditorOptionsAfterMultiline = style({
gridArea: 'after',
justifySelf: 'end',
});

export const TiptapEditorScrollArea = style({
minWidth: 0,
});

export const TiptapEditorScrollAreaMultiline = style({
gridArea: 'textarea',
});

export const TiptapEditorContent = style([
DefaultReset,
{
flexGrow: 1,
height: 'auto',
padding: `${toRem(13)} 0 0`,
selectors: {
[`${TiptapEditorScrollArea}:first-child &`]: {
paddingLeft: toRem(13),
},
[`${TiptapEditorScrollArea}:last-child &`]: {
paddingRight: toRem(13),
},
'&:focus': {
outline: 'none',
},
},
},
]);

/** Wraps the ProseMirror editable div — resets prose styles from host page. */
export const TiptapProseMirrorWrapper = style({});

globalStyle(`${TiptapProseMirrorWrapper} .ProseMirror`, {
outline: 'none',
minHeight: toRem(20),
paddingBottom: toRem(13),
wordBreak: 'break-word',
overflowWrap: 'break-word',
});

globalStyle(`${TiptapProseMirrorWrapper} .ProseMirror p`, {
margin: 0,
});

globalStyle(`${TiptapProseMirrorWrapper} .ProseMirror p.is-editor-empty:first-child::before`, {
content: 'attr(data-placeholder)',
float: 'left',
color: color.SurfaceVariant.OnContainer,
opacity: config.opacity.Placeholder,
pointerEvents: 'none',
height: '0',
});

globalStyle(`${TiptapProseMirrorWrapper} [data-mention]`, {
display: 'inline',
borderRadius: config.radii.R300,
padding: `0 ${toRem(2)}`,
backgroundColor: color.Secondary.Container,
color: color.Secondary.OnContainer,
cursor: 'default',
userSelect: 'none',
});

globalStyle(`${TiptapProseMirrorWrapper} [data-emoticon] img`, {
height: toRem(20),
verticalAlign: 'middle',
});

globalStyle(`${TiptapProseMirrorWrapper} [data-command]`, {
display: 'inline',
borderRadius: config.radii.R300,
padding: `0 ${toRem(2)}`,
backgroundColor: color.Primary.Container,
color: color.Primary.OnContainer,
cursor: 'default',
userSelect: 'none',
});
234 changes: 234 additions & 0 deletions src/app/components/editor-tiptap/TiptapEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
import type { ReactNode, KeyboardEventHandler, ClipboardEventHandler } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef, useState, useEffect } from 'react';
import { Box, Scroll } from 'folds';
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Placeholder from '@tiptap/extension-placeholder';
import Link from '@tiptap/extension-link';
import type { Editor as TiptapEditorInstance } from '@tiptap/core';

import { mobileOrTablet } from '$utils/user-agent';
import { MatrixMentionExtension } from './extensions/MentionExtension';
import { EmoticonExtension } from './extensions/EmoticonExtension';
import { CommandExtension } from './extensions/CommandExtension';
import * as css from './TiptapEditor.css';

export type { TiptapEditorInstance };

/** Imperative handle exposed via ref for parent components. */
export type TiptapEditorHandle = {
// eslint-disable-next-line typescript-eslint/no-redundant-type-constituents
editor: TiptapEditorInstance | null;
focus: () => void;
reset: () => void;
isEmpty: () => boolean;
};

type TiptapEditorProps = {
editableName?: string;
top?: ReactNode;
bottom?: ReactNode;
before?: ReactNode;
after?: ReactNode;
responsiveAfter?: ReactNode;
forceMultilineLayout?: boolean;
maxHeight?: string;
placeholder?: string;
onKeyDown?: KeyboardEventHandler;
onKeyUp?: KeyboardEventHandler;
onChange?: (editor: TiptapEditorInstance) => void;
onPaste?: ClipboardEventHandler;
className?: string;
variant?: 'Surface' | 'SurfaceVariant' | 'Background';
};

export const TiptapEditor = forwardRef<TiptapEditorHandle, TiptapEditorProps>(
(
{
editableName,
top,
bottom,
before,
after,
responsiveAfter,
forceMultilineLayout = false,
maxHeight = '50vh',
placeholder,
onKeyDown,
onKeyUp,
onChange,
onPaste,
className,
variant = 'SurfaceVariant',
},
ref
) => {
const [isMultiline, setIsMultiline] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);

const editor = useEditor({
extensions: [
StarterKit.configure({
// Disable block-level elements we don't need in a chat composer
heading: false,
bulletList: false,
orderedList: false,
listItem: false,
blockquote: false,
codeBlock: false,
horizontalRule: false,
hardBreak: {
// Shift+Enter inserts a hard break (newline within paragraph)
keepMarks: true,
},
}),
Link.configure({
openOnClick: false,
autolink: true,
}),
Placeholder.configure({
placeholder: placeholder ?? '',
}),
MatrixMentionExtension.configure({
suggestion: {
// Disable the built-in suggestion popup — we handle autocomplete at the
// RoomInputTiptap level using our own React-based UI.
render: () => ({
onStart: () => {},
onUpdate: () => {},
onKeyDown: () => false,
onExit: () => {},
}),
},
}),
EmoticonExtension,
CommandExtension,
],

onUpdate({ editor: updatedEditor }) {
// Detect multiline (more than one paragraph or hard break in first paragraph)
const { doc } = updatedEditor.state;
let multiline = doc.childCount > 1;
if (!multiline) {
doc.forEach((node) => {
if (!multiline) {
node.forEach((child) => {
if (child.type.name === 'hardBreak') multiline = true;
});
}
});
}
setIsMultiline(multiline || forceMultilineLayout);
onChange?.(updatedEditor);
},

editorProps: {
attributes: {
...(editableName ? { 'data-editable-name': editableName } : {}),
class: css.TiptapEditorContent,
autocapitalize: 'sentences',
},
handleKeyDown(_, event) {
onKeyDown?.(event as unknown as React.KeyboardEvent);
return false; // let Tiptap handle the event normally as well
},
handleDOMEvents: {
keyup: (_, event) => {
onKeyUp?.(event as unknown as React.KeyboardEvent);
return false;
},
paste: (_, event) => {
onPaste?.(event as unknown as React.ClipboardEvent);
return false;
},
blur: () => {
if (mobileOrTablet() && editor) {
editor.commands.focus();
}
return false;
},
},
},
});

// Keep multiline in sync with forceMultilineLayout changes
useEffect(() => {
if (forceMultilineLayout && !isMultiline) setIsMultiline(true);
}, [forceMultilineLayout, isMultiline]);

useImperativeHandle(
ref,
() => ({
editor,
focus: () => editor?.commands.focus(),
reset: () => editor?.commands.clearContent(true),
isEmpty: () => editor?.isEmpty ?? true,
}),
[editor]
);

const layoutIsMultiline = isMultiline || forceMultilineLayout;
const hasBefore = Boolean(before);
const hasAfter = Boolean(after);
const hasResponsiveAfter = Boolean(responsiveAfter);
const showResponsiveAfterInFooter = hasResponsiveAfter && layoutIsMultiline;
const showResponsiveAfterInline = hasResponsiveAfter && !showResponsiveAfterInFooter;

const handlePaste = useCallback<ClipboardEventHandler>(
(e) => {
onPaste?.(e);
},
[onPaste]
);

return (
<div
ref={rootRef}
className={`${css.TiptapEditorRoot} ${className ?? ''}`}
onPaste={handlePaste}
>
{top}
<Box
className={`${css.TiptapEditorRow} ${layoutIsMultiline ? css.TiptapEditorRowMultiline : ''}`}
alignItems="Start"
style={{ display: after ? 'grid' : 'flex' }}
>
{hasBefore && (
<Box
className={`${css.TiptapEditorOptions} ${layoutIsMultiline ? css.TiptapEditorOptionsMultiline : ''}`}
alignItems="Center"
gap="100"
shrink="No"
>
{before}
</Box>
)}
<Scroll
className={`${css.TiptapEditorScrollArea} ${layoutIsMultiline ? css.TiptapEditorScrollAreaMultiline : ''}`}
variant={variant}
style={{ maxHeight: showResponsiveAfterInFooter ? undefined : maxHeight }}
size="300"
visibility="Always"
hideTrack
>
<div className={css.TiptapProseMirrorWrapper}>
<EditorContent editor={editor} />
</div>
</Scroll>
{(hasAfter || showResponsiveAfterInline) && (
<Box
className={`${css.TiptapEditorOptions} ${layoutIsMultiline ? css.TiptapEditorOptionsMultiline : ''} ${layoutIsMultiline ? css.TiptapEditorOptionsAfterMultiline : ''}`}
alignItems="Center"
gap="100"
shrink="No"
>
{showResponsiveAfterInline && responsiveAfter}
{after}
</Box>
)}
</Box>
{bottom}
</div>
);
}
);
Loading
Loading