Introduce focus mode in notes (#891)
This commit is contained in:
parent
c656c2aa67
commit
74d2027d17
2 changed files with 296 additions and 0 deletions
200
frontend/components/Note/NoteFocusMode.tsx
Normal file
200
frontend/components/Note/NoteFocusMode.tsx
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import React, { useEffect, useRef } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import MarkdownRenderer from '../Shared/MarkdownRenderer';
|
||||
import { Note } from '../../entities/Note';
|
||||
import { ENABLE_NOTE_COLOR } from '../../config/featureFlags';
|
||||
|
||||
const shouldUseLightText = (hexColor: string | undefined): boolean => {
|
||||
if (!hexColor) return false;
|
||||
const hex = hexColor.replace('#', '');
|
||||
const r = parseInt(hex.substring(0, 2), 16);
|
||||
const g = parseInt(hex.substring(2, 4), 16);
|
||||
const b = parseInt(hex.substring(4, 6), 16);
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
return luminance < 0.4;
|
||||
};
|
||||
|
||||
interface NoteFocusModeProps {
|
||||
note: Note;
|
||||
isEditing: boolean;
|
||||
saveStatus: 'saved' | 'saving' | 'unsaved';
|
||||
onNoteChange: (updates: Partial<Note>) => void;
|
||||
onContentChange?: (newContent: string) => void;
|
||||
onEditNote: () => void;
|
||||
onExitEditing: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const NoteFocusMode: React.FC<NoteFocusModeProps> = ({
|
||||
note,
|
||||
isEditing,
|
||||
saveStatus,
|
||||
onNoteChange,
|
||||
onContentChange,
|
||||
onEditNote,
|
||||
onExitEditing,
|
||||
onClose,
|
||||
}) => {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const noteColor = ENABLE_NOTE_COLOR ? note.color : undefined;
|
||||
const lightText = shouldUseLightText(noteColor);
|
||||
|
||||
useEffect(() => {
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (isEditing) {
|
||||
onExitEditing();
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown, true);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown, true);
|
||||
}, [isEditing, onExitEditing, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
const textColor = noteColor
|
||||
? lightText
|
||||
? '#ffffff'
|
||||
: '#333333'
|
||||
: undefined;
|
||||
const mutedColor = noteColor
|
||||
? lightText
|
||||
? '#e0e0e0'
|
||||
: '#666666'
|
||||
: undefined;
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex flex-col bg-white dark:bg-gray-900 transition-opacity duration-200"
|
||||
style={noteColor ? { backgroundColor: noteColor } : undefined}
|
||||
>
|
||||
{/* Minimal header */}
|
||||
<div className="flex items-center justify-between px-6 py-3 flex-shrink-0">
|
||||
<div className="text-xs">
|
||||
{isEditing && note.title && (
|
||||
<>
|
||||
{saveStatus === 'saving' && (
|
||||
<span
|
||||
className="text-blue-500 dark:text-blue-400 italic"
|
||||
style={mutedColor ? { color: mutedColor } : undefined}
|
||||
>
|
||||
Saving...
|
||||
</span>
|
||||
)}
|
||||
{saveStatus === 'saved' && (
|
||||
<span
|
||||
className="text-gray-400 dark:text-gray-500"
|
||||
style={mutedColor ? { color: mutedColor } : undefined}
|
||||
>
|
||||
Saved
|
||||
</span>
|
||||
)}
|
||||
{saveStatus === 'unsaved' && (
|
||||
<span
|
||||
className="text-amber-600 dark:text-amber-400"
|
||||
style={mutedColor ? { color: mutedColor } : undefined}
|
||||
>
|
||||
Unsaved
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-md text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
style={mutedColor ? { color: mutedColor } : undefined}
|
||||
aria-label="Exit focus mode"
|
||||
>
|
||||
<XMarkIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Centered content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-3xl mx-auto px-6 md:px-8 pb-16">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={note.title || ''}
|
||||
onChange={(e) =>
|
||||
onNoteChange({ title: e.target.value })
|
||||
}
|
||||
placeholder="Note title..."
|
||||
className="w-full bg-transparent text-gray-900 dark:text-gray-100 border-none focus:outline-none focus:ring-0 mb-6"
|
||||
style={{
|
||||
...(textColor ? { color: textColor } : {}),
|
||||
fontSize: '2.25rem',
|
||||
lineHeight: '2.5rem',
|
||||
fontWeight: 500,
|
||||
paddingLeft: 0,
|
||||
paddingRight: 0,
|
||||
}}
|
||||
/>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={note.content || ''}
|
||||
onChange={(e) =>
|
||||
onNoteChange({ content: e.target.value })
|
||||
}
|
||||
placeholder="Write your note... (Markdown supported)"
|
||||
className="w-full h-full min-h-[60vh] bg-transparent text-gray-900 dark:text-gray-100 border-none focus:outline-none focus:ring-0 resize-none text-lg leading-relaxed"
|
||||
style={textColor ? { color: textColor } : undefined}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h1
|
||||
onClick={onEditNote}
|
||||
className="cursor-pointer text-gray-900 dark:text-gray-100 mb-6"
|
||||
style={{
|
||||
...(textColor ? { color: textColor } : {}),
|
||||
fontSize: '2.25rem',
|
||||
lineHeight: '2.5rem',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
title="Click to edit"
|
||||
>
|
||||
{note.title || 'Untitled Note'}
|
||||
</h1>
|
||||
<div
|
||||
onClick={onEditNote}
|
||||
className="cursor-pointer text-gray-900 dark:text-gray-100 text-lg leading-relaxed"
|
||||
style={textColor ? { color: textColor } : undefined}
|
||||
title="Click to edit"
|
||||
>
|
||||
<MarkdownRenderer
|
||||
content={note.content}
|
||||
noteColor={noteColor}
|
||||
onContentChange={onContentChange}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
export default NoteFocusMode;
|
||||
|
|
@ -16,6 +16,7 @@ import {
|
|||
ClockIcon,
|
||||
EllipsisVerticalIcon,
|
||||
XMarkIcon,
|
||||
ArrowsPointingOutIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { useToast } from './Shared/ToastContext';
|
||||
import { Link, useParams, useNavigate } from 'react-router-dom';
|
||||
|
|
@ -32,6 +33,7 @@ import { deleteNoteWithStoreUpdate } from '../utils/noteDeleteUtils';
|
|||
import { useStore } from '../store/useStore';
|
||||
import { createProject } from '../utils/projectsService';
|
||||
import { ENABLE_NOTE_COLOR } from '../config/featureFlags';
|
||||
import NoteFocusMode from './Note/NoteFocusMode';
|
||||
|
||||
const NOTE_COLORS = [
|
||||
{ name: 'None', value: '' },
|
||||
|
|
@ -82,6 +84,7 @@ const Notes: React.FC = () => {
|
|||
const [saveStatus, setSaveStatus] = useState<
|
||||
'saved' | 'saving' | 'unsaved'
|
||||
>('saved');
|
||||
const [isFocusMode, setIsFocusMode] = useState(false);
|
||||
const hasAutoSelected = useRef(false);
|
||||
|
||||
const editingNoteColor =
|
||||
|
|
@ -759,6 +762,24 @@ const Notes: React.FC = () => {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
onClick={() => setIsFocusMode(true)}
|
||||
className="p-2 text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
style={{
|
||||
color: editingNoteColor
|
||||
? shouldUseLightText(
|
||||
editingNoteColor
|
||||
)
|
||||
? '#e0e0e0'
|
||||
: '#333333'
|
||||
: undefined,
|
||||
}}
|
||||
aria-label="Focus mode"
|
||||
title="Focus mode"
|
||||
>
|
||||
<ArrowsPointingOutIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<div
|
||||
className="relative"
|
||||
ref={noteOptionsDropdownRef}
|
||||
|
|
@ -880,6 +901,7 @@ const Notes: React.FC = () => {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showProjectDropdown && (
|
||||
|
|
@ -1132,6 +1154,24 @@ const Notes: React.FC = () => {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
onClick={() => setIsFocusMode(true)}
|
||||
className="p-2 text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
style={{
|
||||
color: previewNoteColor
|
||||
? shouldUseLightText(
|
||||
previewNoteColor
|
||||
)
|
||||
? '#e0e0e0'
|
||||
: '#333333'
|
||||
: undefined,
|
||||
}}
|
||||
aria-label="Focus mode"
|
||||
title="Focus mode"
|
||||
>
|
||||
<ArrowsPointingOutIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<div
|
||||
className="relative"
|
||||
ref={noteOptionsDropdownRef}
|
||||
|
|
@ -1245,6 +1285,7 @@ const Notes: React.FC = () => {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
|
@ -1309,6 +1350,61 @@ const Notes: React.FC = () => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{isFocusMode && (isEditing ? editingNote : previewNote) && (
|
||||
<NoteFocusMode
|
||||
note={(isEditing ? editingNote : previewNote)!}
|
||||
isEditing={isEditing}
|
||||
saveStatus={saveStatus}
|
||||
onNoteChange={handleNoteChange}
|
||||
onContentChange={
|
||||
!isEditing && previewNote
|
||||
? async (newContent) => {
|
||||
const updatedNote = {
|
||||
...previewNote,
|
||||
content: newContent,
|
||||
};
|
||||
setPreviewNote(updatedNote);
|
||||
if (previewNote.uid) {
|
||||
try {
|
||||
const savedNote =
|
||||
await updateNote(
|
||||
previewNote.uid,
|
||||
updatedNote
|
||||
);
|
||||
const updatedNotes = notes.map(
|
||||
(n) =>
|
||||
n.uid === previewNote.uid
|
||||
? savedNote
|
||||
: n
|
||||
);
|
||||
setNotes(updatedNotes);
|
||||
setPreviewNote(savedNote);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'Error updating note:',
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onEditNote={() => {
|
||||
if (previewNote) {
|
||||
handleEditNote(previewNote);
|
||||
}
|
||||
}}
|
||||
onExitEditing={() => {
|
||||
if (editingNote?.title) {
|
||||
handleSaveInlineNote();
|
||||
} else {
|
||||
handleCancelEdit();
|
||||
}
|
||||
}}
|
||||
onClose={() => setIsFocusMode(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isNoteModalOpen && (
|
||||
<NoteModal
|
||||
isOpen={isNoteModalOpen}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue