Skip to content

Commit fd18bc9

Browse files
committed
fix: centralize toolbar button rendering and fix visibility on resize
Signed-off-by: Benjamin Frueh <[email protected]>
1 parent de794b0 commit fd18bc9

File tree

8 files changed

+132
-173
lines changed

8 files changed

+132
-173
lines changed

src/App.tsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,7 @@ export default function App({
344344
renderAssistant()
345345
renderComment()
346346
renderEmojiPicker()
347-
}, [updateLang, renderSmartPicker, renderAssistant, renderEmojiPicker, renderTable])
347+
}, [updateLang, renderSmartPicker, renderAssistant, renderEmojiPicker, renderTable, renderComment])
348348

349349
const onLibraryChange = useCallback(async (items: LibraryItems) => {
350350
if (!isLibraryLoaded) {
@@ -358,6 +358,26 @@ export default function App({
358358
}
359359
}, [isLibraryLoaded])
360360

361+
useEffect(() => {
362+
const excalidrawElement = document.querySelector('.excalidraw')
363+
if (!excalidrawElement) return
364+
365+
const observer = new MutationObserver(() => {
366+
renderSmartPicker()
367+
renderTable()
368+
renderAssistant()
369+
renderComment()
370+
renderEmojiPicker()
371+
})
372+
373+
observer.observe(excalidrawElement, {
374+
attributes: true,
375+
attributeFilter: ['class'],
376+
})
377+
378+
return () => observer.disconnect()
379+
}, [renderEmojiPicker, renderSmartPicker, renderTable, renderAssistant, renderComment])
380+
361381
const libraryReturnUrl = encodeURIComponent(window.location.href)
362382

363383
// Data loading is now handled by useBoardDataManager

src/components/ToolbarButton.tsx

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { createRoot, type Root } from 'react-dom/client'
7+
import { Icon } from '@mdi/react'
8+
9+
interface ToolbarButtonConfig {
10+
class: string
11+
buttonClass?: string
12+
icon?: string
13+
label?: string
14+
onClick?: () => void
15+
customContainer?: (container: HTMLElement) => void
16+
}
17+
18+
export function renderToolbarButton(config: ToolbarButtonConfig): Root | null {
19+
if (document.querySelector(`.${config.class}`)) {
20+
return null
21+
}
22+
23+
const extraToolsTrigger = document.querySelector('.App-toolbar__extra-tools-trigger')
24+
if (!extraToolsTrigger?.parentNode) {
25+
return null
26+
}
27+
28+
const container = document.createElement('label')
29+
container.classList.add('ToolIcon', 'Shape', config.class)
30+
extraToolsTrigger.parentNode.insertBefore(container, extraToolsTrigger)
31+
32+
if (config.customContainer) {
33+
config.customContainer(container)
34+
return null
35+
}
36+
37+
const root = createRoot(container)
38+
root.render(
39+
<button
40+
className={`dropdown-menu-button ${config.buttonClass || ''}`}
41+
aria-label={config.label}
42+
onClick={config.onClick}
43+
title={config.label}>
44+
<Icon path={config.icon} size={1} />
45+
</button>,
46+
)
47+
return root
48+
}

src/hooks/useAssistant.tsx

Lines changed: 9 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,8 @@
33
* SPDX-License-Identifier: MIT
44
*/
55
import { useCallback } from 'react'
6-
import { createRoot } from 'react-dom/client'
76
import { useExcalidrawStore } from '../stores/useExcalidrawStore'
87
import { useShallow } from 'zustand/react/shallow'
9-
import { Icon } from '@mdi/react'
108
import { mdiCreation } from '@mdi/js'
119
import AssistantDialog from '../components/AssistantDialog.vue'
1210
import Vue from 'vue'
@@ -15,6 +13,7 @@ import { getViewportCenterPoint, moveElementsToViewport } from '../utils/positio
1513
import type { ExcalidrawElement } from '@excalidraw/excalidraw/types/element/types'
1614
import type { ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types/types'
1715
import { getCapabilities } from '@nextcloud/capabilities'
16+
import { renderToolbarButton } from '../components/ToolbarButton'
1817

1918
export function useAssistant() {
2019
const capabilities = getCapabilities() as { assistant?: { version: string, enabled: boolean } }
@@ -90,36 +89,17 @@ export function useAssistant() {
9089
})
9190
}, [getMermaidFromAssistant, loadToExcalidraw])
9291

93-
const renderAssistantButton = useCallback(() => {
94-
return (
95-
<button
96-
className="dropdown-menu-button App-toolbar__extra-tools-trigger"
97-
aria-label="Assistant"
98-
aria-keyshortcuts="0"
99-
onClick={() => handleAssistantToMermaid()}
100-
title="Assistant">
101-
<Icon path={mdiCreation} size={1} />
102-
</button>
103-
)
104-
}, [handleAssistantToMermaid])
105-
10692
/**
10793
* injects assistant button in toolbar, handles assistant dialog
10894
*/
10995
const renderAssistant = useCallback(() => {
110-
const extraTools = document.getElementsByClassName(
111-
'App-toolbar__extra-tools-trigger',
112-
)[0]
113-
const assistantButton = document.createElement('label')
114-
assistantButton.classList.add(...['ToolIcon', 'Shape'])
115-
if (extraTools) {
116-
extraTools.parentNode?.insertBefore(
117-
assistantButton,
118-
extraTools.previousSibling,
119-
)
120-
const root = createRoot(assistantButton)
121-
root.render(renderAssistantButton())
122-
}
123-
}, [excalidrawAPI, renderAssistantButton])
96+
renderToolbarButton({
97+
class: 'assistant-container',
98+
icon: mdiCreation,
99+
label: 'Assistant',
100+
onClick: handleAssistantToMermaid,
101+
})
102+
}, [handleAssistantToMermaid])
103+
124104
return { renderAssistant }
125105
}

src/hooks/useComment.tsx

Lines changed: 13 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,14 @@
55

66
import { useCallback, useState, useEffect, useRef } from 'react'
77
import { createRoot } from 'react-dom/client'
8-
import type { Root } from 'react-dom/client'
9-
import { Icon } from '@mdi/react'
108
import { mdiCommentOutline, mdiAccount } from '@mdi/js'
119
import { useExcalidrawStore } from '../stores/useExcalidrawStore'
1210
import { useShallow } from 'zustand/react/shallow'
1311
import { viewportCoordsToSceneCoords, convertToExcalidrawElements } from '@nextcloud/excalidraw'
1412
import { generateUrl } from '@nextcloud/router'
1513
import { getCurrentUser } from '@nextcloud/auth'
1614
import { CommentPopover } from '../components/CommentPopover'
15+
import { renderToolbarButton } from '../components/ToolbarButton'
1716
import { getRelativeTime } from '../utils/time'
1817
import './useComment.scss'
1918

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

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

780-
const renderCommentButton = useCallback(() => (
781-
<button
782-
className={`dropdown-menu-button comment-trigger ${isPlacingComment ? 'active' : ''}`}
783-
aria-label="Add comment"
784-
onClick={() => {
778+
const renderComment = useCallback(() => {
779+
renderToolbarButton({
780+
class: 'comment-container',
781+
buttonClass: 'comment-trigger',
782+
icon: mdiCommentOutline,
783+
label: 'Add comment',
784+
onClick: () => {
785785
setIsPlacingComment(true)
786-
if (props?.onOpenSidebar) {
787-
props.onOpenSidebar()
788-
}
789-
}}
790-
title="Add comment"
791-
>
792-
<Icon path={mdiCommentOutline} size={1} />
793-
</button>
794-
), [isPlacingComment, props])
795-
796-
useEffect(() => {
797-
if (!excalidrawAPI || buttonRootRef.current) return
798-
799-
const extraToolsButton = Array.from(
800-
document.getElementsByClassName('App-toolbar__extra-tools-trigger'),
801-
).find((el: Element) => !el.classList.contains('comment-trigger'))
802-
803-
if (!extraToolsButton) return
804-
805-
const buttonContainer = document.createElement('label')
806-
buttonContainer.classList.add('ToolIcon', 'Shape', 'comment-container')
807-
extraToolsButton.parentNode?.insertBefore(buttonContainer, extraToolsButton.previousSibling)
808-
809-
const root = createRoot(buttonContainer)
810-
root.render(renderCommentButton())
811-
buttonRootRef.current = root
812-
}, [excalidrawAPI, renderCommentButton])
786+
props?.onOpenSidebar?.()
787+
},
788+
})
789+
}, [props])
813790

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

837814
return {
838815
commentThreads,
839-
renderComment: renderCommentButton,
816+
renderComment,
840817
panToThread,
841818
deleteThread,
842819
}

src/hooks/useEmojiPicker.tsx

Lines changed: 19 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import Vue from 'vue'
1515
import { Notomoji } from '@svgmoji/noto'
1616
import EmojiData from 'svgmoji/emoji.json'
1717
import { imagePath } from '@nextcloud/router'
18+
import { renderToolbarButton } from '../components/ToolbarButton'
1819

