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
751 changes: 6 additions & 745 deletions src/app/globals.scss

Large diffs are not rendered by default.

322 changes: 241 additions & 81 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
'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';
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' },
Expand Down Expand Up @@ -85,15 +88,222 @@ 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.
const handleFormat = useCallback((command: string, value?: string) => {
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<HTMLElement>();
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'));
Expand All @@ -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) => {
Expand Down Expand Up @@ -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<HTMLElement>(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
Expand Down Expand Up @@ -421,76 +642,22 @@ export default function WordProcessor() {

return (
<div className="word-processor">
{/* Title Bar */}
<div className="word-title-bar">
<div className="word-title-bar__quick-access">
<Button
kind="ghost"
size="sm"
hasIconOnly
renderIcon={Save}
iconDescription="Save"
tooltipPosition="bottom"
onClick={handleSave}
/>
<Button
kind="ghost"
size="sm"
hasIconOnly
renderIcon={Undo}
iconDescription="Undo"
tooltipPosition="bottom"
onClick={() => handleFormat('undo')}
/>
<Button
kind="ghost"
size="sm"
hasIconOnly
renderIcon={Redo}
iconDescription="Redo"
tooltipPosition="bottom"
onClick={() => handleFormat('redo')}
/>
</div>

<div className="word-title-bar__document-title">
{documentName} — Carbon Type
</div>

<div className="word-title-bar__right">
{autosaveEnabled && autosaveStatus && (
<span className="word-title-bar__autosave-status">
{autosaveStatus === 'saving' ? 'Saving\u2026' : 'Autosaved'}
</span>
)}
<span className="word-title-bar__autosave-toggle">
<span className="word-title-bar__autosave-toggle-label">Autosave</span>
<Toggle
id="autosave-toggle"
size="sm"
labelText="Autosave"
hideLabel
toggled={autosaveEnabled}
onToggle={(checked: boolean) => {
setAutosaveEnabled(checked);
if (!checked) {
if (autosaveClearTimerRef.current) clearTimeout(autosaveClearTimerRef.current);
setAutosaveStatus('');
}
}}
/>
</span>
<Button
kind="ghost"
size="sm"
hasIconOnly
renderIcon={Printer}
iconDescription="Print"
tooltipPosition="bottom"
onClick={handlePrint}
/>
</div>
</div>
<WordTitleBar
documentName={documentName}
autosaveEnabled={autosaveEnabled}
autosaveStatus={autosaveStatus}
onAutosaveToggle={(checked: boolean) => {
setAutosaveEnabled(checked);
if (!checked) {
if (autosaveClearTimerRef.current) clearTimeout(autosaveClearTimerRef.current);
setAutosaveStatus('');
}
}}
onSave={handleSave}
onUndo={() => handleFormat('undo')}
onRedo={() => handleFormat('redo')}
onPrint={handlePrint}
/>

{/* Ribbon */}
<Ribbon
Expand Down Expand Up @@ -538,14 +705,7 @@ export default function WordProcessor() {
</div>
</div>

{/* Status Bar */}
<div className="word-status-bar">
<span>Page 1 of 1</span>
<span className="word-status-bar__divider">|</span>
<span>Words: {wordCount}</span>
<span className="word-status-bar__divider">|</span>
<span>Zoom: {zoom}%</span>
</div>
<WordStatusBar wordCount={wordCount} zoom={zoom} />
</div>
);
}
17 changes: 17 additions & 0 deletions src/app/styles/_base.scss
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading