Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 19 additions & 6 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -362,13 +362,26 @@ export default function App({

// UI Initialization Effect
useEffect(() => {

updateLang()
renderSmartPicker()
renderTable()
renderAssistant()
renderComment()
renderEmojiPicker()
}, [updateLang, renderSmartPicker, renderAssistant, renderEmojiPicker, renderTable])
const renderCustomElements = () => {
renderSmartPicker()
renderTable()
renderAssistant()
renderComment()
renderEmojiPicker()
}

renderCustomElements()

const excalidrawElement = document.querySelector('.excalidraw')
if (!excalidrawElement) return

const observer = new MutationObserver(renderCustomElements)
observer.observe(excalidrawElement, { attributes: true, attributeFilter: ['class'] })

return () => observer.disconnect()
}, [updateLang, renderSmartPicker, renderAssistant, renderComment, renderEmojiPicker, renderTable])

const onLibraryChange = useCallback(async (items: LibraryItems) => {
if (!isLibraryLoaded) {
Expand Down
48 changes: 48 additions & 0 deletions src/components/ToolbarButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { createRoot, type Root } from 'react-dom/client'
import { Icon } from '@mdi/react'

interface ToolbarButtonConfig {
class: string
buttonClass?: string
icon?: string
label?: string
onClick?: () => void
customContainer?: (container: HTMLElement) => void
}

export function renderToolbarButton(config: ToolbarButtonConfig): Root | null {
if (document.querySelector(`.${config.class}`)) {
return null
}

const extraToolsTrigger = document.querySelector('.App-toolbar__extra-tools-trigger')
if (!extraToolsTrigger?.parentNode) {
return null
}

const container = document.createElement('label')
container.classList.add('ToolIcon', 'Shape', config.class)
extraToolsTrigger.parentNode.insertBefore(container, extraToolsTrigger)

if (config.customContainer) {
config.customContainer(container)
return null
}

const root = createRoot(container)
root.render(
<button
className={`dropdown-menu-button ${config.buttonClass || ''}`}
aria-label={config.label}
onClick={config.onClick}
title={config.label}>
<Icon path={config.icon} size={1} />
</button>,
)
return root
}
38 changes: 9 additions & 29 deletions src/hooks/useAssistant.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,8 @@
* SPDX-License-Identifier: MIT
*/
import { useCallback } from 'react'
import { createRoot } from 'react-dom/client'
import { useExcalidrawStore } from '../stores/useExcalidrawStore'
import { useShallow } from 'zustand/react/shallow'
import { Icon } from '@mdi/react'
import { mdiCreation } from '@mdi/js'
import AssistantDialog from '../components/AssistantDialog.vue'
import Vue from 'vue'
Expand All @@ -15,6 +13,7 @@ import { getViewportCenterPoint, moveElementsToViewport } from '../utils/positio
import type { ExcalidrawElement } from '@excalidraw/excalidraw/types/element/types'
import type { ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types/types'
import { getCapabilities } from '@nextcloud/capabilities'
import { renderToolbarButton } from '../components/ToolbarButton'

export function useAssistant() {
const capabilities = getCapabilities() as { assistant?: { version: string, enabled: boolean } }
Expand Down Expand Up @@ -90,36 +89,17 @@ export function useAssistant() {
})
}, [getMermaidFromAssistant, loadToExcalidraw])

const renderAssistantButton = useCallback(() => {
return (
<button
className="dropdown-menu-button App-toolbar__extra-tools-trigger"
aria-label="Assistant"
aria-keyshortcuts="0"
onClick={() => handleAssistantToMermaid()}
title="Assistant">
<Icon path={mdiCreation} size={1} />
</button>
)
}, [handleAssistantToMermaid])

/**
* injects assistant button in toolbar, handles assistant dialog
*/
const renderAssistant = useCallback(() => {
const extraTools = document.getElementsByClassName(
'App-toolbar__extra-tools-trigger',
)[0]
const assistantButton = document.createElement('label')
assistantButton.classList.add(...['ToolIcon', 'Shape'])
if (extraTools) {
extraTools.parentNode?.insertBefore(
assistantButton,
extraTools.previousSibling,
)
const root = createRoot(assistantButton)
root.render(renderAssistantButton())
}
}, [excalidrawAPI, renderAssistantButton])
renderToolbarButton({
class: 'assistant-container',
icon: mdiCreation,
label: 'Assistant',
onClick: handleAssistantToMermaid,
})
}, [handleAssistantToMermaid])

