├── .gitignore
├── demo.gif
├── index.html
├── readme.md
├── app.js
├── src
├── Highlight.js
├── index.js
├── Peer.js
├── InlineEditable.js
├── Comments.js
├── Document.js
├── Editor.js
├── App.js
└── HyperDoc.js
├── webpack.config.js
├── package.json
└── css
├── style.css
└── normalize.css
/.gitignore:
--------------------------------------------------------------------------------
1 | docs
--------------------------------------------------------------------------------
/demo.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/frnsys/hyperdocs/HEAD/demo.gif
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | hyperchat
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | 
2 |
3 | EXPERIMENTAL
4 |
5 | p2p collaborative document editing, built on [`hypermerge`](https://github.com/automerge/hypermerge). Besides basic collaborative editing, you can also see peer names, cursors, and text selections.
6 |
7 | # Usage
8 |
9 | 1. Clone this repo
10 | 2. `npm install`
11 | 3. `npm run compile`
12 | 4. `npm start`
13 |
14 | # [Issues](https://github.com/frnsys/hyperdocs/issues)
--------------------------------------------------------------------------------
/app.js:
--------------------------------------------------------------------------------
1 | let path = require('path');
2 | let electron = require('electron');
3 |
4 | let win = null;
5 | let app = electron.app;
6 | let BrowserWindow = electron.BrowserWindow;
7 |
8 | app.on('ready', function () {
9 | win = new BrowserWindow({
10 | minWidth: 800,
11 | minHeight: 600
12 | });
13 |
14 | win.loadURL('file://' + path.join(__dirname, 'index.html'));
15 | win.on('close', function () {
16 | win = null
17 | });
18 |
19 | win.webContents.openDevTools();
20 | })
21 |
--------------------------------------------------------------------------------
/src/Highlight.js:
--------------------------------------------------------------------------------
1 | import React, {Component} from 'react';
2 |
3 | class Highlight extends Component {
4 | render() {
5 | let text = this.props.text;
6 | let start = text.substr(0, this.props.start);
7 | let highlight = text.substring(this.props.start, this.props.end);
8 | let end = text.substr(this.props.end);
9 | let style = {
10 | background: this.props.color
11 | };
12 |
13 | start = {start} ;
14 | end = {end} ;
15 | highlight = {highlight} ;
16 |
17 | return {start}{highlight}{end}
;
18 | }
19 | }
20 |
21 | export default Highlight;
22 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import App from './App';
2 | import React from 'react';
3 | import HyperDoc from './HyperDoc';
4 | import Hypermerge from 'hypermerge';
5 | import ram from 'random-access-memory';
6 | import {render} from 'react-dom';
7 |
8 | // const path = 'docs';
9 | const path = ram;
10 |
11 | const colors = [
12 | '#1313ef',
13 | '#ef1321',
14 | '#24b554',
15 | '#851fd3',
16 | '#0eaff4',
17 | '#edc112',
18 | '#7070ff'
19 | ];
20 |
21 | const hm = new Hypermerge({
22 | path: path
23 | });
24 |
25 | hm.once('ready', (hm) => {
26 | hm.joinSwarm({utp: false}); // getting an error with utp?
27 |
28 | let id = hm.swarm.id.toString('hex');
29 | console.log(`My ID: ${id}`);
30 |
31 | // ugh hacky
32 | HyperDoc.hm = hm;
33 | let main = document.getElementById('main');
34 | render( , main);
35 | });
36 |
--------------------------------------------------------------------------------
/src/Peer.js:
--------------------------------------------------------------------------------
1 | import Highlight from './Highlight';
2 | import React, {Component} from 'react';
3 |
4 |
5 | class Peer extends Component {
6 | render() {
7 | let peer = this.props.peer;
8 | let pos = peer.pos.end;
9 | let style = {
10 | position: 'absolute',
11 | background: peer.color,
12 | left: pos.left
13 | };
14 |
15 | let idx = peer.idx;
16 | let highlight = '';
17 | if (idx.start !== idx.end) {
18 | highlight = ;
23 | }
24 |
25 | return (
26 |
27 |
{peer.name}
28 |
29 | {highlight}
30 |
);
31 | }
32 | }
33 |
34 | export default Peer;
35 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | var path = require('path');
2 | var ExtractTextPlugin = require("extract-text-webpack-plugin");
3 |
4 | module.exports = {
5 | entry: ['@babel/polyfill', './src/index'],
6 | output: {
7 | filename: 'bundle.js'
8 | },
9 | target: 'electron-main',
10 | devtool: 'source-map',
11 | module: {
12 | rules: [{
13 | test: /\.(sass|scss)$/,
14 | use: ExtractTextPlugin.extract({ fallback: 'style-loader', use: 'css-loader!sass-loader' })
15 | }, {
16 | test: /\.css$/,
17 | use: [{
18 | loader: "style-loader" // creates style nodes from JS strings
19 | }, {
20 | loader: "css-loader" // translates CSS into CommonJS
21 | }]
22 | }, {
23 | test: /\.js$/,
24 | exclude: /node_modules/,
25 | use: {
26 | loader: 'babel-loader',
27 | options: {
28 | presets: ['@babel/preset-env', '@babel/preset-react'],
29 | plugins: ['@babel/plugin-proposal-class-properties']
30 | }
31 | }
32 | }]
33 | },
34 | plugins: [
35 | new ExtractTextPlugin('css/style.css')
36 | ],
37 | resolve: {
38 | extensions: ['.js', '.sass']
39 | }
40 | };
41 |
42 |
--------------------------------------------------------------------------------
/src/InlineEditable.js:
--------------------------------------------------------------------------------
1 | import React, {Component} from 'react';
2 |
3 |
4 | // simple component that displays text
5 | // which can be clicked on to edit
6 | class InlineEditable extends Component {
7 | constructor(props) {
8 | super(props);
9 | this.state = {
10 | editing: false
11 | };
12 | this.input = React.createRef();
13 | }
14 |
15 | onKeyPress(ev) {
16 | if (ev.key === 'Enter') {
17 | this.props.onEdit(ev.target.value);
18 | this.setState({ editing: false });
19 | }
20 | }
21 |
22 | onBlur(ev) {
23 | this.props.onEdit(ev.target.value);
24 | this.setState({ editing: false });
25 | }
26 |
27 | onClick() {
28 | this.setState({ editing: true });
29 | }
30 |
31 | componentDidUpdate() {
32 | if (this.input.current) {
33 | this.input.current.focus();
34 | }
35 | }
36 |
37 | render() {
38 | if (this.state.editing) {
39 | return
46 | } else {
47 | return {this.props.value}
;
48 | }
49 | }
50 | }
51 |
52 | export default InlineEditable;
53 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "hyperdocs",
3 | "version": "0.0.0",
4 | "description": "collaborative editing app",
5 | "main": "index.js",
6 | "scripts": {
7 | "start": "electron app.js",
8 | "compile": "webpack --watch --progress"
9 | },
10 | "author": "Francis Tseng",
11 | "license": "MIT",
12 | "repository": {
13 | "type": "git",
14 | "url": "https://github.com/frnsys/hyperdocs"
15 | },
16 | "devDependencies": {
17 | "@babel/core": "^7.0.0-beta.51",
18 | "@babel/plugin-proposal-class-properties": "^7.0.0-beta.51",
19 | "@babel/polyfill": "^7.0.0-beta.51",
20 | "@babel/preset-env": "^7.0.0-beta.51",
21 | "@babel/preset-react": "^7.0.0-beta.51",
22 | "babel-loader": "^8.0.0-beta.0",
23 | "css-loader": "^0.28.4",
24 | "electron": "^2.0.3",
25 | "extract-text-webpack-plugin": "^2.1.2",
26 | "sass-loader": "^4.0.0",
27 | "style-loader": "^0.18.2",
28 | "url-loader": "^0.5.9",
29 | "webpack": "^2.6.1"
30 | },
31 | "dependencies": {
32 | "automerge": "^0.7.11",
33 | "datland-swarm-defaults": "^1.0.2",
34 | "hypermerge": "git://github.com/frnsys/hypermerge.git#fix-hypercore-crypto",
35 | "react": "^16.2.0",
36 | "react-dom": "^16.2.0",
37 | "react-markdown": "^3.3.4",
38 | "react-select": "^1.2.1",
39 | "textarea-caret": "github:component/textarea-caret-position"
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/Comments.js:
--------------------------------------------------------------------------------
1 | import Highlight from './Highlight';
2 | import React, {Component} from 'react';
3 |
4 | const Comment = (props) => {
5 | let c = props.comment;
6 | return (
7 |
8 |
{c.author}
9 |
{c.body}
10 |
On {new Date(c.created).toLocaleString()}
11 |
);
12 | }
13 |
14 | class AddComment extends Component {
15 | constructor(props) {
16 | super(props);
17 | this.state = {
18 | value: ''
19 | };
20 | }
21 |
22 | addComment() {
23 | this.props.addComment(this.state.value);
24 |
25 | // TODO ideally this doesn't happen until
26 | // we confirm the comment registered...
27 | this.setState({value: ''});
28 | }
29 |
30 | render() {
31 | return (
32 |
33 |
);
38 | }
39 | }
40 |
41 | class Comments extends Component {
42 | render() {
43 | // TODO styling/positioning needs a lot of work
44 | let style = {
45 | top: this.props.top
46 | };
47 |
48 | if (this.props.focused) {
49 | style.border = '2px solid #7070ff';
50 | } else {
51 | style.display = 'none';
52 | }
53 |
54 | return (
55 |
56 | {this.props.thread.length > 0 &&
57 |
Resolve }
58 | {this.props.thread.map((c) =>
)}
59 |
this.props.add(this.props.id, body)} />
60 | );
61 | }
62 | }
63 |
64 | export default Comments;
65 |
--------------------------------------------------------------------------------
/src/Document.js:
--------------------------------------------------------------------------------
1 | import Peer from './Peer';
2 | import Editor from './Editor';
3 | import Comments from './Comments';
4 | import Highlight from './Highlight';
5 | import React, {Component} from 'react';
6 |
7 | function commentForCaret(comments, start, end) {
8 | return comments.find((c) => c.start <= start && c.end >= end);
9 | }
10 |
11 | class Doc extends Component {
12 | constructor(props) {
13 | super(props);
14 | this.state = {
15 | scrollTop: 0,
16 | focusedComment: null
17 | };
18 | this.editor = React.createRef();
19 | }
20 |
21 | onScroll(scrollTop) {
22 | this.setState({ scrollTop });
23 | }
24 |
25 | onSelect(caretPos, caretIdx) {
26 | // find focused comment, if any
27 | let comments = Object.values(this.props.doc.comments);
28 | let focusedComment = comments.find((c) => c.start <= caretIdx.start && c.end >= caretIdx.end);
29 |
30 | this.setState({
31 | caretPos: caretPos,
32 | caretIdx: caretIdx,
33 | addNewComment: !focusedComment && caretIdx.start != caretIdx.end,
34 | focusedComment: focusedComment ? focusedComment.id : null
35 | });
36 |
37 | this.props.doc.setSelection(this.props.id, caretPos, caretIdx);
38 | }
39 |
40 | render() {
41 | let text = this.props.doc.text;
42 | let peers = Object.values(this.props.doc.peers).filter((p) => p.id !== this.props.id && p.pos);
43 | let activeComments = Object.values(this.props.doc.comments).filter((c) => !c.resolved);
44 |
45 | let caretTop = this.state.caretPos ? this.state.caretPos.start.top : 0;
46 | let addComment = {
47 | if (body) {
48 | let { start, end } = this.state.caretIdx;
49 | this.props.doc.addComment(this.props.id, null, body, start, end);
50 | this.editor.current.focus();
51 | }
52 | }} />;
53 |
54 | return
55 |
56 |
57 | {activeComments.map((c) => {
58 | let focused = c.id === this.state.focusedComment;
59 | return this.props.doc.addComment(this.props.id, id, body)}
65 | resolve={() => this.props.doc.resolveComment(c.id)}
66 | {...c} />
67 | })}
68 | {this.state.addNewComment && addComment}
69 |
70 |
71 |
72 | {activeComments.map((c) => {
73 | return
;
74 | })}
75 | {peers.map((p) =>
)}
76 |
77 |
this.props.doc.editText(edits)} />
84 |
85 |
86 |
;
87 | }
88 | }
89 |
90 | export default Doc;
91 |
--------------------------------------------------------------------------------
/src/Editor.js:
--------------------------------------------------------------------------------
1 | import React, {Component} from 'react';
2 | import getCaretCoordinates from 'textarea-caret';
3 |
4 |
5 | class Editor extends Component {
6 | constructor(props) {
7 | super(props);
8 | this.state = {
9 | selectionStart: 0,
10 | selectionEnd: 0
11 | };
12 | this.textarea = React.createRef();
13 | }
14 |
15 | componentDidMount() {
16 | this.textarea.current.focus();
17 | }
18 |
19 | componentDidUpdate(prevProps, prevState) {
20 | if (prevProps.text !== this.props.text && this.props.diffs.length) {
21 | // only adjust cursor if text has changed
22 | // from our perspective.
23 | // if it hasn't, we assume we made the
24 | // changes and our caret has automatically updated.
25 | let start = this.state.selectionStart;
26 | let end = this.state.selectionEnd;
27 | this.props.diffs.forEach((d) => {
28 | if (d.index < start) {
29 | if (d.action === 'insert') {
30 | start++;
31 | } else if (d.action === 'remove') {
32 | start--;
33 | }
34 | }
35 |
36 | if (d.index < end) {
37 | if (d.action === 'insert') {
38 | end++;
39 | } else if (d.action === 'remove') {
40 | end--;
41 | }
42 | }
43 | });
44 | this.textarea.current.selectionStart = start;
45 | this.textarea.current.selectionEnd = end;
46 | this.onSelect();
47 | }
48 | }
49 |
50 | onSelect(ev) {
51 | // on new textarea selection/caret movement,
52 | // update peers
53 | let textarea = this.textarea.current;
54 | let caretPos = {
55 | start: getCaretCoordinates(textarea, textarea.selectionStart),
56 | end: getCaretCoordinates(textarea, textarea.selectionEnd)
57 | };
58 | let caretIdx = {
59 | start: textarea.selectionStart,
60 | end: textarea.selectionEnd
61 | };
62 |
63 | this.setState({
64 | selectionStart: textarea.selectionStart,
65 | selectionEnd: textarea.selectionEnd,
66 | caretPos: caretPos
67 | });
68 |
69 | this.props.onSelect(caretPos, caretIdx);
70 | }
71 |
72 | onChange(ev) {
73 | // track insertions & deletions by character,
74 | // this is what hypermerge/automerge requires
75 | let edits = [];
76 | let caret = this.textarea.current.selectionEnd;
77 | let newText = ev.target.value;
78 | let prevText = this.props.text;
79 | let diff = newText.length - prevText.length;
80 | let inserted = diff > 0;
81 | diff = Math.abs(diff);
82 |
83 | // check if selected & replaced,
84 | // which needs to be split into two operations
85 | let selectedDiff = this.state.selectionEnd - this.state.selectionStart;
86 | if (selectedDiff && selectedDiff !== diff) {
87 | let adjCaret = caret-(selectedDiff-diff);
88 | let removed = prevText.substr(adjCaret, selectedDiff);
89 | edits.push({
90 | caret: adjCaret,
91 | diff: selectedDiff,
92 | inserted: inserted,
93 | changed: removed
94 | });
95 | // console.log(`removed ${selectedDiff} characters: ${removed}`);
96 |
97 | prevText = `${prevText.substr(0, adjCaret)}${prevText.substr(adjCaret+selectedDiff)}`;
98 | diff = newText.length - prevText.length;
99 | inserted = true;
100 | }
101 |
102 | let changed;
103 | if (inserted) {
104 | caret = caret - diff;
105 | changed = newText.substr(caret, diff);
106 | // console.log(`inserted ${diff} characters: ${changed}`);
107 | } else {
108 | changed = prevText.substr(caret, diff);
109 | // console.log(`removed ${diff} characters: ${changed}`);
110 | }
111 | edits.push({
112 | caret: caret,
113 | diff: diff,
114 | inserted: inserted,
115 | changed: changed
116 | });
117 | this.props.onEdit(edits);
118 | }
119 |
120 | onScroll() {
121 | this.props.onScroll(this.textarea.current.scrollTop);
122 | }
123 |
124 | focus() {
125 | this.textarea.current.focus();
126 | }
127 |
128 | render() {
129 | return ;
136 | }
137 | }
138 |
139 | export default Editor;
140 |
--------------------------------------------------------------------------------
/css/style.css:
--------------------------------------------------------------------------------
1 | * {
2 | -moz-box-sizing: border-box;
3 | -webkit-box-sizing: border-box;
4 | box-sizing: border-box;
5 | }
6 |
7 | html, body {
8 | font-family: sans-serif;
9 | background: pink;
10 | }
11 |
12 | main {
13 | padding: 2em;
14 | max-width: 720px;
15 | margin: 0 auto;
16 | }
17 |
18 | nav {
19 | display: flex;
20 | align-items: center;
21 | justify-content: space-between;
22 | }
23 |
24 | button {
25 | padding: 0.2em 0.3em;
26 | border-radius: 0.2em;
27 | border: none;
28 | display: inline-block;
29 | cursor: pointer;
30 | background: #fff;
31 | }
32 |
33 | input[type=text] {
34 | padding: 0.2em 0.3em;
35 | border-radius: 0.2em;
36 | border: none;
37 | }
38 |
39 | textarea {
40 | width: 100%;
41 | padding: 0.5em;
42 | display: block;
43 | }
44 | .doc-editor-textarea {
45 | min-height: 50vh;
46 | }
47 | .doc-editor-textarea, .highlighter {
48 | padding: 1em;
49 | }
50 | .highlighter {
51 | position: absolute;
52 | top: 0;
53 | right: 0;
54 | left: 0;
55 | bottom: 0;
56 | pointer-events: none;
57 | white-space: pre-wrap;
58 | }
59 | .highlight {
60 | opacity: 0.2;
61 | }
62 | .highlight-text {
63 | visibility: hidden;
64 | }
65 |
66 | .doc-preview {
67 | min-height: 50vh;
68 | padding: 1em;
69 | margin: 1em 0;
70 | background: #fff;
71 | position: relative;
72 | }
73 | .doc-preview img {
74 | max-width: 100%;
75 | }
76 | .doc-preview-label {
77 | position: absolute;
78 | top: 0;
79 | right: 0;
80 | background: #222;
81 | color: #fff;
82 | padding: 0.2em 0.3em;
83 | font-size: 0.75em;
84 | border-radius: 0 0 0 0.25em;
85 | }
86 |
87 | .doc-editor {
88 | position: relative;
89 | margin: 1em 0;
90 | }
91 | .doc-editor-constrained {
92 | overflow: hidden;
93 | position: relative;
94 | }
95 |
96 | .peer-label {
97 | font-size: 0.6em;
98 | background: blue;
99 | color: #fff;
100 | padding: 0.2em 0.3em;
101 | border-radius: 0.2em;
102 | }
103 | .peer-cursor {
104 | width: 1px;
105 | background: blue;
106 | height: 1em;
107 | }
108 |
109 | .doc-id {
110 | font-size: 0.85em;
111 | text-align: center;
112 | color: rgba(0,0,0,0.5);
113 | }
114 | .doc-id span {
115 | border-bottom: 2px solid #7070ff;
116 | }
117 |
118 | .doc-title {
119 | font-size: 1.6em;
120 | font-weight: bold;
121 | margin: 1em 0 0 0;
122 | }
123 |
124 | .app-name {
125 | max-width: 8em;
126 | }
127 |
128 | #doc-list {
129 | margin: 0;
130 | padding: 0;
131 | }
132 | #doc-list li {
133 | list-style-type: none;
134 | }
135 | #doc-list a {
136 | cursor: pointer;
137 | text-decoration: underline;
138 | }
139 |
140 | .Select-control {
141 | border-radius: 0.2em !important;
142 | }
143 | .Select-control, .Select-input {
144 | height: auto !important;
145 | border: none !important;
146 | }
147 | .Select-input > input {
148 | padding: 0.25em 0 !important;
149 | max-width: 10em !important;
150 | }
151 | .Select-placeholder {
152 | line-height: 26px !important;
153 | }
154 |
155 | .doc-add-comment {
156 | position: absolute;
157 | font-size: 0.6em;
158 | background: blue;
159 | background: #d8eafd;
160 | color: #000;
161 | padding: 0.3em 0.3em 0.1em 0.3em;
162 | cursor: pointer;
163 | border: 1px solid #becede;
164 | border-radius: 0.2em;
165 | z-index: 1;
166 | }
167 | .doc-add-comment:hover {
168 | background: #cfe4fc;
169 | }
170 |
171 | .doc-comment {
172 | background: #eee;
173 | border-bottom: 1px solid #ddd;
174 | padding: 0.5em;
175 | font-size: 0.9em;
176 | width: 15em;
177 | word-break: break-all;
178 | }
179 | .doc-comment:last-child {
180 | border-bottom: none;
181 | }
182 | .doc-comment-author {
183 | font-size: 0.8em;
184 | color: #555;
185 | font-weight: bold;
186 | }
187 | .doc-comment-datetime {
188 | font-size: 0.8em;
189 | color: #555;
190 | }
191 | .doc-comment-body {
192 | margin: 0.5em 0;
193 | }
194 |
195 | .doc-comments {
196 | position: absolute;
197 | left: -14em;
198 | z-index: 1;
199 | box-shadow: 2px 2px 8px rgba(0,0,0,0.1);
200 | border: 2px solid #ddd;
201 | }
202 |
203 | .doc-comment textarea {
204 | min-height: 5em;
205 | }
206 |
207 | .doc-comment-resolve {
208 | position: absolute;
209 | font-size: 0.75em;
210 | right: 0.25em;
211 | top: 0.25em;
212 | border: 1px solid #ccc;
213 | background: #e5e5e5;
214 | }
215 | .doc-comment-resolve:hover {
216 | background: #dadada;
217 | }
218 |
219 | .doc-comment button {
220 | width: 100%;
221 | padding: 0.5em;
222 | background: #7070ff;
223 | color: #fff;
224 | border-radius: 0 0 0.2em 0.2em;
225 | }
226 |
227 | .doc-overlay {
228 | position: 'absolute';
229 | top: 0;
230 | left: 0;
231 | right: 0;
232 | bottom: 0;
233 | }
234 |
235 | .doc-editor-constrained .doc-overlay {
236 | pointer-events: none;
237 | }
238 |
239 | .doc-misc {
240 | text-align: center;
241 | margin: 1em 0;
242 | }
243 |
--------------------------------------------------------------------------------
/src/App.js:
--------------------------------------------------------------------------------
1 | import Doc from './Document';
2 | import HyperDoc from './HyperDoc';
3 | import InlineEditable from './InlineEditable';
4 | import React, {Component} from 'react';
5 | import Automerge from 'automerge';
6 | import { Creatable } from 'react-select';
7 | import 'react-select/dist/react-select.css';
8 |
9 |
10 | function shrinkId(id) {
11 | if (id.length <= 12) return id;
12 | let front = id.substring(0, 6);
13 | let end = id.substring(id.length - 6);
14 | return `${front}...${end}`;
15 | }
16 |
17 |
18 | class App extends Component {
19 | constructor(props) {
20 | super(props);
21 | this.state = {
22 | doc: null,
23 | docs: [],
24 | peerIds: {},
25 | name: props.id.substr(0, 6),
26 | color: props.colors[parseInt(props.id, 16) % props.colors.length]
27 | };
28 | }
29 |
30 | registerPeer(peer, msg) {
31 | // keep track of peer ids
32 | let peerIds = this.state.peerIds;
33 | let id = peer.remoteId.toString('hex');
34 | peerIds[id] = msg.id;
35 | }
36 |
37 | registerWithPeer(peer) {
38 | // tell new peers this peer's id
39 | this.props.hm._messagePeer(peer, {type: 'hi', id: this.props.id});
40 | }
41 |
42 | componentDidMount() {
43 | this.props.hm.on('peer:message', (actorId, peer, msg) => {
44 | if (msg.type === 'hi') {
45 | this.registerPeer(peer, msg);
46 | }
47 | });
48 |
49 | this.props.hm.on('peer:joined', (actorId, peer) => {
50 | this.registerWithPeer(peer);
51 | });
52 |
53 | this.props.hm.on('peer:left', (actorId, peer) => {
54 | if (this.state.doc && peer.remoteId) {
55 | // remove the leaving peer from the editor
56 | let id = peer.remoteId.toString('hex');
57 | id = this.state.peerIds[id];
58 | this.state.doc.leave(id);
59 | }
60 | });
61 |
62 | // remove self when closing window
63 | window.onbeforeunload = () => {
64 | this.state.doc.leave(this.props.id);
65 | }
66 |
67 | this.props.hm.on('document:updated', () => {
68 | this.updateDocsList();
69 | });
70 |
71 | this.props.hm.on('document:ready', () => {
72 | this.updateDocsList();
73 | });
74 | }
75 |
76 | onDocumentReady(doc) {
77 | doc.join(this.props.id, this.state.name, this.state.color);
78 | doc.on('updated', (doc) => this.setState({ doc }));
79 | this.setState({ doc });
80 | }
81 |
82 | createDocument() {
83 | let doc = HyperDoc.new();
84 | doc.once('ready', this.onDocumentReady.bind(this));
85 | }
86 |
87 | selectDocument(selected) {
88 | let docId = selected.value;
89 | this.openDocument(docId);
90 | }
91 |
92 | openDocument(docId) {
93 | try {
94 | let doc = HyperDoc.open(docId);
95 | if (doc.ready) {
96 | this.onDocumentReady(doc);
97 | } else {
98 | doc.once('ready', this.onDocumentReady.bind(this));
99 | }
100 | } catch(e) {
101 | console.log(e);
102 | }
103 | }
104 |
105 | updateDocsList() {
106 | let docs = Object.keys(this.props.hm.docs).map((docId) => {
107 | return { value: docId, label: this.props.hm.docs[docId].title };
108 | }).filter((d) => d.label);
109 | this.setState({ docs });
110 | }
111 |
112 | onEditName(ev) {
113 | let name = ev.target.value;
114 | if (name && this.state.doc) {
115 | this.state.doc.setName(this.props.id, name);
116 | this.setState({ name: name });
117 | }
118 | }
119 |
120 | export() {
121 | let fname = `${this.state.doc.title}.txt`;
122 | let text = this.state.doc.text;
123 | let el = document.createElement('a');
124 | el.setAttribute('href', `data:text/plain;charset=utf-8,${encodeURIComponent(text)}`);
125 | el.setAttribute('download', fname);
126 | el.style.display = 'none';
127 | document.body.appendChild(el);
128 | el.click();
129 | document.body.removeChild(el);
130 | }
131 |
132 | render() {
133 | let main;
134 | if (this.state.doc) {
135 | main = (
136 |
137 |
this.state.doc.title = title} />
141 | {this.state.doc.nPeers} peers
142 |
143 | Copy to share: {this.state.doc.id}
144 |
145 | Save as text
146 |
147 |
148 | );
149 | } else {
150 | // TODO these should be proper accessible links
151 | // which support browser history/clicking back
152 | main = (
153 |
154 |
Documents
155 |
162 |
163 | );
164 | }
165 |
166 | return
167 |
168 | Create new document
169 |
175 | `Open '${shrinkId(label)}'`}
181 | />
182 |
183 | {main}
184 |
185 | }
186 | }
187 |
188 | export default App;
189 |
--------------------------------------------------------------------------------
/src/HyperDoc.js:
--------------------------------------------------------------------------------
1 | import crypto from 'crypto';
2 | import Automerge from 'automerge';
3 | import EventEmitter from 'events';
4 |
5 | function DUMMY(changeDoc) {
6 | let id = crypto.randomBytes(32).toString('hex');
7 | changeDoc.text.insertAt(0, ...['a', 'b', 'c', 'd', 'e', 'f']);
8 | changeDoc.comments[id] = {
9 | id: id,
10 | start: 0,
11 | end: 5,
12 | resolved: false,
13 | thread: [{
14 | id: crypto.randomBytes(32).toString('hex'),
15 | created: Date.now(),
16 | author: 'Francis',
17 | body: 'This is a test comment'
18 | }, {
19 | id: crypto.randomBytes(32).toString('hex'),
20 | created: Date.now(),
21 | author: 'Frank',
22 | body: 'This is my response'
23 | }]
24 | };
25 | }
26 |
27 |
28 |
29 | class HyperDoc extends EventEmitter {
30 | constructor(doc) {
31 | super();
32 |
33 | // ugh hacky
34 | this.hm = HyperDoc.hm;
35 |
36 | if (doc) {
37 | this._setDoc(doc);
38 | } else {
39 | this.ready = false;
40 | }
41 | this.diffs = [];
42 |
43 | this.hm.on('document:updated', this._onUpdate.bind(this));
44 | }
45 |
46 | static new() {
47 | let hyd = new HyperDoc();
48 | this.hm.create();
49 | this.listenForDoc(hyd);
50 | return hyd;
51 | }
52 |
53 | static open(id) {
54 | if (this.hm.has(id)) {
55 | let doc = this.hm.find(id);
56 | return new HyperDoc(doc);
57 | } else {
58 | let hyd = new HyperDoc();
59 | this.hm.open(id);
60 | this.listenForDoc(hyd);
61 | return hyd;
62 | }
63 | }
64 |
65 | static listenForDoc(hyd) {
66 | this.hm.once('document:ready', (docId, doc, prevDoc) => {
67 | doc = this.hm.change(doc, (changeDoc) => {
68 | if (!changeDoc.text) {
69 | changeDoc.text = new Automerge.Text();
70 | changeDoc.title = 'Untitled';
71 | changeDoc.peers = {};
72 | changeDoc.comments = {};
73 |
74 | // TODO TESTING
75 | DUMMY(changeDoc);
76 | }
77 | });
78 |
79 | hyd._setDoc(doc);
80 | hyd.emit('ready', hyd);
81 | });
82 | }
83 |
84 | _setDoc(doc) {
85 | this.doc = doc;
86 | this.id = this.hm.getId(doc);
87 | this.ready = true;
88 | }
89 |
90 | get peers() {
91 | return this.doc.peers;
92 | }
93 |
94 | get nPeers() {
95 | return Object.keys(this.doc.peers).length;
96 | }
97 |
98 | get text() {
99 | return this.doc.text.join('');
100 | }
101 |
102 | get title() {
103 | return this.doc.title;
104 | }
105 |
106 | set title(title) {
107 | this._changeDoc((changeDoc) => {
108 | changeDoc.title = title;
109 | });
110 | }
111 |
112 | get comments() {
113 | return this.doc.comments;
114 | }
115 |
116 | _changeDoc(changeFn) {
117 | this.doc = this.hm.change(this.doc, changeFn);
118 | this.emit('updated', this);
119 | }
120 |
121 | _onUpdate(docId, doc, prevDoc) {
122 | if (this.id == docId) {
123 | let diff = Automerge.diff(prevDoc, doc);
124 | this.lastDiffs = diff.filter((d) => d.type === 'text');
125 | this.doc = doc;
126 | this.emit('updated', this);
127 | }
128 | }
129 |
130 | setSelection(peerId, caretPos, caretIdx) {
131 | // update peers about caret position
132 | this._changeDoc((changeDoc) => {
133 | changeDoc.peers[peerId].pos = caretPos;
134 | changeDoc.peers[peerId].idx = caretIdx;
135 | });
136 | }
137 |
138 | setName(peerId, name) {
139 | this._changeDoc((changeDoc) => {
140 | changeDoc.peers[peerId].name = name;
141 | });
142 | }
143 |
144 | editText(edits) {
145 | this._changeDoc((changeDoc) => {
146 | edits.forEach((e) => {
147 | if (e.inserted) {
148 | changeDoc.text.insertAt(e.caret, ...e.changed);
149 | } else {
150 | for (let i=0; i {
157 | if (e.caret < c.start + 1) {
158 | if (e.inserted) {
159 | c.start++;
160 | } else {
161 | c.start--;
162 | }
163 | }
164 |
165 | if (e.caret < c.end) {
166 | if (e.inserted) {
167 | c.end++;
168 | } else {
169 | c.end--;
170 | }
171 | }
172 | });
173 | });
174 | });
175 | }
176 |
177 | join(id, name, color) {
178 | this._changeDoc((changeDoc) => {
179 | changeDoc.peers[id] = {
180 | id: id,
181 | name: name,
182 | color: color
183 | };
184 | });
185 | }
186 |
187 | leave(id) {
188 | this._changeDoc((changeDoc) => {
189 | delete changeDoc.peers[id];
190 | });
191 | }
192 |
193 | addComment(peerId, threadId, body, start, end) {
194 | if (!body) return;
195 | this._changeDoc((changeDoc) => {
196 | // TODO ideally this uses persistent id or sth
197 | let name = changeDoc.peers[peerId].name;
198 | let commentId = crypto.randomBytes(32).toString('hex');
199 | let comment = {
200 | id: commentId,
201 | created: Date.now(),
202 | author: name,
203 | body: body
204 | };
205 | if (threadId) {
206 | changeDoc.comments[threadId].thread.push(comment);
207 | } else {
208 | threadId = crypto.randomBytes(32).toString('hex');
209 | changeDoc.comments[threadId] = {
210 | id: threadId,
211 | start: start,
212 | end: end,
213 | resolved: false,
214 | thread: [comment]
215 | };
216 | }
217 | });
218 | }
219 |
220 | resolveComment(threadId) {
221 | this._changeDoc((changeDoc) => {
222 | changeDoc.comments[threadId].resolved = true;
223 | });
224 | }
225 | }
226 |
227 | export default HyperDoc;
228 |
--------------------------------------------------------------------------------
/css/normalize.css:
--------------------------------------------------------------------------------
1 | /*! normalize.css v7.0.0 | MIT License | github.com/necolas/normalize.css */
2 |
3 | /* Document
4 | ========================================================================== */
5 |
6 | /**
7 | * 1. Correct the line height in all browsers.
8 | * 2. Prevent adjustments of font size after orientation changes in
9 | * IE on Windows Phone and in iOS.
10 | */
11 |
12 | html {
13 | line-height: 1.15; /* 1 */
14 | -ms-text-size-adjust: 100%; /* 2 */
15 | -webkit-text-size-adjust: 100%; /* 2 */
16 | }
17 |
18 | /* Sections
19 | ========================================================================== */
20 |
21 | /**
22 | * Remove the margin in all browsers (opinionated).
23 | */
24 |
25 | body {
26 | margin: 0;
27 | }
28 |
29 | /**
30 | * Add the correct display in IE 9-.
31 | */
32 |
33 | article,
34 | aside,
35 | footer,
36 | header,
37 | nav,
38 | section {
39 | display: block;
40 | }
41 |
42 | /**
43 | * Correct the font size and margin on `h1` elements within `section` and
44 | * `article` contexts in Chrome, Firefox, and Safari.
45 | */
46 |
47 | h1 {
48 | font-size: 2em;
49 | margin: 0.67em 0;
50 | }
51 |
52 | /* Grouping content
53 | ========================================================================== */
54 |
55 | /**
56 | * Add the correct display in IE 9-.
57 | * 1. Add the correct display in IE.
58 | */
59 |
60 | figcaption,
61 | figure,
62 | main { /* 1 */
63 | display: block;
64 | }
65 |
66 | /**
67 | * Add the correct margin in IE 8.
68 | */
69 |
70 | figure {
71 | margin: 1em 40px;
72 | }
73 |
74 | /**
75 | * 1. Add the correct box sizing in Firefox.
76 | * 2. Show the overflow in Edge and IE.
77 | */
78 |
79 | hr {
80 | box-sizing: content-box; /* 1 */
81 | height: 0; /* 1 */
82 | overflow: visible; /* 2 */
83 | }
84 |
85 | /**
86 | * 1. Correct the inheritance and scaling of font size in all browsers.
87 | * 2. Correct the odd `em` font sizing in all browsers.
88 | */
89 |
90 | pre {
91 | font-family: monospace, monospace; /* 1 */
92 | font-size: 1em; /* 2 */
93 | }
94 |
95 | /* Text-level semantics
96 | ========================================================================== */
97 |
98 | /**
99 | * 1. Remove the gray background on active links in IE 10.
100 | * 2. Remove gaps in links underline in iOS 8+ and Safari 8+.
101 | */
102 |
103 | a {
104 | background-color: transparent; /* 1 */
105 | -webkit-text-decoration-skip: objects; /* 2 */
106 | }
107 |
108 | /**
109 | * 1. Remove the bottom border in Chrome 57- and Firefox 39-.
110 | * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
111 | */
112 |
113 | abbr[title] {
114 | border-bottom: none; /* 1 */
115 | text-decoration: underline; /* 2 */
116 | text-decoration: underline dotted; /* 2 */
117 | }
118 |
119 | /**
120 | * Prevent the duplicate application of `bolder` by the next rule in Safari 6.
121 | */
122 |
123 | b,
124 | strong {
125 | font-weight: inherit;
126 | }
127 |
128 | /**
129 | * Add the correct font weight in Chrome, Edge, and Safari.
130 | */
131 |
132 | b,
133 | strong {
134 | font-weight: bolder;
135 | }
136 |
137 | /**
138 | * 1. Correct the inheritance and scaling of font size in all browsers.
139 | * 2. Correct the odd `em` font sizing in all browsers.
140 | */
141 |
142 | code,
143 | kbd,
144 | samp {
145 | font-family: monospace, monospace; /* 1 */
146 | font-size: 1em; /* 2 */
147 | }
148 |
149 | /**
150 | * Add the correct font style in Android 4.3-.
151 | */
152 |
153 | dfn {
154 | font-style: italic;
155 | }
156 |
157 | /**
158 | * Add the correct background and color in IE 9-.
159 | */
160 |
161 | mark {
162 | background-color: #ff0;
163 | color: #000;
164 | }
165 |
166 | /**
167 | * Add the correct font size in all browsers.
168 | */
169 |
170 | small {
171 | font-size: 80%;
172 | }
173 |
174 | /**
175 | * Prevent `sub` and `sup` elements from affecting the line height in
176 | * all browsers.
177 | */
178 |
179 | sub,
180 | sup {
181 | font-size: 75%;
182 | line-height: 0;
183 | position: relative;
184 | vertical-align: baseline;
185 | }
186 |
187 | sub {
188 | bottom: -0.25em;
189 | }
190 |
191 | sup {
192 | top: -0.5em;
193 | }
194 |
195 | /* Embedded content
196 | ========================================================================== */
197 |
198 | /**
199 | * Add the correct display in IE 9-.
200 | */
201 |
202 | audio,
203 | video {
204 | display: inline-block;
205 | }
206 |
207 | /**
208 | * Add the correct display in iOS 4-7.
209 | */
210 |
211 | audio:not([controls]) {
212 | display: none;
213 | height: 0;
214 | }
215 |
216 | /**
217 | * Remove the border on images inside links in IE 10-.
218 | */
219 |
220 | img {
221 | border-style: none;
222 | }
223 |
224 | /**
225 | * Hide the overflow in IE.
226 | */
227 |
228 | svg:not(:root) {
229 | overflow: hidden;
230 | }
231 |
232 | /* Forms
233 | ========================================================================== */
234 |
235 | /**
236 | * 1. Change the font styles in all browsers (opinionated).
237 | * 2. Remove the margin in Firefox and Safari.
238 | */
239 |
240 | button,
241 | input,
242 | optgroup,
243 | select,
244 | textarea {
245 | font-family: sans-serif; /* 1 */
246 | font-size: 100%; /* 1 */
247 | line-height: 1.15; /* 1 */
248 | margin: 0; /* 2 */
249 | }
250 |
251 | /**
252 | * Show the overflow in IE.
253 | * 1. Show the overflow in Edge.
254 | */
255 |
256 | button,
257 | input { /* 1 */
258 | overflow: visible;
259 | }
260 |
261 | /**
262 | * Remove the inheritance of text transform in Edge, Firefox, and IE.
263 | * 1. Remove the inheritance of text transform in Firefox.
264 | */
265 |
266 | button,
267 | select { /* 1 */
268 | text-transform: none;
269 | }
270 |
271 | /**
272 | * 1. Prevent a WebKit bug where (2) destroys native `audio` and `video`
273 | * controls in Android 4.
274 | * 2. Correct the inability to style clickable types in iOS and Safari.
275 | */
276 |
277 | button,
278 | html [type="button"], /* 1 */
279 | [type="reset"],
280 | [type="submit"] {
281 | -webkit-appearance: button; /* 2 */
282 | }
283 |
284 | /**
285 | * Remove the inner border and padding in Firefox.
286 | */
287 |
288 | button::-moz-focus-inner,
289 | [type="button"]::-moz-focus-inner,
290 | [type="reset"]::-moz-focus-inner,
291 | [type="submit"]::-moz-focus-inner {
292 | border-style: none;
293 | padding: 0;
294 | }
295 |
296 | /**
297 | * Restore the focus styles unset by the previous rule.
298 | */
299 |
300 | button:-moz-focusring,
301 | [type="button"]:-moz-focusring,
302 | [type="reset"]:-moz-focusring,
303 | [type="submit"]:-moz-focusring {
304 | outline: 1px dotted ButtonText;
305 | }
306 |
307 | /**
308 | * Correct the padding in Firefox.
309 | */
310 |
311 | fieldset {
312 | padding: 0.35em 0.75em 0.625em;
313 | }
314 |
315 | /**
316 | * 1. Correct the text wrapping in Edge and IE.
317 | * 2. Correct the color inheritance from `fieldset` elements in IE.
318 | * 3. Remove the padding so developers are not caught out when they zero out
319 | * `fieldset` elements in all browsers.
320 | */
321 |
322 | legend {
323 | box-sizing: border-box; /* 1 */
324 | color: inherit; /* 2 */
325 | display: table; /* 1 */
326 | max-width: 100%; /* 1 */
327 | padding: 0; /* 3 */
328 | white-space: normal; /* 1 */
329 | }
330 |
331 | /**
332 | * 1. Add the correct display in IE 9-.
333 | * 2. Add the correct vertical alignment in Chrome, Firefox, and Opera.
334 | */
335 |
336 | progress {
337 | display: inline-block; /* 1 */
338 | vertical-align: baseline; /* 2 */
339 | }
340 |
341 | /**
342 | * Remove the default vertical scrollbar in IE.
343 | */
344 |
345 | textarea {
346 | overflow: auto;
347 | }
348 |
349 | /**
350 | * 1. Add the correct box sizing in IE 10-.
351 | * 2. Remove the padding in IE 10-.
352 | */
353 |
354 | [type="checkbox"],
355 | [type="radio"] {
356 | box-sizing: border-box; /* 1 */
357 | padding: 0; /* 2 */
358 | }
359 |
360 | /**
361 | * Correct the cursor style of increment and decrement buttons in Chrome.
362 | */
363 |
364 | [type="number"]::-webkit-inner-spin-button,
365 | [type="number"]::-webkit-outer-spin-button {
366 | height: auto;
367 | }
368 |
369 | /**
370 | * 1. Correct the odd appearance in Chrome and Safari.
371 | * 2. Correct the outline style in Safari.
372 | */
373 |
374 | [type="search"] {
375 | -webkit-appearance: textfield; /* 1 */
376 | outline-offset: -2px; /* 2 */
377 | }
378 |
379 | /**
380 | * Remove the inner padding and cancel buttons in Chrome and Safari on macOS.
381 | */
382 |
383 | [type="search"]::-webkit-search-cancel-button,
384 | [type="search"]::-webkit-search-decoration {
385 | -webkit-appearance: none;
386 | }
387 |
388 | /**
389 | * 1. Correct the inability to style clickable types in iOS and Safari.
390 | * 2. Change font properties to `inherit` in Safari.
391 | */
392 |
393 | ::-webkit-file-upload-button {
394 | -webkit-appearance: button; /* 1 */
395 | font: inherit; /* 2 */
396 | }
397 |
398 | /* Interactive
399 | ========================================================================== */
400 |
401 | /*
402 | * Add the correct display in IE 9-.
403 | * 1. Add the correct display in Edge, IE, and Firefox.
404 | */
405 |
406 | details, /* 1 */
407 | menu {
408 | display: block;
409 | }
410 |
411 | /*
412 | * Add the correct display in all browsers.
413 | */
414 |
415 | summary {
416 | display: list-item;
417 | }
418 |
419 | /* Scripting
420 | ========================================================================== */
421 |
422 | /**
423 | * Add the correct display in IE 9-.
424 | */
425 |
426 | canvas {
427 | display: inline-block;
428 | }
429 |
430 | /**
431 | * Add the correct display in IE.
432 | */
433 |
434 | template {
435 | display: none;
436 | }
437 |
438 | /* Hidden
439 | ========================================================================== */
440 |
441 | /**
442 | * Add the correct display in IE 10-.
443 | */
444 |
445 | [hidden] {
446 | display: none;
447 | }
448 |
449 |
--------------------------------------------------------------------------------