Source-Code: Whiteboard TypeScript-Quellen, Live-Bundles, Excalidraw-Upstream-Constants

This commit is contained in:
Hermes Agent
2026-07-02 15:32:04 +00:00
parent 5a7db008ee
commit 6754548957
13 changed files with 7097 additions and 8 deletions
+21 -8
View File
@@ -14,16 +14,29 @@ Statt Base64 wird das Bild als Datei in Nextcloud gespeichert. Im Board-JSON ste
``` ```
whiteboard-url-refs/ whiteboard-url-refs/
├── README.md ← diese Datei ├── README.md
├── docs/ ├── docs/
│ ├── 01-reference.md ← Recap aller bestehenden Patches │ ├── 01-reference.md ← Recap aller bestehenden Patches
│ └── 02-implementierungsplan.md ← 4-Phasen-Plan + 23 Test-Gates │ └── 02-implementierungsplan.md ← 4-Phasen-Plan + 23 Test-Gates
├── patches/ ├── src/
── (kommende JS-Patches für Phase 24) ── README.md ← Source-Herkunft dokumentiert
│ ├── whiteboard-source/ ← TypeScript-Source (aus upstream)
│ │ ├── hooks/useFiles.ts ← 🔑 File-Handler (hier dataURL→URL)
│ │ ├── constants/excalidraw.ts ← Excalidraw Init-State
│ │ ├── main.ts ← Entry Point
│ │ └── vite.config.ts ← Build-Config
│ └── whiteboard-built/ ← Live JS-Bundles (gepatcht)
│ ├── NcSelect-*.chunk.mjs ← Whiteboard→Excalidraw Bridge
│ ├── percentages-*.chunk.mjs ← Excalidraw Core Constants
│ ├── image-blob-reduce-*.mjs ← Resize-Library
│ └── whiteboard-*-patch.js ← Unsere bestehenden Patches
├── upstream/
│ └── excalidraw/
│ └── constants.ts ← DEFAULT_IMAGE_OPTIONS (1440px/4MB)
├── patches/ ← (kommende JS-Patches)
└── test-scripts/ └── test-scripts/
├── generate-test-images.sh ← Testbilder (5008000px) ├── generate-test-images.sh ← Testbilder 5008000px
── verify-phase1.sh ← CORS + WebDAV prüfen ── verify-phase1.sh ← CORS + WebDAV prüfen
└── (weitere Test-Skripte pro Phase)
``` ```
## Phasen ## Phasen
+30
View File
@@ -0,0 +1,30 @@
# Source Code Reference
## whiteboard-source/
Source files from [nextcloud/whiteboard](https://github.com/nextcloud/whiteboard) (mirrored at Gitea `Hermes/whiteboard`).
| File | Purpose |
|---|---|
| `hooks/useFiles.ts` | **Kern-Datei für Phase 2**: File-Upload, `BinaryFileData`, `dataURL` → hier muss `url` rein |
| `constants/excalidraw.ts` | Excalidraw Initial-State, `files: {}` |
| `main.ts` | Entry point |
| `vite.config.ts` | Build-Konfiguration |
## whiteboard-built/
Built/bundled JS files vom **Live-System** (Nextcloud Docker Volume). Das sind die Dateien, die wir aktuell patchen.
| File | Current Patches |
|---|---|
| `NcSelect-CknHatsl.chunk.mjs` | `maxImageSizeBytes`, `maxWidthOrHeight:8192`, `imageOptions:{...}` |
| `percentages-BXMCSKIN-D6x-nnv3.chunk.mjs` | `IM=1/0`, `TM=100*1024*1024` |
| `image-blob-reduce.esm-D7YfjmiK.chunk.mjs` | Die image-blob-reduce Library (nutzt `IM` constant) |
| `whiteboard-settings.mjs` | Settings-UI: liest `maxFileSize` aus Nextcloud Config |
| `whiteboard-scroll-patch.js` | Unser Scroll-Zoom-Patch |
| `whiteboard-image-patch.js` | Unser Image-Quality-Patch |
## upstream/excalidraw/
Upstream Excalidraw-Quellcode (nicht gebundled).
| File | Purpose |
|---|---|
| `constants.ts` | `DEFAULT_IMAGE_OPTIONS` (Source of Truth für 1440px/4MB Limits) |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,74 @@
/**
* Whiteboard Image Quality Patch v5
*
* Overrides HTMLImageElement.prototype.naturalWidth/naturalHeight
* so Excalidraw's B0/resizeImageFile skips downscaling (sees dims ≤ IM=1440),
* while initializeImageDimensions gets aspect-ratio-preserving sizes.
*
* v5 fix: instead of returning flat 100x100 (which made images display as tiny
* thumbnails), return scaled dimensions that preserve aspect ratio and fit within
* IM=1440. This prevents resize AND gives correct display proportions.
*/
(function() {
'use strict';
var FAKE_MAX = 1440; // Must match Excalidraw's IM constant (maxWidthOrHeight)
// Save original getters
var origNW = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'naturalWidth');
var origNH = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'naturalHeight');
if (!origNW || !origNH) {
console.warn('[IMAGE-PATCH v5] Cannot read naturalWidth/naturalHeight descriptors, aborting');
return;
}
// Override naturalWidth on the prototype
Object.defineProperty(HTMLImageElement.prototype, 'naturalWidth', {
get: function() {
var realW = origNW.get.call(this);
if (realW <= FAKE_MAX || typeof this.src !== 'string' || !this.src.startsWith('blob:')) {
return realW;
}
// Image is large AND is a blob URL — need to check height too
var realH = origNH.get.call(this);
this.__realNW = realW;
this.__realNH = realH;
if (realH <= FAKE_MAX) {
// Height is under limit — no need to fake width either
return realW;
}
// Both dimensions over FAKE_MAX — scale down proportionally
if (realW >= realH) {
return FAKE_MAX;
} else {
return Math.round(FAKE_MAX * realW / realH);
}
},
configurable: true
});
// Override naturalHeight on the prototype
Object.defineProperty(HTMLImageElement.prototype, 'naturalHeight', {
get: function() {
var realH = origNH.get.call(this);
if (realH <= FAKE_MAX || typeof this.src !== 'string' || !this.src.startsWith('blob:')) {
return realH;
}
var realW = origNW.get.call(this);
this.__realNW = realW;
this.__realNH = realH;
if (realW <= FAKE_MAX) {
return realH;
}
if (realH >= realW) {
return FAKE_MAX;
} else {
return Math.round(FAKE_MAX * realH / realW);
}
},
configurable: true
});
console.log('[IMAGE-PATCH v5] Aspect-ratio-preserving image protection active (FAKE_MAX=' + FAKE_MAX + ')');
})();
@@ -0,0 +1,49 @@
/**
* Whiteboard Scroll Patch — Miro-like zoom behavior
* Injects ctrlKey into wheel events so plain scroll = zoom.
* Panning: middle-mouse-drag (Excalidraw natively supports this).
*/
(function() {
'use strict';
function injectScrollPatch() {
const container = document.querySelector('.excalidraw-container')
|| document.querySelector('[class*="excalidraw"]')
|| document.querySelector('.whiteboard canvas');
if (!container) {
setTimeout(injectScrollPatch, 500);
return;
}
container.addEventListener('wheel', function(e) {
if (!e.ctrlKey && !e.metaKey && !e.shiftKey && !e.altKey) {
e.stopPropagation();
e.preventDefault();
const newEvent = new WheelEvent('wheel', {
deltaX: e.deltaX,
deltaY: e.deltaY,
deltaZ: e.deltaZ,
deltaMode: e.deltaMode,
clientX: e.clientX,
clientY: e.clientY,
screenX: e.screenX,
screenY: e.screenY,
ctrlKey: true,
bubbles: true,
cancelable: true,
});
e.target.dispatchEvent(newEvent);
}
}, { passive: false, capture: true });
console.log('[SCROLL-PATCH] Miro-like zoom enabled');
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function() {
setTimeout(injectScrollPatch, 1000);
});
} else {
setTimeout(injectScrollPatch, 1000);
}
})();
File diff suppressed because one or more lines are too long
@@ -0,0 +1,16 @@
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { ExcalidrawInitialDataState } from '@nextcloud/excalidraw/types/types'
export const initialDataState: ExcalidrawInitialDataState = {
elements: [],
appState: {
currentItemFontFamily: 3,
currentItemStrokeWidth: 1,
currentItemRoughness: 0,
},
files: {},
}
+342
View File
@@ -0,0 +1,342 @@
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
/* eslint-disable no-console */
import { useCallback, useEffect, useRef } from 'react'
import {
convertToExcalidrawElements,
viewportCoordsToSceneCoords,
} from '@nextcloud/excalidraw'
import type {
BinaryFileData,
DataURL,
} from '@excalidraw/excalidraw/types/types'
import type { FileId } from '@excalidraw/excalidraw/types/element/types'
import axios from '@nextcloud/axios'
import { loadState } from '@nextcloud/initial-state'
import { useExcalidrawStore } from '../stores/useExcalidrawStore'
import { useSidebarDownload } from './useSidebarDownload'
export type Meta = {
name: string
type: string
lastModified: number
fileId: string
dataURL: string
}
export interface FileHandlerInterface {
addFile: (file: BinaryFileData) => void
sendImageFiles: (files: Record<string, BinaryFileData>) => void
}
export function useFiles(
sendImageFiles: (files: Record<string, BinaryFileData>) => Promise<void>,
) {
const { excalidrawAPI } = useExcalidrawStore()
const filesRef = useRef(new Map<string, BinaryFileData>())
const downloadFile = useCallback((meta: Meta) => {
const url = meta.dataURL
const a = document.createElement('a')
a.href = url
a.download = meta.name
a.click()
}, [])
const {
activeMeta,
showDownloadButton,
hideDownloadButton,
handleDownload,
} = useSidebarDownload(downloadFile)
const supportedTypes = [
'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',
]
const addFile = useCallback(
(file: BinaryFileData) => {
if (!excalidrawAPI) return
// Check if we already have this file with the same content
const existingFile = filesRef.current.get(file.id)
if (existingFile) {
// If the file exists and has the same dataURL, skip adding it again
// This prevents unnecessary re-renders
if (existingFile.dataURL === file.dataURL) {
// Skip adding file with same content to prevent blinking
return
}
// File exists but content changed, updating
}
// Store in our ref
filesRef.current.set(file.id, file)
// Check if the file is already in Excalidraw's files
const excalidrawFiles = excalidrawAPI.getFiles()
if (excalidrawFiles[file.id]) {
// If the file exists in Excalidraw but content changed, we need to update it
// For now, we'll just add it again and let Excalidraw handle the update
}
// Add to Excalidraw
excalidrawAPI.addFiles([file])
},
[excalidrawAPI],
)
const handleFilesDragEvent = useCallback(
(ev: DragEvent) => {
if (!excalidrawAPI || !(ev instanceof DragEvent)) return
for (const file of Array.from(ev.dataTransfer?.files || [])) {
handleFileInsert(file, ev)
}
},
[excalidrawAPI],
)
const handleFileInsert = useCallback(
(file: File, ev: Event) => {
if (!excalidrawAPI) return
const maxFileSize = loadState('whiteboard', 'maxFileSize', 10)
if (file.size > maxFileSize * 1024 * 1024) {
ev.stopImmediatePropagation()
excalidrawAPI.setToast({
message: `Max image size is ${maxFileSize} MB`,
closable: true,
duration: 5000,
})
return
}
// if excalidraw can handle it, do nothing
if (supportedTypes.includes(file.type)) {
return
}
ev.stopImmediatePropagation()
const fr = new FileReader()
fr.readAsDataURL(file)
fr.onload = () => {
if (typeof fr.result !== 'string') return
const constructedFile: BinaryFileData = {
mimeType: file.type,
created: Date.now(),
id: (Math.random() + 1).toString(36).substring(7) as FileId,
dataURL: fr.result as DataURL,
}
const meta: Meta = {
name: file.name,
type: file.type,
lastModified: file.lastModified,
fileId: constructedFile.id,
dataURL: fr.result,
}
addCustomFileElement(constructedFile, meta, ev.x, ev.y)
}
},
[excalidrawAPI],
)
const getMimeIcon = useCallback(
async (mimeType: string): Promise<FileId> => {
if (!excalidrawAPI) throw new Error('Excalidraw API not available')
let file = 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,
}
sendImageFiles({ [file.id]: file }).then(() => {
resolve(file.id)
})
}
}
reader.readAsDataURL(blob)
})
}
return file.id
},
[excalidrawAPI, sendImageFiles],
)
const addCustomFileElement = useCallback(
async (
constructedFile: BinaryFileData,
meta: Meta,
clientX: number,
clientY: number,
) => {
if (!excalidrawAPI) return
const { x, y } = viewportCoordsToSceneCoords(
{ clientX, clientY },
excalidrawAPI.getAppState(),
)
const iconId = await getMimeIcon(meta.type)
const elements = 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',
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)
excalidrawAPI.updateScene({ elements })
},
[excalidrawAPI, getMimeIcon],
)
useEffect(() => {
if (!excalidrawAPI) return
// Set up drag event listener
const containerRef = document.getElementsByClassName(
'excalidraw-container',
)[0]
if (containerRef) {
containerRef.addEventListener('drop', handleFilesDragEvent)
}
// Set up pointer down handler for file download
const pointerDownHandler = async (_activeTool, state) => {
// Always hide any existing download button first
hideDownloadButton()
const clickedElement = state.hit.element
if (!clickedElement || !clickedElement.customData) {
return
}
// Show download button for this meta
showDownloadButton(clickedElement.customData.meta)
}
excalidrawAPI.onPointerDown(pointerDownHandler)
return () => {
// Clean up event listeners
if (containerRef) {
containerRef.removeEventListener('drop', handleFilesDragEvent)
}
// Hide any download button on cleanup
hideDownloadButton()
}
}, [
excalidrawAPI,
handleFilesDragEvent,
showDownloadButton,
hideDownloadButton,
])
return {
addFile,
downloadFile,
getMimeIcon,
addCustomFileElement,
activeMeta,
handleDownload,
}
}
+547
View File
@@ -0,0 +1,547 @@
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type Vue from 'vue'
import type { ComponentOptions, CreateElement, VNode } from 'vue'
import { loadState } from '@nextcloud/initial-state'
import { linkTo } from '@nextcloud/router'
import { getSharingToken, isPublicShare } from '@nextcloud/sharing/public'
import './styles/index.scss'
import logger from './utils/logger'
import {
matchesComparisonRequest,
renderWhiteboardView,
} from './utils/renderWhiteboardView'
import type { WhiteboardRootHandle } from './utils/renderWhiteboardView'
import { callMobileMessage } from './utils/mobileInterface'
declare global {
interface Window {
EXCALIDRAW_ASSET_PATH?: string | string[]
}
}
window.EXCALIDRAW_ASSET_PATH = linkTo('whiteboard', 'dist/')
type RecordingContext = {
fileId: number
collabBackendUrl: string
jwt: string
}
type PublicShareContext = {
fileId: number
collabBackendUrl: string
sharingToken: string | null
}
type DirectEditingContext = {
fileId: number
collabBackendUrl: string
jwt: string
}
type ViewerContext = {
collabBackendUrl: string
resolveSharingToken: () => string | null
}
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
const VIEWER_REGISTRATION_DELAY_MS = 250
const bootstrapWhiteboardRuntime = (): void => {
const runtime = detectRuntime()
switch (runtime.type) {
case 'recording':
runRecordingRuntime(runtime.context)
return
case 'public-share':
runPublicShareRuntime(runtime.context)
return
case 'direct-editing':
runDirectEditingRuntime(runtime.context)
return
case 'viewer':
default:
runDefaultViewerRuntime(runtime.context)
}
}
const detectRuntime = (): RuntimeDescriptor => {
const fileId = normalizeNumericState(
loadState('whiteboard', 'file_id', '0'),
)
const collabBackendUrl = loadState('whiteboard', 'collabBackendUrl', '')
if (loadState('whiteboard', 'isRecording', false)) {
return {
type: 'recording',
context: {
fileId,
collabBackendUrl,
jwt: loadState('whiteboard', 'jwt', ''),
},
}
}
if (loadState('whiteboard', 'directEditing', false)) {
return {
type: 'direct-editing',
context: {
fileId,
collabBackendUrl,
jwt: loadState('whiteboard', 'jwt', ''),
},
}
}
if (isPublicShare()) {
return {
type: 'public-share',
context: {
fileId,
collabBackendUrl,
sharingToken: getSharingToken(),
},
}
}
return {
type: 'viewer',
context: {
collabBackendUrl,
resolveSharingToken: () => getSharingToken(),
},
}
}
function runRecordingRuntime(context: RecordingContext): void {
runWhenDomReady(async () => {
await primeRecordingJwt(context.fileId, context.jwt)
document.body.removeAttribute('id')
document.body.innerHTML = ''
const whiteboardElement = createWhiteboardElement()
whiteboardElement.classList.add('recording')
document.body.appendChild(whiteboardElement)
renderWhiteboardView(whiteboardElement, {
fileId: context.fileId,
isEmbedded: false,
fileName: '',
publicSharingToken: null,
collabBackendUrl: context.collabBackendUrl,
versionSource: null,
fileVersion: null,
})
})
}
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,
resolveSharingToken: () => context.sharingToken,
}
let hasOpenedInViewer = false
const isWhiteboardPublicShare = (): boolean => {
// LoadViewer triggers whiteboard-main on all public shares; only continue if
// this share was explicitly marked as a whiteboard file by the backend.
if (!context.fileId) {
return false
}
// On NC29/30, there's a hidden input with id="mimetype" that we can check.
// On NC31+, this element doesn't exist, so we rely on the backend-provided
// whiteboard file id check above.
const mimetypeElmt = document.getElementById('mimetype') as HTMLInputElement | null
if (mimetypeElmt && mimetypeElmt.value !== 'application/vnd.excalidraw+json') {
return false
}
return true
}
const ensurePublicShareLayout = (): void => {
document.body.classList.add('whiteboard-public-share')
}
const openInViewer = (): void => {
if (!isWhiteboardPublicShare()) {
hasOpenedInViewer = true
return
}
if (hasOpenedInViewer) {
return
}
ensurePublicShareLayout()
const viewerApi = getViewerApi()
if (!viewerApi) {
return
}
if (typeof viewerApi.openWith !== 'function' && typeof viewerApi.open !== 'function') {
return
}
hasOpenedInViewer = true
try {
viewerApi.setRootElement?.(null)
} catch {
// ignore
}
try {
if (typeof viewerApi.openWith === 'function') {
viewerApi.openWith('whiteboard', { path: '/', enableSidebar: false, canLoop: false })
return
}
viewerApi.open?.({ path: '/', enableSidebar: false, canLoop: false })
} catch (error) {
hasOpenedInViewer = false
logger.error('Could not open public share in viewer', { error })
}
}
const openEmbeddedFallback = (): void => {
if (hasOpenedInViewer) {
return
}
if (!isWhiteboardPublicShare()) {
return
}
const previewHost = document.getElementById('preview') || document.getElementById('imgframe')
if (!previewHost) {
return
}
ensurePublicShareLayout()
previewHost.innerHTML = ''
const whiteboardElement = createWhiteboardElement()
previewHost.appendChild(whiteboardElement)
renderWhiteboardView(whiteboardElement, {
fileId: context.fileId,
isEmbedded: false,
fileName: document.title,
publicSharingToken: context.sharingToken,
collabBackendUrl: context.collabBackendUrl,
versionSource: null,
fileVersion: null,
})
}
runWhenDomReady(() => {
registerViewerHandler(createWhiteboardComponent(viewerContext), 0, openInViewer)
window.setTimeout(() => {
openEmbeddedFallback()
}, 2500)
})
}
function runDefaultViewerRuntime(context: ViewerContext): void {
runWhenDomReady(() => {
registerViewerHandler(createWhiteboardComponent(context))
})
}
type ViewerComponentOptions = {
collabBackendUrl: string
resolveSharingToken: () => string | null
}
type WhiteboardComponentData = { root: WhiteboardRootHandle | null }
type WhiteboardComponentInstance = Vue &
WhiteboardComponentData & {
fileid?: number | null
fileId?: number | null
fileVersion?: string | null
source?: string | null
isEmbedded?: boolean
isComparisonView?: boolean
basename?: string
}
type VueComponentDefinition = ComponentOptions<WhiteboardComponentInstance> & {
data: () => WhiteboardComponentData
}
type ViewerHandlerRegistration = {
id: string
mimes: string[]
component: VueComponentDefinition
group: string | null
theme: string
canCompare: boolean
}
type ViewerApi = {
registerHandler?: (handler: ViewerHandlerRegistration) => void
open?: (options?: {
path?: string
fileInfo?: unknown
list?: unknown[]
enableSidebar?: boolean
loadMore?: () => unknown[]
canLoop?: boolean
onPrev?: () => void
onNext?: () => void
onClose?: () => void
}) => void
openWith?: (handlerId: string, options?: {
path?: string
fileInfo?: unknown
list?: unknown[]
enableSidebar?: boolean
loadMore?: () => unknown[]
canLoop?: boolean
onPrev?: () => void
onNext?: () => void
onClose?: () => void
}) => void
setRootElement?: (el?: string | null) => void
compareFileInfo?: unknown
}
type WindowWithViewer = Window & {
OCA?: {
Viewer?: ViewerApi
}
}
const createWhiteboardComponent = (
options: ViewerComponentOptions,
): VueComponentDefinition => ({
name: 'Whiteboard',
render(
this: WhiteboardComponentInstance,
createElement: CreateElement,
): VNode {
this.$emit('update:loaded', true)
const containerId = generateWhiteboardElementId()
this.$nextTick(() => {
const rootElement = document.getElementById(containerId)
if (!rootElement) {
return
}
rootElement.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
event.stopPropagation()
}
})
const normalizedFileId
= Number(this.fileid ?? this.fileId ?? 0) || 0
const isComparisonView = Boolean(this.isComparisonView)
const isEmbedded = Boolean(this.isEmbedded)
const rawVersionSource = this.source ?? null
const rawFileVersion = this.fileVersion ?? null
const isVersionsDavSource
= rawVersionSource?.includes('/dav/versions/')
|| rawVersionSource?.includes('/dav/trashbin/')
|| false
const shouldUseVersionPreview
= isComparisonView
|| (rawFileVersion !== null && isVersionsDavSource)
|| matchesComparisonRequest(
rawVersionSource,
rawFileVersion ?? null,
)
const versionSource = isEmbedded
? rawVersionSource
: shouldUseVersionPreview
? rawVersionSource
: null
const fileVersion = isEmbedded
? rawFileVersion
: shouldUseVersionPreview
? rawFileVersion
: null
const fileName
= typeof this.basename === 'string' ? this.basename : ''
this.root = renderWhiteboardView(rootElement, {
fileId: normalizedFileId,
isEmbedded,
fileName,
publicSharingToken: options.resolveSharingToken(),
collabBackendUrl: options.collabBackendUrl,
versionSource,
fileVersion,
isComparisonView,
})
})
return createElement(
'div',
{
attrs: { id: containerId },
class: [
'whiteboard',
{
'whiteboard-viewer__embedding': Boolean(
this.isEmbedded,
),
},
],
},
'',
)
},
beforeDestroy(this: WhiteboardComponentInstance) {
this.root?.unmount()
},
props: {
filename: { type: String, default: null },
fileid: { type: Number, default: null },
fileId: { type: Number, default: null },
fileVersion: { type: String, default: null },
source: { type: String, default: null },
isEmbedded: { type: Boolean, default: false },
isComparisonView: { type: Boolean, default: false },
},
data: (): WhiteboardComponentData => ({ root: null }),
})
const registerViewerHandler = (
component: VueComponentDefinition,
attempt = 0,
afterRegister?: () => void,
): void => {
const viewerApi = getViewerApi()
if (viewerApi?.registerHandler) {
viewerApi.registerHandler({
id: 'whiteboard',
mimes: ['application/vnd.excalidraw+json'],
component,
group: null,
theme: 'default',
canCompare: true,
})
afterRegister?.()
return
}
if (attempt >= VIEWER_REGISTRATION_ATTEMPTS) {
logger.error('Could not register whiteboard handler for viewer')
return
}
window.setTimeout(
() => registerViewerHandler(component, attempt + 1, afterRegister),
VIEWER_REGISTRATION_DELAY_MS,
)
}
const getViewerApi = (): ViewerApi | undefined =>
(window as WindowWithViewer).OCA?.Viewer
const runWhenDomReady = (callback: () => void | Promise<void>): void => {
if (document.readyState === 'loading') {
const handler = () => {
document.removeEventListener('DOMContentLoaded', handler)
callback()
}
document.addEventListener('DOMContentLoaded', handler)
return
}
callback()
}
const primeRecordingJwt = async (
fileId: number,
jwt: string,
): Promise<void> => {
if (!jwt) {
return
}
const { useJWTStore } = await import('./stores/useJwtStore')
const payload = useJWTStore.getState().parseJwt(jwt)
if (!payload) {
return
}
useJWTStore.setState((state) => ({
...state,
tokens: {
...state.tokens,
[fileId]: jwt,
},
tokenExpiries: {
...state.tokenExpiries,
[fileId]: payload.exp,
},
}))
}
const normalizeNumericState = (value: unknown): number => {
const normalized = Number(value)
return Number.isFinite(normalized) ? normalized : 0
}
const generateWhiteboardElementId = () =>
`whiteboard-${Math.random()
.toString(36)
.replace(/[^a-z]+/g, '')
.substr(2, 10)}`
const createWhiteboardElement = (id = generateWhiteboardElementId()) => {
const element = document.createElement('div')
element.id = id
element.className = 'whiteboard'
return element
}
bootstrapWhiteboardRuntime()
+82
View File
@@ -0,0 +1,82 @@
// SPDX-FileCopyrightText: Ferdinand Thiessen <opensource@fthiessen.de>
// SPDX-License-Identifier: AGPL-3.0-or-later
import { createAppConfig } from '@nextcloud/vite-config'
import react from '@vitejs/plugin-react'
import { defineConfig, normalizePath } from 'vite'
import { join, resolve } from 'path'
import { viteStaticCopy } from 'vite-plugin-static-copy'
const EXCALIDRAW_FONTS_DIR = normalizePath(resolve('node_modules/@nextcloud/excalidraw/dist/prod/fonts'))
const AppConfig = createAppConfig({
main: resolve(join('src', 'main.ts')),
settings: resolve(join('src', 'admin.ts')),
personal: resolve(join('src', 'personal.ts')),
}, {
config: defineConfig({
resolve: {
alias: [
{
find: /^@excalidraw\/element(.*)$/,
replacement: '@nextcloud/excalidraw-element$1',
},
{
find: /^@excalidraw\/excalidraw(.*)$/,
replacement: '@nextcloud/excalidraw$1',
},
],
},
build: {
cssCodeSplit: true,
chunkSizeWarningLimit: 3000,
minify: 'esbuild',
target: 'es2020',
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom'],
},
// assetFileNames: 'js/[name]-[hash].[ext]',
},
},
},
worker: {
format: 'es',
rollupOptions: {
output: {
entryFileNames: 'js/[name]-[hash].js',
},
},
},
css: {
modules: {
localsConvention: 'camelCase',
},
},
optimizeDeps: {
esbuildOptions: {
jsx: 'automatic',
},
},
esbuild: {
jsxInject: 'import React from \'react\'',
},
plugins: [
react({
jsxRuntime: 'classic',
}),
viteStaticCopy({
targets: [
{
src: EXCALIDRAW_FONTS_DIR,
dest: 'dist',
},
],
}),
],
}),
})
export default AppConfig
+555
View File
@@ -0,0 +1,555 @@
import type {
ExcalidrawElement,
FontFamilyValues,
} from "@excalidraw/element/types";
import type { AppProps, AppState } from "@excalidraw/excalidraw/types";
import { COLOR_PALETTE } from "./colors";
export const supportsResizeObserver =
typeof window !== "undefined" && "ResizeObserver" in window;
export const APP_NAME = "Excalidraw";
// distance when creating text before it's considered `autoResize: false`
// we're using higher threshold so that clicks that end up being drags
// don't unintentionally create text elements that are wrapped to a few chars
// (happens a lot with fast clicks with the text tool)
export const TEXT_AUTOWRAP_THRESHOLD = 36; // px
export const DRAGGING_THRESHOLD = 10; // px
export const MINIMUM_ARROW_SIZE = 20; // px
export const LINE_CONFIRM_THRESHOLD = 8; // px
export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
export const ELEMENT_TRANSLATE_AMOUNT = 1;
export const TEXT_TO_CENTER_SNAP_THRESHOLD = 30;
export const SHIFT_LOCKING_ANGLE = Math.PI / 12;
export const DEFAULT_LASER_COLOR = "red";
export const CURSOR_TYPE = {
TEXT: "text",
CROSSHAIR: "crosshair",
GRABBING: "grabbing",
GRAB: "grab",
POINTER: "pointer",
MOVE: "move",
AUTO: "",
};
export const POINTER_BUTTON = {
MAIN: 0,
WHEEL: 1,
SECONDARY: 2,
TOUCH: -1,
ERASER: 5,
} as const;
export const POINTER_EVENTS = {
enabled: "all",
disabled: "none",
// asserted as any so it can be freely assigned to React Element
// "pointerEnvets" CSS prop
inheritFromUI: "var(--ui-pointerEvents)" as any,
} as const;
export enum EVENT {
COPY = "copy",
PASTE = "paste",
CUT = "cut",
KEYDOWN = "keydown",
KEYUP = "keyup",
MOUSE_MOVE = "mousemove",
RESIZE = "resize",
UNLOAD = "unload",
FOCUS = "focus",
BLUR = "blur",
DRAG_OVER = "dragover",
DROP = "drop",
GESTURE_END = "gestureend",
BEFORE_UNLOAD = "beforeunload",
GESTURE_START = "gesturestart",
GESTURE_CHANGE = "gesturechange",
POINTER_MOVE = "pointermove",
POINTER_DOWN = "pointerdown",
POINTER_UP = "pointerup",
STATE_CHANGE = "statechange",
WHEEL = "wheel",
TOUCH_START = "touchstart",
TOUCH_END = "touchend",
HASHCHANGE = "hashchange",
VISIBILITY_CHANGE = "visibilitychange",
SCROLL = "scroll",
// custom events
EXCALIDRAW_LINK = "excalidraw-link",
MENU_ITEM_SELECT = "menu.itemSelect",
MESSAGE = "message",
FULLSCREENCHANGE = "fullscreenchange",
}
export const YOUTUBE_STATES = {
UNSTARTED: -1,
ENDED: 0,
PLAYING: 1,
PAUSED: 2,
BUFFERING: 3,
CUED: 5,
} as const;
export const ENV = {
TEST: "test",
DEVELOPMENT: "development",
PRODUCTION: "production",
};
export const CLASSES = {
SIDEBAR: "sidebar",
SHAPE_ACTIONS_MENU: "App-menu__left",
ZOOM_ACTIONS: "zoom-actions",
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup",
SHAPE_ACTIONS_THEME_SCOPE: "shape-actions-theme-scope",
FRAME_NAME: "frame-name",
DROPDOWN_MENU_EVENT_WRAPPER: "dropdown-menu-event-wrapper",
};
export const FONT_SIZES = {
sm: 16,
md: 20,
lg: 28,
xl: 36,
} as const;
export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai";
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
/**
* // TODO: shouldn't be really `const`, likely neither have integers as values, due to value for the custom fonts, which should likely be some hash.
*
* Let's think this through and consider:
* - https://developer.mozilla.org/en-US/docs/Web/CSS/generic-family
* - https://drafts.csswg.org/css-fonts-4/#font-family-prop
* - https://learn.microsoft.com/en-us/typography/opentype/spec/ibmfc
*/
export const FONT_FAMILY = {
Virgil: 1,
Helvetica: 2,
Cascadia: 3,
// leave 4 unused as it was historically used for Assistant (which we don't use anymore) or custom font (Obsidian)
Excalifont: 5,
Nunito: 6,
"Lilita One": 7,
"Comic Shanns": 8,
"Liberation Sans": 9,
Assistant: 10,
};
// Segoe UI Emoji fails to properly fallback for some glyphs: ∞, ∫, ≠
// so we need to have generic font fallback before it
export const SANS_SERIF_GENERIC_FONT = "sans-serif";
export const MONOSPACE_GENERIC_FONT = "monospace";
export const FONT_FAMILY_GENERIC_FALLBACKS = {
[SANS_SERIF_GENERIC_FONT]: 998,
[MONOSPACE_GENERIC_FONT]: 999,
};
export const FONT_FAMILY_FALLBACKS = {
[CJK_HAND_DRAWN_FALLBACK_FONT]: 100,
...FONT_FAMILY_GENERIC_FALLBACKS,
[WINDOWS_EMOJI_FALLBACK_FONT]: 1000,
};
export function getGenericFontFamilyFallback(
fontFamily: number,
): keyof typeof FONT_FAMILY_GENERIC_FALLBACKS {
switch (fontFamily) {
case FONT_FAMILY.Cascadia:
case FONT_FAMILY["Comic Shanns"]:
return MONOSPACE_GENERIC_FONT;
default:
return SANS_SERIF_GENERIC_FONT;
}
}
export const getFontFamilyFallbacks = (
fontFamily: number,
): Array<keyof typeof FONT_FAMILY_FALLBACKS> => {
const genericFallbackFont = getGenericFontFamilyFallback(fontFamily);
switch (fontFamily) {
case FONT_FAMILY.Excalifont:
return [
CJK_HAND_DRAWN_FALLBACK_FONT,
genericFallbackFont,
WINDOWS_EMOJI_FALLBACK_FONT,
];
default:
return [genericFallbackFont, WINDOWS_EMOJI_FALLBACK_FONT];
}
};
export const THEME = {
LIGHT: "light",
DARK: "dark",
} as const;
export const DARK_THEME_FILTER = "invert(93%) hue-rotate(180deg)";
export const FRAME_STYLE = {
strokeColor: "#bbb" as ExcalidrawElement["strokeColor"],
strokeWidth: 2 as ExcalidrawElement["strokeWidth"],
strokeStyle: "solid" as ExcalidrawElement["strokeStyle"],
fillStyle: "solid" as ExcalidrawElement["fillStyle"],
roughness: 0 as ExcalidrawElement["roughness"],
roundness: null as ExcalidrawElement["roundness"],
backgroundColor: "transparent" as ExcalidrawElement["backgroundColor"],
radius: 8,
nameOffsetY: 3,
nameColorLightTheme: "#999999",
nameColorDarkTheme: "#7a7a7a",
nameFontSize: 14,
nameLineHeight: 1.25,
};
export const MIN_FONT_SIZE = 1;
export const DEFAULT_FONT_SIZE = 20;
export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Excalifont;
export const DEFAULT_TEXT_ALIGN = "left";
export const DEFAULT_VERTICAL_ALIGN = "top";
export const DEFAULT_VERSION = "{version}";
export const DEFAULT_TRANSFORM_HANDLE_SPACING = 2;
export const SIDE_RESIZING_THRESHOLD = 2 * DEFAULT_TRANSFORM_HANDLE_SPACING;
// a small epsilon to make side resizing always take precedence
// (avoids an increase in renders and changes to tests)
export const EPSILON = 0.00001;
export const DEFAULT_COLLISION_THRESHOLD =
2 * SIDE_RESIZING_THRESHOLD - EPSILON;
export const COLOR_WHITE = "#ffffff";
export const COLOR_CHARCOAL_BLACK = "#1e1e1e";
// keep this in sync with CSS
export const COLOR_VOICE_CALL = "#a2f1a6";
export const CANVAS_ONLY_ACTIONS = ["selectAll"];
export const DEFAULT_GRID_SIZE = 20;
export const DEFAULT_GRID_STEP = 5;
export const IMAGE_MIME_TYPES = {
svg: "image/svg+xml",
png: "image/png",
jpg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
bmp: "image/bmp",
ico: "image/x-icon",
avif: "image/avif",
jfif: "image/jfif",
} as const;
export const STRING_MIME_TYPES = {
text: "text/plain",
html: "text/html",
json: "application/json",
// excalidraw data
excalidraw: "application/vnd.excalidraw+json",
excalidrawClipboard: "application/vnd.excalidraw.clipboard+json",
// LEGACY: fully-qualified library JSON data
excalidrawlib: "application/vnd.excalidrawlib+json",
// list of excalidraw library item ids
excalidrawlibIds: "application/vnd.excalidrawlib.ids+json",
} as const;
export const MIME_TYPES = {
...STRING_MIME_TYPES,
// image-encoded excalidraw data
"excalidraw.svg": "image/svg+xml",
"excalidraw.png": "image/png",
// binary
binary: "application/octet-stream",
// image
...IMAGE_MIME_TYPES,
} as const;
export const ALLOWED_PASTE_MIME_TYPES = [
MIME_TYPES.text,
MIME_TYPES.html,
...Object.values(IMAGE_MIME_TYPES),
] as const;
export const EXPORT_IMAGE_TYPES = {
png: "png",
svg: "svg",
clipboard: "clipboard",
} as const;
export const EXPORT_DATA_TYPES = {
excalidraw: "excalidraw",
excalidrawClipboard: "excalidraw/clipboard",
excalidrawLibrary: "excalidrawlib",
excalidrawClipboardWithAPI: "excalidraw-api/clipboard",
} as const;
export const getExportSource = () =>
window.EXCALIDRAW_EXPORT_SOURCE || window.location.origin;
// time in milliseconds
export const IMAGE_RENDER_TIMEOUT = 500;
export const TAP_TWICE_TIMEOUT = 300;
export const TOUCH_CTX_MENU_TIMEOUT = 500;
export const TITLE_TIMEOUT = 10000;
export const VERSION_TIMEOUT = 30000;
export const SCROLL_TIMEOUT = 100;
export const ZOOM_STEP = 0.1;
export const MIN_ZOOM = 0.1;
export const MAX_ZOOM = 30;
export const HYPERLINK_TOOLTIP_DELAY = 300;
// Report a user inactive after IDLE_THRESHOLD milliseconds
export const IDLE_THRESHOLD = 60_000;
// Report a user active each ACTIVE_THRESHOLD milliseconds
export const ACTIVE_THRESHOLD = 3_000;
export const URL_QUERY_KEYS = {
addLibrary: "addLibrary",
} as const;
export const URL_HASH_KEYS = {
addLibrary: "addLibrary",
} as const;
export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
canvasActions: {
changeViewBackgroundColor: true,
clearCanvas: true,
export: { saveFileToDisk: true },
loadScene: true,
saveToActiveFile: true,
toggleTheme: null,
saveAsImage: true,
},
tools: {
image: true,
},
};
export const MAX_DECIMALS_FOR_SVG_EXPORT = 2;
export const EXPORT_SCALES = [1, 2, 3];
export const DEFAULT_EXPORT_PADDING = 10; // px
export const DEFAULT_IMAGE_OPTIONS: AppProps["imageOptions"] = {
maxWidthOrHeight: 1440,
maxFileSizeBytes: 4 * 1024 * 1024,
};
export const SVG_NS = "http://www.w3.org/2000/svg";
export const SVG_DOCUMENT_PREAMBLE = `<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
`;
export const ENCRYPTION_KEY_BITS = 128;
export const VERSIONS = {
excalidraw: 2,
excalidrawLibrary: 2,
} as const;
export const BOUND_TEXT_PADDING = 5;
export const ARROW_LABEL_WIDTH_FRACTION = 0.7;
export const ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO = 11;
export const VERTICAL_ALIGN = {
TOP: "top",
MIDDLE: "middle",
BOTTOM: "bottom",
};
export const TEXT_ALIGN = {
LEFT: "left",
CENTER: "center",
RIGHT: "right",
};
export const ELEMENT_READY_TO_ERASE_OPACITY = 20;
// Radius represented as 25% of element's largest side (width/height).
// Used for LEGACY and PROPORTIONAL_RADIUS algorithms, or when the element is
// below the cutoff size.
export const DEFAULT_PROPORTIONAL_RADIUS = 0.25;
// Fixed radius for the ADAPTIVE_RADIUS algorithm. In pixels.
export const DEFAULT_ADAPTIVE_RADIUS = 32;
// roundness type (algorithm)
export const ROUNDNESS = {
// Used for legacy rounding (rectangles), which currently works the same
// as PROPORTIONAL_RADIUS, but we need to differentiate for UI purposes and
// forwards-compat.
LEGACY: 1,
// Used for linear elements & diamonds
PROPORTIONAL_RADIUS: 2,
// Current default algorithm for rectangles, using fixed pixel radius.
// It's working similarly to a regular border-radius, but attemps to make
// radius visually similar across differnt element sizes, especially
// very large and very small elements.
//
// NOTE right now we don't allow configuration and use a constant radius
// (see DEFAULT_ADAPTIVE_RADIUS constant)
ADAPTIVE_RADIUS: 3,
} as const;
export const ROUGHNESS = {
architect: 0,
artist: 1,
cartoonist: 2,
} as const;
export type StrokeWidthKey = "thin" | "medium" | "bold";
export const STROKE_WIDTH_KEYS: readonly StrokeWidthKey[] = [
"thin",
"medium",
"bold",
];
export const STROKE_WIDTH: Readonly<
Record<StrokeWidthKey | "extraBold", ExcalidrawElement["strokeWidth"]>
> = {
thin: 1,
medium: 2,
bold: 4,
extraBold: 8, // unused (may be introduced in the future)
};
// freedraw schema 2.0 uses thinner stroke, but to maintain backwards and
// forwards compatibility, instead of changing the shape renderer, we scale
// the stroke width by 1/2 (previous, thin was 1, medium 2 etc.)
//
// note that in the UI, STROKE_WIDTH.thin == FREEDRAW_STROKE_WIDTH.thin still
export const FREEDRAW_STROKE_WIDTH: Readonly<
Record<StrokeWidthKey | "extraBold", ExcalidrawElement["strokeWidth"]>
> = {
thin: 0.5,
medium: 1,
bold: 2,
extraBold: 4, // legacy (may be used again in the future)
};
export const getStrokeWidthByKey = (
elementType: ExcalidrawElement["type"],
strokeWidthKey: StrokeWidthKey,
): ExcalidrawElement["strokeWidth"] => {
return elementType === "freedraw"
? FREEDRAW_STROKE_WIDTH[strokeWidthKey]
: STROKE_WIDTH[strokeWidthKey];
};
export const DEFAULT_ELEMENT_STROKE_WIDTH_KEY: StrokeWidthKey = "medium";
export const DEFAULT_ELEMENT_PROPS: {
strokeColor: ExcalidrawElement["strokeColor"];
backgroundColor: ExcalidrawElement["backgroundColor"];
fillStyle: ExcalidrawElement["fillStyle"];
strokeWidth: ExcalidrawElement["strokeWidth"];
strokeStyle: ExcalidrawElement["strokeStyle"];
roughness: ExcalidrawElement["roughness"];
opacity: ExcalidrawElement["opacity"];
locked: ExcalidrawElement["locked"];
} = {
strokeColor: COLOR_PALETTE.black,
backgroundColor: COLOR_PALETTE.transparent,
fillStyle: "solid",
strokeWidth: STROKE_WIDTH[DEFAULT_ELEMENT_STROKE_WIDTH_KEY],
strokeStyle: "solid",
roughness: ROUGHNESS.artist,
opacity: 100,
locked: false,
};
export const LIBRARY_SIDEBAR_TAB = "library";
export const CANVAS_SEARCH_TAB = "search";
export const DEFAULT_SIDEBAR = {
name: "default",
defaultTab: LIBRARY_SIDEBAR_TAB,
} as const;
export const LIBRARY_DISABLED_TYPES = new Set([
"iframe",
"embeddable",
"image",
] as const);
// use these constants to easily identify reference sites
export const TOOL_TYPE = {
selection: "selection",
lasso: "lasso",
rectangle: "rectangle",
diamond: "diamond",
ellipse: "ellipse",
arrow: "arrow",
line: "line",
freedraw: "freedraw",
text: "text",
image: "image",
eraser: "eraser",
hand: "hand",
frame: "frame",
magicframe: "magicframe",
embeddable: "embeddable",
laser: "laser",
} as const;
export const EDITOR_LS_KEYS = {
OAI_API_KEY: "excalidraw-oai-api-key",
// legacy naming (non)scheme
MERMAID_TO_EXCALIDRAW: "mermaid-to-excalidraw",
PUBLISH_LIBRARY: "publish-library-data",
} as const;
/**
* not translated as this is used only in public, stateless API as default value
* where filename is optional and we can't retrieve name from app state
*/
export const DEFAULT_FILENAME = "Untitled";
export const STATS_PANELS = { generalStats: 1, elementProperties: 2 } as const;
export const MIN_WIDTH_OR_HEIGHT = 1;
export const ARROW_TYPE: { [T in AppState["currentItemArrowType"]]: T } = {
sharp: "sharp",
round: "round",
elbow: "elbow",
};
export const DEFAULT_REDUCED_GLOBAL_ALPHA = 0.3;
export const ELEMENT_LINK_KEY = "element";
/** used in tests */
export const ORIG_ID = Symbol.for("__test__originalId__");
export enum UserIdleState {
ACTIVE = "active",
AWAY = "away",
IDLE = "idle",
}
/**
* distance at which we merge points instead of adding a new merge-point
* when converting a line to a polygon (merge currently means overlaping
* the start and end points)
*/
export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20;
export const DOUBLE_TAP_POSITION_THRESHOLD = 35;
export const BIND_MODE_TIMEOUT = 700; // ms
// glass background for mobile action buttons
export const MOBILE_ACTION_BUTTON_BG = {
background: "var(--mobile-action-button-bg)",
} as const;
export const DEFAULT_STROKE_STREAMLINE = 0.5;
export const DEFAULT_STROKE_STREAMLINE_PRECISE = 0.2;