diff --git a/lib/Listener/BeforeTemplateRenderedListener.php b/lib/Listener/BeforeTemplateRenderedListener.php index 5ec35ec1..1b099e9b 100644 --- a/lib/Listener/BeforeTemplateRenderedListener.php +++ b/lib/Listener/BeforeTemplateRenderedListener.php @@ -14,6 +14,7 @@ use OCA\Whiteboard\Service\ConfigService; use OCP\AppFramework\Services\IInitialState; use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventDispatcher; use OCP\EventDispatcher\IEventListener; use OCP\Files\File; use OCP\Files\NotFoundException; @@ -28,6 +29,7 @@ class BeforeTemplateRenderedListener implements IEventListener { public function __construct( private IInitialState $initialState, private ConfigService $configService, + private IEventDispatcher $eventDispatcher, ) { } @@ -51,6 +53,11 @@ public function handle(Event $event): void { return; } + // Load the Text editor if available for table insertion support + if (class_exists('OCA\Text\Event\LoadEditor')) { + $this->eventDispatcher->dispatchTyped(new \OCA\Text\Event\LoadEditor()); + } + Util::addScript('whiteboard', 'whiteboard-main'); Util::addStyle('whiteboard', 'whiteboard-main'); diff --git a/src/components/TableEditorDialog.vue b/src/components/TableEditorDialog.vue index dd304876..79b26b13 100644 --- a/src/components/TableEditorDialog.vue +++ b/src/components/TableEditorDialog.vue @@ -61,10 +61,15 @@ export default defineComponent({ } // Convert HTML to markdown for the Text editor input - const contentForEditor = this.currentHtml && this.currentHtml.trim() + let contentForEditor = this.currentHtml && this.currentHtml.trim() ? this.generateMarkdownFromHtml(this.currentHtml) : '' + // If no content provided, create a minimal table with header and one body row + // This ensures the table editor recognizes it as a proper table with columns + if (!contentForEditor) { + contentForEditor = '| |\n| --- |\n| |\n' + } // Use the dedicated createTable function for table-only editing this.editor = await window.OCA.Text.createTable({ el: editorContainer, @@ -97,47 +102,25 @@ export default defineComponent({ } try { - // Extract HTML from the Text app's Tiptap editor to preserve all content (including pipe characters) - // The Text app's createTable() returns a wrapper object with a 'vm' property - // that contains the Vue instance rendering the editor component - let html = '' - - // Access the Vue instance created by the Text app - const vm = this.editor.vm - - // Navigate the component tree to find the Tiptap editor instance: - // vm.$children[0] is the Text app's table editor component - // which has an 'editor' property that is the actual Tiptap editor instance - if (vm && vm.$children && vm.$children.length > 0) { - const editorComponent = vm.$children[0] - - if (editorComponent && editorComponent.editor) { - // Get raw HTML from Tiptap editor (this is the reliable source of content) - const fullHtml = editorComponent.editor.getHTML() - const parser = new DOMParser() - const doc = parser.parseFromString(fullHtml, 'text/html') - const table = doc.querySelector('table') - - if (table) { - html = table.outerHTML - } else { - console.warn('No table found in HTML, using full HTML') - html = fullHtml - } - } + // Use Text app's getHTML() to extract clean HTML from Tiptap editor + const fullHtml = this.editor.getHTML() + if (!fullHtml) { + this.error = t('whiteboard', 'Failed to get editor content') + return } - if (!html) { - console.error('Could not extract HTML from Tiptap editor') - } + // Parse the HTML and extract just the table element + const parser = new DOMParser() + const doc = parser.parseFromString(fullHtml, 'text/html') + const table = doc.querySelector('table') - if (!html || !html.trim()) { - this.error = t('whiteboard', 'Failed to extract table content') + if (!table) { + this.error = t('whiteboard', 'No table found in editor content') return } this.$emit('submit', { - html: html.trim(), + html: table.outerHTML.trim(), }) this.show = false @@ -228,7 +211,9 @@ export default defineComponent({ {{ t('whiteboard', 'Loading editor…') }} -
+
+
+
@@ -275,6 +260,14 @@ export default defineComponent({ min-height: 400px; border-radius: var(--border-radius); overflow: hidden; + + // Hide block manipulation controls from Text editor + // These aren't needed in table-only editing mode + :deep(.floating-buttons), + :deep(.drag-handle), + :deep(.drag-button) { + display: none !important; + } } .dialog-buttons { diff --git a/src/hooks/useTableInsertion.tsx b/src/hooks/useTableInsertion.tsx index f11bd52a..9e482698 100644 --- a/src/hooks/useTableInsertion.tsx +++ b/src/hooks/useTableInsertion.tsx @@ -8,6 +8,7 @@ import Vue from 'vue' import { Icon } from '@mdi/react' import { mdiTable } from '@mdi/js' import { useExcalidrawStore } from '../stores/useExcalidrawStore' +import { useWhiteboardConfigStore } from '../stores/useWhiteboardConfigStore' import { useShallow } from 'zustand/react/shallow' import TableEditorDialog from '../components/TableEditorDialog.vue' import { convertHtmlTableToImage } from '../utils/tableToImage' @@ -25,6 +26,11 @@ export function useTableInsertion() { excalidrawAPI: state.excalidrawAPI as (ExcalidrawImperativeAPI | null), })), ) + const { isReadOnly } = useWhiteboardConfigStore( + useShallow((state) => ({ + isReadOnly: state.isReadOnly, + })), + ) // Track last click for double-click detection const lastClickRef = useRef<{ elementId: string; timestamp: number } | null>(null) @@ -71,6 +77,11 @@ export function useTableInsertion() { return } + if (isReadOnly) { + console.error('Table editing is disabled in read-only mode') + return + } + // Get the HTML from customData const initialHtml = tableElement.customData?.tableHtml as string | undefined @@ -128,7 +139,7 @@ export function useTableInsertion() { console.error('Failed to edit table:', error) } } - }, [excalidrawAPI, openTableEditor]) + }, [excalidrawAPI, openTableEditor, isReadOnly]) /** * Inserts a new table into the whiteboard at the viewport center. @@ -139,6 +150,11 @@ export function useTableInsertion() { return } + if (isReadOnly) { + console.error('Table insertion is disabled in read-only mode') + return + } + try { const tableData = await openTableEditor() const imageElement = await convertHtmlTableToImage(excalidrawAPI, tableData.html) @@ -158,7 +174,7 @@ export function useTableInsertion() { console.error('Failed to insert table:', error) } } - }, [excalidrawAPI, openTableEditor]) + }, [excalidrawAPI, openTableEditor, isReadOnly]) // Set up pointer down handler to detect double-clicks on table elements for editing useEffect(() => { @@ -211,23 +227,55 @@ export function useTableInsertion() { const hasInsertedRef = useRef(false) /** - * Injects the "Insert Table" button into Excalidraw's toolbar. + * Check if Text app is available and compatible with table insertion feature. + * + * - Checks for createTable API (permanent check - required for basic functionality) + * - Checks for getHTML() method (temporary check for older Text app versions) + * + * TODO: The getHTML() check can be removed once the latest Text app version include it. */ - const renderTable = useCallback(() => { - // Only insert once to avoid duplicate buttons - if (hasInsertedRef.current) return - - // Only show table button if Text app's createTable API is available + const checkTextAppCompatibility = async (): Promise => { + // Permanent check: Text app must be installed and provide the createTable API if (!window.OCA?.Text?.createTable) { console.warn('Table button not shown: Text app createTable API is not available') - return + return false } - // Find the extra tools trigger element in the toolbar - // We insert our button before this element + try { + const testContainer = document.createElement('div') + testContainer.style.display = 'none' + document.body.appendChild(testContainer) + + const testEditor = await window.OCA.Text.createTable({ + el: testContainer, + content: '| Test |\n| --- |\n| Test |\n', + }) + + testContainer.remove() + + // TODO: Remove this check once the latest Text app version exposes getHTML() + if (typeof testEditor?.getHTML !== 'function') { + console.warn('Table button not shown: Text app getHTML() method is not available') + return false + } + + return true + } catch (error) { + console.error('Table button not shown: Error checking Text app compatibility:', error) + return false + } + } + + /** + * Inserts the table button into the Excalidraw toolbar DOM. + */ + const insertTableButton = useCallback(() => { const extraTools = Array.from(document.getElementsByClassName('App-toolbar__extra-tools-trigger')) .find(el => !el.classList.contains('table-trigger')) - if (!extraTools) return + + if (!extraTools) { + return false + } const tableButton = document.createElement('button') tableButton.type = 'button' @@ -245,11 +293,35 @@ export function useTableInsertion() { tableButton, extraTools.previousSibling, ) - // Render the React icon component into the button ReactDOM.render(renderTableButton(), tableButton) - hasInsertedRef.current = true + return true }, [renderTableButton, insertTable]) + /** + * Injects the "Insert Table" button into Excalidraw's toolbar. + * Only renders if Text app is available and compatible. + */ + const renderTable = useCallback(async () => { + // Only insert once to avoid duplicate buttons + if (hasInsertedRef.current) return + // Set immediately to prevent race conditions with async operations + hasInsertedRef.current = true + + // Check if Text app is available and compatible + const isCompatible = await checkTextAppCompatibility() + if (!isCompatible) { + hasInsertedRef.current = false + return + } + + // Insert the button into the toolbar + const inserted = insertTableButton() + if (!inserted) { + // Toolbar not ready yet, allow retry + hasInsertedRef.current = false + } + }, [insertTableButton]) + useEffect(() => { if (excalidrawAPI) renderTable() }, [excalidrawAPI, renderTable]) diff --git a/src/utils/tableToImage.ts b/src/utils/tableToImage.ts index 4a98e819..015ffb96 100644 --- a/src/utils/tableToImage.ts +++ b/src/utils/tableToImage.ts @@ -9,7 +9,7 @@ import { convertToExcalidrawElements } from '@nextcloud/excalidraw' // Style constants - hardcoded values for static image rendering (CSS variables won't work in exported images) const CELL_BASE_STYLE = 'border: 1px solid #ddd; padding: 12px 16px; line-height: 1.4;' const HEADER_CELL_STYLE = `${CELL_BASE_STYLE} background-color: #f5f5f5; font-weight: 600; text-align: left;` -const TABLE_STYLE = 'border-collapse: collapse; font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, Arial, sans-serif; font-size: 14px; display: block;' +const TABLE_STYLE = 'border-collapse: collapse; font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, Arial, sans-serif; font-size: 14px;' /** * Convert HTML table to an image element for Excalidraw @@ -85,6 +85,14 @@ function applyStylesToHtml(html: string): string { const bodyCells = table.querySelectorAll('td') bodyCells.forEach(cell => { cell.setAttribute('style', CELL_BASE_STYLE) + // Ensure empty paragraphs don't collapse + const paragraphs = cell.querySelectorAll('p') + paragraphs.forEach(p => { + if (p instanceof HTMLElement) { + p.style.minHeight = '1.4em' + p.style.margin = '0' + } + }) }) return table.outerHTML @@ -97,14 +105,16 @@ function applyStylesToHtml(html: string): string { */ async function htmlToDataUrl(html: string): Promise { return new Promise((resolve) => { - // Create a temporary container + // Create a temporary off-screen container for measurement const container = document.createElement('div') container.innerHTML = html container.style.position = 'absolute' - container.style.backgroundColor = 'white' + container.style.left = '-9999px' + container.style.visibility = 'hidden' + document.body.appendChild(container) - // Wait for next frame to ensure rendering + // Wait for layout to complete requestAnimationFrame(() => { const svgDataUrl = createSvgDataUrl(container) document.body.removeChild(container) @@ -119,15 +129,25 @@ async function htmlToDataUrl(html: string): Promise { * @return SVG data URL */ function createSvgDataUrl(element: HTMLElement): string { - const bbox = element.getBoundingClientRect() - const width = Math.max(bbox.width) - const height = Math.max(bbox.height) + // Get the table element directly for accurate measurements + const table = element.querySelector('table') || element + + // Get bounding box of the entire table to capture all content + const bbox = table.getBoundingClientRect() + + // Add padding to prevent border/content cutoff + const padding = 4 + const width = Math.ceil(bbox.width) + (padding * 2) + const height = Math.ceil(bbox.height) + (padding * 2) + + // Get the table HTML with all our style overrides applied + const tableHtml = table.outerHTML const svg = ` - -
- ${element.innerHTML} + +
+ ${tableHtml}