diff --git a/demos/src/Demos/SingleRoomCollab/React/MenuBar.jsx b/demos/src/Demos/SingleRoomCollab/React/MenuBar.jsx new file mode 100644 index 000000000..976df6152 --- /dev/null +++ b/demos/src/Demos/SingleRoomCollab/React/MenuBar.jsx @@ -0,0 +1,136 @@ +import './MenuBar.scss' + +import React, { Fragment } from 'react' + +import MenuItem from './MenuItem' + +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/Demos/SingleRoomCollab/React/MenuBar.scss b/demos/src/Demos/SingleRoomCollab/React/MenuBar.scss new file mode 100644 index 000000000..9f0833d86 --- /dev/null +++ b/demos/src/Demos/SingleRoomCollab/React/MenuBar.scss @@ -0,0 +1,7 @@ +.divider { + background-color: rgba(#fff, 0.25); + height: 1.25rem; + margin-left: 0.5rem; + margin-right: 0.75rem; + width: 1px; +} diff --git a/demos/src/Demos/SingleRoomCollab/React/MenuItem.jsx b/demos/src/Demos/SingleRoomCollab/React/MenuItem.jsx new file mode 100644 index 000000000..874bef9a3 --- /dev/null +++ b/demos/src/Demos/SingleRoomCollab/React/MenuItem.jsx @@ -0,0 +1,18 @@ +import './MenuItem.scss' + +import React from 'react' +import remixiconUrl from 'remixicon/fonts/remixicon.symbol.svg' + +export default ({ + icon, title, action, isActive = null, +}) => ( + +) diff --git a/demos/src/Demos/SingleRoomCollab/React/MenuItem.scss b/demos/src/Demos/SingleRoomCollab/React/MenuItem.scss new file mode 100644 index 000000000..c2e2e1843 --- /dev/null +++ b/demos/src/Demos/SingleRoomCollab/React/MenuItem.scss @@ -0,0 +1,22 @@ +.menu-item { + background-color: transparent; + border: none; + border-radius: 0.4rem; + color: #fff; + cursor: pointer; + 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: #303030; + } +} diff --git a/demos/src/Demos/SingleRoomCollab/React/index.html b/demos/src/Demos/SingleRoomCollab/React/index.html new file mode 100644 index 000000000..e69de29bb diff --git a/demos/src/Demos/SingleRoomCollab/React/index.jsx b/demos/src/Demos/SingleRoomCollab/React/index.jsx new file mode 100644 index 000000000..28471076e --- /dev/null +++ b/demos/src/Demos/SingleRoomCollab/React/index.jsx @@ -0,0 +1,132 @@ +import './styles.scss' + +import { TiptapCollabProvider } from '@hocuspocus/provider' +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' +import * as Y from 'yjs' + +import MenuBar from './MenuBar' + +const room = 'room-1' +const colors = ['#958DF1', '#F98181', '#FBBC88', '#FAF594', '#70CFF8', '#94FADB', '#B9F18D'] +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 getRandomColor = () => getRandomElement(colors) +const getRandomName = () => getRandomElement(names) + +const ydoc = new Y.Doc() +const websocketProvider = new TiptapCollabProvider({ + appId: '7j9y6m10', + name: room, + document: ydoc, +}) + +const getInitialUser = () => { + return JSON.parse(localStorage.getItem('currentUser')) || { + name: getRandomName(), + color: getRandomColor(), + } +} + +export default () => { + const [status, setStatus] = useState('connecting') + 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, + }), + ], + }) + + useEffect(() => { + // 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().updateUser(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' + ? `${editor.storage.collaborationCursor.users.length} user${editor.storage.collaborationCursor.users.length === 1 ? '' : 's'} online in ${room}` + : 'offline'} +
+
+ +
+
+
+ ) +} diff --git a/demos/src/Demos/SingleRoomCollab/React/index.spec.js b/demos/src/Demos/SingleRoomCollab/React/index.spec.js new file mode 100644 index 000000000..b31746f1a --- /dev/null +++ b/demos/src/Demos/SingleRoomCollab/React/index.spec.js @@ -0,0 +1,21 @@ +context('/src/Examples/CollaborativeEditing/React/', () => { + beforeEach(() => { + cy.visit('/src/Examples/CollaborativeEditing/React/') + }) + + /* it('should show the current room with participants', () => { + cy.wait(6000) + cy.get('.editor__status') + .should('contain', 'rooms.') + .should('contain', 'users online') + }) + + it('should allow user to change name', () => { + cy.window().then(win => { + cy.stub(win, 'prompt').returns('John Doe') + cy.get('.editor__name > button').click() + cy.wait(6000) + cy.get('.editor__name').should('contain', 'John Doe') + }) + }) */ +}) diff --git a/demos/src/Demos/SingleRoomCollab/React/styles.scss b/demos/src/Demos/SingleRoomCollab/React/styles.scss new file mode 100644 index 000000000..29749cf0a --- /dev/null +++ b/demos/src/Demos/SingleRoomCollab/React/styles.scss @@ -0,0 +1,199 @@ +/* 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; + background: #0d0d0d; + border-bottom: 3px solid #0d0d0d; + border-top-left-radius: 0.25rem; + border-top-right-radius: 0.25rem; + 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; +}