Signed-off-by: Julius Härtl <jus@bitgrid.net>
This commit is contained in:
Julius Härtl 2019-04-23 12:00:59 +02:00
Родитель 1bdb0ff3ea
Коммит 2660b7fee5
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4C614C6ED2CDE6DF
15 изменённых файлов: 446 добавлений и 120 удалений

Просмотреть файл

@ -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'],
]
];

Просмотреть файл

@ -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;

Просмотреть файл

@ -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;
}
}

Просмотреть файл

@ -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
});
});

Просмотреть файл

@ -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),