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
7 changes: 7 additions & 0 deletions lib/Listener/BeforeTemplateRenderedListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -28,6 +29,7 @@ class BeforeTemplateRenderedListener implements IEventListener {
public function __construct(
private IInitialState $initialState,
private ConfigService $configService,
private IEventDispatcher $eventDispatcher,
) {
}

Expand All @@ -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');

Expand Down
65 changes: 29 additions & 36 deletions src/components/TableEditorDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -228,7 +211,9 @@ export default defineComponent({
{{ t('whiteboard', 'Loading editor…') }}
</div>

<div ref="editorContainer" class="editor-container" />
<div class="editor-container">
<div ref="editorContainer" />
</div>

<div class="dialog-buttons">
<NcButton @click="onCancel">
Expand Down Expand Up @@ -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 {
Expand Down
100 changes: 86 additions & 14 deletions src/hooks/useTableInsertion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand All @@ -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)
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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<boolean> => {
// 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'
Expand All @@ -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])
Expand Down
40 changes: 30 additions & 10 deletions src/utils/tableToImage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -97,14 +105,16 @@ function applyStylesToHtml(html: string): string {
*/
async function htmlToDataUrl(html: string): Promise<string> {
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)
Expand All @@ -119,15 +129,25 @@ async function htmlToDataUrl(html: string): Promise<string> {
* @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 = `
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
<foreignObject width="100%" height="100%">
<div xmlns="http://www.w3.org/1999/xhtml" style="background: white;">
${element.innerHTML}
<foreignObject x="0" y="0" width="${width}" height="${height}">
<div xmlns="http://www.w3.org/1999/xhtml" style="background: white; padding: ${padding}px;">
${tableHtml}
</div>
</foreignObject>
</svg>
Expand Down
Loading