From ab2f5d994d6f07d52e0c92c63efa92866161ed61 Mon Sep 17 00:00:00 2001 From: silver Date: Wed, 26 Nov 2025 17:06:54 +0100 Subject: [PATCH 01/13] integrate text editor Signed-off-by: silver --- lib/AppInfo/Application.php | 2 ++ lib/Listener/LoadTextEditorListener.php | 35 +++++++++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 lib/Listener/LoadTextEditorListener.php diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 19b5352a..d9d67a35 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -15,6 +15,7 @@ use OCA\Whiteboard\Listener\AddContentSecurityPolicyListener; use OCA\Whiteboard\Listener\BeforeTemplateRenderedListener; use OCA\Whiteboard\Listener\LoadViewerListener; +use OCA\Whiteboard\Listener\LoadTextEditorListener; use OCA\Whiteboard\Listener\RegisterTemplateCreatorListener; use OCA\Whiteboard\Settings\SetupCheck; use OCP\AppFramework\App; @@ -44,6 +45,7 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(AddContentSecurityPolicyEvent::class, AddContentSecurityPolicyListener::class); $context->registerEventListener(LoadViewer::class, LoadViewerListener::class); + $context->registerEventListener(LoadViewer::class, LoadTextEditorListener::class); $context->registerEventListener(RegisterTemplateCreatorEvent::class, RegisterTemplateCreatorListener::class); $context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class); $context->registerSetupCheck(SetupCheck::class); diff --git a/lib/Listener/LoadTextEditorListener.php b/lib/Listener/LoadTextEditorListener.php new file mode 100644 index 00000000..63ff084b --- /dev/null +++ b/lib/Listener/LoadTextEditorListener.php @@ -0,0 +1,35 @@ + */ +class LoadTextEditorListener implements IEventListener { + public function __construct( + private IEventDispatcher $eventDispatcher, + ) { + } + + #[\Override] + public function handle(Event $event): void { + if (!($event instanceof LoadViewer)) { + return; + } + + // Load the Text editor if available + if (class_exists('OCA\Text\Event\LoadEditor')) { + $this->eventDispatcher->dispatchTyped(new \OCA\Text\Event\LoadEditor()); + } + } +} From 04c8535ff61307af085fc5080a01a6402689d877 Mon Sep 17 00:00:00 2001 From: silver Date: Wed, 26 Nov 2025 17:12:43 +0100 Subject: [PATCH 02/13] feat: Add table insertion and markdown rendering utilities Implement core functionality for inserting and editing markdown tables as images in whiteboards: - Add useTableInsertion hook with double-click editing support - Add tableToImage utility for markdown-to-SVG/PNG conversion - Support inline markdown formatting (bold, italic, code, links) - Use Nextcloud Text editor integration via Vue dialogs - Preserve table markdown in customData for re-editing Signed-off-by: silver --- src/hooks/useTableInsertion.tsx | 162 ++++++++++++++++++++++ src/utils/tableToImage.ts | 238 ++++++++++++++++++++++++++++++++ 2 files changed, 400 insertions(+) create mode 100644 src/hooks/useTableInsertion.tsx create mode 100644 src/utils/tableToImage.ts diff --git a/src/hooks/useTableInsertion.tsx b/src/hooks/useTableInsertion.tsx new file mode 100644 index 00000000..6ed56c39 --- /dev/null +++ b/src/hooks/useTableInsertion.tsx @@ -0,0 +1,162 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { useCallback, useEffect, useRef } from 'react' +import Vue from 'vue' +import { useExcalidrawStore } from '../stores/useExcalidrawStore' +import { useShallow } from 'zustand/react/shallow' +import TableEditorDialog from '../components/TableEditorDialog.vue' +import { convertMarkdownTableToImage } from '../utils/tableToImage' +import { viewportCoordsToSceneCoords } from '@nextcloud/excalidraw' +import { getViewportCenterPoint, moveElementsToViewport } from '../utils/positionElementsAtViewport' +import type { ExcalidrawImperativeAPI } from '@nextcloud/excalidraw/dist/types/excalidraw/types' +import type { ExcalidrawImageElement } from '@nextcloud/excalidraw/dist/types/excalidraw/element/types' + +const DOUBLE_CLICK_THRESHOLD_MS = 500 + +export function useTableInsertion() { + const { excalidrawAPI } = useExcalidrawStore( + useShallow((state) => ({ + excalidrawAPI: state.excalidrawAPI as (ExcalidrawImperativeAPI | null), + })), + ) + + // Track last click for double-click detection + const lastClickRef = useRef<{ elementId: string; timestamp: number } | null>(null) + + /** + * Opens the table editor dialog + * Resolves Promise with markdown content after dialog is submitted + */ + const openTableEditor = useCallback((initialMarkdown?: string) => { + return new Promise<{ markdown: string }>((resolve, reject) => { + const element = document.createElement('div') + document.body.appendChild(element) + const View = Vue.extend(TableEditorDialog) + const view = new View({ + propsData: { + initialMarkdown, + }, + }).$mount(element) + + view.$on('cancel', () => { + view.$destroy() + reject(new Error('Table editor was cancelled')) + }) + + view.$on('submit', (tableData: { markdown: string }) => { + view.$destroy() + resolve(tableData) + }) + }) + }, []) + + /** + * Edits an existing table element + */ + const editTable = useCallback(async (tableElement: ExcalidrawImageElement) => { + if (!excalidrawAPI) { + console.error('Excalidraw API is not available') + return + } + + // Get the markdown from customData + const initialMarkdown = tableElement.customData?.tableMarkdown as string | undefined + if (!initialMarkdown) { + console.error('Table element does not have markdown data') + return + } + + try { + const tableData = await openTableEditor(initialMarkdown) + const newImageElement = await convertMarkdownTableToImage(tableData.markdown, excalidrawAPI) + + // Replace the existing element with the updated one + const elements = excalidrawAPI.getSceneElementsIncludingDeleted().slice() + const elementIndex = elements.findIndex(el => el.id === tableElement.id) + if (elementIndex !== -1) { + elements[elementIndex] = { + ...newImageElement, + id: tableElement.id, + x: tableElement.x, + y: tableElement.y, + angle: tableElement.angle, + } + excalidrawAPI.updateScene({ elements }) + } + } catch (error) { + if (error instanceof Error && error.message !== 'Table editor was cancelled') { + console.error('Failed to edit table:', error) + } + } + }, [excalidrawAPI, openTableEditor]) + + /** + * Inserts a table image into the whiteboard at the viewport center + */ + const insertTable = useCallback(async (initialMarkdown?: string) => { + if (!excalidrawAPI) { + console.error('Excalidraw API is not available') + return + } + + try { + const tableData = await openTableEditor(initialMarkdown) + const imageElement = await convertMarkdownTableToImage(tableData.markdown, excalidrawAPI) + + // Add the image element to the scene at the viewport center + const elements = excalidrawAPI.getSceneElementsIncludingDeleted().slice() + const movedElements = moveElementsToViewport( + [imageElement], + viewportCoordsToSceneCoords(getViewportCenterPoint(), excalidrawAPI.getAppState()), + ) + elements.push(...movedElements) + + excalidrawAPI.updateScene({ elements }) + } catch (error) { + if (error instanceof Error && error.message !== 'Table editor was cancelled') { + console.error('Failed to insert table:', error) + } + } + }, [excalidrawAPI, openTableEditor]) + + // Set up pointer down handler to detect clicks on table elements + useEffect(() => { + if (!excalidrawAPI) return + + // activeTool: current tool (selection, rectangle, etc.) - unused but required by API signature + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const pointerDownHandler = (_activeTool: any, state: any) => { + const clickedElement = state.hit?.element + if (!clickedElement || !clickedElement.customData) { + return + } + + // Check if this is a table element + if (clickedElement.customData.isTable && clickedElement.type === 'image') { + // Double-click detection: check if it's a quick second click + const now = Date.now() + const lastClick = lastClickRef.current + const isDoubleClick = lastClick + && lastClick.elementId === clickedElement.id + && now - lastClick.timestamp < DOUBLE_CLICK_THRESHOLD_MS + + if (isDoubleClick) { + // Double-click detected - fire and forget + editTable(clickedElement).catch((error) => { + console.error('Error editing table:', error) + }) + lastClickRef.current = null + } else { + // First click + lastClickRef.current = { elementId: clickedElement.id, timestamp: now } + } + } + } + + excalidrawAPI.onPointerDown(pointerDownHandler) + }, [excalidrawAPI, editTable]) + + return { insertTable } +} diff --git a/src/utils/tableToImage.ts b/src/utils/tableToImage.ts new file mode 100644 index 00000000..fa2b7ed1 --- /dev/null +++ b/src/utils/tableToImage.ts @@ -0,0 +1,238 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { ExcalidrawImperativeAPI, BinaryFileData, DataURL } from '@nextcloud/excalidraw/dist/types/excalidraw/types' +import type { FileId, ExcalidrawImageElement } from '@nextcloud/excalidraw/dist/types/excalidraw/element/types' +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;' +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;' +const CODE_STYLE = 'background-color: #f5f5f5; padding: 2px 4px; border-radius: 3px; font-family: monospace; font-size: 0.9em;' +const LINK_STYLE = 'color: #00679e; text-decoration: none;' + +/** + * Convert markdown table to an image element for Excalidraw + * @param markdown - The markdown table content + * @param excalidrawAPI - The Excalidraw API instance + * @return The image element to be added to the canvas + */ +export async function convertMarkdownTableToImage( + markdown: string, + excalidrawAPI: ExcalidrawImperativeAPI, +): Promise { + // Render the markdown table to HTML + const html = await renderMarkdownToHtml(markdown) + + // Convert HTML to canvas/image + const dataUrl = await htmlToDataUrl(html) + + // Get dimensions from the rendered content + const { width, height } = await getImageDimensions(dataUrl) + + // Create file data for Excalidraw + const fileId = generateFileId() as FileId + const file: BinaryFileData = { + mimeType: 'image/png', + id: fileId, + dataURL: dataUrl as DataURL, + created: Date.now(), + } + + // Add file to excalidraw + excalidrawAPI.addFiles([file]) + + // Create image element using convertToExcalidrawElements to ensure proper structure + const elements = convertToExcalidrawElements([ + { + type: 'image', + fileId, + x: 0, + y: 0, + width, + height, + // Store original markdown for re-editing + customData: { + tableMarkdown: markdown, + isTable: true, + }, + }, + ]) + + return elements[0] as ExcalidrawImageElement +} + +/** + * Render markdown to HTML using a simple markdown parser + * @param markdown - The markdown table content + * @return HTML string + */ +async function renderMarkdownToHtml(markdown: string): Promise { + // Parse markdown table to HTML + const lines = markdown.trim().split('\n') + if (lines.length < 2) { + throw new Error('Invalid table format') + } + + let html = `` + + // Parse header + const headerCells = lines[0].split('|').filter(cell => cell.trim()) + html += '' + headerCells.forEach(cell => { + html += `` + }) + html += '' + + // Skip separator line (index 1) + // Parse body rows + html += '' + for (let i = 2; i < lines.length; i++) { + const cells = lines[i].split('|').filter(cell => cell.trim()) + if (cells.length > 0) { + html += '' + cells.forEach(cell => { + html += `` + }) + html += '' + } + } + html += '
${parseInlineMarkdown(cell.trim())}
${parseInlineMarkdown(cell.trim())}
' + + return html +} + +/** + * Parse inline markdown formatting (bold, italic, code, strikethrough, etc.) + * @param text - The text to parse + * @return HTML string with inline formatting + */ +function parseInlineMarkdown(text: string): string { + let result = text + + // Escape HTML special characters first (except & which might be part of existing entities) + result = result + .replace(//g, '>') + + // Bold with ** or __ + result = result.replace(/\*\*(.+?)\*\*/g, '$1') + result = result.replace(/__(.+?)__/g, '$1') + + // Italic with * or _ + result = result.replace(/\*(.+?)\*/g, '$1') + result = result.replace(/_(.+?)_/g, '$1') + + // Strikethrough with ~~ + result = result.replace(/~~(.+?)~~/g, '$1') + + // Inline code with ` + result = result.replace(/`(.+?)`/g, `$1`) + + // Links [text](url) + result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, `$1`) + + return result +} + +/** + * Convert HTML to a data URL using canvas rendering + * @param html - The HTML content to convert + * @return Data URL of the rendered image + */ +async function htmlToDataUrl(html: string): Promise { + return new Promise((resolve, reject) => { + // Create a temporary container + const container = document.createElement('div') + container.innerHTML = html + container.style.position = 'absolute' + container.style.left = '-9999px' + container.style.top = '-9999px' + container.style.padding = '16px' + container.style.backgroundColor = 'white' + document.body.appendChild(container) + + // Wait for next frame to ensure rendering + requestAnimationFrame(() => { + const cleanup = () => document.body.removeChild(container) + try { + // Use html2canvas if available, otherwise use a simple SVG approach + if (typeof window.html2canvas === 'function') { + window.html2canvas(container).then(canvas => { + const dataUrl = canvas.toDataURL('image/png') + cleanup() + resolve(dataUrl) + }).catch(error => { + cleanup() + reject(error) + }) + } else { + // Fallback: create SVG with foreignObject + const svgDataUrl = createSvgDataUrl(container) + cleanup() + resolve(svgDataUrl) + } + } catch (error) { + cleanup() + reject(error) + } + }) + }) +} + +/** + * Create an SVG data URL with foreignObject containing the HTML + * @param element - The HTML element to convert + * @return SVG data URL + */ +function createSvgDataUrl(element: HTMLElement): string { + const bbox = element.getBoundingClientRect() + const width = Math.max(bbox.width, 400) + const height = Math.max(bbox.height, 200) + + const svg = ` + + +
+ ${element.innerHTML} +
+
+
+ ` + + // Encode SVG to base64 - using TextEncoder for proper UTF-8 handling + const bytes = new TextEncoder().encode(svg) + const base64 = btoa(String.fromCharCode(...bytes)) + return 'data:image/svg+xml;base64,' + base64 +} + +/** + * Get image dimensions from data URL + * @param dataUrl - The data URL of the image + * @return Object with width and height + */ +async function getImageDimensions(dataUrl: string): Promise<{ width: number; height: number }> { + return new Promise((resolve, reject) => { + const img = new Image() + img.onload = () => { + resolve({ width: img.width, height: img.height }) + } + img.onerror = reject + img.src = dataUrl + }) +} + +/** + * Generate a unique file ID + */ +function generateFileId(): string { + return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15) +} + +declare global { + interface Window { + html2canvas?: (element: HTMLElement) => Promise + } +} From ba5c2d3497efd8c82fbe9e9ffa646a99cacd0868 Mon Sep 17 00:00:00 2001 From: silver Date: Wed, 26 Nov 2025 17:16:02 +0100 Subject: [PATCH 03/13] feat: Add table editor UI Integrate table functionality into the whiteboard interface: - Add "Insert table" menu item to Excalidraw menu with mdiTable icon - Create TableEditorDialog using Nextcloud Text editor component Requires Text app to be installed and enabled for table editing. Signed-off-by: silver --- src/components/ExcalidrawMenu.tsx | 10 +- src/components/TableEditorDialog.vue | 222 +++++++++++++++++++++++++++ 2 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 src/components/TableEditorDialog.vue diff --git a/src/components/ExcalidrawMenu.tsx b/src/components/ExcalidrawMenu.tsx index 867a5662..6482f2c8 100644 --- a/src/components/ExcalidrawMenu.tsx +++ b/src/components/ExcalidrawMenu.tsx @@ -6,7 +6,7 @@ import { useCallback, useEffect, useRef, memo } from 'react' import type { KeyboardEvent as ReactKeyboardEvent } from 'react' import { Icon } from '@mdi/react' -import { mdiMonitorScreenshot, mdiImageMultiple, mdiTimerOutline } from '@mdi/js' +import { mdiMonitorScreenshot, mdiImageMultiple, mdiTimerOutline, mdiTable} from '@mdi/js' import { MainMenu, CaptureUpdateAction } from '@nextcloud/excalidraw' import { RecordingMenuItem } from './Recording' import { PresentationMenuItem } from './Presentation' @@ -17,6 +17,7 @@ import type { ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types/types import { useExcalidrawStore } from '../stores/useExcalidrawStore' import type { RecordingHookState } from '../types/recording' import type { PresentationState } from '../types/presentation' +import { useTableInsertion } from '../hooks/useTableInsertion' interface ExcalidrawMenuProps { fileNameWithoutExtension: string @@ -31,6 +32,7 @@ export const ExcalidrawMenu = memo(function ExcalidrawMenu({ fileNameWithoutExte const { excalidrawAPI } = useExcalidrawStore(useShallow(state => ({ excalidrawAPI: state.excalidrawAPI, }))) + const { insertTable } = useTableInsertion() const openExportDialog = useCallback(() => { // Trigger export by dispatching the keyboard shortcut to the Excalidraw canvas @@ -152,6 +154,12 @@ export const ExcalidrawMenu = memo(function ExcalidrawMenu({ fileNameWithoutExte + } + onSelect={() => insertTable()}> + {t('whiteboard', 'Insert table')} + + } onSelect={openExportDialog} diff --git a/src/components/TableEditorDialog.vue b/src/components/TableEditorDialog.vue new file mode 100644 index 00000000..626d00ca --- /dev/null +++ b/src/components/TableEditorDialog.vue @@ -0,0 +1,222 @@ + + + + + + From 2a7e2841aabeb77b1c0c7fce426e28007404cea0 Mon Sep 17 00:00:00 2001 From: silver Date: Mon, 1 Dec 2025 14:24:51 +0100 Subject: [PATCH 04/13] remove html2canvas Signed-off-by: silver --- src/utils/tableToImage.ts | 37 ++++++------------------------------- 1 file changed, 6 insertions(+), 31 deletions(-) diff --git a/src/utils/tableToImage.ts b/src/utils/tableToImage.ts index fa2b7ed1..d9f3d1f8 100644 --- a/src/utils/tableToImage.ts +++ b/src/utils/tableToImage.ts @@ -138,12 +138,12 @@ function parseInlineMarkdown(text: string): string { } /** - * Convert HTML to a data URL using canvas rendering + * Convert HTML to an SVG data URL * @param html - The HTML content to convert - * @return Data URL of the rendered image + * @return SVG data URL of the rendered image */ async function htmlToDataUrl(html: string): Promise { - return new Promise((resolve, reject) => { + return new Promise((resolve) => { // Create a temporary container const container = document.createElement('div') container.innerHTML = html @@ -156,28 +156,9 @@ async function htmlToDataUrl(html: string): Promise { // Wait for next frame to ensure rendering requestAnimationFrame(() => { - const cleanup = () => document.body.removeChild(container) - try { - // Use html2canvas if available, otherwise use a simple SVG approach - if (typeof window.html2canvas === 'function') { - window.html2canvas(container).then(canvas => { - const dataUrl = canvas.toDataURL('image/png') - cleanup() - resolve(dataUrl) - }).catch(error => { - cleanup() - reject(error) - }) - } else { - // Fallback: create SVG with foreignObject - const svgDataUrl = createSvgDataUrl(container) - cleanup() - resolve(svgDataUrl) - } - } catch (error) { - cleanup() - reject(error) - } + const svgDataUrl = createSvgDataUrl(container) + document.body.removeChild(container) + resolve(svgDataUrl) }) }) } @@ -230,9 +211,3 @@ async function getImageDimensions(dataUrl: string): Promise<{ width: number; hei function generateFileId(): string { return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15) } - -declare global { - interface Window { - html2canvas?: (element: HTMLElement) => Promise - } -} From d536afcda05a7df433a0ab4cf11e3092f470c3d0 Mon Sep 17 00:00:00 2001 From: silver Date: Mon, 1 Dec 2025 14:25:25 +0100 Subject: [PATCH 05/13] add hint about all content being rendered as table elements Signed-off-by: silver --- src/components/ExcalidrawMenu.tsx | 2 -- src/components/TableEditorDialog.vue | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/components/ExcalidrawMenu.tsx b/src/components/ExcalidrawMenu.tsx index 6482f2c8..ab721e0b 100644 --- a/src/components/ExcalidrawMenu.tsx +++ b/src/components/ExcalidrawMenu.tsx @@ -153,13 +153,11 @@ export const ExcalidrawMenu = memo(function ExcalidrawMenu({ fileNameWithoutExte - } onSelect={() => insertTable()}> {t('whiteboard', 'Insert table')} - } onSelect={openExportDialog} diff --git a/src/components/TableEditorDialog.vue b/src/components/TableEditorDialog.vue index 626d00ca..2dfe0026 100644 --- a/src/components/TableEditorDialog.vue +++ b/src/components/TableEditorDialog.vue @@ -150,7 +150,7 @@ export default defineComponent({

{{ isEditing ? t('whiteboard', 'Edit Table') : t('whiteboard', 'Insert Table') }}

-

{{ t('whiteboard', 'Use the table feature in the editor to create or edit your table') }}

+

{{ t('whiteboard', 'All content will be rendered as table rows.') }}

From fb2e0cc149acb912c3b5ec90426f9a628567b3db Mon Sep 17 00:00:00 2001 From: silver Date: Mon, 1 Dec 2025 15:08:20 +0100 Subject: [PATCH 06/13] allow empty cells Signed-off-by: silver --- src/utils/tableToImage.ts | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/src/utils/tableToImage.ts b/src/utils/tableToImage.ts index d9f3d1f8..8d88be5b 100644 --- a/src/utils/tableToImage.ts +++ b/src/utils/tableToImage.ts @@ -79,7 +79,7 @@ async function renderMarkdownToHtml(markdown: string): Promise { let html = `` // Parse header - const headerCells = lines[0].split('|').filter(cell => cell.trim()) + const headerCells = lines[0].split('|').slice(1, -1) // Remove first and last empty strings from pipes to allow empty cells html += '' headerCells.forEach(cell => { html += `` @@ -90,11 +90,29 @@ async function renderMarkdownToHtml(markdown: string): Promise { // Parse body rows html += '' for (let i = 2; i < lines.length; i++) { - const cells = lines[i].split('|').filter(cell => cell.trim()) - if (cells.length > 0) { + const line = lines[i].trim() + if (!line) continue // Skip empty lines + + // Check if line looks like a table row (starts and ends with |) + if (line.startsWith('|') && line.endsWith('|')) { + // Standard table row - split by pipes + const cells = line.split('|').slice(1, -1) // Remove first and last empty strings from pipes to allow empty cells + if (cells.length > 0) { + html += '' + cells.forEach(cell => { + html += `` + }) + html += '' + } + } else { + // Non-table line - split by | to create cells + const cells = line.split('|') html += '' cells.forEach(cell => { - html += `` + const trimmed = cell.trim() + if (trimmed) { // Only create cell if not empty + html += `` + } }) html += '' } From 2afd869712a83a1bd34f2dc74481942b882ca0b9 Mon Sep 17 00:00:00 2001 From: silver Date: Mon, 1 Dec 2025 17:56:11 +0100 Subject: [PATCH 07/13] use createTable to only use table markdown from Text Signed-off-by: silver --- src/components/TableEditorDialog.vue | 15 ++-------- src/utils/tableToImage.ts | 43 +++++++--------------------- 2 files changed, 14 insertions(+), 44 deletions(-) diff --git a/src/components/TableEditorDialog.vue b/src/components/TableEditorDialog.vue index 2dfe0026..d15584f8 100644 --- a/src/components/TableEditorDialog.vue +++ b/src/components/TableEditorDialog.vue @@ -27,7 +27,7 @@ export default defineComponent({ editor: null, isLoading: true, error: null, - currentMarkdown: this.initialMarkdown || this.getDefaultTable(), + currentMarkdown: this.initialMarkdown || '', } }, computed: { @@ -60,8 +60,8 @@ export default defineComponent({ return } - // Create the Text editor instance with callbacks - this.editor = await window.OCA.Text.createEditor({ + // Use the dedicated createTable function for table-only editing + this.editor = await window.OCA.Text.createTable({ el: editorContainer, content: this.currentMarkdown, // Track content changes @@ -88,13 +88,6 @@ export default defineComponent({ } }, - getDefaultTable() { - return `| Column 1 | Column 2 | Column 3 | -| -------- | -------- | -------- | -| Cell 1 | Cell 2 | Cell 3 | -| Cell 4 | Cell 5 | Cell 6 |` - }, - onCancel() { this.show = false this.$emit('cancel') @@ -107,7 +100,6 @@ export default defineComponent({ } try { - // Use the tracked markdown content const markdown = this.currentMarkdown if (!markdown || !markdown.trim()) { @@ -150,7 +142,6 @@ export default defineComponent({

{{ isEditing ? t('whiteboard', 'Edit Table') : t('whiteboard', 'Insert Table') }}

-

{{ t('whiteboard', 'All content will be rendered as table rows.') }}

diff --git a/src/utils/tableToImage.ts b/src/utils/tableToImage.ts index 8d88be5b..2fb4fafd 100644 --- a/src/utils/tableToImage.ts +++ b/src/utils/tableToImage.ts @@ -10,8 +10,6 @@ import { convertToExcalidrawElements } from '@nextcloud/excalidraw' const CELL_BASE_STYLE = 'border: 1px solid #ddd; padding: 12px 16px;' 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;' -const CODE_STYLE = 'background-color: #f5f5f5; padding: 2px 4px; border-radius: 3px; font-family: monospace; font-size: 0.9em;' -const LINK_STYLE = 'color: #00679e; text-decoration: none;' /** * Convert markdown table to an image element for Excalidraw @@ -82,7 +80,7 @@ async function renderMarkdownToHtml(markdown: string): Promise { const headerCells = lines[0].split('|').slice(1, -1) // Remove first and last empty strings from pipes to allow empty cells html += '
' headerCells.forEach(cell => { - html += `` + html += `` }) html += '' @@ -100,7 +98,7 @@ async function renderMarkdownToHtml(markdown: string): Promise { if (cells.length > 0) { html += '' cells.forEach(cell => { - html += `` + html += `` }) html += '' } @@ -111,7 +109,7 @@ async function renderMarkdownToHtml(markdown: string): Promise { cells.forEach(cell => { const trimmed = cell.trim() if (trimmed) { // Only create cell if not empty - html += `` + html += `` } }) html += '' @@ -123,36 +121,17 @@ async function renderMarkdownToHtml(markdown: string): Promise { } /** - * Parse inline markdown formatting (bold, italic, code, strikethrough, etc.) - * @param text - The text to parse - * @return HTML string with inline formatting + * Escape HTML special characters to prevent XSS + * @param text - The text to escape + * @return Escaped HTML string */ -function parseInlineMarkdown(text: string): string { - let result = text - - // Escape HTML special characters first (except & which might be part of existing entities) - result = result +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') .replace(//g, '>') - - // Bold with ** or __ - result = result.replace(/\*\*(.+?)\*\*/g, '$1') - result = result.replace(/__(.+?)__/g, '$1') - - // Italic with * or _ - result = result.replace(/\*(.+?)\*/g, '$1') - result = result.replace(/_(.+?)_/g, '$1') - - // Strikethrough with ~~ - result = result.replace(/~~(.+?)~~/g, '$1') - - // Inline code with ` - result = result.replace(/`(.+?)`/g, `$1`) - - // Links [text](url) - result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, `$1`) - - return result + .replace(/"/g, '"') + .replace(/'/g, ''') } /** From 009cb87df13ad62a9f5cff3f7c77ea3262b9b3f4 Mon Sep 17 00:00:00 2001 From: silver Date: Tue, 2 Dec 2025 15:48:30 +0100 Subject: [PATCH 08/13] add 'insert table' icon to toolbar Signed-off-by: silver --- src/App.tsx | 5 +++- src/components/ExcalidrawMenu.tsx | 9 +------ src/hooks/useTableInsertion.tsx | 45 ++++++++++++++++++++++++++++++- 3 files changed, 49 insertions(+), 10 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 6f512dd6..8cf5fa73 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,6 +15,7 @@ import { useWhiteboardConfigStore } from './stores/useWhiteboardConfigStore' import { useThemeHandling } from './hooks/useThemeHandling' import { useCollaboration } from './hooks/useCollaboration' import { useSmartPicker } from './hooks/useSmartPicker' +import { useTableInsertion } from './hooks/useTableInsertion' import { useReadOnlyState } from './hooks/useReadOnlyState' import { ExcalidrawMenu } from './components/ExcalidrawMenu' import Embeddable from './components/Embeddable' @@ -113,6 +114,7 @@ export default function App({ const { theme } = useThemeHandling() const { renderSmartPicker } = useSmartPicker() + const { renderTable } = useTableInsertion() const { renderAssistant } = useAssistant() const { renderEmojiPicker } = useEmojiPicker() const { onChange: onChangeSync, onPointerUpdate } = useSync() @@ -334,10 +336,11 @@ export default function App({ useEffect(() => { updateLang() renderSmartPicker() + renderTable() renderAssistant() renderComment() renderEmojiPicker() - }, [updateLang, renderSmartPicker, renderAssistant, renderEmojiPicker]) + }, [updateLang, renderSmartPicker, renderAssistant, renderEmojiPicker, renderTable]) const onLibraryChange = useCallback(async (items: LibraryItems) => { if (!isLibraryLoaded) { diff --git a/src/components/ExcalidrawMenu.tsx b/src/components/ExcalidrawMenu.tsx index ab721e0b..14a4fa14 100644 --- a/src/components/ExcalidrawMenu.tsx +++ b/src/components/ExcalidrawMenu.tsx @@ -6,7 +6,7 @@ import { useCallback, useEffect, useRef, memo } from 'react' import type { KeyboardEvent as ReactKeyboardEvent } from 'react' import { Icon } from '@mdi/react' -import { mdiMonitorScreenshot, mdiImageMultiple, mdiTimerOutline, mdiTable} from '@mdi/js' +import { mdiMonitorScreenshot, mdiImageMultiple, mdiTimerOutline } from '@mdi/js' import { MainMenu, CaptureUpdateAction } from '@nextcloud/excalidraw' import { RecordingMenuItem } from './Recording' import { PresentationMenuItem } from './Presentation' @@ -17,7 +17,6 @@ import type { ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types/types import { useExcalidrawStore } from '../stores/useExcalidrawStore' import type { RecordingHookState } from '../types/recording' import type { PresentationState } from '../types/presentation' -import { useTableInsertion } from '../hooks/useTableInsertion' interface ExcalidrawMenuProps { fileNameWithoutExtension: string @@ -32,7 +31,6 @@ export const ExcalidrawMenu = memo(function ExcalidrawMenu({ fileNameWithoutExte const { excalidrawAPI } = useExcalidrawStore(useShallow(state => ({ excalidrawAPI: state.excalidrawAPI, }))) - const { insertTable } = useTableInsertion() const openExportDialog = useCallback(() => { // Trigger export by dispatching the keyboard shortcut to the Excalidraw canvas @@ -153,11 +151,6 @@ export const ExcalidrawMenu = memo(function ExcalidrawMenu({ fileNameWithoutExte - } - onSelect={() => insertTable()}> - {t('whiteboard', 'Insert table')} - } onSelect={openExportDialog} diff --git a/src/hooks/useTableInsertion.tsx b/src/hooks/useTableInsertion.tsx index 6ed56c39..98c82704 100644 --- a/src/hooks/useTableInsertion.tsx +++ b/src/hooks/useTableInsertion.tsx @@ -3,7 +3,10 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ import { useCallback, useEffect, useRef } from 'react' +import * as ReactDOM from 'react-dom' import Vue from 'vue' +import { Icon } from '@mdi/react' +import { mdiTable } from '@mdi/js' import { useExcalidrawStore } from '../stores/useExcalidrawStore' import { useShallow } from 'zustand/react/shallow' import TableEditorDialog from '../components/TableEditorDialog.vue' @@ -158,5 +161,45 @@ export function useTableInsertion() { excalidrawAPI.onPointerDown(pointerDownHandler) }, [excalidrawAPI, editTable]) - return { insertTable } + const renderTableButton = useCallback(() => { + return ( +
+ +
+ ) + }, []) + + const hasInsertedRef = useRef(false) + const renderTable = useCallback(() => { + if (hasInsertedRef.current) return + + const extraTools = Array.from(document.getElementsByClassName('App-toolbar__extra-tools-trigger')) + .find(el => !el.classList.contains('table-trigger')) + if (!extraTools) return + + const tableButton = document.createElement('button') + tableButton.type = 'button' + tableButton.className = 'ToolIcon_type_button ToolIcon table-trigger' + tableButton.setAttribute('data-testid', 'toolbar-table') + tableButton.setAttribute('aria-label', 'Insert table') + tableButton.setAttribute('title', 'Insert table') + tableButton.style.padding = '0' + tableButton.style.display = 'flex' + tableButton.style.alignItems = 'center' + tableButton.style.justifyContent = 'center' + tableButton.onclick = () => insertTable() + + extraTools.parentNode?.insertBefore( + tableButton, + extraTools.previousSibling, + ) + ReactDOM.render(renderTableButton(), tableButton) + hasInsertedRef.current = true + }, [renderTableButton, insertTable]) + + useEffect(() => { + if (excalidrawAPI) renderTable() + }, [excalidrawAPI, renderTable]) + + return { insertTable, renderTable } } From 171eb4ed344585e2bf7ada102232d4cb0c2cf8f4 Mon Sep 17 00:00:00 2001 From: silver Date: Wed, 3 Dec 2025 15:43:39 +0100 Subject: [PATCH 09/13] feat: add collaborative table editing lock mechanism Implement client-side locking to prevent concurrent table edits: - Lock stored in element.customData.tableLock with 5-minute timeout - Lock acquisition checks and shows error if table locked by another user - Lock automatically cleared when user saves/cancels edit - Lock state syncs via normal onChange flow and survives reconciliation - No heartbeat needed - timeout sufficient for cleanup Signed-off-by: silver --- src/hooks/useTableInsertion.tsx | 87 +++++++++++++++-- src/utils/mergeElementsWithMetadata.ts | 21 +++++ src/utils/tableLocking.ts | 126 +++++++++++++++++++++++++ src/utils/tableToImage.ts | 1 + 4 files changed, 229 insertions(+), 6 deletions(-) create mode 100644 src/utils/tableLocking.ts diff --git a/src/hooks/useTableInsertion.tsx b/src/hooks/useTableInsertion.tsx index 98c82704..b51d5b67 100644 --- a/src/hooks/useTableInsertion.tsx +++ b/src/hooks/useTableInsertion.tsx @@ -11,6 +11,7 @@ import { useExcalidrawStore } from '../stores/useExcalidrawStore' import { useShallow } from 'zustand/react/shallow' import TableEditorDialog from '../components/TableEditorDialog.vue' import { convertMarkdownTableToImage } from '../utils/tableToImage' +import { tryAcquireLock, releaseLock } from '../utils/tableLocking' import { viewportCoordsToSceneCoords } from '@nextcloud/excalidraw' import { getViewportCenterPoint, moveElementsToViewport } from '../utils/positionElementsAtViewport' import type { ExcalidrawImperativeAPI } from '@nextcloud/excalidraw/dist/types/excalidraw/types' @@ -18,6 +19,13 @@ import type { ExcalidrawImageElement } from '@nextcloud/excalidraw/dist/types/ex const DOUBLE_CLICK_THRESHOLD_MS = 500 +/** + * Features: + * - Insert new markdown tables as image elements + * - Edit existing tables via double-click + * - Collaborative locking to prevent concurrent edits + * - Automatic sync with other users via normal Excalidraw onChange flow + */ export function useTableInsertion() { const { excalidrawAPI } = useExcalidrawStore( useShallow((state) => ({ @@ -36,6 +44,8 @@ export function useTableInsertion() { return new Promise<{ markdown: string }>((resolve, reject) => { const element = document.createElement('div') document.body.appendChild(element) + + // Instantiate the Vue component with initial markdown data const View = Vue.extend(TableEditorDialog) const view = new View({ propsData: { @@ -56,7 +66,19 @@ export function useTableInsertion() { }, []) /** - * Edits an existing table element + * Edits an existing table element by opening the editor dialog. + * + * Flow: + * 1. Validate the table element has markdown data + * 2. Acquire a collaborative lock (shows error if locked by another user) + * 3. Open the editor dialog with the current markdown + * 4. On save: convert markdown to new image, update element, clear lock + * 5. On cancel/error: release the lock + * + * The updated element is synced to other users via the normal Excalidraw onChange flow, + * which triggers throttled websocket broadcasts and server API persistence. + * + * @param tableElement - The table image element to edit */ const editTable = useCallback(async (tableElement: ExcalidrawImageElement) => { if (!excalidrawAPI) { @@ -71,24 +93,57 @@ export function useTableInsertion() { return } + // Acquire a collaborative lock to prevent simultaneous editing by multiple users + // Lock is stored in element.customData.tableLock with user info and timestamp + // If another user has a non-expired lock, shows an error and returns false + const lockAcquired = tryAcquireLock(excalidrawAPI, tableElement) + if (!lockAcquired) { + return + } + try { const tableData = await openTableEditor(initialMarkdown) const newImageElement = await convertMarkdownTableToImage(tableData.markdown, excalidrawAPI) - // Replace the existing element with the updated one + // Replace the existing element with the updated one while preserving position const elements = excalidrawAPI.getSceneElementsIncludingDeleted().slice() const elementIndex = elements.findIndex(el => el.id === tableElement.id) if (elementIndex !== -1) { - elements[elementIndex] = { + const currentElement = elements[elementIndex] + + const updatedElement = { ...newImageElement, + // Preserve the original element's ID and position id: tableElement.id, x: tableElement.x, y: tableElement.y, angle: tableElement.angle, + // Increment version numbers to ensure this update wins during collaborative reconciliation + // Excalidraw uses these to resolve conflicts when multiple users edit simultaneously + version: (currentElement.version || 0) + 1, + versionNonce: (currentElement.versionNonce || 0) + 1, + customData: { + // Include the new markdown and isTable flag from the newly generated element + ...(newImageElement.customData || {}), + // Explicitly clear the lock so other users can edit + tableLock: undefined, + }, } + + elements[elementIndex] = updatedElement + // Trigger Excalidraw's onChange which handles all sync (websocket, server API, local storage) excalidrawAPI.updateScene({ elements }) + + // Verify the update was applied + setTimeout(() => { + const verifyElements = excalidrawAPI.getSceneElementsIncludingDeleted() + verifyElements.find(el => el.id === tableElement.id) + }, 100) } } catch (error) { + // Release lock on cancel or failure + releaseLock(excalidrawAPI, tableElement.id) + if (error instanceof Error && error.message !== 'Table editor was cancelled') { console.error('Failed to edit table:', error) } @@ -96,7 +151,15 @@ export function useTableInsertion() { }, [excalidrawAPI, openTableEditor]) /** - * Inserts a table image into the whiteboard at the viewport center + * Inserts a new table into the whiteboard at the viewport center. + * + * Flow: + * 1. Open the table editor dialog (optionally with initial markdown) + * 2. Convert the markdown to an image element + * 3. Position the element at the center of the current viewport + * 4. Add to the scene (syncs automatically via onChange) + * + * @param initialMarkdown - Optional initial markdown content for the table */ const insertTable = useCallback(async (initialMarkdown?: string) => { if (!excalidrawAPI) { @@ -116,6 +179,7 @@ export function useTableInsertion() { ) elements.push(...movedElements) + // Add to scene - this triggers onChange which syncs to other users excalidrawAPI.updateScene({ elements }) } catch (error) { if (error instanceof Error && error.message !== 'Table editor was cancelled') { @@ -124,10 +188,11 @@ export function useTableInsertion() { } }, [excalidrawAPI, openTableEditor]) - // Set up pointer down handler to detect clicks on table elements + // Set up pointer down handler to detect double-clicks on table elements for editing useEffect(() => { if (!excalidrawAPI) return + // Register a handler for pointer down events on the canvas // activeTool: current tool (selection, rectangle, etc.) - unused but required by API signature // eslint-disable-next-line @typescript-eslint/no-explicit-any const pointerDownHandler = (_activeTool: any, state: any) => { @@ -136,7 +201,6 @@ export function useTableInsertion() { return } - // Check if this is a table element if (clickedElement.customData.isTable && clickedElement.type === 'image') { // Double-click detection: check if it's a quick second click const now = Date.now() @@ -150,6 +214,7 @@ export function useTableInsertion() { editTable(clickedElement).catch((error) => { console.error('Error editing table:', error) }) + // Reset to allow next double-click lastClickRef.current = null } else { // First click @@ -158,6 +223,7 @@ export function useTableInsertion() { } } + // Register the handler with Excalidraw's pointer down event system excalidrawAPI.onPointerDown(pointerDownHandler) }, [excalidrawAPI, editTable]) @@ -169,10 +235,18 @@ export function useTableInsertion() { ) }, []) + // Prevent double-insertion of the table button in the toolbar const hasInsertedRef = useRef(false) + + /** + * Injects the "Insert Table" button into Excalidraw's toolbar. + */ const renderTable = useCallback(() => { + // Only insert once to avoid duplicate buttons if (hasInsertedRef.current) return + // Find the extra tools trigger element in the toolbar + // We insert our button before this element const extraTools = Array.from(document.getElementsByClassName('App-toolbar__extra-tools-trigger')) .find(el => !el.classList.contains('table-trigger')) if (!extraTools) return @@ -193,6 +267,7 @@ export function useTableInsertion() { tableButton, extraTools.previousSibling, ) + // Render the React icon component into the button ReactDOM.render(renderTableButton(), tableButton) hasInsertedRef.current = true }, [renderTableButton, insertTable]) diff --git a/src/utils/mergeElementsWithMetadata.ts b/src/utils/mergeElementsWithMetadata.ts index f02153ca..ee528274 100644 --- a/src/utils/mergeElementsWithMetadata.ts +++ b/src/utils/mergeElementsWithMetadata.ts @@ -73,6 +73,27 @@ export function mergeElementsWithMetadata( whiteboardElement.customData.creator = localElement.customData.creator } + // Preserve table-specific custom data from whichever version won reconciliation + // This ensures tableMarkdown, isTable, and tableLock are not lost + const sourceElement = remoteElement || localElement + if (sourceElement?.customData) { + if (!whiteboardElement.customData) { + whiteboardElement.customData = {} + } + + // Preserve table metadata + if (sourceElement.customData.isTable !== undefined) { + whiteboardElement.customData.isTable = sourceElement.customData.isTable + } + if (sourceElement.customData.tableMarkdown !== undefined) { + whiteboardElement.customData.tableMarkdown = sourceElement.customData.tableMarkdown + } + // Preserve or clear lock status from the source element + if ('tableLock' in sourceElement.customData) { + whiteboardElement.customData.tableLock = sourceElement.customData.tableLock + } + } + return whiteboardElement }) diff --git a/src/utils/tableLocking.ts b/src/utils/tableLocking.ts new file mode 100644 index 00000000..c3793f93 --- /dev/null +++ b/src/utils/tableLocking.ts @@ -0,0 +1,126 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { getCurrentUser } from '@nextcloud/auth' +import { showError } from '@nextcloud/dialogs' +import type { ExcalidrawImperativeAPI } from '@nextcloud/excalidraw/dist/types/excalidraw/types' +import type { ExcalidrawImageElement } from '@nextcloud/excalidraw/dist/types/excalidraw/element/types' + +const LOCK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes + +/** + * Structure stored in element.customData.tableLock to track who is editing + */ +export interface TableLock { + uid: string + displayName: string + lockedAt: number +} + +/** + * Checks if a lock has expired based on the 5-minute timeout. + * @param lock - The lock object to check + * @return true if the lock is expired or missing, false if still valid + */ +export function isLockExpired(lock: { lockedAt?: number } | undefined): boolean { + if (!lock || !lock.lockedAt) return true + return Date.now() - (lock.lockedAt || 0) > LOCK_TIMEOUT_MS +} + +/** + * Sets or clears a lock on a table element by updating its customData. + * + * This directly modifies the element's customData.tableLock property and triggers + * Excalidraw's updateScene(), which automatically syncs the change to all collaborators + * via the normal onChange flow (websocket + server API). + * + * The lock survives reconciliation because mergeElementsWithMetadata explicitly + * preserves tableLock from whichever element version wins. + * + * @param excalidrawAPI - The Excalidraw API instance + * @param elementId - The ID of the table element to lock/unlock + * @param lock - The lock object to set, or undefined to clear the lock + */ +export function setLockOnElement( + excalidrawAPI: ExcalidrawImperativeAPI, + elementId: string, + lock: TableLock | undefined, +): void { + const elements = excalidrawAPI.getSceneElementsIncludingDeleted().slice() + const idx = elements.findIndex(el => el.id === elementId) + // findIndex() can return -1 if the element does not exist + if (idx === -1) return + + // Update the element with the new lock state + elements[idx] = { + ...elements[idx], + customData: { + ...elements[idx].customData, + // Set tableLock to the provided value, or explicitly undefined to clear + ...(lock ? { tableLock: lock } : { tableLock: undefined }), + }, + } + // Trigger onChange which syncs to other users + excalidrawAPI.updateScene({ elements }) +} + +/** + * Attempts to acquire an edit lock on a table element. + * @param excalidrawAPI - The Excalidraw API instance + * @param tableElement - The table element to lock + * @return true if lock was successfully acquired, false if blocked by another user + */ +export function tryAcquireLock( + excalidrawAPI: ExcalidrawImperativeAPI, + tableElement: ExcalidrawImageElement, +): boolean { + const user = getCurrentUser() + if (!user) { + console.error('User not available') + return false + } + + // Get the current state of the element (may have been updated by another user) + const elementsNow = excalidrawAPI.getSceneElementsIncludingDeleted() + const current = elementsNow.find(el => el.id === tableElement.id) + const existingLock = current?.customData?.tableLock + + // Check if another user has a valid (non-expired) lock + if (existingLock && existingLock.uid !== user.uid && !isLockExpired(existingLock)) { + // Show error to user and prevent editing + showError(`This table is currently being edited by ${existingLock.displayName}`) + return false + } + + // Lock is available - acquire it for this user + // No heartbeat needed - the lock will expire after 5 minutes if not released + const lockInfo: TableLock = { + uid: user.uid, + displayName: user.displayName || user.uid, + lockedAt: Date.now(), + } + setLockOnElement(excalidrawAPI, tableElement.id, lockInfo) + return true +} + +/** + * Releases a lock on a table element by clearing the tableLock property. + * + * This should be called when: + * - User saves their table edits (lock cleared automatically in editTable) + * - User cancels the edit dialog + * - An error occurs during editing + * @param excalidrawAPI - The Excalidraw API instance + * @param elementId - The ID of the table element to unlock + */ +export function releaseLock( + excalidrawAPI: ExcalidrawImperativeAPI, + elementId: string, +): void { + try { + setLockOnElement(excalidrawAPI, elementId, undefined) + } catch (e) { + console.error('Failed to release lock:', e) + } +} diff --git a/src/utils/tableToImage.ts b/src/utils/tableToImage.ts index 2fb4fafd..cfb194a1 100644 --- a/src/utils/tableToImage.ts +++ b/src/utils/tableToImage.ts @@ -55,6 +55,7 @@ export async function convertMarkdownTableToImage( customData: { tableMarkdown: markdown, isTable: true, + tableLock: undefined, }, }, ]) From bb3c5c098a702d65c2a094171aa84514c39534cf Mon Sep 17 00:00:00 2001 From: silver Date: Wed, 3 Dec 2025 17:04:21 +0100 Subject: [PATCH 10/13] test the locking mechanism Signed-off-by: silver --- lib/AppInfo/Application.php | 2 +- lib/Listener/LoadTextEditorListener.php | 3 + tests/integration/tableLocking.spec.mjs | 252 ++++++++++++++++++++++++ 3 files changed, 256 insertions(+), 1 deletion(-) create mode 100644 tests/integration/tableLocking.spec.mjs diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index d9d67a35..3ca22b1b 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -14,8 +14,8 @@ use OCA\Viewer\Event\LoadViewer; use OCA\Whiteboard\Listener\AddContentSecurityPolicyListener; use OCA\Whiteboard\Listener\BeforeTemplateRenderedListener; -use OCA\Whiteboard\Listener\LoadViewerListener; use OCA\Whiteboard\Listener\LoadTextEditorListener; +use OCA\Whiteboard\Listener\LoadViewerListener; use OCA\Whiteboard\Listener\RegisterTemplateCreatorListener; use OCA\Whiteboard\Settings\SetupCheck; use OCP\AppFramework\App; diff --git a/lib/Listener/LoadTextEditorListener.php b/lib/Listener/LoadTextEditorListener.php index 63ff084b..6d656b52 100644 --- a/lib/Listener/LoadTextEditorListener.php +++ b/lib/Listener/LoadTextEditorListener.php @@ -16,6 +16,9 @@ /** @template-implements IEventListener */ class LoadTextEditorListener implements IEventListener { + /** + * @psalm-suppress PossiblyUnusedMethod + */ public function __construct( private IEventDispatcher $eventDispatcher, ) { diff --git a/tests/integration/tableLocking.spec.mjs b/tests/integration/tableLocking.spec.mjs new file mode 100644 index 00000000..27d1dd00 --- /dev/null +++ b/tests/integration/tableLocking.spec.mjs @@ -0,0 +1,252 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { isLockExpired, setLockOnElement, tryAcquireLock, releaseLock } from '../../src/utils/tableLocking.ts' +import * as auth from '@nextcloud/auth' +import * as dialogs from '@nextcloud/dialogs' + +// Mock the Nextcloud modules +vi.mock('@nextcloud/auth', () => ({ + getCurrentUser: vi.fn(() => ({ + uid: 'test-user', + displayName: 'Test User', + })), +})) + +vi.mock('@nextcloud/dialogs', () => ({ + showError: vi.fn(), +})) + +describe('tableLocking utilities', () => { + describe('isLockExpired', () => { + it('should return true for undefined lock', () => { + expect(isLockExpired(undefined)).toBe(true) + }) + + it('should return true for lock without lockedAt', () => { + expect(isLockExpired({})).toBe(true) + }) + + it('should return false for fresh lock', () => { + const lock = { lockedAt: Date.now() } + expect(isLockExpired(lock)).toBe(false) + }) + + it('should return true for expired lock (older than 5 minutes)', () => { + const sixMinutesAgo = Date.now() - (6 * 60 * 1000) + const lock = { lockedAt: sixMinutesAgo } + expect(isLockExpired(lock)).toBe(true) + }) + + it('should return false for lock just under 5 minutes old', () => { + const fourMinutesAgo = Date.now() - (4 * 60 * 1000) + const lock = { lockedAt: fourMinutesAgo } + expect(isLockExpired(lock)).toBe(false) + }) + }) + + describe('setLockOnElement', () => { + let mockAPI + let mockElements + + beforeEach(() => { + mockElements = [ + { + id: 'element-1', + type: 'image', + customData: { isTable: true }, + }, + { + id: 'element-2', + type: 'image', + customData: { isTable: true }, + }, + ] + + mockAPI = { + getSceneElementsIncludingDeleted: vi.fn(() => mockElements), + updateScene: vi.fn(), + } + }) + + it('should set lock on element', () => { + const lock = { + uid: 'user-1', + displayName: 'User One', + lockedAt: Date.now(), + } + + setLockOnElement(mockAPI, 'element-1', lock) + + expect(mockAPI.updateScene).toHaveBeenCalledTimes(1) + const updateCall = mockAPI.updateScene.mock.calls[0][0] + expect(updateCall.elements[0].customData.tableLock).toEqual(lock) + }) + + it('should clear lock on element when lock is undefined', () => { + mockElements[0].customData.tableLock = { + uid: 'user-1', + displayName: 'User One', + lockedAt: Date.now(), + } + + setLockOnElement(mockAPI, 'element-1', undefined) + + expect(mockAPI.updateScene).toHaveBeenCalledTimes(1) + const updateCall = mockAPI.updateScene.mock.calls[0][0] + expect(updateCall.elements[0].customData.tableLock).toBeUndefined() + }) + + it('should do nothing if element not found', () => { + setLockOnElement(mockAPI, 'non-existent-id', undefined) + + expect(mockAPI.updateScene).not.toHaveBeenCalled() + }) + + it('should preserve other customData properties', () => { + mockElements[1].customData = { + isTable: true, + tableMarkdown: '| test |', + } + + const lock = { + uid: 'user-1', + displayName: 'User One', + lockedAt: Date.now(), + } + + setLockOnElement(mockAPI, 'element-2', lock) + + const updateCall = mockAPI.updateScene.mock.calls[0][0] + expect(updateCall.elements[1].customData.isTable).toBe(true) + expect(updateCall.elements[1].customData.tableMarkdown).toBe('| test |') + expect(updateCall.elements[1].customData.tableLock).toEqual(lock) + }) + }) + + describe('tryAcquireLock', () => { + let mockAPI + let mockElement + + beforeEach(() => { + mockElement = { + id: 'table-1', + type: 'image', + customData: { isTable: true }, + } + + mockAPI = { + getSceneElementsIncludingDeleted: vi.fn(() => [mockElement]), + updateScene: vi.fn(), + } + + vi.clearAllMocks() + }) + + it('should acquire lock when no lock exists', () => { + const result = tryAcquireLock(mockAPI, mockElement) + + expect(result).toBe(true) + expect(mockAPI.updateScene).toHaveBeenCalled() + const updateCall = mockAPI.updateScene.mock.calls[0][0] + expect(updateCall.elements[0].customData.tableLock).toHaveProperty('uid', 'test-user') + expect(updateCall.elements[0].customData.tableLock).toHaveProperty('displayName', 'Test User') + expect(updateCall.elements[0].customData.tableLock).toHaveProperty('lockedAt') + }) + + it('should acquire lock when existing lock is expired', () => { + const sixMinutesAgo = Date.now() - (6 * 60 * 1000) + mockElement.customData.tableLock = { + uid: 'other-user', + displayName: 'Other User', + lockedAt: sixMinutesAgo, + } + + const result = tryAcquireLock(mockAPI, mockElement) + + expect(result).toBe(true) + expect(mockAPI.updateScene).toHaveBeenCalled() + }) + + it('should reacquire lock when same user already has it', () => { + mockElement.customData.tableLock = { + uid: 'test-user', + displayName: 'Test User', + lockedAt: Date.now(), + } + + const result = tryAcquireLock(mockAPI, mockElement) + + expect(result).toBe(true) + expect(mockAPI.updateScene).toHaveBeenCalled() + }) + + it('should fail to acquire lock when another user has valid lock', () => { + mockElement.customData.tableLock = { + uid: 'other-user', + displayName: 'Other User', + lockedAt: Date.now(), + } + + const result = tryAcquireLock(mockAPI, mockElement) + + expect(result).toBe(false) + expect(dialogs.showError).toHaveBeenCalledWith('This table is currently being edited by Other User') + expect(mockAPI.updateScene).not.toHaveBeenCalled() + }) + + it('should return false when user is not available', () => { + vi.mocked(auth.getCurrentUser).mockReturnValueOnce(null) + + const result = tryAcquireLock(mockAPI, mockElement) + + expect(result).toBe(false) + expect(mockAPI.updateScene).not.toHaveBeenCalled() + }) + }) + + describe('releaseLock', () => { + let mockAPI + let mockElement + + beforeEach(() => { + mockElement = { + id: 'table-1', + type: 'image', + customData: { + isTable: true, + tableLock: { + uid: 'test-user', + displayName: 'Test User', + lockedAt: Date.now(), + }, + }, + } + + mockAPI = { + getSceneElementsIncludingDeleted: vi.fn(() => [mockElement]), + updateScene: vi.fn(), + } + }) + + it('should release lock on element', () => { + releaseLock(mockAPI, 'table-1') + + expect(mockAPI.updateScene).toHaveBeenCalledTimes(1) + const updateCall = mockAPI.updateScene.mock.calls[0][0] + expect(updateCall.elements[0].customData.tableLock).toBeUndefined() + }) + + it('should handle errors gracefully', () => { + mockAPI.getSceneElementsIncludingDeleted.mockImplementation(() => { + throw new Error('Test error') + }) + + // Should not throw + expect(() => releaseLock(mockAPI, 'table-1')).not.toThrow() + }) + }) +}) From dd40f13493eda796c30a35b9cd02027433135983 Mon Sep 17 00:00:00 2001 From: silver Date: Thu, 4 Dec 2025 12:00:44 +0100 Subject: [PATCH 11/13] remove border and some padding in tableeditor Signed-off-by: silver --- src/components/TableEditorDialog.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/TableEditorDialog.vue b/src/components/TableEditorDialog.vue index d15584f8..845f46f3 100644 --- a/src/components/TableEditorDialog.vue +++ b/src/components/TableEditorDialog.vue @@ -168,7 +168,6 @@ export default defineComponent({
${parseInlineMarkdown(cell.trim())}
${parseInlineMarkdown(cell.trim())}
${parseInlineMarkdown(cell.trim())}${parseInlineMarkdown(trimmed)}
${parseInlineMarkdown(cell.trim())}${escapeHtml(cell.trim())}
${parseInlineMarkdown(cell.trim())}${escapeHtml(cell.trim())}
${parseInlineMarkdown(trimmed)}${escapeHtml(trimmed)}