1920
type EmojiObj = {
2021
native: string
@@ -124,38 +125,26 @@ export function useEmojiPicker() {
124125

125126
const hasInsertedRef = useRef(false)
126127
const renderEmojiPicker = useCallback(() => {
127-
if (hasInsertedRef.current) return
128-
const toolElements = document.getElementsByClassName(
129-
'ToolIcon_type_radio ToolIcon_size_medium',
130-
)
131-
132-
if (!toolElements || toolElements.length === 0) {
133-
return
134-
}
135-
136-
const lastToolEl = toolElements[toolElements.length - 1]
137-
const emojiButton = document.createElement('label')
138-
const div = document.createElement('div')
139-
140-
emojiButton.appendChild(div)
141-
emojiButton.classList.add(...['ToolIcon', 'Shape'])
142-
lastToolEl.parentNode?.insertBefore(
143-
emojiButton,
144-
lastToolEl.previousSibling,
145-
)
146-
147-
const View = Vue.extend(EmojiPickerButton)
148-
const vueComponent = new View({}).$mount(div)
149-
vueComponent.$on('selected', (emoji: string) => {
150-
loadToExcalidraw(emoji)
128+
renderToolbarButton({
129+
class: 'emoji-picker-container',
130+
customContainer: (container) => {
131+
const div = document.createElement('div')
132+
container.appendChild(div)
133+
const View = Vue.extend(EmojiPickerButton)
134+
const vueComponent = new View({}).$mount(div)
135+
vueComponent.$on('selected', (emoji: string) => {
136+
loadToExcalidraw(emoji)
137+
})
138+
},
151139
})
152140

153-
// Track cursor position for emoji placement
154-
window.addEventListener('pointermove', (ev: PointerEvent) => {
155-
currentCursorPos.current = { x: ev.clientX, y: ev.clientY }
156-
})
157-
hasInsertedRef.current = true
158-
}, [loadToExcalidraw, currentCursorPos])
141+
if (!hasInsertedRef.current) {
142+
window.addEventListener('pointermove', (ev: PointerEvent) => {
143+
currentCursorPos.current = { x: ev.clientX, y: ev.clientY }
144+
})
145+
hasInsertedRef.current = true
146+
}
147+
}, [loadToExcalidraw])
159148

160149
return { renderEmojiPicker }
161150
}

src/hooks/useSmartPicker.tsx

Lines changed: 9 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,13 @@
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
55

6-
import { useCallback, useRef, useEffect } from 'react'
7-
import * as ReactDOM from 'react-dom'
8-
import { Icon } from '@mdi/react'
6+
import { useCallback } from 'react'
97
import { mdiSlashForwardBox } from '@mdi/js'
108
import { viewportCoordsToSceneCoords } from '@nextcloud/excalidraw'
119
import { getLinkWithPicker } from '@nextcloud/vue/dist/Components/NcRichText.js'
1210
import { useExcalidrawStore } from '../stores/useExcalidrawStore'
1311
import { useShallow } from 'zustand/react/shallow'
12+
import { renderToolbarButton } from '../components/ToolbarButton'
1413

1514
export function useSmartPicker() {
1615
const { excalidrawAPI } = useExcalidrawStore(
@@ -74,39 +73,14 @@ export function useSmartPicker() {
7473
})
7574
}, [addWebEmbed])
7675

77-
const renderSmartPickerButton = useCallback(() => {
78-
return (
79-
<button
80-
className="dropdown-menu-button smart-picker-trigger"
81-
aria-label="Smart picker"
82-
aria-keyshortcuts="0"
83-
onClick={pickFile}
84-
title="Smart picker">
85-
<Icon path={mdiSlashForwardBox} size={1} />
86-
</button>
87-
)
88-
}, [pickFile])
89-
90-
const hasInsertedRef = useRef(false)
9176
const renderSmartPicker = useCallback(() => {
92-
if (hasInsertedRef.current) return
93-
const extraTools = Array.from(document.getElementsByClassName('App-toolbar__extra-tools-trigger'))
94-
.find(el => !el.classList.contains('smart-picker-trigger'))
95-
if (!extraTools) return
96-
97-
const smartPick = document.createElement('label')
98-
smartPick.classList.add('ToolIcon', 'Shape', 'smart-picker-container')
99-
extraTools.parentNode?.insertBefore(
100-
smartPick,
101-
extraTools.previousSibling,
102-
)
103-
ReactDOM.render(renderSmartPickerButton(), smartPick)
104-
hasInsertedRef.current = true
105-
}, [renderSmartPickerButton])
106-
107-
useEffect(() => {
108-
if (excalidrawAPI) renderSmartPicker()
109-
}, [excalidrawAPI, renderSmartPicker])
77+
renderToolbarButton({
78+
class: 'smart-picker-container',
79+
icon: mdiSlashForwardBox,
80+
label: 'Smart picker',
81+
onClick: pickFile,
82+
})
83+
}, [pickFile])
11084

11185
return { renderSmartPicker }
11286
}

0 commit comments

Comments
 (0)