mirror of
https://github.com/Dvorinka/excalidraw-full.git
synced 2026-06-03 22:02:57 +00:00
refactor(frontend): improve type safety and migrate to ESLint 9
Migrate the frontend to ESLint 9, add `eslint.config.mjs`, and replace various `any` types with more specific or safer alternatives across the codebase. - Update `package.json` with new ESLint and TypeScript-ESLint dependencies - Replace `any` with `Record<string, unknown>` or specific types in `TemplatePicker`, `Editor`, and `FileBrowser` - Improve error handling in `TeamSettings` and `Templates` using type guards - Add explicit type imports for Excalidraw elements in `Editor.tsx` - Refactor `Modal.tsx` for cleaner conditional logic - Add `check` script to `package.json` for type checking
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
import tseslint from 'typescript-eslint';
|
||||
import pluginReact from 'eslint-plugin-react';
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist/', 'node_modules/', 'dist-test/', '*.config.*', 'eslint.config.mjs', 'e2e/', 'playwright/'] },
|
||||
tseslint.configs.recommended,
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
plugins: { react: pluginReact },
|
||||
languageOptions: {
|
||||
parserOptions: { project: './tsconfig.json' },
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/no-non-null-assertion': 'warn',
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
},
|
||||
}
|
||||
);
|
||||
Generated
+3096
-2
File diff suppressed because it is too large
Load Diff
@@ -6,8 +6,9 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"check": "tsc --noEmit",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
@@ -37,9 +38,12 @@
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-plugin-react": "^7.37.0",
|
||||
"jsdom": "^25.0.0",
|
||||
"sass": "^1.81.0",
|
||||
"typescript": "^5.6.0",
|
||||
"typescript-eslint": "^8.0.0",
|
||||
"vite": "^6.0.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Search, Bell, Plus, FileText, Loader2, Sun, Moon } from 'lucide-react';
|
||||
import { Button } from '@/components';
|
||||
import { useThemeStore } from '@/stores';
|
||||
import { api } from '@/services';
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
import type { Drawing } from '@/types';
|
||||
import styles from './Layout.module.scss';
|
||||
|
||||
|
||||
@@ -32,7 +32,11 @@ export const Modal: React.FC<ModalProps> = ({
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onCancel?.() ?? onClose?.();
|
||||
if (onCancel) {
|
||||
onCancel();
|
||||
} else {
|
||||
onClose?.();
|
||||
}
|
||||
}
|
||||
};
|
||||
if (isOpen) {
|
||||
|
||||
@@ -11,12 +11,14 @@ interface TemplatePickerProps {
|
||||
onSelect: (template: PickedTemplate) => void;
|
||||
}
|
||||
|
||||
type RawElement = Record<string, unknown>;
|
||||
|
||||
interface TemplateOption {
|
||||
id: PickedTemplate;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: React.ElementType;
|
||||
elements: any[];
|
||||
elements: RawElement[];
|
||||
}
|
||||
|
||||
function makeHandDrawnRect(x: number, y: number, w: number, h: number, text?: string) {
|
||||
@@ -84,15 +86,16 @@ function makeText(x: number, y: number, text: string, fontSize = 20) {
|
||||
|
||||
function makeCheckbox(x: number, y: number, checked = false) {
|
||||
const box = makeHandDrawnRect(x, y, 20, 20);
|
||||
(box as any).backgroundColor = checked ? '#a5eba8' : 'transparent';
|
||||
(box as any).customData = {
|
||||
templateRole: 'checkbox',
|
||||
checked,
|
||||
};
|
||||
return box;
|
||||
return Object.assign(box, {
|
||||
backgroundColor: checked ? '#a5eba8' : 'transparent',
|
||||
customData: {
|
||||
templateRole: 'checkbox',
|
||||
checked,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const BUILTIN_TEMPLATES: Record<PickedTemplate, any[]> = {
|
||||
export const BUILTIN_TEMPLATES: Record<PickedTemplate, RawElement[]> = {
|
||||
blank: [],
|
||||
todo: [
|
||||
makeHandDrawnRect(50, 50, 500, 50),
|
||||
|
||||
@@ -5,7 +5,9 @@ import './i18n';
|
||||
import { App } from './App';
|
||||
import './styles/global.scss';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) throw new Error('Root element not found');
|
||||
ReactDOM.createRoot(rootElement).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
|
||||
@@ -12,45 +12,40 @@ import styles from './Editor.module.scss';
|
||||
// Dynamic import for Excalidraw to avoid SSR issues
|
||||
const Excalidraw = React.lazy(() => import('@excalidraw/excalidraw').then(mod => ({ default: mod.Excalidraw })));
|
||||
|
||||
interface ExcalidrawElement {
|
||||
id: string;
|
||||
type: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
import type { ExcalidrawElement } from '@excalidraw/excalidraw/types/element/types';
|
||||
import type { ExcalidrawImperativeAPI, ExcalidrawInitialDataState } from '@excalidraw/excalidraw/types/types';
|
||||
|
||||
interface ExcalidrawState {
|
||||
type LooseElement = Record<string, unknown>;
|
||||
|
||||
interface EditorState {
|
||||
elements: ExcalidrawElement[];
|
||||
appState: Record<string, unknown>;
|
||||
files: Record<string, { dataURL: string; mimeType: string }>;
|
||||
}
|
||||
|
||||
function prepareElementsForImport(sourceElements: any[], offsetX: number, offsetY: number): any[] {
|
||||
function prepareElementsForImport(sourceElements: LooseElement[], offsetX: number, offsetY: number): LooseElement[] {
|
||||
if (!sourceElements || !sourceElements.length) return [];
|
||||
const idMap = new Map<string, string>();
|
||||
sourceElements.forEach((el: any) => {
|
||||
idMap.set(el.id, `${el.type}-${Math.random().toString(36).slice(2, 9)}`);
|
||||
sourceElements.forEach((el) => {
|
||||
idMap.set(el.id as string, `${el.type}-${Math.random().toString(36).slice(2, 9)}`);
|
||||
});
|
||||
return sourceElements.map((el: any) => {
|
||||
const newEl = { ...el };
|
||||
newEl.id = idMap.get(el.id) || el.id;
|
||||
newEl.x = (el.x || 0) + offsetX;
|
||||
newEl.y = (el.y || 0) + offsetY;
|
||||
newEl.version = (el.version || 1) + 1;
|
||||
return sourceElements.map((el) => {
|
||||
const newEl: LooseElement = { ...el };
|
||||
newEl.id = idMap.get(el.id as string) || el.id;
|
||||
newEl.x = ((el.x as number) || 0) + offsetX;
|
||||
newEl.y = ((el.y as number) || 0) + offsetY;
|
||||
newEl.version = ((el.version as number) || 1) + 1;
|
||||
newEl.versionNonce = Math.floor(Math.random() * 1000000);
|
||||
newEl.updated = Date.now();
|
||||
newEl.seed = Math.floor(Math.random() * 100000);
|
||||
if (newEl.boundElements) {
|
||||
newEl.boundElements = newEl.boundElements.map((be: any) => ({
|
||||
newEl.boundElements = (newEl.boundElements as LooseElement[]).map((be) => ({
|
||||
...be,
|
||||
id: idMap.get(be.id) || be.id,
|
||||
id: idMap.get(be.id as string) || be.id,
|
||||
}));
|
||||
}
|
||||
if (newEl.containerId && idMap.has(newEl.containerId)) {
|
||||
newEl.containerId = idMap.get(newEl.containerId);
|
||||
if (newEl.containerId && idMap.has(newEl.containerId as string)) {
|
||||
newEl.containerId = idMap.get(newEl.containerId as string);
|
||||
}
|
||||
return newEl;
|
||||
});
|
||||
@@ -71,7 +66,7 @@ export const Editor: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [drawing, setDrawing] = useState<Drawing | null>(null);
|
||||
const [revisions, setRevisions] = useState<DrawingRevision[]>([]);
|
||||
const [initialData, setInitialData] = useState<any>(null);
|
||||
const [initialData, setInitialData] = useState<ExcalidrawInitialDataState | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveStatus, setSaveStatus] = useState<'saved' | 'unsaved' | 'saving'>('saved');
|
||||
@@ -81,11 +76,11 @@ export const Editor: React.FC = () => {
|
||||
const [notes, setNotes] = useState('');
|
||||
const [selectedRevision, setSelectedRevision] = useState<string | null>(null);
|
||||
const { theme: appTheme } = useThemeStore();
|
||||
const currentStateRef = useRef<ExcalidrawState | null>(null);
|
||||
const currentStateRef = useRef<EditorState | null>(null);
|
||||
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastSavedDataRef = useRef<string>('');
|
||||
const lastToggledCheckboxRef = useRef<string | null>(null);
|
||||
const [excalidrawAPI, setExcalidrawAPI] = useState<any>(null);
|
||||
const [excalidrawAPI, setExcalidrawAPI] = useState<ExcalidrawImperativeAPI | null>(null);
|
||||
|
||||
const [showTemplates, setShowTemplates] = useState(false);
|
||||
|
||||
@@ -144,13 +139,13 @@ export const Editor: React.FC = () => {
|
||||
}, [id]);
|
||||
|
||||
// Handle changes from Excalidraw
|
||||
const handleExcalidrawChange = useCallback((elements: readonly unknown[], appState: Record<string, unknown>, files: Record<string, { dataURL: string; mimeType: string }>) => {
|
||||
const handleExcalidrawChange = useCallback((elements: readonly ExcalidrawElement[], appState: Record<string, unknown>, files: Record<string, { dataURL: string; mimeType: string }>) => {
|
||||
const selectedIds = Object.keys((appState.selectedElementIds as Record<string, boolean> | undefined) || {});
|
||||
const selectedCheckbox = selectedIds.length === 1
|
||||
? (elements as any[]).find((el) => (
|
||||
? elements.find((el) => (
|
||||
el.id === selectedIds[0] &&
|
||||
!el.isDeleted &&
|
||||
el.customData?.templateRole === 'checkbox'
|
||||
(el.customData as Record<string, unknown> | undefined)?.templateRole === 'checkbox'
|
||||
))
|
||||
: null;
|
||||
|
||||
@@ -158,23 +153,23 @@ export const Editor: React.FC = () => {
|
||||
lastToggledCheckboxRef.current = null;
|
||||
} else if (excalidrawAPI && lastToggledCheckboxRef.current !== selectedCheckbox.id) {
|
||||
lastToggledCheckboxRef.current = selectedCheckbox.id;
|
||||
const nextChecked = !selectedCheckbox.customData?.checked;
|
||||
const nextElements = (elements as any[]).map((el) => (
|
||||
const nextChecked = !((selectedCheckbox.customData as Record<string, unknown> | undefined)?.checked as boolean);
|
||||
const nextElements = elements.map((el) => (
|
||||
el.id === selectedCheckbox.id
|
||||
? {
|
||||
...el,
|
||||
backgroundColor: nextChecked ? '#a5eba8' : 'transparent',
|
||||
customData: {
|
||||
...(el.customData || {}),
|
||||
...((el.customData as Record<string, unknown> | undefined) || {}),
|
||||
checked: nextChecked,
|
||||
},
|
||||
version: (el.version || 1) + 1,
|
||||
version: el.version + 1,
|
||||
versionNonce: Math.floor(Math.random() * 1000000),
|
||||
updated: Date.now(),
|
||||
}
|
||||
: el
|
||||
));
|
||||
excalidrawAPI.updateScene({ elements: nextElements });
|
||||
excalidrawAPI.updateScene({ elements: nextElements as ExcalidrawElement[] });
|
||||
currentStateRef.current = {
|
||||
elements: nextElements,
|
||||
appState: appStateWithoutGrid(appState),
|
||||
@@ -185,7 +180,7 @@ export const Editor: React.FC = () => {
|
||||
}
|
||||
|
||||
currentStateRef.current = {
|
||||
elements: elements as ExcalidrawElement[],
|
||||
elements: elements as unknown as ExcalidrawElement[],
|
||||
appState: appStateWithoutGrid(appState),
|
||||
files,
|
||||
};
|
||||
@@ -286,14 +281,14 @@ export const Editor: React.FC = () => {
|
||||
if (!templateElements || !excalidrawAPI) return;
|
||||
const currentElements = excalidrawAPI.getSceneElements?.() || [];
|
||||
let offsetX = 100;
|
||||
let offsetY = 100;
|
||||
const offsetY = 100;
|
||||
if (currentElements.length > 0) {
|
||||
const maxX = Math.max(...currentElements.map((el: any) => (el.x || 0) + (el.width || 0)));
|
||||
const maxX = Math.max(...currentElements.map((el) => (el.x + el.width)));
|
||||
offsetX = maxX + 100;
|
||||
}
|
||||
const newElements = prepareElementsForImport(templateElements, offsetX, offsetY);
|
||||
const mergedElements = [...currentElements, ...newElements];
|
||||
excalidrawAPI.updateScene({ elements: mergedElements });
|
||||
excalidrawAPI.updateScene({ elements: mergedElements as ExcalidrawElement[] });
|
||||
setShowTemplates(false);
|
||||
setSaveStatus('unsaved');
|
||||
};
|
||||
@@ -407,7 +402,7 @@ export const Editor: React.FC = () => {
|
||||
{initialData && (
|
||||
<React.Suspense fallback={<div className={styles.loadingCanvas}>{t('editor.loadingCanvas')}</div>}>
|
||||
<Excalidraw
|
||||
excalidrawAPI={(api: any) => setExcalidrawAPI(api)}
|
||||
excalidrawAPI={(api: ExcalidrawImperativeAPI) => setExcalidrawAPI(api)}
|
||||
initialData={initialData}
|
||||
onChange={handleExcalidrawChange}
|
||||
theme={appTheme === 'dark' ? 'dark' : 'light'}
|
||||
|
||||
@@ -273,7 +273,12 @@ export const FileBrowser: React.FC = () => {
|
||||
<select
|
||||
className={styles.filterSelect}
|
||||
value={visibilityFilter}
|
||||
onChange={(e) => setVisibilityFilter(e.target.value as any)}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (v === 'all' || v === 'private' || v === 'team' || v === 'public-link') {
|
||||
setVisibilityFilter(v);
|
||||
}
|
||||
}}
|
||||
aria-label="Filter by visibility"
|
||||
title="Filter by visibility"
|
||||
>
|
||||
@@ -287,8 +292,12 @@ export const FileBrowser: React.FC = () => {
|
||||
value={`${sortBy}-${sortOrder}`}
|
||||
onChange={(e) => {
|
||||
const [sb, so] = e.target.value.split('-');
|
||||
setSortBy(sb as any);
|
||||
setSortOrder(so as any);
|
||||
if (sb === 'name' || sb === 'updated' || sb === 'created') {
|
||||
setSortBy(sb);
|
||||
}
|
||||
if (so === 'asc' || so === 'desc') {
|
||||
setSortOrder(so);
|
||||
}
|
||||
}}
|
||||
aria-label="Sort drawings"
|
||||
title="Sort drawings"
|
||||
|
||||
@@ -70,8 +70,9 @@ export const TeamSettings: React.FC = () => {
|
||||
setNewEmail('');
|
||||
setNewPassword('');
|
||||
setNewRole('editor');
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Failed to create user');
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to create user';
|
||||
setError(message);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ export const Templates: React.FC = () => {
|
||||
try {
|
||||
const t = await api.templates.create({ name: name.trim(), type: 'empty', scope: 'personal' });
|
||||
setTemplates([t, ...templates]); setShowModal(false); setName('');
|
||||
} catch (err) { setError('Create failed'); }
|
||||
} catch { setError('Create failed'); }
|
||||
finally { setCreating(false); }
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user