From db6194dfe2e4162a36e4ccb89e852401348cbf74 Mon Sep 17 00:00:00 2001 From: Abhishek Sharma Date: Tue, 28 Apr 2026 17:26:18 +0530 Subject: [PATCH 01/16] Quickstart 2.0 integrated with Neo --- src/components/Editor/EditorTheme.ts | 70 - src/components/Editor/core/DOMReconciler.ts | 484 ++++ src/components/Editor/core/EditorCore.ts | 2213 +++++++++++++++++ src/components/Editor/core/EditorState.ts | 405 +++ src/components/Editor/core/History.ts | 112 + src/components/Editor/core/Selection.ts | 262 ++ src/components/Editor/core/index.ts | 6 + src/components/Editor/core/types.ts | 258 ++ src/components/Editor/hooks/useEditor.ts | 66 + src/components/Editor/index.module.css | 261 +- src/components/Editor/index.tsx | 819 +++--- .../Editor/nodes/DrawioNode/index.module.css | 14 - .../Editor/nodes/DrawioNode/index.tsx | 76 - .../Editor/nodes/ImageNode/ImageComponent.tsx | 40 - .../Editor/nodes/ImageNode/index.module.css | 10 - .../Editor/nodes/ImageNode/index.tsx | 151 -- .../BlockHandlePlugin/index.module.css | 177 ++ .../plugins/BlockHandlePlugin/index.tsx | 484 ++++ .../Editor/plugins/DrawioPlugin/index.tsx | 104 - .../EmptyLineCommandHintPlugin/index.tsx | 79 - .../FloatingToolbarPlugin/index.module.css | 207 +- .../plugins/FloatingToolbarPlugin/index.tsx | 483 ++-- .../plugins/ImagePlugin/index.module.css | 78 - .../Editor/plugins/ImagePlugin/index.tsx | 87 - .../plugins/InitializerPlugin/index.tsx | 35 - .../plugins/SanityCheckPlugin/index.tsx | 51 - .../SlashCommandPlugin/index.module.css | 206 +- .../plugins/SlashCommandPlugin/index.tsx | 833 +++++-- .../plugins/TableMenuPlugin/index.module.css | 172 ++ .../Editor/plugins/TableMenuPlugin/index.tsx | 455 ++++ .../TableOfContentPlugin/index.module.css | 47 - .../plugins/TableOfContentPlugin/index.tsx | 104 - .../TableOfContentsPlugin/index.module.css | 62 + .../plugins/TableOfContentsPlugin/index.tsx | 114 + .../Editor/plugins/TitleSyncPlugin/index.tsx | 32 - .../plugins/ToolbarPlugin/index.module.css | 31 +- .../Editor/plugins/ToolbarPlugin/index.tsx | 740 +++--- src/components/Editor/plugins/commands.ts | 8 - .../Editor/plugins/fileUploadCommand.tsx | 88 - .../Editor/plugins/insertActions.tsx | 40 - .../Editor/utils/convertToLexical.ts | 283 +++ src/components/PageTabs/index.module.css | 33 + src/components/PageTabs/index.tsx | 23 +- src/components/RefArchTabs/index.module.css | 145 ++ src/components/RefArchTabs/index.tsx | 71 + src/pages/quick-start/index.tsx | 30 +- src/services/api.ts | 376 +++ src/services/useApi.ts | 23 + src/store/pageDataStore.ts | 392 ++- 49 files changed, 8752 insertions(+), 2588 deletions(-) delete mode 100644 src/components/Editor/EditorTheme.ts create mode 100644 src/components/Editor/core/DOMReconciler.ts create mode 100644 src/components/Editor/core/EditorCore.ts create mode 100644 src/components/Editor/core/EditorState.ts create mode 100644 src/components/Editor/core/History.ts create mode 100644 src/components/Editor/core/Selection.ts create mode 100644 src/components/Editor/core/index.ts create mode 100644 src/components/Editor/core/types.ts create mode 100644 src/components/Editor/hooks/useEditor.ts delete mode 100644 src/components/Editor/nodes/DrawioNode/index.module.css delete mode 100644 src/components/Editor/nodes/DrawioNode/index.tsx delete mode 100644 src/components/Editor/nodes/ImageNode/ImageComponent.tsx delete mode 100644 src/components/Editor/nodes/ImageNode/index.module.css delete mode 100644 src/components/Editor/nodes/ImageNode/index.tsx create mode 100644 src/components/Editor/plugins/BlockHandlePlugin/index.module.css create mode 100644 src/components/Editor/plugins/BlockHandlePlugin/index.tsx delete mode 100644 src/components/Editor/plugins/DrawioPlugin/index.tsx delete mode 100644 src/components/Editor/plugins/EmptyLineCommandHintPlugin/index.tsx delete mode 100644 src/components/Editor/plugins/ImagePlugin/index.module.css delete mode 100644 src/components/Editor/plugins/ImagePlugin/index.tsx delete mode 100644 src/components/Editor/plugins/InitializerPlugin/index.tsx delete mode 100644 src/components/Editor/plugins/SanityCheckPlugin/index.tsx create mode 100644 src/components/Editor/plugins/TableMenuPlugin/index.module.css create mode 100644 src/components/Editor/plugins/TableMenuPlugin/index.tsx delete mode 100644 src/components/Editor/plugins/TableOfContentPlugin/index.module.css delete mode 100644 src/components/Editor/plugins/TableOfContentPlugin/index.tsx create mode 100644 src/components/Editor/plugins/TableOfContentsPlugin/index.module.css create mode 100644 src/components/Editor/plugins/TableOfContentsPlugin/index.tsx delete mode 100644 src/components/Editor/plugins/TitleSyncPlugin/index.tsx delete mode 100644 src/components/Editor/plugins/commands.ts delete mode 100644 src/components/Editor/plugins/fileUploadCommand.tsx delete mode 100644 src/components/Editor/plugins/insertActions.tsx create mode 100644 src/components/Editor/utils/convertToLexical.ts create mode 100644 src/components/RefArchTabs/index.module.css create mode 100644 src/components/RefArchTabs/index.tsx create mode 100644 src/services/api.ts create mode 100644 src/services/useApi.ts diff --git a/src/components/Editor/EditorTheme.ts b/src/components/Editor/EditorTheme.ts deleted file mode 100644 index 22b7dccf62..0000000000 --- a/src/components/Editor/EditorTheme.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { EditorThemeClasses } from 'lexical'; -import styles from './index.module.css'; - -const theme: EditorThemeClasses = { - ltr: styles.ltr, - rtl: styles.rtl, - placeholder: styles.editorPlaceholder, - paragraph: styles.editorParagraph, - quote: styles.editorQuote, - heading: { - h1: styles.editorH1, - h2: styles.editorH2, - h3: styles.editorH3, - h4: styles.editorH4, - h5: styles.editorH5, - }, - list: { - nested: { - listitem: styles.editorNestedListItem, - }, - ol: styles.editorOList, - ul: styles.editorUList, - listitem: styles.editorListItem, - }, - link: styles.editorLink, - text: { - bold: styles.editorTextBold, - italic: styles.editorTextItalic, - underline: styles.editorTextUnderline, - strikethrough: styles.editorTextStrikethrough, - underlineStrikethrough: styles.editorTextUnderlineStrikethrough, - code: styles.editorTextCode, - }, - image: styles.editorImage, - code: styles.editorCode, - codeHighlight: { - atrule: styles.editorTokenAttr, - attr: styles.editorTokenAttr, - boolean: styles.editorTokenProperty, - builtin: styles.editorTokenSelector, - cdata: styles.editorTokenComment, - char: styles.editorTokenSelector, - class: styles.editorTokenFunction, - 'class-name': styles.editorTokenFunction, - comment: styles.editorTokenComment, - constant: styles.editorTokenProperty, - deleted: styles.editorTokenProperty, - doctype: styles.editorTokenComment, - entity: styles.editorTokenOperator, - function: styles.editorTokenFunction, - important: styles.editorTokenVariable, - inserted: styles.editorTokenSelector, - keyword: styles.editorTokenAttr, - namespace: styles.editorTokenVariable, - number: styles.editorTokenProperty, - operator: styles.editorTokenOperator, - prolog: styles.editorTokenComment, - property: styles.editorTokenProperty, - punct: styles.editorTokenPunctuation, - regex: styles.editorTokenVariable, - selector: styles.editorTokenSelector, - string: styles.editorTokenSelector, - symbol: styles.editorTokenProperty, - tag: styles.editorTokenProperty, - url: styles.editorTokenOperator, - variable: styles.editorTokenVariable, - }, -}; - -export default theme; diff --git a/src/components/Editor/core/DOMReconciler.ts b/src/components/Editor/core/DOMReconciler.ts new file mode 100644 index 0000000000..4647cfed4a --- /dev/null +++ b/src/components/Editor/core/DOMReconciler.ts @@ -0,0 +1,484 @@ +import { + EditorState, + EditorNode, + TextNode, + HeadingNode, + ListNode, + ListItemNode, + CodeNode, + LinkNode, + ImageNode, + DrawioNode, + TableNode, + TableRowNode, + TableCellNode, + RootNode, + isElementNode, + isTextNode, + isDecoratorNode, +} from './types'; +import { getNode } from './EditorState'; + +const DATA_KEY_ATTR = 'data-editor-key'; +const ZERO_WIDTH_SPACE = '​'; + +export interface ReconcilerConfig { + onImageClick?: (node: ImageNode) => void; +} + +export class DOMReconciler { + private config: ReconcilerConfig; + + constructor(config: ReconcilerConfig = {}) { + this.config = config; + } + + // Check if a block node is empty (has no text content) + private isBlockEmpty(state: EditorState, node: EditorNode): boolean { + if (!isElementNode(node)) return false; + + if (node.children.length === 0) return true; + + // Check if all children are empty text nodes + for (const childKey of node.children) { + const child = getNode(state, childKey); + if (!child) continue; + + if (isTextNode(child)) { + if (child.text && child.text.length > 0) return false; + } else if (isElementNode(child)) { + if (!this.isBlockEmpty(state, child)) return false; + } + } + + return true; + } + + // Main reconcile function - updates DOM to match state + reconcile(state: EditorState, container: HTMLElement): void { + const root = getNode(state, state.root) as RootNode; + if (!root) return; + + // Clear container and rebuild (simple approach for now) + // A more optimized version would diff and patch + this.reconcileChildren(state, root.children, container); + } + + private reconcileChildren( + state: EditorState, + childKeys: string[], + container: HTMLElement + ): void { + // Get existing DOM children with keys + const existingElements = new Map(); + Array.from(container.children).forEach(child => { + if (child instanceof HTMLElement) { + const key = child.getAttribute(DATA_KEY_ATTR); + if (key) existingElements.set(key, child); + } + }); + + // Track which keys we've processed + const processedKeys = new Set(); + + // Process each child in order + childKeys.forEach((key, index) => { + processedKeys.add(key); + const node = getNode(state, key); + if (!node) return; + + let element = existingElements.get(key); + + // For tables, always recreate to handle structural changes properly + if (node.type === 'table' && element) { + element.remove(); + element = undefined; + existingElements.delete(key); + } + + if (element) { + // Update existing element + this.updateElement(state, node, element); + } else { + // Create new element + element = this.createElement(state, node) ?? undefined; + if (element) { + // Insert at correct position + const referenceNode = container.children[index] ?? null; + container.insertBefore(element, referenceNode); + } + } + }); + + // Remove elements that are no longer in state + existingElements.forEach((element, key) => { + if (!processedKeys.has(key)) { + element.remove(); + } + }); + + // Ensure correct order + childKeys.forEach((key, index) => { + const element = container.querySelector(`[${DATA_KEY_ATTR}="${key}"]`); + if (element && container.children[index] !== element) { + const referenceNode = container.children[index] || null; + container.insertBefore(element, referenceNode); + } + }); + } + + private createElement(state: EditorState, node: EditorNode): HTMLElement | null { + if (isTextNode(node)) { + return this.createTextElement(node); + } + + if (isDecoratorNode(node)) { + return this.createDecoratorElement(node); + } + + if (isElementNode(node)) { + return this.createElementNode(state, node); + } + + return null; + } + + private createTextElement(node: TextNode): HTMLElement { + const span = document.createElement('span'); + span.setAttribute(DATA_KEY_ATTR, node.key); + this.updateTextContent(span, node); + return span; + } + + private updateTextContent(element: HTMLElement, node: TextNode): void { + // Build formatted content + let content = node.text || ZERO_WIDTH_SPACE; + + // Clear existing content + element.textContent = ''; + + // Apply formatting by wrapping in elements + let currentElement: HTMLElement = element; + + if (node.format.bold) { + const strong = document.createElement('strong'); + currentElement.appendChild(strong); + currentElement = strong; + } + + if (node.format.italic) { + const em = document.createElement('em'); + currentElement.appendChild(em); + currentElement = em; + } + + if (node.format.underline) { + const u = document.createElement('u'); + currentElement.appendChild(u); + currentElement = u; + } + + if (node.format.strikethrough) { + const s = document.createElement('s'); + currentElement.appendChild(s); + currentElement = s; + } + + if (node.format.code) { + const code = document.createElement('code'); + currentElement.appendChild(code); + currentElement = code; + } + + currentElement.textContent = content; + } + + private createElementNode(state: EditorState, node: EditorNode): HTMLElement { + let element: HTMLElement; + + switch (node.type) { + case 'paragraph': + element = document.createElement('p'); + element.className = 'editorParagraph'; + // Check if paragraph is empty (only has empty text node) + if (this.isBlockEmpty(state, node)) { + element.classList.add('editorEmptyBlock'); + } + break; + + case 'heading': + const headingNode = node as HeadingNode; + element = document.createElement(`h${headingNode.level}`); + element.className = `editorH${headingNode.level}`; + break; + + case 'list': + const listNode = node as ListNode; + element = document.createElement(listNode.listType === 'number' ? 'ol' : 'ul'); + element.className = listNode.listType === 'number' ? 'editorOList' : 'editorUList'; + break; + + case 'listitem': + element = document.createElement('li'); + element.className = 'editorListItem'; + const listItemNode = node as ListItemNode; + if (listItemNode.indent > 0) { + element.style.marginLeft = `${listItemNode.indent * 24}px`; + } + break; + + case 'quote': + element = document.createElement('blockquote'); + element.className = 'editorQuote'; + break; + + case 'code': + element = document.createElement('pre'); + element.className = 'editorCode'; + const codeInner = document.createElement('code'); + const codeNode = node as CodeNode; + if (codeNode.language) { + codeInner.className = `language-${codeNode.language}`; + } + element.appendChild(codeInner); + break; + + case 'link': + element = document.createElement('a'); + element.className = 'editorLink'; + const linkNode = node as LinkNode; + element.setAttribute('href', linkNode.url); + element.setAttribute('target', '_blank'); + element.setAttribute('rel', 'noopener noreferrer'); + break; + + case 'table': + element = this.createTableElement(state, node as TableNode); + return element; // Return early as table handles its own children + + case 'tablerow': + element = document.createElement('tr'); + element.className = 'editorTableRow'; + break; + + case 'tablecell': + const cellNode = node as TableCellNode; + element = document.createElement(cellNode.isHeader ? 'th' : 'td'); + element.className = 'editorTableCell'; + if (cellNode.colSpan && cellNode.colSpan > 1) { + element.setAttribute('colspan', String(cellNode.colSpan)); + } + if (cellNode.rowSpan && cellNode.rowSpan > 1) { + element.setAttribute('rowspan', String(cellNode.rowSpan)); + } + break; + + default: + element = document.createElement('div'); + } + + element.setAttribute(DATA_KEY_ATTR, node.key); + + // Recursively create children + if (isElementNode(node)) { + const childContainer = node.type === 'code' + ? element.querySelector('code') || element + : element; + + node.children.forEach(childKey => { + const childNode = getNode(state, childKey); + if (childNode) { + const childElement = this.createElement(state, childNode); + if (childElement) { + childContainer.appendChild(childElement); + } + } + }); + } + + return element; + } + + private createDecoratorElement(node: EditorNode): HTMLElement { + if (node.type === 'image') { + return this.createImageElement(node as ImageNode); + } + + if (node.type === 'drawio') { + return this.createDrawioElement(node as DrawioNode); + } + + const element = document.createElement('div'); + element.setAttribute(DATA_KEY_ATTR, node.key); + element.setAttribute('contenteditable', 'false'); + return element; + } + + private createImageElement(node: ImageNode): HTMLElement { + const wrapper = document.createElement('div'); + wrapper.setAttribute(DATA_KEY_ATTR, node.key); + wrapper.setAttribute('contenteditable', 'false'); + wrapper.className = 'editorImageWrapper'; + + const img = document.createElement('img'); + img.src = node.src; + img.alt = node.alt; + img.className = 'editorImage'; + if (node.width) img.width = node.width; + if (node.height) img.height = node.height; + + if (this.config.onImageClick) { + wrapper.style.cursor = 'pointer'; + wrapper.onclick = () => this.config.onImageClick?.(node); + } + + wrapper.appendChild(img); + return wrapper; + } + + private createDrawioElement(node: DrawioNode): HTMLElement { + const wrapper = document.createElement('div'); + wrapper.setAttribute(DATA_KEY_ATTR, node.key); + wrapper.setAttribute('contenteditable', 'false'); + wrapper.className = 'editorDrawioWrapper'; + + const iframe = document.createElement('iframe'); + iframe.className = 'editorDrawioIframe'; + iframe.frameBorder = '0'; + iframe.width = '100%'; + iframe.height = '400'; + + // Encode the XML and create the viewer URL + const encodedXML = encodeURIComponent(node.diagramXML); + iframe.src = `https://viewer.diagrams.net/?lightbox=1&nav=1&edit=_blank#R${encodedXML}`; + + wrapper.appendChild(iframe); + return wrapper; + } + + private createTableElement(state: EditorState, node: TableNode): HTMLElement { + const wrapper = document.createElement('div'); + wrapper.setAttribute(DATA_KEY_ATTR, node.key); + wrapper.className = 'editorTableWrapper'; + + // Create the actual table + const table = document.createElement('table'); + table.className = 'editorTable'; + + const tbody = document.createElement('tbody'); + + // Create rows and cells + node.children.forEach((rowKey, _rowIndex) => { + const rowNode = getNode(state, rowKey); + if (rowNode && rowNode.type === 'tablerow') { + const tr = document.createElement('tr'); + tr.setAttribute(DATA_KEY_ATTR, rowKey); + tr.className = 'editorTableRow'; + + (rowNode as TableRowNode).children.forEach((cellKey, colIndex) => { + const cellNode = getNode(state, cellKey); + if (cellNode && cellNode.type === 'tablecell') { + const cell = cellNode as TableCellNode; + const td = document.createElement(cell.isHeader ? 'th' : 'td'); + td.setAttribute(DATA_KEY_ATTR, cellKey); + td.className = 'editorTableCell'; + + if (cell.colSpan && cell.colSpan > 1) { + td.setAttribute('colspan', String(cell.colSpan)); + } + if (cell.rowSpan && cell.rowSpan > 1) { + td.setAttribute('rowspan', String(cell.rowSpan)); + } + + // Create cell content + cell.children.forEach(childKey => { + const childNode = getNode(state, childKey); + if (childNode) { + const childElement = this.createElement(state, childNode); + if (childElement) { + td.appendChild(childElement); + } + } + }); + + // Add resize handle to each cell (including last column) + const resizeHandle = document.createElement('div'); + resizeHandle.className = 'editorTableResizeHandle'; + resizeHandle.setAttribute('contenteditable', 'false'); + resizeHandle.setAttribute('data-col-index', String(colIndex)); + td.appendChild(resizeHandle); + + tr.appendChild(td); + } + }); + + tbody.appendChild(tr); + } + }); + + table.appendChild(tbody); + + // Add row button + const addRowBtn = document.createElement('button'); + addRowBtn.className = 'editorTableAddRow'; + addRowBtn.innerHTML = '+'; + addRowBtn.setAttribute('contenteditable', 'false'); + addRowBtn.setAttribute('data-table-key', node.key); + addRowBtn.setAttribute('data-action', 'add-row'); + + // Add column button + const addColBtn = document.createElement('button'); + addColBtn.className = 'editorTableAddCol'; + addColBtn.innerHTML = '+'; + addColBtn.setAttribute('contenteditable', 'false'); + addColBtn.setAttribute('data-table-key', node.key); + addColBtn.setAttribute('data-action', 'add-col'); + + wrapper.appendChild(table); + wrapper.appendChild(addRowBtn); + wrapper.appendChild(addColBtn); + + return wrapper; + } + + private updateElement(state: EditorState, node: EditorNode, element: HTMLElement): void { + if (isTextNode(node)) { + this.updateTextContent(element, node); + return; + } + + if (isElementNode(node)) { + // Update children + const childContainer = node.type === 'code' + ? element.querySelector('code') || element + : element; + + this.reconcileChildren(state, node.children, childContainer as HTMLElement); + } + + // Update empty block class for paragraphs + if (node.type === 'paragraph') { + if (this.isBlockEmpty(state, node)) { + element.classList.add('editorEmptyBlock'); + } else { + element.classList.remove('editorEmptyBlock'); + } + } + + // Update specific attributes based on node type + if (node.type === 'heading') { + const headingNode = node as HeadingNode; + element.className = `editorH${headingNode.level}`; + } + + if (node.type === 'link') { + const linkNode = node as LinkNode; + element.setAttribute('href', linkNode.url); + } + + if (node.type === 'listitem') { + const listItemNode = node as ListItemNode; + element.style.marginLeft = listItemNode.indent > 0 ? `${listItemNode.indent * 24}px` : ''; + } + } +} diff --git a/src/components/Editor/core/EditorCore.ts b/src/components/Editor/core/EditorCore.ts new file mode 100644 index 0000000000..42d7e0c881 --- /dev/null +++ b/src/components/Editor/core/EditorCore.ts @@ -0,0 +1,2213 @@ +import { + EditorState, + EditorSelection, + EditorCommand, + TextFormat, + HeadingLevel, + ListType, + ListNode, + TextNode, + isTextNode, + isElementNode, + ParagraphNode, +} from './types'; +import { + createEmptyState, + cloneState, + getNode, + getBlockAncestor, + findFirstTextNode, + findLastTextNode, + getPreviousSibling, + createTextNode, + createParagraphNode, + createHeadingNode, + createQuoteNode, + createCodeNode, + createListNode, + createListItemNode, + createImageNode, + createDrawioNode, + createTableNode, + createTableRowNode, + createTableCellNode, + insertNode, + removeNode, + updateNode, + serializeState, + deserializeState, +} from './EditorState'; +import { DOMReconciler } from './DOMReconciler'; +import { + getSelection, + setSelection, + createCollapsedSelection, +} from './Selection'; +import { HistoryManager } from './History'; + +export type EditorListener = (state: EditorState, selection: EditorSelection | null) => void; + +export interface EditorCoreConfig { + initialState?: string; + onChange?: (serializedState: string) => void; +} + +export class EditorCore { + private state: EditorState; + private selection: EditorSelection | null = null; + private container: HTMLElement | null = null; + private reconciler: DOMReconciler; + private history: HistoryManager; + private listeners: Set = new Set(); + private config: EditorCoreConfig; + private isComposing: boolean = false; + private isRendering: boolean = false; + + constructor(config: EditorCoreConfig = {}) { + this.config = config; + + // Initialize state + if (config.initialState) { + const parsed = deserializeState(config.initialState); + // Validate parsed state - ensure we can find a text node + if (parsed && findFirstTextNode(parsed, parsed.root)) { + this.state = parsed; + } else { + console.log('EditorCore: invalid initial state, creating empty state'); + this.state = createEmptyState(); + } + } else { + this.state = createEmptyState(); + } + + this.reconciler = new DOMReconciler(); + this.history = new HistoryManager(); + + // Push initial state to history + this.history.forcePush(this.state, null); + } + + // Mount editor to DOM element + mount(element: HTMLElement): void { + this.container = element; + + // Set up contentEditable + element.contentEditable = 'true'; + element.setAttribute('data-editor-root', 'true'); + element.setAttribute('spellcheck', 'true'); + + // Initial render + this.reconciler.reconcile(this.state, element); + + // Set up event listeners + this.setupEventListeners(); + + // Focus and set initial selection + element.focus(); + const firstText = findFirstTextNode(this.state, this.state.root); + if (firstText) { + this.selection = createCollapsedSelection(firstText.key, 0); + setSelection(this.selection, this.state, element); + } + } + + // Unmount and clean up + destroy(): void { + if (this.container) { + this.container.removeEventListener('beforeinput', this.handleBeforeInput); + this.container.removeEventListener('keydown', this.handleKeyDown); + this.container.removeEventListener('click', this.handleClick); + this.container.removeEventListener('mousedown', this.handleMouseDown); + this.container.removeEventListener('compositionstart', this.handleCompositionStart); + this.container.removeEventListener('compositionend', this.handleCompositionEnd); + this.container.removeEventListener('paste', this.handlePaste); + document.removeEventListener('selectionchange', this.handleSelectionChange); + } + this.listeners.clear(); + } + + // Subscribe to state changes + subscribe(listener: EditorListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + // Get current state + getState(): EditorState { + return this.state; + } + + // Get current selection + getSelection(): EditorSelection | null { + return this.selection; + } + + // Get container element + getContainer(): HTMLElement | null { + return this.container; + } + + // Dispatch a command + dispatchCommand(command: EditorCommand): void { + switch (command.type) { + case 'INSERT_TEXT': + this.insertText((command.payload as { text: string }).text); + break; + case 'INSERT_PARAGRAPH': + this.insertParagraph(); + break; + case 'DELETE_BACKWARD': + this.deleteBackward(); + break; + case 'DELETE_FORWARD': + this.deleteForward(); + break; + case 'DELETE_RANGE': + const rangePayload = command.payload as { nodeKey: string; startOffset: number; endOffset: number }; + this.deleteRange(rangePayload.nodeKey, rangePayload.startOffset, rangePayload.endOffset); + break; + case 'FORMAT_TEXT': + this.formatText((command.payload as { format: keyof TextFormat }).format); + break; + case 'SET_BLOCK_TYPE': + const blockPayload = command.payload as { blockType: string; level?: HeadingLevel }; + this.setBlockType(blockPayload.blockType, blockPayload.level); + break; + case 'TOGGLE_LIST': + this.toggleList((command.payload as { listType: ListType }).listType); + break; + case 'INSERT_IMAGE': + const imagePayload = command.payload as { src: string; alt: string }; + this.insertImage(imagePayload.src, imagePayload.alt); + break; + case 'INSERT_DRAWIO': + const drawioPayload = command.payload as { diagramXML: string }; + this.insertDrawio(drawioPayload.diagramXML); + break; + case 'INSERT_HTML': + const htmlPayload = command.payload as { html: string }; + this.insertHtml(htmlPayload.html); + break; + case 'INSERT_TABLE': + const tablePayload = command.payload as { rows: number; cols: number }; + this.insertTable(tablePayload.rows, tablePayload.cols); + break; + case 'ADD_TABLE_ROW': + this.addTableRow((command.payload as { tableKey: string }).tableKey); + break; + case 'ADD_TABLE_COL': + this.addTableCol((command.payload as { tableKey: string }).tableKey); + break; + case 'TABLE_NAV_NEXT': + this.tableNavNext(); + break; + case 'TABLE_NAV_PREV': + this.tableNavPrev(); + break; + case 'INSERT_TABLE_ROW_AT': + const insertRowPayload = command.payload as { tableKey: string; atIndex: number }; + this.insertTableRowAt(insertRowPayload.tableKey, insertRowPayload.atIndex); + break; + case 'INSERT_TABLE_COL_AT': + const insertColPayload = command.payload as { tableKey: string; atIndex: number }; + this.insertTableColAt(insertColPayload.tableKey, insertColPayload.atIndex); + break; + case 'DELETE_TABLE_ROW': + const delRowPayload = command.payload as { tableKey: string; rowIndex: number }; + this.deleteTableRow(delRowPayload.tableKey, delRowPayload.rowIndex); + break; + case 'DELETE_TABLE_COL': + const delColPayload = command.payload as { tableKey: string; colIndex: number }; + this.deleteTableCol(delColPayload.tableKey, delColPayload.colIndex); + break; + case 'MOVE_TABLE_ROW': + const moveRowPayload = command.payload as { tableKey: string; fromIndex: number; toIndex: number }; + this.moveTableRow(moveRowPayload.tableKey, moveRowPayload.fromIndex, moveRowPayload.toIndex); + break; + case 'MOVE_TABLE_COL': + const moveColPayload = command.payload as { tableKey: string; fromIndex: number; toIndex: number }; + this.moveTableCol(moveColPayload.tableKey, moveColPayload.fromIndex, moveColPayload.toIndex); + break; + case 'DUPLICATE_TABLE_ROW': + const dupRowPayload = command.payload as { tableKey: string; rowIndex: number }; + this.duplicateTableRow(dupRowPayload.tableKey, dupRowPayload.rowIndex); + break; + case 'DUPLICATE_TABLE_COL': + const dupColPayload = command.payload as { tableKey: string; colIndex: number }; + this.duplicateTableCol(dupColPayload.tableKey, dupColPayload.colIndex); + break; + case 'DUPLICATE_BLOCK': + const dupBlockPayload = command.payload as { blockKey: string }; + this.duplicateBlock(dupBlockPayload.blockKey); + break; + case 'DELETE_BLOCK': + const delBlockPayload = command.payload as { blockKey: string }; + this.deleteBlock(delBlockPayload.blockKey); + break; + case 'MOVE_BLOCK': + const moveBlockPayload = command.payload as { blockKey: string; targetKey: string; position: 'before' | 'after' }; + this.moveBlock(moveBlockPayload.blockKey, moveBlockPayload.targetKey, moveBlockPayload.position); + break; + case 'UNDO': + this.undo(); + break; + case 'REDO': + this.redo(); + break; + } + } + + // Check if can undo/redo + canUndo(): boolean { + return this.history.canUndo(); + } + + canRedo(): boolean { + return this.history.canRedo(); + } + + // Check current formatting at selection + getActiveFormats(): TextFormat { + if (!this.selection) return {}; + const node = getNode(this.state, this.selection.anchor.key); + if (node && isTextNode(node)) { + return { ...node.format }; + } + return {}; + } + + // Check current block type at selection + getActiveBlockType(): string { + if (!this.selection) return 'paragraph'; + const block = getBlockAncestor(this.state, this.selection.anchor.key); + return block?.type || 'paragraph'; + } + + // Private methods + + private setupEventListeners(): void { + if (!this.container) return; + + this.container.addEventListener('beforeinput', this.handleBeforeInput); + this.container.addEventListener('keydown', this.handleKeyDown); + this.container.addEventListener('click', this.handleClick); + this.container.addEventListener('mousedown', this.handleMouseDown); + this.container.addEventListener('compositionstart', this.handleCompositionStart); + this.container.addEventListener('compositionend', this.handleCompositionEnd); + this.container.addEventListener('paste', this.handlePaste); + document.addEventListener('selectionchange', this.handleSelectionChange); + } + + private handleBeforeInput = (e: InputEvent): void => { + // Don't handle during IME composition + if (this.isComposing) return; + + e.preventDefault(); + + switch (e.inputType) { + case 'insertText': + if (e.data) this.insertText(e.data); + break; + case 'insertParagraph': + case 'insertLineBreak': + this.insertParagraph(); + break; + case 'deleteContentBackward': + this.deleteBackward(); + break; + case 'deleteContentForward': + this.deleteForward(); + break; + case 'deleteByCut': + this.deleteBackward(); + break; + case 'insertFromPaste': + // Handled by paste event listener + break; + } + }; + + private handleKeyDown = (e: KeyboardEvent): void => { + const isMod = e.metaKey || e.ctrlKey; + + // Undo: Cmd/Ctrl + Z + if (isMod && e.key === 'z' && !e.shiftKey) { + e.preventDefault(); + this.undo(); + return; + } + + // Redo: Cmd/Ctrl + Shift + Z or Cmd/Ctrl + Y + if ((isMod && e.key === 'z' && e.shiftKey) || (isMod && e.key === 'y')) { + e.preventDefault(); + this.redo(); + return; + } + + // Bold: Cmd/Ctrl + B + if (isMod && e.key === 'b') { + e.preventDefault(); + this.formatText('bold'); + return; + } + + // Italic: Cmd/Ctrl + I + if (isMod && e.key === 'i') { + e.preventDefault(); + this.formatText('italic'); + return; + } + + // Underline: Cmd/Ctrl + U + if (isMod && e.key === 'u') { + e.preventDefault(); + this.formatText('underline'); + return; + } + + // Escape to exit table + if (e.key === 'Escape') { + const tableCtx = this.getTableContext(); + if (tableCtx) { + e.preventDefault(); + this.exitTable(tableCtx.tableKey, 'after'); + return; + } + } + + // Arrow keys for table exit + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + const tableCtx = this.getTableContext(); + if (tableCtx) { + const table = getNode(this.state, tableCtx.tableKey) as any; + const rows = table.children; + + // Arrow Down from last row - exit below + if (e.key === 'ArrowDown' && tableCtx.rowIndex === rows.length - 1) { + e.preventDefault(); + this.exitTable(tableCtx.tableKey, 'after'); + return; + } + + // Arrow Up from first row - exit above + if (e.key === 'ArrowUp' && tableCtx.rowIndex === 0) { + e.preventDefault(); + this.exitTable(tableCtx.tableKey, 'before'); + return; + } + } + } + + // Tab for table navigation or list indent + if (e.key === 'Tab') { + // Check if we're in a table + const tableCtx = this.getTableContext(); + if (tableCtx) { + e.preventDefault(); + if (e.shiftKey) { + this.tableNavPrev(); + } else { + this.tableNavNext(); + } + return; + } + + const block = this.selection ? getBlockAncestor(this.state, this.selection.anchor.key) : null; + if (block?.type === 'listitem') { + e.preventDefault(); + // TODO: Implement indent/outdent + } + } + }; + + private handleClick = (e: MouseEvent): void => { + const target = e.target as HTMLElement; + + // Handle table add row/col buttons + const action = target.getAttribute('data-action'); + const tableKey = target.getAttribute('data-table-key'); + + if (action && tableKey) { + e.preventDefault(); + e.stopPropagation(); + + if (action === 'add-row') { + this.addTableRow(tableKey); + } else if (action === 'add-col') { + this.addTableCol(tableKey); + } + } + }; + + private handleMouseDown = (e: MouseEvent): void => { + const target = e.target as HTMLElement; + + // Handle table column resize + if (target.classList.contains('editorTableResizeHandle')) { + e.preventDefault(); + e.stopPropagation(); + + const cell = target.closest('.editorTableCell') as HTMLElement; + if (!cell) return; + + const table = target.closest('.editorTable') as HTMLTableElement; + if (!table) return; + + const startX = e.clientX; + const startWidth = cell.offsetWidth; + const colIndex = parseInt(target.getAttribute('data-col-index') || '0'); + + // Add resizing class + target.classList.add('resizing'); + + const handleMouseMove = (moveEvent: MouseEvent) => { + const delta = moveEvent.clientX - startX; + const newWidth = Math.max(60, startWidth + delta); + + // Update width for all cells in this column + const rows = table.querySelectorAll('tr'); + rows.forEach(row => { + const cells = row.querySelectorAll('td, th'); + if (cells[colIndex]) { + (cells[colIndex] as HTMLElement).style.width = `${newWidth}px`; + (cells[colIndex] as HTMLElement).style.minWidth = `${newWidth}px`; + } + }); + }; + + const handleMouseUp = () => { + target.classList.remove('resizing'); + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + } + }; + + private handleCompositionStart = (): void => { + this.isComposing = true; + }; + + private handleCompositionEnd = (e: CompositionEvent): void => { + this.isComposing = false; + if (e.data) { + this.insertText(e.data); + } + }; + + private handlePaste = (e: ClipboardEvent): void => { + e.preventDefault(); + + const clipboardData = e.clipboardData; + if (!clipboardData) return; + + // Try to get plain text first + const text = clipboardData.getData('text/plain'); + if (text) { + // Split by newlines and insert each line + const lines = text.split(/\r?\n/); + lines.forEach((line, index) => { + if (index > 0) { + // Insert paragraph break for each newline + this.dispatchCommand({ type: 'INSERT_PARAGRAPH' }); + } + if (line) { + this.insertText(line); + } + }); + } + }; + + private handleSelectionChange = (): void => { + if (!this.container) return; + + // Don't update selection during composition or rendering + if (this.isComposing || this.isRendering) { + return; + } + + const newSelection = getSelection(this.state, this.container); + if (newSelection) { + this.selection = newSelection; + this.updateFocusedBlock(); + this.notifyListeners(); + } + }; + + private updateFocusedBlock(): void { + if (!this.container || !this.selection) return; + + // Remove focused class from all blocks + this.container.querySelectorAll('.editorFocusedBlock').forEach(el => { + el.classList.remove('editorFocusedBlock'); + }); + + // Find the block containing the selection + const block = getBlockAncestor(this.state, this.selection.anchor.key); + if (block) { + const blockElement = this.container.querySelector(`[data-editor-key="${block.key}"]`); + if (blockElement) { + blockElement.classList.add('editorFocusedBlock'); + } + } + } + + private insertText(text: string): void { + console.log('insertText called:', { text, selection: this.selection }); + if (!this.selection) { + console.log('insertText: no selection'); + return; + } + + const node = getNode(this.state, this.selection.anchor.key); + console.log('insertText: node for key', this.selection.anchor.key, ':', node?.type, node ? (node as any).text : 'N/A'); + if (!node || !isTextNode(node)) { + console.log('insertText: node not found or not text node'); + return; + } + + // Save to history before change + this.history.push(this.state, this.selection); + + // If there's a selection, delete it first (without rendering) + if (!this.selection.isCollapsed) { + this.deleteSelectionInternal(); + // Re-get the node after deletion as state changed + const updatedNode = getNode(this.state, this.selection.anchor.key); + if (!updatedNode || !isTextNode(updatedNode)) return; + } + + // Get node again (might have been updated by deleteSelectionInternal) + const currentNode = getNode(this.state, this.selection.anchor.key); + if (!currentNode || !isTextNode(currentNode)) return; + + // Insert text at cursor + const before = currentNode.text.slice(0, this.selection.anchor.offset); + const after = currentNode.text.slice(this.selection.anchor.offset); + const newText = before + text + after; + + this.state = updateNode(this.state, currentNode.key, { text: newText }); + + // Update selection + const newOffset = this.selection.anchor.offset + text.length; + this.selection = createCollapsedSelection(currentNode.key, newOffset); + + this.render(); + } + + private insertParagraph(): void { + if (!this.selection) return; + + const node = getNode(this.state, this.selection.anchor.key); + if (!node || !isTextNode(node)) return; + + const block = getBlockAncestor(this.state, node.key); + if (!block || !block.parent) return; + + // Save to history + this.history.push(this.state, this.selection); + + // Check if we're in a list item + if (block.type === 'listitem') { + const listNode = getNode(this.state, block.parent); + if (listNode?.type === 'list') { + // Check if this list item is completely empty (no text at all) + const isEmptyItem = node.text === '' && this.selection.anchor.offset === 0; + + if (isEmptyItem) { + // Empty list item - exit the list and create paragraph + this.exitListItem(block, listNode as ListNode, node); + return; + } else { + // Non-empty list item - create new list item below + this.splitListItem(block, listNode as ListNode, node); + return; + } + } + } + + // Regular paragraph/heading/quote - split and create new paragraph + const before = node.text.slice(0, this.selection.anchor.offset); + const after = node.text.slice(this.selection.anchor.offset); + + // Update current text node + this.state = updateNode(this.state, node.key, { text: before }); + + // Create new paragraph with remaining text + const newText = createTextNode(after, { ...node.format }); + const newParagraph = createParagraphNode([newText.key]); + newText.parent = newParagraph.key; + + // Insert new paragraph after current block + const parent = getNode(this.state, block.parent); + if (parent && isElementNode(parent)) { + const blockIndex = parent.children.indexOf(block.key); + this.state = insertNode(this.state, block.parent, newParagraph, blockIndex + 1); + this.state.nodeMap.set(newText.key, newText); + } + + // Move selection to start of new paragraph + this.selection = createCollapsedSelection(newText.key, 0); + + this.render(); + } + + private exitListItem(listItem: any, listNode: ListNode, _textNode: TextNode): void { + const grandParent = getNode(this.state, listNode.parent!); + if (!grandParent || !isElementNode(grandParent)) return; + + const newState = cloneState(this.state); + const listInNewState = newState.nodeMap.get(listNode.key) as ListNode; + const grandParentInNewState = newState.nodeMap.get(listNode.parent!) as any; + + const listIndex = grandParentInNewState.children.indexOf(listNode.key); + const itemIndex = listInNewState.children.indexOf(listItem.key); + + // Get text children from listitem + const textChildren = isElementNode(listItem) ? [...(listItem as ParagraphNode).children] : []; + + // Create new paragraph + const newParagraph = createParagraphNode(textChildren); + newParagraph.parent = listNode.parent; + + // Update text children's parent + textChildren.forEach(childKey => { + const child = newState.nodeMap.get(childKey); + if (child) { + child.parent = newParagraph.key; + } + }); + + // Add paragraph to nodeMap + newState.nodeMap.set(newParagraph.key, newParagraph); + + // Remove listitem from list + listInNewState.children.splice(itemIndex, 1); + newState.nodeMap.delete(listItem.key); + + // Insert paragraph after the list + grandParentInNewState.children.splice(listIndex + 1, 0, newParagraph.key); + + // If list is now empty, remove it + if (listInNewState.children.length === 0) { + const listIdx = grandParentInNewState.children.indexOf(listNode.key); + if (listIdx !== -1) { + grandParentInNewState.children.splice(listIdx, 1); + } + newState.nodeMap.delete(listNode.key); + } + + this.state = newState; + + // Update selection to new paragraph + const firstText = findFirstTextNode(this.state, newParagraph.key); + if (firstText) { + this.selection = createCollapsedSelection(firstText.key, 0); + } + + this.render(); + } + + private splitListItem(listItem: any, listNode: ListNode, textNode: TextNode): void { + const before = textNode.text.slice(0, this.selection!.anchor.offset); + const after = textNode.text.slice(this.selection!.anchor.offset); + + const newState = cloneState(this.state); + const listInNewState = newState.nodeMap.get(listNode.key) as ListNode; + + // Update current text node + const currentTextNode = newState.nodeMap.get(textNode.key) as TextNode; + currentTextNode.text = before; + + // Create new text and list item + const newText = createTextNode(after, { ...textNode.format }); + const newListItem = createListItemNode([newText.key], 0); + newText.parent = newListItem.key; + newListItem.parent = listNode.key; + + // Add to nodeMap + newState.nodeMap.set(newText.key, newText); + newState.nodeMap.set(newListItem.key, newListItem); + + // Insert new list item after current one + const itemIndex = listInNewState.children.indexOf(listItem.key); + listInNewState.children.splice(itemIndex + 1, 0, newListItem.key); + + this.state = newState; + this.selection = createCollapsedSelection(newText.key, 0); + + this.render(); + } + + private deleteBackward(): void { + if (!this.selection) return; + + // Handle range selection (selected text) + if (!this.selection.isCollapsed) { + this.deleteSelection(); + return; + } + + const node = getNode(this.state, this.selection.anchor.key); + if (!node || !isTextNode(node)) return; + + // Save to history + this.history.push(this.state, this.selection); + + if (this.selection.anchor.offset > 0) { + // Delete character before cursor + const newText = node.text.slice(0, this.selection.anchor.offset - 1) + + node.text.slice(this.selection.anchor.offset); + this.state = updateNode(this.state, node.key, { text: newText }); + this.selection = createCollapsedSelection(node.key, this.selection.anchor.offset - 1); + } else { + // At start of text node + const block = getBlockAncestor(this.state, node.key); + if (!block) return; + + // If block is a heading/quote at start, convert to paragraph + if (block.type === 'heading' || block.type === 'quote') { + const children = isElementNode(block) ? [...block.children] : []; + const parent = block.parent; + if (!parent) return; + + const parentNode = getNode(this.state, parent); + if (!parentNode || !isElementNode(parentNode)) return; + + const blockIndex = parentNode.children.indexOf(block.key); + + // Remove old block + this.state = removeNode(this.state, block.key); + + // Create new paragraph with same children + const newParagraph = createParagraphNode(children); + children.forEach(childKey => { + const child = this.state.nodeMap.get(childKey); + if (child) { + child.parent = newParagraph.key; + } + }); + + // Insert new paragraph + this.state = insertNode(this.state, parent, newParagraph, blockIndex); + + // Keep cursor at start + this.selection = createCollapsedSelection(node.key, 0); + } else { + // Try to merge with previous block + const prevBlock = getPreviousSibling(this.state, block.key); + if (prevBlock && isElementNode(prevBlock)) { + const lastText = findLastTextNode(this.state, prevBlock.key); + if (lastText) { + const cursorPos = lastText.text.length; + + // Merge text content + const mergedText = lastText.text + node.text; + this.state = updateNode(this.state, lastText.key, { text: mergedText }); + + // Remove current block + this.state = removeNode(this.state, block.key); + + // Set cursor to merge point + this.selection = createCollapsedSelection(lastText.key, cursorPos); + } + } + } + } + + this.render(); + } + + private deleteSelection(): void { + if (!this.selection || this.selection.isCollapsed) return; + + // Save to history + this.history.push(this.state, this.selection); + + this.deleteSelectionInternal(); + this.render(); + } + + private deleteRange(nodeKey: string, startOffset: number, endOffset: number): void { + console.log('deleteRange called:', { nodeKey, startOffset, endOffset }); + const node = getNode(this.state, nodeKey); + if (!node || !isTextNode(node)) { + console.log('deleteRange: node not found or not text'); + return; + } + + // Validate offsets + const textLen = node.text.length; + const start = Math.max(0, Math.min(startOffset, textLen)); + const end = Math.max(start, Math.min(endOffset, textLen)); + + console.log('deleteRange: text before:', JSON.stringify(node.text), 'deleting', start, 'to', end); + + if (start === end) return; + + // Save to history + this.history.push(this.state, this.selection); + + // Delete the range + const newText = node.text.slice(0, start) + node.text.slice(end); + this.state = updateNode(this.state, nodeKey, { text: newText }); + + console.log('deleteRange: text after:', JSON.stringify(newText)); + + // Set cursor at start of deleted range + this.selection = createCollapsedSelection(nodeKey, start); + console.log('deleteRange: selection set to', nodeKey, start); + + this.render(); + } + + private deleteSelectionInternal(): void { + if (!this.selection || this.selection.isCollapsed) return; + + const { anchor, focus } = this.selection; + + // For now, handle simple case where selection is within same text node + if (anchor.key === focus.key) { + const node = getNode(this.state, anchor.key); + if (!node || !isTextNode(node)) return; + + const startOffset = Math.min(anchor.offset, focus.offset); + const endOffset = Math.max(anchor.offset, focus.offset); + + const newText = node.text.slice(0, startOffset) + node.text.slice(endOffset); + this.state = updateNode(this.state, node.key, { text: newText }); + this.selection = createCollapsedSelection(anchor.key, startOffset); + } else { + // Cross-node selection - need to handle properly + // Determine which is first (anchor or focus) by walking the tree + const anchorBlock = getBlockAncestor(this.state, anchor.key); + const focusBlock = getBlockAncestor(this.state, focus.key); + + if (!anchorBlock || !focusBlock) return; + + // Get root to find all blocks + const root = this.state.nodeMap.get(this.state.root); + if (!root || !isElementNode(root)) return; + + // Find indices of anchor and focus blocks + const anchorBlockIndex = root.children.indexOf(anchorBlock.key); + const focusBlockIndex = root.children.indexOf(focusBlock.key); + + // Determine start and end + let startKey = anchor.key; + let startOffset = anchor.offset; + let startBlockIndex = anchorBlockIndex; + let endKey = focus.key; + let endOffset = focus.offset; + let endBlockIndex = focusBlockIndex; + + if (anchorBlockIndex > focusBlockIndex || + (anchorBlockIndex === focusBlockIndex && anchor.offset > focus.offset)) { + // Swap - focus comes before anchor + startKey = focus.key; + startOffset = focus.offset; + startBlockIndex = focusBlockIndex; + endKey = anchor.key; + endOffset = anchor.offset; + endBlockIndex = anchorBlockIndex; + } + + // Get start and end nodes + const startNode = getNode(this.state, startKey); + const endNode = getNode(this.state, endKey); + + if (!startNode || !isTextNode(startNode) || !endNode || !isTextNode(endNode)) return; + + // Keep text before selection in start node + const keepText = startNode.text.slice(0, startOffset); + // Get text after selection in end node + const appendText = endNode.text.slice(endOffset); + + // Merge the text + this.state = updateNode(this.state, startKey, { text: keepText + appendText }); + + // Delete all blocks between start and end (exclusive of start block) + const blocksToDelete: string[] = []; + for (let i = startBlockIndex + 1; i <= endBlockIndex; i++) { + if (i < root.children.length) { + blocksToDelete.push(root.children[i]); + } + } + + // Delete blocks from end to start to avoid index shifting issues + for (let i = blocksToDelete.length - 1; i >= 0; i--) { + this.state = removeNode(this.state, blocksToDelete[i]); + } + + // Set cursor at merge point + this.selection = createCollapsedSelection(startKey, startOffset); + } + } + + private deleteForward(): void { + if (!this.selection) return; + + const node = getNode(this.state, this.selection.anchor.key); + if (!node || !isTextNode(node)) return; + + // Save to history + this.history.push(this.state, this.selection); + + if (this.selection.anchor.offset < node.text.length) { + // Delete character after cursor + const newText = node.text.slice(0, this.selection.anchor.offset) + + node.text.slice(this.selection.anchor.offset + 1); + this.state = updateNode(this.state, node.key, { text: newText }); + } else { + // At end of text node - try to merge with next + // TODO: Implement merge with next block + } + + this.render(); + } + + private formatText(format: keyof TextFormat): void { + if (!this.selection) return; + + const node = getNode(this.state, this.selection.anchor.key); + if (!node || !isTextNode(node)) return; + + // Save to history + this.history.push(this.state, this.selection); + + // Toggle format + const newFormat = { ...node.format }; + newFormat[format] = !newFormat[format]; + + this.state = updateNode(this.state, node.key, { format: newFormat }); + + this.render(); + } + + private setBlockType(blockType: string, level?: HeadingLevel): void { + console.log('setBlockType called:', { blockType, level, selection: this.selection }); + if (!this.selection) return; + + const block = getBlockAncestor(this.state, this.selection.anchor.key); + console.log('setBlockType: block ancestor:', block?.key, block?.type); + if (!block || !block.parent) return; + + // Save to history + this.history.push(this.state, this.selection); + + const parent = getNode(this.state, block.parent); + if (!parent || !isElementNode(parent)) return; + + const blockIndex = parent.children.indexOf(block.key); + const children = isElementNode(block) ? [...(block as ParagraphNode).children] : []; + console.log('setBlockType: children keys:', children); + + // Clone state for immutable update + const newState = cloneState(this.state); + + // Remove old block from parent's children (but don't delete descendants) + const parentInNewState = newState.nodeMap.get(block.parent); + if (parentInNewState && isElementNode(parentInNewState)) { + const idx = parentInNewState.children.indexOf(block.key); + if (idx !== -1) { + parentInNewState.children.splice(idx, 1); + } + } + // Remove old block from nodeMap (but keep children) + newState.nodeMap.delete(block.key); + + // Create new block + let newBlock; + switch (blockType) { + case 'heading': + newBlock = createHeadingNode(level || 1, children); + break; + case 'quote': + newBlock = createQuoteNode(children); + break; + case 'paragraph': + default: + newBlock = createParagraphNode(children); + } + + // Set new block's parent + newBlock.parent = block.parent; + + // Update children's parent reference to new block + children.forEach(childKey => { + const child = newState.nodeMap.get(childKey); + if (child) { + child.parent = newBlock.key; + } + }); + + // Add new block to nodeMap + newState.nodeMap.set(newBlock.key, newBlock); + + // Insert new block into parent at same position + if (parentInNewState && isElementNode(parentInNewState)) { + parentInNewState.children.splice(blockIndex, 0, newBlock.key); + } + + this.state = newState; + + // Update selection - find first text node and set cursor at end of text + const firstText = findFirstTextNode(this.state, newBlock.key); + console.log('setBlockType: firstText:', firstText?.key, 'text:', JSON.stringify(firstText?.text)); + if (firstText) { + // Put cursor at end of existing text + this.selection = createCollapsedSelection(firstText.key, firstText.text.length); + console.log('setBlockType: selection set to', firstText.key, firstText.text.length); + } + + this.render(); + } + + private toggleList(listType: ListType): void { + if (!this.selection) return; + + const block = getBlockAncestor(this.state, this.selection.anchor.key); + if (!block || !block.parent) return; + + // Save to history + this.history.push(this.state, this.selection); + + const parent = getNode(this.state, block.parent); + if (!parent) return; + + // Clone state for immutable update + const newState = cloneState(this.state); + + if (parent.type === 'list') { + // Already in a list - convert back to paragraph + const listNode = parent as ListNode; + const grandParent = getNode(newState, listNode.parent!); + if (!grandParent || !isElementNode(grandParent)) return; + + const listIndex = grandParent.children.indexOf(listNode.key); + + // Get text content from the listitem + const textChildren = isElementNode(block) ? [...(block as ParagraphNode).children] : []; + + // Create new paragraph with the text + const newParagraph = createParagraphNode(textChildren); + newParagraph.parent = listNode.parent; + + // Update text children's parent + textChildren.forEach(childKey => { + const child = newState.nodeMap.get(childKey); + if (child) { + child.parent = newParagraph.key; + } + }); + + // Add paragraph to nodeMap + newState.nodeMap.set(newParagraph.key, newParagraph); + + // Remove listitem from list + if (isElementNode(listNode)) { + const itemIndex = listNode.children.indexOf(block.key); + if (itemIndex !== -1) { + listNode.children.splice(itemIndex, 1); + } + } + + // Remove the listitem from nodeMap (but keep text children) + newState.nodeMap.delete(block.key); + + // Insert paragraph at list position + grandParent.children.splice(listIndex, 0, newParagraph.key); + + // If list is now empty, remove it + if (listNode.children.length === 0) { + const listIdx = grandParent.children.indexOf(listNode.key); + if (listIdx !== -1) { + grandParent.children.splice(listIdx, 1); + } + newState.nodeMap.delete(listNode.key); + } + + this.state = newState; + + // Update selection + const firstText = findFirstTextNode(this.state, newParagraph.key); + if (firstText) { + this.selection = createCollapsedSelection(firstText.key, firstText.text.length); + } + } else { + // Not in a list - convert block to list item + const parentNode = newState.nodeMap.get(block.parent); + if (!parentNode || !isElementNode(parentNode)) return; + + const blockIndex = parentNode.children.indexOf(block.key); + const textChildren = isElementNode(block) ? [...(block as ParagraphNode).children] : []; + + // Create list item with the text + const newListItem = createListItemNode(textChildren, 0); + + // Create list containing the item + const newList = createListNode(listType, [newListItem.key]); + newList.parent = block.parent; + newListItem.parent = newList.key; + + // Update text children's parent to listitem + textChildren.forEach(childKey => { + const child = newState.nodeMap.get(childKey); + if (child) { + child.parent = newListItem.key; + } + }); + + // Remove old block from parent + const idx = parentNode.children.indexOf(block.key); + if (idx !== -1) { + parentNode.children.splice(idx, 1); + } + newState.nodeMap.delete(block.key); + + // Add list and listitem to nodeMap + newState.nodeMap.set(newList.key, newList); + newState.nodeMap.set(newListItem.key, newListItem); + + // Insert list at block position + parentNode.children.splice(blockIndex, 0, newList.key); + + this.state = newState; + + // Update selection + const firstText = findFirstTextNode(this.state, newListItem.key); + if (firstText) { + this.selection = createCollapsedSelection(firstText.key, firstText.text.length); + } + } + + this.render(); + } + + private insertImage(src: string, alt: string): void { + if (!this.selection) return; + + const block = getBlockAncestor(this.state, this.selection.anchor.key); + if (!block || !block.parent) return; + + // Save to history + this.history.push(this.state, this.selection); + + const parent = getNode(this.state, block.parent); + if (!parent || !isElementNode(parent)) return; + + const blockIndex = parent.children.indexOf(block.key); + + // Create image node + const imageNode = createImageNode(src, alt); + + // Insert image after current block + this.state = insertNode(this.state, block.parent, imageNode, blockIndex + 1); + + // Create a new paragraph after the image for continued editing + const newText = createTextNode('', {}); + const newParagraph = createParagraphNode([newText.key]); + newText.parent = newParagraph.key; + + this.state = insertNode(this.state, block.parent, newParagraph, blockIndex + 2); + this.state.nodeMap.set(newText.key, newText); + + // Move selection to the new paragraph + this.selection = createCollapsedSelection(newText.key, 0); + + this.render(); + } + + private insertDrawio(diagramXML: string): void { + if (!this.selection) return; + + const block = getBlockAncestor(this.state, this.selection.anchor.key); + if (!block || !block.parent) return; + + // Save to history + this.history.push(this.state, this.selection); + + const parent = getNode(this.state, block.parent); + if (!parent || !isElementNode(parent)) return; + + const blockIndex = parent.children.indexOf(block.key); + + // Create drawio node + const drawioNode = createDrawioNode(diagramXML); + + // Insert drawio after current block + this.state = insertNode(this.state, block.parent, drawioNode, blockIndex + 1); + + // Create a new paragraph after the diagram for continued editing + const newText = createTextNode('', {}); + const newParagraph = createParagraphNode([newText.key]); + newText.parent = newParagraph.key; + + this.state = insertNode(this.state, block.parent, newParagraph, blockIndex + 2); + this.state.nodeMap.set(newText.key, newText); + + // Move selection to the new paragraph + this.selection = createCollapsedSelection(newText.key, 0); + + this.render(); + } + + private insertTable(rows: number, cols: number): void { + if (!this.selection) return; + + const block = getBlockAncestor(this.state, this.selection.anchor.key); + if (!block || !block.parent) return; + + // Save to history + this.history.push(this.state, this.selection); + + const parent = getNode(this.state, block.parent); + if (!parent || !isElementNode(parent)) return; + + const blockIndex = parent.children.indexOf(block.key); + + // Create table structure + const tableNode = createTableNode([]); + let firstCellTextKey: string | null = null; + + // First, insert the table node to get the new state + this.state = insertNode(this.state, block.parent, tableNode, blockIndex + 1); + + // Now build the table structure in the new state + for (let r = 0; r < rows; r++) { + const rowNode = createTableRowNode([]); + rowNode.parent = tableNode.key; + + for (let c = 0; c < cols; c++) { + const textNode = createTextNode('', {}); + const cellNode = createTableCellNode([textNode.key], false); + textNode.parent = cellNode.key; + cellNode.parent = rowNode.key; + + this.state.nodeMap.set(textNode.key, textNode); + this.state.nodeMap.set(cellNode.key, cellNode); + rowNode.children.push(cellNode.key); + + // Track first cell for selection + if (r === 0 && c === 0) { + firstCellTextKey = textNode.key; + } + } + + this.state.nodeMap.set(rowNode.key, rowNode); + // Add row to table's children in the state + const tableInState = this.state.nodeMap.get(tableNode.key) as any; + if (tableInState) { + tableInState.children.push(rowNode.key); + } + } + + // Create a new paragraph after the table for continued editing + const newText = createTextNode('', {}); + const newParagraph = createParagraphNode([newText.key]); + newText.parent = newParagraph.key; + + this.state = insertNode(this.state, block.parent, newParagraph, blockIndex + 2); + this.state.nodeMap.set(newText.key, newText); + + // Move selection to the first cell + if (firstCellTextKey) { + this.selection = createCollapsedSelection(firstCellTextKey, 0); + } + + this.render(); + } + + private addTableRow(tableKey: string): void { + const table = getNode(this.state, tableKey); + if (!table || table.type !== 'table') return; + + this.history.push(this.state, this.selection); + + // Get column count from first row + const firstRowKey = (table as any).children[0]; + const firstRow = getNode(this.state, firstRowKey); + if (!firstRow || firstRow.type !== 'tablerow') return; + + const colCount = (firstRow as any).children.length; + + // Create new row + const rowNode = createTableRowNode([]); + rowNode.parent = tableKey; + + for (let c = 0; c < colCount; c++) { + const textNode = createTextNode('', {}); + const cellNode = createTableCellNode([textNode.key], false); + textNode.parent = cellNode.key; + cellNode.parent = rowNode.key; + + this.state.nodeMap.set(textNode.key, textNode); + this.state.nodeMap.set(cellNode.key, cellNode); + rowNode.children.push(cellNode.key); + } + + this.state.nodeMap.set(rowNode.key, rowNode); + (this.state.nodeMap.get(tableKey) as any).children.push(rowNode.key); + + this.render(); + } + + private addTableCol(tableKey: string): void { + const table = getNode(this.state, tableKey); + if (!table || table.type !== 'table') return; + + this.history.push(this.state, this.selection); + + // Add a cell to each row + (table as any).children.forEach((rowKey: string) => { + const row = this.state.nodeMap.get(rowKey); + if (!row || row.type !== 'tablerow') return; + + const textNode = createTextNode('', {}); + const cellNode = createTableCellNode([textNode.key], false); + textNode.parent = cellNode.key; + cellNode.parent = rowKey; + + this.state.nodeMap.set(textNode.key, textNode); + this.state.nodeMap.set(cellNode.key, cellNode); + (row as any).children.push(cellNode.key); + }); + + this.render(); + } + + private insertTableRowAt(tableKey: string, atIndex: number): void { + const table = getNode(this.state, tableKey); + if (!table || table.type !== 'table') return; + + this.history.push(this.state, this.selection); + + const firstRowKey = (table as any).children[0]; + const firstRow = getNode(this.state, firstRowKey); + if (!firstRow || firstRow.type !== 'tablerow') return; + + const colCount = (firstRow as any).children.length; + + const rowNode = createTableRowNode([]); + rowNode.parent = tableKey; + + for (let c = 0; c < colCount; c++) { + const textNode = createTextNode('', {}); + const cellNode = createTableCellNode([textNode.key], false); + textNode.parent = cellNode.key; + cellNode.parent = rowNode.key; + + this.state.nodeMap.set(textNode.key, textNode); + this.state.nodeMap.set(cellNode.key, cellNode); + rowNode.children.push(cellNode.key); + } + + this.state.nodeMap.set(rowNode.key, rowNode); + const tableNode = this.state.nodeMap.get(tableKey) as any; + tableNode.children.splice(atIndex, 0, rowNode.key); + + this.render(); + } + + private insertTableColAt(tableKey: string, atIndex: number): void { + const table = getNode(this.state, tableKey); + if (!table || table.type !== 'table') return; + + this.history.push(this.state, this.selection); + + (table as any).children.forEach((rowKey: string) => { + const row = this.state.nodeMap.get(rowKey); + if (!row || row.type !== 'tablerow') return; + + const textNode = createTextNode('', {}); + const cellNode = createTableCellNode([textNode.key], false); + textNode.parent = cellNode.key; + cellNode.parent = rowKey; + + this.state.nodeMap.set(textNode.key, textNode); + this.state.nodeMap.set(cellNode.key, cellNode); + (row as any).children.splice(atIndex, 0, cellNode.key); + }); + + this.render(); + } + + private deleteTableRow(tableKey: string, rowIndex: number): void { + const table = getNode(this.state, tableKey); + if (!table || table.type !== 'table') return; + + const rows = (table as any).children; + if (rows.length <= 1) return; // Don't delete last row + + this.history.push(this.state, this.selection); + + const rowKey = rows[rowIndex]; + const row = this.state.nodeMap.get(rowKey); + + // Delete all cells and their content + if (row && (row as any).children) { + (row as any).children.forEach((cellKey: string) => { + const cell = this.state.nodeMap.get(cellKey); + if (cell && (cell as any).children) { + (cell as any).children.forEach((childKey: string) => { + this.state.nodeMap.delete(childKey); + }); + } + this.state.nodeMap.delete(cellKey); + }); + } + + this.state.nodeMap.delete(rowKey); + (table as any).children.splice(rowIndex, 1); + + this.render(); + } + + private deleteTableCol(tableKey: string, colIndex: number): void { + const table = getNode(this.state, tableKey); + if (!table || table.type !== 'table') return; + + const firstRow = this.state.nodeMap.get((table as any).children[0]); + if (!firstRow || (firstRow as any).children.length <= 1) return; // Don't delete last column + + this.history.push(this.state, this.selection); + + (table as any).children.forEach((rowKey: string) => { + const row = this.state.nodeMap.get(rowKey); + if (!row || row.type !== 'tablerow') return; + + const cellKey = (row as any).children[colIndex]; + if (cellKey) { + const cell = this.state.nodeMap.get(cellKey); + if (cell && (cell as any).children) { + (cell as any).children.forEach((childKey: string) => { + this.state.nodeMap.delete(childKey); + }); + } + this.state.nodeMap.delete(cellKey); + (row as any).children.splice(colIndex, 1); + } + }); + + this.render(); + } + + private moveTableRow(tableKey: string, fromIndex: number, toIndex: number): void { + const table = getNode(this.state, tableKey); + if (!table || table.type !== 'table') return; + + this.history.push(this.state, this.selection); + + const rows = (table as any).children; + const [removed] = rows.splice(fromIndex, 1); + rows.splice(toIndex, 0, removed); + + this.render(); + } + + private moveTableCol(tableKey: string, fromIndex: number, toIndex: number): void { + const table = getNode(this.state, tableKey); + if (!table || table.type !== 'table') return; + + this.history.push(this.state, this.selection); + + (table as any).children.forEach((rowKey: string) => { + const row = this.state.nodeMap.get(rowKey); + if (!row || row.type !== 'tablerow') return; + + const cells = (row as any).children; + const [removed] = cells.splice(fromIndex, 1); + cells.splice(toIndex, 0, removed); + }); + + this.render(); + } + + private duplicateTableRow(tableKey: string, rowIndex: number): void { + const table = getNode(this.state, tableKey); + if (!table || table.type !== 'table') return; + + this.history.push(this.state, this.selection); + + const sourceRowKey = (table as any).children[rowIndex]; + const sourceRow = this.state.nodeMap.get(sourceRowKey); + if (!sourceRow || sourceRow.type !== 'tablerow') return; + + const newRow = createTableRowNode([]); + newRow.parent = tableKey; + + (sourceRow as any).children.forEach((cellKey: string) => { + const sourceCell = this.state.nodeMap.get(cellKey); + if (!sourceCell || sourceCell.type !== 'tablecell') return; + + // Get text content from source cell + let textContent = ''; + (sourceCell as any).children.forEach((childKey: string) => { + const child = this.state.nodeMap.get(childKey); + if (child && child.type === 'text') { + textContent += (child as any).text; + } + }); + + const textNode = createTextNode(textContent, {}); + const cellNode = createTableCellNode([textNode.key], false); + textNode.parent = cellNode.key; + cellNode.parent = newRow.key; + + this.state.nodeMap.set(textNode.key, textNode); + this.state.nodeMap.set(cellNode.key, cellNode); + newRow.children.push(cellNode.key); + }); + + this.state.nodeMap.set(newRow.key, newRow); + (table as any).children.splice(rowIndex + 1, 0, newRow.key); + + this.render(); + } + + private duplicateTableCol(tableKey: string, colIndex: number): void { + const table = getNode(this.state, tableKey); + if (!table || table.type !== 'table') return; + + this.history.push(this.state, this.selection); + + (table as any).children.forEach((rowKey: string) => { + const row = this.state.nodeMap.get(rowKey); + if (!row || row.type !== 'tablerow') return; + + const sourceCellKey = (row as any).children[colIndex]; + const sourceCell = this.state.nodeMap.get(sourceCellKey); + if (!sourceCell || sourceCell.type !== 'tablecell') return; + + // Get text content from source cell + let textContent = ''; + (sourceCell as any).children.forEach((childKey: string) => { + const child = this.state.nodeMap.get(childKey); + if (child && child.type === 'text') { + textContent += (child as any).text; + } + }); + + const textNode = createTextNode(textContent, {}); + const cellNode = createTableCellNode([textNode.key], false); + textNode.parent = cellNode.key; + cellNode.parent = rowKey; + + this.state.nodeMap.set(textNode.key, textNode); + this.state.nodeMap.set(cellNode.key, cellNode); + (row as any).children.splice(colIndex + 1, 0, cellNode.key); + }); + + this.render(); + } + + private getTableContext(): { tableKey: string; rowIndex: number; colIndex: number } | null { + if (!this.selection) return null; + + // Walk up from current selection to find table cell and table + let current = getNode(this.state, this.selection.anchor.key); + let cellKey: string | null = null; + + while (current && current.parent) { + if (current.type === 'tablecell') { + cellKey = current.key; + break; + } + current = getNode(this.state, current.parent); + } + + if (!cellKey) return null; + + const cell = getNode(this.state, cellKey); + if (!cell || !cell.parent) return null; + + const row = getNode(this.state, cell.parent); + if (!row || row.type !== 'tablerow' || !row.parent) return null; + + const table = getNode(this.state, row.parent); + if (!table || table.type !== 'table') return null; + + const colIndex = (row as any).children.indexOf(cellKey); + const rowIndex = (table as any).children.indexOf(row.key); + + return { tableKey: table.key, rowIndex, colIndex }; + } + + private tableNavNext(): void { + const ctx = this.getTableContext(); + if (!ctx) return; + + const table = getNode(this.state, ctx.tableKey) as any; + const rows = table.children; + const currentRow = getNode(this.state, rows[ctx.rowIndex]) as any; + const colCount = currentRow.children.length; + + let nextRowIndex = ctx.rowIndex; + let nextColIndex = ctx.colIndex + 1; + + if (nextColIndex >= colCount) { + nextColIndex = 0; + nextRowIndex++; + } + + if (nextRowIndex >= rows.length) { + // At end of table, move to next block + const block = getNode(this.state, table.parent); + if (block && isElementNode(block)) { + const tableIndex = block.children.indexOf(ctx.tableKey); + if (tableIndex < block.children.length - 1) { + const nextBlock = getNode(this.state, block.children[tableIndex + 1]); + if (nextBlock) { + const firstText = findFirstTextNode(this.state, nextBlock.key); + if (firstText) { + this.selection = createCollapsedSelection(firstText.key, 0); + this.render(); + } + } + } + } + return; + } + + // Move to next cell + const nextRow = getNode(this.state, rows[nextRowIndex]) as any; + const nextCell = getNode(this.state, nextRow.children[nextColIndex]) as any; + const firstText = findFirstTextNode(this.state, nextCell.key); + if (firstText) { + this.selection = createCollapsedSelection(firstText.key, 0); + this.render(); + } + } + + private tableNavPrev(): void { + const ctx = this.getTableContext(); + if (!ctx) return; + + const table = getNode(this.state, ctx.tableKey) as any; + const rows = table.children; + const currentRow = getNode(this.state, rows[ctx.rowIndex]) as any; + const colCount = currentRow.children.length; + + let prevRowIndex = ctx.rowIndex; + let prevColIndex = ctx.colIndex - 1; + + if (prevColIndex < 0) { + prevColIndex = colCount - 1; + prevRowIndex--; + } + + if (prevRowIndex < 0) { + // At start of table, stay in first cell + return; + } + + // Move to previous cell + const prevRow = getNode(this.state, rows[prevRowIndex]) as any; + const prevCell = getNode(this.state, prevRow.children[prevColIndex]) as any; + const lastText = findLastTextNode(this.state, prevCell.key); + if (lastText) { + this.selection = createCollapsedSelection(lastText.key, lastText.text.length); + this.render(); + } + } + + private exitTable(tableKey: string, direction: 'before' | 'after'): void { + const table = getNode(this.state, tableKey); + if (!table || !table.parent) return; + + const parent = getNode(this.state, table.parent); + if (!parent || !isElementNode(parent)) return; + + const tableIndex = parent.children.indexOf(tableKey); + + if (direction === 'after') { + // Move to block after table + if (tableIndex < parent.children.length - 1) { + const nextBlock = getNode(this.state, parent.children[tableIndex + 1]); + if (nextBlock) { + const firstText = findFirstTextNode(this.state, nextBlock.key); + if (firstText) { + this.selection = createCollapsedSelection(firstText.key, 0); + this.render(); + return; + } + } + } + // No block after, create one + const newText = createTextNode('', {}); + const newParagraph = createParagraphNode([newText.key]); + newText.parent = newParagraph.key; + this.state.nodeMap.set(newText.key, newText); + this.state = insertNode(this.state, table.parent, newParagraph, tableIndex + 1); + this.selection = createCollapsedSelection(newText.key, 0); + this.render(); + } else { + // Move to block before table + if (tableIndex > 0) { + const prevBlock = getNode(this.state, parent.children[tableIndex - 1]); + if (prevBlock) { + const lastText = findLastTextNode(this.state, prevBlock.key); + if (lastText) { + this.selection = createCollapsedSelection(lastText.key, lastText.text.length); + this.render(); + return; + } + } + } + // No block before, create one + const newText = createTextNode('', {}); + const newParagraph = createParagraphNode([newText.key]); + newText.parent = newParagraph.key; + this.state.nodeMap.set(newText.key, newText); + this.state = insertNode(this.state, table.parent, newParagraph, tableIndex); + this.selection = createCollapsedSelection(newText.key, 0); + this.render(); + } + } + + private insertHtml(html: string): void { + if (!this.selection) return; + + const block = getBlockAncestor(this.state, this.selection.anchor.key); + if (!block || !block.parent) return; + + // Save to history + this.history.push(this.state, this.selection); + + const parent = getNode(this.state, block.parent); + if (!parent || !isElementNode(parent)) return; + + let insertIndex = parent.children.indexOf(block.key) + 1; + + // Parse HTML + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + + // Process each top-level element + const processElement = (element: Element): void => { + const tagName = element.tagName.toLowerCase(); + + // Handle headings + if (/^h[1-5]$/.test(tagName)) { + const level = parseInt(tagName[1]) as HeadingLevel; + const { textNodes } = this.createTextNodesFromElement(element); + if (textNodes.length > 0) { + const heading = createHeadingNode(level, textNodes.map(t => t.key)); + textNodes.forEach(t => { t.parent = heading.key; this.state.nodeMap.set(t.key, t); }); + this.state = insertNode(this.state, block.parent!, heading, insertIndex++); + } + } + // Handle paragraphs and divs + else if (tagName === 'p' || tagName === 'div') { + const { textNodes } = this.createTextNodesFromElement(element); + if (textNodes.length > 0) { + const para = createParagraphNode(textNodes.map(t => t.key)); + textNodes.forEach(t => { t.parent = para.key; this.state.nodeMap.set(t.key, t); }); + this.state = insertNode(this.state, block.parent!, para, insertIndex++); + } + } + // Handle unordered lists + else if (tagName === 'ul') { + const listItems = Array.from(element.children).filter(c => c.tagName.toLowerCase() === 'li'); + if (listItems.length > 0) { + const listNode = createListNode('bullet', []); + this.state = insertNode(this.state, block.parent!, listNode, insertIndex++); + + listItems.forEach(li => { + const { textNodes } = this.createTextNodesFromElement(li); + if (textNodes.length > 0) { + const listItem = createListItemNode(textNodes.map(t => t.key), 0); + textNodes.forEach(t => { t.parent = listItem.key; this.state.nodeMap.set(t.key, t); }); + listItem.parent = listNode.key; + this.state.nodeMap.set(listItem.key, listItem); + (this.state.nodeMap.get(listNode.key) as ListNode).children.push(listItem.key); + } + }); + } + } + // Handle ordered lists + else if (tagName === 'ol') { + const listItems = Array.from(element.children).filter(c => c.tagName.toLowerCase() === 'li'); + if (listItems.length > 0) { + const listNode = createListNode('number', []); + this.state = insertNode(this.state, block.parent!, listNode, insertIndex++); + + listItems.forEach(li => { + const { textNodes } = this.createTextNodesFromElement(li); + if (textNodes.length > 0) { + const listItem = createListItemNode(textNodes.map(t => t.key), 0); + textNodes.forEach(t => { t.parent = listItem.key; this.state.nodeMap.set(t.key, t); }); + listItem.parent = listNode.key; + this.state.nodeMap.set(listItem.key, listItem); + (this.state.nodeMap.get(listNode.key) as ListNode).children.push(listItem.key); + } + }); + } + } + // Handle blockquotes + else if (tagName === 'blockquote') { + const { textNodes } = this.createTextNodesFromElement(element); + if (textNodes.length > 0) { + const quote = createQuoteNode(textNodes.map(t => t.key)); + textNodes.forEach(t => { t.parent = quote.key; this.state.nodeMap.set(t.key, t); }); + this.state = insertNode(this.state, block.parent!, quote, insertIndex++); + } + } + // Handle pre/code blocks + else if (tagName === 'pre') { + const text = element.textContent || ''; + const textNode = createTextNode(text, {}); + const codeBlock = createCodeNode(undefined, [textNode.key]); + textNode.parent = codeBlock.key; + this.state.nodeMap.set(textNode.key, textNode); + this.state = insertNode(this.state, block.parent!, codeBlock, insertIndex++); + } + // Handle plain text or other elements - create paragraph + else if (element.textContent?.trim()) { + const { textNodes } = this.createTextNodesFromElement(element); + if (textNodes.length > 0) { + const para = createParagraphNode(textNodes.map(t => t.key)); + textNodes.forEach(t => { t.parent = para.key; this.state.nodeMap.set(t.key, t); }); + this.state = insertNode(this.state, block.parent!, para, insertIndex++); + } + } + }; + + // Process body children + Array.from(doc.body.children).forEach(processElement); + + // If nothing was inserted (plain text only), insert as paragraph + if (insertIndex === parent.children.indexOf(block.key) + 1 && doc.body.textContent?.trim()) { + const text = doc.body.textContent.trim(); + const textNode = createTextNode(text, {}); + const para = createParagraphNode([textNode.key]); + textNode.parent = para.key; + this.state.nodeMap.set(textNode.key, textNode); + this.state = insertNode(this.state, block.parent!, para, insertIndex++); + } + + // Create a new paragraph at the end for continued editing + const newText = createTextNode('', {}); + const newParagraph = createParagraphNode([newText.key]); + newText.parent = newParagraph.key; + this.state.nodeMap.set(newText.key, newText); + this.state = insertNode(this.state, block.parent!, newParagraph, insertIndex); + + // Move selection to the new paragraph + this.selection = createCollapsedSelection(newText.key, 0); + + this.render(); + } + + private createTextNodesFromElement(element: Element): { textNodes: TextNode[] } { + const textNodes: TextNode[] = []; + + const processNode = (node: Node, format: TextFormat): void => { + if (node.nodeType === Node.TEXT_NODE) { + const text = node.textContent || ''; + if (text) { + const textNode = createTextNode(text, { ...format }); + textNodes.push(textNode); + } + } else if (node.nodeType === Node.ELEMENT_NODE) { + const el = node as Element; + const tagName = el.tagName.toLowerCase(); + + // Determine formatting from tag + const newFormat = { ...format }; + if (tagName === 'strong' || tagName === 'b') newFormat.bold = true; + if (tagName === 'em' || tagName === 'i') newFormat.italic = true; + if (tagName === 'u') newFormat.underline = true; + if (tagName === 's' || tagName === 'strike' || tagName === 'del') newFormat.strikethrough = true; + if (tagName === 'code') newFormat.code = true; + + // Process children with accumulated format + Array.from(el.childNodes).forEach(child => processNode(child, newFormat)); + } + }; + + processNode(element, {}); + + // If no text nodes, create an empty one + if (textNodes.length === 0) { + const emptyNode = createTextNode('', {}); + textNodes.push(emptyNode); + } + + return { textNodes }; + } + + private duplicateBlock(blockKey: string): void { + const block = getNode(this.state, blockKey); + if (!block || !block.parent) return; + + const parent = getNode(this.state, block.parent); + if (!parent || !isElementNode(parent)) return; + + // Save to history + this.history.push(this.state, this.selection); + + // Deep clone the block and all its children + const cloneNodeDeep = (nodeKey: string, newParentKey: string): string | null => { + const node = getNode(this.state, nodeKey); + if (!node) return null; + + const newKey = `node_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + + if (isTextNode(node)) { + const newNode: TextNode = { + key: newKey, + type: 'text', + parent: newParentKey, + text: node.text, + format: { ...node.format }, + }; + this.state = updateNode(this.state, newKey, newNode); + return newKey; + } + + if (isElementNode(node)) { + // Create the node first without children + const newNode = { + ...node, + key: newKey, + parent: newParentKey, + children: [] as string[], + }; + this.state = updateNode(this.state, newKey, newNode); + + // Clone children + const newChildren: string[] = []; + for (const childKey of node.children) { + const newChildKey = cloneNodeDeep(childKey, newKey); + if (newChildKey) { + newChildren.push(newChildKey); + } + } + + // Update with children + const updatedNode = getNode(this.state, newKey); + if (updatedNode && isElementNode(updatedNode)) { + this.state = updateNode(this.state, newKey, { + ...updatedNode, + children: newChildren, + }); + } + + return newKey; + } + + return null; + }; + + const newBlockKey = cloneNodeDeep(blockKey, block.parent); + if (!newBlockKey) return; + + // Insert the cloned block after the original + const blockIndex = parent.children.indexOf(blockKey); + const newChildren = [...parent.children]; + newChildren.splice(blockIndex + 1, 0, newBlockKey); + + this.state = updateNode(this.state, block.parent, { + ...parent, + children: newChildren, + }); + + // Set selection to the new block + const firstText = findFirstTextNode(this.state, newBlockKey); + if (firstText) { + this.selection = createCollapsedSelection(firstText.key, 0); + } + + this.render(); + } + + private deleteBlock(blockKey: string): void { + const block = getNode(this.state, blockKey); + if (!block || !block.parent) return; + + const parent = getNode(this.state, block.parent); + if (!parent || !isElementNode(parent)) return; + + // Don't delete if it's the last block + if (parent.children.length <= 1) return; + + // Save to history + this.history.push(this.state, this.selection); + + const blockIndex = parent.children.indexOf(blockKey); + + // Find the block to focus after deletion + const nextBlockKey = blockIndex > 0 + ? parent.children[blockIndex - 1] + : parent.children[blockIndex + 1]; + + // Remove the block + this.state = removeNode(this.state, blockKey); + + // Set selection to the next block + if (nextBlockKey) { + const firstText = findFirstTextNode(this.state, nextBlockKey); + if (firstText) { + this.selection = createCollapsedSelection(firstText.key, 0); + } + } + + this.render(); + } + + private moveBlock(blockKey: string, targetKey: string, position: 'before' | 'after'): void { + const block = getNode(this.state, blockKey); + const target = getNode(this.state, targetKey); + + if (!block || !target || !block.parent || !target.parent) return; + if (blockKey === targetKey) return; + + // Save to history + this.history.push(this.state, this.selection); + + const sourceParent = getNode(this.state, block.parent); + const targetParent = getNode(this.state, target.parent); + + if (!sourceParent || !targetParent || !isElementNode(sourceParent) || !isElementNode(targetParent)) return; + + // Clone state for immutable update + const newState = cloneState(this.state); + + // Get parents from new state + const sourceParentInNewState = newState.nodeMap.get(block.parent) as any; + const targetParentInNewState = newState.nodeMap.get(target.parent) as any; + + // Remove block from source + const sourceIndex = sourceParentInNewState.children.indexOf(blockKey); + if (sourceIndex === -1) return; + sourceParentInNewState.children.splice(sourceIndex, 1); + + // Calculate target index + let targetIndex = targetParentInNewState.children.indexOf(targetKey); + if (position === 'after') { + targetIndex++; + } + + // Adjust if same parent and source was before target + if (block.parent === target.parent && sourceIndex < targetIndex) { + targetIndex--; + } + + // Insert block at new position + targetParentInNewState.children.splice(targetIndex, 0, blockKey); + + // Update block's parent if moved to different parent + if (block.parent !== target.parent) { + const blockInNewState = newState.nodeMap.get(blockKey); + if (blockInNewState) { + blockInNewState.parent = target.parent; + } + } + + this.state = newState; + + // Keep selection on the moved block + const firstText = findFirstTextNode(this.state, blockKey); + if (firstText) { + this.selection = createCollapsedSelection(firstText.key, 0); + } + + this.render(); + } + + private undo(): void { + const entry = this.history.undo(this.state, this.selection); + if (entry) { + this.state = entry.state; + // Validate selection - ensure it points to existing nodes + this.selection = this.validateSelection(entry.selection); + this.render(); + } + } + + private redo(): void { + const entry = this.history.redo(this.state, this.selection); + if (entry) { + this.state = entry.state; + // Validate selection - ensure it points to existing nodes + this.selection = this.validateSelection(entry.selection); + this.render(); + } + } + + private validateSelection(selection: EditorSelection | null): EditorSelection | null { + if (!selection) { + // No selection - create one at first text node + const firstText = findFirstTextNode(this.state, this.state.root); + if (firstText) { + return createCollapsedSelection(firstText.key, 0); + } + return null; + } + + // Check if anchor node exists + const anchorNode = getNode(this.state, selection.anchor.key); + const focusNode = getNode(this.state, selection.focus.key); + + if (!anchorNode || !focusNode) { + // Selection points to non-existent nodes - find first text node + const firstText = findFirstTextNode(this.state, this.state.root); + if (firstText) { + return createCollapsedSelection(firstText.key, 0); + } + return null; + } + + // Validate offsets + let anchorOffset = selection.anchor.offset; + let focusOffset = selection.focus.offset; + + if (isTextNode(anchorNode)) { + anchorOffset = Math.min(anchorOffset, anchorNode.text.length); + } + if (isTextNode(focusNode)) { + focusOffset = Math.min(focusOffset, focusNode.text.length); + } + + return { + anchor: { key: selection.anchor.key, offset: anchorOffset }, + focus: { key: selection.focus.key, offset: focusOffset }, + isCollapsed: selection.anchor.key === selection.focus.key && anchorOffset === focusOffset, + isBackward: selection.isBackward, + }; + } + + private render(): void { + if (!this.container) return; + + this.isRendering = true; + + // Update DOM + this.reconciler.reconcile(this.state, this.container); + + // Restore selection + if (this.selection) { + setSelection(this.selection, this.state, this.container); + } + + this.isRendering = false; + + // Update focused block indicator + this.updateFocusedBlock(); + + // Notify listeners + this.notifyListeners(); + + // Notify onChange callback + if (this.config.onChange) { + this.config.onChange(serializeState(this.state)); + } + } + + private notifyListeners(): void { + this.listeners.forEach(listener => listener(this.state, this.selection)); + } +} diff --git a/src/components/Editor/core/EditorState.ts b/src/components/Editor/core/EditorState.ts new file mode 100644 index 0000000000..b0664078a5 --- /dev/null +++ b/src/components/Editor/core/EditorState.ts @@ -0,0 +1,405 @@ +import { + EditorState, + EditorNode, + RootNode, + ParagraphNode, + TextNode, + HeadingNode, + ListNode, + ListItemNode, + QuoteNode, + CodeNode, + LinkNode, + ImageNode, + DrawioNode, + TableNode, + TableRowNode, + TableCellNode, + TextFormat, + HeadingLevel, + ListType, + isElementNode, + isTextNode, +} from './types'; + +let keyCounter = 0; + +export function generateKey(): string { + return `node_${Date.now()}_${keyCounter++}`; +} + +// Create empty editor state +export function createEmptyState(): EditorState { + const rootKey = generateKey(); + const paragraphKey = generateKey(); + const textKey = generateKey(); + + const textNode: TextNode = { + key: textKey, + type: 'text', + parent: paragraphKey, + text: '', + format: {}, + }; + + const paragraphNode: ParagraphNode = { + key: paragraphKey, + type: 'paragraph', + parent: rootKey, + children: [textKey], + }; + + const rootNode: RootNode = { + key: rootKey, + type: 'root', + parent: null, + children: [paragraphKey], + }; + + const nodeMap = new Map(); + nodeMap.set(rootKey, rootNode); + nodeMap.set(paragraphKey, paragraphNode); + nodeMap.set(textKey, textNode); + + return { + root: rootKey, + nodeMap, + version: 0, + }; +} + +// Clone state (for immutable updates) +export function cloneState(state: EditorState): EditorState { + const nodeMap = new Map(); + state.nodeMap.forEach((node, key) => { + nodeMap.set(key, cloneNode(node)); + }); + return { + root: state.root, + nodeMap, + version: state.version + 1, + }; +} + +function cloneNode(node: EditorNode): EditorNode { + if (isElementNode(node)) { + return { ...node, children: [...node.children] }; + } + if (isTextNode(node)) { + return { ...node, format: { ...node.format } }; + } + return { ...node }; +} + +// Node getters +export function getNode(state: EditorState, key: string): EditorNode | null { + return state.nodeMap.get(key) || null; +} + +export function getRoot(state: EditorState): RootNode { + return state.nodeMap.get(state.root) as RootNode; +} + +export function getParent(state: EditorState, key: string): EditorNode | null { + const node = getNode(state, key); + if (!node || !node.parent) return null; + return getNode(state, node.parent); +} + +export function getChildren(state: EditorState, key: string): EditorNode[] { + const node = getNode(state, key); + if (!node || !isElementNode(node)) return []; + return node.children.map(childKey => getNode(state, childKey)).filter(Boolean) as EditorNode[]; +} + +// Find the nearest block ancestor +export function getBlockAncestor(state: EditorState, key: string): EditorNode | null { + let current = getNode(state, key); + while (current) { + if ( + current.type === 'paragraph' || + current.type === 'heading' || + current.type === 'listitem' || + current.type === 'quote' || + current.type === 'code' + ) { + return current; + } + current = current.parent ? getNode(state, current.parent) : null; + } + return null; +} + +// Get text content of a node and its descendants +export function getTextContent(state: EditorState, key: string): string { + const node = getNode(state, key); + if (!node) return ''; + if (isTextNode(node)) return node.text; + if (isElementNode(node)) { + return node.children.map(childKey => getTextContent(state, childKey)).join(''); + } + return ''; +} + +// Find first/last text node in subtree +export function findFirstTextNode(state: EditorState, key: string): TextNode | null { + const node = getNode(state, key); + if (!node) return null; + if (isTextNode(node)) return node; + if (isElementNode(node)) { + for (const childKey of node.children) { + const found = findFirstTextNode(state, childKey); + if (found) return found; + } + } + return null; +} + +export function findLastTextNode(state: EditorState, key: string): TextNode | null { + const node = getNode(state, key); + if (!node) return null; + if (isTextNode(node)) return node; + if (isElementNode(node)) { + for (let i = node.children.length - 1; i >= 0; i--) { + const found = findLastTextNode(state, node.children[i]); + if (found) return found; + } + } + return null; +} + +// Get sibling nodes +export function getPreviousSibling(state: EditorState, key: string): EditorNode | null { + const node = getNode(state, key); + if (!node || !node.parent) return null; + const parent = getNode(state, node.parent); + if (!parent || !isElementNode(parent)) return null; + const index = parent.children.indexOf(key); + if (index <= 0) return null; + return getNode(state, parent.children[index - 1]); +} + +export function getNextSibling(state: EditorState, key: string): EditorNode | null { + const node = getNode(state, key); + if (!node || !node.parent) return null; + const parent = getNode(state, node.parent); + if (!parent || !isElementNode(parent)) return null; + const index = parent.children.indexOf(key); + if (index < 0 || index >= parent.children.length - 1) return null; + return getNode(state, parent.children[index + 1]); +} + +// Node creation helpers +export function createTextNode(text: string, format: TextFormat = {}): TextNode { + return { + key: generateKey(), + type: 'text', + parent: null, + text, + format, + }; +} + +export function createParagraphNode(children: string[] = []): ParagraphNode { + return { + key: generateKey(), + type: 'paragraph', + parent: null, + children, + }; +} + +export function createHeadingNode(level: HeadingLevel, children: string[] = []): HeadingNode { + return { + key: generateKey(), + type: 'heading', + parent: null, + level, + children, + }; +} + +export function createListNode(listType: ListType, children: string[] = []): ListNode { + return { + key: generateKey(), + type: 'list', + parent: null, + listType, + children, + }; +} + +export function createListItemNode(children: string[] = [], indent: number = 0): ListItemNode { + return { + key: generateKey(), + type: 'listitem', + parent: null, + children, + indent, + }; +} + +export function createQuoteNode(children: string[] = []): QuoteNode { + return { + key: generateKey(), + type: 'quote', + parent: null, + children, + }; +} + +export function createCodeNode(language?: string, children: string[] = []): CodeNode { + return { + key: generateKey(), + type: 'code', + parent: null, + language, + children, + }; +} + +export function createLinkNode(url: string, children: string[] = []): LinkNode { + return { + key: generateKey(), + type: 'link', + parent: null, + url, + children, + }; +} + +export function createImageNode(src: string, alt: string): ImageNode { + return { + key: generateKey(), + type: 'image', + parent: null, + src, + alt, + }; +} + +export function createDrawioNode(diagramXML: string): DrawioNode { + return { + key: generateKey(), + type: 'drawio', + parent: null, + diagramXML, + }; +} + +export function createTableNode(children: string[] = []): TableNode { + return { + key: generateKey(), + type: 'table', + parent: null, + children, + }; +} + +export function createTableRowNode(children: string[] = []): TableRowNode { + return { + key: generateKey(), + type: 'tablerow', + parent: null, + children, + }; +} + +export function createTableCellNode(children: string[] = [], isHeader: boolean = false): TableCellNode { + return { + key: generateKey(), + type: 'tablecell', + parent: null, + children, + isHeader, + }; +} + +// State mutation helpers (return new state) +export function insertNode( + state: EditorState, + parentKey: string, + node: EditorNode, + index: number = -1 +): EditorState { + const newState = cloneState(state); + const parent = newState.nodeMap.get(parentKey); + if (!parent || !isElementNode(parent)) return state; + + node.parent = parentKey; + newState.nodeMap.set(node.key, node); + + if (index === -1 || index >= parent.children.length) { + parent.children.push(node.key); + } else { + parent.children.splice(index, 0, node.key); + } + + return newState; +} + +export function removeNode(state: EditorState, key: string): EditorState { + const newState = cloneState(state); + const node = newState.nodeMap.get(key); + if (!node) return state; + + // Remove from parent's children + if (node.parent) { + const parent = newState.nodeMap.get(node.parent); + if (parent && isElementNode(parent)) { + const index = parent.children.indexOf(key); + if (index !== -1) { + parent.children.splice(index, 1); + } + } + } + + // Remove node and all descendants + const removeRecursive = (nodeKey: string) => { + const n = newState.nodeMap.get(nodeKey); + if (n && isElementNode(n)) { + n.children.forEach(removeRecursive); + } + newState.nodeMap.delete(nodeKey); + }; + removeRecursive(key); + + return newState; +} + +export function updateNode( + state: EditorState, + key: string, + updates: Partial +): EditorState { + const newState = cloneState(state); + const node = newState.nodeMap.get(key); + if (!node) return state; + + newState.nodeMap.set(key, { ...node, ...updates } as EditorNode); + return newState; +} + +// Serialize/deserialize for persistence +export function serializeState(state: EditorState): string { + const obj = { + root: state.root, + nodeMap: Object.fromEntries(state.nodeMap), + version: state.version, + }; + return JSON.stringify(obj); +} + +export function deserializeState(json: string): EditorState | null { + try { + const obj = JSON.parse(json); + if (!obj.root || !obj.nodeMap) return null; + return { + root: obj.root, + nodeMap: new Map(Object.entries(obj.nodeMap)), + version: obj.version || 0, + }; + } catch { + return null; + } +} diff --git a/src/components/Editor/core/History.ts b/src/components/Editor/core/History.ts new file mode 100644 index 0000000000..533553553d --- /dev/null +++ b/src/components/Editor/core/History.ts @@ -0,0 +1,112 @@ +import { EditorState, EditorSelection } from './types'; +import { cloneState } from './EditorState'; + +interface HistoryEntry { + state: EditorState; + selection: EditorSelection | null; +} + +export class HistoryManager { + private undoStack: HistoryEntry[] = []; + private redoStack: HistoryEntry[] = []; + private maxSize: number; + private lastPushTime: number = 0; + private debounceMs: number = 300; + + constructor(maxSize: number = 100) { + this.maxSize = maxSize; + } + + // Push current state to history + push(state: EditorState, selection: EditorSelection | null): void { + const now = Date.now(); + + // Debounce rapid changes (like continuous typing) + if (now - this.lastPushTime < this.debounceMs && this.undoStack.length > 0) { + // Update the last entry instead of creating a new one + this.undoStack[this.undoStack.length - 1] = { + state: cloneState(state), + selection, + }; + } else { + // Push new entry + this.undoStack.push({ + state: cloneState(state), + selection, + }); + + // Limit stack size + if (this.undoStack.length > this.maxSize) { + this.undoStack.shift(); + } + } + + // Clear redo stack on new changes + this.redoStack = []; + this.lastPushTime = now; + } + + // Force push (bypass debounce) + forcePush(state: EditorState, selection: EditorSelection | null): void { + this.undoStack.push({ + state: cloneState(state), + selection, + }); + + if (this.undoStack.length > this.maxSize) { + this.undoStack.shift(); + } + + this.redoStack = []; + this.lastPushTime = Date.now(); + } + + // Undo - returns previous state or null + undo(currentState: EditorState, currentSelection: EditorSelection | null): HistoryEntry | null { + if (this.undoStack.length === 0) return null; + + // Move current state to redo stack + this.redoStack.push({ + state: cloneState(currentState), + selection: currentSelection, + }); + + // Pop and return previous state + const entry = this.undoStack.pop()!; + return { + state: cloneState(entry.state), + selection: entry.selection, + }; + } + + // Redo - returns next state or null + redo(currentState: EditorState, currentSelection: EditorSelection | null): HistoryEntry | null { + if (this.redoStack.length === 0) return null; + + // Move current state to undo stack + this.undoStack.push({ + state: cloneState(currentState), + selection: currentSelection, + }); + + // Pop and return next state + const entry = this.redoStack.pop()!; + return { + state: cloneState(entry.state), + selection: entry.selection, + }; + } + + canUndo(): boolean { + return this.undoStack.length > 0; + } + + canRedo(): boolean { + return this.redoStack.length > 0; + } + + clear(): void { + this.undoStack = []; + this.redoStack = []; + } +} diff --git a/src/components/Editor/core/Selection.ts b/src/components/Editor/core/Selection.ts new file mode 100644 index 0000000000..43c4e514a1 --- /dev/null +++ b/src/components/Editor/core/Selection.ts @@ -0,0 +1,262 @@ +import { EditorState, EditorSelection, SelectionPoint, isTextNode, isElementNode } from './types'; +import { getNode, findFirstTextNode, findLastTextNode } from './EditorState'; + +const DATA_KEY_ATTR = 'data-editor-key'; + +// Get element by node key +export function getElementByKey(key: string, container: HTMLElement): HTMLElement | null { + return container.querySelector(`[${DATA_KEY_ATTR}="${key}"]`); +} + +// Get node key from element +export function getKeyFromElement(element: HTMLElement): string | null { + return element.getAttribute(DATA_KEY_ATTR); +} + +// Find closest element with data-editor-key +export function findClosestKeyElement(node: Node | null): HTMLElement | null { + if (!node) return null; + let current: Node | null = node; + while (current) { + if (current instanceof HTMLElement && current.hasAttribute(DATA_KEY_ATTR)) { + return current; + } + current = current.parentNode; + } + return null; +} + +// Convert DOM selection point to model selection point +export function domPointToModel( + node: Node, + offset: number, + state: EditorState +): SelectionPoint | null { + // Text node case + if (node.nodeType === Node.TEXT_NODE) { + const parent = node.parentElement; + if (!parent) return null; + + const keyElement = findClosestKeyElement(parent); + if (!keyElement) return null; + + const key = getKeyFromElement(keyElement); + if (!key) return null; + + const editorNode = getNode(state, key); + if (!editorNode) return null; + + // If it's a text node element + if (isTextNode(editorNode)) { + // Clamp offset to actual text length (handles zero-width space) + const clampedOffset = Math.min(offset, editorNode.text.length); + return { key, offset: clampedOffset }; + } + + // If it's an element, find the text child + if (isElementNode(editorNode) && editorNode.children.length > 0) { + const firstTextKey = editorNode.children[0]; + const textNode = getNode(state, firstTextKey); + if (textNode && isTextNode(textNode)) { + return { key: firstTextKey, offset: Math.min(offset, textNode.text.length) }; + } + } + + return null; + } + + // Element node case + if (node.nodeType === Node.ELEMENT_NODE) { + const element = node as HTMLElement; + const keyElement = findClosestKeyElement(element); + if (!keyElement) return null; + + const key = getKeyFromElement(keyElement); + if (!key) return null; + + const editorNode = getNode(state, key); + if (!editorNode) return null; + + // If clicking at start of element, select first text + if (isElementNode(editorNode) && editorNode.children.length > 0) { + if (offset === 0) { + const firstText = findFirstTextNode(state, key); + if (firstText) { + return { key: firstText.key, offset: 0 }; + } + } else { + const lastText = findLastTextNode(state, key); + if (lastText) { + return { key: lastText.key, offset: lastText.text.length }; + } + } + } + + return { key, offset: 0 }; + } + + return null; +} + +// Convert model selection point to DOM position +export function modelPointToDOM( + point: SelectionPoint, + state: EditorState, + container: HTMLElement +): { node: Node; offset: number } | null { + const editorNode = getNode(state, point.key); + if (!editorNode) return null; + + // Find the DOM element for this key + const element = getElementByKey(point.key, container); + if (!element) return null; + + if (isTextNode(editorNode)) { + // Find the text node inside the element + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); + const textNode = walker.nextNode(); + + if (textNode) { + // Handle empty text (zero-width space) + const actualLength = textNode.textContent?.length || 0; + const offset = editorNode.text === '' ? 0 : Math.min(point.offset, actualLength); + return { node: textNode, offset }; + } + + // No text node found, return element itself + return { node: element, offset: 0 }; + } + + return { node: element, offset: point.offset }; +} + +// Get current selection from DOM +export function getSelection(state: EditorState, container: HTMLElement): EditorSelection | null { + const domSelection = window.getSelection(); + if (!domSelection || domSelection.rangeCount === 0) return null; + + // Check if selection is within our container + const range = domSelection.getRangeAt(0); + if (!container.contains(range.commonAncestorContainer)) return null; + + const anchor = domPointToModel( + domSelection.anchorNode!, + domSelection.anchorOffset, + state + ); + + const focus = domPointToModel( + domSelection.focusNode!, + domSelection.focusOffset, + state + ); + + if (!anchor || !focus) return null; + + const isCollapsed = anchor.key === focus.key && anchor.offset === focus.offset; + const isBackward = !isCollapsed && isSelectionBackward(domSelection); + + return { anchor, focus, isCollapsed, isBackward }; +} + +// Set DOM selection from model +export function setSelection( + selection: EditorSelection | null, + state: EditorState, + container: HTMLElement +): void { + const domSelection = window.getSelection(); + if (!domSelection) return; + + if (!selection) { + domSelection.removeAllRanges(); + return; + } + + let anchorDOM = modelPointToDOM(selection.anchor, state, container); + let focusDOM = modelPointToDOM(selection.focus, state, container); + + // If selection points are invalid, fall back to first text node + if (!anchorDOM || !focusDOM) { + const firstText = findFirstTextNode(state, state.root); + if (firstText) { + const fallbackPoint = { key: firstText.key, offset: 0 }; + anchorDOM = modelPointToDOM(fallbackPoint, state, container); + focusDOM = anchorDOM; + } + if (!anchorDOM || !focusDOM) return; + } + + try { + const range = document.createRange(); + + if (selection.isBackward) { + range.setStart(focusDOM.node, focusDOM.offset); + range.setEnd(anchorDOM.node, anchorDOM.offset); + } else { + range.setStart(anchorDOM.node, anchorDOM.offset); + range.setEnd(focusDOM.node, focusDOM.offset); + } + + domSelection.removeAllRanges(); + domSelection.addRange(range); + } catch (e) { + // Selection failed - try to recover by focusing first text node + const firstText = findFirstTextNode(state, state.root); + if (firstText) { + const fallbackDOM = modelPointToDOM({ key: firstText.key, offset: 0 }, state, container); + if (fallbackDOM) { + try { + const fallbackRange = document.createRange(); + fallbackRange.setStart(fallbackDOM.node, fallbackDOM.offset); + fallbackRange.collapse(true); + domSelection.removeAllRanges(); + domSelection.addRange(fallbackRange); + } catch { + // Give up + } + } + } + } +} + +// Check if DOM selection is backward +function isSelectionBackward(selection: Selection): boolean { + if (!selection.anchorNode || !selection.focusNode) return false; + + const position = selection.anchorNode.compareDocumentPosition(selection.focusNode); + + if (position === 0) { + return selection.anchorOffset > selection.focusOffset; + } + + return (position & Node.DOCUMENT_POSITION_PRECEDING) !== 0; +} + +// Create collapsed selection at point +export function createCollapsedSelection(key: string, offset: number): EditorSelection { + const point = { key, offset }; + return { anchor: point, focus: point, isCollapsed: true, isBackward: false }; +} + +// Create range selection +export function createRangeSelection( + anchorKey: string, + anchorOffset: number, + focusKey: string, + focusOffset: number +): EditorSelection { + const anchor = { key: anchorKey, offset: anchorOffset }; + const focus = { key: focusKey, offset: focusOffset }; + const isCollapsed = anchorKey === focusKey && anchorOffset === focusOffset; + return { anchor, focus, isCollapsed, isBackward: false }; +} + +// Get selection bounding rect for positioning floating elements +export function getSelectionRect(): DOMRect | null { + const domSelection = window.getSelection(); + if (!domSelection || domSelection.rangeCount === 0) return null; + + const range = domSelection.getRangeAt(0); + return range.getBoundingClientRect(); +} diff --git a/src/components/Editor/core/index.ts b/src/components/Editor/core/index.ts new file mode 100644 index 0000000000..08255c42ca --- /dev/null +++ b/src/components/Editor/core/index.ts @@ -0,0 +1,6 @@ +export * from './types'; +export * from './EditorState'; +export * from './Selection'; +export * from './DOMReconciler'; +export * from './History'; +export { EditorCore, type EditorListener, type EditorCoreConfig } from './EditorCore'; diff --git a/src/components/Editor/core/types.ts b/src/components/Editor/core/types.ts new file mode 100644 index 0000000000..28861e566a --- /dev/null +++ b/src/components/Editor/core/types.ts @@ -0,0 +1,258 @@ +// Node Types +export type NodeType = + | 'root' + | 'paragraph' + | 'heading' + | 'text' + | 'list' + | 'listitem' + | 'quote' + | 'code' + | 'link' + | 'image' + | 'drawio' + | 'table' + | 'tablerow' + | 'tablecell'; + +export type HeadingLevel = 1 | 2 | 3 | 4 | 5; +export type ListType = 'bullet' | 'number'; + +export interface TextFormat { + bold?: boolean; + italic?: boolean; + underline?: boolean; + strikethrough?: boolean; + code?: boolean; +} + +// Base node interface +export interface BaseNode { + key: string; + type: NodeType; + parent: string | null; +} + +// Root node - contains all content +export interface RootNode extends BaseNode { + type: 'root'; + children: string[]; +} + +// Paragraph node +export interface ParagraphNode extends BaseNode { + type: 'paragraph'; + children: string[]; + indent?: number; +} + +// Heading node +export interface HeadingNode extends BaseNode { + type: 'heading'; + level: HeadingLevel; + children: string[]; +} + +// Text node - leaf node containing actual text +export interface TextNode extends BaseNode { + type: 'text'; + text: string; + format: TextFormat; +} + +// List node +export interface ListNode extends BaseNode { + type: 'list'; + listType: ListType; + children: string[]; +} + +// List item node +export interface ListItemNode extends BaseNode { + type: 'listitem'; + children: string[]; + indent: number; +} + +// Quote node +export interface QuoteNode extends BaseNode { + type: 'quote'; + children: string[]; +} + +// Code block node +export interface CodeNode extends BaseNode { + type: 'code'; + language?: string; + children: string[]; +} + +// Link node - inline, wraps text +export interface LinkNode extends BaseNode { + type: 'link'; + url: string; + children: string[]; +} + +// Image node - decorator/void node +export interface ImageNode extends BaseNode { + type: 'image'; + src: string; + alt: string; + width?: number; + height?: number; +} + +// Draw.io diagram node - decorator/void node +export interface DrawioNode extends BaseNode { + type: 'drawio'; + diagramXML: string; +} + +// Table node +export interface TableNode extends BaseNode { + type: 'table'; + children: string[]; // TableRowNode keys + colWidths?: number[]; +} + +// Table row node +export interface TableRowNode extends BaseNode { + type: 'tablerow'; + children: string[]; // TableCellNode keys +} + +// Table cell node +export interface TableCellNode extends BaseNode { + type: 'tablecell'; + children: string[]; // Text or other inline content + colSpan?: number; + rowSpan?: number; + isHeader?: boolean; +} + +// Union of all node types +export type EditorNode = + | RootNode + | ParagraphNode + | HeadingNode + | TextNode + | ListNode + | ListItemNode + | QuoteNode + | CodeNode + | LinkNode + | ImageNode + | DrawioNode + | TableNode + | TableRowNode + | TableCellNode; + +// Element nodes (have children) +export type ElementNode = + | RootNode + | ParagraphNode + | HeadingNode + | ListNode + | ListItemNode + | QuoteNode + | CodeNode + | LinkNode + | TableNode + | TableRowNode + | TableCellNode; + +// Type guards +export function isElementNode(node: EditorNode): node is ElementNode { + return 'children' in node; +} + +export function isTextNode(node: EditorNode): node is TextNode { + return node.type === 'text'; +} + +export function isDecoratorNode(node: EditorNode): node is ImageNode | DrawioNode { + return node.type === 'image' || node.type === 'drawio'; +} + +export function isTableNode(node: EditorNode): node is TableNode { + return node.type === 'table'; +} + +// Editor State +export interface EditorState { + root: string; + nodeMap: Map; + version: number; +} + +// Selection +export interface SelectionPoint { + key: string; + offset: number; +} + +export interface EditorSelection { + anchor: SelectionPoint; + focus: SelectionPoint; + isCollapsed: boolean; + isBackward: boolean; +} + +// Commands +export type CommandType = + | 'INSERT_TEXT' + | 'INSERT_PARAGRAPH' + | 'DELETE_BACKWARD' + | 'DELETE_FORWARD' + | 'DELETE_RANGE' + | 'FORMAT_TEXT' + | 'SET_BLOCK_TYPE' + | 'TOGGLE_LIST' + | 'INSERT_IMAGE' + | 'INSERT_DRAWIO' + | 'INSERT_HTML' + | 'INSERT_TABLE' + | 'ADD_TABLE_ROW' + | 'ADD_TABLE_COL' + | 'TABLE_NAV_NEXT' + | 'TABLE_NAV_PREV' + | 'INSERT_TABLE_ROW_AT' + | 'INSERT_TABLE_COL_AT' + | 'DELETE_TABLE_ROW' + | 'DELETE_TABLE_COL' + | 'MOVE_TABLE_ROW' + | 'MOVE_TABLE_COL' + | 'DUPLICATE_TABLE_ROW' + | 'DUPLICATE_TABLE_COL' + | 'INSERT_LINK' + | 'DUPLICATE_BLOCK' + | 'DELETE_BLOCK' + | 'MOVE_BLOCK' + | 'UNDO' + | 'REDO'; + +export interface EditorCommand { + type: CommandType; + payload?: unknown; +} + +export interface InsertTextCommand extends EditorCommand { + type: 'INSERT_TEXT'; + payload: { text: string }; +} + +export interface FormatTextCommand extends EditorCommand { + type: 'FORMAT_TEXT'; + payload: { format: keyof TextFormat }; +} + +export interface SetBlockTypeCommand extends EditorCommand { + type: 'SET_BLOCK_TYPE'; + payload: { blockType: 'paragraph' | 'heading' | 'quote' | 'code'; level?: HeadingLevel }; +} + +export interface ToggleListCommand extends EditorCommand { + type: 'TOGGLE_LIST'; + payload: { listType: ListType }; +} diff --git a/src/components/Editor/hooks/useEditor.ts b/src/components/Editor/hooks/useEditor.ts new file mode 100644 index 0000000000..b49d191091 --- /dev/null +++ b/src/components/Editor/hooks/useEditor.ts @@ -0,0 +1,66 @@ +import { createContext, useContext, useEffect, useReducer, useRef, RefObject } from 'react'; +import { EditorCore, EditorState, EditorSelection, EditorCommand, TextFormat } from '../core'; + +export interface EditorContextValue { + core: EditorCore | null; + state: EditorState | null; + selection: EditorSelection | null; + dispatchCommand: (command: EditorCommand) => void; + getActiveFormats: () => TextFormat; + getActiveBlockType: () => string; + canUndo: () => boolean; + canRedo: () => boolean; +} + +export const EditorContext = createContext(null); + +export function useEditor(): EditorContextValue { + const context = useContext(EditorContext); + if (context === undefined) { + throw new Error('useEditor must be used within an EditorProvider'); + } + // Return a safe default when context is null (during initialization) + if (context === null) { + return { + core: null, + state: null, + selection: null, + dispatchCommand: () => {}, + getActiveFormats: () => ({}), + getActiveBlockType: () => 'paragraph', + canUndo: () => false, + canRedo: () => false, + }; + } + return context; +} + +export function useEditorContext( + core: EditorCore | null +): [EditorContextValue, RefObject] { + const containerRef = useRef(null) as RefObject; + const [, forceUpdate] = useReducer(x => x + 1, 0); + + useEffect(() => { + if (!core) return; + + const unsubscribe = core.subscribe(() => { + forceUpdate(); + }); + + return unsubscribe; + }, [core]); + + const value: EditorContextValue = { + core, + state: core?.getState() || null, + selection: core?.getSelection() || null, + dispatchCommand: (command) => core?.dispatchCommand(command), + getActiveFormats: () => core?.getActiveFormats() || {}, + getActiveBlockType: () => core?.getActiveBlockType() || 'paragraph', + canUndo: () => core?.canUndo() || false, + canRedo: () => core?.canRedo() || false, + }; + + return [value, containerRef]; +} diff --git a/src/components/Editor/index.module.css b/src/components/Editor/index.module.css index ae9d9d62c8..b0df17546e 100644 --- a/src/components/Editor/index.module.css +++ b/src/components/Editor/index.module.css @@ -8,9 +8,10 @@ } .navColumn { - padding-top: 50px; flex-shrink: 0; width: 250px; + display: flex; + flex-direction: column; } .mainAndTocWrapper { @@ -55,7 +56,21 @@ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); display: flex; flex-direction: column; - overflow: hidden; + overflow: visible; +} + +.editorScrollArea { + flex-grow: 1; + overflow-y: auto; + overflow-x: visible; +} + +.stickyToolbarWrapper { + position: sticky; + top: 0; + z-index: 20; + background-color: #ffffff; + overflow: visible; } .contentHeader { @@ -66,7 +81,6 @@ .editorInner { position: relative; flex-grow: 1; - overflow-y: auto; } .editorInput { @@ -76,9 +90,8 @@ font-size: 16px; caret-color: #212529; outline: 0; - padding: 2rem 3rem; + padding: 2rem 3rem 40vh 5rem; box-sizing: border-box; - padding-bottom: 40vh; } .editorPlaceholder { @@ -87,7 +100,7 @@ position: absolute; text-overflow: ellipsis; top: 2rem; - left: 3rem; + left: 5rem; font-size: 16px; user-select: none; pointer-events: none; @@ -146,6 +159,20 @@ margin: 0 0 1rem 0; } +:global(.editorEmptyBlock.editorFocusedBlock) { + position: relative; +} + +:global(.editorEmptyBlock.editorFocusedBlock)::before { + content: 'Type / for commands'; + position: absolute; + left: 0; + top: 0; + color: #9ca3af; + pointer-events: none; + font-style: normal; +} + .editorEmptyLineCommandHint { position: relative; } @@ -241,6 +268,224 @@ margin: 1rem 0; } +:global(.editorImageWrapper) { + margin: 1.5rem 0; + border-radius: 8px; + overflow: hidden; + display: block; +} + +:global(.editorImageWrapper img) { + max-width: 100%; + height: auto; + display: block; + border-radius: 8px; + border: 1px solid #e5e7eb; +} + +:global(.editorDrawioWrapper) { + margin: 1.5rem 0; + border-radius: 8px; + overflow: hidden; + border: 1px solid #e5e7eb; + background: #fafafa; +} + +:global(.editorDrawioIframe) { + display: block; + width: 100%; + min-height: 400px; + border: none; +} + +:root[data-theme='dark'] :global(.editorImageWrapper img) { + border-color: #374151; +} + +:root[data-theme='dark'] :global(.editorDrawioWrapper) { + border-color: #374151; + background: #1f2937; +} + +/* Table styles */ +:global(.editorTableWrapper) { + margin: 1.5rem 0; + position: relative; + display: inline-flex; + align-items: flex-start; + gap: 4px; + max-width: 100%; +} + +:global(.editorTableControls) { + display: flex; + flex-direction: column; + gap: 4px; + opacity: 0; + transition: opacity 0.2s; + position: absolute; + left: -28px; + top: 0; +} + +:global(.editorTableWrapper:hover .editorTableControls) { + opacity: 1; +} + +:global(.editorTableDragHandle) { + width: 20px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: #f3f4f6; + border: none; + border-radius: 4px; + cursor: grab; + color: #6b7280; + font-size: 12px; + letter-spacing: 2px; +} + +:global(.editorTableDragHandle:hover) { + background: #e5e7eb; + color: #374151; +} + +:global(.editorTable) { + border-collapse: collapse; + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 8px; + overflow: hidden; + table-layout: fixed; +} + +:global(.editorTableRow) { + border-bottom: 1px solid #e5e7eb; +} + +:global(.editorTableRow:last-child) { + border-bottom: none; +} + +:global(.editorTableCell) { + border: 1px solid #e5e7eb; + padding: 8px 12px; + min-width: 120px; + width: 120px; + vertical-align: top; + position: relative; + outline: none; +} + +:global(.editorTableCell:focus-within) { + box-shadow: inset 0 0 0 2px #8b5cf6; + z-index: 1; +} + +:global(.editorTableCell.selected) { + background: rgba(139, 92, 246, 0.1); + box-shadow: inset 0 0 0 2px #8b5cf6; +} + +/* Add row/column buttons */ +:global(.editorTableAddRow) { + position: absolute; + bottom: -12px; + left: 50%; + transform: translateX(-50%); + width: 24px; + height: 24px; + border-radius: 50%; + background: #f3f4f6; + border: 1px solid #e5e7eb; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 0; + transition: opacity 0.2s; + font-size: 16px; + color: #6b7280; +} + +:global(.editorTableAddCol) { + position: absolute; + right: -12px; + top: 50%; + transform: translateY(-50%); + width: 24px; + height: 24px; + border-radius: 50%; + background: #f3f4f6; + border: 1px solid #e5e7eb; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 0; + transition: opacity 0.2s; + font-size: 16px; + color: #6b7280; +} + +:global(.editorTableWrapper:hover .editorTableAddRow), +:global(.editorTableWrapper:hover .editorTableAddCol) { + opacity: 1; +} + +:global(.editorTableAddRow:hover), +:global(.editorTableAddCol:hover) { + background: #e5e7eb; + color: #374151; +} + +/* Column resize handle */ +:global(.editorTableResizeHandle) { + position: absolute; + top: 0; + right: -3px; + bottom: 0; + width: 6px; + cursor: col-resize; + background: transparent; + z-index: 10; +} + +:global(.editorTableResizeHandle:hover), +:global(.editorTableResizeHandle.resizing) { + background: #8b5cf6; +} + +/* Dark mode table styles */ +:root[data-theme='dark'] :global(.editorTableDragHandle) { + background: #374151; + color: #9ca3af; +} + +:root[data-theme='dark'] :global(.editorTableDragHandle:hover) { + background: #4b5563; + color: #e5e7eb; +} + +:root[data-theme='dark'] :global(.editorTable) { + background: #1f2937; + border-color: #374151; +} + +:root[data-theme='dark'] :global(.editorTableRow) { + border-color: #374151; +} + +:root[data-theme='dark'] :global(.editorTableCell) { + border-color: #374151; +} + +:root[data-theme='dark'] :global(.editorTableCell.selected) { + background: rgba(139, 92, 246, 0.2); +} + .ltr { text-align: left; } @@ -303,6 +548,10 @@ --editor-muted: var(--sapNeutralColor); } +:root[data-theme='dark'] .stickyToolbarWrapper { + background-color: var(--sapBackgroundColor); +} + :root[data-theme='dark'] .editorPageWrapper { background-color: var(--editor-bg); } diff --git a/src/components/Editor/index.tsx b/src/components/Editor/index.tsx index 99cb7345e2..3d4563a88b 100644 --- a/src/components/Editor/index.tsx +++ b/src/components/Editor/index.tsx @@ -1,34 +1,17 @@ -import React, { useState, useMemo, useRef } from 'react'; +import React, { useState, useMemo, useRef, useEffect } from 'react'; import '@ui5/webcomponents-icons/dist/AllIcons'; -import { LexicalComposer, InitialConfigType } from '@lexical/react/LexicalComposer'; -import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'; -import { ContentEditable } from '@lexical/react/LexicalContentEditable'; -import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'; -import { AutoFocusPlugin } from '@lexical/react/LexicalAutoFocusPlugin'; -import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'; -import { ListPlugin } from '@lexical/react/LexicalListPlugin'; -import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin'; -import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'; -import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; -import { HeadingNode, QuoteNode } from '@lexical/rich-text'; -import { ListNode, ListItemNode } from '@lexical/list'; -import { CodeHighlightNode, CodeNode } from '@lexical/code'; -import { TableCellNode, TableNode, TableRowNode } from '@lexical/table'; -import { AutoLinkNode, LinkNode } from '@lexical/link'; import { Button, Dialog, Bar, Text, Title } from '@ui5/webcomponents-react'; import { usePageDataStore, Document } from '@site/src/store/pageDataStore'; import { useAuth } from '@site/src/context/AuthContext'; -import { ImageNode } from './nodes/ImageNode'; -import ImagePlugin from './plugins/ImagePlugin'; +import { EditorCore } from './core'; +import { EditorContext, EditorContextValue } from './hooks/useEditor'; import ToolbarPlugin from './plugins/ToolbarPlugin'; import FloatingToolbarPlugin from './plugins/FloatingToolbarPlugin'; -import { DrawioNode } from './nodes/DrawioNode'; -import DrawioPlugin from './plugins/DrawioPlugin'; -import TableOfContentsPlugin from './plugins/TableOfContentPlugin'; import SlashCommandPlugin from './plugins/SlashCommandPlugin'; -import InitializerPlugin from './plugins/InitializerPlugin'; -import EmptyLineCommandHintPlugin from './plugins/EmptyLineCommandHintPlugin'; -import EditorTheme from './EditorTheme'; +import BlockHandlePlugin from './plugins/BlockHandlePlugin'; +import TableMenuPlugin from './plugins/TableMenuPlugin'; +import TableOfContentsPlugin from './plugins/TableOfContentsPlugin'; +import { convertToLexicalFormat } from './utils/convertToLexical'; import styles from './index.module.css'; import PageTabs from '../PageTabs'; import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; @@ -39,437 +22,449 @@ import Breadcrumbs from '../Breadcrumbs'; import ContributorsDisplay from '../ContributorsDisplay'; const findRootDocument = (startDocId: string, allDocs: Document[]): Document | null => { - let currentDoc = allDocs.find((d) => d.id === startDocId); - if (!currentDoc) return null; - while (currentDoc.parentId) { - const parentDoc = allDocs.find((d) => d.id === currentDoc.parentId); - if (!parentDoc) break; - currentDoc = parentDoc; - } - return currentDoc; + let currentDoc = allDocs.find((d) => d.id === startDocId); + if (!currentDoc) return null; + while (currentDoc.parentId) { + const parentDoc = allDocs.find((d) => d.id === currentDoc!.parentId); + if (!parentDoc) break; + currentDoc = parentDoc; + } + return currentDoc; }; const buildDocumentTree = (docId: string, allDocs: Document[]): Document | null => { - const rootDoc = allDocs.find((d) => d.id === docId); - if (!rootDoc) return null; - const children = allDocs - .filter((d) => d.parentId === docId) - .map((childDoc) => buildDocumentTree(childDoc.id, allDocs)) - .filter(Boolean) as Document[]; - return { ...rootDoc, children }; + const rootDoc = allDocs.find((d) => d.id === docId); + if (!rootDoc) return null; + const children = allDocs + .filter((d) => d.parentId === docId) + .map((childDoc) => buildDocumentTree(childDoc.id, allDocs)) + .filter(Boolean) as Document[]; + return { ...rootDoc, children }; }; interface TransformedDocument { - id: string; - editorState: string; - parentId: string | null; - children: TransformedDocument[]; - metadata: { - title: string; - tags: string[]; - authors: string[]; - contributors: string[]; - description: string; - }; + id: string; + editorState: string; + parentId: string | null; + children: TransformedDocument[]; + metadata: { + title: string; + tags: string[]; + authors: string[]; + contributors: string[]; + description: string; + }; } const transformTreeForBackend = (doc: Document): TransformedDocument => { - return { - id: doc.id, - editorState: doc.editorState, - parentId: doc.parentId, - children: doc.children ? doc.children.map(transformTreeForBackend) : [], - metadata: { - title: doc.title, - tags: doc.tags, - authors: doc.authors, - contributors: doc.contributors, - description: doc.description || 'This is a default description.', - }, - }; + return { + id: doc.id, + editorState: doc.editorState ? convertToLexicalFormat(doc.editorState) : '', + parentId: doc.parentId, + children: doc.children ? doc.children.map(transformTreeForBackend) : [], + metadata: { + title: doc.title, + tags: doc.tags ?? [], + authors: doc.authors, + contributors: doc.contributors ?? [], + description: doc.description || 'This is a default description.', + }, + }; }; const buildBreadcrumbPath = (docId: string | null, allDocs: Document[]): Document[] => { - if (!docId) return []; - const path: Document[] = []; - let currentDoc = allDocs.find((d) => d.id === docId); - while (currentDoc) { - path.unshift(currentDoc); - currentDoc = allDocs.find((d) => d.id === currentDoc.parentId); - } - return path; + if (!docId) return []; + const path: Document[] = []; + let currentDoc = allDocs.find((d) => d.id === docId); + while (currentDoc) { + path.unshift(currentDoc); + currentDoc = allDocs.find((d) => d.id === currentDoc!.parentId); + } + return path; }; -function Placeholder() { - return null; +interface EditorContentProps { + containerRef: React.RefObject; } -const editorNodes = [ - HeadingNode, - QuoteNode, - ListNode, - ListItemNode, - CodeNode, - CodeHighlightNode, - TableNode, - TableCellNode, - TableRowNode, - AutoLinkNode, - LinkNode, - ImageNode, - DrawioNode, -]; - -const AutoSavePlugin: React.FC = () => { - useLexicalComposerContext(); // Hook must be called even if editor instance isn't used - const { getActiveDocument, updateDocument } = usePageDataStore(); - const handleSave = (editorState: EditorState) => { - const activeDoc = getActiveDocument(); - if (activeDoc) { - const editorStateJSON = JSON.stringify(editorState.toJSON()); - if (editorStateJSON !== activeDoc.editorState) { - updateDocument(activeDoc.id, { editorState: editorStateJSON }); - } - } - }; - return ; -}; +function EditorContent({ containerRef }: EditorContentProps) { + return ( +
+
+ + + + +
+ ); +} interface EditorProps { - onAddNew: (parentId?: string | null) => void; + onAddNew: (parentId?: string | null) => void; } + const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + interface PublishStatus { - stage: PublishStage; - error: string | null; - commitUrl: string | null; - pullRequestUrl: string | null; + stage: PublishStage; + error: string | null; + commitUrl: string | null; + pullRequestUrl: string | null; } const Editor: React.FC = ({ onAddNew }) => { - const { getActiveDocument, lastSaveTimestamp, deleteDocument, documents, resetStore, updateDocument } = - usePageDataStore(); - const { token, user } = useAuth(); - const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const activeDocument = getActiveDocument(); - const { siteConfig } = useDocusaurusContext(); - const { expressBackendUrl } = siteConfig.customFields as { expressBackendUrl: string }; - const editorColumnRef = useRef(null); - const [publishStatus, setPublishStatus] = useState({ - stage: 'idle', - error: null, - commitUrl: null, - pullRequestUrl: null, - }); - const history = useHistory(); - const baseUrl = siteConfig.baseUrl; - const [showSyncDialog, setShowSyncDialog] = useState(false); - const [userForkUrl, setUserForkUrl] = useState(''); - const [isSyncing, setIsSyncing] = useState(false); - - const breadcrumbPath = useMemo( - () => buildBreadcrumbPath(activeDocument?.id, documents), - [activeDocument, documents] - ); - const handleContributorsUpdate = (updatedContributors: string[]) => { - if (activeDocument) { - updateDocument(activeDocument.id, { contributors: updatedContributors }); + const { getActiveDocument, lastSaveTimestamp, deleteDocument, documents, resetStore, updateDocument, isSyncing, syncError } = + usePageDataStore(); + const { token, user } = useAuth(); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const activeDocument = getActiveDocument(); + const { siteConfig } = useDocusaurusContext(); + const { expressBackendUrl } = siteConfig.customFields as { expressBackendUrl: string }; + const editorColumnRef = useRef(null); + const containerRef = useRef(null); + const coreRef = useRef(null); + const [contextValue, setContextValue] = useState(null); + const [publishStatus, setPublishStatus] = useState({ + stage: 'idle', + error: null, + commitUrl: null, + pullRequestUrl: null, + }); + const history = useHistory(); + const baseUrl = siteConfig.baseUrl; + const [showSyncDialog, setShowSyncDialog] = useState(false); + const [userForkUrl, setUserForkUrl] = useState(''); + + useEffect(() => { + const activeDoc = getActiveDocument(); + if (!containerRef.current) return; + + const core = new EditorCore({ + initialState: activeDoc?.editorState ?? undefined, + onChange: (serializedState) => { + const doc = getActiveDocument(); + if (doc && serializedState !== doc.editorState) { + updateDocument(doc.id, { editorState: serializedState }); } + }, + }); + + core.mount(containerRef.current); + coreRef.current = core; + + const updateContext = () => { + setContextValue({ + core, + state: core.getState(), + selection: core.getSelection(), + dispatchCommand: (cmd) => core.dispatchCommand(cmd), + getActiveFormats: () => core.getActiveFormats(), + getActiveBlockType: () => core.getActiveBlockType(), + canUndo: () => core.canUndo(), + canRedo: () => core.canRedo(), + }); }; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const handleAutomaticSync = async () => { - setIsSyncing(true); - try { - const token = localStorage.getItem('jwt_token'); - const response = await fetch(`${expressBackendUrl}/api/sync-fork`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, - }); - if (!response.ok) { - throw new Error('Automatic sync failed. Please try the manual method.'); - } - setShowSyncDialog(false); - handleSubmit(); - } catch (error) { - alert(error.message); - } finally { - setIsSyncing(false); - } + updateContext(); + const unsubscribe = core.subscribe(updateContext); + + return () => { + unsubscribe(); + core.destroy(); }; + }, [getActiveDocument, updateDocument]); - const handleSubmit = async () => { - setIsLoading(true); - setPublishStatus({ stage: 'idle', error: null, commitUrl: null, pullRequestUrl: null }); + const breadcrumbPath = useMemo( + () => buildBreadcrumbPath(activeDocument?.id ?? null, documents), + [activeDocument, documents] + ); - if (!activeDocument) { - alert('No active document to publish.'); - setIsLoading(false); - return; - } - const rootDoc = findRootDocument(activeDocument.id, documents); - if (!rootDoc) { - alert('Could not find the root document for publishing.'); - setIsLoading(false); - return; - } - const fullDocumentTree = buildDocumentTree(rootDoc.id, documents); - if (!fullDocumentTree) { - alert('Could not construct the document tree.'); - setIsLoading(false); - return; - } - const documentObject = transformTreeForBackend(fullDocumentTree); - const payloadForPublish = { document: JSON.stringify(documentObject) }; - - try { - if (!token) { - alert('Authentication error: You are not logged in. Please log in again.'); - setIsLoading(false); - return; - } + const handleContributorsUpdate = (updatedContributors: string[]) => { + if (activeDocument) { + updateDocument(activeDocument.id, { contributors: updatedContributors }); + } + }; - setPublishStatus((prev) => ({ ...prev, stage: 'forking' })); - const response = await fetch(`${expressBackendUrl}/api/publish`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, - body: JSON.stringify(payloadForPublish), - }); - const result = await response.json(); - - if (!response.ok) { - if (result.error && result.error.includes('SYNC_CONFLICT')) { - if (user?.username) { - setUserForkUrl(`https://github.com/${user.username}/architecture-center`); - } - setShowSyncDialog(true); - setPublishStatus({ stage: 'idle', error: null, commitUrl: null, pullRequestUrl: null }); - setIsLoading(false); - return; - } - throw new Error(result.error || 'Failed to publish to GitHub.'); - } + const handleSubmit = async () => { + setIsLoading(true); + setPublishStatus({ stage: 'idle', error: null, commitUrl: null, pullRequestUrl: null }); - await sleep(1000); - setPublishStatus((prev) => ({ ...prev, stage: 'packaging' })); - await sleep(1000); - setPublishStatus((prev) => ({ ...prev, stage: 'committing' })); - await sleep(1000); - - setPublishStatus({ - stage: 'success', - error: null, - commitUrl: result.commitUrl, - pullRequestUrl: result.pullRequestUrl, - }); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'An unexpected error occurred'; - setPublishStatus({ stage: 'error', error: errorMessage, commitUrl: null, pullRequestUrl: null }); - setIsLoading(false); - } - }; + if (!activeDocument) { + alert('No active document to publish.'); + setIsLoading(false); + return; + } + const rootDoc = findRootDocument(activeDocument.id, documents); + if (!rootDoc) { + alert('Could not find the root document for publishing.'); + setIsLoading(false); + return; + } + const fullDocumentTree = buildDocumentTree(rootDoc.id, documents); + if (!fullDocumentTree) { + alert('Could not construct the document tree.'); + setIsLoading(false); + return; + } + const documentObject = transformTreeForBackend(fullDocumentTree); + const payloadForPublish = { document: JSON.stringify(documentObject) }; - const closeLoadingModal = () => { - setPublishStatus({ stage: 'idle', error: null, commitUrl: null, pullRequestUrl: null }); - }; + try { + if (!token) { + alert('Authentication error: You are not logged in. Please log in again.'); + setIsLoading(false); + return; + } + + setPublishStatus((prev) => ({ ...prev, stage: 'forking' })); + const response = await fetch(`${expressBackendUrl}/api/publish`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify(payloadForPublish), + }); + const result = await response.json(); - const handleSuccessAndReset = () => { - const urlToOpen = publishStatus.pullRequestUrl || publishStatus.commitUrl; - if (urlToOpen) { - window.open(urlToOpen, '_blank', 'noopener,noreferrer'); + if (!response.ok) { + if (result.error && result.error.includes('SYNC_CONFLICT')) { + if (user?.username) { + setUserForkUrl(`https://github.com/${user.username}/architecture-center`); + } + setShowSyncDialog(true); + setPublishStatus({ stage: 'idle', error: null, commitUrl: null, pullRequestUrl: null }); + setIsLoading(false); + return; } - resetStore(); - history.push(baseUrl); - }; + throw new Error(result.error || 'Failed to publish to GitHub.'); + } - if (!activeDocument) return null; + await sleep(1000); + setPublishStatus((prev) => ({ ...prev, stage: 'packaging' })); + await sleep(1000); + setPublishStatus((prev) => ({ ...prev, stage: 'committing' })); + await sleep(1000); - const editorConfig: InitialConfigType = { - namespace: 'MyEditor', - theme: EditorTheme, - onError(error) { - throw error; - }, - nodes: editorNodes, - editorState: activeDocument.editorState || null, - }; + setPublishStatus({ + stage: 'success', + error: null, + commitUrl: result.commitUrl, + pullRequestUrl: result.pullRequestUrl, + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'An unexpected error occurred'; + setPublishStatus({ stage: 'error', error: errorMessage, commitUrl: null, pullRequestUrl: null }); + setIsLoading(false); + } + }; - const handleInfoClick = () => { - const infoUrl = `${baseUrl}community/get-started-quickstart`; - window.open(infoUrl, '_blank', 'noopener,noreferrer'); - }; + const closeLoadingModal = () => { + setPublishStatus({ stage: 'idle', error: null, commitUrl: null, pullRequestUrl: null }); + }; + + const handleSuccessAndReset = () => { + const urlToOpen = publishStatus.pullRequestUrl || publishStatus.commitUrl; + if (urlToOpen) { + window.open(urlToOpen, '_blank', 'noopener,noreferrer'); + } + + // Delete the submitted document (and its children) instead of resetting entire store + if (activeDocument) { + const rootDoc = findRootDocument(activeDocument.id, documents); + if (rootDoc) { + // Delete all documents in this ref arch tree + const docsToDelete = documents.filter(d => { + let current = d; + while (current) { + if (current.id === rootDoc.id) return true; + current = documents.find(doc => doc.id === current.parentId) as typeof current; + } + return false; + }); + docsToDelete.forEach(doc => deleteDocument(doc.id)); + } + } - return ( - -
-
- + // If no documents left, redirect to home + if (documents.length <= 1) { + history.push(baseUrl); + } + + setIsLoading(false); + closeLoadingModal(); + }; + + const handleInfoClick = () => { + const infoUrl = `${baseUrl}community/get-started-quickstart`; + window.open(infoUrl, '_blank', 'noopener,noreferrer'); + }; + + if (!activeDocument) return null; + + return ( + +
+
+ +
+
+
+
+
+ + {lastSaveTimestamp && ( + + {isSyncing ? 'Saving...' : syncError ? `Error: ${syncError}` : `Last saved: ${lastSaveTimestamp}`} + + )} +
+ + {activeDocument && ( + + )}
-
-
-
-
- - {lastSaveTimestamp && ( - Last saved: {lastSaveTimestamp} - )} -
- - {activeDocument && ( - - )} -
-
-
-
- - -
- -
- } - placeholder={} - ErrorBoundary={LexicalErrorBoundary} - /> - - - - - - - - - - - -
- -
-
-
-
- -
+
+
+
+
+ + +
+
+ {contextValue && } +
+ + !(activeDocument?.authors || []).includes(c) + ) + ]} + onContributorsChange={handleContributorsUpdate} + />
+
- {showDeleteConfirm && activeDocument && ( - - - - - } - /> - } - > - - Are you sure you want to delete {activeDocument.title || 'Untitled Page'}? -
- This action cannot be undone. -
-
- )} - +
+ {contextValue && } +
+
+ + {showDeleteConfirm && activeDocument && ( + + + + + } /> - - Sync Required - - } - footer={ - - - {/* */} - - - } - /> - } - > -
-

