chore: refactor iframes to load collabora directly

Signed-off-by: Julius Härtl <jus@bitgrid.net>
This commit is contained in:
Julius Härtl 2023-06-11 20:18:34 +02:00
parent 193640dbc6
commit ba45233bff
No known key found for this signature in database
GPG key ID: 4C614C6ED2CDE6DF
19 changed files with 662 additions and 477 deletions

View file

@ -32,6 +32,7 @@ return [
['name' => 'document#remote', 'url' => 'remote', 'verb' => 'GET'],
['name' => 'document#createFromTemplate', 'url' => 'indexTemplate', 'verb' => 'GET'],
['name' => 'document#publicPage', 'url' => '/public', 'verb' => 'GET'],
['name' => 'document#token', 'url' => '/token', 'verb' => 'POST'],
['name' => 'document#editOnline', 'url' => 'editonline', 'verb' => 'GET'],
['name' => 'document#editOnlineTarget', 'url' => 'editonline/{fileId}/{target}', 'verb' => 'GET'],

View file

@ -51,7 +51,7 @@ describe('Public sharing of office documents', function() {
cy.spy(win, 'postMessage').as('postMessage')
},
})
cy.waitForCollabora()
cy.waitForCollabora(true)
cy.get('@loleafletframe').within(() => {
cy.get('#closebutton').click()
})

View file

