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) +}