- Your fork is out of sync and could not be updated automatically. -

-

- Please sync your fork manually on GitHub and then try again, or attempt another automatic sync. -

-

Manual Sync Instructions:

-
    -
  1. - - - Open your fork on GitHub - - -
  2. -
  3. Find the "Sync fork" button near the top of the page.
  4. -
  5. Click "Update branch" to complete the sync.
  6. -
-
-
- - ); + } + > + + Are you sure you want to delete {activeDocument.title || 'Untitled Page'}? +
+ This action cannot be undone. +
+
+ )} + + + + + Sync Required + + } + footer={ + + + + + } + /> + } + > +
+

+ Your fork is out of sync and could not be updated automatically. +

+

+ Please sync your fork manually on GitHub and then try again. +

+

Manual Sync Instructions:

+
    +
  1. + + + Open your fork on GitHub + + +
  2. +
  3. Find the "Sync fork" button near the top of the page.
  4. +
  5. Click "Update branch" to complete the sync.
  6. +
+
+
+
+ + ); }; export default Editor; diff --git a/src/components/Editor/nodes/DrawioNode/index.module.css b/src/components/Editor/nodes/DrawioNode/index.module.css deleted file mode 100644 index 93ca2d1218..0000000000 --- a/src/components/Editor/nodes/DrawioNode/index.module.css +++ /dev/null @@ -1,14 +0,0 @@ -.drawioContainer { - border: 1px solid var(--ifm-color-emphasis-300); - border-radius: 8px; - margin: 8px 0; - min-height: 400px; -} - -.drawioContainer iframe { - width: 100%; - height: 400px; - border: none; - border-radius: 8px; - background-color: #fff; -} diff --git a/src/components/Editor/nodes/DrawioNode/index.tsx b/src/components/Editor/nodes/DrawioNode/index.tsx deleted file mode 100644 index 0c8817296f..0000000000 --- a/src/components/Editor/nodes/DrawioNode/index.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import React, { useMemo, JSX } from 'react'; -import type { EditorConfig, LexicalNode, NodeKey, SerializedLexicalNode, Spread } from 'lexical'; -import { $applyNodeReplacement, DecoratorNode } from 'lexical'; -import styles from './index.module.css'; - -function DrawioComponent({ diagramXML }: { diagramXML: string }): JSX.Element { - const embedUrl = useMemo(() => { - return `https://viewer.diagrams.net/?lightbox=1&edit=_blank&layers=1&nav=1#R${encodeURIComponent(diagramXML)}`; - }, [diagramXML]); - - return ( -
-