diff --git a/frontend/src/components/ToolRail.tsx b/frontend/src/components/ToolRail.tsx index 3edae80..eb883cf 100644 --- a/frontend/src/components/ToolRail.tsx +++ b/frontend/src/components/ToolRail.tsx @@ -122,7 +122,7 @@ const RailButton: React.FC<{ t: ToolDef; active: boolean; disabled?: boolean; is aria-disabled={disabled} onClick={onClick} className={`relative flex h-[52px] w-[72px] shrink-0 flex-col items-center justify-center gap-[3px] rounded-[10px] transition-colors ${disabled - ? 'cursor-not-allowed text-[#c5cad1]' + ? 'cursor-not-allowed text-text-tertiary opacity-40 dark:text-zinc-600 dark:opacity-60' : active ? t.danger ? 'bg-[#fdecec] text-[#dc2626]' : 'bg-brand-secondary text-brand-primary' : t.danger ? 'text-text-secondary hover:bg-[#fdecec] hover:text-[#dc2626]' diff --git a/frontend/src/features/document-creator/components/CreatePDFModal.tsx b/frontend/src/features/document-creator/components/CreatePDFModal.tsx index 043d369..596166d 100644 --- a/frontend/src/features/document-creator/components/CreatePDFModal.tsx +++ b/frontend/src/features/document-creator/components/CreatePDFModal.tsx @@ -60,25 +60,7 @@ const INITIAL_DOC: DocumentModel = { enabled: true, showPageNumbers: true, }, - blocks: [ - { - id: 'blk-1', - type: 'paragraph', - styleName: 'Normal', - runs: [ - { - id: 'run-1', - text: '', - fontFamily: 'Helvetica', - fontSize: 12, - color: '#0f172a', - }, - ], - alignment: 'left', - spaceBefore: 0, - spaceAfter: 6, - }, - ], + blocks: [], }; export const CreatePDFModal: React.FC = ({ @@ -97,8 +79,8 @@ export const CreatePDFModal: React.FC = ({ }) => { const historyRef = useRef(new HistoryManager(INITIAL_DOC)); const [doc, setDoc] = useState(INITIAL_DOC); - const [selectedBlockId, setSelectedBlockId] = useState('blk-1'); - const [activeRunId, setActiveRunId] = useState('run-1'); + const [selectedBlockId, setSelectedBlockId] = useState(null); + const [activeRunId, setActiveRunId] = useState(null); const [zoomScale, setZoomScale] = useState(1.0); const [isGenerating, setIsGenerating] = useState(false); @@ -117,37 +99,12 @@ export const CreatePDFModal: React.FC = ({ const freshDoc: DocumentModel = { ...INITIAL_DOC, id: `doc-creator-${Date.now()}`, - blocks: [ - { - id: 'blk-1', - type: 'paragraph', - styleName: 'Normal', - runs: [ - { - id: 'run-1', - text: '', - fontFamily: 'Helvetica', - fontSize: 12, - color: '#0f172a', - }, - ], - alignment: 'left', - spaceBefore: 0, - spaceAfter: 6, - }, - ], + blocks: [], }; historyRef.current = new HistoryManager(freshDoc); setDoc(freshDoc); - setSelectedBlockId('blk-1'); - setActiveRunId('run-1'); - - setTimeout(() => { - const el = document.querySelector('[data-block-id="blk-1"]'); - if (el) { - el.focus(); - } - }, 50); + setSelectedBlockId(null); + setActiveRunId(null); } }, [isOpen]); @@ -331,7 +288,7 @@ export const CreatePDFModal: React.FC = ({ ], alignment: 'left', spaceBefore: 0, - spaceAfter: 6, + spaceAfter: 2, }; let nextBlocks = [...doc.blocks]; @@ -725,6 +682,7 @@ export const CreatePDFModal: React.FC = ({ doc={doc} selectedBlockId={selectedBlockId} activeRun={activeRun} + activeParagraph={activeParagraph} activeTool={activeTool} drawColor={drawColor} drawWidth={drawWidth} diff --git a/frontend/src/features/document-creator/components/DocumentEditor.tsx b/frontend/src/features/document-creator/components/DocumentEditor.tsx index 0695b46..c3b87bb 100644 --- a/frontend/src/features/document-creator/components/DocumentEditor.tsx +++ b/frontend/src/features/document-creator/components/DocumentEditor.tsx @@ -109,13 +109,11 @@ const EditableParagraphBlock: React.FC<{ return (
onSelectBlock(block.id, firstRun.id)} - className={`relative rounded px-2.5 py-1.5 transition-all ${ - isSelected ? 'bg-blue-50/40 ring-1 ring-brand-primary/50' : 'hover:bg-slate-50' - }`} + className="relative py-0.5" style={{ textAlign: block.alignment || 'left', marginTop: `${block.spaceBefore || 0}px`, - marginBottom: `${block.spaceAfter || 4}px`, + marginBottom: `${block.spaceAfter !== undefined ? block.spaceAfter : 2}px`, }} >
onUpdateParagraphText(block.id, e.currentTarget.innerText)} onBlur={(e) => onUpdateParagraphText(block.id, e.currentTarget.innerText)} onKeyDown={handleKeyDown} - className="outline-none min-h-[1.5em] empty:before:content-[attr(data-placeholder)] empty:before:text-slate-300 empty:before:pointer-events-none" - data-placeholder="Start typing paragraph text..." + className="outline-none min-h-[1.5em]" + data-placeholder="" style={{ fontFamily: fontStack, fontSize: `${firstRun.fontSize || 12}pt`, @@ -140,26 +138,13 @@ const EditableParagraphBlock: React.FC<{ ].filter(Boolean).join(' ') || 'none', color: firstRun.color || '#0f172a', backgroundColor: firstRun.highlightColor || 'transparent', - lineHeight: block.lineSpacing || 1.25, + lineHeight: block.lineSpacing ? (block.lineSpacing >= 1.0 ? block.lineSpacing : 1.15 + block.lineSpacing) : 1.15, + letterSpacing: firstRun.letterSpacing !== undefined ? `${firstRun.letterSpacing}px` : (block.letterSpacing !== undefined ? `${block.letterSpacing}px` : 'normal'), wordBreak: 'break-word', overflowWrap: 'break-word', whiteSpace: 'pre-wrap', }} /> - - {isSelected && ( - - )}
); }; @@ -237,13 +222,12 @@ const EditableImageBlock: React.FC<{ {/* Floating Quick Action Toolbar */} {isSelected && (
- - {/* Striped Rows Toggle */} - - -
- - {/* Theme Preset Selector */} - Theme: - - -
- - {/* Row / Col Manipulations */} - - - - - - {activeCell && ( - <> -
- - {/* Cell Alignment */} - Cell Align: - - - - - - - - )} - -
- - Fill: - {colors.map((c) => ( - -
-
- )} - - - - {block.rows.map((row, rIdx) => { - const isHeaderRow = (block.hasHeaderRow && rIdx === 0) || row.isHeader; - const isStriped = block.stripedRows && rIdx % 2 === 1; + {/* Advanced Floating Table Action Bar */} + {isSelected && ( +
+ Table: - return ( -
{ + e.stopPropagation(); + onUpdateTableBlock?.(block.id, { hasHeaderRow: !block.hasHeaderRow }); + }} + className={`rounded px-2 py-0.5 border ${block.hasHeaderRow ? 'bg-brand-primary border-brand-primary font-semibold' : 'bg-slate-800 border-slate-700 hover:bg-slate-700' + }`} + > + Header Row + + + {/* Striped Rows Toggle */} + + +
+ + {/* Theme Preset Selector */} + Theme: + + +
+ + {/* Row / Col Manipulations */} + + + + + + {activeCell && ( + <> +
+ + {/* Cell Alignment */} + Cell Align: + + + + + + + + )} + +
+ + Fill: + {colors.map((c) => ( + +
+
+ )} - const cellBg = - cell.backgroundColor || - (isHeaderRow +
+ + {block.rows.map((row, rIdx) => { + const isHeaderRow = (block.hasHeaderRow && rIdx === 0) || row.isHeader; + const isStriped = block.stripedRows && rIdx % 2 === 1; + + return ( + + {row.cells.map((cell, cIdx) => { + const cellText = cell.runs.map((r) => r.text).join(''); + const isCellFocused = activeCell?.r === rIdx && activeCell?.c === cIdx; - const cellTextColor = isHeaderRow && themeName === 'modern' ? '#ffffff' : '#0f172a'; + const cellBg = + cell.backgroundColor || + (isHeaderRow + ? themeName === 'modern' + ? '#0f172a' + : '#f1f5f9' + : isStriped + ? '#f8fafc' + : 'transparent'); - return ( - - ); - })} - - ); - })} - -
setActiveCell({ r: rIdx, c: cIdx })} - className={`p-2.5 text-[12px] relative transition-colors ${ - themeName === 'minimal' - ? 'border-b border-slate-200' - : themeName === 'bordered' - ? 'border border-slate-400' - : 'border border-slate-300' - } ${isCellFocused ? 'ring-2 ring-brand-primary z-10' : ''}`} - style={{ backgroundColor: cellBg, color: cellTextColor }} - > - onUpdateTableCell?.(block.id, rIdx, cIdx, text)} - onFocus={() => setActiveCell({ r: rIdx, c: cIdx })} - /> -
+ const cellTextColor = isHeaderRow && themeName === 'modern' ? '#ffffff' : '#0f172a'; + + return ( + setActiveCell({ r: rIdx, c: cIdx })} + className={`p-2.5 text-[12px] relative transition-colors ${themeName === 'minimal' + ? 'border-b border-slate-200' + : themeName === 'bordered' + ? 'border border-slate-400' + : 'border border-slate-300' + } ${isCellFocused ? 'ring-2 ring-brand-primary z-10' : ''}`} + style={{ backgroundColor: cellBg, color: cellTextColor }} + > + onUpdateTableCell?.(block.id, rIdx, cIdx, text)} + onFocus={() => setActiveCell({ r: rIdx, c: cIdx })} + /> + + ); + })} + + ); + })} + + +
-
- ); -}; + ); + }; export const DocumentEditor: React.FC = ({ doc, @@ -811,9 +789,8 @@ export const DocumentEditor: React.FC = ({ key={`page-${page.pageNumber}`} onMouseDown={(e) => handleMouseDownPage(e, pageIdx)} onMouseMove={(e) => handleMouseMovePage(e, pageIdx)} - className={`shrink-0 relative bg-white shadow-xl transition-shadow border border-slate-200 flex flex-col overflow-hidden ${ - isDrawTool ? 'cursor-crosshair' : '' - }`} + className={`shrink-0 relative bg-white shadow-xl transition-shadow border border-slate-200 flex flex-col overflow-hidden ${isDrawTool ? 'cursor-crosshair' : '' + }`} style={{ width: `${pageW}px`, height: `${pageH}px`, diff --git a/frontend/src/features/document-creator/components/DocumentToolbar.tsx b/frontend/src/features/document-creator/components/DocumentToolbar.tsx index bb0f3fa..36e205c 100644 --- a/frontend/src/features/document-creator/components/DocumentToolbar.tsx +++ b/frontend/src/features/document-creator/components/DocumentToolbar.tsx @@ -8,6 +8,7 @@ interface DocumentToolbarProps { doc: DocumentModel; selectedBlockId: string | null; activeRun: TextRun | null; + activeParagraph?: ParagraphBlock | null; activeTool?: string; drawColor?: string; drawWidth?: number; @@ -38,6 +39,7 @@ const FONT_SIZES = [8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 32, 36, 48, 64, 72 export const DocumentToolbar: React.FC = ({ selectedBlockId, activeRun, + activeParagraph, activeTool = 'select', drawColor = '#2563eb', drawWidth = 3, @@ -86,9 +88,8 @@ export const DocumentToolbar: React.FC = ({ key={c} type="button" onClick={() => onUpdateDrawColor?.(c)} - className={`h-5 w-5 rounded-full border border-white shadow-2xs transition-transform ${ - drawColor === c ? 'scale-125 ring-2 ring-blue-600' : 'hover:scale-110' - }`} + className={`h-5 w-5 rounded-full border border-white shadow-2xs transition-transform ${drawColor === c ? 'scale-125 ring-2 ring-blue-600' : 'hover:scale-110' + }`} style={{ backgroundColor: c }} /> ))} @@ -135,9 +136,8 @@ export const DocumentToolbar: React.FC = ({ onSelectStamp?.(s.label); onInsertStamp?.(s.label); }} - className={`shrink-0 rounded-[6px] border px-2.5 py-1 text-[10.5px] font-bold tracking-wide transition-all hover:scale-105 active:scale-95 shadow-2xs cursor-pointer ${ - activeStamp === s.label ? 'ring-2 ring-emerald-600 ring-offset-1' : '' - }`} + className={`shrink-0 rounded-[6px] border px-2.5 py-1 text-[10.5px] font-bold tracking-wide transition-all hover:scale-105 active:scale-95 shadow-2xs cursor-pointer ${activeStamp === s.label ? 'ring-2 ring-emerald-600 ring-offset-1' : '' + }`} style={{ color: s.textColor, borderColor: s.borderColor, background: s.backgroundColor }} title={`Click to insert ${s.label} stamp`} > @@ -205,14 +205,56 @@ export const DocumentToolbar: React.FC = ({
+ {/* Line Gap / Line Spacing */} +
+ ↕ Line Gap: + +
+ + {/* Letter Space / Letter Spacing */} +
+ ↔ Letter Space: + +
+ +
+ {/* Bold / Italic / Underline / Strikethrough */} @@ -221,9 +263,8 @@ export const DocumentToolbar: React.FC = ({ type="button" title="Italic (Ctrl+I)" onClick={() => onUpdateRunFormatting({ italic: !activeRun?.italic })} - className={`flex h-8 w-8 items-center justify-center rounded-md italic font-serif transition-colors ${ - activeRun?.italic ? 'bg-brand-primary text-white' : 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary' - }`} + className={`flex h-8 w-8 items-center justify-center rounded-md italic font-serif transition-colors ${activeRun?.italic ? 'bg-brand-primary text-white' : 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary' + }`} > I @@ -232,9 +273,8 @@ export const DocumentToolbar: React.FC = ({ type="button" title="Underline (Ctrl+U)" onClick={() => onUpdateRunFormatting({ underline: !activeRun?.underline })} - className={`flex h-8 w-8 items-center justify-center rounded-md underline transition-colors ${ - activeRun?.underline ? 'bg-brand-primary text-white' : 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary' - }`} + className={`flex h-8 w-8 items-center justify-center rounded-md underline transition-colors ${activeRun?.underline ? 'bg-brand-primary text-white' : 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary' + }`} > U @@ -243,9 +283,8 @@ export const DocumentToolbar: React.FC = ({ type="button" title="Strikethrough" onClick={() => onUpdateRunFormatting({ strikethrough: !activeRun?.strikethrough })} - className={`flex h-8 w-8 items-center justify-center rounded-md line-through transition-colors ${ - activeRun?.strikethrough ? 'bg-brand-primary text-white' : 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary' - }`} + className={`flex h-8 w-8 items-center justify-center rounded-md line-through transition-colors ${activeRun?.strikethrough ? 'bg-brand-primary text-white' : 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary' + }`} > S diff --git a/frontend/src/features/document-creator/model/PaginationEngine.ts b/frontend/src/features/document-creator/model/PaginationEngine.ts index 67818d4..ede53f7 100644 --- a/frontend/src/features/document-creator/model/PaginationEngine.ts +++ b/frontend/src/features/document-creator/model/PaginationEngine.ts @@ -73,7 +73,8 @@ export class PaginationEngine { const fontSizePt = block.runs[0]?.fontSize || 12; const fontSizePx = fontSizePt * PT_TO_PX; const lineSpacing = block.lineSpacing || 1.25; - const avgCharWidthPx = fontSizePx * 0.52; + const letterSpacingPx = ((block.runs[0]?.letterSpacing !== undefined ? block.runs[0].letterSpacing : block.letterSpacing) || 0) * PT_TO_PX; + const avgCharWidthPx = fontSizePx * 0.52 + letterSpacingPx; const charsPerLine = Math.max(1, Math.floor(usableWidthPx / avgCharWidthPx)); const paragraphs = fullText.split('\n'); @@ -88,7 +89,7 @@ export class PaginationEngine { const paddingPx = 4; const spaceBeforePx = (block.spaceBefore || 0) * PT_TO_PX; - const spaceAfterPx = (block.spaceAfter || 4) * PT_TO_PX; + const spaceAfterPx = (block.spaceAfter !== undefined ? block.spaceAfter : 2) * PT_TO_PX; const flexGapPx = 4; const contentHeight = totalLines * (fontSizePx * lineSpacing) + paddingPx + spaceBeforePx + spaceAfterPx + flexGapPx; diff --git a/frontend/src/features/document-creator/renderer/PdfDocumentRenderer.ts b/frontend/src/features/document-creator/renderer/PdfDocumentRenderer.ts index 222b1bd..15565c4 100644 --- a/frontend/src/features/document-creator/renderer/PdfDocumentRenderer.ts +++ b/frontend/src/features/document-creator/renderer/PdfDocumentRenderer.ts @@ -127,7 +127,12 @@ export class PdfDocumentRenderer { indentOffset = 18; } - const lineSpacing = pBlock.lineSpacing || 1.15; + const rawLineSpacing = pBlock.lineSpacing || 0; + const lineSpacing = rawLineSpacing >= 1.0 ? rawLineSpacing : 1.15 + rawLineSpacing; + const letterSpacing = (pBlock.runs[0]?.letterSpacing !== undefined ? pBlock.runs[0].letterSpacing : pBlock.letterSpacing) || 0; + if (typeof (pdf as any).setCharSpace === 'function') { + (pdf as any).setCharSpace(letterSpacing * 0.75); + } const lines = pdf.splitTextToSize(textToRender, usableWidth - indentOffset); const blockHeight = lines.length * (fontSize * lineSpacing); @@ -150,6 +155,9 @@ export class PdfDocumentRenderer { pdf.text(line, xPos, currentY + fontSize, { align: alignOption }); currentY += fontSize * lineSpacing; }); + if (typeof (pdf as any).setCharSpace === 'function') { + (pdf as any).setCharSpace(0); + } // Hyperlinks handling pBlock.runs.forEach((run) => { diff --git a/frontend/src/features/document-creator/types/documentModel.ts b/frontend/src/features/document-creator/types/documentModel.ts index 6d4fe35..7fea089 100644 --- a/frontend/src/features/document-creator/types/documentModel.ts +++ b/frontend/src/features/document-creator/types/documentModel.ts @@ -10,6 +10,7 @@ export interface TextRun { color?: string; highlightColor?: string; linkUrl?: string; + letterSpacing?: number; } export type BlockType = 'paragraph' | 'heading' | 'list-item' | 'table' | 'image' | 'quote' | 'page-break'; @@ -21,7 +22,8 @@ export interface ParagraphBlock { headingLevel?: 1 | 2 | 3 | 4; runs: TextRun[]; alignment?: 'left' | 'center' | 'right' | 'justify'; - lineSpacing?: number; // 1.0, 1.15, 1.5, 2.0 + lineSpacing?: number; // 1.0, 1.15, 1.25, 1.5, 2.0, 2.5, 3.0 + letterSpacing?: number; // -1, 0, 0.5, 1.0, 1.5, 2.0, 3.0, 5.0 spaceBefore?: number; spaceAfter?: number; indentLeft?: number; @@ -125,7 +127,7 @@ export const DEFAULT_STYLES: Record = { color: '#0f172a', alignment: 'left', spaceBefore: 0, - spaceAfter: 6, + spaceAfter: 2, }, Title: { name: 'Title',