Skip to content

Commit

Permalink
feat: collaborative editing cursor
Browse files Browse the repository at this point in the history
Signed-off-by: Matt Krick <[email protected]>
  • Loading branch information
mattkrick committed Feb 19, 2025
1 parent 02e131d commit c32df25
Show file tree
Hide file tree
Showing 8 changed files with 83 additions and 46 deletions.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@
"parse-url": "^8.1.0",
"recursive-readdir": "^2.2.3",
"axios": "^1.7.8",
"prosemirror-model": "^1.24.1"
"prosemirror-model": "^1.24.1",
"prosemirror-view": "^1.38.0"
},
"devDependencies": {
"@babel/core": "^7.20.12",
Expand Down
6 changes: 5 additions & 1 deletion packages/client/components/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ const Dashboard = (props: Props) => {
...MobileDashSidebar_viewer
...DashSidebar_viewer
...useNewFeatureSnackbar_viewer
...useTipTapPageEditor_viewer
overLimitCopy
teams {
activeMeetings {
Expand Down Expand Up @@ -172,7 +173,10 @@ const Dashboard = (props: Props) => {
/>
<Route path='/team/:teamId' component={TeamRoot} />
<Route path='/newteam/:defaultOrgId?' component={NewTeam} />
<Route path='/pages/:pageSlug' component={Page} />
<Route
path='/pages/:pageSlug'
render={(routeProps) => <Page {...routeProps} viewerRef={viewer} />}
/>
<Route path='/pages' component={MakePage} />
<Route path='/new-summary/:meetingId/share/:stageId' component={ShareTopicRouterRoot} />
<Route path='/new-summary/:meetingId/:urlAction?' component={NewMeetingSummary} />
Expand Down
32 changes: 21 additions & 11 deletions packages/client/hooks/useTipTapPageEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,14 @@ import {TaskList} from '@tiptap/extension-task-list'
import Underline from '@tiptap/extension-underline'
import {generateJSON, generateText, useEditor} from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import {useRef, useState} from 'react'
import graphql from 'babel-plugin-relay/macro'
import {useState} from 'react'
import {readInlineData} from 'relay-runtime'
import * as Y from 'yjs'
import type {useTipTapPageEditor_viewer$key} from '../__generated__/useTipTapPageEditor_viewer.graphql'
import {LoomExtension} from '../components/promptResponse/loomExtension'
import {TiptapLinkExtension} from '../components/promptResponse/TiptapLinkExtension'
import {themeBackgroundColors} from '../shared/themeBackgroundColors'
import {mentionConfig, serverTipTapExtensions} from '../shared/tiptap/serverTipTapExtensions'
import {toSlug} from '../shared/toSlug'
import ImageBlock from '../tiptap/extensions/imageBlock/ImageBlock'
Expand All @@ -27,6 +31,7 @@ import {tiptapMentionConfig} from '../utils/tiptapMentionConfig'
import useAtmosphere from './useAtmosphere'
import useRouter from './useRouter'

const colorIdx = Math.floor(Math.random() * themeBackgroundColors.length)
let socket: TiptapCollabProviderWebsocket
const makeHocusPocusSocket = (authToken: string | null) => {
if (!socket) {
Expand All @@ -45,11 +50,20 @@ const makeHocusPocusSocket = (authToken: string | null) => {
export const useTipTapPageEditor = (
pageId: number,
options: {
viewerRef: useTipTapPageEditor_viewer$key | null
teamId?: string
placeholder?: string
}
) => {
const {teamId, placeholder} = options
const {viewerRef, teamId} = options
const user = readInlineData(
graphql`
fragment useTipTapPageEditor_viewer on User @inline {
preferredName
}
`,
viewerRef
)
const preferredName = user?.preferredName
const atmosphere = useAtmosphere()
const {history} = useRouter<{meetingId: string}>()
const [document] = useState(() => {
Expand Down Expand Up @@ -77,10 +91,8 @@ export const useTipTapPageEditor = (
})
return doc
})
const placeholderRef = useRef(placeholder)
placeholderRef.current = placeholder
// Connect to your Collaboration server
const provider = useState(() => {
const [provider] = useState(() => {
if (!pageId) return
return new TiptapCollabProvider({
websocketProvider: makeHocusPocusSocket(atmosphere.authToken),
Expand Down Expand Up @@ -115,9 +127,7 @@ export const useTipTapPageEditor = (
LoomExtension,
Placeholder.configure({
showOnlyWhenEditable: false,
placeholder: () => {
return placeholderRef.current || 'New page'
}
placeholder: 'New page'
}),
Mention.configure(
atmosphere && teamId ? tiptapMentionConfig(atmosphere, teamId) : mentionConfig
Expand All @@ -137,8 +147,8 @@ export const useTipTapPageEditor = (
CollaborationCursor.configure({
provider,
user: {
name: 'Cyndi Lauper',
color: '#f783ac'
name: preferredName,
color: `#${themeBackgroundColors[colorIdx]}`
}
})
],
Expand Down
10 changes: 7 additions & 3 deletions packages/client/modules/pages/Page.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import type {useTipTapPageEditor_viewer$key} from '../../__generated__/useTipTapPageEditor_viewer.graphql'
import {TipTapEditor} from '../../components/promptResponse/TipTapEditor'
import useRouter from '../../hooks/useRouter'
import {useTipTapPageEditor} from '../../hooks/useTipTapPageEditor'

interface Props {}
interface Props {
viewerRef: useTipTapPageEditor_viewer$key | null
}

export const Page = (_props: Props) => {
export const Page = (props: Props) => {
const {viewerRef} = props
const {match} = useRouter<{orgName: string; pageSlug: string}>()
const {params} = match
const {pageSlug} = params
const pageIdIdx = pageSlug.lastIndexOf('-')
const pageId = Number(pageIdIdx === -1 ? pageSlug : pageSlug.slice(pageIdIdx + 1))
const {editor} = useTipTapPageEditor(pageId, {})
const {editor} = useTipTapPageEditor(pageId, {viewerRef})
if (!editor) return <div>No editor</div>
if (!pageSlug) return <div>No page ID provided in route</div>
return (
Expand Down
15 changes: 15 additions & 0 deletions packages/client/shared/themeBackgroundColors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export const themeBackgroundColors = [
'FD6157',
'D35D22',
'DE8E02',
'ACC125',
'639442',
'40B574',
'33B1C7',
'329AE5',
'7272E5',
'A06BD6',
'D345CF',
'ED4C86',
'A7A3C2'
]
33 changes: 30 additions & 3 deletions packages/client/styles/theme/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@
}

body {
@apply m-0 p-0 font-sans text-[16px] font-normal leading-[normal] text-slate-700 antialiased;
@apply m-0 p-0 font-sans text-[16px] leading-[normal] font-normal text-slate-700 antialiased;
}

a {
Expand Down Expand Up @@ -432,14 +432,14 @@
transition: border 160ms cubic-bezier(0.45, 0.05, 0.55, 0.95);
&.ProseMirror-selectednode::after {
content: '';
@apply pointer-events-none absolute inset-0 h-full w-full select-none rounded-sm bg-[#2383e247];
@apply pointer-events-none absolute inset-0 h-full w-full rounded-sm bg-[#2383e247] select-none;
}
}
.node-imageBlock {
@apply relative;
&.has-focus > div::after {
content: '';
@apply pointer-events-none absolute inset-0 h-full w-full select-none bg-[#2383e247];
@apply pointer-events-none absolute inset-0 h-full w-full bg-[#2383e247] select-none;
}
& img {
@apply overflow-hidden rounded-md;
Expand Down Expand Up @@ -505,3 +505,30 @@ hr.ProseMirror-selectednode {
.ProseMirror hr {
border-top: 1px solid var(--color-slate-400);
}

/* Give a remote user a caret */
.collaboration-cursor__caret {
border-left: 1px solid #0d0d0d;
border-right: 1px solid #0d0d0d;
margin-left: -1px;
margin-right: -1px;
pointer-events: none;
position: relative;
word-break: normal;
}

/* Render the username above the caret */
.collaboration-cursor__label {
border-radius: 3px 3px 3px 0;
color: #fff;
font-size: 12px;
font-style: normal;
font-weight: 600;
left: -1px;
line-height: normal;
padding: 0.1rem 0.3rem;
position: absolute;
top: -1.4em;
user-select: none;
white-space: nowrap;
}
Original file line number Diff line number Diff line change
@@ -1,26 +1,11 @@
import {createAvatar} from '@dicebear/core'
import * as initials from '@dicebear/initials'
import sharp from 'sharp'
import {themeBackgroundColors} from '../../../../../client/shared/themeBackgroundColors'
import getFileStoreManager from '../../../../fileStorage/getFileStoreManager'

export const generateIdenticon = async (userId: string, name: string) => {
const letters = 'abcdefghijklmnopqrstuvwxyz'
// 500 color value from our theme
const backgroundColor = [
'FD6157',
'D35D22',
'DE8E02',
'ACC125',
'639442',
'40B574',
'33B1C7',
'329AE5',
'7272E5',
'A06BD6',
'D345CF',
'ED4C86',
'A7A3C2'
]

const seed =
name
Expand All @@ -31,7 +16,7 @@ export const generateIdenticon = async (userId: string, name: string) => {
.join('') || 'pa'
const avatar = createAvatar(initials, {
seed,
backgroundColor,
backgroundColor: themeBackgroundColors,
fontFamily: ['IBM Plex Sans']
})
const svgBuffer = await avatar.toArrayBuffer()
Expand Down
11 changes: 1 addition & 10 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -19989,16 +19989,7 @@ prosemirror-transform@^1.0.0, prosemirror-transform@^1.1.0, prosemirror-transfor
dependencies:
prosemirror-model "^1.21.0"

prosemirror-view@^1.0.0, prosemirror-view@^1.1.0, prosemirror-view@^1.27.0, prosemirror-view@^1.31.0:
version "1.36.0"
resolved "https://registry.yarnpkg.com/prosemirror-view/-/prosemirror-view-1.36.0.tgz#ab6e444db08b7e3a79c6841c6667df72c7c4f2ec"
integrity sha512-U0GQd5yFvV5qUtT41X1zCQfbw14vkbbKwLlQXhdylEmgpYVHkefXYcC4HHwWOfZa3x6Y8wxDLUBv7dxN5XQ3nA==
dependencies:
prosemirror-model "^1.20.0"
prosemirror-state "^1.0.0"
prosemirror-transform "^1.1.0"

prosemirror-view@^1.37.0, prosemirror-view@^1.37.2:
prosemirror-view@^1.0.0, prosemirror-view@^1.1.0, prosemirror-view@^1.27.0, prosemirror-view@^1.31.0, prosemirror-view@^1.37.0, prosemirror-view@^1.37.2, prosemirror-view@^1.38.0:
version "1.38.0"
resolved "https://registry.yarnpkg.com/prosemirror-view/-/prosemirror-view-1.38.0.tgz#685a256adc8486ebd0c8652125812b2f8297a2d3"
integrity sha512-O45kxXQTaP9wPdXhp8TKqCR+/unS/gnfg9Q93svQcB3j0mlp2XSPAmsPefxHADwzC+fbNS404jqRxm3UQaGvgw==
Expand Down

0 comments on commit c32df25

Please sign in to comment.