diff --git a/artifacts/tx-os/src/components/editable-cell.tsx b/artifacts/tx-os/src/components/editable-cell.tsx index 9d6a3909..9c0b6205 100644 --- a/artifacts/tx-os/src/components/editable-cell.tsx +++ b/artifacts/tx-os/src/components/editable-cell.tsx @@ -15,6 +15,7 @@ import { Color } from "@tiptap/extension-color"; import { FontFamily } from "@tiptap/extension-font-family"; import { TextAlign } from "@tiptap/extension-text-align"; import { safeHtml } from "@/lib/safe-html"; +import { useVisualViewport } from "@/hooks/use-visual-viewport"; import { Bold as BoldIcon, Italic as ItalicIcon, @@ -557,6 +558,14 @@ function FormattingToolbar({ [toolbarRef], ); + // Track the visible viewport so the toolbar can dodge the iOS soft + // keyboard. On iPad / iOS Safari, when the keyboard opens it overlays + // the bottom of the layout viewport — `visualViewport.height` shrinks + // accordingly. We use that to decide when to flip the toolbar above + // the cell instead of below, so the color swatches / Save / Cancel + // stay reachable instead of being hidden under the keyboard. + const vv = useVisualViewport(); + useLayoutEffect(() => { // Walk up to find the nearest horizontally-scrolling ancestor // (the table's `overflow-x-auto` wrapper). The toolbar must stay @@ -578,11 +587,20 @@ function FormattingToolbar({ const rect = anchor.getBoundingClientRect(); const tbHeight = toolbarEl?.offsetHeight ?? 36; const tbWidth = toolbarEl?.offsetWidth ?? 320; - // Always place BELOW the editing cell. The task spec requires the - // toolbar to never sit on top of the row above the active cell, so - // we deliberately do NOT flip above even when there isn't room - // below — the page can scroll instead. - const top = rect.bottom + 4; + // Prefer placing the toolbar BELOW the editing cell (keeps it + // from sitting on top of the row above the active cell, which is + // what the schedule spec wants). We only flip ABOVE when staying + // below would put the toolbar under the iOS soft keyboard or off + // the bottom of the visible viewport — otherwise the color + // swatches / Save / Cancel become unreachable on iPad. See #581. + const vvTop = vv.offsetTop; + const vvBottom = + vv.offsetTop + (vv.height || window.innerHeight); + let top = rect.bottom + 4; + if (top + tbHeight > vvBottom - 4) { + const above = rect.top - tbHeight - 4; + top = Math.max(vvTop + 4, above); + } // Clamp horizontally to the table's scroll container if we can // find one; fall back to the viewport otherwise. This keeps the // toolbar visually anchored inside the table. @@ -613,7 +631,7 @@ function FormattingToolbar({ window.removeEventListener("scroll", update, true); window.removeEventListener("resize", update); }; - }, [anchorRef, toolbarEl]); + }, [anchorRef, toolbarEl, vv.height, vv.offsetTop, vv.keyboardInset]); const node = (