From 93f2bd7d39770e3a5278dd6493a4fe63e3bb26d8 Mon Sep 17 00:00:00 2001 From: Benjamin Frueh Date: Tue, 5 May 2026 14:55:25 +0200 Subject: [PATCH] feat: add DirectEditing for mobile app support Signed-off-by: Benjamin Frueh --- lib/AppInfo/Application.php | 3 + lib/DirectEditing/WhiteboardCreator.php | 43 ++++++++ lib/DirectEditing/WhiteboardDirectEditor.php | 97 +++++++++++++++++++ lib/Listener/RegisterDirectEditorListener.php | 37 +++++++ src/App.tsx | 7 ++ src/main.ts | 46 +++++++++ src/utils/mobileInterface.ts | 51 ++++++++++ templates/directEditing.php | 30 ++++++ 8 files changed, 314 insertions(+) create mode 100644 lib/DirectEditing/WhiteboardCreator.php create mode 100644 lib/DirectEditing/WhiteboardDirectEditor.php create mode 100644 lib/Listener/RegisterDirectEditorListener.php create mode 100644 src/utils/mobileInterface.ts create mode 100644 templates/directEditing.php diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 3ca22b1b..a241280c 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -16,12 +16,14 @@ use OCA\Whiteboard\Listener\BeforeTemplateRenderedListener; use OCA\Whiteboard\Listener\LoadTextEditorListener; use OCA\Whiteboard\Listener\LoadViewerListener; +use OCA\Whiteboard\Listener\RegisterDirectEditorListener; use OCA\Whiteboard\Listener\RegisterTemplateCreatorListener; use OCA\Whiteboard\Settings\SetupCheck; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; use OCP\AppFramework\Bootstrap\IRegistrationContext; +use OCP\DirectEditing\RegisterDirectEditorEvent; use OCP\Files\Template\ITemplateManager; use OCP\Files\Template\RegisterTemplateCreatorEvent; use OCP\IL10N; @@ -48,6 +50,7 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(LoadViewer::class, LoadTextEditorListener::class); $context->registerEventListener(RegisterTemplateCreatorEvent::class, RegisterTemplateCreatorListener::class); $context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class); + $context->registerEventListener(RegisterDirectEditorEvent::class, RegisterDirectEditorListener::class); $context->registerSetupCheck(SetupCheck::class); } diff --git a/lib/DirectEditing/WhiteboardCreator.php b/lib/DirectEditing/WhiteboardCreator.php new file mode 100644 index 00000000..ccbacfcc --- /dev/null +++ b/lib/DirectEditing/WhiteboardCreator.php @@ -0,0 +1,43 @@ +l10n->t('whiteboard'); + } + + #[\Override] + public function getExtension(): string { + return 'whiteboard'; + } + + #[\Override] + public function getMimetype(): string { + return 'application/vnd.excalidraw+json'; + } +} diff --git a/lib/DirectEditing/WhiteboardDirectEditor.php b/lib/DirectEditing/WhiteboardDirectEditor.php new file mode 100644 index 00000000..e57dc1a2 --- /dev/null +++ b/lib/DirectEditing/WhiteboardDirectEditor.php @@ -0,0 +1,97 @@ +l10n->t('Whiteboard'); + } + + #[\Override] + public function getMimetypes(): array { + return [ + 'application/vnd.excalidraw+json', + ]; + } + + #[\Override] + public function getMimetypesOptional(): array { + return []; + } + + #[\Override] + public function getCreators(): array { + return [ + new WhiteboardCreator($this->l10n), + ]; + } + + #[\Override] + public function isSecure(): bool { + return false; + } + + #[\Override] + public function open(IToken $token): Response { + $token->useTokenScope(); + + try { + $file = $token->getFile(); + + Util::addScript('whiteboard', 'whiteboard-main'); + Util::addStyle('whiteboard', 'whiteboard-main'); + + $user = $this->authenticateUserServiceFactory->create(null)->authenticate(); + $jwt = $this->jwtService->generateJWT($user, $file, false); + + $this->initialState->provideInitialState('file_id', $file->getId()); + $this->initialState->provideInitialState('directEditing', true); + $this->initialState->provideInitialState('jwt', $jwt); + $this->initialState->provideInitialState('collabBackendUrl', $this->configService->getCollabBackendUrl()); + + return new TemplateResponse(Application::APP_ID, 'directEditing', [], 'base'); + } catch (InvalidPathException|NotFoundException|UnauthorizedException) { + return new NotFoundResponse(); + } + } +} diff --git a/lib/Listener/RegisterDirectEditorListener.php b/lib/Listener/RegisterDirectEditorListener.php new file mode 100644 index 00000000..c1980958 --- /dev/null +++ b/lib/Listener/RegisterDirectEditorListener.php @@ -0,0 +1,37 @@ + */ +/** + * @psalm-suppress UndefinedClass + * @psalm-suppress MissingTemplateParam + */ +final class RegisterDirectEditorListener implements IEventListener { + + /** @psalm-suppress PossiblyUnusedMethod */ + public function __construct( + private WhiteboardDirectEditor $editor, + ) { + } + + #[\Override] + public function handle(Event $event): void { + if (!$event instanceof RegisterDirectEditorEvent) { + return; + } + $event->register($this->editor); + } +} diff --git a/src/App.tsx b/src/App.tsx index daa742c9..73b67377 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -54,6 +54,7 @@ import { VotingSidebar } from './components/VotingSidebar' import { useVoting } from './hooks/useVoting' import { useContextMenuFilter } from './hooks/useContextMenuFilter' import { useDisableExternalLibraries } from './hooks/useDisableExternalLibraries' +import { callMobileMessage } from './utils/mobileInterface' const Excalidraw = memo(ExcalidrawComponent) @@ -287,6 +288,12 @@ export default function App({ // Use the board data manager hook const { saveOnUnmount, isLoading } = useBoardDataManager() + useEffect(() => { + if (!isLoading && loadState('whiteboard', 'directEditing', false)) { + callMobileMessage('loaded') + } + }, [isLoading]) + // Effect to handle fileId changes - cleanup previous board data useEffect(() => { // Clear any existing Excalidraw data when fileId changes diff --git a/src/main.ts b/src/main.ts index 2f8b4bdd..e4842200 100644 --- a/src/main.ts +++ b/src/main.ts @@ -17,6 +17,7 @@ import { renderWhiteboardView, } from './utils/renderWhiteboardView' import type { WhiteboardRootHandle } from './utils/renderWhiteboardView' +import { callMobileMessage } from './utils/mobileInterface' declare global { interface Window { @@ -38,6 +39,12 @@ type PublicShareContext = { sharingToken: string | null } +type DirectEditingContext = { + fileId: number + collabBackendUrl: string + jwt: string +} + type ViewerContext = { collabBackendUrl: string resolveSharingToken: () => string | null @@ -46,6 +53,7 @@ type ViewerContext = { type RuntimeDescriptor = | { type: 'recording'; context: RecordingContext } | { type: 'public-share'; context: PublicShareContext } + | { type: 'direct-editing'; context: DirectEditingContext } | { type: 'viewer'; context: ViewerContext } const VIEWER_REGISTRATION_ATTEMPTS = 3 @@ -61,6 +69,9 @@ const bootstrapWhiteboardRuntime = (): void => { case 'public-share': runPublicShareRuntime(runtime.context) return + case 'direct-editing': + runDirectEditingRuntime(runtime.context) + return case 'viewer': default: runDefaultViewerRuntime(runtime.context) @@ -84,6 +95,17 @@ const detectRuntime = (): RuntimeDescriptor => { } } + if (loadState('whiteboard', 'directEditing', false)) { + return { + type: 'direct-editing', + context: { + fileId, + collabBackendUrl, + jwt: loadState('whiteboard', 'jwt', ''), + }, + } + } + if (isPublicShare()) { return { type: 'public-share', @@ -126,6 +148,30 @@ function runRecordingRuntime(context: RecordingContext): void { }) } +function runDirectEditingRuntime(context: DirectEditingContext): void { + runWhenDomReady(async () => { + await primeRecordingJwt(context.fileId, context.jwt) + + const whiteboardElement = document.getElementById('whiteboard-app') + if (!whiteboardElement) { + logger.error('Direct editing mount element not found') + return + } + + callMobileMessage('loading') + + renderWhiteboardView(whiteboardElement, { + fileId: context.fileId, + isEmbedded: false, + fileName: '', + publicSharingToken: null, + collabBackendUrl: context.collabBackendUrl, + versionSource: null, + fileVersion: null, + }) + }) +} + function runPublicShareRuntime(context: PublicShareContext): void { const viewerContext: ViewerContext = { collabBackendUrl: context.collabBackendUrl, diff --git a/src/utils/mobileInterface.ts b/src/utils/mobileInterface.ts new file mode 100644 index 00000000..26fb00c1 --- /dev/null +++ b/src/utils/mobileInterface.ts @@ -0,0 +1,51 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +declare global { + interface Window { + DirectEditingMobileInterface?: { + [key: string]: (arg?: string) => void + } + webkit?: { + messageHandlers?: { + DirectEditingMobileInterface?: { + postMessage: (message: unknown) => void + } + } + } + } +} + +export function callMobileMessage(messageName: string, attributes?: unknown): void { + let message: unknown = messageName + if (typeof attributes !== 'undefined') { + message = { + MessageName: messageName, + Values: attributes, + } + } + + let attributesString: string | null = null + try { + attributesString = JSON.stringify(attributes) + } catch { + attributesString = null + } + + if (window.DirectEditingMobileInterface + && typeof window.DirectEditingMobileInterface[messageName] === 'function') { + if (attributesString === null || typeof attributesString === 'undefined') { + window.DirectEditingMobileInterface[messageName]() + } else { + window.DirectEditingMobileInterface[messageName](attributesString) + } + } + + if (window.webkit?.messageHandlers?.DirectEditingMobileInterface) { + window.webkit.messageHandlers.DirectEditingMobileInterface.postMessage(message) + } + + window.postMessage(message) +} diff --git a/templates/directEditing.php b/templates/directEditing.php new file mode 100644 index 00000000..be9e8ea5 --- /dev/null +++ b/templates/directEditing.php @@ -0,0 +1,30 @@ + + + + +