diff --git a/.gitignore b/.gitignore
index 4416880..bf3e11c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,7 @@
\.idea/
/build/
+/backup/
/js/
/dist/
/css/
diff --git a/appinfo/info.xml b/appinfo/info.xml
index 0c2a11e..c9b2bf0 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -40,7 +40,7 @@ The official whiteboard app for Nextcloud. It allows users to create and share w
https://raw.githubusercontent.com/nextcloud/whiteboard/main/screenshots/screenshot1.png
-
+
diff --git a/src/collaboration/collab.ts b/src/collaboration/collab.ts
index 8e1a471..cb545ff 100644
--- a/src/collaboration/collab.ts
+++ b/src/collaboration/collab.ts
@@ -9,6 +9,7 @@ import { Portal } from './Portal'
import { restoreElements } from '@excalidraw/excalidraw'
import { throttle } from 'lodash'
import { hashElementsVersion, reconcileElements } from './util'
+import { registerFilesHandler } from '../files/files.ts'
export class Collab {
@@ -28,6 +29,7 @@ export class Collab {
this.setViewModeEnabled = setViewModeEnabled
this.portal = new Portal(`${fileId}`, this, publicSharingToken)
+ registerFilesHandler(this.excalidrawAPI, this)
}
async startCollab() {
diff --git a/src/files/SideBarDownload.tsx b/src/files/SideBarDownload.tsx
new file mode 100644
index 0000000..2a015a7
--- /dev/null
+++ b/src/files/SideBarDownload.tsx
@@ -0,0 +1,110 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { createRoot } from 'react-dom'
+import { type JSX } from 'react'
+import { type Meta } from './files'
+import { mdiDownloadBox } from '@mdi/js'
+import { Icon } from '@mdi/react'
+
+/**
+ * renders the html button for file downloads
+ * @param meta file data
+ * @param onClick onClick callback
+ * @return {JSX.Element} rendered Button JSX
+ */
+function renderDownloadButton(meta: Meta, onClick: () => void): JSX.Element {
+ const iconUrl = window.OC.MimeType.getIconUrl(meta.type)
+ return (
+
+
+
+ {meta.name}
+
+
+
+ )
+}
+
+/**
+ * removes the download button from the sidebar
+ * makes all default excalidraw settings visible again
+ * @return {void}
+ */
+export function ResetDownloadButton() {
+ const sideBar = document.getElementsByClassName('App-menu__left')[0]
+ if (sideBar === undefined) {
+ return
+ }
+ const panelColumn = sideBar.querySelector('.panelColumn') as HTMLElement
+ if (panelColumn) {
+ panelColumn.style.display = ''
+ }
+ const downloadButton = document.getElementsByClassName('nc-download')[0]
+ sideBar.removeChild(downloadButton)
+}
+
+/**
+ * clears the excalidraw sidebar as soon as it appears
+ * inserts a download button with the file name instead
+ * @param meta file data
+ * @param onClick onClick callback
+ */
+export function InsertDownloadButton(meta: Meta, onClick: () => void) {
+ const callback = () => {
+ const sideBar = document.getElementsByClassName('App-menu__left')[0]
+ if (sideBar === undefined) {
+ return
+ }
+ observer.disconnect()
+ const newElement = document.createElement('div')
+ newElement.classList.add('nc-download')
+ const root = createRoot(newElement)
+ root.render(renderDownloadButton(meta, onClick))
+
+ // hide all excalidraw settings
+ const panelColumn = sideBar.querySelector('.panelColumn') as HTMLElement
+ if (panelColumn) {
+ panelColumn.style.display = 'none'
+ }
+
+ sideBar.appendChild(newElement)
+ }
+
+ const observer = new MutationObserver(callback)
+
+ const sideBar = document.getElementsByClassName('App-menu__left')[0]
+ if (sideBar !== undefined) {
+ callback()
+ } else {
+ observer.observe(document.body, { childList: true, subtree: true })
+ }
+}
diff --git a/src/files/files.ts b/src/files/files.ts
new file mode 100644
index 0000000..6cd37ab
--- /dev/null
+++ b/src/files/files.ts
@@ -0,0 +1,256 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import {
+ convertToExcalidrawElements,
+ viewportCoordsToSceneCoords,
+} from '@excalidraw/excalidraw'
+import type {
+ BinaryFileData,
+ DataURL,
+ ExcalidrawImperativeAPI,
+} from '@excalidraw/excalidraw/types/types'
+import { Collab } from '../collaboration/collab'
+import type { FileId } from '@excalidraw/excalidraw/types/element/types'
+import axios from '@nextcloud/axios'
+import { InsertDownloadButton, ResetDownloadButton } from './SideBarDownload'
+
+export type Meta = {
+ name: string
+ type: string
+ lastModified: number
+ fileId: string
+ dataURL: string
+}
+
+export class FileHandle {
+
+ private collab: Collab
+ private excalidrawApi: ExcalidrawImperativeAPI
+ private types: string[]
+ private openDownloadToasts: string[]
+ constructor(
+ excalidrawApi: ExcalidrawImperativeAPI,
+ collab: Collab,
+ types: string[],
+ ) {
+ this.openDownloadToasts = []
+ this.collab = collab
+ this.excalidrawApi = excalidrawApi
+ this.types = types
+ const containerRef = document.getElementsByClassName(
+ 'excalidraw-container',
+ )[0]
+ if (containerRef) {
+ containerRef.addEventListener('drop', (ev) =>
+ this.filesDragEventListener(ev),
+ )
+ }
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ this.excalidrawApi.onPointerDown(async (activeTool, state, event) => {
+ const clickedElement = state.hit.element
+ if (!clickedElement || !clickedElement.customData) {
+ ResetDownloadButton()
+ return
+ }
+ InsertDownloadButton(clickedElement.customData.meta, () =>
+ this.downloadFile(clickedElement.customData!.meta),
+ )
+ })
+ }
+
+ private downloadFile(meta: Meta) {
+ const url = meta.dataURL
+ const a = document.createElement('a')
+ a.href = url
+ a.download = meta.name
+ a.click()
+ }
+
+ private filesDragEventListener(ev: DragEvent) {
+ if (ev instanceof DragEvent) {
+ for (const file of Array.from(ev.dataTransfer?.files || [])) {
+ this.handleFileInsert(file, ev)
+ }
+ }
+ }
+
+ private handleFileInsert(file: File, ev: Event) {
+ // if excalidraw can handle it, do nothing
+ if (this.types.includes(file.type)) {
+ return
+ }
+ ev.stopImmediatePropagation()
+
+ const fr = new FileReader()
+ fr.readAsDataURL(file)
+ fr.onload = () => {
+ const constructedFile: BinaryFileData = {
+ mimeType: file.type,
+ created: Date.now(),
+ id: (Math.random() + 1).toString(36).substring(7) as FileId,
+ dataURL: fr.result as DataURL,
+ }
+ if (typeof fr.result === 'string') {
+ const meta: Meta = {
+ name: file.name,
+ type: file.type,
+ lastModified: file.lastModified,
+ fileId: constructedFile.id,
+ dataURL: fr.result,
+ }
+ this.addCustomFileElement(constructedFile, meta, ev.x, ev.y)
+ }
+ }
+ }
+
+ private async getMimeIcon(mimeType: string): Promise {
+ let file = this.excalidrawApi.getFiles()[`filetype-icon-${mimeType}`]
+ if (!file) {
+ const iconUrl = window.OC.MimeType.getIconUrl(mimeType)
+ const response = await axios.get(iconUrl, {
+ responseType: 'arraybuffer',
+ })
+ const blob = new Blob([response.data], { type: 'image/svg+xml' })
+
+ return new Promise((resolve) => {
+ const reader = new FileReader()
+ reader.onloadend = () => {
+ if (typeof reader.result === 'string') {
+ file = {
+ mimeType: blob.type,
+ id: `filetype-icon-${mimeType}` as FileId,
+ dataURL: reader.result as DataURL,
+ }
+ this.collab.portal.sendImageFiles({ [file.id]: file })
+ resolve(file.id)
+ }
+ }
+ reader.readAsDataURL(blob)
+ })
+ }
+ return file.id
+ }
+
+ private async addCustomFileElement(
+ constructedFile: BinaryFileData,
+ meta: Meta,
+ clientX: number,
+ clientY: number,
+ ) {
+ const { x, y } = viewportCoordsToSceneCoords(
+ { clientX, clientY },
+ this.excalidrawApi.getAppState(),
+ )
+ const iconId = await this.getMimeIcon(meta.type)
+ this.collab.portal.sendImageFiles({
+ [constructedFile.id]: constructedFile,
+ })
+ const elements = this.excalidrawApi
+ .getSceneElementsIncludingDeleted()
+ .slice()
+ const newElements = convertToExcalidrawElements([
+ {
+ type: 'rectangle',
+ fillStyle: 'hachure',
+ customData: { meta },
+ strokeWidth: 1,
+ strokeStyle: 'solid',
+ opacity: 30,
+ x,
+ y,
+ strokeColor: '#1e1e1e',
+ backgroundColor: '#a5d8ff',
+ width: 260.62,
+ height: 81.57,
+ seed: 1641118746,
+ groupIds: [meta.fileId],
+ roundness: {
+ type: 3,
+ },
+ },
+ {
+ type: 'image',
+ fileId: meta.fileId as FileId,
+ x: x + 28.8678679811,
+ y: y + 16.3505845419,
+ width: 1,
+ height: 1,
+ opacity: 0,
+ locked: true,
+ groupIds: [meta.fileId],
+ },
+ {
+ type: 'image',
+ fileId: iconId,
+ x: x + 28.8678679811,
+ y: y + 16.3505845419,
+ width: 48.880073102719564,
+ height: 48.880073102719564,
+ locked: true,
+ groupIds: [meta.fileId],
+ },
+ {
+ type: 'text',
+ customData: { meta },
+ isDeleted: false,
+ fillStyle: 'solid',
+ strokeWidth: 1,
+ strokeStyle: 'solid',
+ opacity: 100,
+ x: x + 85.2856430662,
+ y: y + 28.8678679811,
+ strokeColor: '#1e1e1e',
+ backgroundColor: 'transparent',
+ width: 140.625,
+ height: 24,
+ seed: 2067517530,
+ groupIds: [meta.fileId],
+ updated: 1733306011391,
+ locked: true,
+ fontSize: 20,
+ fontFamily: 3,
+ text:
+ meta.name.length > 14
+ ? meta.name.slice(0, 11) + '...'
+ : meta.name,
+ textAlign: 'left',
+ verticalAlign: 'top',
+ baseline: 20,
+ },
+ ])
+ elements.push(...newElements)
+ this.excalidrawApi.updateScene({ elements })
+ }
+
+}
+
+/**
+ * adds drop eventlistener to excalidraw
+ * uploads file to nextcloud server, to be shared with all users
+ * if filetype not supported by excalidraw inserts link to file
+ * @param {ExcalidrawImperativeAPI} excalidrawApi excalidrawApi
+ * @param collab {Collab} collab
+ */
+export function registerFilesHandler(
+ excalidrawApi: ExcalidrawImperativeAPI,
+ collab: Collab,
+): FileHandle {
+ const types = [
+ 'application/vnd.excalidraw+json',
+ 'application/vnd.excalidrawlib+json',
+ 'application/json',
+ 'image/svg+xml',
+ 'image/svg+xml',
+ 'image/png',
+ 'image/png',
+ 'image/jpeg',
+ 'image/gif',
+ 'image/webp',
+ 'image/bmp',
+ 'image/x-icon',
+ 'application/octet-stream',
+ ]
+ return new FileHandle(excalidrawApi, collab, types)
+}