notepad improvements

This commit is contained in:
Egor 2025-09-23 13:09:20 -07:00
parent 3c77be8dbb
commit 42b817949c
11 changed files with 308 additions and 49 deletions

View file

@ -72,7 +72,7 @@ export const StatusBar = ({ maxDataPoints = 60 }: StatusBarProps) => {
bg={colorScheme === 'dark' ? 'dark.6' : 'gray.1'}
>
<Group gap="xs">
<Tooltip label="Notepad">
<Tooltip label="Notepad" disabled={isVisible}>
<ActionIcon
variant={isVisible ? 'filled' : 'subtle'}
size="sm"

View file

@ -0,0 +1,31 @@
import { Text, Button, Group } from '@mantine/core';
import { Modal } from '@/components/Modal';
interface CloseConfirmModalProps {
isOpen: boolean;
tabTitle: string;
onConfirm: () => void;
onCancel: () => void;
}
export const CloseConfirmModal = ({
isOpen,
tabTitle,
onConfirm,
onCancel,
}: CloseConfirmModalProps) => (
<Modal opened={isOpen} onClose={onCancel} title="Confirm Close Tab" size="sm">
<Text size="sm" mb="md">
The tab &ldquo;{tabTitle}&rdquo; contains content. Closing it will
permanently delete this data. Are you sure you want to close it?
</Text>
<Group justify="flex-end">
<Button variant="subtle" onClick={onCancel}>
Cancel
</Button>
<Button color="red" onClick={onConfirm}>
Close Tab
</Button>
</Group>
</Modal>
);

View file

@ -1,11 +1,12 @@
import { useEffect, useRef, useState, type MouseEvent } from 'react';
import { Box, Paper, ActionIcon } from '@mantine/core';
import { X, Plus } from 'lucide-react';
import { X } from 'lucide-react';
import { useNotepadStore } from '@/stores/notepad';
import { usePreferencesStore } from '@/stores/preferences';
import { NOTEPAD_MIN_WIDTH, NOTEPAD_MIN_HEIGHT } from '@/constants/notepad';
import { NotepadTabs } from './Tabs.tsx';
import { NotepadEditor } from './Editor.tsx';
import { CloseConfirmModal } from './CloseConfirmModal.tsx';
export const NotepadContainer = () => {
const {
@ -14,6 +15,7 @@ export const NotepadContainer = () => {
tabs,
activeTabId,
addTab,
removeTab,
isLoaded,
isVisible,
setVisible,
@ -22,6 +24,15 @@ export const NotepadContainer = () => {
const containerRef = useRef<HTMLDivElement>(null);
const [resizeDirection, setResizeDirection] = useState<string | null>(null);
const [confirmCloseModal, setConfirmCloseModal] = useState<{
isOpen: boolean;
tabId: string | null;
tabTitle: string;
}>({
isOpen: false,
tabId: null,
tabTitle: '',
});
const activeTab = tabs.find((tab) => tab.id === activeTabId);
@ -30,6 +41,32 @@ export const NotepadContainer = () => {
addTab(newTab);
};
const handleTabCloseRequest = (tabId: string) => {
const tab = tabs.find((t) => t.id === tabId);
if (!tab) return;
if (tab.content.trim().length > 0) {
setConfirmCloseModal({
isOpen: true,
tabId,
tabTitle: tab.title,
});
} else {
removeTab(tabId);
}
};
const handleConfirmClose = () => {
if (confirmCloseModal.tabId) {
removeTab(confirmCloseModal.tabId);
}
setConfirmCloseModal({ isOpen: false, tabId: null, tabTitle: '' });
};
const handleCancelClose = () => {
setConfirmCloseModal({ isOpen: false, tabId: null, tabTitle: '' });
};
const handleResizeStart =
(direction: string) => (e: MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
@ -87,11 +124,11 @@ export const NotepadContainer = () => {
withBorder
style={{
position: 'fixed',
left: 5,
bottom: 5,
left: 0,
bottom: 24,
width: position.width,
height: position.height,
zIndex: 1000,
zIndex: 100,
backgroundColor:
resolvedColorScheme === 'dark'
? 'var(--mantine-color-dark-6)'
@ -132,11 +169,6 @@ export const NotepadContainer = () => {
width: 12,
height: 12,
cursor: 'ne-resize',
backgroundColor:
resolvedColorScheme === 'dark'
? 'var(--mantine-color-dark-5)'
: 'var(--mantine-color-gray-4)',
borderRadius: '2px',
}}
onMouseDown={handleResizeStart('top-right')}
/>
@ -155,7 +187,10 @@ export const NotepadContainer = () => {
minHeight: 28,
}}
>
<NotepadTabs />
<NotepadTabs
onCreateNewTab={handleCreateNewTab}
onCloseTab={handleTabCloseRequest}
/>
<Box
style={{
@ -166,10 +201,6 @@ export const NotepadContainer = () => {
flexShrink: 0,
}}
>
<ActionIcon variant="subtle" size="xs" onClick={handleCreateNewTab}>
<Plus size={12} />
</ActionIcon>
<ActionIcon
variant="subtle"
size="xs"
@ -185,6 +216,13 @@ export const NotepadContainer = () => {
{activeTab && <NotepadEditor tab={activeTab} />}
</Box>
</Box>
<CloseConfirmModal
isOpen={confirmCloseModal.isOpen}
tabTitle={confirmCloseModal.tabTitle}
onConfirm={handleConfirmClose}
onCancel={handleCancelClose}
/>
</Paper>
);
};

View file

@ -1,9 +1,16 @@
import { useCallback, useEffect, useState, useRef } from 'react';
import {
useCallback,
useEffect,
useState,
useRef,
type MouseEvent,
} from 'react';
import { Box } from '@mantine/core';
import CodeMirror, { type ReactCodeMirrorRef } from '@uiw/react-codemirror';
import { search } from '@codemirror/search';
import { EditorView } from '@codemirror/view';
import { EditorView, highlightActiveLine, keymap } from '@codemirror/view';
import { oneDark } from '@codemirror/theme-one-dark';
import { history, historyKeymap } from '@codemirror/commands';
import { useNotepadStore } from '@/stores/notepad';
import { usePreferencesStore } from '@/stores/preferences';
import type { NotepadTab } from '@/types/electron';
@ -13,7 +20,8 @@ interface NotepadEditorProps {
}
export const NotepadEditor = ({ tab }: NotepadEditorProps) => {
const { saveTabContent } = useNotepadStore();
const { saveTabContent, showLineNumbers, setShowLineNumbers } =
useNotepadStore();
const { resolvedColorScheme } = usePreferencesStore();
const [content, setContent] = useState(tab.content);
const [saveTimeout, setSaveTimeout] = useState<ReturnType<
@ -38,6 +46,14 @@ export const NotepadEditor = ({ tab }: NotepadEditorProps) => {
[tab.id, saveTabContent, saveTimeout]
);
const handleEditorContextMenu = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (target.closest('.cm-gutters') || target.closest('.cm-lineNumbers')) {
e.preventDefault();
setShowLineNumbers(false);
}
};
const handleBoxClick = useCallback(() => {
if (editorRef.current?.view) {
editorRef.current.view.focus();
@ -59,6 +75,9 @@ export const NotepadEditor = ({ tab }: NotepadEditorProps) => {
const extensions = [
search(),
history(),
keymap.of(historyKeymap),
highlightActiveLine(),
EditorView.lineWrapping,
EditorView.theme({
'&': {
@ -72,12 +91,13 @@ export const NotepadEditor = ({ tab }: NotepadEditorProps) => {
return (
<Box
h="100%"
onClick={handleBoxClick}
onContextMenu={handleEditorContextMenu}
style={{
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
onClick={handleBoxClick}
>
<CodeMirror
ref={editorRef}
@ -86,7 +106,7 @@ export const NotepadEditor = ({ tab }: NotepadEditorProps) => {
theme={theme}
extensions={extensions}
basicSetup={{
lineNumbers: true,
lineNumbers: showLineNumbers,
foldGutter: false,
dropCursor: false,
allowMultipleSelections: false,

View file

@ -1,9 +1,21 @@
import { type MouseEvent, type DragEvent, useState } from 'react';
import { Box, ActionIcon, Text } from '@mantine/core';
import { X } from 'lucide-react';
import {
type MouseEvent,
type DragEvent,
type KeyboardEvent,
useState,
useRef,
useEffect,
} from 'react';
import { Box, ActionIcon, Text, TextInput } from '@mantine/core';
import { X, Plus } from 'lucide-react';
import { useNotepadStore } from '@/stores/notepad';
import { usePreferencesStore } from '@/stores/preferences';
interface NotepadTabsProps {
onCreateNewTab: () => Promise<void>;
onCloseTab: (tabId: string) => void;
}
interface TabProps {
id: string;
index: number;
@ -14,7 +26,10 @@ interface TabProps {
onDragStart: (e: DragEvent, index: number) => void;
onDragOver: (e: DragEvent) => void;
onDrop: (e: DragEvent, index: number) => void;
onRename: (newTitle: string) => void;
isDragOver: boolean;
showLineNumbers: boolean;
setShowLineNumbers: (show: boolean) => void;
}
const Tab = ({
@ -26,14 +41,77 @@ const Tab = ({
onDragStart,
onDragOver,
onDrop,
onRename,
isDragOver,
showLineNumbers,
setShowLineNumbers,
}: TabProps) => {
const { resolvedColorScheme } = usePreferencesStore();
const [isEditing, setIsEditing] = useState(false);
const [editingTitle, setEditingTitle] = useState(title);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isEditing]);
const handleTitleClick = (e: MouseEvent) => {
e.stopPropagation();
if (!isActive) {
onSelect();
}
};
const handleTitleDoubleClick = (e: MouseEvent) => {
e.stopPropagation();
if (isActive) {
setIsEditing(true);
setEditingTitle(title);
}
};
const handleInputKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleSaveTitle();
} else if (e.key === 'Escape') {
setIsEditing(false);
setEditingTitle(title);
}
};
const handleInputBlur = () => {
handleSaveTitle();
};
const handleSaveTitle = () => {
const trimmedTitle = editingTitle.trim();
if (trimmedTitle && trimmedTitle !== title) {
onRename(trimmedTitle);
}
setIsEditing(false);
};
const handleTabClick = () => {
if (!isEditing) {
onSelect();
}
};
const handleMouseDown = (e: MouseEvent) => {
if (e.button === 1) {
e.preventDefault();
onClose(e);
}
};
return (
<Box
draggable
onClick={onSelect}
draggable={!isEditing}
onClick={handleTabClick}
onMouseDown={handleMouseDown}
onDragStart={(e) => onDragStart(e, index)}
onDragOver={onDragOver}
onDrop={(e) => onDrop(e, index)}
@ -53,7 +131,7 @@ const Tab = ({
? 'var(--mantine-color-dark-4)'
: 'var(--mantine-color-gray-3)'
}`,
cursor: 'pointer',
cursor: isEditing ? 'default' : 'pointer',
display: 'flex',
alignItems: 'center',
gap: '4px',
@ -62,17 +140,49 @@ const Tab = ({
opacity: isDragOver ? 0.5 : 1,
}}
>
<Text
size="xs"
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
}}
>
{title}
</Text>
{isEditing ? (
<TextInput
ref={inputRef}
value={editingTitle}
onChange={(e) => setEditingTitle(e.target.value)}
onKeyDown={handleInputKeyDown}
onBlur={handleInputBlur}
size="xs"
variant="unstyled"
style={{
flex: 1,
minWidth: 0,
}}
styles={{
input: {
fontSize: 'var(--mantine-font-size-xs)',
padding: 0,
minHeight: 'auto',
height: 'auto',
lineHeight: 1,
},
}}
/>
) : (
<Text
size="xs"
onClick={handleTitleClick}
onDoubleClick={handleTitleDoubleClick}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
setShowLineNumbers(!showLineNumbers);
}}
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
}}
>
{title}
</Text>
)}
<ActionIcon variant="subtle" size="xs" onClick={onClose}>
<X size={10} />
@ -81,9 +191,19 @@ const Tab = ({
);
};
export const NotepadTabs = () => {
const { tabs, activeTabId, setActiveTab, removeTab, reorderTabs } =
useNotepadStore();
export const NotepadTabs = ({
onCreateNewTab,
onCloseTab,
}: NotepadTabsProps) => {
const {
tabs,
activeTabId,
setActiveTab,
reorderTabs,
updateTab,
showLineNumbers,
setShowLineNumbers,
} = useNotepadStore();
const { resolvedColorScheme } = usePreferencesStore();
const [draggedTabIndex, setDraggedTabIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
@ -94,7 +214,19 @@ export const NotepadTabs = () => {
const handleTabClose = (e: MouseEvent, tabId: string) => {
e.stopPropagation();
removeTab(tabId);
onCloseTab(tabId);
};
const handleTabRename = (tabId: string, newTitle: string) => {
updateTab(tabId, { title: newTitle });
};
const handleTabBarContextMenu = (e: MouseEvent) => {
e.preventDefault();
if (!showLineNumbers) {
setShowLineNumbers(true);
}
};
const handleDragStart = (e: DragEvent, index: number) => {
@ -130,6 +262,7 @@ export const NotepadTabs = () => {
return (
<Box
onContextMenu={handleTabBarContextMenu}
style={{
borderBottom: `1px solid ${
resolvedColorScheme === 'dark'
@ -154,13 +287,28 @@ export const NotepadTabs = () => {
title={tab.title}
onSelect={() => handleTabSelect(tab.id)}
onClose={(e) => handleTabClose(e, tab.id)}
onRename={(newTitle) => handleTabRename(tab.id, newTitle)}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDrop={handleDrop}
isDragOver={dragOverIndex === index}
showLineNumbers={showLineNumbers}
setShowLineNumbers={setShowLineNumbers}
/>
</Box>
))}
<ActionIcon
variant="subtle"
size="xs"
onClick={onCreateNewTab}
style={{
margin: '4px',
alignSelf: 'center',
}}
>
<Plus size={12} />
</ActionIcon>
</Box>
);
};

View file

@ -1,6 +1,4 @@
export const DEFAULT_NOTEPAD_POSITION = {
x: 0,
y: 0,
width: 400,
height: 400,
};

View file

@ -89,6 +89,7 @@ async function downloadFile(asset: GitHubAsset, tempPackedFilePath: string) {
if (totalBytes > 0) {
const progress = (downloadedBytes / totalBytes) * 100;
const now = Date.now();
if (now - lastProgressUpdate > 100) {
mainWindow.webContents.send('download-progress', progress);
lastProgressUpdate = now;

View file

@ -117,6 +117,16 @@ const appAPI: AppAPI = {
ipcRenderer.removeListener('window-unmaximized', handler);
};
},
onLineNumbersChanged: (callback) => {
const handler = (_: IpcRendererEvent, showLineNumbers: boolean) =>
callback(showLineNumbers);
ipcRenderer.on('line-numbers-changed', handler);
return () => {
ipcRenderer.removeListener('line-numbers-changed', handler);
};
},
};
const configAPI: ConfigAPI = {

View file

@ -13,6 +13,7 @@ interface NotepadStore extends NotepadState {
reorderTabs: (fromIndex: number, toIndex: number) => void;
setPosition: (position: NotepadState['position']) => void;
setVisible: (visible: boolean) => void;
setShowLineNumbers: (showLineNumbers: boolean) => void;
loadState: () => Promise<void>;
saveState: () => Promise<void>;
saveTabContent: (tabId: string, content: string) => Promise<void>;
@ -24,6 +25,7 @@ export const useNotepadStore = create<NotepadStore>()(
activeTabId: null,
position: DEFAULT_NOTEPAD_POSITION,
isVisible: false,
showLineNumbers: true,
isLoaded: false,
setTabs: (tabs) => set({ tabs }),
@ -85,6 +87,10 @@ export const useNotepadStore = create<NotepadStore>()(
set({ isVisible: visible });
},
setShowLineNumbers: (showLineNumbers) => {
set({ showLineNumbers });
},
loadState: async () => {
try {
const savedState = await window.electronAPI.notepad.loadState();
@ -106,6 +112,7 @@ export const useNotepadStore = create<NotepadStore>()(
activeTabId: savedState.activeTabId || tabsWithContent[0]?.id || null,
position: savedState.position,
isVisible: savedState.isVisible,
showLineNumbers: savedState.showLineNumbers ?? true,
isLoaded: true,
});
} catch {
@ -132,6 +139,7 @@ export const useNotepadStore = create<NotepadStore>()(
activeTabId: state.activeTabId,
position: state.position,
isVisible: state.isVisible,
showLineNumbers: state.showLineNumbers,
});
},
@ -148,6 +156,7 @@ useNotepadStore.subscribe(
activeTabId: state.activeTabId,
position: state.position,
isVisible: state.isVisible,
showLineNumbers: state.showLineNumbers,
}),
() => {
if (useNotepadStore.getState().isLoaded) {
@ -159,7 +168,8 @@ useNotepadStore.subscribe(
a.tabs === b.tabs &&
a.activeTabId === b.activeTabId &&
a.position === b.position &&
a.isVisible === b.isVisible,
a.isVisible === b.isVisible &&
a.showLineNumbers === b.showLineNumbers,
}
);

View file

@ -174,6 +174,9 @@ export interface AppAPI {
quitAndInstall: () => Promise<void>;
isUpdateDownloaded: () => Promise<boolean>;
onWindowStateToggle: (callback: () => void) => () => void;
onLineNumbersChanged: (
callback: (showLineNumbers: boolean) => void
) => () => void;
}
export interface ConfigAPI {
@ -221,24 +224,22 @@ export interface NotepadState {
tabs: NotepadTab[];
activeTabId: string | null;
position: {
x: number;
y: number;
width: number;
height: number;
};
isVisible: boolean;
showLineNumbers: boolean;
}
export interface SavedNotepadState {
tabs: SavedNotepadTab[];
activeTabId: string | null;
position: {
x: number;
y: number;
width: number;
height: number;
};
isVisible: boolean;
showLineNumbers?: boolean;
}
export interface NotepadAPI {

4
src/types/ipc.d.ts vendored
View file

@ -4,7 +4,8 @@ export type IPCChannel =
| 'versions-updated'
| 'kobold-output'
| 'window-maximized'
| 'window-unmaximized';
| 'window-unmaximized'
| 'line-numbers-changed';
export interface IPCChannelPayloads {
'download-progress': [progress: number];
@ -13,4 +14,5 @@ export interface IPCChannelPayloads {
'kobold-output': [message: string];
'window-maximized': [];
'window-unmaximized': [];
'line-numbers-changed': [showLineNumbers: boolean];
}