diff options
-rw-r--r-- | appinfo/app.php | 1 | ||||
-rw-r--r-- | appinfo/database.xml | 6 | ||||
-rw-r--r-- | appinfo/info.xml | 4 | ||||
-rw-r--r-- | appinfo/routes.php | 3 | ||||
-rw-r--r-- | css/style.scss | 118 | ||||
-rw-r--r-- | lib/Controller/SessionController.php | 19 | ||||
-rw-r--r-- | lib/Db/Document.php | 13 | ||||
-rw-r--r-- | lib/Db/StepMapper.php | 8 | ||||
-rw-r--r-- | lib/DocumentSaveConflictException.php | 29 | ||||
-rw-r--r-- | lib/Service/DocumentService.php | 76 | ||||
-rw-r--r-- | lib/Service/SessionService.php | 1 | ||||
-rw-r--r-- | src/collab.js | 124 | ||||
-rw-r--r-- | src/components/Editor.vue | 131 | ||||
-rw-r--r-- | src/files.js | 16 | ||||
-rw-r--r-- | src/main.js | 17 |
15 files changed, 446 insertions, 120 deletions
diff --git a/appinfo/app.php b/appinfo/app.php index 7c15d6096..8dd192622 100644 --- a/appinfo/app.php +++ b/appinfo/app.php @@ -3,7 +3,6 @@ declare(strict_types=1); namespace OCA\Text\AppInfo; - $eventDispatcher = \OC::$server->getEventDispatcher(); // only load text editor if the user is logged in diff --git a/appinfo/database.xml b/appinfo/database.xml index cbb2019c7..cf9a6292f 100644 --- a/appinfo/database.xml +++ b/appinfo/database.xml @@ -31,6 +31,12 @@ <length>4</length> <default>0</default> </field> + <field> + <name>last_saved_version_time</name> + <type>integer</type> + <unsigned>true</unsigned> + <length>4</length> + </field> </declaration> </table> diff --git a/appinfo/info.xml b/appinfo/info.xml index c7d6db71f..4804cdd59 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -5,7 +5,7 @@ <name>Text</name> <summary>Type together</summary> <description>Collaborative text editor</description> - <version>0.1.0-dev12</version> + <version>0.1.0-dev13</version> <licence>agpl</licence> <author mail="jus@bitgrid.net">Julius Härtl</author> <namespace>Text</namespace> @@ -15,7 +15,7 @@ <repository type="git">https://github.com/nextcloud/text.git</repository> <screenshot>https://raw.githubusercontent.com/nextcloud/text/master/img/screenshot.png</screenshot> <dependencies> - <nextcloud min-version="15" max-version="16"/> + <nextcloud min-version="17" max-version="17"/> </dependencies> <navigations> <navigation> diff --git a/appinfo/routes.php b/appinfo/routes.php index ff8413314..29b92263b 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -17,8 +17,5 @@ return [ ['name' => 'Session#push', 'url' => '/session/push', 'verb' => 'POST'], // Close session ['name' => 'Session#close', 'url' => '/session/close', 'verb' => 'GET'], - //['name' => 'Session#get', 'url' => '/session/get', 'verb' => 'GET'], - - ] ]; diff --git a/css/style.scss b/css/style.scss index 18b8f125d..757adc700 100644 --- a/css/style.scss +++ b/css/style.scss @@ -4,8 +4,7 @@ } .ProseMirror.ProseMirror-example-setup-style { - position: fixed; - height: 100%; + overflow: scroll; } .ProseMirror.ProseMirror-example-setup-style, @@ -13,6 +12,9 @@ width: 100%; max-width: 900px; margin: auto; + display: flex; + height: 100%; + flex-direction: column; } .ProseMirror { @@ -73,7 +75,7 @@ li.ProseMirror-selectednode:after { } .ProseMirror-menuseparator { - border-right: 1px solid #ddd; + border-right: 1px solid var(--color-text-maxcontrast); margin-right: 3px; } @@ -85,7 +87,8 @@ li.ProseMirror-selectednode:after { vertical-align: 1px; cursor: pointer; position: relative; - padding-right: 15px; + padding: 8px; + padding-right: 30px; } .ProseMirror-menu-dropdown-wrap { @@ -107,8 +110,8 @@ li.ProseMirror-selectednode:after { .ProseMirror-menu-dropdown-menu, .ProseMirror-menu-submenu { position: absolute; - background: white; - color: #666; + background: var(--color-main-background); + color: var(--color-main-text); box-shadow: 0 0 3px var(--color-box-shadow); padding: 2px; } @@ -124,7 +127,7 @@ li.ProseMirror-selectednode:after { } .ProseMirror-menu-dropdown-item:hover { - background: #f2f2f2; + background: var(--color-background-dark); } .ProseMirror-menu-submenu-wrap { @@ -151,12 +154,7 @@ li.ProseMirror-selectednode:after { } .ProseMirror-menu-active { - background: #eee; - border-radius: 4px; -} - -.ProseMirror-menu-active { - background: #eee; + background: var(--color-background-darker); border-radius: 4px; } @@ -175,10 +173,10 @@ li.ProseMirror-selectednode:after { border-top-right-radius: inherit; position: relative; min-height: 1em; - color: #666; + color: var(--color-text-light); padding: 1px 6px; top: 0; left: 0; right: 0; - background: white; + background: transparent; z-index: 10; -moz-box-sizing: border-box; box-sizing: border-box; @@ -195,7 +193,6 @@ li.ProseMirror-selectednode:after { display: inline-block; line-height: .8; vertical-align: -2px; /* Compensate for padding */ - padding: 8px; border-radius: 2px; cursor: pointer; &:hover, &:focus { @@ -203,6 +200,10 @@ li.ProseMirror-selectednode:after { } } +.ProseMirror-icon, .Prosemirror-menu-dropdown { + padding: 8px; +} + .ProseMirror-menu-dropdown-menu { padding: 8px; border-radius: 2px; @@ -236,7 +237,7 @@ li.ProseMirror-selectednode:after { position: absolute; top: -2px; width: 20px; - border-top: 1px solid black; + border-top: 1px solid var(--color-main-text); animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite; } @@ -274,46 +275,35 @@ li.ProseMirror-selectednode:after { } .ProseMirror blockquote { padding-left: 1em; - border-left: 3px solid #eee; + border-left: 3px solid var(--color-text-lighter); margin-left: 0; margin-right: 0; } .ProseMirror-example-setup-style img { cursor: default; + max-height: 50vh; } .ProseMirror-prompt { - background: white; - padding: 5px 10px 5px 15px; - border: 1px solid silver; + background: var(--color-main-background); + padding: 20px; position: fixed; border-radius: 3px; - z-index: 11; - box-shadow: -.5px 2px 5px rgba(0, 0, 0, .2); + z-index: 20000; + box-shadow: -.5px 2px 5px var(--color-box-shadow); } .ProseMirror-prompt h5 { margin: 0; font-weight: normal; font-size: 100%; - color: #444; -} - -.ProseMirror-prompt input[type="text"], -.ProseMirror-prompt textarea { - background: #eee; - border: none; - outline: none; -} - -.ProseMirror-prompt input[type="text"] { - padding: 0 4px; + color: var(--color-main-text); } .ProseMirror-prompt-close { position: absolute; left: 2px; top: 1px; - color: #666; + color: var(--color-main-text); border: none; background: transparent; padding: 0; } @@ -333,19 +323,28 @@ li.ProseMirror-selectednode:after { .ProseMirror-prompt-buttons { margin-top: 5px; - display: none; + float: right; } + +.ProseMirror-prompt-submit { + color: var(--color-primary-text); + background-color: var(--color-primary); + border: 0; +} + #editor, .editor { - background: white; - color: black; + background: var(--color-main-background); + color: var(--color-main-text); background-clip: padding-box; border-radius: 4px; padding: 5px 0; } -div[contenteditable=true] { +div[contenteditable=true], +div[contenteditable=false] { border: none !important; width: 100%; + background-color: transparent; } .ProseMirror p:first-child, @@ -360,16 +359,49 @@ div[contenteditable=true] { .ProseMirror { padding: 4px 8px 4px 14px; - line-height: 1.2; + line-height: 150%; + font-size: 14px; outline: none; } -.ProseMirror p { margin-bottom: 1em } -.ProseMirror em { font-style: italic; } +.ProseMirror a { + color: var(--color-primary); + text-decoration: underline; +} + +.ProseMirror p { + margin-bottom: 1em; + line-height: 150%; +} +.ProseMirror em { + font-style: italic; +} .ProseMirror h1 { font-size: 24px; +} +.ProseMirror h2 { + font-size: 22px; +} +.ProseMirror h3 { + font-size: 20px; +} +.ProseMirror h4 { + font-size: 18px; +} +.ProseMirror h5 { + font-size: 16px; +} +.ProseMirror h6 { + font-size: 14px; +} +.ProseMirror h1, +.ProseMirror h2, +.ProseMirror h3, +.ProseMirror h4, +.ProseMirror h5, +.ProseMirror h6 { font-weight: 600; margin-top: 10px; margin-bottom: 20px; diff --git a/lib/Controller/SessionController.php b/lib/Controller/SessionController.php index 7416bd5c3..2347d953c 100644 --- a/lib/Controller/SessionController.php +++ b/lib/Controller/SessionController.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace OCA\Text\Controller; +use OC\Files\Node\File; use OCA\Text\Service\DocumentService; use OCA\Text\Service\SessionService; use OCP\AppFramework\Controller; @@ -16,6 +17,7 @@ use OCP\ICacheFactory; use OCP\IRequest; use OCP\ITempManager; use OCP\Security\ISecureRandom; +use OCA\Text\DocumentSaveConflictException; class SessionController extends Controller { @@ -97,13 +99,26 @@ class SessionController extends Controller { * @NoCSRFRequired * @NoAdminRequired */ - public function sync($documentId, $version = 0): DataResponse { + public function sync($documentId, $sessionId, $token, $version = 0, $autosaveContent = null): DataResponse { + if (!$this->sessionService->isValidSession($documentId, $sessionId, $token)) { + return new DataResponse([], 500); + } if ($version === $this->cache->get('document-version-'.$documentId)) { return new DataResponse(['steps' => []]); } + try { + $document = $this->documentService->autosave($documentId, $version, $autosaveContent); + } catch (DocumentSaveConflictException $e) { + /** @var File $file */ + $file = $this->documentService->getFile($documentId); + return new DataResponse([ + 'outsideChange' => $file->getContent() + ], 409); + } return new DataResponse([ 'steps' => $this->documentService->getSteps($documentId, $version), - 'sessions' => $this->sessionService->getActiveSessions($documentId) + 'sessions' => $this->sessionService->getActiveSessions($documentId), + 'document' => $document ]); } diff --git a/lib/Db/Document.php b/lib/Db/Document.php index 3155eac73..5c1e30c06 100644 --- a/lib/Db/Document.php +++ b/lib/Db/Document.php @@ -26,18 +26,29 @@ namespace OCA\Text\Db; use OCP\AppFramework\Db\Entity; -class Document extends Entity { +class Document extends Entity implements \JsonSerializable { public $id; protected $currentVersion = 0; protected $lastSavedVersion = 0; protected $initialVersion = 0; + protected $lastSavedVersionTime = 0; public function __construct() { $this->addType('id', 'integer'); $this->addType('currentVersion', 'integer'); $this->addType('lastSavedVersion', 'integer'); + $this->addType('lastSavedVersionTime', 'integer'); $this->addType('initialVersion', 'integer'); } + public function jsonSerialize() { + return [ + 'id' => $this->id, + 'currentVersion' => $this->currentVersion, + 'lastSavedVersion' => $this->lastSavedVersion, + 'lastSavedVersionTime' => $this->lastSavedVersionTime, + ]; + } + } diff --git a/lib/Db/StepMapper.php b/lib/Db/StepMapper.php index d8b370592..847409750 100644 --- a/lib/Db/StepMapper.php +++ b/lib/Db/StepMapper.php @@ -45,4 +45,12 @@ class StepMapper extends QBMapper { return $this->findEntities($qb); } + + public function deleteAll($documentId) { + /* @var $qb IQueryBuilder */ + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->getTableName()) + ->where($qb->expr()->eq('document_id', $qb->createNamedParameter($documentId))) + ->execute(); + } } diff --git a/lib/DocumentSaveConflictException.php b/lib/DocumentSaveConflictException.php new file mode 100644 index 000000000..927c90a6a --- /dev/null +++ b/lib/DocumentSaveConflictException.php @@ -0,0 +1,29 @@ +<?php +/** + * @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net> + * + * @author Julius Härtl <jus@bitgrid.net> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +namespace OCA\Text; + + +class DocumentSaveConflictException extends \Exception { + +} diff --git a/lib/Service/DocumentService.php b/lib/Service/DocumentService.php index 77c67557a..e68bfebd5 100644 --- a/lib/Service/DocumentService.php +++ b/lib/Service/DocumentService.php @@ -29,15 +29,21 @@ use OCA\Text\Db\DocumentMapper; use OCA\Text\Db\SessionMapper; use OCA\Text\Db\Step; use OCA\Text\Db\StepMapper; +use OCA\Text\DocumentSaveConflictException; use OCP\AppFramework\Db\DoesNotExistException; use OCP\Files\IAppData; use OCP\Files\InvalidPathException; use OCP\Files\IRootFolder; use OCP\Files\NotFoundException; +use OCP\Files\NotPermittedException; use OCP\ICacheFactory; class DocumentService { + /** + * Delay to wait for between autosave versions + */ + const AUTOSAVE_MINIMUM_DELAY = 10; private $sessionMapper; private $userId; @@ -80,9 +86,12 @@ class DocumentService { * @throws \OCP\Files\NotPermittedException */ public function getDocumentById($fileId) { + return $this->createDocument($this->getFile($fileId)); + } + + public function getFile($fileId) { /** @var File $file */ - $file = $this->rootFolder->getUserFolder($this->userId)->getById($fileId); - return $this->createDocument($file); + return $this->rootFolder->getUserFolder($this->userId)->getById($fileId)[0]; } /** @@ -93,24 +102,23 @@ class DocumentService { * @throws \OCP\Files\NotPermittedException */ protected function createDocument(File $file) { - /* remove this after debugging */ - try { - $documentBaseFile = $this->appData->getFolder('documents')->getFile($file->getFileInfo()->getId()); - } catch (NotFoundException $e) { - $documentBaseFile = $this->appData->getFolder('documents')->newFile($file->getFileInfo()->getId()); - } - $documentBaseFile->putContent($file->fopen('r')); - /** endremove */ - try { $document = $this->documentMapper->find($file->getFileInfo()->getId()); + + // TODO: do not hard reset if changed from outside since this will throw away possible steps + // TODO: Only do this when no sessions active, otherise we need to resolve the conflict differently + $lastMTime = $document->getLastSavedVersionTime(); + if ($file->getMTime() > $lastMTime && $lastMTime > 0) { + $this->resetDocument($document->getId()); + throw new NotFoundException(); + } + return $document; } catch (DoesNotExistException $e) { } catch (InvalidPathException $e) { } catch (NotFoundException $e) { } - // TODO: lock file - // TODO: unlock after saving + try { $documentBaseFile = $this->appData->getFolder('documents')->getFile($file->getFileInfo()->getId()); } catch (NotFoundException $e) { @@ -122,6 +130,7 @@ class DocumentService { $document->setId($file->getFileInfo()->getId()); $document->setCurrentVersion(0); $document->setLastSavedVersion(0); + $document->setLastSavedVersionTime($file->getFileInfo()->getMtime()); $document = $this->documentMapper->insert($document); $this->cache->set('document-version-'.$document->getId(), 0); return $document; @@ -155,7 +164,6 @@ class DocumentService { $document->setCurrentVersion($newVersion); $this->documentMapper->update($document); $this->cache->set('document-version-'.$document->getId(), $newVersion); - // TODO write version to cache for quicker checking // TODO write steps to cache for quicker reading return $steps; } @@ -164,4 +172,44 @@ class DocumentService { return $this->stepMapper->find($documentId, $lastVersion); } + public function autosave($documentId, $version, $autoaveDocument, $force = false, $manualSave = false) { + /** @var Document $document */ + $document = $this->documentMapper->find($documentId); + $lastMTime = $document->getLastSavedVersionTime(); + /** @var File $file */ + $file = $this->rootFolder->getUserFolder($this->userId)->getById($documentId)[0]; + if ($file->getMTime() > $lastMTime && $lastMTime > 0 && $force === false) { + throw new DocumentSaveConflictException('File changed in the meantime from outside'); + } + // TODO: check for etag rather than mtime + // Do not save if version already saved + if ($version === (string)$document->getLastSavedVersion()) { + return null; + } + // Only save once every AUTOSAVE_MINIMUM_DELAY seconds + if ($file->getMTime() === $lastMTime && $lastMTime > time()- self::AUTOSAVE_MINIMUM_DELAY && $manualSave === false) { + return null; + } + $file->putContent($autoaveDocument); + $document->setLastSavedVersion($version); + $document->setLastSavedVersionTime(time()); + $this->documentMapper->update($document); + return $document; + } + + public function resetDocument($documentId) { + $this->stepMapper->deleteAll($documentId); + try { + $document = $this->documentMapper->find($documentId); + $this->documentMapper->delete($document); + } catch (DoesNotExistException $e) { + } + + try { + $this->appData->getFolder('documents')->getFile($documentId)->delete(); + } catch (NotFoundException $e) { + } catch (NotPermittedException $e) { + } + } + } diff --git a/lib/Service/SessionService.php b/lib/Service/SessionService.php index 748baf2f5..94bc145ad 100644 --- a/lib/Service/SessionService.php +++ b/lib/Service/SessionService.php @@ -95,6 +95,7 @@ class SessionService { } catch (DoesNotExistException $e) { return false; } + // TODO: move to cache $session->setLastContact($this->timeFactory->getTime()); $this->sessionMapper->update($session); return true; diff --git a/src/collab.js b/src/collab.js index dd982d1a1..6c11af571 100644 --- a/src/collab.js +++ b/src/collab.js @@ -28,11 +28,28 @@ import {schema, defaultMarkdownParser, defaultMarkdownSerializer} from "prosemir import {collab, receiveTransaction, sendableSteps, getVersion} from 'prosemirror-collab'; import {Step} from 'prosemirror-transform'; -const FETCH_INTERVAL = 100; -const MIN_PUSH_RETRY = 200; +const FETCH_INTERVAL = 200; +const MIN_PUSH_RETRY = 500; const MAX_PUSH_RETRY = 10000; const WARNING_PUSH_RETRY = 2000; +/** + * Define how often the editor should retry to apply local changes, before warning the user + */ +const MAX_REBASE_RETRY = 5; + +const ERROR_TYPE = { + /** + * Failed to save collaborative document due to external change + * collission needs to be resolved manually + */ + SAVE_COLLISSION: 0, + /** + * Failed to push changes for MAX_REBASE_RETRY times + */ + PUSH_FAILURE: 1, +} + // TODO to fetch changes more frequently while typing // we either need to have a state machine similar to the prosemirror example to fetch // changes inbetween push tries or return updates with the push error @@ -47,12 +64,36 @@ class EditorSync { this.stepClientIDs = [] this.lock = false this.retryTime = MIN_PUSH_RETRY + this.dirty = false + this.fetchInverval = FETCH_INTERVAL; + + this.onSyncHandlers = [] + this.onErrorHandlers = [] + this.onStateChangeHandlers = [] // example for polling - // TODO: dynamic fetch interval - // reduce fetch interval if no other user joined or no change since x sec - setInterval(() => this.fetchSteps(), FETCH_INTERVAL) + // the interval will be adjusted dynamically depending on the time without any change + this.fetcher = setInterval(() => this.fetchSteps(), this.fetchInverval) + } + + onSync(handler) { + this.onSyncHandlers.push(handler) + } + onStateChange(handler) { + this.onStateChangeHandlers.push(handler) + } + + triggerStateChange() { + this.onStateChangeHandlers.forEach((handler) => handler()) + } + + onError(handler) { + this.onErrorHandlers.push(handler) + } + + content() { + return defaultMarkdownSerializer.serialize(this.view.state.doc) } fetchSteps() { @@ -60,15 +101,27 @@ class EditorSync { return; } this.lock = true; + this.triggerStateChange() const authority = this; + let autosaveContent = undefined + if (!sendableSteps(this.view.state)) { + autosaveContent = this.content() + } axios.get(OC.generateUrl('/apps/text/session/sync'), {params: { documentId: this.document.id, sessionId: this.session.id, token: this.session.token, - version: authority.steps.length + version: authority.steps.length, + autosaveContent }}).then((response) => { + this.onSyncHandlers.forEach((handler) => handler(response.data)) + + if (response.data.document) { + console.log('Saved document', response.data.document) + } if (response.data.steps.length === 0) { this.lock = false; + this.increaseRefetchTimer(); return; } for (let i = 0; i < response.data.steps.length; i++) { @@ -84,11 +137,35 @@ class EditorSync { ) console.log(getVersion(authority.view.state)) this.lock = false; + this.sendSteps() + this.resetRefetchTimer(); }).catch((e) => { this.lock = false; + this.sendSteps() + if (e.response.status === 409) { + console.log('Conflict during file save, please resolve') + this.view.setProps({editable: () => false}) + // TODO recover + this.onErrorHandlers.forEach((handler) => handler(ERROR_TYPE.SAVE_COLLISSION, { + outsideChange: e.response.outsideChange + })) + } }) } + resetRefetchTimer() { + this.fetchInverval = FETCH_INTERVAL; + clearInterval(this.fetcher) + this.fetcher = setInterval(() => this.fetchSteps(), this.fetchInverval) + + } + + increaseRefetchTimer() { + this.fetchInverval = Math.min(this.fetchInverval + 100, FETCH_INTERVAL*5) + clearInterval(this.fetcher) + this.fetcher = setInterval(() => this.fetchSteps(), this.fetchInverval) + } + stepsSince(version) { return { steps: this.steps.slice(version), @@ -100,6 +177,8 @@ class EditorSync { let newRetry = this.retryTime ? Math.min(this.retryTime * 2, MAX_PUSH_RETRY) : MIN_PUSH_RETRY if (newRetry > WARNING_PUSH_RETRY && this.retryTime < WARNING_PUSH_RETRY) { OC.Notification.showTemporary('Changes could not be sent yet'); + this.view.setProps({editable: () => false}) + // TODO recover } this.retryTime = newRetry setTimeout(callback, this.retryTime) @@ -112,19 +191,21 @@ class EditorSync { sendSteps() { let sendable = sendableSteps(this.view.state) if (!sendable) { + this.dirty = false + this.triggerStateChange() return; } - + this.dirty = true + this.triggerStateChange() if (this.lock) { setTimeout(() => { this.sendSteps() }, 500) return; } - this.lock = true; - const authority = this; - let version = sendable.version; - let steps = sendable.steps; + this.lock = true + const authority = this + let steps = sendable.steps axios.post(OC.generateUrl('/apps/text/session/push'), { documentId: this.document.id, sessionId: this.session.id, @@ -133,8 +214,6 @@ class EditorSync { version: getVersion(authority.view.state) }).then((response) => { // sucessfully applied steps on the server - let newSteps = [] - let newClientIDs = [] steps.forEach(step => { authority.steps.push(step) authority.stepClientIDs.push(this.session.id) @@ -147,6 +226,7 @@ class EditorSync { this.lock = false }).catch((e) => { console.log('failed to apply steps due to collission, retrying'); + // TODO: increase retry counter to check against MAX_REBASE_RETRY this.lock = false // TODO: remove if we have state machine this.fetchSteps() @@ -159,8 +239,11 @@ class EditorSync { } -const initEditor = (unusedauthority, tmpEditorId, data, fileContent) => { +const initEditor = (unusedauthority, tmpEditorId, data, fileContent, editorView) => { const authority = new EditorSync(defaultMarkdownParser.parse(fileContent), data) + authority.onSync((syncState) => { + editorView.sessions = syncState.sessions + }) const view = new EditorView(document.querySelector("#editor" + tmpEditorId), { state: EditorState.create({ @@ -173,9 +256,6 @@ const initEditor = (unusedauthority, tmpEditorId, data, fileContent) => { }) ] }), - get content() { - return defaultMarkdownSerializer.serialize(this.view.state.doc) - }, focus() { this.view.focus() }, destroy() { this.view.destroy() }, dispatchTransaction: transaction => { @@ -187,6 +267,14 @@ const initEditor = (unusedauthority, tmpEditorId, data, fileContent) => { }) authority.view = view; authority.fetchSteps() + window.OCA.Text = { + view, + authority + } + return { + view: view, + authority: authority + } } -export { initEditor } +export { initEditor, ERROR_TYPE } diff --git a/src/components/Editor.vue b/src/components/Editor.vue index 41f1e2958..7244dbca9 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -21,47 +21,114 @@ --> <template> - <div id="editor-container" v-if="session"> + <div id="editor-container" v-if="session && show"> <div id="editor-session-list"> - <avatar :user="session.userId" style="border: 2px solid #000;" :style="{'border-color': session.color}"></avatar> - <avatar :user="name"></avatar> - <input v-model="name" /> + <div class="save-status" :class="lastSavedStatusClass" v-tooltip="lastSavedStatusTooltip">{{ lastSavedStatus }}</div> + <avatar v-for="session in activeSessions" :key="session.id" :user="session.userId" :displayName="session.displayName" :style="sessionStyle(session)"></avatar> </div> <div id="editor2"></div> </div> </template> <script> + const COLLABORATOR_IDLE_TIME = 5; + const COLLABORATOR_DISCONNECT_TIME = 20; + import axios from 'nextcloud-axios' - import { initEditor } from './../collab'; + import { initEditor, ERROR_TYPE } from './../collab'; import { Avatar } from 'nextcloud-vue'; + import Tooltip from 'nextcloud-vue/dist/Directives/Tooltip' + import {sendableSteps, getVersion} from 'prosemirror-collab'; + export default { name: 'Editor', components: { Avatar }, + directives: { + Tooltip + }, beforeMount () { - this.initSession() + if (this.show) { + this.initSession() + } + // TODO: handle viewer next? show: false -> true }, props: { - path: { + relativePath: { default: '/example.md' }, + fileId: { + default: null + }, + show: { + default: true + } }, data() { return { + editor: null, document: null, content: null, session: null, - name: 'Guest' + sessions: [], + name: 'Guest', + dirty: false, + lastSavedString: '', + syncError: null + } + }, + computed: { + activeSessions() { + // TODO: filter out duplicate user ids + return this.sessions.filter((session) => session.lastContact > Date.now()/1000-COLLABORATOR_DISCONNECT_TIME) + }, + sessionStyle() { + return (session) => { + return { + 'opacity': session.lastContact > Date.now()/1000-COLLABORATOR_IDLE_TIME ? 1 : 0.5, + 'border-color': session.color + } + } + }, + lastSavedStatus() { + if (this.dirty) { + return '*' + this.lastSavedString + } + return this.lastSavedString + }, + lastSavedStatusClass() { + if (!this.syncError) { + return ''; + } + return 'error'; + }, + lastSavedStatusTooltip() { + if (!this.syncError) { + return {} + } + // TODO: move to v-popover, trigger reloadEditor for now + // TODO: implement conflict resolving + return { + content: 'The document has been changed outside of the editor. The changes cannot be applied.', + show: true, + trigger: 'manual', + placement: 'bottom' + } } }, methods: { + reloadEditor() { + + }, + updateLastSavedStatus() { + this.lastSavedString = moment(this.document.lastSavedVersionTime*1000).fromNow(); + }, initSession() { axios.get(OC.generateUrl('/apps/text/session/create'), { // TODO: viewer should provide the file id so we can use it in all places (also for public pages) - params: {file: this.path} + params: {file: this.relativePath} }).then((response) => { this.document = response.data.document; this.session = response.data.session; @@ -74,9 +141,27 @@ } } ).then((fileContent) => { - initEditor(null, 2, response.data, fileContent.data); - this.$emit('loaded') - // TODO: resize viewer + const {editor, authority} = initEditor(null, 2, response.data, fileContent.data, this); + this.authority = authority + this.authority.onSync((data) => { + if (data.document) { + this.document = data.document + } + }) + this.authority.onError((error, data) => { + if (error === ERROR_TYPE.SAVE_COLLISSION) { + this.syncError = { + type: ERROR_TYPE.SAVE_COLLISSION, + data: data + } + } + }) + this.authority.onStateChange(() => { + this.dirty = this.authority.dirty + }) + + setInterval(() => { this.updateLastSavedStatus() }, 2000) + this.$emit('update:loaded', true) }); }); @@ -89,10 +174,19 @@ #editor-container { display: block; + // Size that is used for modal as well max-width: 900px; width: 100vw; + height: calc(100vh - 88px); margin: 0 auto; + border-radius: 3px; position: relative; + background-color: var(--color-main-background); + } + + #editor2 { + height: 100%; + overflow-y: scroll; } #editor-session-list { @@ -101,9 +195,22 @@ right: 0; z-index: 100; padding: 3px; + display: flex; input, div { vertical-align: middle; + margin-left: 3px; + } + } + + .save-status { + padding: 6px; + color: var(--color-text-lighter); + + &.error { + background-color: var(--color-error); + color: var(--color-main-background); + border-radius: 3px; } } diff --git a/src/files.js b/src/files.js index d64cd4076..4fc0482b5 100644 --- a/src/files.js +++ b/src/files.js @@ -33,8 +33,8 @@ const newFileMenuPlugin = { // register the new menu entry menu.addMenuEntry({ id: 'file', - displayName: t('files_texteditor', 'New text document'), - templateName: t('files_texteditor', 'New text document.md'), + displayName: t('text', 'New text document'), + templateName: t('text', 'New text document.md'), iconClass: 'icon-filetype-text', fileType: 'file', actionHandler: function (name) { @@ -51,10 +51,10 @@ OC.Plugins.register('OCA.Files.NewFileMenu', newFileMenuPlugin); import Editor from './components/Editor' $(document).ready(function() { -OCA.Viewer.registerHandler({ - id: 'text', - mimes: ['text/markdown'], - component: Editor, - group: null -}); + OCA.Viewer.registerHandler({ + id: 'text', + mimes: ['text/markdown'], + component: Editor, + group: null + }); }); diff --git a/src/main.js b/src/main.js index 4857083a8..4ad6dfeb4 100644 --- a/src/main.js +++ b/src/main.js @@ -2,24 +2,9 @@ import Vue from 'vue' __webpack_nonce__ = btoa(OC.requestToken); // eslint-disable-line no-native-reassign -/* -import { initEditor } from './collab'; -import axios from 'nextcloud-axios' - -axios.get(OC.generateUrl('/apps/text/session/create'), {params: {file: '/example.md'}}) - .then((response) => { - console.log(response.data); - axios.get(OC.generateUrl('/apps/text/session/fetch',), {params: - {documentId: response.data.document.id, sessionId: response.data.session.id, token: response.data.session.token} - }).then((fileContent) => { - let contentDom = document.querySelector("#editor-content"); - contentDom.innerHTML = fileContent.data; - initEditor(null, 1, response.data, fileContent.data); - }); - }); -*/ Vue.prototype.t = t Vue.prototype.OCA = OCA + import Editor from './components/Editor' new Vue({ render: h => h(Editor), |