зеркало из https://github.com/nextcloud/text.git
Add first file handling
Signed-off-by: Julius Härtl <jus@bitgrid.net>
This commit is contained in:
Родитель
1bdb0ff3ea
Коммит
2660b7fee5
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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'],
|
||||
|
||||
|
||||
]
|
||||
];
|
||||
|
|
118
css/style.scss
118
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;
|
||||
|
|
|
@ -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
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
||||
}
|
|
@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
124
src/collab.js
124
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 }
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
16
src/files.js
16
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
|
||||
});
|
||||
});
|
||||
|
|
17
src/main.js
17
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),
|
||||
|
|
Загрузка…
Ссылка в новой задаче