- Added offline detection logic with `OfflineContext` to improve app functionality in offline scenarios. - Integrated Tauri IPC functions to handle local tool settings and character attributes when offline. - Refined indentation logic in `TextEditor` for better compatibility with WebKit engines. - Removed unused `indent` property and related settings in editor components to simplify configuration. - Updated locale files with improved translation consistency and parameterized placeholders.
544 lines
21 KiB
TypeScript
544 lines
21 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 {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 [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 {
|
|
response = await apiPost<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);
|
|
}, []);
|
|
|
|
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 && 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>
|
|
);
|
|
} |