docs: add collaboration split pane demo

This commit is contained in:
Sven Adlung 2024-06-20 10:48:51 +02:00 committed by GitHub
parent cd64e01ebd
commit ba76209ddf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 529 additions and 2 deletions

View File

@ -8,6 +8,7 @@
--gray-3: rgba(61, 37, 20, 0.12);
--gray-4: rgba(53, 38, 28, 0.30);
--gray-5: rgba(28, 25, 23, 0.60);
--green: #22C55E;
--purple: #6A00F5;
--purple-contrast: #5800CC;
--purple-light: rgba(88, 5, 255, 0.05);

View File

@ -0,0 +1,200 @@
import CharacterCount from '@tiptap/extension-character-count'
import Collaboration from '@tiptap/extension-collaboration'
import CollaborationCursor from '@tiptap/extension-collaboration-cursor'
import Highlight from '@tiptap/extension-highlight'
import TaskItem from '@tiptap/extension-task-item'
import TaskList from '@tiptap/extension-task-list'
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import React, { useCallback, useEffect, useState } from 'react'
const colors = [
'#958DF1',
'#F98181',
'#FBBC88',
'#FAF594',
'#70CFF8',
'#94FADB',
'#B9F18D',
'#C3E2C2',
'#EAECCC',
'#AFC8AD',
'#EEC759',
'#9BB8CD',
'#FF90BC',
'#FFC0D9',
'#DC8686',
'#7ED7C1',
'#F3EEEA',
'#89B9AD',
'#D0BFFF',
'#FFF8C9',
'#CBFFA9',
'#9BABB8',
'#E3F4F4',
]
const names = [
'Lea Thompson',
'Cyndi Lauper',
'Tom Cruise',
'Madonna',
'Jerry Hall',
'Joan Collins',
'Winona Ryder',
'Christina Applegate',
'Alyssa Milano',
'Molly Ringwald',
'Ally Sheedy',
'Debbie Harry',
'Olivia Newton-John',
'Elton John',
'Michael J. Fox',
'Axl Rose',
'Emilio Estevez',
'Ralph Macchio',
'Rob Lowe',
'Jennifer Grey',
'Mickey Rourke',
'John Cusack',
'Matthew Broderick',
'Justine Bateman',
'Lisa Bonet',
]
const defaultContent = `
<p>Hi 👋, this is a collaborative document.</p>
<p>Feel free to edit and collaborate in real-time!</p>
`
const getRandomElement = list => list[Math.floor(Math.random() * list.length)]
const getRandomColor = () => getRandomElement(colors)
const getRandomName = () => getRandomElement(names)
const getInitialUser = () => {
return (
{
name: getRandomName(),
color: getRandomColor(),
}
)
}
const Editor = ({ ydoc, provider, room }) => {
const [status, setStatus] = useState('connecting')
const [currentUser, setCurrentUser] = useState(getInitialUser)
const editor = useEditor({
onCreate: ({ editor: currentEditor }) => {
provider.on('synced', () => {
if (currentEditor.isEmpty) {
currentEditor.commands.setContent(defaultContent)
}
})
},
extensions: [
StarterKit.configure({
history: false,
}),
Highlight,
TaskList,
TaskItem,
CharacterCount.configure({
limit: 10000,
}),
Collaboration.configure({
document: ydoc,
}),
CollaborationCursor.configure({
provider,
}),
],
})
useEffect(() => {
// Update status changes
const statusHandler = event => {
setStatus(event.status)
}
provider.on('status', statusHandler)
return () => {
provider.off('status', statusHandler)
}
}, [provider])
// Save current user to localStorage and emit to editor
useEffect(() => {
if (editor && currentUser) {
localStorage.setItem('currentUser', JSON.stringify(currentUser))
editor.chain().focus().updateUser(currentUser).run()
}
}, [editor, currentUser])
const setName = useCallback(() => {
const name = (window.prompt('Name', currentUser.name) || '').trim().substring(0, 32)
if (name) {
return setCurrentUser({ ...currentUser, name })
}
}, [currentUser])
if (!editor) {
return null
}
return (
<div className="column-half">
<div className="control-group">
<div className="button-group">
<button
onClick={() => editor.chain().focus().toggleBold().run()}
className={editor.isActive('bold') ? 'is-active' : ''}
>
Bold
</button>
<button
onClick={() => editor.chain().focus().toggleItalic().run()}
className={editor.isActive('italic') ? 'is-active' : ''}
>
Italic
</button>
<button
onClick={() => editor.chain().focus().toggleStrike().run()}
className={editor.isActive('strike') ? 'is-active' : ''}
>
Strike
</button>
<button
onClick={() => editor.chain().focus().toggleBulletList().run()}
className={editor.isActive('bulletList') ? 'is-active' : ''}
>
Bullet list
</button>
<button
onClick={() => editor.chain().focus().toggleCode().run()}
className={editor.isActive('code') ? 'is-active' : ''}
>
Code
</button>
</div>
</div>
<EditorContent editor={editor} className="main-group" />
<div className="collab-status-group" data-state={status === 'connected' ? 'online' : 'offline'}>
<label>
{status === 'connected'
? `${editor.storage.collaborationCursor.users.length} user${
editor.storage.collaborationCursor.users.length === 1 ? '' : 's'
} online in ${room}`
: 'offline'}
</label>
<button style={{ '--color': currentUser.color }} onClick={setName}> {currentUser.name}</button>
</div>
</div>
)
}
export default Editor

View File

