Files
ERitors-Scribe-Desktop/components/editor/TextEditor.tsx
natreex dbbe33b19b Refactor and extend offline synchronization logic across components and services
- Integrated sync queue mechanisms with `LocalSyncQueueContext` for offline data handling.
- Updated key sync-related services (e.g., book, chapter, series) to support offline-first functionality.
- Removed redundant database fetch methods to optimize repository logic and improve maintainability.
- Enhanced Tauri IPC usage for sync operations and removed legacy methods in Rust services.
2026-03-30 21:06:58 -04:00

554 lines
22 KiB
TypeScript

'use client'
import React, {useCallback, useContext, useEffect, useRef, useState} from 'react';
import {EditorContent, JSONContent} from '@tiptap/react';
import {
AlignCenter,
AlignJustify,
AlignLeft,
AlignRight,
Bold,
Ghost,
Heading1,
Heading2,
Heading3,
Layers,
List,
ListOrdered,
LucideIcon,
Save,
SlidersHorizontal,
Underline
} from 'lucide-react';
import {EditorContext, EditorContextProps} from "@/context/EditorContext";
import {ChapterContext, ChapterContextProps} from '@/context/ChapterContext';
import {BookContext, BookContextProps} from "@/context/BookContext";
import {apiPost} from '@/lib/api/client';
import {isDesktop} from '@/lib/configs';
import * as tauri from '@/lib/tauri';
import OfflineContext, {OfflineContextType} from '@/context/OfflineContext';
import {BooksSyncContext, BooksSyncContextProps} from '@/context/BooksSyncContext';
import {SyncedBook} from '@/lib/types/synced-book';
import {LocalSyncQueueContext, LocalSyncQueueContextProps} from '@/context/SyncQueueContext';
import {AlertContext, AlertContextProps} from '@/context/AlertContext';
import {SessionContext, SessionContextProps} from "@/context/SessionContext";
import DraftCompanion from "@/components/editor/DraftCompanion";
import GhostWriter from "@/components/ghostwriter/GhostWriter";
import IconButton from "@/components/ui/IconButton";
import Button from "@/components/ui/Button";
import UserEditorSettings, {EditorDisplaySettings} from "@/components/editor/UserEditorSetting";
import {useTranslations} from '@/lib/i18n';
import {LangContext, LangContextProps} from "@/context/LangContext";
import {isWebKitWithoutIndentFix} from "@/lib/utils/webkitDetect";
import {getCookie, setCookie} from "@/lib/utils/cookies";
import Modal from "@/components/ui/Modal";
import {Info} from 'lucide-react';
interface ToolbarButton {
action: () => void;
icon: LucideIcon;
isActive: boolean;
}
interface EditorClasses {
base: string;
h1: string;
h2: string;
h3: string;
container: string;
theme: string;
lists: string;
listItems: string;
}
const defaultEditorSettings: EditorDisplaySettings = {
zoomLevel: 3,
lineHeight: 1.5,
theme: 'sombre',
fontFamily: 'lora',
maxWidth: 768,
focusMode: false
};
const fontSizeClasses: Record<number, string> = {
1: 'text-sm',
2: 'text-base',
3: 'text-lg',
4: 'text-xl',
5: 'text-2xl'
};
const h1SizeClasses: Record<number, string> = {
1: 'text-xl',
2: 'text-2xl',
3: 'text-3xl',
4: 'text-4xl',
5: 'text-5xl'
};
const h2SizeClasses: Record<number, string> = {
1: 'text-lg',
2: 'text-xl',
3: 'text-2xl',
4: 'text-3xl',
5: 'text-4xl'
};
const h3SizeClasses: Record<number, string> = {
1: 'text-base',
2: 'text-lg',
3: 'text-xl',
4: 'text-2xl',
5: 'text-3xl'
};
const fontFamilyClasses: Record<string, string> = {
'lora': 'Lora',
'serif': 'font-serif',
'sans-serif': 'font-sans',
'monospace': 'font-mono'
};
const lineHeightClasses: Record<number, string> = {
1.2: 'leading-tight',
1.5: 'leading-normal',
1.75: 'leading-relaxed',
2: 'leading-loose'
};
const maxWidthClasses: Record<number, string> = {
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'
};
function getClosestKey(value: number, obj: Record<number, string>): number {
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}: LangContextProps = useContext<LangContextProps>(LangContext)
const {editor}: EditorContextProps = useContext<EditorContextProps>(EditorContext);
const {chapter}: ChapterContextProps = useContext<ChapterContextProps>(ChapterContext);
const {book}: BookContextProps = useContext<BookContextProps>(BookContext);
const {errorMessage, successMessage}: AlertContextProps = useContext<AlertContextProps>(AlertContext);
const {session}: SessionContextProps = useContext<SessionContextProps>(SessionContext);
const {isCurrentlyOffline}: OfflineContextType = useContext<OfflineContextType>(OfflineContext);
const {addToQueue}: LocalSyncQueueContextProps = useContext<LocalSyncQueueContextProps>(LocalSyncQueueContext);
const {localSyncedBooks}: BooksSyncContextProps = useContext<BooksSyncContextProps>(BooksSyncContext);
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>(defaultEditorSettings);
const [indentDisabled] = useState<boolean>(() => isWebKitWithoutIndentFix());
const [showIndentModal, setShowIndentModal] = useState<boolean>(() => isWebKitWithoutIndentFix() && !getCookie('indent_notice_seen'));
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 text-text-primary',
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 zoomKey: number = getClosestKey(settings.zoomLevel, fontSizeClasses);
const lineHeightKey: number = getClosestKey(settings.lineHeight, lineHeightClasses);
const maxWidthKey: number = getClosestKey(settings.maxWidth, maxWidthClasses);
const fontFamily: string = fontFamilyClasses[settings.fontFamily] || fontFamilyClasses['lora'];
const lineHeight: string = lineHeightClasses[lineHeightKey];
const baseClass: string = `${fontSizeClasses[zoomKey]} ${fontFamily} ${lineHeight}`;
const h1Class: string = `${h1SizeClasses[zoomKey]} font-bold ${fontFamily} ${lineHeight}`;
const h2Class: string = `${h2SizeClasses[zoomKey]} font-bold ${fontFamily} ${lineHeight}`;
const h3Class: string = `${h3SizeClasses[zoomKey]} font-bold ${fontFamily} ${lineHeight}`;
const containerClass: string = maxWidthClasses[maxWidthKey];
const listsClass: string = 'pl-12';
let themeClass: string = '';
switch (settings.theme) {
case 'clair':
themeClass = 'bg-gray-light text-darkest-background';
break;
case 'sépia':
themeClass = 'bg-editor-page-sepia text-darkest-background';
break;
default:
themeClass = 'bg-tertiary text-text-primary';
}
setEditorClasses({
base: baseClass,
h1: h1Class,
h2: h2Class,
h3: h3Class,
container: containerClass,
theme: themeClass,
lists: listsClass,
listItems: baseClass
});
}, []);
const editorContainerRef: React.RefObject<HTMLDivElement | null> = useRef<HTMLDivElement>(null);
const editorContainerBgClass: string =
editorSettings.theme === 'sépia' ? 'bg-editor-page-sepia' : '';
const toolbarButtons: ToolbarButton[] = ((): ToolbarButton[] => {
if (!editor) return [];
return [
{
action: (): boolean => editor.chain().focus().setParagraph().run(),
icon: AlignJustify,
isActive: editor.isActive('paragraph')
},
{
action: (): boolean => editor.chain().focus().toggleBold().run(),
icon: Bold,
isActive: editor.isActive('bold')
},
{
action: (): boolean => editor.chain().focus().toggleUnderline().run(),
icon: Underline,
isActive: editor.isActive('underline')
},
{
action: (): boolean => editor.chain().focus().setTextAlign('left').run(),
icon: AlignLeft,
isActive: editor.isActive({textAlign: 'left'})
},
{
action: (): boolean => editor.chain().focus().setTextAlign('center').run(),
icon: AlignCenter,
isActive: editor.isActive({textAlign: 'center'})
},
{
action: (): boolean => editor.chain().focus().setTextAlign('right').run(),
icon: AlignRight,
isActive: editor.isActive({textAlign: 'right'})
},
{
action: (): boolean => editor.chain().focus().toggleBulletList().run(),
icon: List,
isActive: editor.isActive('bulletList')
},
{
action: (): boolean => editor.chain().focus().toggleOrderedList().run(),
icon: ListOrdered,
isActive: editor.isActive('orderedList')
},
{
action: (): boolean => editor.chain().focus().toggleHeading({level: 1}).run(),
icon: Heading1,
isActive: editor.isActive('heading', {level: 1})
},
{
action: (): boolean => editor.chain().focus().toggleHeading({level: 2}).run(),
icon: Heading2,
isActive: editor.isActive('heading', {level: 2})
},
{
action: (): boolean => editor.chain().focus().toggleHeading({level: 3}).run(),
icon: Heading3,
isActive: editor.isActive('heading', {level: 3})
},
];
})();
const saveContent: () => Promise<void> = useCallback(async (): Promise<void> => {
if (!editor || !chapter) return;
setIsSaving(true);
const content: Record<string, unknown> = editor.state.doc.toJSON();
const chapterId: string = chapter.chapterId || '';
const version: number = chapter.chapterContent.version || 0;
try {
let response: boolean;
if (isDesktop && (isCurrentlyOffline() || book?.localBook)) {
response = await tauri.saveChapterContent({
chapterId,
version,
content,
totalWordCount: editor.getText().length,
contentId: '',
});
} else {
const saveData = {
chapterId,
version,
content,
totalWordCount: editor.getText().length,
currentTime: mainTimer
};
response = await apiPost<boolean>(`chapter/content`, saveData, session?.accessToken ?? '');
if (isDesktop && localSyncedBooks.find((sb: SyncedBook): boolean => sb.id === book?.bookId)) {
addToQueue('save_chapter_content', saveData);
}
}
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, addToQueue, book?.localBook, isCurrentlyOffline]);
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);
}, []);
const handleCloseIndentModal: () => void = useCallback((): void => {
setCookie('indent_notice_seen', 'true', 365);
setShowIndentModal(false);
}, []);
useEffect((): void => {
updateEditorClasses(editorSettings);
}, [editorSettings, updateEditorClasses]);
useEffect((): () => void => {
function startTimer(): void {
if (timerRef.current === null) {
timerRef.current = window.setInterval((): void => {
setMainTimer((prevTimer: number): number => 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: JSONContent = JSON.parse(chapter.chapterContent.content);
editor.commands.setContent(parsedContent);
} catch (e: unknown) {
errorMessage(t('editor.error.parsingContent'));
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 bg-tertiary ${indentDisabled ? 'no-text-indent' : ''}`}>
<div
className={`flex justify-between items-center gap-3 rounded-xl mx-1 mb-1 px-4 py-2 bg-darkest-background transition-opacity duration-300 ${editorSettings.focusMode ? 'opacity-70 hover:opacity-100' : ''}`}>
<div className="flex flex-wrap gap-1">
{toolbarButtons.map((button: ToolbarButton, index: number) => (
<IconButton
key={index}
icon={button.icon}
variant="ghost"
shape="square"
selected={button.isActive}
onClick={button.action}
/>
))}
</div>
<div className="flex items-center gap-1">
<IconButton
icon={SlidersHorizontal}
variant="ghost"
shape="square"
selected={showUserSettings}
onClick={handleShowUserSettings}
tooltip={t("textEditor.preferences")}
/>
{chapter?.chapterContent.version === 2 && !isCurrentlyOffline() && !book?.localBook && book?.quillsenseEnabled !== false && (
<IconButton
icon={Ghost}
variant="ghost"
shape="square"
selected={showGhostWriter}
onClick={handleShowGhostWriter}
tooltip={t("textEditor.ghostWriter")}
/>
)}
{chapter?.chapterContent.version && chapter.chapterContent.version > 2 && (
<IconButton
icon={Layers}
variant="ghost"
shape="square"
selected={showDraftCompanion}
onClick={handleShowDraftCompanion}
tooltip={t("textEditor.draftCompanion")}
/>
)}
{indentDisabled && (
<IconButton
icon={Info}
variant="ghost"
shape="square"
onClick={(): void => setShowIndentModal(true)}
tooltip={t("textEditor.indentDisabled")}
/>
)}
<IconButton
icon={Save}
variant="ghost"
shape="square"
onClick={saveContent}
tooltip={t("textEditor.save")}
/>
</div>
</div>
<div className="flex flex-1 min-h-0 bg-tertiary">
<div
className={`flex-1 bg-background rounded-xl mx-1 overflow-hidden transition-all duration-300 ${editorSettings.focusMode ? 'bg-darkest-background' : ''}`}>
<div className="h-full overflow-auto p-4">
<div
ref={editorContainerRef}
className={`editor-container mx-auto p-4 rounded-xl min-h-full ${editorClasses.container} ${editorClasses.theme} ${editorContainerBgClass}`}>
<EditorContent className={`w-full h-full ${editorClasses.base} editor-content`}
editor={editor}/>
</div>
</div>
</div>
{(showDraftCompanion || showGhostWriter || showUserSettings) && (
<div
className={`w-4/12 bg-darkest-background rounded-xl mx-1 overflow-hidden flex flex-col transition-opacity duration-300 ${editorSettings.focusMode ? 'opacity-50 hover:opacity-100' : ''}`}>
<div className="flex-1 overflow-auto flex flex-col">
{showDraftCompanion && <DraftCompanion/>}
{showGhostWriter && <GhostWriter/>}
{showUserSettings && (
<UserEditorSettings
settings={editorSettings}
onSettingsChange={setEditorSettings}
/>
)}
</div>
</div>
)}
</div>
{showIndentModal && (
<Modal
title={t("textEditor.indentDisabledTitle")}
icon={Info}
size="sm"
onClose={handleCloseIndentModal}
footer={
<Button variant="primary" onClick={handleCloseIndentModal}>
{t("textEditor.indentDisabledUnderstood")}
</Button>
}
>
<p className="text-text-secondary leading-relaxed">
{t("textEditor.indentDisabledDescription")}
</p>
</Modal>
)}
</div>
);
}