Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/nextcloud/text.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJulius Härtl <jus@bitgrid.net>2019-04-23 13:00:59 +0300
committerJulius Härtl <jus@bitgrid.net>2019-04-23 13:00:59 +0300
commit2660b7fee51fe87c375307891e4a8abb98c07a69 (patch)
tree0be402f081d029bdc6a82bd80e8df0b7add65ce6
parent1bdb0ff3eaa0f8a874bbf306ca5fe8a45f8e6ff4 (diff)
Add first file handling
Signed-off-by: Julius Härtl <jus@bitgrid.net>
-rw-r--r--appinfo/app.php1
-rw-r--r--appinfo/database.xml6
-rw-r--r--appinfo/info.xml4
-rw-r--r--appinfo/routes.php3
-rw-r--r--css/style.scss118
-rw-r--r--lib/Controller/SessionController.php19
-rw-r--r--lib/Db/Document.php13
-rw-r--r--lib/Db/StepMapper.php8
-rw-r--r--lib/DocumentSaveConflictException.php29
-rw-r--r--lib/Service/DocumentService.php76
-rw-r--r--lib/Service/SessionService.php1
-rw-r--r--src/collab.js124
-rw-r--r--src/components/Editor.vue131
-rw-r--r--src/files.js16
-rw-r--r--src/main.js17
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),