@ -0,0 +1,39 @@
import './styles.scss'
import { TiptapCollabProvider } from '@hocuspocus/provider'
import * as Y from 'yjs'
import Editor from './Editor.jsx'
const appId = '7j9y6m10'
const room = `room.${new Date()
.getFullYear()
.toString()
.slice(-2)}${new Date().getMonth() + 1}${new Date().getDate()}`
// ydoc and provider for Editor A
const ydocA = new Y.Doc()
const providerA = new TiptapCollabProvider({
appId,
name: room,
document: ydocA,
})
// ydoc and provider for Editor B
const ydocB = new Y.Doc()
const providerB = new TiptapCollabProvider({
appId,
name: room,
document: ydocB,
})
const App = () => {
return (
<div className="col-group">
<Editor provider={providerA} ydoc={ydocA} room={room} />
<Editor provider={providerB} ydoc={ydocB} room={room} />
</div>
)
}
export default App

View File

@ -0,0 +1,7 @@
context('/src/Demos/CollaborationSplitPane/React/', () => {
beforeEach(() => {
cy.visit('/src/Demos/CollaborationSplitPane/React/')
})
// TODO: Write tests
})

View File

@ -0,0 +1,280 @@
/* Basic editor styles */
.tiptap {
:first-child {
margin-top: 0;
}
/* List styles */
ul,
ol {
padding: 0 1rem;
margin: 1.25rem 1rem 1.25rem 0.4rem;
li p {
margin-top: 0.25em;
margin-bottom: 0.25em;
}
}
/* Heading styles */
h1,
h2,
h3,
h4,
h5,
h6 {
line-height: 1.1;
margin-top: 2.5rem;
text-wrap: pretty;
}
h1,
h2 {
margin-top: 3.5rem;
margin-bottom: 1.5rem;
}
h1 {
font-size: 1.4rem;
}
h2 {
font-size: 1.2rem;
}
h3 {
font-size: 1.1rem;
}
h4,
h5,
h6 {
font-size: 1rem;
}
/* Code and preformatted text styles */
code {
background-color: var(--purple-light);
border-radius: 0.4rem;
color: var(--black);
font-size: 0.85rem;
padding: 0.25em 0.3em;
}
pre {
background: var(--black);
border-radius: 0.5rem;
color: var(--white);
font-family: 'JetBrainsMono', monospace;
margin: 1.5rem 0;
padding: 0.75rem 1rem;
code {
background: none;
color: inherit;
font-size: 0.8rem;
padding: 0;
}
}
blockquote {
border-left: 3px solid var(--gray-3);
margin: 1.5rem 0;
padding-left: 1rem;
}
hr {
border: none;
border-top: 1px solid var(--gray-2);
margin: 2rem 0;
}
/* Highlight specific styles */
mark {
background-color: #FAF594;
border-radius: 0.4rem;
box-decoration-break: clone;
padding: 0.1rem 0.3rem;
}
/* Task list specific styles */
ul[data-type="taskList"] {
list-style: none;
margin-left: 0;
padding: 0;
li {
align-items: flex-start;
display: flex;
> label {
flex: 0 0 auto;
margin-right: 0.5rem;
user-select: none;
}
> div {
flex: 1 1 auto;
}
}
input[type="checkbox"] {
cursor: pointer;
}
ul[data-type="taskList"] {
margin: 0;
}
}
p {
word-break: break-all;
}
/* 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: #0d0d0d;
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;
}
}
.col-group {
display: flex;
flex-direction: row;
height: 100vh;
@media (max-width: 540px) {
flex-direction: column;
}
}
/* Column-half */
body {
overflow: hidden;
}
.column-half {
display: flex;
flex-direction: column;
flex: 1;
overflow: auto;
&:last-child {
border-left: 1px solid var(--gray-3);
@media (max-width: 540px) {
border-left: none;
border-top: 1px solid var(--gray-3);
}
}
& > .main-group {
flex-grow: 1;
}
}
/* Collaboration status */
.collab-status-group {
align-items: center;
background-color: var(--white);
border-top: 1px solid var(--gray-3);
bottom: 0;
color: var(--gray-5);
display: flex;
flex-direction: row;
font-size: 0.75rem;
font-weight: 400;
gap: 1rem;
justify-content: space-between;
padding: 0.375rem 0.5rem 0.375rem 1rem;
position: sticky;
width: 100%;
z-index: 100;
button {
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
align-self: stretch;
background: none;
display: -webkit-box;
flex-shrink: 1;
font-size: 0.75rem;
max-width: 100%;
padding: 0.25rem 0.375rem;
overflow: hidden;
position: relative;
text-overflow: ellipsis;
white-space: nowrap;
&::before {
background-color: var(--color);
border-radius: 0.375rem;
content: "";
height: 100%;
left: 0;
opacity: 0.5;
position: absolute;
top: 0;
transition: all 0.2s cubic-bezier(0.65,0.05,0.36,1);
width: 100%;
z-index: -1;
}
&:hover::before {
opacity: 1;
}
}
label {
align-items: center;
display: flex;
flex-direction: row;
flex-shrink: 0;
gap: 0.375rem;
line-height: 1.1;
&::before {
border-radius: 50%;
content: " ";
height: 0.35rem;
width: 0.35rem;
}
}
&[data-state="online"] {
label {
&::before {
background-color: var(--green);
}
}
}
&[data-state="offline"] {
label {
&::before {
background-color: var(--red);
}
}
}
}

View File

@ -1,6 +1,6 @@
context('/src/Examples/CollaborativeEditing/React/', () => {
context('/src/Demos/SingleRoomCollab/React/', () => {
beforeEach(() => {
cy.visit('/src/Examples/CollaborativeEditing/React/')
cy.visit('/src/Demos/SingleRoomCollab/React/')
})
/* it('should show the current room with participants', () => {