From 54253ebbf4d6b7c27cb53aa8f746a289dc999ead Mon Sep 17 00:00:00 2001 From: Antonin Cezard Date: Mon, 29 Apr 2024 15:51:50 +0200 Subject: [PATCH] fix: make toolbar stuck to bottom with ios CSS couldn't do it. Following community answers, it seemed like only JS dom manipulations could help us here. This is pretty hacky but could not find a better way --- src/components/notes/editor.jsx | 4 + src/hooks/useFixedToBottomOnIos.ts | 44 +++++++++++ src/lib/patches/fixedToBottomIos.ts | 112 ++++++++++++++++++++++++++++ 3 files changed, 160 insertions(+) create mode 100644 src/hooks/useFixedToBottomOnIos.ts create mode 100644 src/lib/patches/fixedToBottomIos.ts diff --git a/src/components/notes/editor.jsx b/src/components/notes/editor.jsx index 646aebd2..0ed531c2 100644 --- a/src/components/notes/editor.jsx +++ b/src/components/notes/editor.jsx @@ -27,6 +27,7 @@ import { useI18n } from 'cozy-ui/transpiled/react/providers/I18n' import HeaderMenu from 'components/header_menu' import { SHARING_LOCATION } from '../../constants/strings' import { useNoteContext } from 'components/notes/NoteProvider' +import { useFixedToBottomOnIOS } from 'hooks/useFixedToBottomOnIos' export default function Editor(props) { // base parameters @@ -37,6 +38,9 @@ export default function Editor(props) { const location = useLocation() const fileQuery = buildFileByIdQuery(noteId) const fileResult = useQuery(fileQuery.definition, fileQuery.options) + // On iOS, the toolbar is fixed to the bottom of the screen manually + // This is to avoid an issue with the keyboard that hides the toolbar + useFixedToBottomOnIOS() // plugins and config const isPublic = useContext(IsPublicContext) diff --git a/src/hooks/useFixedToBottomOnIos.ts b/src/hooks/useFixedToBottomOnIos.ts new file mode 100644 index 00000000..0e30e75e --- /dev/null +++ b/src/hooks/useFixedToBottomOnIos.ts @@ -0,0 +1,44 @@ +import { useEffect } from 'react' + +import { + applyFixToElement, + handleMutation, + setupEventListeners, + tearDownEventListeners, + TOOLBAR_SELECTOR +} from 'lib/patches/fixedToBottomIos' + +/** + * Custom hook that fixes an element to the bottom of the screen on iOS devices. + * Listens for the specified element and ensures it's fixed to the viewport bottom. + * Only activated on iOS devices. + */ +export const useFixedToBottomOnIOS = (): void => { + useEffect(() => { + // Only proceed if on iOS devices + if (!/iPhone|iPad|iPod/.test(window.navigator.userAgent)) return + + let observer: MutationObserver | undefined + + // Check if the element is already present and set up positions, in practice it's unlikely to be present on initial render + // If not present, set up a mutation observer to handle future additions + const existingElement = document.querySelector( + TOOLBAR_SELECTOR + ) + + if (existingElement) { + applyFixToElement(existingElement) + setupEventListeners(existingElement) + } else { + observer = new MutationObserver(handleMutation) + observer.observe(document.body, { childList: true, subtree: true }) + } + + // Cleanup function + return () => { + observer?.disconnect() + + if (existingElement) tearDownEventListeners(existingElement) + } + }, []) +} diff --git a/src/lib/patches/fixedToBottomIos.ts b/src/lib/patches/fixedToBottomIos.ts new file mode 100644 index 00000000..f65512c1 --- /dev/null +++ b/src/lib/patches/fixedToBottomIos.ts @@ -0,0 +1,112 @@ +export const TOOLBAR_SELECTOR = '[data-testid="ak-editor-main-toolbar"]' + +/** + * Applies a fix to the specified element to ensure it stays fixed to the bottom of the viewport. + * @param element - The element to apply the fix to. + * @returns A function that can be called to remove the applied fix. + */ +export const applyFixToElement = (element: HTMLElement): (() => void) => { + const adjustFixedPos = createAdjustmentFunction(element) + enableAbsolutePositioning(element, adjustFixedPos) + + return () => { + document.removeEventListener('scroll', adjustFixedPos) + window.visualViewport.removeEventListener('resize', adjustFixedPos) + } +} + +/** + * Creates an adjustment function that fixes an element to the bottom of the viewport. + * The adjustment function adjusts the position of the element based on the viewport height and scroll position. + * + * @param element - The HTML element to be fixed to the bottom of the viewport. + * @returns A function that adjusts the position of the element. + */ +export const createAdjustmentFunction = (element: HTMLElement) => (): void => { + const viewport = window.visualViewport + const elementHeight = element.offsetHeight + const elementBottom = + viewport.height - elementHeight + document.documentElement.scrollTop + + element.style.top = `${elementBottom}px` +} + +/** + * Enables absolute positioning for the specified element and adjusts its position. + * This function is typically used to fix elements to the bottom of the screen on iOS devices. + * + * @param element - The HTML element to enable absolute positioning for. + * @param adjustPosition - A callback function that adjusts the position of the element. + */ +export const enableAbsolutePositioning = ( + element: HTMLElement, + adjustPosition: () => void +): void => { + element.style.position = 'absolute' + element.style.bottom = 'auto' + element.style.marginTop = '-5rem' // Offset the element to avoid the keyboard + adjustPosition() + document.addEventListener('scroll', adjustPosition, { passive: true }) + window.visualViewport.addEventListener('resize', adjustPosition, { + passive: true + }) +} + +/** + * Sets up event listeners for the given element to adjust its position when scrolling or resizing the window. + * @param element - The HTML element to adjust. + */ +export const setupEventListeners = (element: HTMLElement): void => { + const adjustFunction = createAdjustmentFunction(element) + document.addEventListener('scroll', adjustFunction, { passive: true }) + window.visualViewport.addEventListener('resize', adjustFunction, { + passive: true + }) +} + +/** + * Removes event listeners for scroll and resize events. + * @param element - The HTML element to remove event listeners from. + */ +export const tearDownEventListeners = (element: HTMLElement): void => { + const adjustFunction = createAdjustmentFunction(element) + document.removeEventListener('scroll', adjustFunction) + window.visualViewport.removeEventListener('resize', adjustFunction) +} + +/** + * Handles mutations in the DOM and applies fixes to the toolbar element. + * @param mutations - An array of MutationRecord objects representing DOM mutations. + * @param observer - The MutationObserver instance used to observe DOM mutations. + */ +export const handleMutation = ( + mutations: MutationRecord[], + observer: MutationObserver +): void => { + mutations.forEach(mutation => { + Array.from(mutation.addedNodes).forEach(node => { + if (node.nodeType === 1) { + // Ensuring node is an Element + const element = node as HTMLElement + const elementIsToolbar = + element.matches(TOOLBAR_SELECTOR) || + element.querySelector(TOOLBAR_SELECTOR) + + if (elementIsToolbar) { + observer.disconnect() // Disconnect observer to avoid infinite loop + + const targetElement = element.matches(TOOLBAR_SELECTOR) + ? element + : element.querySelector(TOOLBAR_SELECTOR) + + if (targetElement) { + applyFixToElement(targetElement) // Adjust toolbar position immediately + setupEventListeners(targetElement) // Set up event listeners for future position adjustments + } + + return // Exit after applying fix to the first matching element (which should be the toolbar) + } + } + }) + }) +}