├── .gitignore ├── wipe_docs ├── shell.nix ├── get-doc-dir.js ├── index.html ├── README.md ├── swarm.js ├── package.json ├── css ├── style.css └── quill.snow.css ├── app.js ├── document-list.js ├── local-docs.js ├── index.js └── document.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /wipe_docs: -------------------------------------------------------------------------------- 1 | #/usr/bin/env bash 2 | 3 | rm -rf ~/.config/Electron/hyperpad/* 4 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | with import {}; 2 | 3 | stdenv.mkDerivation { 4 | name = "nixos.org-homepage"; 5 | 6 | postHook = "unset http_proxy"; # hack for nix-shell 7 | 8 | buildInputs = 9 | [ python 10 | gcc 11 | electron 12 | ]; 13 | } 14 | -------------------------------------------------------------------------------- /get-doc-dir.js: -------------------------------------------------------------------------------- 1 | var argv = require('minimist')(process.argv) 2 | var path = require('path') 3 | var electron = require('electron') 4 | 5 | module.exports = function () { 6 | if (argv.dir) return path.join(argv.dir, 'hyperpad') 7 | else return path.join(electron.app.getPath('userData'), 'hyperpad') 8 | } 9 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
6 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hyperpad-desktop 2 | 3 | > A peer-to-peer collaborative text editor for humans and communities. 4 | 5 | ## What is it? 6 | 7 | Hyperpad is a free, open source, peer-to-peer text editor for people and their 8 | communities. Authors control who gets access, and data is hosted by the peers 9 | who are interested in it. 10 | 11 | ## Current Status 12 | 13 | Alpha quality: you can create docs, share them with others, and edit in 14 | realtime. Some things may not work correctly. 15 | 16 | ## Install & Run 17 | 18 | ``` 19 | $ git clone git@github.com:noffle/hyperpad-desktop 20 | 21 | $ cd hyperpad-desktop 22 | 23 | $ npm install 24 | 25 | $ npm run rebuild 26 | 27 | $ npm run start 28 | ``` 29 | 30 | ## License 31 | 32 | [ISC](https://en.wikipedia.org/wiki/ISC_license) 33 | -------------------------------------------------------------------------------- /swarm.js: -------------------------------------------------------------------------------- 1 | var dswarm = require('discovery-swarm') 2 | var getport = require('get-port') 3 | 4 | module.exports = function (hash, str) { 5 | var swarm = dswarm() 6 | 7 | var res = { 8 | swarm: swarm, 9 | peers: 0 10 | } 11 | 12 | getport().then(function (port) { 13 | console.log('listening on swarm port', port) 14 | swarm.listen(port) 15 | 16 | swarm.join(hash) 17 | console.log('joined swarm for', hash) 18 | }) 19 | 20 | swarm.on('connection', function (conn, info) { 21 | console.log('new peer', info) 22 | var r = str.log.replicate({live:true}) 23 | r.pipe(conn).pipe(r) 24 | .once('end', function () { 25 | console.log('lost peer') 26 | res.peers-- 27 | }) 28 | .once('error', function (err) { 29 | console.log('lost peer', err) 30 | res.peers-- 31 | }) 32 | res.peers++ 33 | }) 34 | 35 | return res 36 | } 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hyperpad-desktop", 3 | "description": "Peer-to-peer collaborative text editing on your desktop with Electron.", 4 | "author": "Stephen Whitmore ", 5 | "version": "1.0.1", 6 | "repository": { 7 | "url": "git://github.com/noffle/hyperpad-desktop.git" 8 | }, 9 | "homepage": "https://github.com/noffle/hyperpad-desktop", 10 | "bugs": "https://github.com/noffle/hyperpad-desktop/issues", 11 | "main": "app.js", 12 | "scripts": { 13 | "lint": "standard", 14 | "start": "electron app.js", 15 | "rebuild": "electron-rebuild", 16 | "postinstall": "npm run rebuild" 17 | }, 18 | "keywords": [], 19 | "dependencies": { 20 | "choo": "^6.8.0", 21 | "discovery-swarm": "^4.4.2", 22 | "electron": "^1.7.5", 23 | "get-port": "^3.2.0", 24 | "hyper-string": "^3.1.0", 25 | "hyperlog-index": "^5.2.2", 26 | "level": "^2.1.1", 27 | "minimist": "^1.2.0", 28 | "mkdirp": "^0.5.1", 29 | "quill": "^1.3.4", 30 | "randombytes": "^2.0.6", 31 | "subleveldown": "^2.1.0" 32 | }, 33 | "devDependencies": { 34 | "cross-env": "^5.1.1", 35 | "cross-spawn": "^5.1.0", 36 | "electron-rebuild": "^1.7.3", 37 | "standard": "~10.0.0" 38 | }, 39 | "license": "ISC" 40 | } 41 | -------------------------------------------------------------------------------- /css/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | } 4 | .right-side { 5 | display: table-cell; 6 | padding: 5px; 7 | padding-right: 10px; 8 | width: 100%; 9 | height: 100%; 10 | } 11 | .left-side { 12 | padding: 5px; 13 | height: 100%; 14 | display: table-cell; 15 | } 16 | .doclist { 17 | width: 350px; 18 | font-size: 15px; 19 | border-width: 1px 0 0 0; 20 | border-style: solid; 21 | border-color: #AAAAAA; 22 | cursor: pointer; 23 | } 24 | .docitem { 25 | border-width: 0px 1px 1px 1px; 26 | border-style: solid; 27 | border-color: #AAAAAA; 28 | height: 40px; 29 | } 30 | .docitem-selected { 31 | border-width: 0px 1px 1px 1px; 32 | border-style: solid; 33 | border-color: #AAAAAA; 34 | background: #DFDFDF; 35 | height: 40px; 36 | } 37 | .docitem-special { 38 | border-width: 0px 1px 1px 1px; 39 | border-style: solid; 40 | border-color: #AAAAAA; 41 | background: #F1F1F1; 42 | color: #999999; 43 | text-align: center; 44 | height: 40px; 45 | } 46 | .docitem-contents { 47 | display: inline; 48 | color: #222222; 49 | } 50 | .docitem-contents-top { 51 | padding-left: 3px; 52 | padding-top: 3px; 53 | font-family: sans-serif; 54 | color: black; 55 | font-weight: bold; 56 | font-size: 18px; 57 | } 58 | .docitem-contents-bottom { 59 | font-family: sans-serif; 60 | color: grey; 61 | font-size: 11px; 62 | } 63 | .doc-title-input { 64 | font-weight: bold; 65 | width: 100%; 66 | font-size: 32px; 67 | margin-bottom: 15px; 68 | } 69 | .closeButton { 70 | float: right; 71 | margin-top: 6px; 72 | margin-right: 10px; 73 | padding-left: 4px; 74 | width: 15px; 75 | height: 18px; 76 | border-style: solid; 77 | border-width: 1px; 78 | border-color: red; 79 | background-color: red; 80 | color: white; 81 | font-family: sans-serif; 82 | font-weight: bold; 83 | } 84 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env electron 2 | 3 | var path = require('path') 4 | var mkdirp = require('mkdirp') 5 | var electron = require('electron') 6 | var app = electron.app // Module to control application life. 7 | var BrowserWindow = electron.BrowserWindow // Module to create native browser window. 8 | 9 | var APP_NAME = 'Hyperpad' 10 | var win = null 11 | 12 | // Set up app storage dir 13 | var userDataPath = require('./get-doc-dir')() 14 | mkdirp.sync(userDataPath) 15 | console.log(userDataPath) 16 | 17 | // Set up global node exception handler 18 | handleUncaughtExceptions() 19 | 20 | // Create app window 21 | createMainWindow() 22 | 23 | //--------------------------------------------------------------------------- 24 | 25 | function createMainWindow () { 26 | var win 27 | 28 | app.once('ready', ready) 29 | 30 | // Quit when all windows are closed. 31 | app.on('window-all-closed', function () { 32 | app.quit() 33 | }) 34 | 35 | function ready () { 36 | var INDEX = 'file://' + path.resolve(__dirname, './index.html') 37 | if (!win) { 38 | win = new BrowserWindow({title: APP_NAME, show: false}) 39 | win.once('ready-to-show', function () { 40 | win.show() 41 | win.maximize() 42 | }) 43 | } 44 | // if (argv.debug) win.webContents.openDevTools() 45 | win.loadURL(INDEX) 46 | 47 | win.on('closed', function () { 48 | win = null 49 | app.quit() 50 | }) 51 | 52 | var ipc = electron.ipcMain 53 | 54 | ipc.on('get-user-data-path', function (ev) { 55 | ev.returnValue = userDataPath 56 | }) 57 | } 58 | 59 | return win 60 | } 61 | 62 | function handleUncaughtExceptions () { 63 | process.on('uncaughtException', function (error) { 64 | console.log('uncaughtException in Node:', error) 65 | 66 | // Show a vaguely informative dialog. 67 | if (app && win) { 68 | var opts = { 69 | type: 'error', 70 | buttons: [ 'OK' ], 71 | title: 'Error Fatal', 72 | message: error.message 73 | } 74 | electron.dialog.showMessageBox(win, opts, function () { 75 | process.exit(1) 76 | }) 77 | } 78 | }) 79 | } 80 | -------------------------------------------------------------------------------- /document-list.js: -------------------------------------------------------------------------------- 1 | var html = require('choo/html') 2 | 3 | module.exports = function (state, emit) { 4 | var extra = [renderCreateDocRow(), renderAddDocRow()] 5 | return html` 6 |
7 | ${extra.concat(state.documents.map(function (elm, i) { 8 | return renderListDoc(elm, i, state.selectedDocumentIdx === i, state.mouseoverDocIdx === i) 9 | }))} 10 |
11 | ` 12 | 13 | function renderCreateDocRow () { 14 | return html`
15 |
Create new pad
16 |
` 17 | function onClickCreate () { 18 | emit('createDocument') 19 | } 20 | } 21 | 22 | function renderAddDocRow () { 23 | return html`
24 |
Add pad from hash
25 |
26 | 27 |
28 |
` 29 | function onKeyDown (e) { 30 | if (e.keyCode === 13) { 31 | var hash = this.value 32 | this.value = '' 33 | emit('addDocument', hash) 34 | } 35 | } 36 | } 37 | 38 | function renderListDoc (elm, i, selected, mouseover) { 39 | var clazz = 'docitem' 40 | if (selected) clazz = 'docitem-selected' 41 | return html`
42 | ${mouseover ? renderCloseButton(i) : html``} 43 |
44 |
${elm.title}
45 |
${state.documents[i].hash}
46 |
47 |
` 48 | function onClick () { 49 | emit('selectDocument', i) 50 | } 51 | function onEnter () { 52 | emit('mouseEnterDocument', i) 53 | } 54 | function onExit () { 55 | emit('mouseExitDocument', i) 56 | } 57 | } 58 | 59 | function renderCloseButton (i) { 60 | return html`
X
` 61 | 62 | function onClick () { 63 | emit('deleteDocument', i) 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /local-docs.js: -------------------------------------------------------------------------------- 1 | var level = require('level') 2 | var path = require('path') 3 | var fs = require('fs') 4 | var ipc = require('electron').ipcRenderer 5 | var randomBytes = require('randombytes') 6 | var hstring = require('hyper-string') 7 | 8 | module.exports = { 9 | list: list, 10 | create: create, 11 | add: add, 12 | del: del 13 | } 14 | 15 | function create (cb) { 16 | var id = randomBytes(20).toString('hex') 17 | var userDataPath = ipc.sendSync('get-user-data-path') 18 | var docPath = path.join(userDataPath, id) 19 | var db = level(docPath) 20 | var str = hstring(db) 21 | 22 | // update doc list 23 | var docListPath = path.join(userDataPath, 'docs.json') 24 | var docList 25 | if (fs.existsSync(docListPath)) { 26 | docList = JSON.parse(fs.readFileSync(docListPath, 'utf8')) 27 | } else { 28 | docList = { docs: [] } 29 | } 30 | docList.docs.unshift(id) 31 | fs.writeFileSync(docListPath, JSON.stringify(docList), 'utf8') 32 | 33 | // TODO: break this out into hyper-doc module (named document; comments; members; etc) 34 | str.log.append({type: 'id', id: id}, function (err) { 35 | db.close(function () { 36 | cb(null, id) 37 | }) 38 | }) 39 | } 40 | 41 | function add (hash) { 42 | // update doc list 43 | var userDataPath = ipc.sendSync('get-user-data-path') 44 | var docListPath = path.join(userDataPath, 'docs.json') 45 | var docList 46 | if (fs.existsSync(docListPath)) { 47 | docList = JSON.parse(fs.readFileSync(docListPath, 'utf8')) 48 | } else { 49 | docList = { docs: [] } 50 | } 51 | docList.docs.unshift(hash) 52 | fs.writeFileSync(docListPath, JSON.stringify(docList), 'utf8') 53 | } 54 | 55 | function list (cb) { 56 | var userDataPath = ipc.sendSync('get-user-data-path') 57 | 58 | // get doc list 59 | var docListPath = path.join(userDataPath, 'docs.json') 60 | var hashes = [] 61 | if (fs.existsSync(docListPath)) { 62 | hashes = JSON.parse(fs.readFileSync(docListPath, 'utf8')).docs 63 | } 64 | 65 | // get all titles 66 | var pending = hashes.length 67 | hashes.forEach(function (name, idx) { 68 | fs.stat(path.join(userDataPath, name), function (err, stats) { 69 | if (err) throw err 70 | var db = level(path.join(userDataPath, name)) 71 | db.get('!doc!title', function (err, title) { 72 | db.close(function () { 73 | hashes[idx] = { hash: name, title: title || name.substring(0, 15) } 74 | if (!--pending) cb(null, hashes) 75 | }) 76 | }) 77 | }) 78 | }) 79 | } 80 | 81 | function del (idx, cb) { 82 | var userDataPath = ipc.sendSync('get-user-data-path') 83 | var docListPath = path.join(userDataPath, 'docs.json') 84 | if (fs.existsSync(docListPath)) { 85 | var json = JSON.parse(fs.readFileSync(docListPath, 'utf8')) 86 | json.docs.splice(idx, 1) 87 | fs.writeFileSync(docListPath, JSON.stringify(json), 'utf8') 88 | cb() 89 | } else { 90 | cb(new Error('no such doc! this shouldnt happen! aah!')) 91 | } 92 | } 93 | 94 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var Quill = require('quill') 2 | var Doc = require('./document') 3 | var renderDocumentList = require('./document-list') 4 | var localDocs = require('./local-docs') 5 | var path = require('path') 6 | var ipc = require('electron').ipcRenderer 7 | 8 | var choo = require('choo') 9 | var html = require('choo/html') 10 | 11 | var editor 12 | 13 | var app = choo() 14 | app.use(function (state, emitter) { 15 | state.documents = [] 16 | state.selectedDocumentIdx = -1 17 | state.editingDocumentTitle = false 18 | 19 | setTimeout(function () { 20 | state.editor = new Quill('#editor', { 21 | modules: { toolbar: '' }, 22 | theme: 'snow' 23 | }) 24 | }, 500) 25 | 26 | // Load up initial doc list 27 | localDocs.list(function (err, docs) { 28 | if (err) throw err 29 | state.documents = docs 30 | emitter.emit('render') 31 | }) 32 | 33 | emitter.on('createDocument', function () { 34 | localDocs.create(function (err, hash) { 35 | state.documents.unshift({ hash: hash, title: hash }) 36 | emitter.emit('selectDocument', 0) 37 | }) 38 | }) 39 | 40 | emitter.on('addDocument', function (hash) { 41 | state.documents.unshift({ hash: hash, title: hash }) 42 | localDocs.add(hash) 43 | emitter.emit('selectDocument', 0) 44 | }) 45 | 46 | emitter.on('deleteDocument', function (i) { 47 | localDocs.del(i, function () { 48 | state.documents.splice(i, 1) 49 | if (state.selectedDocumentIdx === i) { 50 | state.selectedDocumentIdx-- 51 | } 52 | emitter.emit('render') 53 | }) 54 | }) 55 | 56 | emitter.on('selectDocument', function (i) { 57 | state.selectedDocumentIdx = i 58 | emitter.emit('render') 59 | selectDocument(state, emitter, state.documents[i].hash, state.editor) 60 | }) 61 | 62 | emitter.on('gotDocumentTitle', function (title) { 63 | state.documents[state.selectedDocumentIdx].title = title 64 | emitter.emit('render') 65 | }) 66 | 67 | emitter.on('clickDocumentTitle', function () { 68 | state.editingDocumentTitle = true 69 | emitter.emit('render') 70 | setTimeout(function () { document.getElementById('doc-title').focus() }, 150) 71 | }) 72 | emitter.on('setDocumentTitle', function (title) { 73 | state.editingDocumentTitle = false 74 | if (!title) emitter.emit('render') 75 | else { 76 | state.currentDoc.setTitle(title, function () { 77 | emitter.emit('render') 78 | }) 79 | } 80 | }) 81 | 82 | emitter.on('mouseEnterDocument', function (i) { 83 | state.mouseoverDocIdx = i 84 | emitter.emit('render') 85 | }) 86 | emitter.on('mouseExitDocument', function (i) { 87 | state.mouseoverDocIdx = -1 88 | emitter.emit('render') 89 | }) 90 | }) 91 | app.route('/', mainView) 92 | app.mount('body') 93 | 94 | function mainView (state, emit) { 95 | return html` 96 | 97 |
98 | ${renderDocumentList(state, emit)} 99 |
100 | 104 | 105 | ` 106 | } 107 | 108 | function renderDocumentTitle (state, emit) { 109 | if (state.editingDocumentTitle) { 110 | return html` 111 | 112 | ` 113 | } else { 114 | return html` 115 |
116 |

