diff --git a/README.md b/README.md index 86bc590..3d0c5e8 100644 --- a/README.md +++ b/README.md @@ -338,7 +338,13 @@ Additionally your frontend can - determine which types of text format (node) appear on the quanta toolbar when editing rich text, including adding custom formats ([TODO](https://github.com/collective/volto-hydra/issues/109)) - add a callback of ```onBlockFieldChange``` to rerender just the editable fields more quickly while editing (TODO) -Note Hydra.js knows about the schema of your blocks and based on the field name will determine what what to make your html editable. +Note Hydra.js knows about the schema of your blocks and based on the field name will determine what to make your html editable. + +IMPORTANT: Currently, Hydra.js supports BOLD, ITALIC & STRIKETHROUGH formats on slate blocks and following are the conditions when it breaks slate: +- if you select the whole text and change its formats your frontend might throw slate error saying `Cannot get the leaf node at path [0,0] because it refers to a non-leaf node:` but it is due to proper syncing of json b/w hydrajs & adminUI. +- At the endline if you press format button then it will change the state (active/inactive) but frontend might throw slate error/warning that `Error: Cannot find a descendant at path [0,4,0] in node:` +- pressing ENTER is not implemented so, pressing it will have abnormal changes & error ([TODO](https://github.com/collective/volto-hydra/issues/33)) +These will not break the codebase completely as deserializer at the adminUI cleans up the html and make a proper #### Inline media uploading ([TODO](https://github.com/collective/volto-hydra/issues/36)) diff --git a/examples/hydra-nextjs/src/components/Menu/Menu.js b/examples/hydra-nextjs/src/components/Menu/Menu.js index 67181bc..177d0b5 100644 --- a/examples/hydra-nextjs/src/components/Menu/Menu.js +++ b/examples/hydra-nextjs/src/components/Menu/Menu.js @@ -1,7 +1,7 @@ 'use client'; import React, { useEffect, useState } from 'react'; import { Menu as SemanticMenu } from 'semantic-ui-react'; -import { getTokenFromCookie } from '#utils/hydra'; +import { getTokenFromCookie } from '@volto-hydra/hydra-js'; import Link from 'next/link'; import RecursiveMenuItem from '@/components/RecursiveMenuItem'; import { fetchContent } from '#utils/api'; diff --git a/examples/hydra-nextjs/src/components/RecursiveMenuItem/RecursiveMenuItem.js b/examples/hydra-nextjs/src/components/RecursiveMenuItem/RecursiveMenuItem.js index a0466bb..8719415 100644 --- a/examples/hydra-nextjs/src/components/RecursiveMenuItem/RecursiveMenuItem.js +++ b/examples/hydra-nextjs/src/components/RecursiveMenuItem/RecursiveMenuItem.js @@ -3,7 +3,7 @@ import React, { useEffect, useState } from 'react'; import { Menu as SemanticMenu } from 'semantic-ui-react'; import Link from 'next/link'; import HoverableDropdown from '@/components/HoverableDropdown'; -import { getTokenFromCookie } from '#utils/hydra'; +import { getTokenFromCookie } from '@volto-hydra/hydra-js'; import { fetchContent } from '#utils/api'; import extractEndpoints from '#utils/extractEndpoints'; diff --git a/packages/hydra-js/hydra.js b/packages/hydra-js/hydra.js index 0583639..4520cc0 100644 --- a/packages/hydra-js/hydra.js +++ b/packages/hydra-js/hydra.js @@ -1,4 +1,4 @@ -/** Bridge class creating two-way link between the Hydra and the frontend **/ +/** Bridge class creating two-way link between the Hydra and the frontend */ class Bridge { /** * @@ -23,6 +23,9 @@ class Bridge { this.formData = null; this.blockTextMutationObserver = null; this.selectedBlockUid = null; + this.handleBlockFocusIn = null; + this.handleBlockFocusOut = null; + this.isInlineEditing = false; this.init(options); } @@ -101,6 +104,10 @@ class Bridge { } else { throw new Error('No form data has been sent from the adminUI'); } + } else if (event.data.type === 'TOGGLE_MARK_DONE') { + console.log('toggle mark data rec'); + this.formData = JSON.parse(JSON.stringify(event.data.data)); + callback(event.data.data); } } }; @@ -133,61 +140,21 @@ class Bridge { document.removeEventListener('click', this.blockClickHandler); document.addEventListener('click', this.blockClickHandler); } - /** - * Method to add border, ADD button and Quanta toolbar to the selected block - * @param {Element} blockElement - Block element with the data-block-uid attribute - */ - selectBlock(blockElement) { - // Helper function to handle each element - const handleElement = (element) => { - const editableField = element.getAttribute('data-editable-field'); - if (editableField === 'value') { - this.makeBlockContentEditable(element); - } else if (editableField !== null) { - element.setAttribute('contenteditable', 'true'); - } - }; - - // Function to recursively handle all children - const handleElementAndChildren = (element) => { - handleElement(element); - Array.from(element.children).forEach((child) => - handleElementAndChildren(child), - ); - }; - - // Remove border and button from the previously selected block - if (this.currentlySelectedBlock) { - this.deselectBlock(this.currentlySelectedBlock); - } - const blockUid = blockElement.getAttribute('data-block-uid'); - this.selectedBlockUid = blockUid; - - // Handle the selected block and its children for contenteditable - handleElementAndChildren(blockElement); - // Only when the block is a slate block, add nodeIds to the block's data - this.observeBlockTextChanges(blockElement); - // if the block is a slate block, add nodeIds to the block's data - if (this.formData && this.formData.blocks[blockUid]['@type'] === 'slate') { - this.formData.blocks[blockUid] = this.addNodeIds( - this.formData.blocks[blockUid], - ); - this.setDataCallback(this.formData); + createQuantaToolbar(blockUid, show = { formatBtns: true }) { + // Check if the toolbar already exists + if (this.quantaToolbar) { + return; } - // Add focus out event listener - blockElement.addEventListener( - 'focusout', - this.handleBlockFocusOut.bind(this), - ); + // Create the quantaToolbar + this.quantaToolbar = document.createElement('div'); + this.quantaToolbar.className = 'volto-hydra-quantaToolbar'; - // Set the currently selected block - this.currentlySelectedBlock = blockElement; - // Add border to the currently selected block - this.currentlySelectedBlock.classList.add('volto-hydra--outline'); + // Prevent click event propagation for the quantaToolbar + this.quantaToolbar.addEventListener('click', (e) => e.stopPropagation()); - // Create and append the Add button + // Create the Add button this.addButton = document.createElement('button'); this.addButton.className = 'volto-hydra-add-button'; this.addButton.innerHTML = addSVG; @@ -200,23 +167,85 @@ class Bridge { }; this.currentlySelectedBlock.appendChild(this.addButton); - // Create the quantaToolbar - this.quantaToolbar = document.createElement('div'); - this.quantaToolbar.className = 'volto-hydra-quantaToolbar'; - - // Prevent event propagation for the quantaToolbar - this.quantaToolbar.addEventListener('click', (e) => e.stopPropagation()); - // Create the drag button const dragButton = document.createElement('button'); dragButton.className = 'volto-hydra-drag-button'; - dragButton.innerHTML = dragSVG; // Use your drag SVG here - dragButton.disabled = true; // Disable drag button for now + dragButton.innerHTML = dragSVG; + dragButton.disabled = true; + + let boldButton = null; + let italicButton = null; + let delButton = null; + + if (show.formatBtns) { + // Create the bold button + boldButton = document.createElement('button'); + boldButton.className = `volto-hydra-format-button ${ + show.formatBtns ? 'show' : '' + }`; + boldButton.innerHTML = boldSVG; + boldButton.addEventListener('click', () => { + this.formatSelectedText('bold'); + }); + + // Create the italic button + italicButton = document.createElement('button'); + italicButton.className = `volto-hydra-format-button ${ + show.formatBtns ? 'show' : '' + }`; + italicButton.innerHTML = italicSVG; + italicButton.addEventListener('click', () => { + this.formatSelectedText('italic'); + }); + + // Create the del button + delButton = document.createElement('button'); + delButton.className = `volto-hydra-format-button ${ + show.formatBtns ? 'show' : '' + }`; + delButton.innerHTML = delSVG; + delButton.addEventListener('click', () => { + this.formatSelectedText('del'); + }); + + // Function to handle the text selection and show/hide the bold button + const handleSelectionChange = () => { + const selection = window.getSelection(); + const range = selection.getRangeAt(0); + + // Append the bold button only if text is selected and the block has the data-editable-field="value" attribute + + const formats = this.isFormatted(range); + boldButton.classList.toggle( + 'active', + formats.bold.enclosing || formats.bold.present, + ); + italicButton.classList.toggle( + 'active', + formats.italic.enclosing || formats.italic.present, + ); + delButton.classList.toggle( + 'active', + formats.del.enclosing || formats.del.present, + ); + }; + + // Add event listener to handle text selection within the block + this.handleMouseUp = (e) => { + if (e.target.closest('[data-editable-field="value"]')) { + handleSelectionChange(); + } + }; + this.currentlySelectedBlock.addEventListener( + 'mouseup', + this.handleMouseUp, + ); + } // Create the three-dot menu button const menuButton = document.createElement('button'); menuButton.className = 'volto-hydra-menu-button'; - menuButton.innerHTML = threeDotsSVG; // Use your three dots SVG here + menuButton.innerHTML = threeDotsSVG; // Create the dropdown menu const dropdownMenu = document.createElement('div'); @@ -256,11 +285,103 @@ class Bridge { // Append elements to the quantaToolbar this.quantaToolbar.appendChild(dragButton); + if (show.formatBtns) { + this.quantaToolbar.appendChild(boldButton); + this.quantaToolbar.appendChild(italicButton); + this.quantaToolbar.appendChild(delButton); + } this.quantaToolbar.appendChild(menuButton); this.quantaToolbar.appendChild(dropdownMenu); // Append the quantaToolbar to the currently selected block this.currentlySelectedBlock.appendChild(this.quantaToolbar); + } + + /** + * Method to add border, ADD button and Quanta toolbar to the selected block + * @param {Element} blockElement - Block element with the data-block-uid attribute + */ + selectBlock(blockElement) { + // Remove border and button from the previously selected block + if (this.currentlySelectedBlock) { + this.deselectBlock(this.currentlySelectedBlock, blockElement); + } + if ( + this.currentlySelectedBlock === null || + this.currentlySelectedBlock !== blockElement + ) { + this.handleBlockFocusOut = (e) => { + // console.log("focus out"); + window.parent.postMessage( + { type: 'INLINE_EDIT_EXIT' }, + this.adminOrigin, + ); + }; + this.handleBlockFocusIn = (e) => { + // console.log("focus in"); + window.parent.postMessage( + { + type: 'INLINE_EDIT_ENTER', + }, + this.adminOrigin, + ); + this.isInlineEditing = true; + }; + // Add focus in event listener + blockElement.addEventListener( + 'focusout', + this.handleBlockFocusOut.bind(this), + ); + + blockElement.addEventListener( + 'focusin', + this.handleBlockFocusIn.bind(this), + ); + } + // Helper function to handle each element + const handleElement = (element) => { + const editableField = element.getAttribute('data-editable-field'); + if (editableField === 'value') { + this.makeBlockContentEditable(element); + } else if (editableField !== null) { + element.setAttribute('contenteditable', 'true'); + } + }; + + // Function to recursively handle all children + const handleElementAndChildren = (element) => { + handleElement(element); + Array.from(element.children).forEach((child) => + handleElementAndChildren(child), + ); + }; + + const blockUid = blockElement.getAttribute('data-block-uid'); + this.selectedBlockUid = blockUid; + + // Handle the selected block and its children for contenteditable + handleElementAndChildren(blockElement); + let show = { formatBtns: false }; + this.observeBlockTextChanges(blockElement); + // // if the block is a slate block, add nodeIds to the block's data + if (this.formData && this.formData.blocks[blockUid]['@type'] === 'slate') { + show.formatBtns = true; + this.formData.blocks[blockUid] = this.addNodeIds( + this.formData.blocks[blockUid], + ); + this.setDataCallback(this.formData); + // window.parent.postMessage( + // { type: "ADD_NODEIDS", data: this.formData }, + // this.adminOrigin + // ); + } + + // Set the currently selected block + this.currentlySelectedBlock = blockElement; + // Add border to the currently selected block + this.currentlySelectedBlock.classList.add('volto-hydra--outline'); + + if (this.formData) this.createQuantaToolbar(blockUid, show); if (!this.clickOnBtn) { window.parent.postMessage( @@ -377,38 +498,44 @@ class Bridge { * Reset the block's listeners, mutation observer and remove the nodeIds from the block's data * @param {Element} blockElement Selected block element */ - deselectBlock(blockElement) { - this.currentlySelectedBlock.classList.remove('volto-hydra--outline'); - if (this.addButton) { - this.addButton.remove(); - this.addButton = null; - } - if (this.deleteButton) { - this.deleteButton.remove(); - this.deleteButton = null; - } - if (this.quantaToolbar) { - this.quantaToolbar.remove(); - this.quantaToolbar = null; - } - const blockUid = blockElement.getAttribute('data-block-uid'); - if (this.selectedBlockUid !== null && this.selectedBlockUid !== blockUid) { + deselectBlock(prevBlockElement, currBlockElement) { + const currBlockUid = currBlockElement.getAttribute('data-block-uid'); + if ( + this.selectedBlockUid !== null && + this.selectedBlockUid !== currBlockUid + ) { + this.currentlySelectedBlock.classList.remove('volto-hydra--outline'); + if (this.addButton) { + this.addButton.remove(); + this.addButton = null; + } + if (this.deleteButton) { + this.deleteButton.remove(); + this.deleteButton = null; + } + if (this.quantaToolbar) { + this.quantaToolbar.remove(); + this.quantaToolbar = null; + } // Remove contenteditable attribute - blockElement.removeAttribute('contenteditable'); - const childNodes = blockElement.querySelectorAll('[data-hydra-node]'); + prevBlockElement.removeAttribute('contenteditable'); + const childNodes = prevBlockElement.querySelectorAll('[data-hydra-node]'); childNodes.forEach((node) => { node.removeAttribute('contenteditable'); }); // Clean up JSON structure - this.resetJsonNodeIds(this.blocksJson); + // if (this.formData.blocks[this.selectedBlockUid]["@type"] === "slate") this.resetJsonNodeIds(this.formData.blocks[this.selectedBlockUid]); // Remove focus out event listener - blockElement.removeEventListener( + prevBlockElement.removeEventListener( 'focusout', - this.handleBlockFocusOut.bind(this), + this.handleBlockFocusOut, ); + // Remove focus in event listener + prevBlockElement.removeEventListener('focusin', this.handleBlockFocusIn); } + document.removeEventListener('mouseup', this.handleMouseUp); // Disconnect the mutation observer if (this.blockTextMutationObserver) { this.blockTextMutationObserver.disconnect(); @@ -442,38 +569,55 @@ class Bridge { observeBlockTextChanges(blockElement) { this.blockTextMutationObserver = new MutationObserver((mutations) => { mutations.forEach((mutation) => { - if ( - mutation.type === 'characterData' || - mutation.type === 'childList' - ) { - let targetElement = null; - - if (mutation.type === 'characterData') { - targetElement = - mutation.target?.parentElement.closest('[data-hydra-node]'); - } else { - targetElement = mutation.target.closest('[data-hydra-node]'); - } + if (mutation.type === 'characterData') { + const targetElement = + mutation.target?.parentElement.closest('[data-hydra-node]'); - if (targetElement) { - this.handleTextChange(targetElement); + if (targetElement && this.isInlineEditing) { + this.handleTextChangeOnSlate(targetElement); + } else if (this.isInlineEditing) { + const targetElement = mutation.target?.parentElement.closest( + '[data-editable-field]', + ); + if (targetElement) { + this.handleTextChange(targetElement); + } } } }); }); this.blockTextMutationObserver.observe(blockElement, { - childList: true, subtree: true, characterData: true, }); } /** - * Handle the text change in the slate block element + * Handle the text change in the block element with attr data-editable-field * @param {Element} target */ handleTextChange(target) { + const blockUid = target + .closest('[data-block-uid]') + .getAttribute('data-block-uid'); + const editableField = target.getAttribute('data-editable-field'); + if (editableField) + this.formData.blocks[blockUid][editableField] = target.innerText; + console.log('editableField', this.formData.blocks[blockUid][editableField]); + if (this.formData.blocks[blockUid]['@type'] !== 'slate') { + window.parent.postMessage( + { type: 'INLINE_EDIT_DATA', data: this.formData }, + this.adminOrigin, + ); + } + } + + /** + * Handle the text change in the slate block element + * @param {Element} target + */ + handleTextChangeOnSlate(target) { const closestNode = target.closest('[data-hydra-node]'); if (closestNode) { const nodeId = closestNode.getAttribute('data-hydra-node'); @@ -515,8 +659,279 @@ class Bridge { return json; } - handleBlockFocusOut(e) { - window.parent.postMessage({ type: 'INLINE_EDIT_EXIT' }, this.adminOrigin); + findParentWithAttribute(node, attribute) { + while (node && node.nodeType === Node.ELEMENT_NODE) { + if (node.hasAttribute(attribute)) { + return node; + } + node = node.parentElement; + } + return null; + } + + getSelectionHTML(range) { + const div = document.createElement('div'); + div.appendChild(range.cloneContents()); + return div.innerHTML; + } + isFormatted(range) { + const formats = { + bold: { present: false, enclosing: false }, + italic: { present: false, enclosing: false }, + del: { present: false, enclosing: false }, + }; + + // Check if the selection is collapsed (empty) + // if (range.collapsed) return formats; + + // Get the common ancestor container of the selection + let container = range.commonAncestorContainer; + + // Traverse upwards until we find the editable parent or the root + while ( + container && + container !== document && + !(container.dataset && container.dataset.editableField === 'value') + ) { + // Check if the container itself has any of the formatting + if (container.nodeName === 'STRONG' || container.nodeName === 'B') { + if ( + container.contains(range.startContainer) && + container.contains(range.endContainer) + ) { + formats.bold.enclosing = true; + formats.bold.present = true; + } + } + if (container.nodeName === 'EM' || container.nodeName === 'I') { + if ( + container.contains(range.startContainer) && + container.contains(range.endContainer) + ) { + formats.italic.enclosing = true; + formats.italic.present = true; + } + } + if (container.nodeName === 'DEL') { + if ( + container.contains(range.startContainer) && + container.contains(range.endContainer) + ) { + formats.del.enclosing = true; + formats.del.present = true; + } + } + + container = container.parentNode; + } + + // Check for formatting within the selection + const selectionHTML = this.getSelectionHTML(range).toString(); + if (selectionHTML.includes('') || selectionHTML.includes('')) { + formats.bold.present = true; + } + if (selectionHTML.includes('') || selectionHTML.includes('')) { + formats.italic.present = true; + } + if (selectionHTML.includes('')) { + formats.del.present = true; + } + + return formats; + } + + // Helper function to get the next node in the selection + nextNode(node) { + if (!node) return null; // Handle the case where node is null + + if (node.firstChild) return node.firstChild; + + while (node) { + if (node.nextSibling) return node.nextSibling; + node = node.parentNode; + } + + return null; // Reached the end, return null + } + + formatSelectedText(format) { + this.isInlineEditing = false; + const selection = window.getSelection(); + if (!selection.rangeCount) return; + + const range = selection.getRangeAt(0); + const currentFormats = this.isFormatted(range); + + if (currentFormats[format].present) { + this.unwrapFormatting(range, format); + } else { + // Handle selections that include non-Text nodes + const fragment = range.extractContents(); // Extract the selected content + const newNode = document.createElement( + format === 'bold' + ? 'strong' + : format === 'italic' + ? 'em' + : format === 'del' + ? 'del' + : 'span', + ); + newNode.appendChild(fragment); // Append the extracted content to the new node + range.insertNode(newNode); // Insert the new node back into the document + } + this.sendFormattedHTMLToAdminUI(selection); + } + + // Helper function to unwrap formatting while preserving other formatting + unwrapFormatting(range, format) { + const formattingElements = { + bold: ['STRONG', 'B'], + italic: ['EM', 'I'], + del: ['DEL'], + }; + const selection = window.getSelection(); + // Check if the selection is entirely within a formatting element of the specified type + let container = range.commonAncestorContainer; + while ( + container && + container !== document && + !(container.dataset && container.dataset.editableField === 'value') + ) { + if (formattingElements[format].includes(container.nodeName)) { + // Check if the entire content of the formatting element is selected + const isEntireContentSelected = + range.startOffset === 0 && + range.endOffset === container.textContent.length; + + if (isEntireContentSelected || selection.isCollapsed) { + // Unwrap the entire element + this.unwrapElement(container); + } else { + // Unwrap only the selected portion + this.unwrapSelectedPortion( + container, + range, + format, + formattingElements, + ); + } + return; // No need to check further + } + container = container.parentNode; + } + + // If the selection is not entirely within a formatting element, remove all occurrences of the format within the selection + let node = range.startContainer; + while (node && node !== range.endContainer) { + if ( + node.nodeType === Node.ELEMENT_NODE && + formattingElements[format].includes(node.nodeName) + ) { + this.unwrapElement(node); + } else if ( + node.nodeType === Node.TEXT_NODE && + node.parentNode && + formattingElements[format].includes(node.parentNode.nodeName) + ) { + // Handle the case where the text node itself is within the formatting element + this.unwrapElement(node.parentNode); + } + node = this.nextNode(node); + } + } + + // Helper function to unwrap the selected portion within a formatting element + unwrapSelectedPortion(element, range, format, formattingElements) { + const formattingTag = formattingElements[format][0]; + + // Check if selection starts at the beginning of the formatting element + const selectionStartsAtBeginning = range.startOffset === 0; + + // Check if selection ends at the end of the formatting element + const selectionEndsAtEnd = range.endOffset === element.textContent.length; + + // Extract the contents before the selection (only if not at the beginning) + let beforeFragment = null; + if (!selectionStartsAtBeginning) { + const beforeRange = document.createRange(); + beforeRange.setStart(element, 0); + beforeRange.setEnd(range.startContainer, range.startOffset); + beforeFragment = beforeRange.extractContents(); + } + + // Extract the selected contents + const selectionFragment = range.extractContents(); + + // Extract the contents after the selection (only if not at the end) + let afterFragment = null; + if (!selectionEndsAtEnd) { + const afterRange = document.createRange(); + afterRange.setStart(range.endContainer, range.endOffset); + afterRange.setEnd(element, element.childNodes.length); + afterFragment = afterRange.extractContents(); + } + + // Create new elements to wrap the before and after fragments, keeping the original formatting (only if fragments exist) + const beforeWrapper = beforeFragment + ? document.createElement(formattingTag) + : null; + if (beforeWrapper) { + beforeWrapper.appendChild(beforeFragment); + } + const afterWrapper = afterFragment + ? document.createElement(formattingTag) + : null; + if (afterWrapper) { + afterWrapper.appendChild(afterFragment); + } + + // Replace the original element with the unwrapped selection and the formatted before/after parts + const parent = element.parentNode; + if (beforeWrapper) { + parent.insertBefore(beforeWrapper, element); + } + parent.insertBefore(selectionFragment, element); + if (afterWrapper) { + parent.insertBefore(afterWrapper, element); + } + parent.removeChild(element); + } + + // Helper function to unwrap a single formatting element + unwrapElement(element) { + const parent = element.parentNode; + while (element.firstChild) { + parent.insertBefore(element.firstChild, element); + } + parent.removeChild(element); + } + sendFormattedHTMLToAdminUI(selection) { + if (!selection.rangeCount) return; // No selection + + const range = selection.getRangeAt(0); + const commonAncestor = range.commonAncestorContainer; + + const editableParent = this.findEditableParent(commonAncestor); + if (!editableParent) return; // Couldn't find the editable parent + + const htmlString = editableParent.outerHTML; + + window.parent.postMessage( + { + type: 'TOGGLE_MARK', + html: htmlString, + }, + this.adminOrigin, + ); + } + findEditableParent(node) { + if (!node || node === document) return null; // Reached the top without finding + + if (node.dataset && node.dataset.editableField === 'value') { + return node; + } + + return this.findEditableParent(node.parentNode); } injectCSS() { const style = document.createElement('style'); @@ -575,17 +990,30 @@ class Bridge { top: -45px; left: 0; box-sizing: border-box; - width: 70px; + width: fit-content; height: 40px; } .volto-hydra-drag-button, - .volto-hydra-menu-button { + .volto-hydra-menu-button, + .volto-hydra-format-button { background: none; border: none; cursor: pointer; padding: 0.5em; margin: 0; } + .volto-hydra-format-button { + border-radius: 5px; + margin: 1px; + display: none; + } + .volto-hydra-format-button.show { + display: block !important; + } + .volto-hydra-format-button.active, + .volto-hydra-format-button:hover { + background-color: #ddd; + } .volto-hydra-drag-button { cursor: default; background: #E4E8EC; @@ -598,7 +1026,7 @@ class Bridge { display: none; position: absolute; top: 100%; - right: -200%; + right: -80%; background: white; border: 1px solid #ccc; border-radius: 4px; @@ -717,6 +1145,9 @@ const dragSVG = ` `; +const boldSVG = ``; +const italicSVG = ``; +const delSVG = ``; const addSVG = ``; const threeDotsSVG = ` diff --git a/packages/volto-hydra/src/components/Iframe/View.jsx b/packages/volto-hydra/src/components/Iframe/View.jsx index c0b7b92..2fd7834 100644 --- a/packages/volto-hydra/src/components/Iframe/View.jsx +++ b/packages/volto-hydra/src/components/Iframe/View.jsx @@ -23,6 +23,8 @@ import { getAllowedBlocksList, setAllowedBlocksList, } from '../../utils/allowedBlockList'; +import toggleMark from '../../utils/toggleMark'; +import addNodeIds from '../../utils/addNodeIds'; /** * Format the URL for the Iframe with location, token and edit mode @@ -216,8 +218,21 @@ const Iframe = (props) => { } break; + // case 'INLINE_EDIT_ENTER': + // isInlineEditingRef.current = true; // Set to true to prevent sending form data to iframe + // const updatedJson = addNodeIds(form.blocks[selectedBlock]); + // onChangeFormData({ + // ...form, + // blocks: { ...form.blocks, [selectedBlock]: updatedJson }, + // }); + // break; + case 'INLINE_EDIT_DATA': isInlineEditingRef.current = true; + console.log( + 'Inline data recieved', + event.data.data?.blocks[selectedBlock], + ); onChangeFormData(event.data.data); break; @@ -225,6 +240,38 @@ const Iframe = (props) => { isInlineEditingRef.current = false; break; + case 'TOGGLE_MARK': + console.log('TOGGLE_BOLD', event.data.html); + isInlineEditingRef.current = true; + const deserializedHTMLData = toggleMark(event.data.html); + console.log('deserializedHTMLData', deserializedHTMLData); + onChangeFormData({ + ...form, + blocks: { + ...form.blocks, + [selectedBlock]: { + ...form.blocks[selectedBlock], + value: deserializedHTMLData, + }, + }, + }); + event.source.postMessage( + { + type: 'TOGGLE_MARK_DONE', + data: { + ...form, + blocks: { + ...form.blocks, + [selectedBlock]: { + ...form.blocks[selectedBlock], + value: deserializedHTMLData, + }, + }, + }, + }, + event.origin, + ); + break; default: break; } @@ -239,16 +286,20 @@ const Iframe = (props) => { }; }, [ dispatch, + form, + form?.blocks, handleNavigateToUrl, history.location.pathname, iframeSrc, onChangeFormData, onSelectBlock, properties, + selectedBlock, token, ]); useEffect(() => { + // console.log('form data changed', form?.blocks[selectedBlock]); if ( !isInlineEditingRef.current && form && diff --git a/packages/volto-hydra/src/utils/addNodeIds.js b/packages/volto-hydra/src/utils/addNodeIds.js new file mode 100644 index 0000000..096a570 --- /dev/null +++ b/packages/volto-hydra/src/utils/addNodeIds.js @@ -0,0 +1,33 @@ +/** + * Add nodeIds in the json object to each of the Selected Block's children + * @param {JSON} json Selected Block's data + * @param {BigInteger} nodeIdCounter (Optional) Counter to keep track of the nodeIds + * @returns {JSON} block's data with nodeIds added + */ +function addNodeIds(json, nodeIdCounter = { current: 0 }) { + if (Array.isArray(json)) { + return json.map((item) => addNodeIds(item, nodeIdCounter)); + } else if (typeof json === 'object' && json !== null) { + // Clone the object to ensure it's extensible + json = JSON.parse(JSON.stringify(json)); + + if (json.hasOwnProperty('data')) { + json.nodeId = nodeIdCounter.current++; + for (const key in json) { + if (json.hasOwnProperty(key) && key !== 'nodeId' && key !== 'data') { + json[key] = addNodeIds(json[key], nodeIdCounter); + } + } + } else { + json.nodeId = nodeIdCounter.current++; + for (const key in json) { + if (json.hasOwnProperty(key) && key !== 'nodeId') { + json[key] = addNodeIds(json[key], nodeIdCounter); + } + } + } + } + return json; +} + +export default addNodeIds; diff --git a/packages/volto-hydra/src/utils/toggleMark.js b/packages/volto-hydra/src/utils/toggleMark.js new file mode 100644 index 0000000..5a607f5 --- /dev/null +++ b/packages/volto-hydra/src/utils/toggleMark.js @@ -0,0 +1,99 @@ +import { jsx } from 'slate-hyperscript'; +import addNodeIds from './addNodeIds'; + +const deserialize = (el, markAttributes = {}) => { + if (el.nodeType === Node.TEXT_NODE) { + return jsx('text', markAttributes, el.textContent); + } else if (el.nodeType !== Node.ELEMENT_NODE) { + return null; + } + + const nodeAttributes = { ...markAttributes }; + + // define attributes for text nodes + // (Lets try handling this after the recursive call because slate is throwing an error when we pass this json) + // switch (el.nodeName) { + // case 'STRONG': + // nodeAttributes.type = 'strong'; + // break; + // default: + // break; + // } + + const children = Array.from(el.childNodes) + .map((node) => { + // Add data-slate-node attribute if missing + if (node.nodeType === Node.ELEMENT_NODE && !node.dataset.slateNode) { + node.dataset.slateNode = 'element'; // Or 'text' if it's a text node + } + return deserialize(node, nodeAttributes); + }) + .flat(); + + // Ensure formatting elements have at least one child (empty text if necessary) + if (children.length === 0 && ['STRONG', 'EM', 'DEL'].includes(el.nodeName)) { + children.push(jsx('text', {}, ' ')); + } + + switch (el.nodeName) { + case 'BODY': + return jsx('fragment', {}, children); + case 'BR': + // Add newline only if it's not the last child of a block-level element + const parent = el.parentNode; + if (parent && parent.lastChild !== el && isBlockElement(parent)) { + return '\n'; + } else { + return null; // Ignore
if it's at the end or within an inline element + } + case 'BLOCKQUOTE': + return jsx('element', { type: 'quote' }, children); + case 'P': + return jsx('element', { type: 'p' }, children); + case 'A': + return jsx( + 'element', + { type: 'link', url: el.getAttribute('href') }, + children, + ); + case 'STRONG': // Handle elements explicitly + return jsx('element', { type: 'strong' }, children); + + case 'EM': // Handle elements explicitly + return jsx('element', { type: 'em' }, children); + + case 'DEL': // Handle elements explicitly + return jsx('element', { type: 'del' }, children); + default: + return children; + } +}; +/** + * Converts html string (recieved from hydrajs) to slate compatible json data by first deserializing the html string to slate json data and adding node ids to the json data + * @param {String} html html string + * @returns {JSON} slate compatible json data + */ +export default function toggleMark(html) { + const document = new DOMParser().parseFromString(html, 'text/html'); + const d = deserialize(document.body); + return addNodeIds(d, { current: 1 }); +} + +// Helper function to check if an element is block-level +function isBlockElement(element) { + const blockElements = [ + 'P', + 'DIV', + 'BLOCKQUOTE', + 'H1', + 'H2', + 'H3', + 'H4', + 'H5', + 'H6', + 'UL', + 'OL', + 'LI', + ]; // Add more as needed + return blockElements.includes(element.nodeName); +}