From 8174896b8dab851e09278be100bee59ceb26a9e5 Mon Sep 17 00:00:00 2001 From: Alex Micharski Date: Tue, 17 Mar 2026 11:55:00 -0400 Subject: [PATCH 1/2] split large components up into smaller components; fix help links navigating to same tab --- src/app/page.tsx | 99 +-- .../ApplicationHeader/ApplicationHeader.tsx | 54 -- src/components/Ribbon/HelpTabPanel.tsx | 30 + src/components/Ribbon/HomeTabPanel.tsx | 289 +++++++++ src/components/Ribbon/Ribbon.tsx | 608 ++---------------- src/components/Ribbon/RibbonControls.tsx | 194 ++++++ src/components/Ribbon/ribbonConfig.ts | 66 ++ src/components/Ribbon/types.ts | 22 + .../WordStatusBar/WordStatusBar.tsx | 16 + src/components/WordTitleBar/WordTitleBar.tsx | 90 +++ 10 files changed, 762 insertions(+), 706 deletions(-) delete mode 100644 src/components/ApplicationHeader/ApplicationHeader.tsx create mode 100644 src/components/Ribbon/HelpTabPanel.tsx create mode 100644 src/components/Ribbon/HomeTabPanel.tsx create mode 100644 src/components/Ribbon/RibbonControls.tsx create mode 100644 src/components/Ribbon/ribbonConfig.ts create mode 100644 src/components/Ribbon/types.ts create mode 100644 src/components/WordStatusBar/WordStatusBar.tsx create mode 100644 src/components/WordTitleBar/WordTitleBar.tsx diff --git a/src/app/page.tsx b/src/app/page.tsx index 29e7ec0..03eb293 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,11 +1,11 @@ 'use client'; import React, { useState, useRef, useCallback, useEffect } from 'react'; -import { Button, Toggle } from '@carbon/react'; -import { Save, Undo, Redo, Printer } from '@carbon/icons-react'; import { asBlob } from 'html-docx-js/dist/html-docx'; import Ribbon from '@/components/Ribbon/Ribbon'; import DocumentEditor from '@/components/DocumentEditor/DocumentEditor'; +import WordTitleBar from '@/components/WordTitleBar/WordTitleBar'; +import WordStatusBar from '@/components/WordStatusBar/WordStatusBar'; type TextAlignment = 'left' | 'center' | 'right' | 'justify'; @@ -411,76 +411,22 @@ export default function WordProcessor() { return (
- {/* Title Bar */} -
-
-
- -
- {documentName} — Carbon Type -
- -
- {autosaveEnabled && autosaveStatus && ( - - {autosaveStatus === 'saving' ? 'Saving\u2026' : 'Autosaved'} - - )} - - Autosave - { - setAutosaveEnabled(checked); - if (!checked) { - if (autosaveClearTimerRef.current) clearTimeout(autosaveClearTimerRef.current); - setAutosaveStatus(''); - } - }} - /> - -
-
+ { + setAutosaveEnabled(checked); + if (!checked) { + if (autosaveClearTimerRef.current) clearTimeout(autosaveClearTimerRef.current); + setAutosaveStatus(''); + } + }} + onSave={handleSave} + onUndo={() => handleFormat('undo')} + onRedo={() => handleFormat('redo')} + onPrint={handlePrint} + /> {/* Ribbon */}
- {/* Status Bar */} -
- Page 1 of 1 - | - Words: {wordCount} - | - Zoom: {zoom}% -
+ ); } diff --git a/src/components/ApplicationHeader/ApplicationHeader.tsx b/src/components/ApplicationHeader/ApplicationHeader.tsx deleted file mode 100644 index c4fd1ca..0000000 --- a/src/components/ApplicationHeader/ApplicationHeader.tsx +++ /dev/null @@ -1,54 +0,0 @@ -'use client'; - -import { -Header, -HeaderContainer, -HeaderName, -HeaderNavigation, -HeaderMenuButton, -HeaderMenuItem, -SkipToContent, -SideNav, -SideNavItems, -HeaderSideNavItems, -} from '@carbon/react'; - -import Link from 'next/link'; - -const ApplicationHeader = () => ( - ( -
- - - -Template - - - -Google - - - - - - -Google - - - - -
-)} -/> -); - -export default ApplicationHeader; diff --git a/src/components/Ribbon/HelpTabPanel.tsx b/src/components/Ribbon/HelpTabPanel.tsx new file mode 100644 index 0000000..21578a0 --- /dev/null +++ b/src/components/Ribbon/HelpTabPanel.tsx @@ -0,0 +1,30 @@ +import { Button, TabPanel } from '@carbon/react'; +import { RibbonChunk } from './RibbonControls'; + +interface HelpTabPanelProps { + onFeatureRequest: () => void; + onBug: () => void; + onContribute: () => void; +} + +const HelpTabPanel = ({ onFeatureRequest, onBug, onContribute }: HelpTabPanelProps) => ( + +
+ +
+ + + +
+
+
+
+); + +export default HelpTabPanel; diff --git a/src/components/Ribbon/HomeTabPanel.tsx b/src/components/Ribbon/HomeTabPanel.tsx new file mode 100644 index 0000000..794e37d --- /dev/null +++ b/src/components/Ribbon/HomeTabPanel.tsx @@ -0,0 +1,289 @@ +import { Button, TabPanel } from '@carbon/react'; +import { + TextAlignLeft, + TextAlignCenter, + TextAlignRight, + TextAlignJustify, + ListBulleted, + ListNumbered, + Cut, + Copy, + Paste, + TextIndentMore, + TextIndentLess, + TextClearFormat, +} from '@carbon/icons-react'; +import { FONTS, SIZES, STYLES } from './ribbonConfig'; +import { CitationStyleDropdown, FormatButton, LineSpacingDropdown, RibbonChunk, RibbonDivider } from './RibbonControls'; +import { RibbonProps } from './types'; + +type HomeTabPanelProps = Pick< + RibbonProps, + | 'onFormat' + | 'fontSize' + | 'fontFamily' + | 'onFontSizeChange' + | 'onFontFamilyChange' + | 'isBold' + | 'isItalic' + | 'isUnderline' + | 'isStrikethrough' + | 'isSubscript' + | 'isSuperscript' + | 'isUnorderedList' + | 'isOrderedList' + | 'alignment' + | 'lineSpacing' + | 'onLineSpacingChange' + | 'citationStyle' + | 'onCitationStyleChange' +>; + +const HomeTabPanel = ({ + onFormat, + fontSize, + fontFamily, + onFontSizeChange, + onFontFamilyChange, + isBold, + isItalic, + isUnderline, + isStrikethrough, + isSubscript, + isSuperscript, + isUnorderedList, + isOrderedList, + alignment, + lineSpacing, + onLineSpacingChange, + citationStyle, + onCitationStyleChange, +}: HomeTabPanelProps) => { + const fmt = (cmd: string, val?: string) => () => onFormat(cmd, val); + + return ( + +
+ +
+
+
+
+
+
+
+ + + +
+ + +
+
+ + B + + + I + + + U + + + S + + + X2 + + + X2 + + + A + +
+
+ + + +
+
+
+
+
+ + + + } + > +
+ {STYLES.map((style) => ( + + ))} +
+
+
+
+ ); +}; + +export default HomeTabPanel; diff --git a/src/components/Ribbon/Ribbon.tsx b/src/components/Ribbon/Ribbon.tsx index de48ef9..24f7411 100644 --- a/src/components/Ribbon/Ribbon.tsx +++ b/src/components/Ribbon/Ribbon.tsx @@ -1,330 +1,21 @@ 'use client'; -import React from 'react'; -import ReactDOM from 'react-dom'; import { Tabs, Tab, TabList, TabPanels, TabPanel, Button } from '@carbon/react'; import { - TextAlignLeft, - TextAlignCenter, - TextAlignRight, - TextAlignJustify, - ListBulleted, - ListNumbered, - Cut, - Copy, - Paste, Table, Image, Link, TextIndentMore, TextIndentLess, - TextClearFormat, - TextLineSpacing, SpellCheck, ZoomIn, ZoomOut, Printer, - ChevronDown, } from '@carbon/icons-react'; - -// ─── Types ──────────────────────────────────────────────────────────────────── - -interface RibbonProps { - onFormat: (command: string, value?: string) => void; - fontSize: string; - fontFamily: string; - onFontSizeChange: (size: string) => void; - onFontFamilyChange: (family: string) => void; - isBold: boolean; - isItalic: boolean; - isUnderline: boolean; - isStrikethrough: boolean; - isSubscript: boolean; - isSuperscript: boolean; - isUnorderedList: boolean; - isOrderedList: boolean; - alignment: string; - onPrint: () => void; - onZoom: (delta: number) => void; - lineSpacing: string; - onLineSpacingChange: (spacing: string) => void; - citationStyle: string; - onCitationStyleChange: (style: string) => void; -} - -// ─── Helper sub-components ──────────────────────────────────────────────────── - -const RibbonChunk = ({ - label, - children, - launcher, -}: { - label: string; - children: React.ReactNode; - launcher?: React.ReactNode; -}) => ( -
-
{children}
-
- {label} - {launcher && {launcher}} -
-
-); - -const RibbonDivider = () =>
; - -// ─── Format Button (styled-text toggle button) ──────────────────────────────── - -const FormatButton = ({ - active = false, - onClick, - title, - children, -}: { - active?: boolean; - onClick: () => void; - title: string; - children: React.ReactNode; -}) => ( - -); - -// ─── Dialog Launcher Icon ──────────────────────────────────────────────────── - -const DialogLauncherIcon = () => ( - -); - -// ─── Citation Style Dropdown ───────────────────────────────────────────────── - -const CITATION_STYLE_OPTIONS = ['APA v7', 'APA v6', 'MLA', 'Chicago'] as const; - -interface CitationStyleDropdownProps { - value: string; - onChange: (style: string) => void; -} - -const CitationStyleDropdown = ({ value, onChange }: CitationStyleDropdownProps) => { - const [open, setOpen] = React.useState(false); - const [menuPos, setMenuPos] = React.useState({ top: 0, left: 0 }); - const wrapperRef = React.useRef(null); - const menuRef = React.useRef(null); - const btnRef = React.useRef(null); - - React.useEffect(() => { - if (!open) return; - const handleOutsideClick = (e: MouseEvent) => { - const target = e.target as Node; - if (!wrapperRef.current?.contains(target) && !menuRef.current?.contains(target)) { - setOpen(false); - } - }; - document.addEventListener('mousedown', handleOutsideClick); - return () => document.removeEventListener('mousedown', handleOutsideClick); - }, [open]); - - const handleToggle = () => { - if (!open && btnRef.current) { - const rect = btnRef.current.getBoundingClientRect(); - setMenuPos({ top: rect.bottom + 2, left: rect.left }); - } - setOpen((o) => !o); - }; - - return ( -
- - {open && typeof document !== 'undefined' && ReactDOM.createPortal( -
    - {CITATION_STYLE_OPTIONS.map((s) => ( -
  • - -
  • - ))} -
, - document.body - )} -
- ); -}; - -// ─── Ribbon ─────────────────────────────────────────────────────────────────── - -const FONTS = [ - 'Calibri', - 'Arial', - 'Times New Roman', - 'Georgia', - 'Courier New', - 'Verdana', - 'Tahoma', - 'Impact', -]; -const SIZES = ['8', '9', '10', '11', '12', '14', '16', '18', '20', '24', '28', '32', '36', '48', '72']; - -const STYLES: { label: string; cmd: string; val: string; previewStyle: React.CSSProperties }[] = [ - { - label: 'Normal', - cmd: 'formatBlock', - val: 'p', - previewStyle: { - fontFamily: 'Calibri, sans-serif', - fontSize: '11px', - fontWeight: '400', - color: '#161616', - }, - }, - { - label: 'Heading 1', - cmd: 'formatBlock', - val: 'h1', - previewStyle: { - fontFamily: "'Calibri Light', Calibri, sans-serif", - fontSize: '15px', - fontWeight: '700', - color: '#2e74b5', - }, - }, - { - label: 'Heading 2', - cmd: 'formatBlock', - val: 'h2', - previewStyle: { - fontFamily: "'Calibri Light', Calibri, sans-serif", - fontSize: '13px', - fontWeight: '700', - color: '#2e74b5', - }, - }, - { - label: 'Heading 3', - cmd: 'formatBlock', - val: 'h3', - previewStyle: { - fontFamily: "'Calibri Light', Calibri, sans-serif", - fontSize: '12px', - fontWeight: '700', - fontStyle: 'italic', - color: '#1f3864', - }, - }, -]; - -const LINE_SPACINGS = ['1.0', '1.15', '1.5', '2.0', '2.5', '3.0']; - -interface LineSpacingDropdownProps { - value: string; - onChange: (spacing: string) => void; -} - -const LineSpacingDropdown = ({ value, onChange }: LineSpacingDropdownProps) => { - const [open, setOpen] = React.useState(false); - const [menuPos, setMenuPos] = React.useState({ top: 0, left: 0 }); - const wrapperRef = React.useRef(null); - const menuRef = React.useRef(null); - const btnRef = React.useRef(null); - - React.useEffect(() => { - if (!open) return; - const handleOutsideClick = (e: MouseEvent) => { - const target = e.target as Node; - if (!wrapperRef.current?.contains(target) && !menuRef.current?.contains(target)) { - setOpen(false); - } - }; - document.addEventListener('mousedown', handleOutsideClick); - return () => document.removeEventListener('mousedown', handleOutsideClick); - }, [open]); - - const handleToggle = () => { - if (!open && btnRef.current) { - const rect = btnRef.current.getBoundingClientRect(); - setMenuPos({ top: rect.bottom, left: rect.left }); - } - setOpen((o) => !o); - }; - - return ( -
- - {open && typeof document !== 'undefined' && ReactDOM.createPortal( -
    - {LINE_SPACINGS.map((s) => ( -
  • - -
  • - ))} -
, - document.body - )} -
- ); -}; +import HelpTabPanel from './HelpTabPanel'; +import HomeTabPanel from './HomeTabPanel'; +import { RibbonChunk, RibbonDivider } from './RibbonControls'; +import { RibbonProps } from './types'; const Ribbon = ({ onFormat, @@ -348,13 +39,16 @@ const Ribbon = ({ citationStyle, onCitationStyleChange, }: RibbonProps) => { - const fmt = (cmd: string, val?: string) => () => onFormat(cmd, val); + const openHelpLink = (url: string) => () => { + window.open(url, '_blank', 'noopener,noreferrer'); + }; return (
Home + Help {/* Insert Page Layout References @@ -363,259 +57,33 @@ const Ribbon = ({ - {/* ── Home ─────────────────────────────────────────────────────── */} - -
- {/* Clipboard */} - -
-
- -
-
-
-
-
- - - {/* Font */} - -
- - -
-
- - B - - - I - - - U - - - S - - - X2 - - - X2 - - - A - -
-
- - - {/* Paragraph */} - -
-
-
-
-
- - - {/* Styles */} - - } - > -
- {STYLES.map((style) => ( - - ))} -
-
- - - {/* Editing */} - {/* -
- -
-
- - -
-
*/} -
-
+ + + - {/* ── Insert ───────────────────────────────────────────────────── */}
@@ -673,7 +141,6 @@ const Ribbon = ({
- {/* ── Page Layout ──────────────────────────────────────────────── */}
@@ -699,7 +166,7 @@ const Ribbon = ({ renderIcon={TextIndentMore} iconDescription="Increase Indent" tooltipPosition="bottom" - onClick={fmt('indent')} + onClick={() => onFormat('indent')} />
- {/* ── References ───────────────────────────────────────────────── */}
@@ -736,7 +202,6 @@ const Ribbon = ({
- {/* ── Review ───────────────────────────────────────────────────── */}
@@ -754,7 +219,6 @@ const Ribbon = ({
- {/* ── View ─────────────────────────────────────────────────────── */}
diff --git a/src/components/Ribbon/RibbonControls.tsx b/src/components/Ribbon/RibbonControls.tsx new file mode 100644 index 0000000..d8ae7f2 --- /dev/null +++ b/src/components/Ribbon/RibbonControls.tsx @@ -0,0 +1,194 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { ChevronDown, TextLineSpacing } from '@carbon/icons-react'; +import { CITATION_STYLE_OPTIONS, LINE_SPACINGS } from './ribbonConfig'; + +export const RibbonChunk = ({ + label, + children, + launcher, +}: { + label: string; + children: React.ReactNode; + launcher?: React.ReactNode; +}) => ( +
+
{children}
+
+ {label} + {launcher && {launcher}} +
+
+); + +export const RibbonDivider = () =>
; + +export const FormatButton = ({ + active = false, + onClick, + title, + children, +}: { + active?: boolean; + onClick: () => void; + title: string; + children: React.ReactNode; +}) => ( + +); + +const DialogLauncherIcon = () => ( + +); + +export const CitationStyleDropdown = ({ value, onChange }: { value: string; onChange: (style: string) => void }) => { + const [open, setOpen] = React.useState(false); + const [menuPos, setMenuPos] = React.useState({ top: 0, left: 0 }); + const wrapperRef = React.useRef(null); + const menuRef = React.useRef(null); + const btnRef = React.useRef(null); + + React.useEffect(() => { + if (!open) return; + const handleOutsideClick = (e: MouseEvent) => { + const target = e.target as Node; + if (!wrapperRef.current?.contains(target) && !menuRef.current?.contains(target)) { + setOpen(false); + } + }; + document.addEventListener('mousedown', handleOutsideClick); + return () => document.removeEventListener('mousedown', handleOutsideClick); + }, [open]); + + const handleToggle = () => { + if (!open && btnRef.current) { + const rect = btnRef.current.getBoundingClientRect(); + setMenuPos({ top: rect.bottom + 2, left: rect.left }); + } + setOpen((o) => !o); + }; + + return ( +
+ + {open && typeof document !== 'undefined' && ReactDOM.createPortal( +
    + {CITATION_STYLE_OPTIONS.map((s) => ( +
  • + +
  • + ))} +
, + document.body + )} +
+ ); +}; + +export const LineSpacingDropdown = ({ value, onChange }: { value: string; onChange: (spacing: string) => void }) => { + const [open, setOpen] = React.useState(false); + const [menuPos, setMenuPos] = React.useState({ top: 0, left: 0 }); + const wrapperRef = React.useRef(null); + const menuRef = React.useRef(null); + const btnRef = React.useRef(null); + + React.useEffect(() => { + if (!open) return; + const handleOutsideClick = (e: MouseEvent) => { + const target = e.target as Node; + if (!wrapperRef.current?.contains(target) && !menuRef.current?.contains(target)) { + setOpen(false); + } + }; + document.addEventListener('mousedown', handleOutsideClick); + return () => document.removeEventListener('mousedown', handleOutsideClick); + }, [open]); + + const handleToggle = () => { + if (!open && btnRef.current) { + const rect = btnRef.current.getBoundingClientRect(); + setMenuPos({ top: rect.bottom, left: rect.left }); + } + setOpen((o) => !o); + }; + + return ( +
+ + {open && typeof document !== 'undefined' && ReactDOM.createPortal( +
    + {LINE_SPACINGS.map((s) => ( +
  • + +
  • + ))} +
, + document.body + )} +
+ ); +}; diff --git a/src/components/Ribbon/ribbonConfig.ts b/src/components/Ribbon/ribbonConfig.ts new file mode 100644 index 0000000..99b72e8 --- /dev/null +++ b/src/components/Ribbon/ribbonConfig.ts @@ -0,0 +1,66 @@ +import React from 'react'; + +export const FONTS = [ + 'Calibri', + 'Arial', + 'Times New Roman', + 'Georgia', + 'Courier New', + 'Verdana', + 'Tahoma', + 'Impact', +]; + +export const SIZES = ['8', '9', '10', '11', '12', '14', '16', '18', '20', '24', '28', '32', '36', '48', '72']; + +export const STYLES: { label: string; cmd: string; val: string; previewStyle: React.CSSProperties }[] = [ + { + label: 'Normal', + cmd: 'formatBlock', + val: 'p', + previewStyle: { + fontFamily: 'Calibri, sans-serif', + fontSize: '11px', + fontWeight: '400', + color: '#161616', + }, + }, + { + label: 'Heading 1', + cmd: 'formatBlock', + val: 'h1', + previewStyle: { + fontFamily: "'Calibri Light', Calibri, sans-serif", + fontSize: '15px', + fontWeight: '700', + color: '#2e74b5', + }, + }, + { + label: 'Heading 2', + cmd: 'formatBlock', + val: 'h2', + previewStyle: { + fontFamily: "'Calibri Light', Calibri, sans-serif", + fontSize: '13px', + fontWeight: '700', + color: '#2e74b5', + }, + }, + { + label: 'Heading 3', + cmd: 'formatBlock', + val: 'h3', + previewStyle: { + fontFamily: "'Calibri Light', Calibri, sans-serif", + fontSize: '12px', + fontWeight: '700', + fontStyle: 'italic', + color: '#1f3864', + }, + }, +]; + +export const LINE_SPACINGS = ['1.0', '1.15', '1.5', '2.0', '2.5', '3.0']; + +export const CITATION_STYLE_OPTIONS = ['APA v7', 'APA v6', 'MLA', 'Chicago'] as const; diff --git a/src/components/Ribbon/types.ts b/src/components/Ribbon/types.ts new file mode 100644 index 0000000..3038d2b --- /dev/null +++ b/src/components/Ribbon/types.ts @@ -0,0 +1,22 @@ +export interface RibbonProps { + onFormat: (command: string, value?: string) => void; + fontSize: string; + fontFamily: string; + onFontSizeChange: (size: string) => void; + onFontFamilyChange: (family: string) => void; + isBold: boolean; + isItalic: boolean; + isUnderline: boolean; + isStrikethrough: boolean; + isSubscript: boolean; + isSuperscript: boolean; + isUnorderedList: boolean; + isOrderedList: boolean; + alignment: string; + onPrint: () => void; + onZoom: (delta: number) => void; + lineSpacing: string; + onLineSpacingChange: (spacing: string) => void; + citationStyle: string; + onCitationStyleChange: (style: string) => void; +} diff --git a/src/components/WordStatusBar/WordStatusBar.tsx b/src/components/WordStatusBar/WordStatusBar.tsx new file mode 100644 index 0000000..55db64e --- /dev/null +++ b/src/components/WordStatusBar/WordStatusBar.tsx @@ -0,0 +1,16 @@ +interface WordStatusBarProps { + wordCount: number; + zoom: number; +} + +const WordStatusBar = ({ wordCount, zoom }: WordStatusBarProps) => ( +
+ Page 1 of 1 + | + Words: {wordCount} + | + Zoom: {zoom}% +
+); + +export default WordStatusBar; diff --git a/src/components/WordTitleBar/WordTitleBar.tsx b/src/components/WordTitleBar/WordTitleBar.tsx new file mode 100644 index 0000000..20b3ca5 --- /dev/null +++ b/src/components/WordTitleBar/WordTitleBar.tsx @@ -0,0 +1,90 @@ +import { Button, Toggle } from '@carbon/react'; +import { Save, Undo, Redo, Printer } from '@carbon/icons-react'; + +interface WordTitleBarProps { + documentName: string; + autosaveEnabled: boolean; + autosaveStatus: '' | 'saving' | 'saved'; + onAutosaveToggle: (enabled: boolean) => void; + onSave: () => void; + onUndo: () => void; + onRedo: () => void; + onPrint: () => void; +} + +const WordTitleBar = ({ + documentName, + autosaveEnabled, + autosaveStatus, + onAutosaveToggle, + onSave, + onUndo, + onRedo, + onPrint, +}: WordTitleBarProps) => ( +
+
+
+ +
+ {documentName} - Carbon Type +
+ +
+ {autosaveEnabled && autosaveStatus && ( + + {autosaveStatus === 'saving' ? 'Saving...' : 'Autosaved'} + + )} + + Autosave + + +
+
+); + +export default WordTitleBar; From c4d20ec57ca6385b036414a92a08477224b69e18 Mon Sep 17 00:00:00 2001 From: Alex Micharski Date: Wed, 18 Mar 2026 08:50:28 -0400 Subject: [PATCH 2/2] added more font style options --- src/app/globals.scss | 751 +---------------------- src/app/page.tsx | 223 ++++++- src/app/styles/_base.scss | 17 + src/app/styles/_document.scss | 104 ++++ src/app/styles/_mobile.scss | 121 ++++ src/app/styles/_ribbon.scss | 547 +++++++++++++++++ src/app/styles/_status.scss | 37 ++ src/app/styles/_title-bar.scss | 76 +++ src/components/Ribbon/HomeTabPanel.tsx | 57 +- src/components/Ribbon/RibbonControls.tsx | 297 ++++++++- src/components/Ribbon/ribbonConfig.ts | 111 +++- 11 files changed, 1569 insertions(+), 772 deletions(-) create mode 100644 src/app/styles/_base.scss create mode 100644 src/app/styles/_document.scss create mode 100644 src/app/styles/_mobile.scss create mode 100644 src/app/styles/_ribbon.scss create mode 100644 src/app/styles/_status.scss create mode 100644 src/app/styles/_title-bar.scss diff --git a/src/app/globals.scss b/src/app/globals.scss index 06fee8f..5892860 100644 --- a/src/app/globals.scss +++ b/src/app/globals.scss @@ -1,750 +1,11 @@ @use "@carbon/react"; +@use "./styles/base"; +@use "./styles/title-bar"; +@use "./styles/ribbon"; +@use "./styles/document"; +@use "./styles/status"; +@use "./styles/mobile"; @tailwind base; @tailwind components; @tailwind utilities; - -// ─── Word Processor Layout ─────────────────────────────────────────────────── - -* { - box-sizing: border-box; -} - -body, -html { - margin: 0; - padding: 0; - overflow: hidden; -} - -.word-processor { - display: flex; - flex-direction: column; - height: 100dvh; - overflow: hidden; -} - -// ─── Title Bar ─────────────────────────────────────────────────────────────── - -.word-title-bar { - display: flex; - align-items: center; - background: #2b5797; - color: #fff; - height: 34px; - padding: 0 4px; - flex-shrink: 0; - gap: 4px; - - .cds--btn--ghost { - color: rgba(255, 255, 255, 0.85); - min-height: 28px; - - &:hover { - background: rgba(255, 255, 255, 0.15); - color: #fff; - } - - svg { - fill: rgba(255, 255, 255, 0.85); - } - } -} - -.word-title-bar__quick-access { - display: flex; - align-items: center; - gap: 1px; -} - -.word-title-bar__document-title { - flex: 1; - text-align: center; - font-size: 13px; - font-weight: 400; - color: #fff; - pointer-events: none; - user-select: none; -} - -.word-title-bar__right { - display: flex; - align-items: center; - gap: 4px; -} - -.word-title-bar__autosave-status { - font-size: 12px; - color: rgba(255, 255, 255, 0.9); - padding: 0 6px; - white-space: nowrap; -} - -.word-title-bar__autosave-toggle { - display: flex; - align-items: center; - gap: 6px; - padding: 0 6px; - - .word-title-bar__autosave-toggle-label { - font-size: 12px; - color: rgba(255, 255, 255, 0.85); - white-space: nowrap; - user-select: none; - } - - // Override Carbon toggle colours for the dark title bar - .cds--toggle__switch { - margin: 0; - } - - .cds--toggle__label { - margin: 0; - } -} - -// ─── Ribbon ────────────────────────────────────────────────────────────────── - -.word-ribbon { - flex-shrink: 0; - background: #f5f5f5; - border-bottom: 1px solid #b8b8b8; - - // Tab list — make it look like Word ribbon tabs - .cds--tabs__nav { - background: #d8d8d8; - border-bottom: 1px solid #b8b8b8; - } - - .cds--tabs__nav-link { - font-size: 11px; - padding: 4px 12px; - min-height: 26px; - color: #222; - background: transparent; - } - - .cds--tabs__nav-item--selected .cds--tabs__nav-link { - color: #222; - background: #f5f5f5; - border-bottom: 2px solid #f5f5f5; - } - - .cds--tabs__nav-item:hover .cds--tabs__nav-link { - background: #c8c8c8; - } - - // Tab content area — flush, no extra padding - .cds--tab-content { - padding: 0; - background: #f5f5f5; - } -} - -// ─── Ribbon Panel (one tab's content row) ──────────────────────────────────── - -.ribbon-panel { - display: flex; - flex-direction: row; - align-items: stretch; - min-height: 72px; - padding: 4px 4px 0; - flex-wrap: nowrap; - overflow-x: auto; -} - -// ─── Ribbon Chunk ───────────────────────────────────────────────────────────── - -.ribbon-chunk { - display: flex; - flex-direction: column; - align-items: center; - padding: 0 6px 3px; - position: relative; - min-width: 40px; -} - -.ribbon-chunk__controls { - display: flex; - flex-direction: column; - gap: 2px; - flex: 1; - width: 100%; -} - -.ribbon-chunk__label { - font-size: 10px; - color: #555; - white-space: nowrap; - margin-top: 3px; - padding-top: 2px; - border-top: 1px solid #c8c8c8; - width: 100%; - user-select: none; - display: flex; - align-items: center; - justify-content: center; - gap: 3px; -} - -.ribbon-chunk__launcher { - display: inline-flex; - align-items: center; - flex-shrink: 0; -} - -.ribbon-divider { - width: 1px; - background: #c0c0c0; - align-self: stretch; - margin: 4px 4px 6px; - flex-shrink: 0; -} - -// ─── Ribbon Row (small button row inside a chunk) ──────────────────────────── - -.ribbon-row { - display: flex; - flex-direction: row; - align-items: center; - gap: 1px; - flex-wrap: nowrap; -} - -// ─── Clipboard chunk — large Paste + small Cut/Copy column ─────────────────── - -.ribbon-clipboard { - display: flex; - flex-direction: row; - align-items: flex-start; - gap: 2px; -} - -.ribbon-clipboard__paste { - // Large paste button - .cds--btn { - flex-direction: column; - height: 52px; - width: 44px; - padding: 2px 4px; - font-size: 11px; - gap: 2px; - - svg { - width: 20px; - height: 20px; - } - } -} - -.ribbon-clipboard__small { - display: flex; - flex-direction: column; - gap: 1px; - padding-top: 2px; -} - -// ─── Font selects ───────────────────────────────────────────────────────────── - -.ribbon-select { - height: 22px; - border: 1px solid #ababab; - border-radius: 2px; - background: #fff; - font-size: 12px; - padding: 0 4px; - cursor: pointer; - min-width: 130px; - color: #161616; - - &:focus { - outline: 2px solid #0f62fe; - outline-offset: -2px; - } - - &--size { - min-width: 44px; - width: 44px; - } -} - -// ─── Styles chunk ───────────────────────────────────────────────────────────── - -.ribbon-styles { - display: flex; - flex-direction: row; - flex-wrap: wrap; - gap: 2px; - - .ribbon-style-btn.cds--btn { - min-height: unset; - padding: 3px 8px; - line-height: 1.2; - } -} - -// ─── Custom format buttons (B / I / U etc.) ─────────────────────────────────── - -.format-btn { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 26px; - min-height: 22px; - padding: 2px 4px; - background: transparent; - border: 1px solid transparent; - cursor: pointer; - border-radius: 2px; - color: #222; - line-height: 1; - - &:hover { - background: #dde0e5; - } - - &--active { - background: #cce0ff; - border-color: #0f62fe; - } - - &:focus-visible { - outline: 2px solid #0f62fe; - outline-offset: 1px; - } -} - -.format-btn__label { - font-family: 'Times New Roman', Georgia, serif; - font-size: 14px; - color: inherit; - line-height: 1; - - &--bold { - font-weight: bold; - } - - &--italic { - font-style: italic; - } - - &--underline { - text-decoration: underline; - } - - &--strikethrough { - text-decoration: line-through; - } - - &--highlight { - background-color: #ffff00; - padding: 0 2px; - } -} - -// ─── Ghost button sizing tweaks ─────────────────────────────────────────────── - -.word-ribbon .cds--btn--ghost { - min-height: 22px; - padding: 2px 4px; - color: #222; - - &:hover { - background: #dde0e5; - color: #222; - } - - svg { - fill: #222; - } -} - -.word-ribbon .cds--btn--primary { - min-height: 22px; - padding: 2px 4px; -} - -// ─── Line Spacing Dropdown ──────────────────────────────────────────────────── - -.line-spacing-dropdown { - position: relative; - display: inline-flex; -} - -.line-spacing-btn { - display: inline-flex; - align-items: center; - gap: 2px; - min-height: 22px; - padding: 2px 4px; - background: transparent; - border: none; - cursor: pointer; - color: #222; - border-radius: 0; - - &:hover { - background: #dde0e5; - } - - svg { - fill: #222; - } -} - -.line-spacing-menu { - position: fixed; - background: #fff; - border: 1px solid #c6c6c6; - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2); - z-index: 9999; - min-width: 64px; - padding: 4px 0; - list-style: none; - margin: 0; -} - -.line-spacing-menu-item { - display: block; - width: 100%; - padding: 5px 16px; - background: none; - border: none; - cursor: pointer; - text-align: left; - font-size: 13px; - color: #161616; - - &:hover { - background: #e8e8e8; - } - - &--active { - background: #0f62fe; - color: #fff; - - &:hover { - background: #0353e9; - } - } -} - -// ─── Citation Style Dropdown ────────────────────────────────────────────────── - -.citation-style-launcher { - display: inline-flex; - align-items: center; -} - -.citation-style-launcher-btn { - display: inline-flex; - align-items: center; - justify-content: center; - width: 14px; - height: 14px; - padding: 0; - background: transparent; - border: none; - cursor: pointer; - color: #555; - border-radius: 2px; - - &:hover { - background: #c8c8c8; - color: #161616; - } -} - -.citation-style-menu { - position: fixed; - background: #fff; - border: 1px solid #c6c6c6; - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2); - z-index: 9999; - min-width: 120px; - padding: 4px 0; - list-style: none; - margin: 0; -} - -.citation-style-menu-item { - display: block; - width: 100%; - padding: 6px 16px; - background: none; - border: none; - cursor: pointer; - text-align: left; - font-size: 13px; - color: #161616; - - &:hover { - background: #e8e8e8; - } - - &--active { - background: #0f62fe; - color: #fff; - - &:hover { - background: #0353e9; - } - } -} - -// ─── Document Canvas ─────────────────────────────────────────────────────────── - -.document-canvas { - flex: 1; - overflow-y: auto; - background: #808080; - display: flex; - justify-content: center; - padding: 24px 24px; - - @media (max-width: 815px) { - padding: 0; - } -} - -// ─── Document Page (white paper) ───────────────────────────────────────────── - -.document-page { - background: #fff; - width: min(816px, 100%); - min-height: 1056px; - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.55); - padding: clamp(16px, 6vw, 96px); - flex-shrink: 0; - - @media (max-width: 815px) { - width: 100%; - min-height: 100%; - box-shadow: none; - } -} - -// ─── Document content (contentEditable) ────────────────────────────────────── - -.document-content { - min-height: 864px; - outline: none; - line-height: 1.5; - word-wrap: break-word; - font-family: Calibri, sans-serif; - font-size: 12pt; - color: #000; - - p { - margin: 0 0 12px; - } - - h1 { - font-size: 26pt; - font-weight: 700; - margin: 0 0 14px; - } - - h2 { - font-size: 18pt; - font-weight: 700; - margin: 0 0 12px; - } - - h3 { - font-size: 14pt; - font-weight: 700; - margin: 0 0 10px; - } - - ul, ol { - margin: 0 0 12px; - padding-left: 2em; - } - - ul { - list-style-type: disc; - - ul { list-style-type: circle; } - ul ul { list-style-type: square; } - } - - ol { - list-style-type: decimal; - - ol { list-style-type: lower-alpha; } - ol ol { list-style-type: lower-roman; } - } - - li { - margin: 0 0 4px; - } -} - -// ─── Status Bar ─────────────────────────────────────────────────────────────── - -.word-status-bar { - display: flex; - align-items: center; - gap: 8px; - background: #2b5797; - color: rgba(255, 255, 255, 0.9); - height: 22px; - padding: 0 12px; - font-size: 11px; - flex-shrink: 0; - user-select: none; -} - -.word-status-bar__divider { - opacity: 0.4; -} - -.word-status-bar__autosave { - 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/page.tsx b/src/app/page.tsx index 849b19e..9af8d55 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -6,8 +6,11 @@ import Ribbon from '@/components/Ribbon/Ribbon'; import DocumentEditor from '@/components/DocumentEditor/DocumentEditor'; import WordTitleBar from '@/components/WordTitleBar/WordTitleBar'; import WordStatusBar from '@/components/WordStatusBar/WordStatusBar'; +import { DocumentTextStyle, getStylePresets } from '@/components/Ribbon/ribbonConfig'; type TextAlignment = 'left' | 'center' | 'right' | 'justify'; +type CaseMode = 'sentence' | 'lowercase' | 'uppercase' | 'capitalize' | 'toggle'; +type TextEffectMode = 'none' | 'shadow' | 'outline' | 'smallCaps' | 'allCaps'; const CITATION_CONFIGS = { 'APA v7': { fontFamily: 'Calibri', fontSize: '11', lineSpacing: '2.0', alignment: 'left' as TextAlignment, firstLineIndent: '0.5in' }, @@ -85,6 +88,114 @@ export default function WordProcessor() { URL.revokeObjectURL(url); }, [documentName, fontFamily, fontSize, sanitizeForExport]); + const transformCase = useCallback((value: string, mode: CaseMode): string => { + switch (mode) { + case 'lowercase': + return value.toLowerCase(); + case 'uppercase': + return value.toUpperCase(); + case 'capitalize': + return value.toLowerCase().replace(/\b\p{L}/gu, (match) => match.toUpperCase()); + case 'toggle': + return value + .split('') + .map((char) => { + const lower = char.toLowerCase(); + const upper = char.toUpperCase(); + if (char === lower && char !== upper) return upper; + if (char === upper && char !== lower) return lower; + return char; + }) + .join(''); + case 'sentence': { + const lower = value.toLowerCase(); + let shouldCapitalize = true; + return lower.replace(/\p{L}|[.!?]/gu, (char) => { + if (/[.!?]/.test(char)) { + shouldCapitalize = true; + return char; + } + if (shouldCapitalize) { + shouldCapitalize = false; + return char.toUpperCase(); + } + return char; + }); + } + default: + return value; + } + }, []); + + const applyTextEffect = useCallback((mode: TextEffectMode) => { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) return; + + const range = selection.getRangeAt(0); + if (range.collapsed) return; + + const fragment = range.extractContents(); + const walker = document.createTreeWalker(fragment, NodeFilter.SHOW_TEXT); + let textNode = walker.nextNode(); + while (textNode) { + const currentNode = textNode as Text; + if ((currentNode.textContent ?? '').length > 0) { + const span = document.createElement('span'); + span.textContent = currentNode.textContent; + + if (mode === 'none') { + span.style.textShadow = 'none'; + span.style.fontVariant = 'normal'; + span.style.textTransform = 'none'; + span.style.removeProperty('-webkit-text-stroke'); + } + + if (mode === 'shadow') { + span.style.textShadow = '1px 1px 2px rgba(0, 0, 0, 0.35)'; + span.style.fontVariant = 'normal'; + span.style.textTransform = 'none'; + span.style.removeProperty('-webkit-text-stroke'); + } + + if (mode === 'outline') { + span.style.textShadow = 'none'; + span.style.fontVariant = 'normal'; + span.style.textTransform = 'none'; + span.style.setProperty('-webkit-text-stroke', '0.6px currentColor'); + } + + if (mode === 'smallCaps') { + span.style.textShadow = 'none'; + span.style.fontVariant = 'small-caps'; + span.style.textTransform = 'none'; + span.style.removeProperty('-webkit-text-stroke'); + } + + if (mode === 'allCaps') { + span.style.textShadow = 'none'; + span.style.fontVariant = 'normal'; + span.style.textTransform = 'uppercase'; + span.style.removeProperty('-webkit-text-stroke'); + } + + currentNode.replaceWith(span); + } + textNode = walker.nextNode(); + } + + const firstInserted = fragment.firstChild; + const lastInserted = fragment.lastChild; + range.insertNode(fragment); + + if (firstInserted && lastInserted) { + const nextRange = document.createRange(); + nextRange.setStartBefore(firstInserted); + nextRange.setEndAfter(lastInserted); + selection.removeAllRanges(); + selection.addRange(nextRange); + } + }, []); + // document.execCommand is the standard mechanism for formatting contentEditable // regions. While marked deprecated in the spec, all major browsers continue to // support it and there is no equivalent modern replacement for all commands. @@ -92,8 +203,107 @@ export default function WordProcessor() { const el = editorRef.current; if (!el) return; el.focus(); + + if (command === 'changeCase') { + const mode = value as CaseMode | undefined; + if (!mode) return; + + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) return; + + const range = selection.getRangeAt(0); + if (range.collapsed) return; + + const fragment = range.extractContents(); + const walker = document.createTreeWalker(fragment, NodeFilter.SHOW_TEXT); + let textNode = walker.nextNode(); + while (textNode) { + textNode.textContent = transformCase(textNode.textContent ?? '', mode); + textNode = walker.nextNode(); + } + + const firstInserted = fragment.firstChild; + const lastInserted = fragment.lastChild; + range.insertNode(fragment); + + if (firstInserted && lastInserted) { + const nextRange = document.createRange(); + nextRange.setStartBefore(firstInserted); + nextRange.setEndAfter(lastInserted); + selection.removeAllRanges(); + selection.addRange(nextRange); + } + + return; + } + + if (command === 'textEffect') { + const mode = value as TextEffectMode | undefined; + if (!mode) return; + applyTextEffect(mode); + return; + } + document.execCommand(command, false, value ?? undefined); + if (command === 'formatBlock' && value) { + const selectedStyle = getStylePresets(citationStyle).find((style) => style.val === value); + if (selectedStyle) { + const applyTextStyle = (block: HTMLElement, textStyle: DocumentTextStyle) => { + block.style.fontFamily = textStyle.fontFamily; + block.style.fontSize = `${textStyle.fontSizePt}pt`; + block.style.fontWeight = textStyle.fontWeight; + block.style.fontStyle = textStyle.fontStyle ?? 'normal'; + block.style.color = textStyle.color; + }; + + const getBlockAncestor = (node: Node): HTMLElement | null => { + let current: Node | null = node; + while (current && current !== el) { + if ( + current.nodeType === Node.ELEMENT_NODE && + ['P', 'H1', 'H2', 'H3'].includes((current as Element).tagName) + ) { + return current as HTMLElement; + } + current = current.parentNode; + } + return null; + }; + + const selection = window.getSelection(); + if (selection?.rangeCount) { + const range = selection.getRangeAt(0); + const blocks = new Set(); + const startBlock = getBlockAncestor(range.startContainer); + const endBlock = getBlockAncestor(range.endContainer); + if (startBlock) blocks.add(startBlock); + if (endBlock) blocks.add(endBlock); + + const ancestor = range.commonAncestorContainer; + const walkerRoot = + ancestor.nodeType === Node.ELEMENT_NODE + ? (ancestor as Element) + : (ancestor.parentElement ?? el); + const walker = document.createTreeWalker(walkerRoot, NodeFilter.SHOW_ELEMENT, { + acceptNode(node) { + const tag = (node as Element).tagName; + return ['P', 'H1', 'H2', 'H3'].includes(tag) + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_SKIP; + }, + }); + + while (walker.nextNode()) { + const node = walker.currentNode as HTMLElement; + if (range.intersectsNode(node)) blocks.add(node); + } + + blocks.forEach((block) => applyTextStyle(block, selectedStyle.textStyle)); + } + } + } + setIsBold(document.queryCommandState('bold')); setIsItalic(document.queryCommandState('italic')); setIsUnderline(document.queryCommandState('underline')); @@ -107,7 +317,7 @@ export default function WordProcessor() { else if (document.queryCommandState('justifyRight')) setAlignment('right'); else if (document.queryCommandState('justifyFull')) setAlignment('justify'); else setAlignment('left'); - }, []); + }, [applyTextEffect, citationStyle, transformCase]); const handleFontFamilyChange = useCallback( (family: string) => { @@ -273,6 +483,17 @@ export default function WordProcessor() { block.style.lineHeight = config.lineSpacing; block.style.textIndent = config.firstLineIndent; }); + + // Keep existing heading and paragraph blocks aligned with the selected citation style. + getStylePresets(style).forEach((preset) => { + el.querySelectorAll(preset.val).forEach((block) => { + block.style.fontFamily = preset.textStyle.fontFamily; + block.style.fontSize = `${preset.textStyle.fontSizePt}pt`; + block.style.fontWeight = preset.textStyle.fontWeight; + block.style.fontStyle = preset.textStyle.fontStyle ?? 'normal'; + block.style.color = preset.textStyle.color; + }); + }); // Set font, size, and line-height on the editor root so new paragraphs // inherit them and so getComputedStyle at any cursor position returns the // correct values (prevents selectionchange from snapping font back to the diff --git a/src/app/styles/_base.scss b/src/app/styles/_base.scss new file mode 100644 index 0000000..f0fc9dd --- /dev/null +++ b/src/app/styles/_base.scss @@ -0,0 +1,17 @@ +* { + box-sizing: border-box; +} + +body, +html { + margin: 0; + padding: 0; + overflow: hidden; +} + +.word-processor { + display: flex; + flex-direction: column; + height: 100dvh; + overflow: hidden; +} diff --git a/src/app/styles/_document.scss b/src/app/styles/_document.scss new file mode 100644 index 0000000..add87a8 --- /dev/null +++ b/src/app/styles/_document.scss @@ -0,0 +1,104 @@ +.document-canvas { + flex: 1; + overflow-y: auto; + background: #808080; + display: flex; + justify-content: center; + padding: 24px 24px; + + @media (max-width: 815px) { + padding: 0; + } +} + +.document-page { + background: #fff; + width: min(816px, 100%); + min-height: 1056px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.55); + padding: clamp(16px, 6vw, 96px); + flex-shrink: 0; + + @media (max-width: 815px) { + width: 100%; + min-height: 100%; + box-shadow: none; + } +} + +.document-content { + min-height: 864px; + outline: none; + line-height: 1.5; + word-wrap: break-word; + font-family: Calibri, sans-serif; + font-size: 12pt; + color: #000; + + p { + margin: 0 0 12px; + } + + h1 { + font-size: 26pt; + font-weight: 700; + margin: 0 0 14px; + } + + h2 { + font-size: 18pt; + font-weight: 700; + margin: 0 0 12px; + } + + h3 { + font-size: 14pt; + font-weight: 700; + margin: 0 0 10px; + } + + ul, + ol { + margin: 0 0 12px; + padding-left: 2em; + } + + ul { + list-style-type: disc; + + ul { + list-style-type: circle; + } + + ul ul { + list-style-type: square; + } + } + + ol { + list-style-type: decimal; + + ol { + list-style-type: lower-alpha; + } + + ol ol { + list-style-type: lower-roman; + } + } + + li { + margin: 0 0 4px; + } +} + +.document-content:focus, +.document-content:focus-visible { + outline: none !important; + box-shadow: none; +} + +.document-page:focus-within { + outline: none; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.55); +} diff --git a/src/app/styles/_mobile.scss b/src/app/styles/_mobile.scss new file mode 100644 index 0000000..dbd7c6f --- /dev/null +++ b/src/app/styles/_mobile.scss @@ -0,0 +1,121 @@ +.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; + } +} + +@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/styles/_ribbon.scss b/src/app/styles/_ribbon.scss new file mode 100644 index 0000000..fd93490 --- /dev/null +++ b/src/app/styles/_ribbon.scss @@ -0,0 +1,547 @@ +.word-ribbon { + flex-shrink: 0; + background: #f5f5f5; + border-bottom: 1px solid #b8b8b8; + + // Tab list - make it look like Word ribbon tabs + .cds--tabs__nav { + background: #d8d8d8; + border-bottom: 1px solid #b8b8b8; + } + + .cds--tabs__nav-link { + font-size: 11px; + padding: 4px 12px; + min-height: 26px; + color: #222; + background: transparent; + } + + .cds--tabs__nav-item--selected .cds--tabs__nav-link { + color: #222; + background: #f5f5f5; + border-bottom: 2px solid #f5f5f5; + } + + .cds--tabs__nav-item:hover .cds--tabs__nav-link { + background: #c8c8c8; + } + + // Tab content area - flush, no extra padding + .cds--tab-content { + padding: 0; + background: #f5f5f5; + } +} + +.ribbon-panel { + display: flex; + flex-direction: row; + align-items: stretch; + min-height: 72px; + padding: 4px 4px 0; + flex-wrap: nowrap; + overflow-x: auto; +} + +.ribbon-chunk { + display: flex; + flex-direction: column; + align-items: center; + padding: 0 6px 3px; + position: relative; + min-width: 40px; +} + +.ribbon-chunk__controls { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; + width: 100%; +} + +.ribbon-chunk__label { + font-size: 10px; + color: #555; + white-space: nowrap; + margin-top: 3px; + padding-top: 2px; + border-top: 1px solid #c8c8c8; + width: 100%; + user-select: none; + display: flex; + align-items: center; + justify-content: center; + gap: 3px; +} + +.ribbon-chunk__launcher { + display: inline-flex; + align-items: center; + flex-shrink: 0; +} + +.ribbon-divider { + width: 1px; + background: #c0c0c0; + align-self: stretch; + margin: 4px 4px 6px; + flex-shrink: 0; +} + +.ribbon-row { + display: flex; + flex-direction: row; + align-items: center; + gap: 1px; + flex-wrap: nowrap; +} + +.ribbon-clipboard { + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 2px; +} + +.ribbon-clipboard__paste { + // Large paste button + .cds--btn { + flex-direction: column; + height: 52px; + width: 44px; + padding: 2px 4px; + font-size: 11px; + gap: 2px; + + svg { + width: 20px; + height: 20px; + } + } +} + +.ribbon-clipboard__small { + display: flex; + flex-direction: column; + gap: 1px; + padding-top: 2px; +} + +.ribbon-select { + height: 22px; + border: 1px solid #ababab; + border-radius: 2px; + background: #fff; + font-size: 12px; + padding: 0 4px; + cursor: pointer; + min-width: 130px; + color: #161616; + + &:focus { + outline: 2px solid #0f62fe; + outline-offset: -2px; + } + + &--size { + min-width: 44px; + width: 44px; + } +} + +.ribbon-styles { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 2px; + + .ribbon-style-btn.cds--btn { + min-height: unset; + padding: 3px 8px; + line-height: 1.2; + } +} + +.format-btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 26px; + min-height: 22px; + padding: 2px 4px; + background: transparent; + border: 1px solid transparent; + cursor: pointer; + border-radius: 2px; + color: #222; + line-height: 1; + + &:hover { + background: #dde0e5; + } + + &--active { + background: #cce0ff; + border-color: #0f62fe; + } + + &:focus-visible { + outline: 2px solid #0f62fe; + outline-offset: 1px; + } +} + +.format-btn__label { + font-family: 'Times New Roman', Georgia, serif; + font-size: 14px; + color: inherit; + line-height: 1; + + &--bold { + font-weight: bold; + } + + &--italic { + font-style: italic; + } + + &--underline { + text-decoration: underline; + } + + &--strikethrough { + text-decoration: line-through; + } + + &--highlight { + background-color: #ffff00; + padding: 0 2px; + } +} + +.word-ribbon .cds--btn--ghost { + min-height: 22px; + padding: 2px 4px; + color: #222; + + &:hover { + background: #dde0e5; + color: #222; + } + + svg { + fill: #222; + } +} + +.word-ribbon .cds--btn--primary { + min-height: 22px; + padding: 2px 4px; +} + +.line-spacing-dropdown { + position: relative; + display: inline-flex; +} + +.line-spacing-btn { + display: inline-flex; + align-items: center; + gap: 2px; + min-height: 22px; + padding: 2px 4px; + background: transparent; + border: none; + cursor: pointer; + color: #222; + border-radius: 0; + + &:hover { + background: #dde0e5; + } + + svg { + fill: #222; + } +} + +.line-spacing-menu { + position: fixed; + background: #fff; + border: 1px solid #c6c6c6; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2); + z-index: 9999; + min-width: 64px; + padding: 4px 0; + list-style: none; + margin: 0; +} + +.line-spacing-menu-item { + display: block; + width: 100%; + padding: 5px 16px; + background: none; + border: none; + cursor: pointer; + text-align: left; + font-size: 13px; + color: #161616; + + &:hover { + background: #e8e8e8; + } + + &--active { + background: #0f62fe; + color: #fff; + + &:hover { + background: #0353e9; + } + } +} + +.change-case-dropdown { + position: relative; + display: inline-flex; +} + +.change-case-btn { + display: inline-flex; + align-items: center; + gap: 2px; + min-height: 22px; + padding: 2px 6px; + background: transparent; + border: 1px solid transparent; + cursor: pointer; + color: #222; + border-radius: 2px; + + &:hover { + background: #dde0e5; + } + + &:focus-visible { + outline: 2px solid #0f62fe; + outline-offset: 1px; + } + + svg { + fill: #222; + } +} + +.change-case-btn__text { + font-family: 'Times New Roman', Georgia, serif; + font-size: 13px; + line-height: 1; +} + +.change-case-menu { + position: fixed; + background: #fff; + border: 1px solid #c6c6c6; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2); + z-index: 9999; + min-width: 180px; + padding: 4px 0; + list-style: none; + margin: 0; +} + +.change-case-menu-item { + display: block; + width: 100%; + padding: 6px 12px; + background: none; + border: none; + cursor: pointer; + text-align: left; + font-size: 13px; + color: #161616; + + &:hover { + background: #e8e8e8; + } +} + +.text-effects-dropdown, +.font-color-dropdown, +.highlight-color-dropdown { + position: relative; + display: inline-flex; +} + +.text-effects-btn, +.font-color-btn, +.highlight-color-btn { + display: inline-flex; + align-items: center; + gap: 2px; + min-height: 22px; + padding: 2px 6px; + background: transparent; + border: 1px solid transparent; + cursor: pointer; + color: #222; + border-radius: 2px; + + &:hover { + background: #dde0e5; + } + + &:focus-visible { + outline: 2px solid #0f62fe; + outline-offset: 1px; + } + + svg { + fill: #222; + } +} + +.text-effects-btn__text, +.font-color-btn__text, +.highlight-color-btn__text { + font-family: 'Times New Roman', Georgia, serif; + font-size: 13px; + line-height: 1; +} + +.font-color-btn, +.highlight-color-btn { + justify-content: center; + gap: 4px; + min-width: 34px; + padding: 2px 4px; +} + +.font-color-btn__glyph, +.highlight-color-btn__glyph { + display: inline-flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0; +} + +.font-color-btn__bar, +.highlight-color-btn__bar { + width: 12px; + height: 2px; + margin-top: 1px; +} + +.font-color-btn__bar { + background: #0f62fe; +} + +.highlight-color-btn__bar { + background: #ffff00; +} + +.text-effects-menu, +.font-color-menu, +.highlight-color-menu { + position: fixed; + background: #fff; + border: 1px solid #c6c6c6; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2); + z-index: 9999; + min-width: 180px; + padding: 4px 0; + list-style: none; + margin: 0; +} + +.text-effects-menu-item, +.font-color-menu-item, +.highlight-color-menu-item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 6px 12px; + background: none; + border: none; + cursor: pointer; + text-align: left; + font-size: 13px; + color: #161616; + + &:hover { + background: #e8e8e8; + } +} + +.color-option-swatch { + width: 12px; + height: 12px; + border: 1px solid #8d8d8d; + flex-shrink: 0; +} + +.citation-style-launcher { + display: inline-flex; + align-items: center; +} + +.citation-style-launcher-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + padding: 0; + background: transparent; + border: none; + cursor: pointer; + color: #555; + border-radius: 2px; + + &:hover { + background: #c8c8c8; + color: #161616; + } +} + +.citation-style-menu { + position: fixed; + background: #fff; + border: 1px solid #c6c6c6; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2); + z-index: 9999; + min-width: 120px; + padding: 4px 0; + list-style: none; + margin: 0; +} + +.citation-style-menu-item { + display: block; + width: 100%; + padding: 6px 16px; + background: none; + border: none; + cursor: pointer; + text-align: left; + font-size: 13px; + color: #161616; + + &:hover { + background: #e8e8e8; + } + + &--active { + background: #0f62fe; + color: #fff; + + &:hover { + background: #0353e9; + } + } +} diff --git a/src/app/styles/_status.scss b/src/app/styles/_status.scss new file mode 100644 index 0000000..b6f39a1 --- /dev/null +++ b/src/app/styles/_status.scss @@ -0,0 +1,37 @@ +.word-status-bar { + display: flex; + align-items: center; + gap: 8px; + background: #2b5797; + color: rgba(255, 255, 255, 0.9); + height: 22px; + padding: 0 12px; + font-size: 11px; + flex-shrink: 0; + user-select: none; +} + +.word-status-bar__divider { + opacity: 0.4; +} + +.word-status-bar__autosave { + opacity: 0.85; + font-style: italic; +} + +.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; +} diff --git a/src/app/styles/_title-bar.scss b/src/app/styles/_title-bar.scss new file mode 100644 index 0000000..2b7ab8b --- /dev/null +++ b/src/app/styles/_title-bar.scss @@ -0,0 +1,76 @@ +.word-title-bar { + display: flex; + align-items: center; + background: #2b5797; + color: #fff; + height: 34px; + padding: 0 4px; + flex-shrink: 0; + gap: 4px; + + .cds--btn--ghost { + color: rgba(255, 255, 255, 0.85); + min-height: 28px; + + &:hover { + background: rgba(255, 255, 255, 0.15); + color: #fff; + } + + svg { + fill: rgba(255, 255, 255, 0.85); + } + } +} + +.word-title-bar__quick-access { + display: flex; + align-items: center; + gap: 1px; +} + +.word-title-bar__document-title { + flex: 1; + text-align: center; + font-size: 13px; + font-weight: 400; + color: #fff; + pointer-events: none; + user-select: none; +} + +.word-title-bar__right { + display: flex; + align-items: center; + gap: 4px; +} + +.word-title-bar__autosave-status { + font-size: 12px; + color: rgba(255, 255, 255, 0.9); + padding: 0 6px; + white-space: nowrap; +} + +.word-title-bar__autosave-toggle { + display: flex; + align-items: center; + gap: 6px; + padding: 0 6px; + + .word-title-bar__autosave-toggle-label { + font-size: 12px; + color: rgba(255, 255, 255, 0.85); + white-space: nowrap; + user-select: none; + } + + // Override Carbon toggle colours for the dark title bar + .cds--toggle__switch { + margin: 0; + } + + .cds--toggle__label { + margin: 0; + } +} diff --git a/src/components/Ribbon/HomeTabPanel.tsx b/src/components/Ribbon/HomeTabPanel.tsx index 794e37d..7d895bf 100644 --- a/src/components/Ribbon/HomeTabPanel.tsx +++ b/src/components/Ribbon/HomeTabPanel.tsx @@ -13,8 +13,18 @@ import { TextIndentLess, TextClearFormat, } from '@carbon/icons-react'; -import { FONTS, SIZES, STYLES } from './ribbonConfig'; -import { CitationStyleDropdown, FormatButton, LineSpacingDropdown, RibbonChunk, RibbonDivider } from './RibbonControls'; +import { FONTS, SIZES, getStylePresets, toPreviewStyle } from './ribbonConfig'; +import { + ChangeCaseDropdown, + CitationStyleDropdown, + FontColorDropdown, + FormatButton, + HighlightColorDropdown, + LineSpacingDropdown, + RibbonChunk, + RibbonDivider, + TextEffectsDropdown, +} from './RibbonControls'; import { RibbonProps } from './types'; type HomeTabPanelProps = Pick< @@ -60,6 +70,32 @@ const HomeTabPanel = ({ onCitationStyleChange, }: HomeTabPanelProps) => { const fmt = (cmd: string, val?: string) => () => onFormat(cmd, val); + const styles = getStylePresets(citationStyle); + const parsedFontSize = Number.parseInt(fontSize, 10); + + const getClosestFontSizeIndex = (targetSize: number): number => { + let bestIndex = 0; + let bestDistance = Number.POSITIVE_INFINITY; + + SIZES.forEach((size, index) => { + const current = Number.parseInt(size, 10); + const distance = Math.abs(current - targetSize); + if (distance < bestDistance) { + bestDistance = distance; + bestIndex = index; + } + }); + + return bestIndex; + }; + + const stepFontSize = (delta: -1 | 1) => { + const currentIndex = Number.isNaN(parsedFontSize) + ? 0 + : getClosestFontSizeIndex(parsedFontSize); + const nextIndex = Math.max(0, Math.min(SIZES.length - 1, currentIndex + delta)); + onFontSizeChange(SIZES[nextIndex]); + }; return ( @@ -132,6 +168,13 @@ const HomeTabPanel = ({ ))} + stepFontSize(1)} title="Increase Font Size"> + A+ + + stepFontSize(-1)} title="Decrease Font Size"> + A- + + onFormat('changeCase', caseType)} />
@@ -152,9 +195,9 @@ const HomeTabPanel = ({ X2 - - A - + onFormat('textEffect', effect)} /> + onFormat('foreColor', color)} /> + onFormat('hiliteColor', color)} /> ))}
diff --git a/src/components/Ribbon/RibbonControls.tsx b/src/components/Ribbon/RibbonControls.tsx index 976a770..4cd1990 100644 --- a/src/components/Ribbon/RibbonControls.tsx +++ b/src/components/Ribbon/RibbonControls.tsx @@ -1,7 +1,14 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { ChevronDown, TextLineSpacing } from '@carbon/icons-react'; -import { CITATION_STYLE_OPTIONS, LINE_SPACINGS } from './ribbonConfig'; +import { + CASE_OPTIONS, + CITATION_STYLE_OPTIONS, + FONT_COLOR_OPTIONS, + HIGHLIGHT_COLOR_OPTIONS, + LINE_SPACINGS, + TEXT_EFFECT_OPTIONS, +} from './ribbonConfig'; export const RibbonMobileContext = React.createContext(false); @@ -270,3 +277,291 @@ export const LineSpacingDropdown = ({ value, onChange }: { value: string; onChan
); }; + +export const ChangeCaseDropdown = ({ onChange }: { onChange: (mode: string) => void }) => { + const [open, setOpen] = React.useState(false); + const [menuPos, setMenuPos] = React.useState({ top: 0, left: 0 }); + const wrapperRef = React.useRef(null); + const menuRef = React.useRef(null); + const btnRef = React.useRef(null); + + React.useEffect(() => { + if (!open) return; + const handleOutsideClick = (e: MouseEvent) => { + const target = e.target as Node; + if (!wrapperRef.current?.contains(target) && !menuRef.current?.contains(target)) { + setOpen(false); + } + }; + document.addEventListener('mousedown', handleOutsideClick); + return () => document.removeEventListener('mousedown', handleOutsideClick); + }, [open]); + + const handleToggle = () => { + if (!open && btnRef.current) { + const rect = btnRef.current.getBoundingClientRect(); + setMenuPos({ top: rect.bottom, left: rect.left }); + } + setOpen((o) => !o); + }; + + return ( +
+ + {open && typeof document !== 'undefined' && ReactDOM.createPortal( +
    + {CASE_OPTIONS.map((option) => ( +
  • + +
  • + ))} +
, + document.body + )} +
+ ); +}; + +export const TextEffectsDropdown = ({ onChange }: { onChange: (effect: string) => void }) => { + const [open, setOpen] = React.useState(false); + const [menuPos, setMenuPos] = React.useState({ top: 0, left: 0 }); + const wrapperRef = React.useRef(null); + const menuRef = React.useRef(null); + const btnRef = React.useRef(null); + + React.useEffect(() => { + if (!open) return; + const handleOutsideClick = (e: MouseEvent) => { + const target = e.target as Node; + if (!wrapperRef.current?.contains(target) && !menuRef.current?.contains(target)) { + setOpen(false); + } + }; + document.addEventListener('mousedown', handleOutsideClick); + return () => document.removeEventListener('mousedown', handleOutsideClick); + }, [open]); + + const handleToggle = () => { + if (!open && btnRef.current) { + const rect = btnRef.current.getBoundingClientRect(); + setMenuPos({ top: rect.bottom, left: rect.left }); + } + setOpen((o) => !o); + }; + + return ( +
+ + {open && typeof document !== 'undefined' && ReactDOM.createPortal( +
    + {TEXT_EFFECT_OPTIONS.map((option) => ( +
  • + +
  • + ))} +
, + document.body + )} +
+ ); +}; + +const ColorSwatch = ({ color }: { color: string }) => ( +