Skip to content

Commit

Permalink
fix: make toolbar stuck to bottom with ios
Browse files Browse the repository at this point in the history
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
  • Loading branch information
acezard committed May 2, 2024
1 parent 33ec5c2 commit 54253eb
Show file tree
Hide file tree
Showing 3 changed files with 160 additions and 0 deletions.
4 changes: 4 additions & 0 deletions src/components/notes/editor.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
44 changes: 44 additions & 0 deletions src/hooks/useFixedToBottomOnIos.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>(
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)
}
}, [])
}
112 changes: 112 additions & 0 deletions src/lib/patches/fixedToBottomIos.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>(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)
}
}
})
})
}

0 comments on commit 54253eb

Please sign in to comment.