${getDocumentTitle(state)}

117 |

${(state.documents[state.selectedDocumentIdx] || {hash:''}).hash}

118 |
119 | ` 120 | } 121 | 122 | function onClick () { 123 | emit('clickDocumentTitle') 124 | } 125 | 126 | function onKeyPress (ev) { 127 | if (ev.keyCode === 27) { 128 | emit('setDocumentTitle', null) 129 | } else if (ev.keyCode === 13) { 130 | emit('setDocumentTitle', this.value) 131 | } 132 | } 133 | 134 | function onBlur () { 135 | emit('setDocumentTitle', null) 136 | } 137 | 138 | function getDocumentTitle (state) { 139 | if (state.selectedDocumentIdx >= 0) return state.documents[state.selectedDocumentIdx].title 140 | else return '' 141 | } 142 | } 143 | 144 | // TODO: can I do this in a more choo-like way? 145 | var editorElm 146 | function renderEditor (state) { 147 | if (editorElm) { 148 | if (state.selectedDocumentIdx >= 0) { 149 | editorElm.style.display = 'block' 150 | } 151 | return editorElm 152 | } 153 | 154 | var editorElement = document.createElement("div") 155 | editorElement.id = 'editor' 156 | window.onresize = function () { 157 | editorElement.style.height = (window.innerHeight - 80) + 'px' 158 | } 159 | editorElement.classList.add('editor') 160 | editorElement.style['font-family'] = '"SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace' 161 | editorElement.style.display = 'none' 162 | editorElement.isSameNode = function (target) { 163 | return (target && target.nodeName && target.nodeName === 'DIV') 164 | } 165 | editorElm = editorElement 166 | return editorElement 167 | } 168 | 169 | function selectDocument (state, emitter, hash, editor) { 170 | document.getElementById('doc-title').innerText = hash 171 | if (state.currentDoc) { 172 | state.currentDoc.unregister(start) 173 | } else { 174 | start() 175 | } 176 | 177 | function start () { 178 | var userDataPath = ipc.sendSync('get-user-data-path') 179 | state.currentDoc = Doc(path.join(userDataPath, hash), hash, editor, emitter) 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /document.js: -------------------------------------------------------------------------------- 1 | var hstring = require('hyper-string') 2 | var level = require('level') 3 | var path = require('path') 4 | var hindex = require('hyperlog-index') 5 | var sublevel = require('subleveldown') 6 | var dswarm = require('./swarm') 7 | 8 | module.exports = function (docPath, hash, editor, emitter) { 9 | editor.focus() 10 | 11 | var db = level(docPath) 12 | var str = hstring(db) 13 | this.str = str 14 | 15 | var swarm = dswarm(hash, str) 16 | 17 | var title = docPath 18 | var titleIndex = hindex({ 19 | log: str.log, 20 | db: sublevel(db, 'doc'), 21 | map: function (node, next) { 22 | if (node.value.type === 'title') { 23 | titleIndex.db.put('title', node.value.title, function (err) { 24 | emitter.emit('gotDocumentTitle', node.value.title) 25 | }) 26 | } 27 | next() 28 | } 29 | }) 30 | var index 31 | var chars 32 | var localOpQueue = [] 33 | var remoteOpQueue = [] 34 | 35 | titleIndex.ready(function () { 36 | titleIndex.db.get('title', function (err, title) { 37 | console.log('res', err, title) 38 | if (title) emitter.emit('gotDocumentTitle', title) 39 | }) 40 | }) 41 | 42 | // Receive remote edits 43 | str.log.on('add', function (node) { 44 | if (Buffer.isBuffer(node.value)) { 45 | var data = JSON.parse(node.value.toString()) 46 | data.key = node.key 47 | // console.log('remote add', data) 48 | remoteOpQueue.push(data) 49 | processQueue() 50 | } 51 | }) 52 | 53 | str.snapshot(function (err, res) { 54 | if (err) throw err 55 | 56 | console.log('ready!') 57 | index = res 58 | chars = index.chars() 59 | 60 | editor.insertText(0, index.text()) 61 | 62 | listenForEdits() 63 | }) 64 | 65 | var queueLocked = false 66 | function processQueue () { 67 | // console.log('processQueue', localOpQueue.length) 68 | if (!localOpQueue.length) { 69 | if (remoteOpQueue.length) { 70 | processRemoteOps() 71 | return 72 | } 73 | // console.log('bail: empty') 74 | queueLocked = false 75 | return 76 | } 77 | if (queueLocked) { 78 | // console.log('bail: locked') 79 | return 80 | } 81 | // console.log('gonna process') 82 | queueLocked = true 83 | 84 | var ops = localOpQueue.shift() 85 | 86 | var pos = 0 87 | var opIdx = 0 88 | ;(function next (err) { 89 | if (err) throw err 90 | var op = ops[opIdx] 91 | if (!op) { 92 | // console.log('done processing op') 93 | queueLocked = false 94 | processQueue() 95 | return 96 | } 97 | opIdx++ 98 | 99 | if (op.retain) { 100 | pos += op.retain 101 | next() 102 | } else if (op.insert) { 103 | console.log('op.insert', pos, chars.length) 104 | var after = pos > 0 ? chars[pos - 1].pos : null 105 | var before = pos < chars.length ? chars[pos].pos : null 106 | str.insert(after, before, op.insert, function (err, res) { 107 | if (err) throw err 108 | var key = res[0].pos.substring(0, res[0].pos.lastIndexOf('@')) 109 | index.insert(after, before, op.insert, key) 110 | chars = index.chars() 111 | console.log('insert', after, before, op.insert, key) 112 | next() 113 | }) 114 | } else if (op.delete) { 115 | var from = chars[pos].pos 116 | var to = chars[pos + op.delete - 1].pos 117 | console.log('delete', from, to, op.delete) 118 | str.delete(from, to, function (err, res) { 119 | if (err) throw err 120 | index.delete(from, to) 121 | chars = index.chars() 122 | next() 123 | }) 124 | } 125 | })() 126 | } 127 | 128 | // XXX(sww): remember, this function MUST stay synchronous. if it becomes async, ALL CONCURRENT HELL BREAKS LOOSE 129 | function processRemoteOps () { 130 | remoteOpQueue.forEach(function (op) { 131 | console.log('remote op', op) 132 | if (op.op === 'insert') { 133 | // Update index 134 | var res = index.insert(op.prev, op.next, op.txt, op.key) 135 | var prev = index.pos(res[0]) 136 | chars = index.chars() 137 | 138 | // Update editor 139 | editor.insertText(prev, op.txt, 'silent') 140 | } else if (op.op === 'delete') { 141 | // Update index 142 | var from = index.pos(op.from) 143 | var to = index.pos(op.to) + 1 144 | index.delete(op.from, op.to) 145 | var numToDelete = to - from 146 | chars.splice(from, numToDelete) 147 | 148 | // Update editor 149 | editor.deleteText(from, numToDelete, 'silent') 150 | } 151 | }) 152 | remoteOpQueue = [] 153 | } 154 | 155 | function listenForEdits () { 156 | editor.on('text-change', onTextChange) 157 | } 158 | 159 | function onTextChange (delta, oldDelta, source) { 160 | console.log('got op', delta.ops, source) 161 | localOpQueue.push(delta.ops) 162 | processQueue() 163 | } 164 | 165 | function unregister (cb) { 166 | console.log('unreg editor', editor) 167 | editor.deleteText(0, 99999999999, 'silent') 168 | editor.off('text-change', onTextChange) 169 | str.log.removeAllListeners() // TODO: we can do better! 170 | db.close(function () { 171 | swarm.swarm.destroy(cb) 172 | }) 173 | } 174 | 175 | function setTitle (name, cb) { 176 | str.log.append({ type: 'title', title: name }, function (err) { 177 | if (err) return cb(err) 178 | titleIndex.ready(cb) 179 | }) 180 | } 181 | 182 | return { 183 | unregister: unregister, 184 | setTitle: setTitle 185 | } 186 | } 187 | 188 | -------------------------------------------------------------------------------- /css/quill.snow.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Quill Editor v1.0.0 3 | * https://quilljs.com/ 4 | * Copyright (c) 2014, Jason Chen 5 | * Copyright (c) 2013, salesforce.com 6 | */ 7 | .ql-container { 8 | box-sizing: border-box; 9 | font-family: Helvetica, Arial, sans-serif; 10 | font-size: 13px; 11 | height: 100%; 12 | margin: 0px; 13 | position: relative; 14 | } 15 | .ql-clipboard { 16 | left: -100000px; 17 | height: 1px; 18 | overflow-y: hidden; 19 | position: absolute; 20 | top: 50%; 21 | } 22 | .ql-clipboard p { 23 | margin: 0; 24 | padding: 0; 25 | } 26 | .ql-editor { 27 | box-sizing: border-box; 28 | cursor: text; 29 | line-height: 1.42; 30 | height: 100%; 31 | outline: none; 32 | overflow-y: auto; 33 | padding: 12px 15px; 34 | tab-size: 4; 35 | -moz-tab-size: 4; 36 | text-align: left; 37 | white-space: pre-wrap; 38 | word-wrap: break-word; 39 | } 40 | .ql-editor p, 41 | .ql-editor ol, 42 | .ql-editor ul, 43 | .ql-editor pre, 44 | .ql-editor blockquote, 45 | .ql-editor h1, 46 | .ql-editor h2, 47 | .ql-editor h3, 48 | .ql-editor h4, 49 | .ql-editor h5, 50 | .ql-editor h6 { 51 | margin: 0; 52 | padding: 0; 53 | counter-reset: list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9; 54 | } 55 | .ql-editor ol, 56 | .ql-editor ul { 57 | padding-left: 1.5em; 58 | } 59 | .ql-editor ol > li, 60 | .ql-editor ul > li { 61 | list-style-type: none; 62 | } 63 | .ql-editor ul > li::before { 64 | content: '\25CF'; 65 | } 66 | .ql-editor li::before { 67 | display: inline-block; 68 | margin-right: 0.3em; 69 | text-align: right; 70 | white-space: nowrap; 71 | width: 1.2em; 72 | } 73 | .ql-editor li:not(.ql-direction-rtl)::before { 74 | margin-left: -1.5em; 75 | } 76 | .ql-editor ol li, 77 | .ql-editor ul li { 78 | padding-left: 1.5em; 79 | } 80 | .ql-editor ol li { 81 | counter-reset: list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9; 82 | counter-increment: list-num; 83 | } 84 | .ql-editor ol li:before { 85 | content: counter(list-num, decimal) '. '; 86 | } 87 | .ql-editor ol li.ql-indent-1 { 88 | counter-increment: list-1; 89 | } 90 | .ql-editor ol li.ql-indent-1:before { 91 | content: counter(list-1, lower-alpha) '. '; 92 | } 93 | .ql-editor ol li.ql-indent-1 { 94 | counter-reset: list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9; 95 | } 96 | .ql-editor ol li.ql-indent-2 { 97 | counter-increment: list-2; 98 | } 99 | .ql-editor ol li.ql-indent-2:before { 100 | content: counter(list-2, lower-roman) '. '; 101 | } 102 | .ql-editor ol li.ql-indent-2 { 103 | counter-reset: list-3 list-4 list-5 list-6 list-7 list-8 list-9; 104 | } 105 | .ql-editor ol li.ql-indent-3 { 106 | counter-increment: list-3; 107 | } 108 | .ql-editor ol li.ql-indent-3:before { 109 | content: counter(list-3, decimal) '. '; 110 | } 111 | .ql-editor ol li.ql-indent-3 { 112 | counter-reset: list-4 list-5 list-6 list-7 list-8 list-9; 113 | } 114 | .ql-editor ol li.ql-indent-4 { 115 | counter-increment: list-4; 116 | } 117 | .ql-editor ol li.ql-indent-4:before { 118 | content: counter(list-4, lower-alpha) '. '; 119 | } 120 | .ql-editor ol li.ql-indent-4 { 121 | counter-reset: list-5 list-6 list-7 list-8 list-9; 122 | } 123 | .ql-editor ol li.ql-indent-5 { 124 | counter-increment: list-5; 125 | } 126 | .ql-editor ol li.ql-indent-5:before { 127 | content: counter(list-5, lower-roman) '. '; 128 | } 129 | .ql-editor ol li.ql-indent-5 { 130 | counter-reset: list-6 list-7 list-8 list-9; 131 | } 132 | .ql-editor ol li.ql-indent-6 { 133 | counter-increment: list-6; 134 | } 135 | .ql-editor ol li.ql-indent-6:before { 136 | content: counter(list-6, decimal) '. '; 137 | } 138 | .ql-editor ol li.ql-indent-6 { 139 | counter-reset: list-7 list-8 list-9; 140 | } 141 | .ql-editor ol li.ql-indent-7 { 142 | counter-increment: list-7; 143 | } 144 | .ql-editor ol li.ql-indent-7:before { 145 | content: counter(list-7, lower-alpha) '. '; 146 | } 147 | .ql-editor ol li.ql-indent-7 { 148 | counter-reset: list-8 list-9; 149 | } 150 | .ql-editor ol li.ql-indent-8 { 151 | counter-increment: list-8; 152 | } 153 | .ql-editor ol li.ql-indent-8:before { 154 | content: counter(list-8, lower-roman) '. '; 155 | } 156 | .ql-editor ol li.ql-indent-8 { 157 | counter-reset: list-9; 158 | } 159 | .ql-editor ol li.ql-indent-9 { 160 | counter-increment: list-9; 161 | } 162 | .ql-editor ol li.ql-indent-9:before { 163 | content: counter(list-9, decimal) '. '; 164 | } 165 | .ql-editor .ql-indent-1:not(.ql-direction-rtl) { 166 | padding-left: 3em; 167 | } 168 | .ql-editor li.ql-indent-1:not(.ql-direction-rtl) { 169 | padding-left: 4.5em; 170 | } 171 | .ql-editor .ql-indent-1.ql-direction-rtl.ql-align-right { 172 | padding-right: 3em; 173 | } 174 | .ql-editor li.ql-indent-1.ql-direction-rtl.ql-align-right { 175 | padding-right: 4.5em; 176 | } 177 | .ql-editor .ql-indent-2:not(.ql-direction-rtl) { 178 | padding-left: 6em; 179 | } 180 | .ql-editor li.ql-indent-2:not(.ql-direction-rtl) { 181 | padding-left: 7.5em; 182 | } 183 | .ql-editor .ql-indent-2.ql-direction-rtl.ql-align-right { 184 | padding-right: 6em; 185 | } 186 | .ql-editor li.ql-indent-2.ql-direction-rtl.ql-align-right { 187 | padding-right: 7.5em; 188 | } 189 | .ql-editor .ql-indent-3:not(.ql-direction-rtl) { 190 | padding-left: 9em; 191 | } 192 | .ql-editor li.ql-indent-3:not(.ql-direction-rtl) { 193 | padding-left: 10.5em; 194 | } 195 | .ql-editor .ql-indent-3.ql-direction-rtl.ql-align-right { 196 | padding-right: 9em; 197 | } 198 | .ql-editor li.ql-indent-3.ql-direction-rtl.ql-align-right { 199 | padding-right: 10.5em; 200 | } 201 | .ql-editor .ql-indent-4:not(.ql-direction-rtl) { 202 | padding-left: 12em; 203 | } 204 | .ql-editor li.ql-indent-4:not(.ql-direction-rtl) { 205 | padding-left: 13.5em; 206 | } 207 | .ql-editor .ql-indent-4.ql-direction-rtl.ql-align-right { 208 | padding-right: 12em; 209 | } 210 | .ql-editor li.ql-indent-4.ql-direction-rtl.ql-align-right { 211 | padding-right: 13.5em; 212 | } 213 | .ql-editor .ql-indent-5:not(.ql-direction-rtl) { 214 | padding-left: 15em; 215 | } 216 | .ql-editor li.ql-indent-5:not(.ql-direction-rtl) { 217 | padding-left: 16.5em; 218 | } 219 | .ql-editor .ql-indent-5.ql-direction-rtl.ql-align-right { 220 | padding-right: 15em; 221 | } 222 | .ql-editor li.ql-indent-5.ql-direction-rtl.ql-align-right { 223 | padding-right: 16.5em; 224 | } 225 | .ql-editor .ql-indent-6:not(.ql-direction-rtl) { 226 | padding-left: 18em; 227 | } 228 | .ql-editor li.ql-indent-6:not(.ql-direction-rtl) { 229 | padding-left: 19.5em; 230 | } 231 | .ql-editor .ql-indent-6.ql-direction-rtl.ql-align-right { 232 | padding-right: 18em; 233 | } 234 | .ql-editor li.ql-indent-6.ql-direction-rtl.ql-align-right { 235 | padding-right: 19.5em; 236 | } 237 | .ql-editor .ql-indent-7:not(.ql-direction-rtl) { 238 | padding-left: 21em; 239 | } 240 | .ql-editor li.ql-indent-7:not(.ql-direction-rtl) { 241 | padding-left: 22.5em; 242 | } 243 | .ql-editor .ql-indent-7.ql-direction-rtl.ql-align-right { 244 | padding-right: 21em; 245 | } 246 | .ql-editor li.ql-indent-7.ql-direction-rtl.ql-align-right { 247 | padding-right: 22.5em; 248 | } 249 | .ql-editor .ql-indent-8:not(.ql-direction-rtl) { 250 | padding-left: 24em; 251 | } 252 | .ql-editor li.ql-indent-8:not(.ql-direction-rtl) { 253 | padding-left: 25.5em; 254 | } 255 | .ql-editor .ql-indent-8.ql-direction-rtl.ql-align-right { 256 | padding-right: 24em; 257 | } 258 | .ql-editor li.ql-indent-8.ql-direction-rtl.ql-align-right { 259 | padding-right: 25.5em; 260 | } 261 | .ql-editor .ql-indent-9:not(.ql-direction-rtl) { 262 | padding-left: 27em; 263 | } 264 | .ql-editor li.ql-indent-9:not(.ql-direction-rtl) { 265 | padding-left: 28.5em; 266 | } 267 | .ql-editor .ql-indent-9.ql-direction-rtl.ql-align-right { 268 | padding-right: 27em; 269 | } 270 | .ql-editor li.ql-indent-9.ql-direction-rtl.ql-align-right { 271 | padding-right: 28.5em; 272 | } 273 | .ql-editor .ql-video { 274 | display: block; 275 | max-width: 100%; 276 | } 277 | .ql-editor .ql-video.ql-align-center { 278 | margin: 0 auto; 279 | } 280 | .ql-editor .ql-video.ql-align-right { 281 | margin: 0 0 0 auto; 282 | } 283 | .ql-editor .ql-bg-black { 284 | background-color: #000; 285 | } 286 | .ql-editor .ql-bg-red { 287 | background-color: #e60000; 288 | } 289 | .ql-editor .ql-bg-orange { 290 | background-color: #f90; 291 | } 292 | .ql-editor .ql-bg-yellow { 293 | background-color: #ff0; 294 | } 295 | .ql-editor .ql-bg-green { 296 | background-color: #008a00; 297 | } 298 | .ql-editor .ql-bg-blue { 299 | background-color: #06c; 300 | } 301 | .ql-editor .ql-bg-purple { 302 | background-color: #93f; 303 | } 304 | .ql-editor .ql-color-white { 305 | color: #fff; 306 | } 307 | .ql-editor .ql-color-red { 308 | color: #e60000; 309 | } 310 | .ql-editor .ql-color-orange { 311 | color: #f90; 312 | } 313 | .ql-editor .ql-color-yellow { 314 | color: #ff0; 315 | } 316 | .ql-editor .ql-color-green { 317 | color: #008a00; 318 | } 319 | .ql-editor .ql-color-blue { 320 | color: #06c; 321 | } 322 | .ql-editor .ql-color-purple { 323 | color: #93f; 324 | } 325 | .ql-editor .ql-font-serif { 326 | font-family: Georgia, Times New Roman, serif; 327 | } 328 | .ql-editor .ql-font-monospace { 329 | font-family: Monaco, Courier New, monospace; 330 | } 331 | .ql-editor .ql-size-small { 332 | font-size: 0.75em; 333 | } 334 | .ql-editor .ql-size-large { 335 | font-size: 1.5em; 336 | } 337 | .ql-editor .ql-size-huge { 338 | font-size: 2.5em; 339 | } 340 | .ql-editor .ql-direction-rtl { 341 | direction: rtl; 342 | text-align: inherit; 343 | } 344 | .ql-editor .ql-align-center { 345 | text-align: center; 346 | } 347 | .ql-editor .ql-align-justify { 348 | text-align: justify; 349 | } 350 | .ql-editor .ql-align-right { 351 | text-align: right; 352 | } 353 | .ql-editor.ql-blank::before { 354 | color: rgba(0,0,0,0.6); 355 | content: attr(data-placeholder); 356 | font-style: italic; 357 | pointer-events: none; 358 | position: absolute; 359 | } 360 | .ql-snow.ql-toolbar:after, 361 | .ql-snow .ql-toolbar:after { 362 | clear: both; 363 | content: ''; 364 | display: table; 365 | } 366 | .ql-snow.ql-toolbar button, 367 | .ql-snow .ql-toolbar button { 368 | background: none; 369 | border: none; 370 | cursor: pointer; 371 | display: inline-block; 372 | float: left; 373 | height: 24px; 374 | outline: none; 375 | padding: 3px 5px; 376 | width: 28px; 377 | } 378 | .ql-snow.ql-toolbar button svg, 379 | .ql-snow .ql-toolbar button svg { 380 | float: left; 381 | height: 100%; 382 | } 383 | .ql-snow.ql-toolbar input.ql-image[type=file], 384 | .ql-snow .ql-toolbar input.ql-image[type=file] { 385 | display: none; 386 | } 387 | .ql-snow.ql-toolbar button:hover, 388 | .ql-snow .ql-toolbar button:hover, 389 | .ql-snow.ql-toolbar button.ql-active, 390 | .ql-snow .ql-toolbar button.ql-active, 391 | .ql-snow.ql-toolbar .ql-picker-label:hover, 392 | .ql-snow .ql-toolbar .ql-picker-label:hover, 393 | .ql-snow.ql-toolbar .ql-picker-label.ql-active, 394 | .ql-snow .ql-toolbar .ql-picker-label.ql-active, 395 | .ql-snow.ql-toolbar .ql-picker-item:hover, 396 | .ql-snow .ql-toolbar .ql-picker-item:hover, 397 | .ql-snow.ql-toolbar .ql-picker-item.ql-selected, 398 | .ql-snow .ql-toolbar .ql-picker-item.ql-selected { 399 | color: #06c; 400 | } 401 | .ql-snow.ql-toolbar button:hover .ql-fill, 402 | .ql-snow .ql-toolbar button:hover .ql-fill, 403 | .ql-snow.ql-toolbar button.ql-active .ql-fill, 404 | .ql-snow .ql-toolbar button.ql-active .ql-fill, 405 | .ql-snow.ql-toolbar .ql-picker-label:hover .ql-fill, 406 | .ql-snow .ql-toolbar .ql-picker-label:hover .ql-fill, 407 | .ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-fill, 408 | .ql-snow .ql-toolbar .ql-picker-label.ql-active .ql-fill, 409 | .ql-snow.ql-toolbar .ql-picker-item:hover .ql-fill, 410 | .ql-snow .ql-toolbar .ql-picker-item:hover .ql-fill, 411 | .ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-fill, 412 | .ql-snow .ql-toolbar .ql-picker-item.ql-selected .ql-fill, 413 | .ql-snow.ql-toolbar button:hover .ql-stroke.ql-fill, 414 | .ql-snow .ql-toolbar button:hover .ql-stroke.ql-fill, 415 | .ql-snow.ql-toolbar button.ql-active .ql-stroke.ql-fill, 416 | .ql-snow .ql-toolbar button.ql-active .ql-stroke.ql-fill, 417 | .ql-snow.ql-toolbar .ql-picker-label:hover .ql-stroke.ql-fill, 418 | .ql-snow .ql-toolbar .ql-picker-label:hover .ql-stroke.ql-fill, 419 | .ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-stroke.ql-fill, 420 | .ql-snow .ql-toolbar .ql-picker-label.ql-active .ql-stroke.ql-fill, 421 | .ql-snow.ql-toolbar .ql-picker-item:hover .ql-stroke.ql-fill, 422 | .ql-snow .ql-toolbar .ql-picker-item:hover .ql-stroke.ql-fill, 423 | .ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-stroke.ql-fill, 424 | .ql-snow .ql-toolbar .ql-picker-item.ql-selected .ql-stroke.ql-fill { 425 | fill: #06c; 426 | } 427 | .ql-snow.ql-toolbar button:hover .ql-stroke, 428 | .ql-snow .ql-toolbar button:hover .ql-stroke, 429 | .ql-snow.ql-toolbar button.ql-active .ql-stroke, 430 | .ql-snow .ql-toolbar button.ql-active .ql-stroke, 431 | .ql-snow.ql-toolbar .ql-picker-label:hover .ql-stroke, 432 | .ql-snow .ql-toolbar .ql-picker-label:hover .ql-stroke, 433 | .ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-stroke, 434 | .ql-snow .ql-toolbar .ql-picker-label.ql-active .ql-stroke, 435 | .ql-snow.ql-toolbar .ql-picker-item:hover .ql-stroke, 436 | .ql-snow .ql-toolbar .ql-picker-item:hover .ql-stroke, 437 | .ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-stroke, 438 | .ql-snow .ql-toolbar .ql-picker-item.ql-selected .ql-stroke, 439 | .ql-snow.ql-toolbar button:hover .ql-stroke-mitter, 440 | .ql-snow .ql-toolbar button:hover .ql-stroke-mitter, 441 | .ql-snow.ql-toolbar button.ql-active .ql-stroke-mitter, 442 | .ql-snow .ql-toolbar button.ql-active .ql-stroke-mitter, 443 | .ql-snow.ql-toolbar .ql-picker-label:hover .ql-stroke-mitter, 444 | .ql-snow .ql-toolbar .ql-picker-label:hover .ql-stroke-mitter, 445 | .ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-stroke-mitter, 446 | .ql-snow .ql-toolbar .ql-picker-label.ql-active .ql-stroke-mitter, 447 | .ql-snow.ql-toolbar .ql-picker-item:hover .ql-stroke-mitter, 448 | .ql-snow .ql-toolbar .ql-picker-item:hover .ql-stroke-mitter, 449 | .ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-stroke-mitter, 450 | .ql-snow .ql-toolbar .ql-picker-item.ql-selected .ql-stroke-mitter { 451 | stroke: #06c; 452 | } 453 | .ql-snow { 454 | box-sizing: border-box; 455 | } 456 | .ql-snow * { 457 | box-sizing: border-box; 458 | } 459 | .ql-snow .ql-hidden { 460 | display: none; 461 | } 462 | .ql-snow .ql-out-bottom, 463 | .ql-snow .ql-out-top { 464 | visibility: hidden; 465 | } 466 | .ql-snow .ql-tooltip { 467 | position: absolute; 468 | } 469 | .ql-snow .ql-tooltip a { 470 | cursor: pointer; 471 | text-decoration: none; 472 | } 473 | .ql-snow .ql-formats { 474 | display: inline-block; 475 | vertical-align: middle; 476 | } 477 | .ql-snow .ql-formats:after { 478 | clear: both; 479 | content: ''; 480 | display: table; 481 | } 482 | .ql-snow .ql-toolbar.snow, 483 | .ql-snow .ql-stroke { 484 | fill: none; 485 | stroke: #444; 486 | stroke-linecap: round; 487 | stroke-linejoin: round; 488 | stroke-width: 2; 489 | } 490 | .ql-snow .ql-stroke-mitter { 491 | fill: none; 492 | stroke: #444; 493 | stroke-mitterlimit: 10; 494 | stroke-width: 2; 495 | } 496 | .ql-snow .ql-fill, 497 | .ql-snow .ql-stroke.ql-fill { 498 | fill: #444; 499 | } 500 | .ql-snow .ql-empty { 501 | fill: none; 502 | } 503 | .ql-snow .ql-even { 504 | fill-rule: evenodd; 505 | } 506 | .ql-snow .ql-thin, 507 | .ql-snow .ql-stroke.ql-thin { 508 | stroke-width: 1; 509 | } 510 | .ql-snow .ql-transparent { 511 | opacity: 0.4; 512 | } 513 | .ql-snow .ql-direction svg:last-child { 514 | display: none; 515 | } 516 | .ql-snow .ql-direction.ql-active svg:last-child { 517 | display: inline; 518 | } 519 | .ql-snow .ql-direction.ql-active svg:first-child { 520 | display: none; 521 | } 522 | .ql-snow .ql-editor h1 { 523 | font-size: 2em; 524 | } 525 | .ql-snow .ql-editor h2 { 526 | font-size: 1.5em; 527 | } 528 | .ql-snow .ql-editor h3 { 529 | font-size: 1.17em; 530 | } 531 | .ql-snow .ql-editor h4 { 532 | font-size: 1em; 533 | } 534 | .ql-snow .ql-editor h5 { 535 | font-size: 0.83em; 536 | } 537 | .ql-snow .ql-editor h6 { 538 | font-size: 0.67em; 539 | } 540 | .ql-snow .ql-editor a { 541 | text-decoration: underline; 542 | } 543 | .ql-snow .ql-editor blockquote { 544 | border-left: 4px solid #ccc; 545 | margin-bottom: 5px; 546 | margin-top: 5px; 547 | padding-left: 16px; 548 | } 549 | .ql-snow .ql-editor code, 550 | .ql-snow .ql-editor pre { 551 | background-color: #f0f0f0; 552 | border-radius: 3px; 553 | } 554 | .ql-snow .ql-editor pre { 555 | white-space: pre-wrap; 556 | margin-bottom: 5px; 557 | margin-top: 5px; 558 | padding: 5px 10px; 559 | } 560 | .ql-snow .ql-editor code { 561 | font-size: 85%; 562 | padding-bottom: 2px; 563 | padding-top: 2px; 564 | } 565 | .ql-snow .ql-editor code:before, 566 | .ql-snow .ql-editor code:after { 567 | content: "\A0"; 568 | letter-spacing: -2px; 569 | } 570 | .ql-snow .ql-editor pre.ql-syntax { 571 | background-color: #23241f; 572 | color: #f8f8f2; 573 | overflow: visible; 574 | } 575 | .ql-snow .ql-editor img { 576 | max-width: 100%; 577 | } 578 | .ql-snow .ql-picker { 579 | color: #444; 580 | display: inline-block; 581 | float: left; 582 | font-size: 14px; 583 | font-weight: 500; 584 | height: 24px; 585 | position: relative; 586 | vertical-align: middle; 587 | } 588 | .ql-snow .ql-picker-label { 589 | cursor: pointer; 590 | display: inline-block; 591 | height: 100%; 592 | padding-left: 8px; 593 | padding-right: 2px; 594 | position: relative; 595 | width: 100%; 596 | } 597 | .ql-snow .ql-picker-label::before { 598 | display: inline-block; 599 | line-height: 22px; 600 | } 601 | .ql-snow .ql-picker-options { 602 | background-color: #fff; 603 | display: none; 604 | min-width: 100%; 605 | padding: 4px 8px; 606 | position: absolute; 607 | white-space: nowrap; 608 | } 609 | .ql-snow .ql-picker-options .ql-picker-item { 610 | cursor: pointer; 611 | display: block; 612 | padding-bottom: 5px; 613 | padding-top: 5px; 614 | } 615 | .ql-snow .ql-picker.ql-expanded .ql-picker-label { 616 | color: #ccc; 617 | z-index: 2; 618 | } 619 | .ql-snow .ql-picker.ql-expanded .ql-picker-label .ql-fill { 620 | fill: #ccc; 621 | } 622 | .ql-snow .ql-picker.ql-expanded .ql-picker-label .ql-stroke { 623 | stroke: #ccc; 624 | } 625 | .ql-snow .ql-picker.ql-expanded .ql-picker-options { 626 | display: block; 627 | margin-top: -1px; 628 | top: 100%; 629 | z-index: 1; 630 | } 631 | .ql-snow .ql-color-picker, 632 | .ql-snow .ql-icon-picker { 633 | width: 28px; 634 | } 635 | .ql-snow .ql-color-picker .ql-picker-label, 636 | .ql-snow .ql-icon-picker .ql-picker-label { 637 | padding: 2px 4px; 638 | } 639 | .ql-snow .ql-color-picker .ql-picker-label svg, 640 | .ql-snow .ql-icon-picker .ql-picker-label svg { 641 | right: 4px; 642 | } 643 | .ql-snow .ql-icon-picker .ql-picker-options { 644 | padding: 4px 0px; 645 | } 646 | .ql-snow .ql-icon-picker .ql-picker-item { 647 | height: 24px; 648 | width: 24px; 649 | padding: 2px 4px; 650 | } 651 | .ql-snow .ql-color-picker .ql-picker-options { 652 | padding: 3px 5px; 653 | width: 152px; 654 | } 655 | .ql-snow .ql-color-picker .ql-picker-item { 656 | border: 1px solid transparent; 657 | float: left; 658 | height: 16px; 659 | margin: 2px; 660 | padding: 0px; 661 | width: 16px; 662 | } 663 | .ql-snow .ql-color-picker .ql-picker-item.ql-primary-color { 664 | margin-bottom: toolbarPadding; 665 | } 666 | .ql-snow .ql-picker:not(.ql-color-picker):not(.ql-icon-picker) svg { 667 | position: absolute; 668 | margin-top: -9px; 669 | right: 0; 670 | top: 50%; 671 | width: 18px; 672 | } 673 | .ql-snow .ql-picker.ql-header .ql-picker-label[data-label]:not([data-label=''])::before, 674 | .ql-snow .ql-picker.ql-font .ql-picker-label[data-label]:not([data-label=''])::before, 675 | .ql-snow .ql-picker.ql-size .ql-picker-label[data-label]:not([data-label=''])::before, 676 | .ql-snow .ql-picker.ql-header .ql-picker-item[data-label]:not([data-label=''])::before, 677 | .ql-snow .ql-picker.ql-font .ql-picker-item[data-label]:not([data-label=''])::before, 678 | .ql-snow .ql-picker.ql-size .ql-picker-item[data-label]:not([data-label=''])::before { 679 | content: attr(data-label); 680 | } 681 | .ql-snow .ql-picker.ql-header { 682 | width: 98px; 683 | } 684 | .ql-snow .ql-picker.ql-header .ql-picker-label::before, 685 | .ql-snow .ql-picker.ql-header .ql-picker-item::before { 686 | content: 'Normal'; 687 | } 688 | .ql-snow .ql-picker.ql-header .ql-picker-label[data-value="1"]::before, 689 | .ql-snow .ql-picker.ql-header .ql-picker-item[data-value="1"]::before { 690 | content: 'Heading 1'; 691 | } 692 | .ql-snow .ql-picker.ql-header .ql-picker-label[data-value="2"]::before, 693 | .ql-snow .ql-picker.ql-header .ql-picker-item[data-value="2"]::before { 694 | content: 'Heading 2'; 695 | } 696 | .ql-snow .ql-picker.ql-header .ql-picker-label[data-value="3"]::before, 697 | .ql-snow .ql-picker.ql-header .ql-picker-item[data-value="3"]::before { 698 | content: 'Heading 3'; 699 | } 700 | .ql-snow .ql-picker.ql-header .ql-picker-label[data-value="4"]::before, 701 | .ql-snow .ql-picker.ql-header .ql-picker-item[data-value="4"]::before { 702 | content: 'Heading 4'; 703 | } 704 | .ql-snow .ql-picker.ql-header .ql-picker-label[data-value="5"]::before, 705 | .ql-snow .ql-picker.ql-header .ql-picker-item[data-value="5"]::before { 706 | content: 'Heading 5'; 707 | } 708 | .ql-snow .ql-picker.ql-header .ql-picker-label[data-value="6"]::before, 709 | .ql-snow .ql-picker.ql-header .ql-picker-item[data-value="6"]::before { 710 | content: 'Heading 6'; 711 | } 712 | .ql-snow .ql-picker.ql-header .ql-picker-item[data-value="1"]::before { 713 | font-size: 2em; 714 | } 715 | .ql-snow .ql-picker.ql-header .ql-picker-item[data-value="2"]::before { 716 | font-size: 1.5em; 717 | } 718 | .ql-snow .ql-picker.ql-header .ql-picker-item[data-value="3"]::before { 719 | font-size: 1.17em; 720 | } 721 | .ql-snow .ql-picker.ql-header .ql-picker-item[data-value="4"]::before { 722 | font-size: 1em; 723 | } 724 | .ql-snow .ql-picker.ql-header .ql-picker-item[data-value="5"]::before { 725 | font-size: 0.83em; 726 | } 727 | .ql-snow .ql-picker.ql-header .ql-picker-item[data-value="6"]::before { 728 | font-size: 0.67em; 729 | } 730 | .ql-snow .ql-picker.ql-font { 731 | width: 108px; 732 | } 733 | .ql-snow .ql-picker.ql-font .ql-picker-label::before, 734 | .ql-snow .ql-picker.ql-font .ql-picker-item::before { 735 | content: 'Sans Serif'; 736 | } 737 | .ql-snow .ql-picker.ql-font .ql-picker-label[data-value=serif]::before, 738 | .ql-snow .ql-picker.ql-font .ql-picker-item[data-value=serif]::before { 739 | content: 'Serif'; 740 | } 741 | .ql-snow .ql-picker.ql-font .ql-picker-label[data-value=monospace]::before, 742 | .ql-snow .ql-picker.ql-font .ql-picker-item[data-value=monospace]::before { 743 | content: 'Monospace'; 744 | } 745 | .ql-snow .ql-picker.ql-font .ql-picker-item[data-value=serif]::before { 746 | font-family: Georgia, Times New Roman, serif; 747 | } 748 | .ql-snow .ql-picker.ql-font .ql-picker-item[data-value=monospace]::before { 749 | font-family: Monaco, Courier New, monospace; 750 | } 751 | .ql-snow .ql-picker.ql-size { 752 | width: 98px; 753 | } 754 | .ql-snow .ql-picker.ql-size .ql-picker-label::before, 755 | .ql-snow .ql-picker.ql-size .ql-picker-item::before { 756 | content: 'Normal'; 757 | } 758 | .ql-snow .ql-picker.ql-size .ql-picker-label[data-value=small]::before, 759 | .ql-snow .ql-picker.ql-size .ql-picker-item[data-value=small]::before { 760 | content: 'Small'; 761 | } 762 | .ql-snow .ql-picker.ql-size .ql-picker-label[data-value=large]::before, 763 | .ql-snow .ql-picker.ql-size .ql-picker-item[data-value=large]::before { 764 | content: 'Large'; 765 | } 766 | .ql-snow .ql-picker.ql-size .ql-picker-label[data-value=huge]::before, 767 | .ql-snow .ql-picker.ql-size .ql-picker-item[data-value=huge]::before { 768 | content: 'Huge'; 769 | } 770 | .ql-snow .ql-picker.ql-size .ql-picker-item[data-value=small]::before { 771 | font-size: 10px; 772 | } 773 | .ql-snow .ql-picker.ql-size .ql-picker-item[data-value=large]::before { 774 | font-size: 18px; 775 | } 776 | .ql-snow .ql-picker.ql-size .ql-picker-item[data-value=huge]::before { 777 | font-size: 32px; 778 | } 779 | .ql-snow .ql-color-picker.ql-background .ql-picker-item { 780 | background-color: #fff; 781 | } 782 | .ql-snow .ql-color-picker.ql-color .ql-picker-item { 783 | background-color: #000; 784 | } 785 | .ql-toolbar.ql-snow { 786 | border: 1px solid #ccc; 787 | box-sizing: border-box; 788 | font-family: 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif; 789 | padding: 8px; 790 | } 791 | .ql-toolbar.ql-snow .ql-formats { 792 | margin-right: 15px; 793 | } 794 | .ql-toolbar.ql-snow .ql-picker-label { 795 | border: 1px solid transparent; 796 | } 797 | .ql-toolbar.ql-snow .ql-picker-options { 798 | border: 1px solid transparent; 799 | box-shadow: rgba(0,0,0,0.2) 0 2px 8px; 800 | } 801 | .ql-toolbar.ql-snow .ql-picker.ql-expanded .ql-picker-label { 802 | border-color: #ccc; 803 | } 804 | .ql-toolbar.ql-snow .ql-picker.ql-expanded .ql-picker-options { 805 | border-color: #ccc; 806 | } 807 | .ql-toolbar.ql-snow .ql-color-picker .ql-picker-item.ql-selected, 808 | .ql-toolbar.ql-snow .ql-color-picker .ql-picker-item:hover { 809 | border-color: #000; 810 | } 811 | .ql-toolbar.ql-snow + .ql-container.ql-snow { 812 | border-top: 0px; 813 | } 814 | .ql-snow .ql-tooltip { 815 | background-color: #fff; 816 | border: 1px solid #ccc; 817 | box-shadow: 0px 0px 5px #ddd; 818 | color: #444; 819 | margin-top: 10px; 820 | padding: 5px 12px; 821 | white-space: nowrap; 822 | } 823 | .ql-snow .ql-tooltip::before { 824 | content: "Visit URL:"; 825 | line-height: 26px; 826 | margin-right: 8px; 827 | } 828 | .ql-snow .ql-tooltip input[type=text] { 829 | display: none; 830 | border: 1px solid #ccc; 831 | font-size: 13px; 832 | height: 26px; 833 | margin: 0px; 834 | padding: 3px 5px; 835 | width: 170px; 836 | } 837 | .ql-snow .ql-tooltip a.ql-preview { 838 | display: inline-block; 839 | max-width: 200px; 840 | overflow-x: hidden; 841 | text-overflow: ellipsis; 842 | vertical-align: top; 843 | } 844 | .ql-snow .ql-tooltip a.ql-action::after { 845 | border-right: 1px solid #ccc; 846 | content: 'Edit'; 847 | margin-left: 16px; 848 | padding-right: 8px; 849 | } 850 | .ql-snow .ql-tooltip a.ql-remove::before { 851 | content: 'Remove'; 852 | margin-left: 8px; 853 | } 854 | .ql-snow .ql-tooltip a { 855 | line-height: 26px; 856 | } 857 | .ql-snow .ql-tooltip.ql-editing a.ql-preview, 858 | .ql-snow .ql-tooltip.ql-editing a.ql-remove { 859 | display: none; 860 | } 861 | .ql-snow .ql-tooltip.ql-editing input[type=text] { 862 | display: inline-block; 863 | } 864 | .ql-snow .ql-tooltip.ql-editing a.ql-action::after { 865 | border-right: 0px; 866 | content: 'Save'; 867 | padding-right: 0px; 868 | } 869 | .ql-snow .ql-tooltip[data-mode=link]::before { 870 | content: "Enter link:"; 871 | } 872 | .ql-snow .ql-tooltip[data-mode=formula]::before { 873 | content: "Enter formula:"; 874 | } 875 | .ql-snow .ql-tooltip[data-mode=video]::before { 876 | content: "Enter video:"; 877 | } 878 | .ql-snow a { 879 | color: #06c; 880 | } 881 | .ql-container.ql-snow { 882 | border: 1px solid #ccc; 883 | } 884 | --------------------------------------------------------------------------------