Add components for Act management and integrate Electron setup
This commit is contained in:
516
components/editor/TextEditor.tsx
Normal file
516
components/editor/TextEditor.tsx
Normal file
@@ -0,0 +1,516 @@
|
||||
'use client'
|
||||
import React, {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";
|
||||
|
||||
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<T extends Record<number, any>>(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<LangContextProps>(LangContext)
|
||||
const {editor} = useContext(EditorContext);
|
||||
const {chapter} = useContext(ChapterContext);
|
||||
const {errorMessage, successMessage} = useContext(AlertContext);
|
||||
const {session} = useContext(SessionContext);
|
||||
|
||||
const [mainTimer, setMainTimer] = useState<number>(0);
|
||||
const [showDraftCompanion, setShowDraftCompanion] = useState<boolean>(false);
|
||||
const [showGhostWriter, setShowGhostWriter] = useState<boolean>(false);
|
||||
const [showUserSettings, setShowUserSettings] = useState<boolean>(false);
|
||||
const [isSaving, setIsSaving] = useState<boolean>(false);
|
||||
const [editorSettings, setEditorSettings] = useState<EditorDisplaySettings>(DEFAULT_EDITOR_SETTINGS);
|
||||
const [editorClasses, setEditorClasses] = useState<EditorClasses>({
|
||||
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<number | null> = useRef<number | null>(null);
|
||||
const timeoutRef: React.RefObject<number | null> = useRef<number | null>(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<void> = useCallback(async (): Promise<void> => {
|
||||
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 {
|
||||
const response: boolean = await System.authPostToServer<boolean>(`chapter/content`, {
|
||||
chapterId,
|
||||
version,
|
||||
content,
|
||||
totalWordCount: editor.getText().length,
|
||||
currentTime: mainTimer
|
||||
}, session?.accessToken ?? '');
|
||||
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) {
|
||||
console.error('Erreur lors du parsing du contenu:', 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<void> {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
|
||||
event.preventDefault();
|
||||
await saveContent();
|
||||
}
|
||||
}
|
||||
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 w-full h-full">
|
||||
<div
|
||||
className={`flex justify-between gap-3 border-b border-secondary/30 px-4 py-3 bg-gradient-to-b from-dark-background/80 to-dark-background/50 backdrop-blur-sm transition-opacity duration-300 shadow-md ${editorSettings.focusMode ? 'opacity-70 hover:opacity-100' : ''}`}>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{toolbarButtons.map((button: ToolbarButton, index: number) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={button.action}
|
||||
className={`group flex items-center px-3 py-2 rounded-lg transition-all duration-200 ${button.isActive ? 'bg-primary text-text-primary shadow-md shadow-primary/30 scale-105' : 'text-muted hover:text-text-primary hover:bg-secondary/50 hover:shadow-sm hover:scale-105'}`}
|
||||
>
|
||||
<FontAwesomeIcon icon={button.icon}
|
||||
className={'w-4 h-4 transition-transform duration-200 group-hover:scale-110'}/>
|
||||
{
|
||||
button.label &&
|
||||
<span className="ml-2 text-sm font-medium">
|
||||
{t(`textEditor.toolbar.${button.label}`)}
|
||||
</span>
|
||||
}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CollapsableButton
|
||||
showCollapsable={showUserSettings}
|
||||
text={t("textEditor.preferences")}
|
||||
onClick={handleShowUserSettings}
|
||||
icon={faCog}
|
||||
/>
|
||||
{chapter?.chapterContent.version === 2 && (
|
||||
<CollapsableButton
|
||||
showCollapsable={showGhostWriter}
|
||||
text={t("textEditor.ghostWriter")}
|
||||
onClick={handleShowGhostWriter}
|
||||
icon={faGhost}
|
||||
/>
|
||||
)}
|
||||
{chapter?.chapterContent.version && chapter.chapterContent.version > 2 && (
|
||||
<CollapsableButton
|
||||
showCollapsable={showDraftCompanion}
|
||||
text={t("textEditor.draftCompanion")}
|
||||
onClick={handleShowDraftCompanion}
|
||||
icon={faLayerGroup}
|
||||
/>
|
||||
)}
|
||||
<SubmitButtonWLoading
|
||||
callBackAction={saveContent}
|
||||
isLoading={isSaving}
|
||||
text={t("textEditor.save")}
|
||||
loadingText={t("textEditor.saving")}
|
||||
icon={faFloppyDisk}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between w-full h-full overflow-auto">
|
||||
<div
|
||||
className={`flex-1 p-8 overflow-auto transition-all duration-300 ${editorSettings.focusMode ? 'bg-black/20' : ''}`}>
|
||||
<div
|
||||
className={`editor-container mx-auto p-6 rounded-2xl shadow-2xl min-h-[80%] border border-secondary/50 ${editorClasses.container} ${editorClasses.theme} relative`}
|
||||
style={containerStyle}>
|
||||
<div
|
||||
className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-primary/30 to-transparent"></div>
|
||||
<div
|
||||
className="absolute bottom-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-primary/30 to-transparent"></div>
|
||||
<EditorContent className={`w-full h-full ${editorClasses.base} editor-content`}
|
||||
editor={editor}/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(showDraftCompanion || showGhostWriter || showUserSettings) && (
|
||||
<div
|
||||
className={`w-4/12 transition-opacity duration-300 ${editorSettings.focusMode ? 'opacity-50 hover:opacity-100' : ''}`}>
|
||||
{showDraftCompanion && <DraftCompanion/>}
|
||||
{showGhostWriter && <GhostWriter/>}
|
||||
{showUserSettings && (
|
||||
<UserEditorSettings
|
||||
settings={editorSettings}
|
||||
onSettingsChange={setEditorSettings}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user