Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 157 additions & 3 deletions src/app/globals.scss
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ html {
.word-processor {
display: flex;
flex-direction: column;
height: 100vh;
height: 100dvh;
overflow: hidden;
}

Expand Down Expand Up @@ -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) ──────────────────────────────────────
Expand Down Expand Up @@ -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;
}
}
14 changes: 13 additions & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,27 @@ 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<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body style={{ margin: 0, padding: 0, overflow: 'hidden' }}>
<body style={{ margin: 0, padding: 0 }}>
<Providers>{children}</Providers>
{process.env.NODE_ENV === 'development' && (
<div className="dev-watermark" aria-hidden="true">DEV</div>
)}
</body>
</html>
);
Expand Down
10 changes: 10 additions & 0 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <font face="..."> tags (created by execCommand) to <span style="font-family: '...'"> so
// that Word's HTML renderer handles multi-word font names (e.g. Times New Roman) reliably.
Expand Down Expand Up @@ -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 <div className="word-processor" aria-hidden="true" />;
}

return (
<div className="word-processor">
{/* Title Bar */}
Expand Down
92 changes: 46 additions & 46 deletions src/components/ApplicationHeader/ApplicationHeader.tsx
Original file line number Diff line number Diff line change
@@ -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 = () => (
<HeaderContainer
render={({ isSideNavExpanded, onClickSideNavExpand }) => (
<Header aria-label="Template">
<SkipToContent />
<HeaderMenuButton
aria-label="Open menu"
onClick={onClickSideNavExpand}
isActive={isSideNavExpanded}
/>
<Link href="/" passHref legacyBehavior>
<HeaderName prefix="IBM">Template</HeaderName>
</Link>
<HeaderNavigation aria-label="Google">
<Link href="https://www.google.com" passHref legacyBehavior>
<HeaderMenuItem>Google</HeaderMenuItem>
</Link>
</HeaderNavigation>
<SideNav
aria-label="Side navigation"
expanded={isSideNavExpanded}
isPersistent={false}
>
<SideNavItems>
<HeaderSideNavItems>
<Link href="https://www.google.com" passHref legacyBehavior>
<HeaderMenuItem>Google</HeaderMenuItem>
</Link>
</HeaderSideNavItems>
</SideNavItems>
</SideNav>
</Header>
)}
/>
<HeaderContainer
render={({ isSideNavExpanded, onClickSideNavExpand }) => (
<Header aria-label="Template">
<SkipToContent />
<HeaderMenuButton
aria-label="Open menu"
onClick={onClickSideNavExpand}
isActive={isSideNavExpanded}
/>
<Link href="/" passHref legacyBehavior>
<HeaderName prefix="IBM">Template</HeaderName>
</Link>
<HeaderNavigation aria-label="Google">
<Link href="https://www.google.com" passHref legacyBehavior>
<HeaderMenuItem>Google</HeaderMenuItem>
</Link>
</HeaderNavigation>
<SideNav
aria-label="Side navigation"
expanded={isSideNavExpanded}
isPersistent={false}
>
<SideNavItems>
<HeaderSideNavItems>
<Link href="https://www.google.com" passHref legacyBehavior>
<HeaderMenuItem>Google</HeaderMenuItem>
</Link>
</HeaderSideNavItems>
</SideNavItems>
</SideNav>
</Header>
)}
/>
);

export default ApplicationHeader;
29 changes: 29 additions & 0 deletions src/components/DocumentEditor/DocumentEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,35 @@ const DocumentEditor = forwardRef<HTMLDivElement, DocumentEditorProps>(
// 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<HTMLDivElement>) => {
const el = e.currentTarget;
const text = el.innerText;
Expand Down
Loading
Loading