From 791ec9ca6a9708631782341607b1d345169d6d14 Mon Sep 17 00:00:00 2001 From: Alex Micharski Date: Tue, 17 Mar 2026 09:16:23 -0400 Subject: [PATCH 1/2] added mobile responsiveness --- src/app/globals.scss | 160 +++++++++++++++++- src/app/layout.tsx | 14 +- .../ApplicationHeader/ApplicationHeader.tsx | 92 +++++----- .../DocumentEditor/DocumentEditor.tsx | 29 ++++ src/components/Ribbon/Ribbon.tsx | 110 +++++++++++- 5 files changed, 346 insertions(+), 59 deletions(-) diff --git a/src/app/globals.scss b/src/app/globals.scss index f123f11..403f1d9 100644 --- a/src/app/globals.scss +++ b/src/app/globals.scss @@ -20,7 +20,7 @@ html { .word-processor { display: flex; flex-direction: column; - height: 100vh; + height: 100dvh; overflow: hidden; } @@ -443,17 +443,27 @@ html { display: flex; justify-content: center; padding: 24px 24px; + + @media (max-width: 815px) { + padding: 0; + } } // ─── Document Page (white paper) ───────────────────────────────────────────── .document-page { background: #fff; - width: 816px; + width: min(816px, 100%); min-height: 1056px; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.55); - padding: 96px; + padding: clamp(16px, 6vw, 96px); flex-shrink: 0; + + @media (max-width: 815px) { + width: 100%; + min-height: 100%; + box-shadow: none; + } } // ─── Document content (contentEditable) ────────────────────────────────────── @@ -536,3 +546,147 @@ html { opacity: 0.85; font-style: italic; } + +// ─── Dev watermark ───────────────────────────────────────────────────────────── + +.dev-watermark { + position: fixed; + bottom: 32px; // sit above the status bar + right: 12px; + z-index: 10000; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.12em; + color: rgba(255, 60, 60, 0.55); + border: 1px solid rgba(255, 60, 60, 0.35); + border-radius: 3px; + padding: 1px 5px; + pointer-events: none; + user-select: none; +} + +// ─── Mobile: Ribbon chunk dropdown ────────────────────────────────────────── + +.word-ribbon--mobile { + .ribbon-panel { + min-height: unset; + padding: 4px 6px; + gap: 4px; + } + + .ribbon-divider { + display: none; + } +} + +.ribbon-chunk-mobile-btn { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 3px 8px; + height: 26px; + background: transparent; + border: 1px solid #c0c0c0; + border-radius: 3px; + font-size: 12px; + color: #222; + cursor: pointer; + white-space: nowrap; + line-height: 1; + + &:hover, + &--open { + background: #dde0e5; + } + + svg { + fill: #222; + flex-shrink: 0; + } +} + +.ribbon-chunk-mobile-dropdown { + position: fixed; + background: #f5f5f5; + border: 1px solid #c6c6c6; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25); + z-index: 9999; + padding: 8px; + min-width: 180px; + max-width: calc(100vw - 16px); + overflow-y: auto; + overflow-x: hidden; + + // Re-apply ribbon button overrides — the portal lives outside .word-ribbon. + .cds--btn--ghost { + min-height: 22px; + padding: 2px 4px; + color: #222; + + &:hover { + background: #dde0e5; + color: #222; + } + + svg { + fill: #222; + } + } + + .cds--btn--primary { + min-height: 22px; + padding: 2px 4px; + } + + .ribbon-row { + flex-wrap: wrap; + gap: 3px; + margin-bottom: 4px; + + &:last-child { + margin-bottom: 0; + } + } + + .ribbon-select { + min-width: 100%; + height: 28px; + margin-bottom: 4px; + + &--size { + min-width: 60px; + width: 60px; + } + } + + .ribbon-styles { + gap: 4px; + } + + &__launcher { + display: flex; + justify-content: flex-end; + margin-top: 4px; + padding-top: 4px; + border-top: 1px solid #c8c8c8; + } +} + +// ─── Mobile: Title Bar ──────────────────────────────────────────────────────── + +@media (max-width: 672px) { + // Hide the verbose "Autosave" label and transient status text to free up + // horizontal space; the toggle itself stays so the user can still control it. + .word-title-bar__autosave-toggle-label { + display: none; + } + + .word-title-bar__autosave-status { + display: none; + } + + // Shrink the centred document title so it doesn't crowd the icon buttons. + .word-title-bar__document-title { + font-size: 11px; + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index fac41db..31d1078 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -11,6 +11,15 @@ export const metadata: Metadata = { } }; +export const viewport = { + width: 'device-width', + initialScale: 1, + // Shrink the layout viewport (and all vh-sized elements) when the soft + // keyboard opens, instead of overlaying the content. Supported by Chrome + // on Android 108+; other browsers fall back gracefully. + interactiveWidget: 'resizes-content', +}; + export default function RootLayout({ children, }: Readonly<{ @@ -18,8 +27,11 @@ export default function RootLayout({ }>) { return ( - + {children} + {process.env.NODE_ENV === 'development' && ( + + )} ); diff --git a/src/components/ApplicationHeader/ApplicationHeader.tsx b/src/components/ApplicationHeader/ApplicationHeader.tsx index c4fd1ca..4bc7627 100644 --- a/src/components/ApplicationHeader/ApplicationHeader.tsx +++ b/src/components/ApplicationHeader/ApplicationHeader.tsx @@ -1,54 +1,54 @@ -'use client'; +"use client"; import { -Header, -HeaderContainer, -HeaderName, -HeaderNavigation, -HeaderMenuButton, -HeaderMenuItem, -SkipToContent, -SideNav, -SideNavItems, -HeaderSideNavItems, -} from '@carbon/react'; + Header, + HeaderContainer, + HeaderName, + HeaderNavigation, + HeaderMenuButton, + HeaderMenuItem, + SkipToContent, + SideNav, + SideNavItems, + HeaderSideNavItems, +} from "@carbon/react"; -import Link from 'next/link'; +import Link from "next/link"; const ApplicationHeader = () => ( - ( -
- - - -Template - - - -Google - - - - - - -Google - - - - -
-)} -/> + ( +
+ + + + Template + + + + Google + + + + + + + Google + + + + +
+ )} + /> ); export default ApplicationHeader; diff --git a/src/components/DocumentEditor/DocumentEditor.tsx b/src/components/DocumentEditor/DocumentEditor.tsx index 3f340cc..370a04e 100644 --- a/src/components/DocumentEditor/DocumentEditor.tsx +++ b/src/components/DocumentEditor/DocumentEditor.tsx @@ -42,6 +42,35 @@ const DocumentEditor = forwardRef( // eslint-disable-next-line react-hooks/exhaustive-deps }, [ref]); + // Scroll the cursor into view when the soft keyboard opens on mobile. + // The Visual Viewport API fires a "resize" event whenever the visible area + // shrinks (e.g. keyboard appearing). If the cursor ends up below the new + // viewport bottom we nudge it back into view. + useEffect(() => { + const vv = window.visualViewport; + if (!vv) return; + + const scrollCursorIntoView = () => { + const sel = window.getSelection(); + if (!sel || !sel.rangeCount) return; + + const rects = sel.getRangeAt(0).getClientRects(); + if (!rects.length) return; + const cursorRect = rects[rects.length - 1]; + + if (cursorRect.bottom > vv.offsetTop + vv.height) { + const focusEl = + sel.focusNode?.nodeType === Node.TEXT_NODE + ? sel.focusNode.parentElement + : (sel.focusNode as Element | null); + focusEl?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } + }; + + vv.addEventListener('resize', scrollCursorIntoView); + return () => vv.removeEventListener('resize', scrollCursorIntoView); + }, []); + const handleInput = (e: React.FormEvent) => { const el = e.currentTarget; const text = el.innerText; diff --git a/src/components/Ribbon/Ribbon.tsx b/src/components/Ribbon/Ribbon.tsx index b2f0c57..33c98a7 100644 --- a/src/components/Ribbon/Ribbon.tsx +++ b/src/components/Ribbon/Ribbon.tsx @@ -59,6 +59,22 @@ interface RibbonProps { onCitationStyleChange: (style: string) => void; } +// ─── Mobile context / hook ──────────────────────────────────────────────────── + +const RibbonMobileContext = React.createContext(false); + +const useMobileRibbon = () => { + const [isMobile, setIsMobile] = React.useState(false); + React.useEffect(() => { + const mq = window.matchMedia('(max-width: 815px)'); + setIsMobile(mq.matches); + const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches); + mq.addEventListener('change', handler); + return () => mq.removeEventListener('change', handler); + }, []); + return isMobile; +}; + // ─── Helper sub-components ──────────────────────────────────────────────────── const RibbonChunk = ({ @@ -69,15 +85,88 @@ const RibbonChunk = ({ label: string; children: React.ReactNode; launcher?: React.ReactNode; -}) => ( -
-
{children}
-
- {label} - {launcher && {launcher}} +}) => { + const isMobile = React.useContext(RibbonMobileContext); + const [open, setOpen] = React.useState(false); + const [menuPos, setMenuPos] = React.useState({ top: 0, left: 0 }); + const btnRef = React.useRef(null); + const menuRef = React.useRef(null); + + React.useEffect(() => { + if (!open) return; + const handleOutside = (e: MouseEvent) => { + const t = e.target as Node; + if (!btnRef.current?.contains(t) && !menuRef.current?.contains(t)) { + setOpen(false); + } + }; + document.addEventListener('mousedown', handleOutside); + return () => document.removeEventListener('mousedown', handleOutside); + }, [open]); + + if (isMobile) { + const handleToggle = () => { + if (!open && btnRef.current) { + const rect = btnRef.current.getBoundingClientRect(); + const MARGIN = 8; + // MIN_WIDTH matches the CSS min-width of .ribbon-chunk-mobile-dropdown. + // Using it as a lower-bound estimate lets us pre-clamp right overflow + // synchronously, without needing a post-render measurement. + const MIN_WIDTH = 180; + const vw = window.innerWidth; + const top = rect.bottom + 2; + let left = rect.left; + if (left + MIN_WIDTH + MARGIN > vw) left = vw - MIN_WIDTH - MARGIN; + if (left < MARGIN) left = MARGIN; + setMenuPos({ top, left }); + } + setOpen((o) => !o); + }; + + return ( + <> + + {open && typeof document !== 'undefined' && ReactDOM.createPortal( +
+ {children} + {launcher &&
{launcher}
} +
, + document.body + )} + + ); + } + + return ( +
+
{children}
+
+ {label} + {launcher && {launcher}} +
-
-); + ); +}; const RibbonDivider = () =>
; @@ -290,9 +379,11 @@ const Ribbon = ({ onCitationStyleChange, }: RibbonProps) => { const fmt = (cmd: string, val?: string) => () => onFormat(cmd, val); + const isMobile = useMobileRibbon(); return ( -
+ +
Home @@ -770,6 +861,7 @@ const Ribbon = ({
+
); }; From 3759a1ea4238a869cbfe3b624a67cb6aa90de89a Mon Sep 17 00:00:00 2001 From: Alex Micharski Date: Tue, 17 Mar 2026 10:17:12 -0400 Subject: [PATCH 2/2] fixed hydration issue --- src/app/page.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/app/page.tsx b/src/app/page.tsx index 29e7ec0..a3ffebd 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -51,6 +51,11 @@ export default function WordProcessor() { const [citationStyle, setCitationStyle] = useState(''); const [alignment, setAlignment] = useState('left'); const [zoom, setZoom] = useState(100); + const [isHydrated, setIsHydrated] = useState(false); + + useEffect(() => { + setIsHydrated(true); + }, []); // Convert tags (created by execCommand) to so // that Word's HTML renderer handles multi-word font names (e.g. Times New Roman) reliably. @@ -409,6 +414,11 @@ export default function WordProcessor() { // Scale factor as a fraction (e.g. 0.8 for 80%) const scaleFactor = zoom / 100; + if (!isHydrated) { + // Keep server and first client render identical to avoid Carbon tooltip/tab ID mismatches. + return