diff --git a/examples/hydra-vue-f7/src/js/hydra.js b/examples/hydra-vue-f7/src/js/hydra.js index 1e643d2..6b019e3 100644 --- a/examples/hydra-vue-f7/src/js/hydra.js +++ b/examples/hydra-vue-f7/src/js/hydra.js @@ -19,13 +19,15 @@ class Bridge { this.quantaToolbar = null; this.currentUrl = typeof window !== 'undefined' ? new URL(window.location.href) : null; - this.setDataCallback = null; this.formData = null; this.blockTextMutationObserver = null; + this.attributeMutationObserver = null; this.selectedBlockUid = null; this.handleBlockFocusIn = null; this.handleBlockFocusOut = null; this.isInlineEditing = false; + this.handleMouseUp = null; + this.blockObserver = null; this.init(options); } @@ -110,14 +112,33 @@ class Bridge { this.enableBlockClickListener(); this.injectCSS(); this.listenForSelectBlockMessage(); + window.parent.postMessage( + { type: 'GET_INITIAL_DATA' }, + this.adminOrigin, + ); + const reciveInitialData = (e) => { + if (e.origin === this.adminOrigin) { + if (e.data.type === 'INITIAL_DATA') { + this.formData = JSON.parse(JSON.stringify(e.data.data)); + window.postMessage( + { type: 'FORM_DATA', data: this.formData }, + window.location.origin, + ); + } + } + }; + window.removeEventListener('message', reciveInitialData); + window.addEventListener('message', reciveInitialData); } } } onEditChange(callback) { - this.setDataCallback = callback; this.realTimeDataHandler = (event) => { - if (event.origin === this.adminOrigin) { + if ( + event.origin === this.adminOrigin || + event.origin === window.location.origin + ) { if (event.data.type === 'FORM_DATA') { if (event.data.data) { this.formData = JSON.parse(JSON.stringify(event.data.data)); @@ -126,7 +147,6 @@ class Bridge { 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); } @@ -144,7 +164,7 @@ class Bridge { const url = new URL(window.location.href); const domain = url.hostname; - document.cookie = `auth_token=${token}; expires=${expiryDate.toUTCString()}; path=/; domain=${domain};`; + document.cookie = `access_token=${token}; expires=${expiryDate.toUTCString()}; path=/; domain=${domain}; SameSite=None; Secure`; } /** @@ -152,6 +172,7 @@ class Bridge { */ enableBlockClickListener() { this.blockClickHandler = (event) => { + event.stopPropagation(); const blockElement = event.target.closest('[data-block-uid]'); if (blockElement) { this.selectBlock(blockElement); @@ -179,7 +200,8 @@ class Bridge { this.addButton = document.createElement('button'); this.addButton.className = 'volto-hydra-add-button'; this.addButton.innerHTML = addSVG; - this.addButton.onclick = () => { + this.addButton.onclick = (e) => { + e.stopPropagation(); this.clickOnBtn = true; window.parent.postMessage( { type: 'ADD_BLOCK', uid: blockUid }, @@ -192,6 +214,8 @@ class Bridge { const dragButton = document.createElement('button'); dragButton.className = 'volto-hydra-drag-button'; dragButton.innerHTML = dragSVG; + let isDragging = false; + let startY; // dragButton.disabled = true; dragButton.addEventListener('mousedown', (e) => { e.preventDefault(); @@ -207,14 +231,12 @@ class Bridge { draggedBlock.style.height = `${rect.height}px`; draggedBlock.style.left = `${e.clientX}px`; draggedBlock.style.top = `${e.clientY}px`; - console.log( - 'DRAGGED BLOCK POSITION', - draggedBlock.style.left, - draggedBlock.style.top, - ); let closestBlockUid = null; let throttleTimeout; // Throttle the mousemove event for performance (maybe not needed but if we got larger blocks than yeah needed!) let insertAt = null; // 0 for top & 1 for bottom + isDragging = true; + startY = e.clientY; + let startYTimeout; // Handle mouse movement const onMouseMove = (e) => { draggedBlock.style.left = `${e.clientX}px`; @@ -260,22 +282,52 @@ class Bridge { insertAt = 1; } closestBlock.classList.add( - `${insertAt === 0 ? 'highlighted-block' : 'highlighted-block-bottom'}`, + `${ + insertAt === 0 + ? 'highlighted-block' + : 'highlighted-block-bottom' + }`, + `${ + insertAt === 0 + ? 'highlighted-block' + : 'highlighted-block-bottom' + }`, ); closestBlockUid = closestBlock.getAttribute('data-block-uid'); } else { - console.log('Not hovering over any block'); + // console.log("Not hovering over any block"); } throttleTimeout = null; }, 100); } + if (isDragging) { + const currentY = e.clientY; + const deltaY = currentY - startY; + clearTimeout(startYTimeout); + startYTimeout = setTimeout(() => { + startY = currentY; + }, 153); + + // Check if the mouse is near the top or bottom of the viewport + const scrollThreshold = 50; // distance from the top/bottom of the viewport + const scrollSpeedFactor = 0.1; // for speeed scrolling (try/error) + + if (currentY < scrollThreshold) { + // Scroll up, speed based on deltaY + window.scrollBy(0, -Math.abs(deltaY) * scrollSpeedFactor); + } else if (currentY > window.innerHeight - scrollThreshold) { + // Scroll down, speed based on deltaY + window.scrollBy(0, Math.abs(deltaY) * scrollSpeedFactor); + } + } }; // Cleanup on mouseup & updating the blocks layout & sending it to adminUI const onMouseUp = () => { document.querySelector('body').classList.remove('grabbing'); document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); - + isDragging = false; + clearTimeout(startYTimeout); draggedBlock.remove(); if (closestBlockUid) { const draggedBlockId = @@ -316,6 +368,7 @@ class Bridge { let boldButton = null; let italicButton = null; let delButton = null; + let linkButton = null; if (show.formatBtns) { // Create the bold button @@ -348,13 +401,70 @@ class Bridge { this.formatSelectedText('del'); }); + // Create the del button + linkButton = document.createElement('button'); + linkButton.className = `volto-hydra-format-button ${ + show.formatBtns ? 'show' : '' + }`; + linkButton.innerHTML = linkSVG; + linkButton.addEventListener('click', () => { + const selection = window.getSelection(); + const commonAncestor = selection.getRangeAt(0).commonAncestorContainer; + + if (!selection.rangeCount || selection.isCollapsed) return; + + const range = selection.getRangeAt(0); + const container = document.createElement('div'); + container.classList.add('link-input-container'); + + const inputField = document.createElement('input'); + inputField.type = 'text'; + inputField.placeholder = 'Enter URL'; + + const beforeButton = document.createElement('button'); + beforeButton.textContent = 'Before'; + + const afterButton = document.createElement('button'); + afterButton.textContent = 'After'; + afterButton.addEventListener('click', () => { + const url = inputField.value; + const link = document.createElement('a'); + link.href = url; + range.surroundContents(link); + this.isInlineEditing = false; + const editableParent = this.findEditableParent(commonAncestor); + const htmlString = editableParent.outerHTML; + + window.parent.postMessage( + { + type: 'TOGGLE_MARK', + html: htmlString, + }, + this.adminOrigin, + ); + container.remove(); // Close the input field (why errrrror? whyyy..)(sometimes) + }); + + container.appendChild(beforeButton); + container.appendChild(inputField); + container.appendChild(afterButton); + + const buttonRect = linkButton.getBoundingClientRect(); + container.style.position = 'absolute'; + container.style.top = `${ + buttonRect.top - container.offsetHeight - 5 + }px`; // 5px gap above the button (UI later) + container.style.left = `${buttonRect.left}px`; + + // Append the container to the link button's parent + linkButton.parentNode.appendChild(container); + }); + // 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', @@ -372,7 +482,12 @@ class Bridge { // Add event listener to handle text selection within the block this.handleMouseUp = (e) => { - if (e.target.closest('[data-editable-field="value"]')) { + if ( + e.target.closest('[data-editable-field="value"]') && + e.target + .closest('[data-block-uid]') + .getAttribute('data-block-uid') === blockUid + ) { handleSelectionChange(); } }; @@ -429,6 +544,7 @@ class Bridge { this.quantaToolbar.appendChild(boldButton); this.quantaToolbar.appendChild(italicButton); this.quantaToolbar.appendChild(delButton); + this.quantaToolbar.appendChild(linkButton); } this.quantaToolbar.appendChild(menuButton); this.quantaToolbar.appendChild(dropdownMenu); @@ -442,6 +558,7 @@ class Bridge { * @param {Element} blockElement - Block element with the data-block-uid attribute */ selectBlock(blockElement) { + if (!blockElement) return; // Remove border and button from the previously selected block if (this.currentlySelectedBlock) { this.deselectBlock(this.currentlySelectedBlock, blockElement); @@ -450,87 +567,93 @@ class Bridge { this.currentlySelectedBlock === null || this.currentlySelectedBlock !== blockElement ) { + this.isInlineEditing = true; this.handleBlockFocusOut = (e) => { // console.log("focus out"); window.parent.postMessage( { type: 'INLINE_EDIT_EXIT' }, this.adminOrigin, ); + this.isInlineEditing = false; }; 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'); - } - }; + blockElement.addEventListener('focusout', this.handleBlockFocusOut); - // Function to recursively handle all children - const handleElementAndChildren = (element) => { - handleElement(element); - Array.from(element.children).forEach((child) => - handleElementAndChildren(child), - ); - }; + blockElement.addEventListener('focusin', this.handleBlockFocusIn); - const blockUid = blockElement.getAttribute('data-block-uid'); - this.selectedBlockUid = blockUid; + // 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'); + } + }; - // 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 - // ); - } + // Function to recursively handle all children + const handleElementAndChildren = (element) => { + handleElement(element); + Array.from(element.children).forEach((child) => + handleElementAndChildren(child), + ); + }; - // Set the currently selected block - this.currentlySelectedBlock = blockElement; - // Add border to the currently selected block - this.currentlySelectedBlock.classList.add('volto-hydra--outline'); + const blockUid = blockElement.getAttribute('data-block-uid'); + this.selectedBlockUid = blockUid; - if (this.formData) this.createQuantaToolbar(blockUid, show); + let show = { formatBtns: false }; + // 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], + ); + window.postMessage( + { type: 'FORM_DATA', data: this.formData }, + window.location.origin, + ); + window.parent.postMessage( + { type: 'INLINE_EDIT_DATA', data: this.formData }, + this.adminOrigin, + ); + window.parent.postMessage( + { type: 'INLINE_EDIT_EXIT' }, + this.adminOrigin, + ); + } + handleElementAndChildren(blockElement); + // 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( - { type: 'OPEN_SETTINGS', uid: blockUid }, - this.adminOrigin, - ); - } else { - this.clickOnBtn = false; + if (!this.clickOnBtn) { + window.parent.postMessage( + { type: 'OPEN_SETTINGS', uid: blockUid }, + this.adminOrigin, + ); + } else { + this.clickOnBtn = false; + } } + this.observeBlockTextChanges(blockElement); + const editableChildren = blockElement.querySelectorAll( + '[data-editable-field]', + ); + editableChildren.forEach((child) => { + child.setAttribute('contenteditable', 'true'); + }); } /** * Method to listen for the SELECT_BLOCK message from the adminUI @@ -570,24 +693,29 @@ class Bridge { * @param {String} uid - UID of the block */ observeForBlock(uid) { - const observer = new MutationObserver((mutationsList, observer) => { + if (this.blockObserver) this.blockObserver.disconnect(); + this.blockObserver = new MutationObserver((mutationsList, observer) => { for (const mutation of mutationsList) { if (mutation.type === 'childList') { const blockElement = document.querySelector( `[data-block-uid="${uid}"]`, ); + if (blockElement) { this.selectBlock(blockElement); !this.elementIsVisibleInViewport(blockElement, true) && blockElement.scrollIntoView({ behavior: 'smooth' }); observer.disconnect(); - break; + return; } } } }); - observer.observe(document.body, { childList: true, subtree: true }); + this.blockObserver.observe(document.body, { + childList: true, + subtree: true, + }); } /** @@ -639,12 +767,16 @@ class Bridge { * @param {Element} blockElement Selected block element */ deselectBlock(prevBlockElement, currBlockElement) { - const currBlockUid = currBlockElement.getAttribute('data-block-uid'); + const currBlockUid = currBlockElement?.getAttribute('data-block-uid'); if ( this.selectedBlockUid !== null && + currBlockUid && this.selectedBlockUid !== currBlockUid ) { this.currentlySelectedBlock.classList.remove('volto-hydra--outline'); + if (this.blockObserver) { + this.blockObserver.disconnect(); + } if (this.addButton) { this.addButton.remove(); this.addButton = null; @@ -681,6 +813,10 @@ class Bridge { this.blockTextMutationObserver.disconnect(); this.blockTextMutationObserver = null; } + if (this.attributeMutationObserver) { + this.attributeMutationObserver.disconnect(); + this.attributeMutationObserver = null; + } } /** @@ -726,7 +862,9 @@ class Bridge { } }); }); - + if (this.blockTextMutationObserver) { + this.blockTextMutationObserver.disconnect(); + } this.blockTextMutationObserver.observe(blockElement, { subtree: true, characterData: true, @@ -744,10 +882,12 @@ class Bridge { 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 }, + { + type: 'INLINE_EDIT_DATA', + data: this.formData, + }, this.adminOrigin, ); } @@ -767,7 +907,11 @@ class Bridge { closestNode.innerText?.replace(/\n$/, ''), ); // this.resetJsonNodeIds(updatedJson); - this.formData.blocks[this.selectedBlockUid] = updatedJson; + this.formData.blocks[this.selectedBlockUid] = { + ...updatedJson, + plaintext: this.currentlySelectedBlock.innerText, + }; + window.parent.postMessage( { type: 'INLINE_EDIT_DATA', data: this.formData }, this.adminOrigin, @@ -788,7 +932,12 @@ class Bridge { return json.map((item) => this.updateJsonNode(item, nodeId, newText)); } else if (typeof json === 'object' && json !== null) { if (json.nodeId === parseInt(nodeId, 10)) { - json.text = newText; + if (json.hasOwnProperty('text')) { + json.text = newText; + } else { + json.children[0].text = newText; + } + return json; } for (const key in json) { if (json.hasOwnProperty(key) && key !== 'nodeId' && key !== 'data') { @@ -901,7 +1050,6 @@ class Bridge { const range = selection.getRangeAt(0); const currentFormats = this.isFormatted(range); - if (currentFormats[format].present) { this.unwrapFormatting(range, format); } else { @@ -929,7 +1077,7 @@ class Bridge { 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 ( @@ -943,7 +1091,7 @@ class Bridge { range.startOffset === 0 && range.endOffset === container.textContent.length; - if (isEntireContentSelected || selection.isCollapsed) { + if (isEntireContentSelected) { // Unwrap the entire element this.unwrapElement(container); } else { @@ -1040,10 +1188,27 @@ class Bridge { // Helper function to unwrap a single formatting element unwrapElement(element) { const parent = element.parentNode; + if (!parent) return; // Handle the case where the element has no parent + + // Store the next sibling of the element before modifying the DOM + const nextSibling = element.nextSibling; + while (element.firstChild) { parent.insertBefore(element.firstChild, element); } + + // Remove the element itself parent.removeChild(element); + + // If there was a next sibling, set the selection to the beginning of it + if (nextSibling) { + const range = document.createRange(); + range.setStart(nextSibling, 0); + range.collapse(true); + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + } } sendFormattedHTMLToAdminUI(selection) { if (!selection.rangeCount) return; // No selection @@ -1265,7 +1430,7 @@ export function getTokenFromCookie() { if (typeof document === 'undefined') { return null; } - const name = 'auth_token='; + const name = 'access_token='; const decodedCookie = decodeURIComponent(document.cookie); const cookieArray = decodedCookie.split(';'); for (let i = 0; i < cookieArray.length; i++) { @@ -1304,6 +1469,7 @@ const boldSVG = ``; const delSVG = ``; const addSVG = ``; +const linkSVG = ``; const threeDotsSVG = `