return { renderAssistant }
}
49 changes: 13 additions & 36 deletions src/hooks/useComment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,14 @@

import { useCallback, useState, useEffect, useRef } from 'react'
import { createRoot } from 'react-dom/client'
import type { Root } from 'react-dom/client'
import { Icon } from '@mdi/react'
import { mdiCommentOutline, mdiAccount } from '@mdi/js'
import { useExcalidrawStore } from '../stores/useExcalidrawStore'
import { useShallow } from 'zustand/react/shallow'
import { viewportCoordsToSceneCoords, convertToExcalidrawElements } from '@nextcloud/excalidraw'
import { generateUrl } from '@nextcloud/router'
import { getCurrentUser } from '@nextcloud/auth'
import { CommentPopover } from '../components/CommentPopover'
import { renderToolbarButton } from '../components/ToolbarButton'
import { getRelativeTime } from '../utils/time'
import './useComment.scss'

Expand Down Expand Up @@ -239,7 +238,6 @@ export function useComment(props?: UseCommentProps) {
const [isPlacingComment, setIsPlacingComment] = useState(false)
const [pendingThread, setPendingThread] = useState<{ id: string, x: number, y: number } | null>(null)

const buttonRootRef = useRef<Root | null>(null)
const dragStateRef = useRef<DragState | null>(null)
const popoverRenderRef = useRef<(() => void) | null>(null)
const onThreadClickRef = useRef(onCommentThreadClick)
Expand Down Expand Up @@ -777,39 +775,18 @@ export function useComment(props?: UseCommentProps) {
return () => document.removeEventListener('pointerdown', handleClickOutsidePopover)
}, [activeCommentThreadId, onCommentThreadClick, cleanupEmptyThreads, pendingThread])

const renderCommentButton = useCallback(() => (
<button
className={`dropdown-menu-button comment-trigger ${isPlacingComment ? 'active' : ''}`}
aria-label="Add comment"
onClick={() => {
const renderComment = useCallback(() => {
renderToolbarButton({
class: 'comment-container',
buttonClass: 'comment-trigger',
icon: mdiCommentOutline,
label: 'Add comment',
onClick: () => {
setIsPlacingComment(true)
if (props?.onOpenSidebar) {
props.onOpenSidebar()
}
}}
title="Add comment"
>
<Icon path={mdiCommentOutline} size={1} />
</button>
), [isPlacingComment, props])

useEffect(() => {
if (!excalidrawAPI || buttonRootRef.current) return

const extraToolsButton = Array.from(
document.getElementsByClassName('App-toolbar__extra-tools-trigger'),
).find((el: Element) => !el.classList.contains('comment-trigger'))

if (!extraToolsButton) return

const buttonContainer = document.createElement('label')
buttonContainer.classList.add('ToolIcon', 'Shape', 'comment-container')
extraToolsButton.parentNode?.insertBefore(buttonContainer, extraToolsButton.previousSibling)

const root = createRoot(buttonContainer)
root.render(renderCommentButton())
buttonRootRef.current = root
}, [excalidrawAPI, renderCommentButton])
props?.onOpenSidebar?.()
},
})
}, [props])

