Skip to content

Commit 0b02571

Browse files
committed
feat: add DirectEditing for mobile app support
Signed-off-by: Benjamin Frueh <benjamin.frueh@gmail.com>
1 parent e822aee commit 0b02571

8 files changed

Lines changed: 309 additions & 0 deletions

File tree

lib/AppInfo/Application.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,14 @@
1616
use OCA\Whiteboard\Listener\BeforeTemplateRenderedListener;
1717
use OCA\Whiteboard\Listener\LoadTextEditorListener;
1818
use OCA\Whiteboard\Listener\LoadViewerListener;
19+
use OCA\Whiteboard\Listener\RegisterDirectEditorListener;
1920
use OCA\Whiteboard\Listener\RegisterTemplateCreatorListener;
2021
use OCA\Whiteboard\Settings\SetupCheck;
2122
use OCP\AppFramework\App;
2223
use OCP\AppFramework\Bootstrap\IBootContext;
2324
use OCP\AppFramework\Bootstrap\IBootstrap;
2425
use OCP\AppFramework\Bootstrap\IRegistrationContext;
26+
use OCP\DirectEditing\RegisterDirectEditorEvent;
2527
use OCP\Files\Template\ITemplateManager;
2628
use OCP\Files\Template\RegisterTemplateCreatorEvent;
2729
use OCP\IL10N;
@@ -48,6 +50,7 @@ public function register(IRegistrationContext $context): void {
4850
$context->registerEventListener(LoadViewer::class, LoadTextEditorListener::class);
4951
$context->registerEventListener(RegisterTemplateCreatorEvent::class, RegisterTemplateCreatorListener::class);
5052
$context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class);
53+
$context->registerEventListener(RegisterDirectEditorEvent::class, RegisterDirectEditorListener::class);
5154
$context->registerSetupCheck(SetupCheck::class);
5255
}
5356

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\Whiteboard\DirectEditing;
11+
12+
use OCP\DirectEditing\ACreateEmpty;
13+
use OCP\IL10N;
14+
15+
class WhiteboardCreator extends ACreateEmpty {
16+
17+
public const CREATOR_ID = 'whiteboard';
18+
19+
public function __construct(
20+
private IL10N $l10n,
21+
) {
22+
}
23+
24+
#[\Override]
25+
public function getId(): string {
26+
return self::CREATOR_ID;
27+
}
28+
29+
#[\Override]
30+
public function getName(): string {
31+
return $this->l10n->t('whiteboard');
32+
}
33+
34+
#[\Override]
35+
public function getExtension(): string {
36+
return 'whiteboard';
37+
}
38+
39+
#[\Override]
40+
public function getMimetype(): string {
41+
return 'application/vnd.excalidraw+json';
42+
}
43+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\Whiteboard\DirectEditing;
11+
12+
use OCA\Whiteboard\AppInfo\Application;
13+
use OCA\Whiteboard\Exception\UnauthorizedException;
14+
use OCA\Whiteboard\Service\Authentication\AuthenticateUserServiceFactory;
15+
use OCA\Whiteboard\Service\ConfigService;
16+
use OCA\Whiteboard\Service\JWTService;
17+
use OCP\AppFramework\Http\NotFoundResponse;
18+
use OCP\AppFramework\Http\Response;
19+
use OCP\AppFramework\Http\TemplateResponse;
20+
use OCP\AppFramework\Services\IInitialState;
21+
use OCP\DirectEditing\IEditor;
22+
use OCP\DirectEditing\IToken;
23+
use OCP\Files\InvalidPathException;
24+
use OCP\Files\NotFoundException;
25+
use OCP\IL10N;
26+
use OCP\Util;
27+
28+
class WhiteboardDirectEditor implements IEditor {
29+
30+
public function __construct(
31+
private IL10N $l10n,
32+
private IInitialState $initialState,
33+
private ConfigService $configService,
34+
private JWTService $jwtService,
35+
private AuthenticateUserServiceFactory $authenticateUserServiceFactory,
36+
) {
37+
}
38+
39+
#[\Override]
40+
public function getId(): string {
41+
return Application::APP_ID;
42+
}
43+
44+
#[\Override]
45+
public function getName(): string {
46+
return $this->l10n->t('Whiteboard');
47+
}
48+
49+
#[\Override]
50+
public function getMimetypes(): array {
51+
return [
52+
'application/vnd.excalidraw+json',
53+
];
54+
}
55+
56+
#[\Override]
57+
public function getMimetypesOptional(): array {
58+
return [];
59+
}
60+
61+
#[\Override]
62+
public function getCreators(): array {
63+
return [
64+
new WhiteboardCreator($this->l10n),
65+
];
66+
}
67+
68+
#[\Override]
69+
public function isSecure(): bool {
70+
return false;
71+
}
72+
73+
#[\Override]
74+
public function open(IToken $token): Response {
75+
$token->useTokenScope();
76+
77+
try {
78+
$file = $token->getFile();
79+
80+
Util::addScript('whiteboard', 'whiteboard-main');
81+
Util::addStyle('whiteboard', 'whiteboard-main');
82+
83+
$user = $this->authenticateUserServiceFactory->create(null)->authenticate();
84+
$jwt = $this->jwtService->generateJWT($user, $file, false);
85+
86+
$this->initialState->provideInitialState('file_id', $file->getId());
87+
$this->initialState->provideInitialState('directEditing', true);
88+
$this->initialState->provideInitialState('jwt', $jwt);
89+
$this->initialState->provideInitialState('collabBackendUrl', $this->configService->getCollabBackendUrl());
90+
91+
return new TemplateResponse(Application::APP_ID, 'directEditing', [], 'base');
92+
} catch (InvalidPathException|NotFoundException|UnauthorizedException) {
93+
return new NotFoundResponse();
94+
}
95+
}
96+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\Whiteboard\Listener;
11+
12+
use OCA\Whiteboard\DirectEditing\WhiteboardDirectEditor;
13+
use OCP\DirectEditing\RegisterDirectEditorEvent;
14+
use OCP\EventDispatcher\Event;
15+
use OCP\EventDispatcher\IEventListener;
16+
17+
/** @template-implements IEventListener<Event|RegisterDirectEditorEvent> */
18+
/** @psalm-suppress PossiblyUnusedMethod */
19+
final class RegisterDirectEditorListener implements IEventListener {
20+
21+
public function __construct(
22+
private WhiteboardDirectEditor $editor,
23+
) {
24+
}
25+
26+
#[\Override]
27+
public function handle(Event $event): void {
28+
if (!$event instanceof RegisterDirectEditorEvent) {
29+
return;
30+
}
31+
$event->register($this->editor);
32+
}
33+
}

src/App.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import { VotingSidebar } from './components/VotingSidebar'
5454
import { useVoting } from './hooks/useVoting'
5555
import { useContextMenuFilter } from './hooks/useContextMenuFilter'
5656
import { useDisableExternalLibraries } from './hooks/useDisableExternalLibraries'
57+
import { callMobileMessage } from './utils/mobileInterface'
5758

5859
const Excalidraw = memo(ExcalidrawComponent)
5960

@@ -287,6 +288,12 @@ export default function App({
287288
// Use the board data manager hook
288289
const { saveOnUnmount, isLoading } = useBoardDataManager()
289290

291+
useEffect(() => {
292+
if (!isLoading && loadState('whiteboard', 'directEditing', false)) {
293+
callMobileMessage('loaded')
294+
}
295+
}, [isLoading])
296+
290297
// Effect to handle fileId changes - cleanup previous board data
291298
useEffect(() => {
292299
// Clear any existing Excalidraw data when fileId changes

src/main.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
renderWhiteboardView,
1818
} from './utils/renderWhiteboardView'
1919
import type { WhiteboardRootHandle } from './utils/renderWhiteboardView'
20+
import { callMobileMessage } from './utils/mobileInterface'
2021

2122
declare global {
2223
interface Window {
@@ -38,6 +39,12 @@ type PublicShareContext = {
3839
sharingToken: string | null
3940
}
4041

42+
type DirectEditingContext = {
43+
fileId: number
44+
collabBackendUrl: string
45+
jwt: string
46+
}
47+
4148
type ViewerContext = {
4249
collabBackendUrl: string
4350
resolveSharingToken: () => string | null
@@ -46,6 +53,7 @@ type ViewerContext = {
4653
type RuntimeDescriptor =
4754
| { type: 'recording'; context: RecordingContext }
4855
| { type: 'public-share'; context: PublicShareContext }
56+
| { type: 'direct-editing'; context: DirectEditingContext }
4957
| { type: 'viewer'; context: ViewerContext }
5058

5159
const VIEWER_REGISTRATION_ATTEMPTS = 3
@@ -61,6 +69,9 @@ const bootstrapWhiteboardRuntime = (): void => {
6169
case 'public-share':
6270
runPublicShareRuntime(runtime.context)
6371
return
72+
case 'direct-editing':
73+
runDirectEditingRuntime(runtime.context)
74+
return
6475
case 'viewer':
6576
default:
6677
runDefaultViewerRuntime(runtime.context)
@@ -84,6 +95,17 @@ const detectRuntime = (): RuntimeDescriptor => {
8495
}
8596
}
8697

98+
if (loadState('whiteboard', 'directEditing', false)) {
99+
return {
100+
type: 'direct-editing',
101+
context: {
102+
fileId,
103+
collabBackendUrl,
104+
jwt: loadState('whiteboard', 'jwt', ''),
105+
},
106+
}
107+
}
108+
87109
if (isPublicShare()) {
88110
return {
89111
type: 'public-share',
@@ -126,6 +148,30 @@ function runRecordingRuntime(context: RecordingContext): void {
126148
})
127149
}
128150

151+
function runDirectEditingRuntime(context: DirectEditingContext): void {
152+
runWhenDomReady(async () => {
153+
await primeRecordingJwt(context.fileId, context.jwt)
154+
155+
const whiteboardElement = document.getElementById('whiteboard-app')
156+
if (!whiteboardElement) {
157+
logger.error('Direct editing mount element not found')
158+
return
159+
}
160+
161+
callMobileMessage('loading')
162+
163+
renderWhiteboardView(whiteboardElement, {
164+
fileId: context.fileId,
165+
isEmbedded: false,
166+
fileName: '',
167+
publicSharingToken: null,
168+
collabBackendUrl: context.collabBackendUrl,
169+
versionSource: null,
170+
fileVersion: null,
171+
})
172+
})
173+
}
174+
129175
function runPublicShareRuntime(context: PublicShareContext): void {
130176
const viewerContext: ViewerContext = {
131177
collabBackendUrl: context.collabBackendUrl,

src/utils/mobileInterface.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
declare global {
7+
interface Window {
8+
DirectEditingMobileInterface?: {
9+
[key: string]: (arg?: string) => void
10+
}
11+
webkit?: {
12+
messageHandlers?: {
13+
DirectEditingMobileInterface?: {
14+
postMessage: (message: unknown) => void
15+
}
16+
}
17+
}
18+
}
19+
}
20+
21+
export function callMobileMessage(messageName: string, attributes?: unknown): void {
22+
let message: unknown = messageName
23+
if (typeof attributes !== 'undefined') {
24+
message = {
25+
MessageName: messageName,
26+
Values: attributes,
27+
}
28+
}
29+
30+
let attributesString: string | null = null
31+
try {
32+
attributesString = JSON.stringify(attributes)
33+
} catch {
34+
attributesString = null
35+
}
36+
37+
if (window.DirectEditingMobileInterface
38+
&& typeof window.DirectEditingMobileInterface[messageName] === 'function') {
39+
if (attributesString === null || typeof attributesString === 'undefined') {
40+
window.DirectEditingMobileInterface[messageName]()
41+
} else {
42+
window.DirectEditingMobileInterface[messageName](attributesString)
43+
}
44+
}
45+
46+
if (window.webkit?.messageHandlers?.DirectEditingMobileInterface) {
47+
window.webkit.messageHandlers.DirectEditingMobileInterface.postMessage(message)
48+
}
49+
50+
window.postMessage(message)
51+
}

templates/directEditing.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
/**
3+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
4+
* SPDX-License-Identifier: AGPL-3.0-or-later
5+
*/
6+
?>
7+
8+
<style>
9+
body {
10+
position: fixed;
11+
background-color: var(--color-main-background);
12+
}
13+
14+
#whiteboard-app {
15+
width: 100%;
16+
height: 100%;
17+
position: fixed;
18+
}
19+
20+
#body-public footer {
21+
position: static;
22+
left: auto;
23+
bottom: auto;
24+
transform: none;
25+
width: auto;
26+
max-width: none;
27+
}
28+
</style>
29+
30+
<div id="whiteboard-app" class="whiteboard"></div>

0 commit comments

Comments
 (0)