├── src ├── types │ ├── modules.d.ts │ ├── himalaya │ │ └── index.d.ts │ └── automerge │ │ ├── tsconfig.json │ │ └── index.d.ts ├── index.ts ├── content │ ├── fromRaw.ts │ ├── voidElements.ts │ ├── fromText.ts │ ├── fromJSON.ts │ ├── getContainerByObjectId.ts │ └── RichContent.ts ├── mutations │ ├── observerConfig.ts │ ├── simplifyCharQueue.ts │ ├── processDetachQueue.ts │ ├── processMutationList.ts │ ├── simplifyQueues.ts │ ├── processAttachQueue.ts │ ├── processBuildQueue.ts │ ├── handleCharacterData.ts │ └── handleMutation.ts ├── plugins │ └── strikethroughPlugin.ts ├── ranges │ ├── getIsBackward.ts │ ├── getNextColor.ts │ ├── defaultColors.ts │ ├── PeerRanges.ts │ ├── LocalRange.ts │ └── getRemoteRangeBBox.ts ├── components │ ├── DefaultCaretFlag.tsx │ ├── RemoteSelectionRange.tsx │ ├── RemoteCursor.tsx │ ├── DocNode.tsx │ ├── DefaultRemoteCaret.tsx │ └── Editor.tsx ├── RichDoc.ts └── network │ └── RichConnector.ts ├── code-of-conduct.md ├── docs └── rich2.gif ├── tslint.json ├── playground ├── index.css ├── index.tsx ├── server │ ├── template.html │ └── server.ts ├── webpack.config.dev.js └── App.tsx ├── .prettierrc ├── .gitignore ├── .editorconfig ├── .travis.yml ├── tsconfig.json ├── webpack.config.js ├── LICENSE ├── CONTRIBUTING.md ├── package.json └── README.md /src/types/modules.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'diff-match-patch' 2 | -------------------------------------------------------------------------------- /code-of-conduct.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | don't be a dick. 4 | -------------------------------------------------------------------------------- /docs/rich2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattkrick/rich/HEAD/docs/rich2.gif -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "tslint-config-standard" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /playground/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: sans-serif; 5 | } 6 | -------------------------------------------------------------------------------- /src/types/himalaya/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'himalaya' { 2 | function parse (json: string): Array 3 | } 4 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Editor } from './components/Editor' 2 | export { default as RichDoc } from './RichDoc' 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "bracketSpacing": false, 3 | "singleQuote": true, 4 | "arrowParens": "always", 5 | "printWidth": 100, 6 | "semi": false 7 | } 8 | -------------------------------------------------------------------------------- /src/content/fromRaw.ts: -------------------------------------------------------------------------------- 1 | import Automerge from 'automerge' 2 | 3 | const fromRaw = (raw: string) => { 4 | return Automerge.load(raw) 5 | } 6 | 7 | export default fromRaw 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | .history 4 | .nyc_output 5 | .DS_Store 6 | *.log 7 | .vscode 8 | .idea 9 | dist 10 | compiled 11 | .awcache 12 | .rpt2_cache 13 | -------------------------------------------------------------------------------- /playground/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import App from './App' 4 | import './index.css' 5 | 6 | ReactDOM.render(, document.getElementById('root')) 7 | -------------------------------------------------------------------------------- /src/mutations/observerConfig.ts: -------------------------------------------------------------------------------- 1 | const config = { 2 | attributes: true, 3 | childList: true, 4 | subtree: true, 5 | characterData: true 6 | // attributeOldValue: true, 7 | // characterDataOldValue: true 8 | } 9 | 10 | export default config 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | #root = true 2 | 3 | [*] 4 | indent_style = space 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | max_line_length = 100 10 | indent_size = 2 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /src/content/voidElements.ts: -------------------------------------------------------------------------------- 1 | export default new Set([ 2 | 'area', 3 | 'base', 4 | 'br', 5 | 'col', 6 | 'embed', 7 | 'hr', 8 | 'img', 9 | 'input', 10 | 'keygen', 11 | 'link', 12 | 'menuitem', 13 | 'meta', 14 | 'param', 15 | 'source', 16 | 'track', 17 | 'wbr' 18 | ]) 19 | -------------------------------------------------------------------------------- /playground/server/template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/content/fromText.ts: -------------------------------------------------------------------------------- 1 | import { parse } from 'himalaya' 2 | import fromJSON from './fromJSON' 3 | 4 | const fromText = (text: string) => { 5 | const html = `
${text}
` 6 | const json = parse(html)[0] 7 | // TODO crawl tree to rename class to className 8 | return fromJSON(json) 9 | } 10 | 11 | export default fromText 12 | -------------------------------------------------------------------------------- /src/plugins/strikethroughPlugin.ts: -------------------------------------------------------------------------------- 1 | import RichDoc from '../RichDoc' 2 | 3 | const strikeThroughPlugin = (doc: RichDoc) => ({ 4 | onKeyDown: (event: KeyboardEvent): boolean | void => { 5 | if (event.shiftKey && event.ctrlKey && event.key === 'x') { 6 | doc.toggleStyle({ textDecoration: 'line-through' }) 7 | } 8 | } 9 | }) 10 | 11 | export default strikeThroughPlugin 12 | -------------------------------------------------------------------------------- /src/ranges/getIsBackward.ts: -------------------------------------------------------------------------------- 1 | const getIsBackward = (selection: Selection) => { 2 | const { anchorNode, anchorOffset, focusNode, focusOffset, isCollapsed } = selection 3 | if (isCollapsed) return false 4 | const position = anchorNode.compareDocumentPosition(focusNode) 5 | return !(position === 4 || (position === 0 && anchorOffset < focusOffset)) 6 | } 7 | 8 | export default getIsBackward 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | branches: 3 | only: 4 | - master 5 | - /^greenkeeper/.*$/ 6 | cache: 7 | yarn: true 8 | directories: 9 | - node_modules 10 | notifications: 11 | email: false 12 | node_js: 13 | - node 14 | script: 15 | - npm run test:prod && npm run build 16 | after_success: 17 | - npm run report-coverage 18 | - npm run deploy-docs 19 | - npm run semantic-release 20 | -------------------------------------------------------------------------------- /src/ranges/getNextColor.ts: -------------------------------------------------------------------------------- 1 | import DEFAULT_COLORS from './defaultColors' 2 | 3 | let availableColors = [...DEFAULT_COLORS] 4 | const colorMap: {[actorId: string]: string} = {} 5 | 6 | const getActorColor = (actorId: string) => { 7 | if (!colorMap[actorId]) { 8 | const nextColor = availableColors.shift() as string 9 | if (availableColors.length === 0) { 10 | availableColors = [...DEFAULT_COLORS] 11 | } 12 | colorMap[actorId] = nextColor 13 | } 14 | return colorMap[actorId] 15 | } 16 | 17 | export default getActorColor 18 | -------------------------------------------------------------------------------- /playground/webpack.config.dev.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | module.exports = { 4 | mode: 'development', 5 | devtool: 'cheap-module-source-map', 6 | resolve: { 7 | extensions: ['.js', '.json', '.ts', '.tsx'], 8 | }, 9 | entry: { 10 | app: [path.join(__dirname, './index.tsx')] 11 | }, 12 | output: { 13 | path: path.join(__dirname, '../static'), 14 | filename: 'app.js', 15 | publicPath: '/static/', 16 | }, 17 | module: { 18 | rules: [ 19 | { 20 | test: /\.css$/, 21 | use: ['style-loader', 'css-loader'] 22 | }, 23 | { test: /\.tsx?$/, loader: "awesome-typescript-loader" }, 24 | ] 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/types/automerge/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "declaration": true, 5 | "declarationMap": true, 6 | "declarationDir": "dist/types", 7 | "esModuleInterop": true, 8 | "importHelpers": true, 9 | "jsx": "react", 10 | "lib": ["es2017", "dom"], 11 | "module":"es2015", 12 | "moduleResolution": "node", 13 | "noImplicitReturns": true, 14 | "noFallthroughCasesInSwitch": true, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true, 17 | "outDir": "dist/lib", 18 | "removeComments": true, 19 | "target": "es2017", 20 | "strict": true, 21 | "sourceMap": true, 22 | "baseUrl": "src/types", 23 | "typeRoots": ["src/types"] 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/components/DefaultCaretFlag.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | interface Props { 4 | color: string 5 | flag: string 6 | } 7 | 8 | export interface CaretFlagProps { 9 | peerId: string 10 | } 11 | 12 | export type CaretFlag = (props: CaretFlagProps) => string 13 | 14 | class DefaultCaretFlag extends React.Component { 15 | render() { 16 | const {color, flag} = this.props 17 | const flagStyle = { 18 | backgroundColor: color, 19 | color: 'white', 20 | fontSize: 10, 21 | top: -14, 22 | left: -2, 23 | position: 'absolute', 24 | padding: 2, 25 | whiteSpace: 'nowrap' 26 | } as React.CSSProperties 27 | return ( 28 | {flag} 29 | ) 30 | } 31 | } 32 | 33 | export default DefaultCaretFlag 34 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "declaration": true, 5 | "declarationMap": true, 6 | "declarationDir": "dist/types", 7 | "esModuleInterop": true, 8 | "importHelpers": true, 9 | "jsx": "react", 10 | "lib": ["es2017", "dom"], 11 | "module":"es2015", 12 | "moduleResolution": "node", 13 | "noImplicitReturns": true, 14 | "noFallthroughCasesInSwitch": true, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true, 17 | "outDir": "dist/lib", 18 | "removeComments": true, 19 | "target": "es2017", 20 | "strict": true, 21 | "sourceMap": true, 22 | "typeRoots": [ 23 | "node_modules/@types", 24 | "src/types" 25 | ] 26 | }, 27 | "include": [ 28 | "src" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /src/content/fromJSON.ts: -------------------------------------------------------------------------------- 1 | import Automerge, { AutomergeProxy } from 'automerge' 2 | import { AutomergeRootElement } from '../components/Editor' 3 | 4 | const setContent = (node: AutomergeProxy) => { 5 | if (node.type === 'text') { 6 | const { content } = node 7 | node.content = new Automerge.Text() 8 | node.content.insertAt(0, ...content) 9 | } else if (node.children) { 10 | node.children.forEach((child: AutomergeProxy) => { 11 | setContent(child) 12 | }) 13 | } 14 | } 15 | 16 | const fromJSON = (json: AutomergeRootElement) => { 17 | return Automerge.change(Automerge.init(), 'init', (proxyDoc: AutomergeProxy) => { 18 | Object.keys(json).forEach((key) => { 19 | proxyDoc[key] = json[key] 20 | }) 21 | setContent(proxyDoc) 22 | }) 23 | } 24 | 25 | export default fromJSON 26 | -------------------------------------------------------------------------------- /src/mutations/simplifyCharQueue.ts: -------------------------------------------------------------------------------- 1 | // // Don't bother updating something if we're just going to remove it 2 | // const simplifyCharQueue = (detachQueue: DetachQueue, charQueue: CharQueue) => { 3 | // return charQueue 4 | // if (!detachQueue.length || !charQueue.length) return charQueue 5 | // let isMatch = false 6 | // for (let ii = charQueue.length - 1; ii >= 0; ii--) { 7 | // const target = charQueue[ii] 8 | // for (let jj = detachQueue.length - 1; jj >= 0; jj--) { 9 | // const detachment = detachQueue[jj] 10 | // if (target === detachment.node) { 11 | // isMatch = true 12 | // charQueue[ii] = undefined 13 | // break 14 | // } 15 | // } 16 | // } 17 | // return isMatch ? charQueue.filter(Boolean) : charQueue 18 | // } 19 | // 20 | // export default simplifyCharQueue 21 | -------------------------------------------------------------------------------- /src/ranges/defaultColors.ts: -------------------------------------------------------------------------------- 1 | // const DEFAULT_COLOR_RAINBOW = [ 2 | // '#f44336', 3 | // '#E91E63', 4 | // '#9C27B0', 5 | // '#673AB7', 6 | // '#3F51B5', 7 | // '#2196F3', 8 | // '#03A9F4', 9 | // '#00BCD4', 10 | // '#009688', 11 | // '#4CAF50', 12 | // '#8BC34A', 13 | // '#CDDC39', 14 | // '#FFEB3B', 15 | // '#FFC107', 16 | // '#FF9800', 17 | // '#FF5722', 18 | // '#795548', 19 | // '#9E9E9E', 20 | // '#607D8B', 21 | // ] 22 | 23 | const DEFAULT_COLORS = [ 24 | '#3F51B5', 25 | '#FF9800', 26 | '#E91E63', 27 | '#4CAF50', 28 | '#673AB7', 29 | '#CDDC39', 30 | '#00BCD4', 31 | '#FF5722', 32 | '#FFEB3B', 33 | '#2196F3', 34 | '#f44336', 35 | '#8BC34A', 36 | '#9C27B0', 37 | '#009688', 38 | '#FFC107', 39 | '#795548', 40 | '#9E9E9E' 41 | ] 42 | export default DEFAULT_COLORS 43 | -------------------------------------------------------------------------------- /src/components/RemoteSelectionRange.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export interface BoundingBox { 4 | top: number 5 | left: number 6 | height: number 7 | width: number 8 | } 9 | 10 | interface Props { 11 | bbox: BoundingBox 12 | color: string 13 | } 14 | 15 | class RemoteSelectionRange extends React.Component { 16 | render() { 17 | const {color, bbox: {top, left, height, width}} = this.props 18 | const style = { 19 | position: 'absolute', 20 | background: color, 21 | opacity: 0.40, 22 | top, 23 | left, 24 | // matches browser default for things like br to show something was highlighted 25 | width: Math.max(width, 4), 26 | height 27 | } as React.CSSProperties 28 | return ( 29 | 30 | ) 31 | } 32 | } 33 | 34 | export default RemoteSelectionRange 35 | -------------------------------------------------------------------------------- /src/RichDoc.ts: -------------------------------------------------------------------------------- 1 | import RichContent from './content/RichContent' 2 | import PeerRanges from './ranges/PeerRanges' 3 | import LocalRange from './ranges/LocalRange' 4 | 5 | interface SupportedPluginHandlers { 6 | // DOMAttributes 7 | onKeyDown?: (event: KeyboardEvent) => boolean | void 8 | } 9 | 10 | type RichPlugin = (doc: RichDoc) => SupportedPluginHandlers 11 | 12 | class RichDoc { 13 | content: RichContent 14 | peerRanges: PeerRanges 15 | plugins: Array 16 | id: string 17 | localRange: LocalRange 18 | 19 | constructor (id: string, rawContent: string, plugins: Array = []) { 20 | this.content = RichContent.fromRaw(rawContent) 21 | this.peerRanges = new PeerRanges() 22 | this.localRange = new LocalRange() 23 | this.plugins = plugins 24 | this.id = id 25 | } 26 | 27 | toggleStyle (_cssObj: any) { 28 | // if (this.localRange.isCollapsed())) 29 | } 30 | } 31 | 32 | export default RichDoc 33 | -------------------------------------------------------------------------------- /src/content/getContainerByObjectId.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Uses a dfs because textNodes can't hold id attributes 3 | * and climbing up the JSON tree gets very messy 4 | */ 5 | import { AutomergeNode } from '../components/Editor' 6 | 7 | const getContainerByObjectId = (objectId: string, rootEl: Node): Node | null => { 8 | // TODO see if we can remove this conditional 9 | if (rootEl && rootEl._json && (rootEl._json as AutomergeNode)._objectId === objectId) { 10 | // this is how content editable keeps a caret in a br (hit backspace so there are no more chars on line, but don't remove line) 11 | return rootEl 12 | } 13 | 14 | const treeWalker = document.createTreeWalker(rootEl) 15 | while (true) { 16 | const nextNode = treeWalker.nextNode() 17 | if (nextNode && !nextNode._json) { 18 | debugger 19 | } 20 | if (!nextNode || (nextNode._json as AutomergeNode)._objectId === objectId) { 21 | return nextNode 22 | } 23 | } 24 | } 25 | 26 | export default getContainerByObjectId 27 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | module.exports = { 4 | mode: 'production', 5 | devtool: 'source-map', 6 | entry: path.join(__dirname, 'src', 'index.ts'), 7 | output: { 8 | path: path.join(__dirname, './dist'), 9 | filename: 'rich.js', 10 | library: 'Rich', 11 | // libraryTarget: 'umd' 12 | }, 13 | externals: { 14 | react: { 15 | root: 'React', 16 | commonjs2: 'react', 17 | commonjs: 'react', 18 | amd: 'react', 19 | }, 20 | 'react-dom': { 21 | root: 'ReactDOM', 22 | commonjs2: 'react-dom', 23 | commonjs: 'react-dom', 24 | amd: 'react-dom', 25 | }, 26 | }, 27 | resolve: { 28 | extensions: ['.js', '.json', '.ts', '.tsx'], 29 | }, 30 | module: { 31 | rules: [ 32 | { 33 | test: /\.(ts|tsx)$/, 34 | loader: "awesome-typescript-loader", 35 | // options: { 36 | // useCache: true, 37 | // useBabel: true 38 | // } 39 | }, 40 | ] 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2017 Matt Krick 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Easiest PRs Ever 2 | 3 | Easier PRs mean a stronger community. 4 | Rich is the easiest text editor to PR. 5 | 6 | 7 | ## Debugging / Adding Features 8 | 9 | `yarn build & yarn run dev` 10 | 11 | Rich was built using the included playground. 12 | It has the following features: 13 | - **Perfect source maps** (the playground compiles Rich from source to guarantee this) 14 | - **Built-in watcher** (`yarn build` uses the webpack watcher so changes to Rich propagate to the playground) 15 | - **Built-in centralized server** (for testing collaborative editing) 16 | - if testing on multiple devices, just change the ip address in `App.tsx` to your local network IP 17 | - if testing on mobile devices, do the above, plug in via usb, enable usb debugging, and use Chrome > Debugger > Remote Devices 18 | 19 | ## Reporting bugs 20 | 21 | Reproducing bugs in text editors can be tricky! 22 | Rich makes it easy. 23 | Just list the steps necessary to reproduce the bug in the playground. 24 | For complex bugs, you may need to PR the playground in order to reproduce. That's OK! 25 | Just open a PR with steps to reproduce the bug. 26 | -------------------------------------------------------------------------------- /src/mutations/processDetachQueue.ts: -------------------------------------------------------------------------------- 1 | import { AutomergeElement, AutomergeNode } from '../components/Editor' 2 | import { ChildListMutation, ChildListQueue } from './handleMutation' 3 | import RichContent from '../content/RichContent' 4 | import { AutomergeProxy } from 'automerge' 5 | 6 | const processDetachQueue = (detachQueue: ChildListQueue, content: RichContent) => { 7 | for (let ii = 0; ii < detachQueue.length; ii++) { 8 | const detachment = detachQueue[ii] 9 | const { node, target } = detachment as ChildListMutation 10 | // when collapsing 2 lines into 1 this occurs. // TODO see if we can move this to simplyQueues? 11 | const targetId = target._json && (target._json as AutomergeNode)._objectId 12 | if (!targetId) continue 13 | const removalIdx = (target._json as AutomergeElement).children!.findIndex( 14 | (child) => child._objectId === (node._json as AutomergeNode)._objectId 15 | ) 16 | if (removalIdx !== -1) { 17 | content.change_('remove node', (proxyDoc: AutomergeProxy) => { 18 | const targetDoc = proxyDoc._get(targetId) 19 | targetDoc.children.deleteAt(removalIdx) 20 | }) 21 | target._json = content.root._state.getIn(['opSet', 'cache', targetId]) 22 | } 23 | } 24 | } 25 | 26 | export default processDetachQueue 27 | -------------------------------------------------------------------------------- /src/mutations/processMutationList.ts: -------------------------------------------------------------------------------- 1 | import { CharQueue, ChildListQueue } from './handleMutation' 2 | 3 | const processMutationList = (mutationsList: Array) => { 4 | const rawCharQueue: CharQueue = new Set() 5 | const rawBuildQueue: ChildListQueue = [] 6 | const rawDetachQueue: ChildListQueue = [] 7 | // console.log('mutations', mutationsList) 8 | for (let ii = 0; ii < mutationsList.length; ii++) { 9 | const mutation = mutationsList[ii] 10 | if (!mutation.target) continue 11 | const { type } = mutation 12 | const target = mutation.target 13 | if (type === 'characterData') { 14 | rawCharQueue.add(target) 15 | } else if (type === 'childList' && target !== null) { 16 | const addedNodes = mutation.addedNodes 17 | for (let ii = 0; ii < addedNodes.length; ii++) { 18 | const node = addedNodes[ii] 19 | rawBuildQueue.push({ 20 | node, 21 | target 22 | }) 23 | } 24 | const removedNodes = mutation.removedNodes 25 | for (let ii = 0; ii < removedNodes.length; ii++) { 26 | const node = removedNodes[ii] 27 | rawDetachQueue.push({ 28 | node, 29 | target 30 | }) 31 | } 32 | } 33 | } 34 | return { rawCharQueue, rawBuildQueue, rawDetachQueue } 35 | } 36 | 37 | export default processMutationList 38 | -------------------------------------------------------------------------------- /src/ranges/PeerRanges.ts: -------------------------------------------------------------------------------- 1 | import { RichRange } from './LocalRange' 2 | 3 | export interface PeerRange extends RichRange { 4 | peerId: string 5 | updatedAt: number 6 | } 7 | 8 | interface PeerRangeMap { 9 | [actorId: string]: PeerRange 10 | } 11 | 12 | class PeerRanges { 13 | peerRangeMap: PeerRangeMap 14 | dirty: boolean 15 | 16 | constructor (remoteRangeMap?: PeerRangeMap) { 17 | this.peerRangeMap = remoteRangeMap || {} 18 | this.dirty = true 19 | } 20 | 21 | flush () { 22 | const dirty = this.dirty 23 | this.dirty = false 24 | return dirty 25 | } 26 | 27 | map (mapFn: (range: PeerRange, idx: number, self: PeerRangeMap) => any): Array { 28 | const peerIds = Object.keys(this.peerRangeMap) 29 | return peerIds.map((peerId, idx) => mapFn(this.peerRangeMap[peerId], idx, this.peerRangeMap)) 30 | } 31 | 32 | updatePeer (peerId: string, updatedRange: RichRange | null | undefined): this { 33 | if (updatedRange) { 34 | this.dirty = true 35 | this.peerRangeMap[peerId] = { 36 | ...updatedRange, 37 | peerId, 38 | updatedAt: Date.now() 39 | } 40 | } else if (updatedRange === null) { 41 | this.removePeer_(peerId) 42 | } 43 | return this 44 | } 45 | 46 | removePeer_ (peerId: string): this { 47 | delete this.peerRangeMap[peerId] 48 | return this 49 | } 50 | } 51 | 52 | export default PeerRanges 53 | -------------------------------------------------------------------------------- /src/components/RemoteCursor.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import DefaultRemoteCaret from "./DefaultRemoteCaret"; 3 | import getRemoteRangeBBox from '../ranges/getRemoteRangeBBox' 4 | import RemoteSelectionRange, {BoundingBox} from "./RemoteSelectionRange"; 5 | import getActorColor from "../ranges/getNextColor"; 6 | import PeerRanges from "../ranges/PeerRanges"; 7 | import {CaretFlag, CaretFlagProps} from "./DefaultCaretFlag"; 8 | 9 | interface Props { 10 | getName?: (peerId: string) => string 11 | peerRanges: PeerRanges 12 | rootEl: Node | null, 13 | caretFlag?: CaretFlag 14 | } 15 | 16 | const defaultCaretFlag = (props: CaretFlagProps) => props.peerId 17 | 18 | class RemoteCursor extends React.Component { 19 | render() { 20 | const {caretFlag = defaultCaretFlag, peerRanges, rootEl} = this.props 21 | if (!rootEl) return null 22 | return peerRanges.map((peerRange) => { 23 | const result = getRemoteRangeBBox(peerRange, rootEl) 24 | if (!result) return null 25 | const {peerId} = peerRange 26 | const {selectionBoxes, caretCoords: {left, top, height}} = result 27 | const color = getActorColor(peerId) 28 | const flag = caretFlag({peerId}) 29 | return ( 30 | 31 | 32 | {selectionBoxes.map((bbox: BoundingBox) => { 33 | return ( 34 | 35 | ) 36 | })} 37 | 38 | ) 39 | }) 40 | } 41 | } 42 | 43 | export default RemoteCursor 44 | -------------------------------------------------------------------------------- /src/mutations/simplifyQueues.ts: -------------------------------------------------------------------------------- 1 | // if we're told to build something in the same breath we're told to destroy it, don't both building it 2 | 3 | import { ChildListMutation, ChildListQueue } from './handleMutation' 4 | 5 | const simplifyQueues = (buildQueue: ChildListQueue, detachQueue: ChildListQueue) => { 6 | if (!buildQueue.length) return { buildQueue, detachQueue } 7 | let isMatch = false 8 | const removedNodes: Array = [] 9 | for (let ii = buildQueue.length - 1; ii >= 0; ii--) { 10 | const buildingBlock = buildQueue[ii] as ChildListMutation 11 | for (let jj = detachQueue.length - 1; jj >= 0; jj--) { 12 | const detachment = detachQueue[jj] 13 | if ( 14 | detachment && 15 | buildingBlock.node === detachment.node && 16 | buildingBlock.target === detachment.target 17 | ) { 18 | isMatch = true 19 | detachQueue[jj] = undefined 20 | buildQueue[ii] = undefined 21 | removedNodes.push(detachment.node) 22 | break 23 | } 24 | } 25 | } 26 | if (!isMatch) return { buildQueue, detachQueue } 27 | 28 | // Sometimes, mutations aren't even triggered for child nodes that get added & deleted (like paste + undo) 29 | for (let ii = 0; ii < buildQueue.length; ii++) { 30 | const buildingBlock = buildQueue[ii] 31 | if (!buildingBlock) continue 32 | for (let jj = 0; jj < removedNodes.length; jj++) { 33 | const removedNode = removedNodes[jj] 34 | if (buildingBlock.target === removedNode) { 35 | buildQueue[ii] = undefined 36 | break 37 | } 38 | } 39 | } 40 | 41 | return { buildQueue: buildQueue.filter(Boolean), detachQueue: detachQueue.filter(Boolean) } 42 | } 43 | 44 | export default simplifyQueues 45 | -------------------------------------------------------------------------------- /src/mutations/processAttachQueue.ts: -------------------------------------------------------------------------------- 1 | import Automerge, { AutomergeProxy } from 'automerge' 2 | import { AutomergeNode, AutomergeTextNode, TemporaryTextNode } from '../components/Editor' 3 | import { AttachQueue } from './processBuildQueue' 4 | import RichContent from '../content/RichContent' 5 | 6 | const processAttachQueue = (attachQueue: AttachQueue, content: RichContent) => { 7 | if (!attachQueue.size) return 8 | const attachNodes = (nodeSet: Set | undefined, target: Node) => { 9 | if (!nodeSet) return 10 | // if the target doesn't exist yet, it's a child something that hasn't been added yet 11 | const targetId = target._json && (target._json as AutomergeNode)._objectId 12 | if (!targetId) return 13 | for (let ii = 0; ii < target.childNodes.length; ii++) { 14 | const node = target.childNodes[ii] 15 | if (!nodeSet.has(node)) continue // always noop? probably in delete queue 16 | content.change_((proxyDoc: AutomergeProxy) => { 17 | const targetDoc = proxyDoc._get(targetId) 18 | targetDoc.children.insertAt(ii, node._json) 19 | const textNode = targetDoc.children[ii] 20 | const { content } = node._json as AutomergeTextNode | TemporaryTextNode 21 | if (typeof content === 'string') { 22 | textNode.content = new Automerge.Text() 23 | textNode.content.insertAt(0, ...content.split('')) 24 | } 25 | }) 26 | target._json = content.root._state.getIn(['opSet', 'cache', targetId]) 27 | node._json = (target._json as AutomergeNode).children[ii] 28 | const childNodeSet = attachQueue.get(node) 29 | attachNodes(childNodeSet, node) 30 | } 31 | attachQueue.set(target, undefined) 32 | } 33 | attachQueue.forEach(attachNodes) 34 | } 35 | 36 | export default processAttachQueue 37 | -------------------------------------------------------------------------------- /src/content/RichContent.ts: -------------------------------------------------------------------------------- 1 | import Automerge, { 2 | AutomergeChanges, 3 | AutomergeClock, 4 | AutomergeUpdater, 5 | getMissingChanges 6 | } from 'automerge' 7 | import fromJSON from './fromJSON' 8 | import fromText from './fromText' 9 | import { AutomergeRootElement } from '../components/Editor' 10 | 11 | // all roots are elements 12 | declare module 'automerge' { 13 | interface AutomergeRoot extends AutomergeRootElement {} 14 | } 15 | 16 | class RichContent { 17 | static fromRaw (raw: string) { 18 | const root = Automerge.load(raw) 19 | return new RichContent(root) 20 | } 21 | 22 | static fromJSON (json: any) { 23 | const root = fromJSON(json) 24 | return new RichContent(root) 25 | } 26 | 27 | static fromText (text: string) { 28 | const root = fromText(text) 29 | return new RichContent(root) 30 | } 31 | 32 | root: AutomergeRootElement 33 | isDirty: boolean 34 | 35 | constructor (store: AutomergeRootElement) { 36 | this.root = store 37 | this.isDirty = true 38 | } 39 | 40 | applyChanges_ (changes?: AutomergeChanges) { 41 | if (changes) { 42 | const nextDoc = Automerge.applyChanges(this.root, changes) 43 | this.root = nextDoc 44 | } 45 | return this 46 | } 47 | 48 | change_ (messageOrUpdater: string | AutomergeUpdater, maybeUpdater?: AutomergeUpdater) { 49 | const nextDoc = Automerge.change(this.root, messageOrUpdater, maybeUpdater) 50 | // pointless for now, but will improve with https://github.com/automerge/automerge/issues/107 51 | this.isDirty = this.isDirty || nextDoc !== this.root 52 | this.root = nextDoc 53 | return this 54 | } 55 | 56 | flushChanges (clock: AutomergeClock) { 57 | this.isDirty = false 58 | const myOpSet = this.root._state.get('opSet') 59 | return getMissingChanges(myOpSet, clock) 60 | } 61 | } 62 | 63 | export default RichContent 64 | -------------------------------------------------------------------------------- /src/components/DocNode.tsx: -------------------------------------------------------------------------------- 1 | import React, {ReactElement} from 'react' 2 | import * as ReactDOM from 'react-dom' 3 | import {AutomergeElement, AutomergeNode, AutomergeTextNode, Schema, TemporaryNode} from "./Editor"; 4 | 5 | declare global { 6 | interface Node { 7 | _json?: AutomergeNode | TemporaryNode 8 | } 9 | } 10 | 11 | interface Props { 12 | node: AutomergeNode 13 | schema?: Schema 14 | } 15 | 16 | const getElementType = (node: AutomergeElement, schema?: Schema) => { 17 | const {tagName, meta} = node 18 | if (meta) { 19 | return tagName 20 | schema 21 | // TODO support custom nodes similar to how draft does it 22 | // const tag = schema(node) 23 | // if (tag) return tag 24 | } 25 | return tagName 26 | } 27 | 28 | const renderText = (node: AutomergeTextNode) => { 29 | return node.content.join('') 30 | } 31 | 32 | const renderElement = (node: AutomergeElement, schema?: Schema): ReactElement<{}> => { 33 | const Element = getElementType(node, schema) 34 | const {children, attributes} = node 35 | return ( 36 | 37 | {children && children.map((child: AutomergeNode) => ( 38 | 39 | ))} 40 | 41 | ) 42 | } 43 | 44 | const renderNodeType = (node: AutomergeNode, schema?: Schema) => { 45 | switch (node.type) { 46 | case 'text': 47 | return renderText(node) 48 | case 'element': 49 | return renderElement(node, schema) 50 | default: 51 | return null 52 | } 53 | } 54 | 55 | class DocNode extends React.Component { 56 | 57 | componentDidMount() { 58 | this.updateNode() 59 | } 60 | 61 | componentDidUpdate() { 62 | this.updateNode() 63 | } 64 | 65 | updateNode() { 66 | const node = ReactDOM.findDOMNode(this)! 67 | node._json = this.props.node 68 | } 69 | 70 | render() { 71 | const {node, schema} = this.props 72 | return renderNodeType(node, schema) 73 | } 74 | } 75 | 76 | export default DocNode 77 | -------------------------------------------------------------------------------- /src/components/DefaultRemoteCaret.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import DefaultCaretFlag from "./DefaultCaretFlag"; 3 | import {PeerRange} from "../ranges/PeerRanges"; 4 | 5 | interface Props { 6 | left: number, 7 | top: number, 8 | height: number, 9 | peerRange: PeerRange 10 | color: string 11 | flag: string 12 | } 13 | 14 | interface State { 15 | showFlag: boolean 16 | } 17 | 18 | class DefaultRemoteCaret extends React.Component { 19 | state = { 20 | showFlag: true 21 | } 22 | 23 | hideFlagTimer?: number 24 | 25 | componentDidMount() { 26 | this.scheduleHideFlag() 27 | } 28 | 29 | componentDidUpdate(prevProps: Props) { 30 | if (prevProps.peerRange !== this.props.peerRange) { 31 | this.scheduleHideFlag() 32 | } 33 | } 34 | 35 | componentWillUnmount() { 36 | clearTimeout(this.hideFlagTimer) 37 | } 38 | 39 | scheduleHideFlag = () => { 40 | clearTimeout(this.hideFlagTimer) 41 | if (!this.state.showFlag) { 42 | this.setState({ 43 | showFlag: true 44 | }) 45 | } 46 | this.hideFlagTimer = window.setTimeout(() => { 47 | this.setState({ 48 | showFlag: false 49 | }) 50 | }, 3000) 51 | } 52 | 53 | render() { 54 | const {color, left, top, height, flag} = this.props 55 | const {showFlag} = this.state 56 | const style = { 57 | position: 'absolute', 58 | left, 59 | top 60 | } as React.CSSProperties 61 | const caretStyle = { 62 | borderLeft: `2px solid ${color}`, 63 | height, 64 | position: 'absolute', 65 | width: 1, 66 | } as React.CSSProperties 67 | const topStyle = { 68 | backgroundColor: color, 69 | position: 'absolute', 70 | left: -2, 71 | top: -2, 72 | height: 6 73 | } as React.CSSProperties 74 | 75 | return ( 76 |
77 | 78 | 79 | {showFlag && } 80 |
81 | ) 82 | } 83 | } 84 | 85 | export default DefaultRemoteCaret 86 | -------------------------------------------------------------------------------- /src/types/automerge/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'automerge' { 2 | import { List, Map } from 'immutable' 3 | interface MappedObject extends Map { 4 | toJS (): U 5 | get (key: K & string): U[K] 6 | } 7 | interface AutomergeClockObject { 8 | [actorId: string]: number 9 | } 10 | type AutomergeClock = MappedObject 11 | interface AutomergeOpSetObject { 12 | states: Map 13 | history: List 14 | byObject: Map 15 | clock: AutomergeClock 16 | deps: Map 17 | local: List 18 | queue: List 19 | } 20 | 21 | type AutomergeOpSet = MappedObject 22 | 23 | interface AutomergeStateObject { 24 | actorId: string 25 | opSet: AutomergeOpSet 26 | } 27 | 28 | type AutomergeStateMap = MappedObject 29 | type AutomergeChanges = List 30 | 31 | interface AutomergeObject { 32 | _state: AutomergeStateMap 33 | _actorId: string 34 | _objectId: string 35 | [key: string]: any 36 | } 37 | 38 | export interface AutomergeRoot extends AutomergeObject { 39 | // TODO export objectId from OpSet 40 | _objectId: '00000000-0000-0000-0000-000000000000' 41 | } 42 | 43 | export interface AutomergeProxy { 44 | insertAt (index: number, ...itemsToAdd: Array): AutomergeProxy 45 | deleteAt (index: number, numberToDelete: number): AutomergeProxy 46 | [key: string]: any 47 | } 48 | 49 | type AutomergeUpdater = (proxyDoc: AutomergeProxy) => void 50 | class Text { 51 | _objectId: string 52 | join (delim: string): string 53 | insertAt (index: number, ...itemsToAdd: Array): AutomergeProxy 54 | deleteAt (index: number, numberToDelete?: number | Array): AutomergeProxy 55 | } 56 | function applyChanges (root: AutomergeRoot, changes: AutomergeChanges): AutomergeRoot 57 | function change ( 58 | root: AutomergeRoot, 59 | message?: string | AutomergeUpdater, 60 | updater?: AutomergeUpdater | undefined 61 | ): AutomergeRoot 62 | function getMissingChanges (opSet: AutomergeOpSet, clock: AutomergeClock): AutomergeChanges 63 | function init (actorId?: string): AutomergeRoot 64 | function load (raw: string): AutomergeRoot 65 | } 66 | -------------------------------------------------------------------------------- /src/mutations/processBuildQueue.ts: -------------------------------------------------------------------------------- 1 | import voidElements from '../content/voidElements' 2 | import { AutomergeElement } from '../components/Editor' 3 | import { ChildListMutation, ChildListQueue } from './handleMutation' 4 | 5 | export type AttachQueue = Map | undefined> 6 | 7 | const createLeaf = (node: Node) => { 8 | if (node.nodeType === Node.TEXT_NODE) { 9 | node._json = { 10 | type: 'text', 11 | content: node.textContent || '' 12 | } 13 | } else { 14 | const tagName = (node as Element).tagName.toLowerCase() 15 | node._json = { 16 | type: 'element', 17 | tagName, 18 | attributes: [] 19 | } 20 | if (!voidElements.has(tagName)) { 21 | node._json.children = [] 22 | } 23 | } 24 | } 25 | 26 | const completeNode = (node: Node, target: Node, attachQueue: AttachQueue) => { 27 | for (let ii = 0; ii < node.childNodes.length; ii++) { 28 | const child = node.childNodes[ii] 29 | if (child._json) continue 30 | completeNode(child, node, attachQueue) 31 | } 32 | 33 | // now that all the children are accounted for, see if the node needs to be attached 34 | if (!node._json) { 35 | // if the node has no json, it definitely needs ot be attached 36 | createLeaf(node) 37 | } else if ( 38 | target._json && 39 | target._json.type === 'element' && 40 | target._json.children && 41 | target._json.children.find( 42 | (child) => child._objectId === (node._json as AutomergeElement)._objectId 43 | ) 44 | ) { 45 | // if it has json, see if the target already owns it. if so, no attachment necessary 46 | return 47 | } 48 | const targetQueue = attachQueue.get(target) 49 | if (!targetQueue) { 50 | attachQueue.set(target, new Set([node])) 51 | } else { 52 | targetQueue.add(node) 53 | } 54 | } 55 | 56 | // there are no guarantees that every node will be accounted for in the mutation events 57 | // some nodes include their children (and maybe grandchildren) so we 58 | const processBuildQueue = (buildQueue: ChildListQueue) => { 59 | const attachQueue = new Map() 60 | for (let ii = 0; ii < buildQueue.length; ii++) { 61 | const nodeToBuild = buildQueue[ii] as ChildListMutation 62 | const { node, target } = nodeToBuild 63 | completeNode(node, target, attachQueue) 64 | } 65 | return attachQueue 66 | } 67 | 68 | export default processBuildQueue 69 | -------------------------------------------------------------------------------- /src/mutations/handleCharacterData.ts: -------------------------------------------------------------------------------- 1 | import * as DMP from 'diff-match-patch' 2 | import { AutomergeTextNode } from '../components/Editor' 3 | import RichContent from '../content/RichContent' 4 | import { AutomergeProxy } from 'automerge' 5 | 6 | const dmp = new DMP() 7 | 8 | interface CharOp { 9 | contentId: string 10 | oldValue: string 11 | target: Node 12 | targetId: string 13 | newValue: string 14 | } 15 | 16 | type Ops = Array 17 | 18 | const makeTextDiff = (contentProxy: AutomergeProxy, oldValue: string, newValue: string) => { 19 | const diffs = dmp.diff_main(oldValue, newValue) 20 | let idx = 0 21 | for (let ii = 0; ii < diffs.length; ii++) { 22 | const diff = diffs[ii] 23 | if (diff[0] === 0) { 24 | idx += diff[1].length 25 | } else if (diff[0] === -1) { 26 | contentProxy.deleteAt(idx, diff[1].length) 27 | } else { 28 | const charArr = diff[1].split('') 29 | contentProxy.insertAt(idx, ...charArr) 30 | idx += diff[1].length 31 | } 32 | } 33 | } 34 | 35 | const commitChanges = (content: RichContent, ops: Ops) => { 36 | content.change_('char', (proxyDoc) => { 37 | ops.forEach(({ oldValue, contentId, newValue }) => { 38 | const contentProxy = proxyDoc._get(contentId) 39 | makeTextDiff(contentProxy, oldValue, newValue) 40 | }) 41 | }) 42 | } 43 | 44 | const updateSchema = (content: RichContent, ops: Ops) => { 45 | ops.forEach(({ targetId, target }) => { 46 | target._json = content.root._state.getIn(['opSet', 'cache', targetId]) 47 | }) 48 | } 49 | 50 | const handleCharacterData = (charQueue: Array, content: RichContent) => { 51 | const ops: Ops = [] 52 | for (let ii = 0; ii < charQueue.length; ii++) { 53 | const target = charQueue[ii] 54 | const newValue = target.textContent || '' 55 | const { content, _objectId: targetId } = target._json as AutomergeTextNode 56 | const oldValue = content.join('') 57 | // json won't exist if the target was detached, so we can just ignore this 58 | if (!target._json || oldValue === newValue) continue 59 | ops.push({ 60 | contentId: content._objectId, 61 | oldValue, 62 | target, 63 | targetId, 64 | newValue 65 | }) 66 | } 67 | if (ops.length) { 68 | commitChanges(content, ops) 69 | updateSchema(content, ops) 70 | } 71 | } 72 | 73 | export default handleCharacterData 74 | -------------------------------------------------------------------------------- /playground/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {Editor} from '../src/index' 3 | import FastRTCSwarm from '@mattkrick/fast-rtc-swarm' 4 | import RichConnector, {RICH_CHANGE} from '../src/network/RichConnector' 5 | import strikethroughPlugin from '../src/plugins/strikethroughPlugin' 6 | import RichDoc from "../src/RichDoc"; 7 | import {CaretFlagProps} from "../src/components/DefaultCaretFlag"; 8 | 9 | let privateAddress 10 | // privateAddress = '192.168.1.103' // change this to your router-address address for easy LAN testing 11 | const socket = new WebSocket(`ws://${privateAddress || 'localhost'}:3000`) 12 | 13 | interface State { 14 | doc: RichDoc 15 | } 16 | 17 | interface Props { 18 | } 19 | 20 | const caretFlag = (_props: CaretFlagProps) => { 21 | return 'Matt' 22 | } 23 | 24 | class App extends React.Component { 25 | state = { 26 | doc: new RichDoc('1', (window as any)._VAL_, [strikethroughPlugin]), 27 | } 28 | swarm!: FastRTCSwarm 29 | richConnector!: RichConnector 30 | 31 | constructor(props: Props) { 32 | super(props) 33 | this.createRichConnector() 34 | socket.addEventListener('open', () => { 35 | this.createSwarm() 36 | }) 37 | } 38 | 39 | createSwarm() { 40 | this.swarm = new FastRTCSwarm() 41 | this.richConnector.addSwarm(this.swarm) 42 | this.swarm.on('signal', (signal) => { 43 | socket.send(JSON.stringify(signal)) 44 | }) 45 | socket.addEventListener('message', (event) => { 46 | const payload = JSON.parse(event.data) 47 | this.swarm.dispatch(payload) 48 | }) 49 | } 50 | 51 | createRichConnector() { 52 | const {doc} = this.state 53 | this.richConnector = new RichConnector() 54 | this.richConnector.addDoc(doc) 55 | this.richConnector.on(RICH_CHANGE, () => { 56 | this.forceUpdate() 57 | }) 58 | } 59 | 60 | onChange = (doc: RichDoc, isContentUpdate: boolean) => { 61 | this.richConnector.dispatch(doc.id) 62 | if (isContentUpdate) { 63 | this.forceUpdate() 64 | } 65 | } 66 | 67 | render() { 68 | const {doc} = this.state 69 | return ( 70 |
78 | 79 |
80 | ) 81 | } 82 | } 83 | 84 | export default App 85 | -------------------------------------------------------------------------------- /src/mutations/handleMutation.ts: -------------------------------------------------------------------------------- 1 | import processBuildQueue from './processBuildQueue' 2 | import processAttachQueue from './processAttachQueue' 3 | import processDetachQueue from './processDetachQueue' 4 | import observerConfig from './observerConfig' 5 | import handleCharacterData from './handleCharacterData' 6 | import simplifyQueues from './simplifyQueues' 7 | import Editor from '../components/Editor' 8 | import processMutationList from './processMutationList' 9 | 10 | export interface ChildListMutation { 11 | node: Node 12 | target: Node 13 | } 14 | 15 | export type ChildListQueue = Array 16 | export type CharQueue = Set 17 | 18 | const handleMutation = (self: Editor) => (mutationsList: Array) => { 19 | const { onChange, doc } = self.props 20 | const { content, localRange } = doc 21 | localRange.cacheRange() 22 | const rootEl = self.rootRef.current as HTMLDivElement 23 | const observer = self.observer as MutationObserver 24 | 25 | observer.disconnect() // ignore any DOM changes while we correct & calculate mutations 26 | const { rawCharQueue, rawBuildQueue, rawDetachQueue } = processMutationList(mutationsList) 27 | 28 | // if the same node is added & removed to/from the same target, cancel them both out 29 | const charQueue = Array.from(rawCharQueue) 30 | const { buildQueue, detachQueue } = simplifyQueues(rawBuildQueue, rawDetachQueue) 31 | 32 | // turn nodes into JSON 33 | const attachQueue = processBuildQueue(buildQueue) 34 | 35 | // link nodes together 36 | processAttachQueue(attachQueue, content) 37 | 38 | // remove nodes 39 | processDetachQueue(detachQueue, content) 40 | 41 | const isContentUpdate = content.isDirty 42 | 43 | // update some nodes (do this last to avoid updating something that's being removed) 44 | handleCharacterData(charQueue, content) 45 | 46 | /* 47 | * this is the magic. now that we've cloned the state into a CRDT json, we can undo it 48 | * & recreate the state from JSON through react. 49 | * this is necessary so react can update its vdom 50 | * in the future, maybe we can do away with react, but then plugins may suffer. TBD 51 | */ 52 | if (isContentUpdate) { 53 | document.execCommand('undo') 54 | } 55 | 56 | const isRangeUpdate = localRange.fromCache() 57 | observer.observe(rootEl, observerConfig) // side-effects complete! resume listening for user events 58 | if (isContentUpdate || isRangeUpdate) { 59 | onChange(doc, isContentUpdate) 60 | } 61 | } 62 | 63 | export default handleMutation 64 | -------------------------------------------------------------------------------- /playground/server/server.ts: -------------------------------------------------------------------------------- 1 | import {AutomergeElement} from '../../src/components/Editor' 2 | import {AutomergeProxy} from "automerge"; 3 | 4 | const {Server} = require('ws') 5 | const http = require('http') 6 | const express = require('express') 7 | const config = require('../webpack.config.dev.js') 8 | const webpack = require('webpack') 9 | const fs = require('fs') 10 | const path = require('path') 11 | const {parse} = require('himalaya') 12 | const Automerge = require('automerge') 13 | const handleOnMessage = require('@mattkrick/fast-rtc-swarm/server') 14 | 15 | const html = fs.readFileSync(path.join(__dirname, './template.html'), 'utf8') 16 | const PORT = 3000 17 | const app = express() 18 | const server = http.createServer(app) 19 | const wss = new Server({server}) 20 | server.listen(PORT) 21 | const compiler = webpack(config) 22 | const setContent = (node: AutomergeProxy) => { 23 | if (node.type === 'text') { 24 | const { content } = node 25 | node.content = new Automerge.Text() 26 | node.content.insertAt(0, ...content) 27 | } else if (node.children) { 28 | node.children.forEach((child: AutomergeProxy) => { 29 | setContent(child) 30 | }) 31 | } 32 | } 33 | 34 | // too lazy to make the playground handle imports for now 35 | const fromJSON = (json: AutomergeElement) => { 36 | return Automerge.change(Automerge.init(), 'init', (proxyDoc: any) => { 37 | Object.keys(json).forEach((key) => { 38 | proxyDoc[key] = json[key] 39 | }) 40 | setContent(proxyDoc) 41 | }) 42 | } 43 | 44 | const val = fromJSON(parse('
Hello
, there
world
')[0]) 45 | const valStr = Automerge.save(val) 46 | 47 | app.use( 48 | require('webpack-dev-middleware')(compiler, { 49 | logLevel: 'warn', 50 | noInfo: false, 51 | publicPath: config.output.publicPath, 52 | stats: { 53 | chunks: false, 54 | colors: true 55 | }, 56 | watchOptions: { 57 | poll: true, 58 | aggregateTimeout: 300 59 | } 60 | }) 61 | ) 62 | 63 | wss.on('connection', (ws: any) => { 64 | ws.on('message', (message: string) => { 65 | const payload = JSON.parse(message) 66 | try { 67 | if (handleOnMessage.default(wss.clients, ws, payload)) return 68 | } catch{ 69 | return 70 | } 71 | 72 | if (payload.type === 'change') { 73 | for (let client of wss.clients) { 74 | if (client !== ws && client.readyState === ws.OPEN) { 75 | client.send(message) 76 | } 77 | } 78 | } 79 | }) 80 | }) 81 | 82 | app.use('*', (_req: any, res: any) => { 83 | const html2 = html.replace( 84 | '', 85 | `` 86 | ) 87 | res.send(html2) 88 | }) 89 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@mattkrick/rich", 3 | "version": "0.0.1", 4 | "description": "A decentralized collaborative rich text editor powered by DOM mutations, CRDTs, and WebRTC", 5 | "keywords": [ 6 | "CRDT", 7 | "wysiwyg", 8 | "rich text", 9 | "editor", 10 | "collaborative", 11 | "google docs", 12 | "text" 13 | ], 14 | "main": "dist/rich.js", 15 | "typings": "dist/types/rich.d.ts", 16 | "files": [ 17 | "dist" 18 | ], 19 | "author": "Matt Krick ", 20 | "repository": { 21 | "type": "git", 22 | "url": "" 23 | }, 24 | "license": "MIT", 25 | "engines": { 26 | "node": ">=6.0.0" 27 | }, 28 | "scripts": { 29 | "dev": "ts-node playground/server/server.ts", 30 | "lint": "yarn prettier && yarn standard", 31 | "prebuild": "rimraf dist", 32 | "build": "webpack --config webpack.config.js -w", 33 | "build:playground": "tsc playground/server/server.ts", 34 | "prettier": "prettier --write --loglevel warn src/**/*.{ts,tsx} playground/**/*.{ts,tsx}", 35 | "precommit": "lint-staged", 36 | "standard": "tslint -c tslint.json --project tsconfig.json --fix src/**/*.{ts,tsx}" 37 | }, 38 | "lint-staged": { 39 | "src/**/*.ts": [ 40 | "prettier --write", 41 | "tslint --fix", 42 | "git add" 43 | ] 44 | }, 45 | "dependencies": { 46 | "@mattkrick/fast-rtc-peer": "^0.0.3", 47 | "@mattkrick/fast-rtc-swarm": "^0.0.3", 48 | "automerge": "^0.8.0", 49 | "diff-match-patch": "^1.0.1", 50 | "eventemitter3": "^3.1.0", 51 | "himalaya": "^1.1.0", 52 | "immutable": "^3.8.2", 53 | "tslib": "^1.9.3", 54 | "uuid": "^3.3.2" 55 | }, 56 | "peerDependencies": { 57 | "react": "^16.4.1", 58 | "react-dom": "^16.4.1" 59 | }, 60 | "devDependencies": { 61 | "@types/express": "^4.16.0", 62 | "@types/jest": "^22.0.0", 63 | "@types/react": "^16.4.6", 64 | "@types/react-dom": "^16.0.6", 65 | "@types/webpack": "^4.4.8", 66 | "awesome-typescript-loader": "^5.2.0", 67 | "clean-webpack-plugin": "^0.1.19", 68 | "css-loader": "^1.0.0", 69 | "express": "^4.16.3", 70 | "gh-pages": "^1.2.0", 71 | "husky": "^0.14.0", 72 | "lint-staged": "^7.1.3", 73 | "prettier": "^1.13.4", 74 | "react": "^16.4.1", 75 | "react-dom": "^16.4.1", 76 | "rimraf": "^2.6.1", 77 | "style-loader": "^0.21.0", 78 | "ts-node": "^7.0.0", 79 | "tslint": "^5.8.0", 80 | "tslint-config-standard": "^7.0.0", 81 | "typedoc": "^0.11.0", 82 | "typescript": "^2.6.2", 83 | "webpack": "^4.16.1", 84 | "webpack-cli": "^3.1.0", 85 | "webpack-dev-middleware": "^3.1.3", 86 | "webpack-hot-client": "^4.1.1", 87 | "ws": "^6.0.0" 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/ranges/LocalRange.ts: -------------------------------------------------------------------------------- 1 | import { AutomergeNode } from '../components/Editor' 2 | import getIsBackward from './getIsBackward' 3 | import getContainerByObjectId from '../content/getContainerByObjectId' 4 | 5 | interface PseudoRange { 6 | startOffset: number 7 | endOffset: number 8 | isBackward: boolean 9 | isCollapsed: boolean 10 | } 11 | 12 | export interface RichRange extends PseudoRange { 13 | startId: string 14 | endId: string 15 | } 16 | 17 | export interface CachedRange extends PseudoRange { 18 | startContainer: Node 19 | endContainer: Node 20 | } 21 | 22 | class LocalRange { 23 | root: RichRange | undefined = undefined 24 | isDirty: boolean = false 25 | cachedRange?: CachedRange 26 | 27 | toWindow (rootEl: Node): boolean { 28 | const selection = window.getSelection() 29 | if (!this.root || !selection.rangeCount) return false 30 | const windowRange = selection.getRangeAt(0) 31 | const { startId, endId } = this.root 32 | const startContainer = getContainerByObjectId(startId, rootEl) 33 | const endContainer = endId === startId ? startContainer : getContainerByObjectId(endId, rootEl) 34 | if ( 35 | windowRange.startContainer === startContainer && 36 | windowRange.endContainer === endContainer && 37 | windowRange.startOffset === this.root.startOffset && 38 | windowRange.endOffset === this.root.endOffset 39 | ) { 40 | return false 41 | } 42 | 43 | // does't handle isBackward, because lazy && unlikely 44 | const { textContent: startText } = startContainer as Node 45 | const maxLen = (startText && startText.length) || 0 46 | windowRange.setStart(startContainer!, Math.min(maxLen, this.root.startOffset)) 47 | 48 | const { textContent: endText } = endContainer as Node 49 | const endTextLen = (endText && endText.length) || 0 50 | windowRange.setEnd(endContainer!, Math.min(endTextLen, this.root.endOffset)) 51 | return true 52 | } 53 | 54 | cacheRange () { 55 | const selection = window.getSelection() 56 | if (!selection.rangeCount) { 57 | if (this.root) { 58 | this.isDirty = true 59 | this.cachedRange = undefined 60 | } 61 | } else { 62 | const isBackward = getIsBackward(selection) 63 | const range = selection.getRangeAt(0) 64 | const { startContainer, startOffset, endContainer, endOffset } = range 65 | const isCollapsed = endContainer === startContainer && endOffset === startOffset 66 | this.cachedRange = { 67 | startContainer, 68 | startOffset, 69 | endContainer, 70 | endOffset, 71 | isBackward, 72 | isCollapsed 73 | } 74 | } 75 | return this 76 | } 77 | 78 | fromCache (): boolean { 79 | if (!this.cachedRange) { 80 | return this.isDirty 81 | } 82 | const { startContainer, endContainer, ...partialRoot } = this.cachedRange 83 | const nextRoot = { 84 | ...partialRoot, 85 | startId: (startContainer._json as AutomergeNode)._objectId, 86 | endId: (endContainer._json as AutomergeNode)._objectId 87 | } 88 | if ( 89 | !this.root || 90 | nextRoot.startId !== this.root.startId || 91 | nextRoot.endId !== this.root.endId || 92 | nextRoot.startOffset !== this.root.startOffset || 93 | nextRoot.endOffset !== this.root.endOffset || 94 | nextRoot.isBackward !== this.root.isBackward 95 | ) { 96 | this.isDirty = true 97 | this.root = nextRoot 98 | return true 99 | } 100 | return false 101 | } 102 | 103 | flush () { 104 | this.isDirty = false 105 | return this.root 106 | } 107 | } 108 | 109 | export default LocalRange 110 | -------------------------------------------------------------------------------- /src/components/Editor.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import DocNode from './DocNode'; 3 | import handleMutation from '../mutations/handleMutation'; 4 | import observerConfig from '../mutations/observerConfig'; 5 | import RemoteCursor from "./RemoteCursor"; 6 | import * as Automerge from "automerge"; 7 | import {AutomergeObject} from "automerge"; 8 | import RichDoc from "../RichDoc"; 9 | import {CaretFlag} from "./DefaultCaretFlag"; 10 | 11 | export type Schema = () => void 12 | 13 | export interface TemporaryTextNode { 14 | type: 'text' 15 | content: string 16 | } 17 | 18 | export interface TemporaryElement { 19 | type: 'element' 20 | tagName: string 21 | attributes: Array 22 | children?: Array 23 | } 24 | 25 | export type TemporaryNode = TemporaryTextNode | TemporaryElement 26 | 27 | export interface AutomergeTextNode extends AutomergeObject { 28 | type: 'text' 29 | content: Automerge.Text 30 | } 31 | 32 | export interface AutomergeRootElement extends AutomergeElement { 33 | _objectId: '00000000-0000-0000-0000-000000000000' 34 | } 35 | 36 | export interface AutomergeElement extends AutomergeObject { 37 | type: 'element' 38 | tagName: string 39 | attributes: Array 40 | children?: Array 41 | meta?: Object 42 | } 43 | 44 | export type AutomergeNode = AutomergeTextNode | AutomergeElement 45 | 46 | interface Props { 47 | doc: RichDoc 48 | schema?: Schema 49 | onChange: (doc: RichDoc, isUpdate: boolean) => void, 50 | caretFlag?: CaretFlag 51 | } 52 | 53 | class Editor extends React.Component { 54 | observer?: MutationObserver 55 | rootRef = React.createRef() 56 | 57 | componentDidMount() { 58 | this.rootRef.current!._json = this.props.doc.content.root 59 | this.observer = new MutationObserver(handleMutation(this)) 60 | this.observer.observe((this.rootRef.current as HTMLDivElement), observerConfig); 61 | } 62 | 63 | componentDidUpdate() { 64 | if (!this.rootRef.current || !this.observer) return 65 | const {doc: {peerRanges, localRange}} = this.props 66 | const rootEl = this.rootRef.current 67 | // listen to user input 68 | this.observer.observe(rootEl, observerConfig) 69 | const isNewLocalRange = localRange.toWindow(rootEl) 70 | if (isNewLocalRange) { 71 | // TODO set scroll if needed 72 | } 73 | 74 | // TODO verify & possibly refactor 75 | const isNewPeerRange = peerRanges.flush() 76 | if (isNewPeerRange) { 77 | this.forceUpdate() 78 | } 79 | } 80 | 81 | onSelect = () => { 82 | const {doc, onChange} = this.props 83 | const {localRange} = doc 84 | const isChanged = localRange.cacheRange().fromCache() 85 | if (isChanged) { 86 | onChange(doc, false) 87 | } 88 | } 89 | 90 | render() { 91 | const {doc, caretFlag, schema} = this.props 92 | const {content, peerRanges} = doc 93 | const style = { 94 | whiteSpace: 'pre-wrap' 95 | } as React.CSSProperties 96 | // ignore DOM mutations made by react, we only care about those my by user input 97 | if (this.observer) { 98 | this.observer.disconnect() 99 | } 100 | const rootEl = this.rootRef.current 101 | return ( 102 | 103 |
104 | {content.root.children!.map((child) => )} 105 |
106 | 107 |
108 | ) 109 | } 110 | } 111 | 112 | export default Editor 113 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rich - WORK IN PROGRESS 2 | 3 | A decentralized collaborative rich text editor powered by DOM mutations, CRDT, and WebRTC 4 | 5 | ## Installation 6 | 7 | `yarn add @mattkrick/rich` 8 | 9 | ## What is it 10 | 11 | A collaborative rich text editor like google docs (without the google). 12 | 13 | ![Rich](docs/rich2.gif) 14 | 15 | ## How's it different? 16 | 17 | Rich is different in 3 ways: DOM Mutations, CRDTs, and WebRTC 18 | 19 | ### Dom Mutations 20 | Every other `contenteditable` editor out there is _really_ smart. 21 | Rich doesn't compete by being smarter. 22 | It competes by being dumber. 23 | A _lot_ dumber. 24 | 25 | For example, all other editors create their own proprietary schema. 26 | The advanced ones even let you customize that schema! 27 | Rich just uses the DOM schema. 28 | 29 | To manage updates, other editors work by overriding the default content editable behavior. 30 | For example, when you hit backspace, they'll intercept that `keydown` event, 31 | decide if they should remove a character or a whole line, 32 | and execute an operation to do that. 33 | When that strategy fails (e.g. iOS spellcheck, android IME autosuggest) 34 | they have special handling for `input`/`beforeInput`/`onComposition` events. 35 | This can limit advanced functionality, such as maintaining autocorrect suggestions. 36 | 37 | Rich has no special handling for different browsers or devices. 38 | It just serializes the DOM & shares it. 39 | It's really that dumb. 40 | By focusing on what you see instead of how to achieve it, it can handle edge cases without trying. 41 | For example, some phones have a shake-your-phone-to-delete-a-word command. 42 | Rich doesn't need special handling to support that. 43 | Being dumb also makes it smaller, faster, and easier to PR. 44 | 45 | ### CRDTs 46 | 47 | "Last-write wins" isn't good enough for collaborative editors. To handle merge conflicts, there are 2 competing technologies: OTs and CRDTs. 48 | OTs require special handling for each & every command, so they are more error prone and adding features is a huge undertaking. 49 | CRDTs are simpler, at the expense of being more memory-intensive, which is usually an acceptable trade-off. 50 | All other collaborative rich-text editors use OTs because before recently, document-based CRDTs didn't exist. 51 | [Automerge](https://github.com/automerge/automerge) changed that. 52 | Rich just serializes the DOM to JSON and lets Automerge handle the conflict merging. 53 | 54 | ### WebRTC 55 | 56 | The largest benefit of CRDTs is that they don't require a centralized server to merge conflicts. 57 | In an age of oversight and censorship by governments, ISPs, and corporations, the world needs an internet where peers can connect directly. 58 | WebRTC provides the framework for such a vision; but without useful tools that use the WebRTC framework, the vision isn't realized. 59 | Rich aims to be just one of the many tools necessary to make a decentralized internet a reality. 60 | By using the WebRTC connector, developers can reduce server throughput, decrease latency between peers, and create a better internet for all. 61 | Of course Rich also works just as well using a centralized server, if you don't mind the increased data throughput, latency, and the occasional MITM attack. 62 | 63 | ## Usage 64 | 65 | See the playground or run `yarn dev`. 66 | Open it in 2 browsers to test the collaborative editing. 67 | Introduce a lag in sending changes to _really_ test the collaborative editing 68 | 69 | ## API 70 | 71 | - ``: The primary component with the following properties 72 | - `doc`: The document, including the content, local selection range, and the selection ranges of connected peers 73 | - `onChange(doc)`: a callback that fires whenever you change the content or your caret position 74 | 75 | - `RichContent.fromRaw`: a function that rehydrates your saved content 76 | - `RichContent.fromText`: a function that turns a string of text into new content 77 | - `RichContent.fromJSON`: a function that turns a serialized DOM into a Rich document 78 | 79 | ## Example 80 | 81 | For the simplest example, see `App.tsx` in the playground folder 82 | 83 | ## License 84 | 85 | MIT 86 | -------------------------------------------------------------------------------- /src/ranges/getRemoteRangeBBox.ts: -------------------------------------------------------------------------------- 1 | import getContainerByObjectId from '../content/getContainerByObjectId' 2 | import { BoundingBox } from '../components/RemoteSelectionRange' 3 | import { RichRange } from './LocalRange' 4 | 5 | const getStartContainerBBox = ( 6 | startContainer: Node, 7 | startOffset: number, 8 | endContainer: Node, 9 | endOffset: number 10 | ): BoundingBox => { 11 | if (startContainer.nodeType === Node.TEXT_NODE) { 12 | const topParentNode = startContainer.parentNode as Element 13 | const topTmpSpan = document.createElement('span') 14 | const fullText = startContainer.textContent as string 15 | const endIdx = startContainer === endContainer ? endOffset : undefined 16 | topTmpSpan.textContent = fullText.slice(startOffset, endIdx) 17 | startContainer.textContent = fullText.slice(0, startOffset) 18 | topParentNode.insertBefore(topTmpSpan, startContainer.nextSibling) 19 | const { left, top, height, width } = topTmpSpan.getBoundingClientRect() 20 | startContainer.textContent = fullText 21 | topParentNode.removeChild(topTmpSpan) 22 | return { left, top, height, width } 23 | } 24 | const { left, top, height, width } = (startContainer as Element).getBoundingClientRect() 25 | return { left, top, height, width } 26 | } 27 | 28 | const getFullLineBBox = (node: Node): BoundingBox => { 29 | const boundingArea = document.createRange() 30 | boundingArea.selectNodeContents(node) 31 | const { top, left, height, width } = (boundingArea as Element | Range).getBoundingClientRect() 32 | return { top, left, height, width } 33 | } 34 | 35 | const getEndContainerBBox = (endContainer: any, endOffset: number): BoundingBox | null => { 36 | if (endContainer.nodeType === Node.TEXT_NODE) { 37 | const parentNode = endContainer.parentNode as Element 38 | const tmpSpan = document.createElement('span') 39 | tmpSpan.textContent = (endContainer.textContent as string).slice(0, endOffset) 40 | parentNode.insertBefore(tmpSpan, endContainer) 41 | const { left, top, height, width } = tmpSpan.getBoundingClientRect() 42 | parentNode.removeChild(tmpSpan) 43 | return { left, top, height, width } 44 | } 45 | let smallestEndContainer = endContainer 46 | while (smallestEndContainer.firstChild) { 47 | smallestEndContainer = smallestEndContainer.firstChild 48 | } 49 | 50 | // necessary for triple clicks since the caret is reported as the beginning of next line 51 | if (smallestEndContainer.nodeType === Node.TEXT_NODE) { 52 | // TODO triple clicking in the playground still yields not perfect results 53 | return null 54 | } 55 | 56 | // necessary for
IIRC 57 | const { left, top, height, width } = smallestEndContainer.getBoundingClientRect() 58 | return { left, top, height, width } 59 | } 60 | 61 | const getCollapsedCaret = (endContainer: Node, endOffset: number) => { 62 | if (endContainer.nodeType === Node.TEXT_NODE) { 63 | const { top, left, height, width } = getEndContainerBBox(endContainer, endOffset) as BoundingBox 64 | return { 65 | top, 66 | left: left + width, 67 | height 68 | } 69 | } 70 | const { left, top, height } = (endContainer as Element).getBoundingClientRect() 71 | return { left, top, height } 72 | } 73 | 74 | const getNestedMiddleBBoxes = ( 75 | childNodes: NodeListOf, 76 | endNode: Node, 77 | selectionBoxes: Array 78 | ) => { 79 | for (let ii = 0; ii < childNodes.length; ii++) { 80 | const node = childNodes[ii] 81 | if (node === endNode) return 82 | if (node.contains(endNode)) { 83 | getNestedMiddleBBoxes(node.childNodes, endNode, selectionBoxes) 84 | return 85 | } else { 86 | const nextSelectionBox = getFullLineBBox(node) 87 | const prevSelectionBox = selectionBoxes[selectionBoxes.length - 1] 88 | if (nextSelectionBox.top === prevSelectionBox.top) { 89 | prevSelectionBox.width += nextSelectionBox.width 90 | // pick max height of the 2? 91 | } else { 92 | selectionBoxes.push(nextSelectionBox) 93 | } 94 | } 95 | } 96 | } 97 | const getMiddleBBoxes = ( 98 | startContainer: Node, 99 | endContainer: Node, 100 | selectionBoxes: Array 101 | ) => { 102 | let parentOfBoth = endContainer.parentNode 103 | while (parentOfBoth && !parentOfBoth.contains(startContainer)) { 104 | parentOfBoth = parentOfBoth.parentNode 105 | } 106 | const { childNodes } = parentOfBoth! 107 | let startContainerFound = false 108 | for (let ii = 0; ii < childNodes.length; ii++) { 109 | const node = childNodes[ii] 110 | if (!startContainerFound) { 111 | if (node.contains(startContainer)) { 112 | // const children = node.childNodes 113 | startContainerFound = true 114 | } 115 | continue 116 | } 117 | if (node.contains(endContainer)) { 118 | getNestedMiddleBBoxes(node.childNodes, endContainer, selectionBoxes) 119 | break 120 | } else { 121 | selectionBoxes.push(getFullLineBBox(node)) 122 | } 123 | } 124 | } 125 | 126 | const getRemoteRangeBBox = (richRange: RichRange, rootEl: Node) => { 127 | const { startId, endId, endOffset, startOffset, isBackward } = richRange 128 | const startContainer = getContainerByObjectId(startId, rootEl) 129 | if (!startContainer) return null 130 | const endContainer = 131 | !endId || endId === startId ? startContainer : getContainerByObjectId(endId, rootEl) 132 | if (!endContainer) return null 133 | const isCollapsed = !endId || (endId === startId && endOffset === startOffset) 134 | if (isCollapsed) { 135 | return { 136 | selectionBoxes: [], 137 | caretCoords: getCollapsedCaret(endContainer, endOffset as number) 138 | } 139 | } else { 140 | const selectionBoxes: Array = [] 141 | const topBBox = getStartContainerBBox( 142 | startContainer, 143 | startOffset, 144 | endContainer, 145 | endOffset as number 146 | ) 147 | selectionBoxes.push(topBBox) 148 | if (startContainer !== endContainer) { 149 | getMiddleBBoxes(startContainer, endContainer, selectionBoxes) 150 | const endContainerBBox = getEndContainerBBox(endContainer, endOffset as number) 151 | if (endContainerBBox) { 152 | selectionBoxes.push(endContainerBBox) 153 | } 154 | } 155 | const referenceBBox = isBackward ? topBBox : selectionBoxes[selectionBoxes.length - 1] 156 | const caretCoords = { 157 | top: referenceBBox.top, 158 | left: isBackward ? referenceBBox.left : referenceBBox.left + referenceBBox.width, 159 | height: referenceBBox.height 160 | } 161 | return { selectionBoxes, caretCoords } 162 | } 163 | } 164 | 165 | export default getRemoteRangeBBox 166 | -------------------------------------------------------------------------------- /src/network/RichConnector.ts: -------------------------------------------------------------------------------- 1 | import { fromJS } from 'immutable' 2 | import FastRTCSwarm from '@mattkrick/fast-rtc-swarm' 3 | import FastRTCPeer, { DATA, DATA_CLOSE, DATA_OPEN } from '@mattkrick/fast-rtc-peer' 4 | import EventEmitter from 'eventemitter3' 5 | import RichContent from '../content/RichContent' 6 | import { AutomergeChanges, AutomergeClock, AutomergeClockObject, getMissingChanges } from 'automerge' 7 | import RichDoc from '../RichDoc' 8 | import { RichRange } from '../ranges/LocalRange' 9 | 10 | const DOC_REQUEST = 'docRequest' 11 | const DOC_RESPONSE = 'docResponse' 12 | const PEER_REMOVAL = 'peerRemoval' 13 | export const RICH_CHANGE = 'change' 14 | 15 | interface RichChange { 16 | type: 'change' 17 | docId: string 18 | range?: RichRange | null 19 | changes?: AutomergeChanges 20 | } 21 | 22 | interface DocResponse { 23 | type: 'docResponse' 24 | docId: string 25 | // always send the clock so the peer knows where were when you received the req, even if you don't send changes 26 | clock: AutomergeClockObject 27 | changes?: AutomergeChanges 28 | range?: RichRange 29 | } 30 | 31 | interface DocRequest { 32 | type: 'docRequest' 33 | docId: string 34 | clock: AutomergeClockObject 35 | } 36 | 37 | interface DocSet { 38 | [docId: string]: RichDoc 39 | } 40 | 41 | class PeerClock { 42 | constructor (public doc: RichDoc, public peer: FastRTCPeer, public clock: AutomergeClock) {} 43 | updateClock (newClock: AutomergeClock) { 44 | this.clock = newClock 45 | } 46 | } 47 | 48 | const getClock = (content: RichContent) => { 49 | return content.root._state.getIn(['opSet', 'clock']) 50 | } 51 | 52 | class RichConnector extends EventEmitter { 53 | docSet: DocSet = {} 54 | peerClocks: Array = [] 55 | swarm?: FastRTCSwarm 56 | 57 | private getPeerClock (peer: FastRTCPeer, doc: RichDoc) { 58 | return this.peerClocks.find((peerClock) => peerClock.doc === doc && peerClock.peer === peer) 59 | } 60 | 61 | private ensurePeerClock (doc: RichDoc, peer: FastRTCPeer, defaultClock: AutomergeClock) { 62 | const existingPeerClock = this.getPeerClock(peer, doc) 63 | if (existingPeerClock) return existingPeerClock 64 | const peerClock = new PeerClock(doc, peer, defaultClock) 65 | this.peerClocks.push(peerClock) 66 | return peerClock 67 | } 68 | 69 | private handleDocRequest (payload: DocRequest, peer: FastRTCPeer) { 70 | const { docId, clock } = payload 71 | const doc = this.docSet[docId] 72 | if (!doc) return 73 | 74 | const immutableClock = fromJS(clock) 75 | const existingPeerClock = this.ensurePeerClock(doc, peer, immutableClock) 76 | const { content, localRange } = doc 77 | const myOpSet = content.root._state.get('opSet') 78 | const myClock = myOpSet.get('clock') 79 | const response = { type: DOC_RESPONSE, docId, clock: myClock.toJS() } as DocResponse 80 | const myChanges = getMissingChanges(myOpSet, immutableClock) 81 | if (myChanges.size > 0) { 82 | response.changes = myChanges 83 | existingPeerClock.updateClock(myClock) 84 | } 85 | if (localRange.root) { 86 | response.range = localRange.root 87 | } 88 | peer.send(JSON.stringify(response)) 89 | } 90 | 91 | private applyRichChange ( 92 | doc: RichDoc, 93 | changes: AutomergeChanges | undefined, 94 | range: RichRange | undefined | null, 95 | peerId: string 96 | ) { 97 | const { content, peerRanges } = doc 98 | content.applyChanges_(changes) 99 | peerRanges.updatePeer(peerId, range) 100 | if (changes || range !== undefined) { 101 | this.emit(RICH_CHANGE, doc) 102 | } 103 | } 104 | 105 | private handleDocResponse (payload: DocResponse, peer: FastRTCPeer) { 106 | const { changes, clock, docId, range } = payload 107 | const doc = this.docSet[docId] 108 | if (!doc) return 109 | 110 | // apply changes 111 | this.applyRichChange(doc, changes, range, peer.id) 112 | 113 | // ensure peer 114 | const { content, localRange } = doc 115 | const immutableClock = fromJS(clock) 116 | const existingPeerClock = this.ensurePeerClock(doc, peer, immutableClock) 117 | 118 | // get changes 119 | const myOpSet = content.root._state.get('opSet') 120 | const myChanges = getMissingChanges(myOpSet, immutableClock) 121 | if (myChanges.size > 0) { 122 | // send changes 123 | const myClock = myOpSet.get('clock') 124 | peer.send( 125 | JSON.stringify({ 126 | type: RICH_CHANGE, 127 | docId, 128 | changes: myChanges, 129 | range: localRange 130 | }) 131 | ) 132 | existingPeerClock.updateClock(myClock) 133 | } 134 | } 135 | 136 | private handleRichChange (payload: RichChange, peer: FastRTCPeer) { 137 | const { docId, changes, range } = payload 138 | const doc = this.docSet[docId] 139 | if (!doc) return 140 | 141 | // apply changes 142 | this.applyRichChange(doc, changes, range, peer.id) 143 | 144 | // update clock 145 | const { content } = doc 146 | const peerClock = this.getPeerClock(peer, doc) 147 | if (peerClock) { 148 | peerClock.updateClock(getClock(content)) 149 | } 150 | } 151 | 152 | private onData = (data: string, peer: FastRTCPeer) => { 153 | const payload = JSON.parse(data) 154 | switch (payload.type) { 155 | case DOC_REQUEST: 156 | this.handleDocRequest(payload, peer) 157 | break 158 | case DOC_RESPONSE: 159 | this.handleDocResponse(payload, peer) 160 | break 161 | case RICH_CHANGE: 162 | this.handleRichChange(payload, peer) 163 | break 164 | case PEER_REMOVAL: 165 | this.removePeerFromDocs(peer, payload.docId) 166 | } 167 | } 168 | 169 | private onDataClose = (peer: FastRTCPeer) => { 170 | this.removePeerFromDocs(peer) 171 | } 172 | 173 | private onDataOpen = (peer: FastRTCPeer) => { 174 | const docIds = Object.keys(this.docSet) 175 | docIds.forEach((docId) => { 176 | peer.send(this.makeDocRequest(docId)) 177 | }) 178 | } 179 | 180 | private makeDocRequest (docId: string) { 181 | const clock = getClock(this.docSet[docId].content).toJS() 182 | return JSON.stringify({ type: DOC_REQUEST, docId, clock }) 183 | } 184 | 185 | removePeerFromDocs (peer: FastRTCPeer, docId?: string) { 186 | for (let ii = this.peerClocks.length - 1; ii >= 0; ii--) { 187 | const peerClock = this.peerClocks[ii] 188 | if (peerClock.peer !== peer) continue 189 | if (docId !== undefined && docId !== peerClock.doc.id) continue 190 | const { doc } = peerClock 191 | doc.peerRanges.removePeer_(peer.id) 192 | this.peerClocks.splice(ii, 1) 193 | this.emit(RICH_CHANGE, doc) 194 | } 195 | } 196 | 197 | addSwarm (swarm: FastRTCSwarm) { 198 | this.swarm = swarm 199 | swarm.on(DATA_OPEN, this.onDataOpen) 200 | swarm.on(DATA, this.onData) 201 | swarm.on(DATA_CLOSE, this.onDataClose) 202 | const docIds = Object.keys(this.docSet) 203 | docIds.forEach((docId) => { 204 | swarm.broadcast(this.makeDocRequest(docId)) 205 | }) 206 | } 207 | 208 | addDoc (doc: RichDoc) { 209 | const { id: docId } = doc 210 | this.docSet[docId] = doc 211 | if (this.swarm) { 212 | this.swarm.broadcast(this.makeDocRequest(docId)) 213 | } 214 | } 215 | 216 | removeDoc (docId: string) { 217 | const doc = this.docSet[docId] 218 | if (!doc) return 219 | for (let ii = 0; ii < this.peerClocks.length; ii++) { 220 | const peerClock = this.peerClocks[ii] 221 | if (peerClock.doc !== doc) continue 222 | peerClock.peer.send( 223 | JSON.stringify({ 224 | type: PEER_REMOVAL, 225 | docId 226 | }) 227 | ) 228 | } 229 | } 230 | 231 | dispatch = (docId: string) => { 232 | const doc = this.docSet[docId] 233 | const { localRange, content } = doc 234 | if (!doc) { 235 | throw new Error('You must call `addDoc` before calling dispatch') 236 | } 237 | if (!localRange.isDirty && !content.isDirty) return 238 | for (let ii = 0; ii < this.peerClocks.length; ii++) { 239 | const peerClock = this.peerClocks[ii] 240 | if (peerClock.doc !== doc) continue 241 | const payload = { docId, type: RICH_CHANGE } as RichChange 242 | if (localRange.isDirty) { 243 | payload.range = localRange.flush() 244 | } 245 | if (content.isDirty) { 246 | payload.changes = content.flushChanges(peerClock.clock) 247 | } 248 | peerClock.peer.send(JSON.stringify(payload)) 249 | } 250 | } 251 | } 252 | 253 | export default RichConnector 254 | --------------------------------------------------------------------------------