├── .npmignore ├── prettier.config.js ├── .vscode └── settings.json ├── .gitignore ├── tsconfig.json ├── src ├── doc.ts ├── text.ts ├── xml.ts ├── index.ts ├── array.ts └── map.ts ├── package.json ├── LICENSE └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | $schema: "http://json.schemastore.org/prettierrc", 3 | tabWidth: 2, 4 | printWidth: 80, 5 | jsxBracketSameLine: true, 6 | }; 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | "editor.formatOnSave": true, 4 | "[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, 5 | "[typescriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" } 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | /types 25 | /dist -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "outDir": "types", 18 | "declaration": true 19 | }, 20 | "include": ["src"] 21 | } 22 | -------------------------------------------------------------------------------- /src/doc.ts: -------------------------------------------------------------------------------- 1 | import * as Y from "yjs"; 2 | import { isYType, observeYJS } from "."; 3 | 4 | const docsObserved = new WeakSet(); 5 | 6 | export function observeDoc(doc: Y.Doc) { 7 | if (docsObserved.has(doc)) { 8 | // already patched 9 | return doc; 10 | } 11 | docsObserved.add(doc); 12 | 13 | const originalGet = doc.get; 14 | 15 | doc.get = function (key: string) { 16 | if (typeof key !== "string") { 17 | throw new Error("unexpected"); 18 | } 19 | const ret = Reflect.apply(originalGet, this, arguments); 20 | if (!ret) { 21 | return ret; 22 | } 23 | if (isYType(ret)) { 24 | return observeYJS(ret); 25 | } 26 | return ret; 27 | }; 28 | 29 | return doc; 30 | } 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mobyjs", 3 | "repository": { 4 | "url": "" 5 | }, 6 | "license": "MIT", 7 | "version": "0.1.3", 8 | "private": false, 9 | "main": "dist/moby.js", 10 | "module": "dist/moby.module.js", 11 | "umd:main": "dist/moby.umd.js", 12 | "source": "src/index.ts", 13 | "types": "types/index.d.ts", 14 | "dependencies": { 15 | "@types/jest": "^26.0.15", 16 | "@types/node": "^12.0.0" 17 | }, 18 | "devDependencies": { 19 | "typescript": "^4.1.2", 20 | "microbundle": "^0.13.0", 21 | "mobx": "^6.0.0", 22 | "yjs": "^13.5.4" 23 | }, 24 | "peerDependencies": { 25 | "mobx": "*", 26 | "yjs": "*" 27 | }, 28 | "scripts": { 29 | "package": "microbundle build --raw --no-compress", 30 | "test": "jest" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/text.ts: -------------------------------------------------------------------------------- 1 | import { IAtom, createAtom } from "mobx"; 2 | import * as Y from "yjs"; 3 | 4 | const textAtoms = new WeakMap(); 5 | 6 | export function observeText(value: Y.Text) { 7 | let atom = textAtoms.get(value); 8 | if (!atom) { 9 | const handler = (_changes: Y.YTextEvent) => { 10 | atom!.reportChanged(); 11 | }; 12 | atom = createAtom( 13 | "text", 14 | () => { 15 | value.observe(handler); 16 | }, 17 | () => { 18 | value.unobserve(handler); 19 | } 20 | ); 21 | 22 | const originalToString = value.toString; 23 | value.toString = function () { 24 | atom!.reportObserved(); 25 | const ret = Reflect.apply(originalToString, this, arguments); 26 | return ret; 27 | }; 28 | textAtoms.set(value, atom); 29 | } 30 | atom!.reportObserved(); 31 | return value; 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 Yousef El-Dardiry 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/xml.ts: -------------------------------------------------------------------------------- 1 | import { IAtom, createAtom, untracked } from "mobx"; 2 | import * as Y from "yjs"; 3 | import { observeYJS } from "."; 4 | 5 | const xmlAtoms = new WeakMap(); 6 | 7 | export function observeXml(value: Y.XmlFragment) { 8 | let atom = xmlAtoms.get(value); 9 | if (!atom) { 10 | const handler = (event: Y.YXmlEvent) => { 11 | event.changes.added.forEach((added) => { 12 | if (added.content instanceof Y.ContentType) { 13 | const addedType = added.content.type; 14 | untracked(() => { 15 | observeYJS(addedType); 16 | }); 17 | } 18 | }); 19 | atom!.reportChanged(); 20 | }; 21 | 22 | atom = createAtom( 23 | "xml", 24 | () => { 25 | value.observe(handler); 26 | }, 27 | () => { 28 | value.unobserve(handler); 29 | } 30 | ); 31 | 32 | const originalToString = value.toString; 33 | value.toString = function () { 34 | atom!.reportObserved(); 35 | const ret = Reflect.apply(originalToString, this, arguments); 36 | return ret; 37 | }; 38 | xmlAtoms.set(value, atom); 39 | } 40 | 41 | untracked(() => { 42 | value.toArray().forEach((val) => { 43 | observeYJS(val); 44 | }); 45 | }); 46 | 47 | atom!.reportObserved(); 48 | return value; 49 | } 50 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { AbstractType } from "yjs"; 2 | import { observeText } from "./text"; 3 | import * as Y from "yjs"; 4 | import { observeMap } from "./map"; 5 | import { observeDoc } from "./doc"; 6 | import { observeXml } from "./xml"; 7 | 8 | export function isYType(element: any) { 9 | return ( 10 | element instanceof Y.AbstractType || 11 | Object.prototype.hasOwnProperty.call(element, "autoLoad") 12 | ); // detect subdocs. Is there a better way for this? 13 | } 14 | 15 | export function observeYJS(element: Y.AbstractType | Y.Doc) { 16 | if (element instanceof Y.XmlText) { 17 | return observeText(element); 18 | } else if (element instanceof Y.Text) { 19 | return observeText(element); 20 | } else if (element instanceof Y.Array) { 21 | } else if (element instanceof Y.Map) { 22 | return observeMap(element); 23 | } else if ( 24 | element instanceof Y.Doc || 25 | Object.prototype.hasOwnProperty.call(element, "autoLoad") 26 | ) { 27 | // subdoc. Ok way to detect this? 28 | return observeDoc((element as any) as Y.Doc); 29 | } else if (element instanceof Y.XmlFragment) { 30 | return observeXml(element); 31 | } else if (element instanceof Y.XmlElement) { 32 | return observeXml(element); 33 | } else { 34 | if (element._item === null && element._start === null) { 35 | // console.warn("edge case"); 36 | } else { 37 | // throw new Error("not yet supported"); 38 | } 39 | } 40 | return element; 41 | } 42 | 43 | export { observeText, observeMap, observeDoc }; 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **deprecated: moved to [@reactivedata/yjs-reactive-bindings](https://www.npmjs.com/package/@reactivedata/yjs-reactive-bindings).** 2 | 3 | # MobY: MobX bindings for Yjs 4 | 5 | Experimental bridge between MobX and Yjs. [Demo + Playground on CodeSandbox](https://codesandbox.io/s/moby-demo-yn42g?file=/src/App.tsx). 6 | 7 | ## What does this solve? 8 | 9 | Although Yjs is great for data syncing, observing changes to your data model can be quite cumbersome. You'd need to manually call `observe` to keep updated of (incoming) changes and keep the rest of your application in sync. 10 | 11 | MobY brings the reactive data model of MobX to Yjs. Combine best of both worlds: 12 | 13 | - Yjs: great for data syncing 14 | - MobX: great for developing applications that automatically react to state changes 15 | 16 | ## Cool, how does it work? 17 | 18 | Set up your yJS document (same as plain-yJS): 19 | 20 | ``` 21 | const ydoc = new Y.Doc(); 22 | const provider = new WebrtcProvider("doc", ydoc); 23 | ``` 24 | 25 | Call observeYJS to patch the document. From now on, `ydoc` will be compatible with MobX observers: 26 | 27 | ``` 28 | observeYJS(ydoc); 29 | ``` 30 | 31 | Use the Yjs document somewhere in an observer, for example using MobX `autorun`: 32 | 33 | ``` 34 | autorun(() => { 35 | console.log(ydoc.getMap("data").get("magicnumber")); // automatically log the Yjs value once it's updated 36 | }) 37 | ``` 38 | 39 | Or use mobx-react-lite to automatically rerender your React components. 40 | 41 | ## Demo? 42 | 43 | [Playground on CodeSandbox](https://codesandbox.io/s/moby-demo-yn42g?file=/src/App.tsx). Open multiple windows and click the button to see the magic. 44 | -------------------------------------------------------------------------------- /src/array.ts: -------------------------------------------------------------------------------- 1 | import { IAtom, createAtom } from "mobx"; 2 | import * as Y from "yjs"; 3 | import { isYType, observeYJS } from "."; 4 | 5 | const arraysObserved = new WeakSet>(); 6 | 7 | export function observeMap(array: Y.Array) { 8 | if (arraysObserved.has(array)) { 9 | // already patched 10 | return array; 11 | } 12 | arraysObserved.add(array); 13 | 14 | let selfAtom: IAtom | undefined; 15 | const atoms = new Map(); 16 | 17 | function reportSelfAtom() { 18 | if (!selfAtom) { 19 | const handler = (_changes: Y.YArrayEvent) => { 20 | selfAtom!.reportChanged(); 21 | }; 22 | selfAtom = createAtom( 23 | "map", 24 | () => { 25 | array.observe(handler); 26 | }, 27 | () => { 28 | array.unobserve(handler); 29 | } 30 | ); 31 | } 32 | } 33 | 34 | function reportArrayElementAtom(key: number) { 35 | let atom = atoms.get(key); 36 | 37 | // possible optimization: only register a single handler for all keys 38 | if (!atom) { 39 | const handler = (changes: Y.YArrayEvent) => { 40 | // TODO 41 | // if (changes.keys.has(key)) { 42 | atom!.reportChanged(); 43 | // } 44 | }; 45 | atom = createAtom( 46 | key + "", 47 | () => { 48 | array.observe(handler); 49 | }, 50 | () => { 51 | array.unobserve(handler); 52 | } 53 | ); 54 | atoms.set(key, atom); 55 | } 56 | 57 | atom.reportObserved(); 58 | } 59 | 60 | const originalGet = array.get; 61 | 62 | array.get = function (key: number) { 63 | if (typeof key !== "number") { 64 | throw new Error("unexpected"); 65 | } 66 | reportArrayElementAtom(key); 67 | const ret = Reflect.apply(originalGet, this, arguments); 68 | if (!ret) { 69 | return ret; 70 | } 71 | if (isYType(ret)) { 72 | return observeYJS(ret); 73 | } 74 | return ret; 75 | }; 76 | 77 | const originalValues = array.toArray; 78 | array.toArray = function () { 79 | reportSelfAtom(); 80 | const ret = Reflect.apply(originalValues, this, arguments); 81 | return ret; 82 | }; 83 | 84 | return array; 85 | } 86 | -------------------------------------------------------------------------------- /src/map.ts: -------------------------------------------------------------------------------- 1 | import { IAtom, createAtom } from "mobx"; 2 | import * as Y from "yjs"; 3 | import { isYType, observeYJS } from "."; 4 | 5 | const mapsObserved = new WeakSet>(); 6 | 7 | export function observeMap(map: Y.Map) { 8 | if (mapsObserved.has(map)) { 9 | // already patched 10 | return map; 11 | } 12 | mapsObserved.add(map); 13 | let selfAtom: IAtom | undefined; 14 | const atoms = new Map(); 15 | 16 | function reportSelfAtom() { 17 | if (!selfAtom) { 18 | const handler = (event: Y.YMapEvent) => { 19 | if ( 20 | event.changes.added.size || 21 | event.changes.deleted.size || 22 | event.changes.keys.size || 23 | event.changes.delta.length 24 | ) { 25 | selfAtom!.reportChanged(); 26 | } 27 | }; 28 | selfAtom = createAtom( 29 | "map", 30 | () => { 31 | map.observe(handler); 32 | }, 33 | () => { 34 | map.unobserve(handler); 35 | } 36 | ); 37 | } 38 | selfAtom.reportObserved(); 39 | } 40 | 41 | function reportMapKeyAtom(key: string) { 42 | let atom = atoms.get(key); 43 | 44 | // possible optimization: only register a single handler for all keys 45 | if (!atom) { 46 | const handler = (event: Y.YMapEvent) => { 47 | if (event.keysChanged.has(key)) { 48 | if ( 49 | event.changes.added.size || 50 | event.changes.deleted.size || 51 | event.changes.keys.size || 52 | event.changes.delta.length 53 | ) { 54 | atom!.reportChanged(); 55 | } 56 | } 57 | }; 58 | atom = createAtom( 59 | key, 60 | () => { 61 | map.observe(handler); 62 | }, 63 | () => { 64 | map.unobserve(handler); 65 | } 66 | ); 67 | atoms.set(key, atom); 68 | } 69 | 70 | atom.reportObserved(); 71 | } 72 | 73 | const originalGet = map.get; 74 | 75 | map.get = function (key: string) { 76 | if (typeof key !== "string") { 77 | throw new Error("unexpected"); 78 | } 79 | reportMapKeyAtom(key); 80 | const ret = Reflect.apply(originalGet, this, arguments); 81 | if (!ret) { 82 | return ret; 83 | } 84 | if (isYType(ret)) { 85 | return observeYJS(ret); 86 | } 87 | return ret; 88 | }; 89 | 90 | const originalValues = map.values; 91 | map.values = function () { 92 | reportSelfAtom(); 93 | const ret = Reflect.apply(originalValues, this, arguments); 94 | return ret; 95 | }; 96 | 97 | const originalForEach = map.forEach; 98 | map.forEach = function () { 99 | reportSelfAtom(); 100 | const ret = Reflect.apply(originalForEach, this, arguments); 101 | return ret; 102 | }; 103 | 104 | const originalToJSON = map.toJSON; 105 | map.toJSON = function () { 106 | reportSelfAtom(); 107 | const ret = Reflect.apply(originalToJSON, this, arguments); 108 | return ret; 109 | }; 110 | 111 | return map; 112 | } 113 | --------------------------------------------------------------------------------