@ -46,57 +46,20 @@ use Psr\Log\LoggerInterface;
class DirectViewController extends Controller {
use DocumentTrait;
/** @var IRootFolder */
private $rootFolder;
/** @var TokenManager */
private $tokenManager;
/** @var DirectMapper */
private $directMapper;
/** @var IConfig */
private $config;
/** @var AppConfig */
private $appConfig;
/** @var TemplateManager */
private $templateManager;
/** @var FederationService */
private $federationService;
/** @var LoggerInterface */
private $logger;
/** @var InitialStateService */
private $initialState;
public function __construct(
$appName,
string $appName,
IRequest $request,
IRootFolder $rootFolder,
TokenManager $tokenManager,
DirectMapper $directMapper,
InitialStateService $initialState,
IConfig $config,
AppConfig $appConfig,
TemplateManager $templateManager,
FederationService $federationService,
LoggerInterface $logger
private IRootFolder $rootFolder,
private TokenManager $tokenManager,
private DirectMapper $directMapper,
private InitialStateService $initialState,
private IConfig $config,
private AppConfig $appConfig,
private TemplateManager $templateManager,
private FederationService $federationService,
private LoggerInterface $logger
) {
parent::__construct($appName, $request);
$this->rootFolder = $rootFolder;
$this->tokenManager = $tokenManager;
$this->directMapper = $directMapper;
$this->initialState = $initialState;
$this->config = $config;
$this->appConfig = $appConfig;
$this->templateManager = $templateManager;
$this->federationService = $federationService;
$this->logger = $logger;
}
/**
@ -157,7 +120,7 @@ class DirectViewController extends Controller {
return $response;
}
list($urlSrc, $token, $wopi) = $this->tokenManager->getToken($item->getId(), null, $direct->getUid(), true);
list($urlSrc, $wopi) = $this->tokenManager->getToken($item->getId(), null, $direct->getUid(), true);
} catch (\Exception $e) {
$this->logger->error('Failed to generate token for existing file on direct editing', ['exception' => $e]);
return $this->renderErrorPage('Failed to open the requested file.');
@ -218,11 +181,11 @@ class DirectViewController extends Controller {
'directGuest' => empty($direct->getUid()),
];
list($urlSrc, $token, $wopi) = $this->tokenManager->getToken($node->getId(), $direct->getShare(), $direct->getUid(), true);
list($urlSrc, $wopi) = $this->tokenManager->getToken($node->getId(), $direct->getShare(), $direct->getUid(), true);
if (!empty($direct->getInitiatorHost())) {
$this->tokenManager->upgradeFromDirectInitiator($direct, $wopi);
}
$params['token'] = $token;
$params['token'] = $wopi->getToken();
$params['token_ttl'] = $wopi->getExpiry();
$params['urlsrc'] = $urlSrc;

View file

@ -16,12 +16,17 @@ use \OCP\AppFramework\Controller;
use \OCP\AppFramework\Http\TemplateResponse;
use \OCP\IConfig;
use \OCP\IRequest;
use Exception;
use OC\User\NoUserException;
use OCA\Richdocuments\Service\FederationService;
use OCA\Richdocuments\Service\InitialStateService;
use OCA\Richdocuments\TemplateManager;
use OCA\Richdocuments\TokenManager;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\RedirectResponse;
use OCP\Constants;
use OCP\Files\File;
@ -41,59 +46,23 @@ class DocumentController extends Controller {
public const SESSION_FILE_TARGET = 'richdocuments_openfile_target';
/** @var ?string */
private $uid;
/** @var IConfig */
private $config;
/** @var AppConfig */
private $appConfig;
/** @var LoggerInterface */
private $logger;
/** @var IManager */
private $shareManager;
/** @var TokenManager */
private $tokenManager;
/** @var ISession */
private $session;
/** @var IRootFolder */
private $rootFolder;
/** @var TemplateManager */
private $templateManager;
/** @var FederationService */
private $federationService;
/** @var InitialStateService */
private $initialState;
private IURLGenerator $urlGenerator;
public function __construct(
$appName,
string $appName,
IRequest $request,
IConfig $config,
AppConfig $appConfig,
IManager $shareManager,
TokenManager $tokenManager,
IRootFolder $rootFolder,
ISession $session,
$UserId,
LoggerInterface $logger,
TemplateManager $templateManager,
FederationService $federationService,
InitialStateService $initialState,
IURLGenerator $urlGenerator
private IConfig $config,
private AppConfig $appConfig,
private IManager $shareManager,
private TokenManager $tokenManager,
private IRootFolder $rootFolder,
private ISession $session,
private ?string $userId,
private LoggerInterface $logger,
private TemplateManager $templateManager,
private FederationService $federationService,
private InitialStateService $initialState,
private IURLGenerator $urlGenerator
) {
parent::__construct($appName, $request);
$this->uid = $UserId;
$this->config = $config;
$this->appConfig = $appConfig;
$this->shareManager = $shareManager;
$this->tokenManager = $tokenManager;
$this->rootFolder = $rootFolder;
$this->session = $session;
$this->logger = $logger;
$this->templateManager = $templateManager;
$this->federationService = $federationService;
$this->initialState = $initialState;
$this->urlGenerator = $urlGenerator;
}
/**
@ -106,7 +75,7 @@ class DocumentController extends Controller {
*
* @return array access_token, urlsrc
*/
public function extAppGetData(int $fileId) {
public function extAppGetData(int $fileId): array {
$secretToken = $this->request->getParam('secret_token');
$apps = array_filter(explode(',', $this->appConfig->getAppValue('external_apps')));
foreach ($apps as $app) {
@ -118,18 +87,18 @@ class DocumentController extends Controller {
'fileId' => $fileId
]);
try {
$folder = $this->rootFolder->getUserFolder($this->uid);
$folder = $this->rootFolder->getUserFolder($this->userId);
$item = $folder->getById($fileId)[0];
if (!($item instanceof Node)) {
throw new \Exception();
throw new Exception();
}
list($urlSrc, $token) = $this->tokenManager->getToken($item->getId());
list($urlSrc, $wopi) = $this->tokenManager->getToken($item->getId());
return [
'status' => 'success',
'urlsrc' => $urlSrc,
'token' => $token
'token' => $wopi->getToken()
];
} catch (\Exception $e) {
} catch (Exception $e) {
$this->logger->error($e->getMessage(), ['exception' => $e]);
}
}
@ -150,7 +119,7 @@ class DocumentController extends Controller {
*/
public function index($fileId, ?string $path = null) {
try {
$folder = $this->rootFolder->getUserFolder($this->uid);
$folder = $this->rootFolder->getUserFolder($this->userId);
if ($path !== null) {
$item = $folder->get($path);
@ -159,7 +128,7 @@ class DocumentController extends Controller {
}
if (!($item instanceof File)) {
throw new \Exception();
throw new Exception();
}
/**
@ -175,17 +144,17 @@ class DocumentController extends Controller {
$templateFile = $this->templateManager->getTemplateSource($item->getId());
if ($templateFile) {
list($urlSrc, $wopi) = $this->tokenManager->getTokenForTemplate($templateFile, $this->uid, $item->getId());
list($urlSrc, $wopi) = $this->tokenManager->getTokenForTemplate($templateFile, $this->userId, $item->getId());
$token = $wopi->getToken();
} else {
list($urlSrc, $token, $wopi) = $this->tokenManager->getToken($item->getId());
list($urlSrc, $wopi) = $this->tokenManager->getToken($item->getId());
}
$params = [
'permissions' => $item->getPermissions(),
'title' => $item->getName(),
'fileId' => $item->getId() . '_' . $this->config->getSystemValue('instanceid'),
'token' => $token,
'token' => $wopi->getToken(),
'token_ttl' => $wopi->getExpiry(),
'urlsrc' => $urlSrc,
'path' => $folder->getRelativePath($item->getPath()),
@ -210,7 +179,7 @@ class DocumentController extends Controller {
}
return $this->documentTemplateResponse($wopi, $params);
} catch (\Exception $e) {
} catch (Exception $e) {
$this->logger->error($e->getMessage(), ['exception' => $e]);
return $this->renderErrorPage('Failed to open the requested file.');
}
@ -234,7 +203,7 @@ class DocumentController extends Controller {
return new TemplateResponse('core', '403', [], 'guest');
}
$userFolder = $this->rootFolder->getUserFolder($this->uid);
$userFolder = $this->rootFolder->getUserFolder($this->userId);
try {
$folder = $userFolder->get($dir);
} catch (NotFoundException $e) {
@ -248,7 +217,7 @@ class DocumentController extends Controller {
$file = $folder->newFile($fileName);
$template = $this->templateManager->get($templateId);
list($urlSrc, $wopi) = $this->tokenManager->getTokenForTemplate($template, $this->uid, $file->getId());
list($urlSrc, $wopi) = $this->tokenManager->getTokenForTemplate($template, $this->userId, $file->getId());
$wopiFileId = $wopi->getFileid() . '_' . $this->config->getSystemValue('instanceid');
@ -272,7 +241,7 @@ class DocumentController extends Controller {
* @param string $shareToken
* @param string $fileName
* @return TemplateResponse|RedirectResponse
* @throws \Exception
* @throws Exception
*/
public function publicPage($shareToken, $fileName, $fileId) {
try {
@ -282,7 +251,7 @@ class DocumentController extends Controller {
if (!$this->session->exists('public_link_authenticated')
|| $this->session->get('public_link_authenticated') !== (string)$share->getId()
) {
throw new \Exception('Invalid password');
throw new Exception('Invalid password');
}
}
@ -315,7 +284,7 @@ class DocumentController extends Controller {
if ($templateFile) {
list($urlSrc, $wopi) = $this->tokenManager->getTokenForTemplate($templateFile, $share->getShareOwner(), $item->getId());
} else {
list($urlSrc, $token, $wopi) = $this->tokenManager->getToken($item->getId(), $shareToken, $this->uid);
list($urlSrc, $wopi) = $this->tokenManager->getToken($item->getId(), $shareToken, $this->userId);
}
$params['token'] = $wopi->getToken();
$params['token_ttl'] = $wopi->getExpiry();
@ -324,7 +293,7 @@ class DocumentController extends Controller {
return $this->documentTemplateResponse($wopi, $params);
}
} catch (\Exception $e) {
} catch (Exception $e) {
$this->logger->error($e->getMessage(), ['exception' => $e]);
return $this->renderErrorPage('Failed to open the requested file.');
}
@ -352,7 +321,7 @@ class DocumentController extends Controller {
if (!$this->session->exists('public_link_authenticated')
|| $this->session->get('public_link_authenticated') !== (string)$share->getId()
) {
throw new \Exception('Invalid password');
throw new Exception('Invalid password');
}
}
@ -366,11 +335,11 @@ class DocumentController extends Controller {
}
if ($node instanceof Node) {
list($urlSrc, $token, $wopi) = $this->tokenManager->getToken($node->getId(), $shareToken, $this->uid);
list($urlSrc, $wopi) = $this->tokenManager->getToken($node->getId(), $shareToken, $this->userId);
$remoteWopi = $this->federationService->getRemoteFileDetails($remoteServer, $remoteServerToken);
if ($remoteWopi === null) {
throw new \Exception('Invalid remote file details for ' . $remoteServerToken);
throw new Exception('Invalid remote file details for ' . $remoteServerToken);
}
$this->tokenManager->upgradeToRemoteToken($wopi, $remoteWopi, $shareToken, $remoteServer, $remoteServerToken);
@ -383,7 +352,7 @@ class DocumentController extends Controller {
'permissions' => $permissions,
'title' => $node->getName(),
'fileId' => $node->getId() . '_' . $this->config->getSystemValue('instanceid'),
'token' => $token,
'token' => $wopi->getToken(),
'token_ttl' => $wopi->getExpiry(),
'urlsrc' => $urlSrc,
'path' => '/',
@ -394,7 +363,7 @@ class DocumentController extends Controller {
}
} catch (ShareNotFound $e) {
return new TemplateResponse('core', '404', [], 'guest');
} catch (\Exception $e) {
} catch (Exception $e) {
$this->logger->error($e->getMessage(), ['exception' => $e]);
return $this->renderErrorPage('Failed to open the requested file.');
}
@ -422,10 +391,10 @@ class DocumentController extends Controller {
}
if ($userId === null) {
$userId = $this->uid;
$userId = $this->userId;
}
if ($userId !== null && $userId !== $this->uid) {
if ($userId !== null && $userId !== $this->userId) {
return $this->renderErrorPage('You are trying to open a file from another user account than the one you are currently logged in with.');
}
@ -462,12 +431,12 @@ class DocumentController extends Controller {
* @UseSession
*/
public function editOnlineTarget(int $fileId, ?string $target = null) {
if (!$this->uid) {
if (!$this->userId) {
return $this->renderErrorPage('File not found', Http::STATUS_NOT_FOUND);
}
try {
$userFolder = $this->rootFolder->getUserFolder($this->uid);
$userFolder = $this->rootFolder->getUserFolder($this->userId);
$files = $userFolder->getById($fileId);
$file = array_shift($files);
if (!$file) {
@ -482,11 +451,32 @@ class DocumentController extends Controller {
}
$redirectUrl = $this->urlGenerator->getAbsoluteURL('/index.php/f/' . $file->getId());
return new RedirectResponse($redirectUrl);
} catch (NotFoundException $e) {
} catch (NotPermittedException $e) {
} catch (NoUserException $e) {
} catch (NotFoundException|NotPermittedException|NoUserException) {
}
return $this->renderErrorPage('File not found', Http::STATUS_NOT_FOUND);
}
#[NoAdminRequired]
#[NoCSRFRequired]
#[PublicPage]
public function token(int $fileId, ?string $shareToken = null): DataResponse {
try {
// Get file and share
$templateFile = $this->templateManager->getTemplateSource($fileId);
if ($templateFile) {
[$urlSrc, $wopi] = $this->tokenManager->getTokenForTemplate($templateFile, $share->getShareOwner(), $item->getId());
} else {
[$urlSrc, $wopi] = $this->tokenManager->getToken($fileId, $shareToken, $this->userId);
}
return new DataResponse(array_merge(
[ 'urlSrc' => $urlSrc ],
$wopi->jsonSerialize(),
));
} catch (Exception $e) {
$this->logger->error('Failed to generate token for file', [ 'exception' => $e ]);
return new DataResponse('Failed to generate token', Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
}

View file

@ -2,6 +2,7 @@
namespace OCA\Richdocuments\Controller;
use OCA\Richdocuments\AppConfig;
use OCA\Richdocuments\Db\Wopi;
use OCP\AppFramework\Http\FeaturePolicy;
use OCP\AppFramework\Http\TemplateResponse;
@ -9,7 +10,7 @@ use OCP\Collaboration\Reference\RenderReferenceEvent;
use OCP\EventDispatcher\IEventDispatcher;
trait DocumentTrait {
private $appConfig;
private AppConfig $appConfig;
private function documentTemplateResponse(Wopi $wopi, array $params): TemplateResponse {
$eventDispatcher = \OC::$server->get(IEventDispatcher::class);

View file

@ -510,9 +510,9 @@ class WopiController extends Controller {
if ($isPutRelative) {
// generate a token for the new file (the user still has to be logged in)
list(, $wopiToken) = $this->tokenManager->getToken((string)$file->getId(), null, $wopi->getEditorUid(), $wopi->getDirect());
list(, $wopi) = $this->tokenManager->getToken((string)$file->getId(), null, $wopi->getEditorUid(), $wopi->getDirect());
$wopi = 'index.php/apps/richdocuments/wopi/files/' . $file->getId() . '_' . $this->config->getSystemValue('instanceid') . '?access_token=' . $wopiToken;
$wopi = 'index.php/apps/richdocuments/wopi/files/' . $file->getId() . '_' . $this->config->getSystemValue('instanceid') . '?access_token=' . $wopi->getToken();
$url = $this->urlGenerator->getAbsoluteURL($wopi);
return new JSONResponse([ 'Name' => $file->getName(), 'Url' => $url ], Http::STATUS_OK);
@ -684,9 +684,9 @@ class WopiController extends Controller {
// generate a token for the new file (the user still has to be
// logged in)
list(, $wopiToken) = $this->tokenManager->getToken((string)$file->getId(), null, $wopi->getEditorUid(), $wopi->getDirect());
list(, $wopi) = $this->tokenManager->getToken((string)$file->getId(), null, $wopi->getEditorUid(), $wopi->getDirect());
$wopi = 'index.php/apps/richdocuments/wopi/files/' . $file->getId() . '_' . $this->config->getSystemValue('instanceid') . '?access_token=' . $wopiToken;
$wopi = 'index.php/apps/richdocuments/wopi/files/' . $file->getId() . '_' . $this->config->getSystemValue('instanceid') . '?access_token=' . $wopi->getToken();
$url = $this->urlGenerator->getAbsoluteURL($wopi);
return new JSONResponse([ 'Name' => $file->getName(), 'Url' => $url ], Http::STATUS_OK);

View file

@ -67,6 +67,8 @@ class InitialStateService {
$this->initialState->provideInitialState('hasDrawSupport', $this->capabilitiesService->hasDrawSupport());
$this->initialState->provideInitialState('hasNextcloudBranding', $this->capabilitiesService->hasNextcloudBranding());
$this->provideOptions();
$this->hasProvidedCapabilities = true;
}
@ -76,17 +78,8 @@ class InitialStateService {
$this->initialState->provideInitialState('document', $this->prepareParams($params));
$this->initialState->provideInitialState('wopi', $wopi);
$this->initialState->provideInitialState('theme', $this->config->getAppValue(Application::APPNAME, 'theme', 'nextcloud'));
$this->initialState->provideInitialState('uiDefaults', [
'UIMode' => $this->config->getAppValue(Application::APPNAME, 'uiDefaults-UIMode', 'notebookbar')
]);
$logoSet = $this->config->getAppValue('theming', 'logoheaderMime', '') !== '';
if (!$logoSet) {
$logoSet = $this->config->getAppValue('theming', 'logoMime', '') !== '';
}
$this->initialState->provideInitialState('theming-customLogo', ($logoSet ?
\OC::$server->getURLGenerator()->getAbsoluteURL(\OC::$server->getThemingDefaults()->getLogo())
: false));
$this->provideOptions();
}
public function prepareParams(array $params): array {
@ -108,4 +101,18 @@ class InitialStateService {
return array_merge($defaults, $params);
}
private function provideOptions(): void {
$this->initialState->provideInitialState('theme', $this->config->getAppValue(Application::APPNAME, 'theme', 'nextcloud'));
$this->initialState->provideInitialState('uiDefaults', [
'UIMode' => $this->config->getAppValue(Application::APPNAME, 'uiDefaults-UIMode', 'notebookbar')
]);
$logoSet = $this->config->getAppValue('theming', 'logoheaderMime', '') !== '';
if (!$logoSet) {
$logoSet = $this->config->getAppValue('theming', 'logoMime', '') !== '';
}
$this->initialState->provideInitialState('theming-customLogo', ($logoSet ?
\OC::$server->getURLGenerator()->getAbsoluteURL(\OC::$server->getThemingDefaults()->getLogo())
: false));
}
}

View file

@ -111,6 +111,8 @@ class TokenManager {
$updatable = (bool)($share->getPermissions() & \OCP\Constants::PERMISSION_UPDATE);
$hideDownload = $share->getHideDownload();
$owneruid = $share->getShareOwner();
$rootFolder = $this->rootFolder->getUserFolder($owneruid);
} elseif ($this->userId !== null) {
try {
$editoruid = $this->userId;
@ -205,7 +207,6 @@ class TokenManager {
return [
$this->wopiParser->getUrlSrc($file->getMimeType())['urlsrc'], // url src might not be found ehre
$wopi->getToken(),
$wopi
];
}
@ -279,7 +280,7 @@ class TokenManager {
public function newInitiatorToken($sourceServer, Node $node = null, $shareToken = null, bool $direct = false, $userId = null): Wopi {
if ($node !== null) {
list($urlSrc, $token, $wopi) = $this->getToken($node->getId(), $shareToken, $userId, $direct);
list($urlSrc, $wopi) = $this->getToken($node->getId(), $shareToken, $userId, $direct);
$wopi->setServerHost($sourceServer);
$wopi->setTokenType(Wopi::TOKEN_TYPE_INITIATOR);
$this->wopiMapper->update($wopi);

View file

@ -111,7 +111,6 @@ const odfViewer = {
$iframe.addClass('full')
$('#content').addClass('full-height')
$('footer').addClass('hidden')
$('#imgframe').addClass('hidden')
$('#controls').addClass('hidden')
$('#content').addClass('loading')
} else {
@ -162,7 +161,6 @@ const odfViewer = {
if (isPublic) {
$('#content').removeClass('full-height')
$('footer').removeClass('hidden')
$('#imgframe').removeClass('hidden')
$('.directLink').removeClass('hidden')
$('.directDownload').removeClass('hidden')
}

View file

@ -47,7 +47,7 @@ const getUIDefaults = () => {
}
const getCollaboraTheme = () => {
return loadState('richdocuments', 'theme', '')
return loadState('richdocuments', 'theme', 'nextcloud')
}
const generateCSSVarTokens = () => {

View file

@ -76,8 +76,17 @@ const splitPath = (path) => {
return [directory, fileName]
}
const getRandomId = (length = 5) => {
return Math.random()
.toString(36)
.replace(/[^a-z]+/g, '')
.slice(0, length || 5)
}
export {
languageToBCP47,
getNextcloudVersion,
splitPath,
getRandomId,
}

91
src/mixins/openLocal.js Normal file
View file

@ -0,0 +1,91 @@
/*
* @copyright Copyright (c) 2023 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/>.
*/
import { getRootUrl } from '@nextcloud/router'
import axios from '@nextcloud/axios'
import { getNextcloudUrl } from '../helpers/url.js'
import { getCurrentUser } from '@nextcloud/auth'
// FIXME: Migrate to vue component
export default {
data() {
return {
openingLocally: false,
}
},
methods: {
unlockAndOpenLocally() {
if (this.openingLocally) {
this.unlockFile()
.catch(_ => {}) // Unlocking failed, possibly because file was not locked, we want to proceed regardless.
.then(() => {
this.openLocally()
})
}
},
showOpenLocalConfirmation() {
// FIXME: Migrate to vue
window.OC.dialogs.confirmDestructive(
t('richdocuments', 'When opening a file locally, the document will close for all users currently viewing the document.'),
t('richdocuments', 'Open file locally'),
{
type: OC.dialogs.YES_NO_BUTTONS,
confirm: t('richdocuments', 'Open locally'),
confirmClasses: 'error',
cancel: t('richdocuments', 'Continue editing online'),
},
(decision) => {
if (!decision) {
return
}
this.openingLocally = true
this.sendPostMessage('Get_Views')
})
},
unlockFile() {
const unlockUrl = getRootUrl() + '/index.php/apps/richdocuments/wopi/files/' + this.fileid
const unlockConfig = {
headers: { 'X-WOPI-Override': 'UNLOCK' },
}
return axios.post(unlockUrl, { access_token: this.formData.accessToken }, unlockConfig)
},
openLocally() {
if (this.openingLocally) {
this.openingLocally = false
axios.post(
OC.linkToOCS('apps/files/api/v1', 2) + 'openlocaleditor?format=json',
{ path: this.filename }
).then((result) => {
const url = 'nc://open/'
+ getCurrentUser()?.uid + '@' + getNextcloudUrl()
+ OC.encodePath(this.filename)
+ '?token=' + result.data.ocs.data.token
this.showOpenLocalConfirmation(url, window.top)
window.location.href = url
})
}
},
},
}

84
src/mixins/pickLink.js Normal file
View file

@ -0,0 +1,84 @@
/*
* @copyright Copyright (c) 2023 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/>.
*/
import { generateOcsUrl } from '@nextcloud/router'
import axios from '@nextcloud/axios'
import { getLinkWithPicker } from '@nextcloud/vue/dist/Components/NcRichText.js'
// FIXME: Migrate to vue component
export default {
methods: {
async pickLink() {
try {
if (this.showLinkPicker) {
return
}
this.showLinkPicker = true
const link = await getLinkWithPicker(null, true)
try {
const url = new URL(link)
if (url.protocol === 'http:' || url.protocol === 'https:') {
this.sendPostMessage('Action_InsertLink', { url: link })
return
}
} catch (e) {
console.debug('error when parsing the link picker result')
}
this.sendPostMessage('Action_Paste', { Mimetype: 'text/plain', Data: link })
} catch (e) {
console.error('Link picker promise rejected :', e)
} finally {
this.showLinkPicker = false
}
},
async resolveLink(url) {
try {
const result = await axios.get(generateOcsUrl('references/resolve', 2), {
params: {
reference: url,
},
})
const resolvedLink = result.data.ocs.data.references[url]
const title = resolvedLink?.openGraphObject?.name
const thumbnailUrl = resolvedLink?.openGraphObject?.thumb
if (thumbnailUrl) {
try {
const imageResponse = await axios.get(thumbnailUrl, { responseType: 'blob' })
if (imageResponse?.status === 200 && imageResponse?.data) {
const reader = new FileReader()
reader.addEventListener('loadend', (e) => {
const b64Image = e.target.result
this.sendPostMessage('Action_GetLinkPreview_Resp', { url, title, image: b64Image })
})
reader.readAsDataURL(imageResponse.data)
}
} catch (e) {
console.error('Error loading the reference image', e)
}
} else {
this.sendPostMessage('Action_GetLinkPreview_Resp', { url, title, image: null })
}
} catch (e) {
console.error('Error resolving a reference', e)
}
},
},
}

51
src/mixins/saveAs.js Normal file
View file

@ -0,0 +1,51 @@
/*
* @copyright Copyright (c) 2023 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/>.
*/
// FIXME: Migrate to vue component
export default {
methods: {
async saveAs(format) {
window.OC.dialogs.prompt(
t('richdocuments', 'Please enter the filename to store the document as.'),
t('richdocuments', 'Save As'),
(result, value) => {
if (result === true && value) {
this.sendPostMessage('Action_SaveAs', { Filename: value, Notify: true })
}
},
true,
t('richdocuments', 'New filename'),
false
).then(() => {
const $dialog = $('.oc-dialog:visible')
const $buttons = $dialog.find('.oc-dialog-buttonrow button')
$buttons.eq(0).text(t('richdocuments', 'Cancel'))
$buttons.eq(1).text(t('richdocuments', 'Save'))
const nameInput = $dialog.find('input')[0]
nameInput.style.minWidth = '250px'
nameInput.style.maxWidth = '400px'
nameInput.value = format ? this.basename.substring(0, this.basename.lastIndexOf('.') + 1) + format : this.basename
nameInput.selectionStart = 0
nameInput.selectionEnd = this.basename.lastIndexOf('.')
})
},
},
}

49
src/mixins/uiMention.js Normal file
View file

@ -0,0 +1,49 @@
/*
* @copyright Copyright (c) 2023 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/>.
*/
import { generateOcsUrl } from '@nextcloud/router'
import axios from '@nextcloud/axios'
import Config from '../services/config.tsx'
import { getNextcloudUrl } from '../helpers/url.js'
export default {
methods: {
async uiMention(search) {
let users = []
if (Config.get('userId') !== null) {
try {
const result = await axios.get(generateOcsUrl('core/autocomplete/get'), {
params: { search },
})
users = result.data.ocs.data
} catch (e) { }
}
const list = users.map((user) => {
const profile = window.location.protocol + '//' + getNextcloudUrl() + '/index.php/u/' + user.id
return { username: user.label, profile }
})
this.sendPostMessage('Action_Mention', { list })
},
},
}

74
src/mixins/version.js Normal file
View file

@ -0,0 +1,74 @@
/*
* @copyright Copyright (c) 2023 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/>.
*/
import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
import { generateRemoteUrl, getRootUrl } from '@nextcloud/router'
import { getCurrentUser } from '@nextcloud/auth'
import axios from '@nextcloud/axios'
import { showError } from '@nextcloud/dialogs'
export default {
data() {
return {
versionToRestore: null,
}
},
mounted() {
subscribe('files_versions:restore:requested', this.onRestoreRequested)
},
beforeDestroy() {
unsubscribe('files_versions:restore:requested', this.onRestoreRequested)
},
methods: {
onRestoreRequested(eventState) {
// Tell Collabora that we are about to restore a version
this.sendPostMessage('Host_VersionRestore', {
Status: 'Pre_Restore',
})
this.versionToRestore = eventState.version
// Prevent files_versions own restore as we'd need to wait for Collabora to be ready
eventState.preventDefault = true
},
async handlePreRestoreAck() {
const restoreUrl = getRootUrl() + '/remote.php/dav/versions/' + getCurrentUser().uid
+ '/versions/' + this.fileid + '/' + this.versionToRestore.fileVersion
try {
await axios({
method: 'MOVE',
url: restoreUrl,
headers: {
Destination: generateRemoteUrl('dav') + '/versions/' + getCurrentUser().uid + '/restore/target',
},
})
emit('files_versions:restore:restored', this.versionToRestore)
} catch (e) {
showError(t('richdocuments', 'Failed to revert the document to older version'))
}
this.versionToRestore = null
},
},
}

View file

@ -20,9 +20,8 @@
*
*/
import { generateUrl, generateRemoteUrl, getRootUrl } from '@nextcloud/router'
import { generateUrl } from '@nextcloud/router'
import { getCurrentUser } from '@nextcloud/auth'
import moment from '@nextcloud/moment'
import { getCurrentDirectory } from '../helpers/filesApp.js'
const isPublic = document.getElementById('isPublic') && document.getElementById('isPublic').value === '1'
@ -80,10 +79,6 @@ export default {
this.getFileList().hideMask && this.getFileList().hideMask()
this.getFileList().setPageTitle && this.getFileList().setPageTitle(this.fileName)
}
if (!isPublic) {
this.addVersionSidebarEvents()
}
},
close() {
@ -94,9 +89,6 @@ export default {
this.updateFileInfo(undefined, Date.now())
this.fileModel = null
if (!isPublic) {
this.removeVersionSidebarEvents()
}
$('#richdocuments-header').remove()
},
@ -131,9 +123,9 @@ export default {
console.error('[FilesAppIntegration] Sharing is not supported')
return
}
if (OCA.Files.Sidebar) {
OCA.Files.Sidebar.open(this.filePath + '/' + this.fileName)
}
OCA?.Files?.Sidebar?.open(this.filePath + '/' + this.fileName)
OCA?.Files?.Sidebar?.setActiveTab('sharing')
},
rename(newName) {
@ -427,115 +419,17 @@ export default {
avatardiv.append(popover)
},
addVersionSidebarEvents() {
$(document.querySelector('#content')).on('click.revisions', '.app-sidebar .preview-container', this.showVersionPreview.bind(this))
$(document.querySelector('#content')).on('click.revisions', '.app-sidebar .downloadVersion', this.showVersionPreview.bind(this))
$(document.querySelector('#content')).on('mousedown.revisions', '.app-sidebar .revertVersion', this.restoreVersion.bind(this))
$(document.querySelector('#content')).on('click.revisionsTab', '.app-sidebar [data-tabid=versionsTabView]', this.addCurrentVersion.bind(this))
},
removeVersionSidebarEvents() {
$(document.querySelector('#content')).off('click.revisions')
$(document.querySelector('#content')).off('click.revisions')
$(document.querySelector('#content')).off('mousedown.revisions')
$(document.querySelector('#content')).off('click.revisionsTab')
},
addCurrentVersion() {
$('#lastSavedVersion').remove()
$('#currentVersion').remove()
if (this.getFileModel()) {
const preview = OC.MimeType.getIconUrl(this.getFileModel().get('mimetype'))
const mtime = this.getFileModel().get('mtime')
$('.tab.versionsTabView').prepend('<ul id="lastSavedVersion"><li data-revision="0"><div><div class="preview-container"><img src="' + preview + '" width="44" /></div><div class="version-container">\n'
+ '<div><a class="downloadVersion">' + t('richdocuments', 'Last saved version') + ' (<span class="versiondate has-tooltip live-relative-timestamp" data-timestamp="' + mtime + '"></span>)</div></div></li></ul>')
$('.tab.versionsTabView').prepend('<ul id="currentVersion"><li data-revision="" class="active"><div><div class="preview-container"><img src="' + preview + '" width="44" /></div><div class="version-container">\n'
+ '<div><a class="downloadVersion">' + t('richdocuments', 'Current version (unsaved changes)') + '</a></div></div></li></ul>')
$('.live-relative-timestamp').each(function() {
$(this).text(moment(parseInt($(this).attr('data-timestamp'), 10)).fromNow())
})
}
},
showRevHistory() {
if (this.handlers.showRevHistory && this.handlers.showRevHistory(this)) {
return
}
if (this.getFileList()) {
this.getFileList()
.showDetailsView(this.fileName, 'versionsTabView')
this.addCurrentVersion()
if (isPublic || !this.getFileList()) {
console.error('[FilesAppIntegration] Versions are not supported')
return
}
},
showVersionPreview(e) {
e.preventDefault()
let element = e.currentTarget.parentElement.parentElement
if ($(e.currentTarget).hasClass('downloadVersion')) {
element = e.currentTarget.parentElement.parentElement.parentElement.parentElement
}
const version = element.dataset.revision
const fileId = this.fileId
const title = this.fileName
console.debug('[FilesAppIntegration] showVersionPreview', version, fileId, title)
this.sendPostMessage('Action_loadRevViewer', { fileId, title, version })
$(element.parentElement.parentElement).find('li').removeClass('active')
$(element).addClass('active')
},
restoreVersion(e) {
e.preventDefault()
e.stopPropagation()
this.sendPostMessage('Host_VersionRestore', { Status: 'Pre_Restore' })
const version = e.currentTarget.parentElement.parentElement.dataset.revision
this._restoreVersionCallback = () => {
this._restoreDAV(version)
this._restoreVersionCallback = null
}
return false
},
restoreVersionExecute() {
if (this._restoreVersionCallback !== null) {
this._restoreVersionCallback()
}
},
restoreVersionAbort() {
this._restoreVersionCallback = null
},
_restoreSuccess(response) {
if (response.status === 'error') {
OC.Notification.showTemporary(t('richdocuments', 'Failed to revert the document to older version'))
}
// Reload the document frame to get the new file
// TODO: ideally we should have a post messsage that can be sent to collabora to just reload the file once the restore is finished
document.getElementById('richdocumentsframe').src = document.getElementById('richdocumentsframe').src
OC.Apps.hideAppSidebar()
},
_restoreError() {
OC.Notification.showTemporary(t('richdocuments', 'Failed to revert the document to older version'))
},
_restoreDAV(version) {
const restoreUrl = getRootUrl() + '/remote.php/dav/versions/' + getCurrentUser().uid
+ '/versions/' + this.fileId + '/' + version
$.ajax({
type: 'MOVE',
url: restoreUrl,
headers: {
Destination: generateRemoteUrl('dav') + '/versions/' + getCurrentUser().uid + '/restore/target',
},
success: this._restoreSuccess.bind(this),
error: this._restoreError.bind(this),
})
OCA?.Files?.Sidebar?.open(this.filePath + '/' + this.fileName)
OCA?.Files?.Sidebar?.setActiveTab('version_vue')
},
/**

View file

@ -21,78 +21,91 @@
-->
<template>
<transition name="fade" appear>
<div class="office-viewer">
<div v-if="showLoadingIndicator"
class="office-viewer__loading-overlay"
:class="{ debug: debug }">
<NcEmptyContent v-if="!error" :title="t('richdocuments', 'Loading {filename} …', { filename: basename }, 1, {escape: false})">
<template #icon>
<NcLoadingIcon />
</template>
<template #action>
<NcButton @click="close">
{{ t('richdocuments', 'Cancel') }}
</NcButton>
</template>
</NcEmptyContent>
<NcEmptyContent v-else :title="t('richdocuments', 'Document loading failed')" :description="errorMessage">
<template #icon>
<AlertOctagonOutline />
</template>
<template #action>
<NcButton @click="close">
{{ t('richdocuments', 'Close') }}
</NcButton>
</template>
</NcEmptyContent>
</div>
<div v-show="!useNativeHeader && showIframe" class="office-viewer__header">
<div class="avatars">
<NcAvatar v-for="view in avatarViews"
:key="view.ViewId"
:user="view.UserId"
:display-name="view.UserName"
:show-user-status="false"
:show-user-status-compact="false"
:style="viewColor(view)" />
</div>
<NcActions>
<NcActionButton icon="office-viewer__header__icon-menu-sidebar" @click="share" />
</NcActions>
</div>
<iframe id="collaboraframe"
ref="documentFrame"
data-cy="documentframe"
class="office-viewer__iframe"
:style="{visibility: showIframe ? 'visible' : 'hidden' }"
:src="src" />
<ZoteroHint :show.sync="showZotero" @submit="reload" />
<div class="office-viewer">
<div v-if="showLoadingIndicator"
class="office-viewer__loading-overlay"
:class="{ debug: debug }">
<NcEmptyContent v-if="!error" :title="t('richdocuments', 'Loading {filename} …', { filename: basename }, 1, {escape: false})">
<template #icon>
<NcLoadingIcon />
</template>
<template #action>
<NcButton @click="close">
{{ t('richdocuments', 'Cancel') }}
</NcButton>
</template>
</NcEmptyContent>
<NcEmptyContent v-else :title="t('richdocuments', 'Document loading failed')" :description="errorMessage">
<template #icon>
<AlertOctagonOutline />
</template>
<template #action>
<NcButton @click="close">
{{ t('richdocuments', 'Close') }}
</NcButton>
</template>
</NcEmptyContent>
</div>
</transition>
<form ref="form"
:target="iframeId"
:action="formData.action"
method="post">
<input name="access_token" :value="formData.accessToken" type="hidden">
<input name="access_token_ttl" :value="formData.accessTokenTTL" type="hidden">
<input name="ui_defaults" :value="formData.uiDefaults" type="hidden">
<input name="css_variables" :value="formData.cssVariables" type="hidden">
<input name="theme" :value="formData.theme" type="hidden">
<input name="buy_product" value="https://nextcloud.com/pricing" type="hidden">
</form>
<iframe :id="iframeId"
ref="documentFrame"
:name="iframeId"
data-cy="documentframe"
scrolling="no"
allowfullscreen
class="office-viewer__iframe"
:style="{visibility: showIframe ? 'visible' : 'hidden' }"
:src="iframeSrc" />
<ZoteroHint :show.sync="showZotero" @submit="reload" />
</div>
</template>
<script>
import { NcAvatar, NcButton, NcActions, NcActionButton, NcEmptyContent, NcLoadingIcon } from '@nextcloud/vue'
import { NcButton, NcEmptyContent, NcLoadingIcon } from '@nextcloud/vue'
import AlertOctagonOutline from 'vue-material-design-icons/AlertOctagonOutline.vue'
import { loadState } from '@nextcloud/initial-state'
import ZoteroHint from '../components/Modal/ZoteroHint.vue'
import { basename, dirname } from 'path'
import { getDocumentUrlForFile, getDocumentUrlForPublicFile } from '../helpers/url.js'
import { getRandomId } from '../helpers/index.js'
import {
getNextcloudUrl,
getWopiUrl,
} from '../helpers/url.js'
import PostMessageService from '../services/postMessage.tsx'
import FilesAppIntegration from './FilesAppIntegration.js'
import { LOADING_ERROR, checkCollaboraConfiguration, checkProxyStatus } from '../services/collabora.js'
import { enableScrollLock, disableScrollLock } from '../helpers/safariFixer.js'
import { getLinkWithPicker } from '@nextcloud/vue/dist/Components/NcRichText.js'
import axios from '@nextcloud/axios'
import { generateOcsUrl } from '@nextcloud/router'
import {
generateUrl,
imagePath,
} from '@nextcloud/router'
import { getCapabilities } from '@nextcloud/capabilities'
import {
generateCSSVarTokens,
getCollaboraTheme,
getUIDefaults,
} from '../helpers/coolParameters.js'
import Config from '../services/config.tsx'
import openLocal from '../mixins/openLocal.js'
import pickLink from '../mixins/pickLink.js'
import saveAs from '../mixins/saveAs.js'
import uiMention from '../mixins/uiMention.js'
import version from '../mixins/version.js'
const FRAME_DOCUMENT = 'FRAME_DOCUMENT'
const PostMessages = new PostMessageService({
FRAME_DOCUMENT: () => document.getElementById('collaboraframe').contentWindow,
})
const LOADING_STATE = {
LOADING: 0,
@ -105,14 +118,14 @@ export default {
name: 'Office',
components: {
AlertOctagonOutline,
NcAvatar,
NcActions,
NcActionButton,
NcButton,
NcEmptyContent,
NcLoadingIcon,
ZoteroHint,
},
mixins: [
openLocal, pickLink, saveAs, uiMention, version,
],
props: {
filename: {
type: String,
@ -127,36 +140,35 @@ export default {
required: false,
default: () => false,
},
source: {
type: String,
default: null,
},
},
data() {
return {
src: null,
postMessage: null,
iframeId: 'collaboraframe_' + getRandomId(),
iframeSrc: null,
loading: LOADING_STATE.LOADING,
loadingTimeout: null,
error: null,
views: [],
showZotero: false,
showLinkPicker: false,
showZotero: false,
formData: {
action: null,
accessToken: null,
accessTokenTTL: null,
uiDefaults: getUIDefaults(),
cssVariables: generateCSSVarTokens(),
theme: getCollaboraTheme(),
},
}
},
computed: {
basename() {
return basename(this.filename)
},
useNativeHeader() {
return true
},
avatarViews() {
return this.views
},
viewColor() {
return view => ({
'border-color': '#' + ('000000' + Number(view.Color).toString(16)).slice(-6),
'border-width': '2px',
'border-style': 'solid',
})
},
showIframe() {
return this.loading >= LOADING_STATE.FRAME_READY
},
@ -176,8 +188,17 @@ export default {
debug() {
return !!window.TESTING
},
isPublic() {
return document.getElementById('isPublic')?.value === '1'
},
shareToken() {
return document.getElementById('sharingToken')?.value
},
},
async mounted() {
this.postMessage = new PostMessageService({
FRAME_DOCUMENT: () => document.getElementById(this.iframeId).contentWindow,
})
try {
await checkCollaboraConfiguration()
await checkProxyStatus()
@ -187,99 +208,77 @@ export default {
return
}
const fileList = OCA?.Files?.App?.getCurrentFileList?.()
FilesAppIntegration.init({
fileName: basename(this.filename),
fileId: this.fileid,
filePath: dirname(this.filename),
fileList,
fileModel: fileList?.getModelForFile(basename(this.filename)),
sendPostMessage: (msgId, values) => {
PostMessages.sendWOPIPostMessage(FRAME_DOCUMENT, msgId, values)
},
})
PostMessages.registerPostMessageHandler(this.postMessageHandler)
if (this.fileid) {
const fileList = OCA?.Files?.App?.getCurrentFileList?.()
FilesAppIntegration.init({
fileName: basename(this.filename),
fileId: this.fileid,
filePath: dirname(this.filename),
fileList,
fileModel: fileList?.getModelForFile(basename(this.filename)),
sendPostMessage: (msgId, values) => {
this.postMessage.sendWOPIPostMessage(FRAME_DOCUMENT, msgId, values)
},
})
}
this.postMessage.registerPostMessageHandler(this.postMessageHandler)
this.load()
},
beforeDestroy() {
PostMessages.unregisterPostMessageHandler(this.postMessageHandler)
this.postMessage.unregisterPostMessageHandler(this.postMessageHandler)
},
methods: {
async load() {
const fileid = this.fileid ?? basename(dirname(this.source))
const version = this.fileid ? 0 : basename(this.source)
enableScrollLock()
const isPublic = document.getElementById('isPublic') && document.getElementById('isPublic').value === '1'
this.src = getDocumentUrlForFile(this.filename, this.fileid) + '&path=' + encodeURIComponent(this.filename)
if (isPublic) {
this.src = getDocumentUrlForPublicFile(this.filename, this.fileid)
}
// Generate WOPI token
const { data } = await axios.post(generateUrl('/apps/richdocuments/token'), {
fileId: fileid, shareToken: this.shareToken,
})
Config.update('urlsrc', data.urlSrc)
// Generate form and submit to the iframe
const action = getWopiUrl({
fileId: fileid + '_' + loadState('richdocuments', 'instanceId', 'instanceid') + (version > 0 ? '_' + version : ''),
title: this.filename,
readOnly: version > 0,
revisionHistory: !this.isPublic,
closeButton: !Config.get('hideCloseButton'),
})
this.$set(this.formData, 'action', action)
this.$set(this.formData, 'accessToken', data.token)
this.$nextTick(() => this.$refs.form.submit())
this.loading = LOADING_STATE.LOADING
this.loadingTimeout = setTimeout(() => {
console.error('FAILED')
console.error('Document loading failed due to timeout: Please check for failing network requests')
this.loading = LOADING_STATE.FAILED
this.error = t('richdocuments', 'Failed to load {productName} - please try again later', { productName: loadState('richdocuments', 'productName', 'Nextcloud Office') })
}, (OC.getCapabilities().richdocuments.config.timeout * 1000 || 15000))
}, (getCapabilities().richdocuments.config.timeout * 1000 || 15000))
},
sendPostMessage(msgId, values = {}) {
this.postMessage.sendWOPIPostMessage(FRAME_DOCUMENT, msgId, values)
},
documentReady() {
this.loading = LOADING_STATE.DOCUMENT_READY
clearTimeout(this.loadingTimeout)
this.sendPostMessage('Host_PostmessageReady')
this.sendPostMessage('Insert_Button', {
id: 'Open_Local_Editor',
imgurl: window.location.protocol + '//' + getNextcloudUrl() + imagePath('richdocuments', 'launch.svg'),
mobile: false,
label: t('richdocuments', 'Open in local editor'),
hint: t('richdocuments', 'Open in local editor'),
insertBefore: 'print',
})
},
async share() {
FilesAppIntegration.share()
},
async pickLink() {
try {
if (this.showLinkPicker) {
return
}
this.showLinkPicker = true
const link = await getLinkWithPicker(null, true)
try {
const url = new URL(link)
if (url.protocol === 'http:' || url.protocol === 'https:') {
PostMessages.sendWOPIPostMessage(FRAME_DOCUMENT, 'Action_InsertLink', { url: link })
return
}
} catch (e) {
console.debug('error when parsing the link picker result')
}
PostMessages.sendWOPIPostMessage(FRAME_DOCUMENT, 'Action_Paste', { Mimetype: 'text/plain', Data: link })
} catch (e) {
console.error('Link picker promise rejected :', e)
} finally {
this.showLinkPicker = false
}
},
async resolveLink(url) {
try {
const result = await axios.get(generateOcsUrl('references/resolve', 2), {
params: {
reference: url,
},
})
const resolvedLink = result.data.ocs.data.references[url]
const title = resolvedLink?.openGraphObject?.name
const thumbnailUrl = resolvedLink?.openGraphObject?.thumb
if (thumbnailUrl) {
try {
const imageResponse = await axios.get(thumbnailUrl, { responseType: 'blob' })
if (imageResponse?.status === 200 && imageResponse?.data) {
const reader = new FileReader()
reader.addEventListener('loadend', (e) => {
const b64Image = e.target.result
PostMessages.sendWOPIPostMessage(FRAME_DOCUMENT, 'Action_GetLinkPreview_Resp', { url, title, image: b64Image })
})
reader.readAsDataURL(imageResponse.data)
}
} catch (e) {
console.error('Error loading the reference image', e)
}
} else {
PostMessages.sendWOPIPostMessage(FRAME_DOCUMENT, 'Action_GetLinkPreview_Resp', { url, title, image: null })
}
} catch (e) {
console.error('Error resolving a reference', e)
}
},
close() {
FilesAppIntegration.close()
disableScrollLock()
@ -288,19 +287,14 @@ export default {
reload() {
this.loading = LOADING_STATE.LOADING
this.load()
this.$refs.documentFrame.contentWindow.location.replace(this.src)
this.$refs.documentFrame.contentWindow.location.replace(this.iframeSrc)
},
postMessageHandler({ parsed, data }) {
if (data === 'NC_ShowNamePicker') {
this.documentReady()
return
} else if (data === 'loading') {
this.loading = LOADING_STATE.LOADING
postMessageHandler({ parsed }) {
const { msgId, args, deprecated } = parsed
console.debug('[viewer] Received post message', msgId, args, deprecated)
if (deprecated) {
return
}
console.debug('[viewer] Received post message', parsed)
const { msgId, args, deprecated } = parsed
if (deprecated) { return }
switch (msgId) {
case 'App_LoadingStatus':
@ -309,8 +303,7 @@ export default {
this.loading = LOADING_STATE.FRAME_READY
this.$emit('update:loaded', true)
FilesAppIntegration.initAfterReady()
}
if (args.Status === 'Document_Loaded') {
} else if (args.Status === 'Document_Loaded') {
this.documentReady()
} else if (args.Status === 'Failed') {
this.loading = LOADING_STATE.FAILED
@ -325,14 +318,16 @@ export default {
this.loading = LOADING_STATE.FAILED
}
break
case 'loading':
break
case 'close':
case 'UI_Close':
this.close()
break
case 'Get_Views_Resp':
case 'Views_List':
this.views = args
this.unlockAndOpenLocally()
break
case 'UI_SaveAs':
this.saveAs(args.format)
break
case 'Action_Save_Resp':
if (args.fileName !== this.filename) {
@ -341,9 +336,15 @@ export default {
break
case 'UI_InsertGraphic':
FilesAppIntegration.insertGraphic((filename, url) => {
PostMessages.sendWOPIPostMessage(FRAME_DOCUMENT, 'postAsset', { FileName: filename, Url: url })
this.postMessage.sendWOPIPostMessage(FRAME_DOCUMENT, 'Action_InsertGraphic', {
filename,
url,
})
})
break
case 'UI_Mention':
this.uiMention(parsed.args.text)
break
case 'UI_CreateFile':
FilesAppIntegration.createNewFile(args.DocumentType)
break
@ -351,12 +352,11 @@ export default {
FilesAppIntegration.rename(args.NewName)
break
case 'UI_FileVersions':
case 'rev-history':
FilesAppIntegration.showRevHistory()
break
case 'App_VersionRestore':
if (args.Status === 'Pre_Restore_Ack') {
FilesAppIntegration.restoreVersionExecute()
this.handlePreRestoreAck()
}
break
case 'UI_Share':
@ -371,28 +371,31 @@ export default {
case 'Action_GetLinkPreview':
this.resolveLink(args.url)
break
case 'Clicked_Button':
this.buttonClicked(args)
break
}
},
async buttonClicked(args) {
if (args?.Id === 'Open_Local_Editor') {
this.showOpenLocalConfirmation()
}
},
},
}
</script>
<style lang="scss" scoped>
.office-viewer {
width: 100%;
height: 100%;
top: 0;
left: 0;
position: absolute;
z-index: 100000;
max-width: 100%;
display: flex;
flex-direction: column;
background-color: var(--color-main-background);
transition: opacity .25s;
&__loading-overlay {
&__loading-overlay:not(.viewer__file--hidden) {
border-top: 3px solid var(--color-primary-element);
position: absolute;
display: flex;
height: 100%;
width: 100%;
@ -414,48 +417,16 @@ export default {
}
}
&__header {
position: absolute;
right: 44px;
top: 3px;
z-index: 99999;
display: flex;
background-color: #fff;
.avatars {
display: flex;
padding: 4px;
::v-deep .avatardiv {
margin-left: -15px;
box-shadow: 0 0 3px var(--color-box-shadow);
}
}
&__icon-menu-sidebar {
background-image: var(--icon-menu-sidebar-000) !important;
}
}
&__iframe {
width: 100%;
flex-grow: 1;
}
::v-deep .fade-enter-active,
::v-deep .fade-leave-active {
transition: opacity .25s;
}
::v-deep .fade-enter,
::v-deep .fade-leave-to {
opacity: 0;
}
}
</style>
<style lang="scss">
.viewer .office-viewer {
.viewer .office-viewer:not(.viewer__file--hidden) {
width: 100%;
height: 100vh;
height: 100dvh;
top: -50px;

View file

@ -35,6 +35,7 @@ if (OCA.Viewer) {
mimes: supportedMimes,
component: Office,
theme: 'light',
canCompare: true,
})
}