'use client' import {useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; import {EditorContent} from '@tiptap/react'; import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; import { faAlignCenter, faAlignLeft, faAlignRight, faBold, faCog, faFloppyDisk, faGhost, faHeading, faLayerGroup, faListOl, faListUl, faParagraph, faUnderline } from '@fortawesome/free-solid-svg-icons'; import {EditorContext} from "@/context/EditorContext"; import {ChapterContext} from '@/context/ChapterContext'; import System from '@/lib/models/System'; import {AlertContext} from '@/context/AlertContext'; import {SessionContext} from "@/context/SessionContext"; import DraftCompanion from "@/components/editor/DraftCompanion"; import GhostWriter from "@/components/ghostwriter/GhostWriter"; import SubmitButtonWLoading from "@/components/form/SubmitButtonWLoading"; import CollapsableButton from "@/components/CollapsableButton"; import {IconDefinition} from "@fortawesome/fontawesome-svg-core"; import UserEditorSettings, {EditorDisplaySettings} from "@/components/editor/UserEditorSetting"; import {useTranslations} from "next-intl"; import {LangContext, LangContextProps} from "@/context/LangContext"; import OfflineContext, {OfflineContextType} from "@/context/OfflineContext"; interface ToolbarButton { action: () => void; icon: IconDefinition; isActive: boolean; label?: string; } interface EditorClasses { base: string; h1: string; h2: string; h3: string; container: string; theme: string; paragraph: string; lists: string; listItems: string; } const DEFAULT_EDITOR_SETTINGS: EditorDisplaySettings = { zoomLevel: 3, indent: 30, lineHeight: 1.5, theme: 'sombre', fontFamily: 'lora', maxWidth: 768, focusMode: false }; const FONT_SIZE_CLASSES = { 1: 'text-sm', 2: 'text-base', 3: 'text-lg', 4: 'text-xl', 5: 'text-2xl' } as const; const H1_SIZE_CLASSES = { 1: 'text-xl', 2: 'text-2xl', 3: 'text-3xl', 4: 'text-4xl', 5: 'text-5xl' } as const; const H2_SIZE_CLASSES = { 1: 'text-lg', 2: 'text-xl', 3: 'text-2xl', 4: 'text-3xl', 5: 'text-4xl' } as const; const H3_SIZE_CLASSES = { 1: 'text-base', 2: 'text-lg', 3: 'text-xl', 4: 'text-2xl', 5: 'text-3xl' } as const; const FONT_FAMILY_CLASSES = { 'lora': 'Lora', 'serif': 'font-serif', 'sans-serif': 'font-sans', 'monospace': 'font-mono' } as const; const LINE_HEIGHT_CLASSES = { 1.2: 'leading-tight', 1.5: 'leading-normal', 1.75: 'leading-relaxed', 2: 'leading-loose' } as const; const MAX_WIDTH_CLASSES = { 600: 'max-w-xl', 650: 'max-w-2xl', 700: 'max-w-3xl', 750: 'max-w-4xl', 800: 'max-w-5xl', 850: 'max-w-6xl', 900: 'max-w-7xl', 950: 'max-w-full', 1000: 'max-w-full', 1050: 'max-w-full', 1100: 'max-w-full', 1150: 'max-w-full', 1200: 'max-w-full' } as const; function getClosestKey>(value: number, obj: T): keyof T { const keys: number[] = Object.keys(obj).map(Number).sort((a: number, b: number): number => a - b); return keys.reduce((prev: number, curr: number): number => Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev ); } export default function TextEditor() { const t = useTranslations(); const {lang} = useContext(LangContext) const {editor} = useContext(EditorContext); const {chapter} = useContext(ChapterContext); const {errorMessage, successMessage} = useContext(AlertContext); const {session} = useContext(SessionContext); const {isCurrentlyOffline} = useContext(OfflineContext); const [mainTimer, setMainTimer] = useState(0); const [showDraftCompanion, setShowDraftCompanion] = useState(false); const [showGhostWriter, setShowGhostWriter] = useState(false); const [showUserSettings, setShowUserSettings] = useState(false); const [isSaving, setIsSaving] = useState(false); const [editorSettings, setEditorSettings] = useState(DEFAULT_EDITOR_SETTINGS); const [editorClasses, setEditorClasses] = useState({ base: 'text-lg font-serif leading-normal', h1: 'text-3xl font-bold', h2: 'text-2xl font-bold', h3: 'text-xl font-bold', container: 'max-w-3xl', theme: 'bg-tertiary/90 backdrop-blur-sm bg-opacity-25 text-text-primary', paragraph: 'indent-6', lists: 'pl-10', listItems: 'text-lg' }); const timerRef: React.RefObject = useRef(null); const timeoutRef: React.RefObject = useRef(null); const updateEditorClasses: (settings: EditorDisplaySettings) => void = useCallback((settings: EditorDisplaySettings): void => { const fontSizeKey = settings.zoomLevel as keyof typeof FONT_SIZE_CLASSES; const h1SizeKey = settings.zoomLevel as keyof typeof H1_SIZE_CLASSES; const h2SizeKey = settings.zoomLevel as keyof typeof H2_SIZE_CLASSES; const h3SizeKey = settings.zoomLevel as keyof typeof H3_SIZE_CLASSES; const fontFamilyKey = settings.fontFamily as keyof typeof FONT_FAMILY_CLASSES; const lineHeightKey = settings.lineHeight as keyof typeof LINE_HEIGHT_CLASSES; const maxWidthKey: number = getClosestKey(settings.maxWidth, MAX_WIDTH_CLASSES); const indentClass = `indent-${Math.round(settings.indent / 4)}`; const baseClass = `${FONT_SIZE_CLASSES[fontSizeKey]} ${FONT_FAMILY_CLASSES[fontFamilyKey]} ${LINE_HEIGHT_CLASSES[lineHeightKey]}`; const h1Class = `${H1_SIZE_CLASSES[h1SizeKey]} font-bold ${FONT_FAMILY_CLASSES[fontFamilyKey]} ${LINE_HEIGHT_CLASSES[lineHeightKey]}`; const h2Class = `${H2_SIZE_CLASSES[h2SizeKey]} font-bold ${FONT_FAMILY_CLASSES[fontFamilyKey]} ${LINE_HEIGHT_CLASSES[lineHeightKey]}`; const h3Class = `${H3_SIZE_CLASSES[h3SizeKey]} font-bold ${FONT_FAMILY_CLASSES[fontFamilyKey]} ${LINE_HEIGHT_CLASSES[lineHeightKey]}`; const containerClass = MAX_WIDTH_CLASSES[maxWidthKey as keyof typeof MAX_WIDTH_CLASSES]; const listsClass = `pl-${Math.round((settings.indent + 20) / 4)}`; let themeClass: string = ''; switch (settings.theme) { case 'clair': themeClass = 'bg-white text-black'; break; case 'sépia': themeClass = 'text-amber-900'; break; default: themeClass = 'bg-tertiary/90 backdrop-blur-sm bg-opacity-25 text-text-primary'; } setEditorClasses({ base: baseClass, h1: h1Class, h2: h2Class, h3: h3Class, container: containerClass, theme: themeClass, paragraph: indentClass, lists: listsClass, listItems: baseClass }); }, []); const containerStyle = useMemo(() => { if (editorSettings.theme === 'sépia') { return {backgroundColor: '#f4f1e8'}; } return {}; }, [editorSettings.theme]); const toolbarButtons: ToolbarButton[] = (() => { if (!editor) return []; return [ { action: (): boolean => editor.chain().focus().setParagraph().run(), icon: faParagraph, isActive: editor.isActive('paragraph') }, { action: (): boolean => editor.chain().focus().toggleBold().run(), icon: faBold, isActive: editor.isActive('bold') }, { action: (): boolean => editor.chain().focus().toggleUnderline().run(), icon: faUnderline, isActive: editor.isActive('underline') }, { action: (): boolean => editor.chain().focus().setTextAlign('left').run(), icon: faAlignLeft, isActive: editor.isActive({textAlign: 'left'}) }, { action: (): boolean => editor.chain().focus().setTextAlign('center').run(), icon: faAlignCenter, isActive: editor.isActive({textAlign: 'center'}) }, { action: (): boolean => editor.chain().focus().setTextAlign('right').run(), icon: faAlignRight, isActive: editor.isActive({textAlign: 'right'}) }, { action: (): boolean => editor.chain().focus().toggleBulletList().run(), icon: faListUl, isActive: editor.isActive('bulletList') }, { action: (): boolean => editor.chain().focus().toggleOrderedList().run(), icon: faListOl, isActive: editor.isActive('orderedList') }, { action: (): boolean => editor.chain().focus().toggleHeading({level: 1}).run(), icon: faHeading, isActive: editor.isActive('heading', {level: 1}), label: '1' }, { action: (): boolean => editor.chain().focus().toggleHeading({level: 2}).run(), icon: faHeading, isActive: editor.isActive('heading', {level: 2}), label: '2' }, { action: (): boolean => editor.chain().focus().toggleHeading({level: 3}).run(), icon: faHeading, isActive: editor.isActive('heading', {level: 3}), label: '3' }, ]; })(); const saveContent: () => Promise = useCallback(async (): Promise => { if (!editor || !chapter) return; setIsSaving(true); const content = editor.state.doc.toJSON(); const chapterId: string = chapter.chapterId || ''; const version: number = chapter.chapterContent.version || 0; try { let response: boolean; if (isCurrentlyOffline()){ response = await window.electron.invoke('db:chapter:content:save',{ chapterId, version, content, totalWordCount: editor.getText().length, currentTime: mainTimer }) } else { response = await System.authPostToServer(`chapter/content`, { chapterId, version, content, totalWordCount: editor.getText().length, currentTime: mainTimer }, session?.accessToken, lang); } if (!response) { errorMessage(t('editor.error.savedFailed')); setIsSaving(false); return; } setMainTimer(0); successMessage(t('editor.success.saved')); setIsSaving(false); } catch (e: unknown) { if (e instanceof Error) { errorMessage(e.message); } else { errorMessage(t('editor.error.unknownError')); } setIsSaving(false); } }, [editor, chapter, mainTimer, session?.accessToken, successMessage, errorMessage]); const handleShowDraftCompanion: () => void = useCallback((): void => { setShowDraftCompanion((prev: boolean): boolean => !prev); setShowGhostWriter(false); setShowUserSettings(false); }, []); const handleShowGhostWriter: () => void = useCallback((): void => { if (chapter?.chapterContent.version === 2) { setShowGhostWriter((prev: boolean): boolean => !prev); setShowDraftCompanion(false); setShowUserSettings(false); } }, [chapter?.chapterContent.version]); const handleShowUserSettings: () => void = useCallback((): void => { setShowUserSettings((prev: boolean): boolean => !prev); setShowDraftCompanion(false); setShowGhostWriter(false); }, []); useEffect((): void => { if (!editor) return; const editorElement: HTMLElement = editor.view.dom; if (editorElement) { const indentClasses: string[] = Array.from({length: 21}, (_, i) => `indent-${i}`); editorElement.classList.remove(...indentClasses); if (editorClasses.paragraph) { editorElement.classList.add(editorClasses.paragraph); } } }, [editor, editorClasses.paragraph]); useEffect((): void => { updateEditorClasses(editorSettings); }, [editorSettings, updateEditorClasses]); useEffect((): () => void => { function startTimer(): void { if (timerRef.current === null) { timerRef.current = window.setInterval(() => { setMainTimer(prevTimer => prevTimer + 1); }, 1000); } } function stopTimer(): void { if (timerRef.current !== null) { clearInterval(timerRef.current); timerRef.current = null; } } function resetTimeout(): void { if (timeoutRef.current !== null) { clearTimeout(timeoutRef.current); } timeoutRef.current = window.setTimeout(stopTimer, 5000); } function handleKeyDown(): void { startTimer(); resetTimeout(); } window.addEventListener('keydown', handleKeyDown, {passive: true}); return (): void => { window.removeEventListener('keydown', handleKeyDown); if (timerRef.current !== null) { clearInterval(timerRef.current); } if (timeoutRef.current !== null) { clearTimeout(timeoutRef.current); } }; }, []); useEffect((): () => void => { document.addEventListener('keydown', handleKeyDown, {passive: false}); return (): void => document.removeEventListener('keydown', handleKeyDown); }, [saveContent]); useEffect((): void => { if (!editor) return; if (chapter?.chapterContent.content) { try { const parsedContent = JSON.parse(chapter.chapterContent.content); editor.commands.setContent(parsedContent); } catch (error) { editor.commands.setContent({ type: "doc", content: [{type: "paragraph", content: []}] }); } } else { editor.commands.setContent({ type: "doc", content: [{type: "paragraph", content: []}] }); } if (chapter?.chapterContent.version !== 2) { setShowGhostWriter(false); } }, [editor, chapter?.chapterContent.content, chapter?.chapterContent.version]); async function handleKeyDown(event: KeyboardEvent): Promise { if ((event.ctrlKey || event.metaKey) && event.key === 's') { event.preventDefault(); await saveContent(); } } if (!editor) { return null; } return (
{toolbarButtons.map((button: ToolbarButton, index: number) => ( ))}
{chapter?.chapterContent.version === 2 && !isCurrentlyOffline() && ( )} {chapter?.chapterContent.version && chapter.chapterContent.version > 2 && ( )}
{(showDraftCompanion || showGhostWriter || showUserSettings) && (
{showDraftCompanion && } {showGhostWriter && } {showUserSettings && ( )}
)}
); }