From cf97cdf6f024d1159a527b49b7089d563093ca58 Mon Sep 17 00:00:00 2001 From: Sven Adlung Date: Fri, 8 Oct 2021 11:08:02 +0200 Subject: [PATCH] demos: add React collaboration demo (#1991) * Add react collab demo * Update editor styles for react collaboration demo --- .../CollaborativeEditing/React/MenuBar.jsx | 134 ++++++++++++ .../CollaborativeEditing/React/MenuBar.scss | 7 + .../CollaborativeEditing/React/MenuItem.jsx | 17 ++ .../CollaborativeEditing/React/MenuItem.scss | 22 ++ .../CollaborativeEditing/React/index.html | 15 ++ .../CollaborativeEditing/React/index.jsx | 140 +++++++++++++ .../CollaborativeEditing/React/index.spec.js | 7 + .../CollaborativeEditing/React/styles.scss | 196 ++++++++++++++++++ 8 files changed, 538 insertions(+) create mode 100644 demos/src/Examples/CollaborativeEditing/React/MenuBar.jsx create mode 100644 demos/src/Examples/CollaborativeEditing/React/MenuBar.scss create mode 100644 demos/src/Examples/CollaborativeEditing/React/MenuItem.jsx create mode 100644 demos/src/Examples/CollaborativeEditing/React/MenuItem.scss create mode 100644 demos/src/Examples/CollaborativeEditing/React/index.html create mode 100644 demos/src/Examples/CollaborativeEditing/React/index.jsx create mode 100644 demos/src/Examples/CollaborativeEditing/React/index.spec.js create mode 100644 demos/src/Examples/CollaborativeEditing/React/styles.scss diff --git a/demos/src/Examples/CollaborativeEditing/React/MenuBar.jsx b/demos/src/Examples/CollaborativeEditing/React/MenuBar.jsx new file mode 100644 index 00000000..2731f913 --- /dev/null +++ b/demos/src/Examples/CollaborativeEditing/React/MenuBar.jsx @@ -0,0 +1,134 @@ +import React, { Fragment } from 'react' +import MenuItem from './MenuItem' +import './MenuBar.scss' + +export default ({ editor }) => { + const items = [ + { + icon: 'bold', + title: 'Bold', + action: () => editor.chain().focus().toggleBold().run(), + isActive: () => editor.isActive('bold'), + }, + { + icon: 'italic', + title: 'Italic', + action: () => editor.chain().focus().toggleItalic().run(), + isActive: () => editor.isActive('italic'), + }, + { + icon: 'strikethrough', + title: 'Strike', + action: () => editor.chain().focus().toggleStrike().run(), + isActive: () => editor.isActive('strike'), + }, + { + icon: 'code-view', + title: 'Code', + action: () => editor.chain().focus().toggleCode().run(), + isActive: () => editor.isActive('code'), + }, + { + icon: 'mark-pen-line', + title: 'Highlight', + action: () => editor.chain().focus().toggleHighlight().run(), + isActive: () => editor.isActive('highlight'), + }, + { + type: 'divider', + }, + { + icon: 'h-1', + title: 'Heading 1', + action: () => editor.chain().focus().toggleHeading({ level: 1 }).run(), + isActive: () => editor.isActive('heading', { level: 1 }), + }, + { + icon: 'h-2', + title: 'Heading 2', + action: () => editor.chain().focus().toggleHeading({ level: 2 }).run(), + isActive: () => editor.isActive('heading', { level: 2 }), + }, + { + icon: 'paragraph', + title: 'Paragraph', + action: () => editor.chain().focus().setParagraph().run(), + isActive: () => editor.isActive('paragraph'), + }, + { + icon: 'list-unordered', + title: 'Bullet List', + action: () => editor.chain().focus().toggleBulletList().run(), + isActive: () => editor.isActive('bulletList'), + }, + { + icon: 'list-ordered', + title: 'Ordered List', + action: () => editor.chain().focus().toggleOrderedList().run(), + isActive: () => editor.isActive('orderedList'), + }, + { + icon: 'list-check-2', + title: 'Task List', + action: () => editor.chain().focus().toggleTaskList().run(), + isActive: () => editor.isActive('taskList'), + }, + { + icon: 'code-box-line', + title: 'Code Block', + action: () => editor.chain().focus().toggleCodeBlock().run(), + isActive: () => editor.isActive('codeBlock'), + }, + { + type: 'divider', + }, + { + icon: 'double-quotes-l', + title: 'Blockquote', + action: () => editor.chain().focus().toggleBlockquote().run(), + isActive: () => editor.isActive('blockquote'), + }, + { + icon: 'separator', + title: 'Horizontal Rule', + action: () => editor.chain().focus().setHorizontalRule().run(), + }, + { + type: 'divider', + }, + { + icon: 'text-wrap', + title: 'Hard Break', + action: () => editor.chain().focus().setHardBreak().run(), + }, + { + icon: 'format-clear', + title: 'Clear Format', + action: () => editor.chain().focus().clearNodes().unsetAllMarks() + .run(), + }, + { + type: 'divider', + }, + { + icon: 'arrow-go-back-line', + title: 'Undo', + action: () => editor.chain().focus().undo().run(), + }, + { + icon: 'arrow-go-forward-line', + title: 'Redo', + action: () => editor.chain().focus().redo().run(), + }, + ] + + return ( +
+ {items.map((item, index) => ( + + {item.type === 'divider' ?
: } + + ))} +
+ ) +} diff --git a/demos/src/Examples/CollaborativeEditing/React/MenuBar.scss b/demos/src/Examples/CollaborativeEditing/React/MenuBar.scss new file mode 100644 index 00000000..d991bb44 --- /dev/null +++ b/demos/src/Examples/CollaborativeEditing/React/MenuBar.scss @@ -0,0 +1,7 @@ +.divider { + background-color: rgba(#000, 0.1); + height: 1.25rem; + margin-left: 0.5rem; + margin-right: 0.75rem; + width: 2px; +} diff --git a/demos/src/Examples/CollaborativeEditing/React/MenuItem.jsx b/demos/src/Examples/CollaborativeEditing/React/MenuItem.jsx new file mode 100644 index 00000000..f4db53c4 --- /dev/null +++ b/demos/src/Examples/CollaborativeEditing/React/MenuItem.jsx @@ -0,0 +1,17 @@ +import React from 'react' +import './MenuItem.scss' +import remixiconUrl from 'remixicon/fonts/remixicon.symbol.svg' + +export default ({ + icon, title, action, isActive = null, +}) => ( + +) diff --git a/demos/src/Examples/CollaborativeEditing/React/MenuItem.scss b/demos/src/Examples/CollaborativeEditing/React/MenuItem.scss new file mode 100644 index 00000000..68d47967 --- /dev/null +++ b/demos/src/Examples/CollaborativeEditing/React/MenuItem.scss @@ -0,0 +1,22 @@ +.menu-item { + background-color: transparent; + border: none; + border-radius: 0.4rem; + color: #0d0d0d; + height: 1.75rem; + margin-right: 0.25rem; + padding: 0.25rem; + width: 1.75rem; + + svg { + fill: currentColor; + height: 100%; + width: 100%; + } + + &:hover, + &.is-active { + background-color: #0d0d0d; + color: #fff; + } +} diff --git a/demos/src/Examples/CollaborativeEditing/React/index.html b/demos/src/Examples/CollaborativeEditing/React/index.html new file mode 100644 index 00000000..57e13017 --- /dev/null +++ b/demos/src/Examples/CollaborativeEditing/React/index.html @@ -0,0 +1,15 @@ + + + + + + + +
+ + + diff --git a/demos/src/Examples/CollaborativeEditing/React/index.jsx b/demos/src/Examples/CollaborativeEditing/React/index.jsx new file mode 100644 index 00000000..fa541c0e --- /dev/null +++ b/demos/src/Examples/CollaborativeEditing/React/index.jsx @@ -0,0 +1,140 @@ +import React, { + useState, useCallback, useEffect, +} from 'react' +import * as Y from 'yjs' +import { WebsocketProvider } from 'y-websocket' +import { IndexeddbPersistence } from 'y-indexeddb' +import { useEditor, EditorContent } from '@tiptap/react' +import StarterKit from '@tiptap/starter-kit' +import TaskList from '@tiptap/extension-task-list' +import TaskItem from '@tiptap/extension-task-item' +import Highlight from '@tiptap/extension-highlight' +import CharacterCount from '@tiptap/extension-character-count' +import Collaboration from '@tiptap/extension-collaboration' +import CollaborationCursor from '@tiptap/extension-collaboration-cursor' +import MenuBar from './MenuBar' +import './styles.scss' + +const colors = ['#958DF1', '#F98181', '#FBBC88', '#FAF594', '#70CFF8', '#94FADB', '#B9F18D'] +const rooms = ['rooms.7', 'rooms.8', 'rooms.9'] +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 getRandomElement = list => list[Math.floor(Math.random() * list.length)] + +const getRandomRoom = () => getRandomElement(rooms) +const getRandomColor = () => getRandomElement(colors) +const getRandomName = () => getRandomElement(names) + +const room = getRandomRoom() + +const ydoc = new Y.Doc() +const websocketProvider = new WebsocketProvider('wss://websocket.tiptap.dev', room, ydoc) + +const getInitialUser = () => { + return JSON.parse(localStorage.getItem('currentUser')) || { + name: getRandomName(), + color: getRandomColor(), + } +} + +export default () => { + const [status, setStatus] = useState('connecting') + const [users, setUsers] = useState([]) + const [currentUser, setCurrentUser] = useState(getInitialUser) + + const editor = useEditor({ + extensions: [ + StarterKit.configure({ + history: false, + }), + Highlight, + TaskList, + TaskItem, + CharacterCount.configure({ + limit: 10000, + }), + Collaboration.configure({ + document: ydoc, + }), + CollaborationCursor.configure({ + provider: websocketProvider, + onUpdate: updatedUsers => { + setUsers(updatedUsers) + }, + }), + ], + }) + + useEffect(() => { + // Store shared data persistently in browser to make offline editing possible + const indexeddbProvider = new IndexeddbPersistence(room, ydoc) + + indexeddbProvider.on('synced', () => { + console.log('Loaded content from database …') + }) + + // Update status changes + websocketProvider.on('status', event => { + setStatus(event.status) + }) + }, []) + + // Save current user to localStorage and emit to editor + useEffect(() => { + if (editor && currentUser) { + localStorage.setItem('currentUser', JSON.stringify(currentUser)) + editor.chain().focus().user(currentUser).run() + } + }, [editor, currentUser]) + + const setName = useCallback(() => { + const name = (window.prompt('Name') || '').trim().substring(0, 32) + + if (name) { + return setCurrentUser({ ...currentUser, name }) + } + }, [currentUser]) + + return ( +
+ {editor && } + +
+
+ {status === 'connected' + ? `${users.length} user${users.length === 1 ? '' : 's'} online in ${room}` + : 'offline'} +
+
+ +
+
+
+ ) +} diff --git a/demos/src/Examples/CollaborativeEditing/React/index.spec.js b/demos/src/Examples/CollaborativeEditing/React/index.spec.js new file mode 100644 index 00000000..222b2b4f --- /dev/null +++ b/demos/src/Examples/CollaborativeEditing/React/index.spec.js @@ -0,0 +1,7 @@ +context('/src/Examples/CollaborativeEditing/React/', () => { + before(() => { + cy.visit('/src/Examples/CollaborativeEditing/React/') + }) + + // TODO: Write tests +}) diff --git a/demos/src/Examples/CollaborativeEditing/React/styles.scss b/demos/src/Examples/CollaborativeEditing/React/styles.scss new file mode 100644 index 00000000..7fc44ecd --- /dev/null +++ b/demos/src/Examples/CollaborativeEditing/React/styles.scss @@ -0,0 +1,196 @@ +/* Basic editor styles */ +.ProseMirror { + > * + * { + margin-top: 0.75em; + } + + ul, + ol { + padding: 0 1rem; + } + + h1, + h2, + h3, + h4, + h5, + h6 { + line-height: 1.1; + } + + code { + background-color: rgba(#616161, 0.1); + color: #616161; + } + + pre { + background: #0d0d0d; + border-radius: 0.5rem; + color: #fff; + font-family: "JetBrainsMono", monospace; + padding: 0.75rem 1rem; + + code { + background: none; + color: inherit; + font-size: 0.8rem; + padding: 0; + } + } + + mark { + background-color: #faf594; + } + + img { + height: auto; + max-width: 100%; + } + + hr { + margin: 1rem 0; + } + + blockquote { + border-left: 2px solid rgba(#0d0d0d, 0.1); + padding-left: 1rem; + } + + hr { + border: none; + border-top: 2px solid rgba(#0d0d0d, 0.1); + margin: 2rem 0; + } + + ul[data-type="taskList"] { + list-style: none; + padding: 0; + + li { + align-items: center; + display: flex; + + > label { + flex: 0 0 auto; + margin-right: 0.5rem; + user-select: none; + } + + > div { + flex: 1 1 auto; + } + } + } +} + +.editor { + background-color: #fff; + border: 3px solid #0d0d0d; + border-radius: 0.75rem; + color: #0d0d0d; + display: flex; + flex-direction: column; + max-height: 26rem; + + &__header { + align-items: center; + border-bottom: 3px solid #0d0d0d; + display: flex; + flex: 0 0 auto; + flex-wrap: wrap; + padding: 0.25rem; + } + + &__content { + flex: 1 1 auto; + overflow-x: hidden; + overflow-y: auto; + padding: 1.25rem 1rem; + -webkit-overflow-scrolling: touch; + } + + &__footer { + align-items: center; + border-top: 3px solid #0d0d0d; + color: #0d0d0d; + display: flex; + flex: 0 0 auto; + font-size: 12px; + flex-wrap: wrap; + font-weight: 600; + justify-content: space-between; + padding: 0.25rem 0.75rem; + white-space: nowrap; + } + + /* Some information about the status */ + &__status { + align-items: center; + border-radius: 5px; + display: flex; + + &::before { + background: rgba(#0d0d0d, 0.5); + border-radius: 50%; + content: " "; + display: inline-block; + flex: 0 0 auto; + height: 0.5rem; + margin-right: 0.5rem; + width: 0.5rem; + } + + &--connecting::before { + background: #616161; + } + + &--connected::before { + background: #b9f18d; + } + } + + &__name { + button { + background: none; + border: none; + border-radius: 0.4rem; + color: #0d0d0d; + font: inherit; + font-size: 12px; + font-weight: 600; + padding: 0.25rem 0.5rem; + + &:hover { + background-color: #0d0d0d; + color: #fff; + } + } + } +} + +/* 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; +}