const panToThread = useCallback((threadId: string) => {
if (!excalidrawAPI) return
Expand All @@ -836,7 +813,7 @@ export function useComment(props?: UseCommentProps) {

return {
commentThreads,
renderComment: renderCommentButton,
renderComment,
panToThread,
deleteThread,
}
Expand Down
49 changes: 19 additions & 30 deletions src/hooks/useEmojiPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import Vue from 'vue'
import { Notomoji } from '@svgmoji/noto'
import EmojiData from 'svgmoji/emoji.json'
import { imagePath } from '@nextcloud/router'
import { renderToolbarButton } from '../components/ToolbarButton'

type EmojiObj = {
native: string
Expand Down Expand Up @@ -124,38 +125,26 @@ export function useEmojiPicker() {

const hasInsertedRef = useRef(false)
const renderEmojiPicker = useCallback(() => {
if (hasInsertedRef.current) return
const toolElements = document.getElementsByClassName(
'ToolIcon Shape',
)

if (!toolElements || toolElements.length === 0) {
return
}

const lastToolEl = toolElements[toolElements.length - 1]
const emojiButton = document.createElement('label')
const div = document.createElement('div')

emojiButton.appendChild(div)
emojiButton.classList.add(...['ToolIcon', 'Shape'])
lastToolEl.parentNode?.insertBefore(
emojiButton,
lastToolEl.previousSibling,
)

const View = Vue.extend(EmojiPickerButton)
const vueComponent = new View({}).$mount(div)
vueComponent.$on('selected', (emoji: string) => {
loadToExcalidraw(emoji)
renderToolbarButton({
class: 'emoji-picker-container',
customContainer: (container) => {
const div = document.createElement('div')
container.appendChild(div)
const View = Vue.extend(EmojiPickerButton)
const vueComponent = new View({}).$mount(div)
vueComponent.$on('selected', (emoji: string) => {
loadToExcalidraw(emoji)
})
},
})

// Track cursor position for emoji placement
window.addEventListener('pointermove', (ev: PointerEvent) => {
currentCursorPos.current = { x: ev.clientX, y: ev.clientY }
})
hasInsertedRef.current = true
}, [loadToExcalidraw, currentCursorPos])
if (!hasInsertedRef.current) {
window.addEventListener('pointermove', (ev: PointerEvent) => {
currentCursorPos.current = { x: ev.clientX, y: ev.clientY }
})
hasInsertedRef.current = true
}
}, [loadToExcalidraw])

return { renderEmojiPicker }
}
44 changes: 9 additions & 35 deletions src/hooks/useSmartPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,13 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { useCallback, useRef, useEffect } from 'react'
import * as ReactDOM from 'react-dom'
import { Icon } from '@mdi/react'
import { useCallback } from 'react'
import { mdiSlashForwardBox } from '@mdi/js'
import { viewportCoordsToSceneCoords } from '@nextcloud/excalidraw'
import { getLinkWithPicker } from '@nextcloud/vue/dist/Components/NcRichText.js'
import { useExcalidrawStore } from '../stores/useExcalidrawStore'
import { useShallow } from 'zustand/react/shallow'
import { renderToolbarButton } from '../components/ToolbarButton'

export function useSmartPicker() {
const { excalidrawAPI } = useExcalidrawStore(
Expand Down Expand Up @@ -74,39 +73,14 @@ export function useSmartPicker() {
})
}, [addWebEmbed])

const renderSmartPickerButton = useCallback(() => {
return (
<button
className="dropdown-menu-button smart-picker-trigger"
aria-label="Smart picker"
aria-keyshortcuts="0"
onClick={pickFile}
title="Smart picker">
<Icon path={mdiSlashForwardBox} size={1} />
</button>
)
}, [pickFile])

const hasInsertedRef = useRef(false)
const renderSmartPicker = useCallback(() => {
if (hasInsertedRef.current) return
const extraTools = Array.from(document.getElementsByClassName('App-toolbar__extra-tools-trigger'))
.find(el => !el.classList.contains('smart-picker-trigger'))
if (!extraTools) return

const smartPick = document.createElement('label')
smartPick.classList.add('ToolIcon', 'Shape', 'smart-picker-container')
extraTools.parentNode?.insertBefore(
smartPick,
extraTools.previousSibling,
)
ReactDOM.render(renderSmartPickerButton(), smartPick)
hasInsertedRef.current = true
}, [renderSmartPickerButton])

useEffect(() => {
if (excalidrawAPI) renderSmartPicker()
}, [excalidrawAPI, renderSmartPicker])
renderToolbarButton({
class: 'smart-picker-container',
icon: mdiSlashForwardBox,
label: 'Smart picker',
onClick: pickFile,
})
}, [pickFile])

return { renderSmartPicker }
}
Loading
Loading