Files
Zero/apps/mail/hooks/use-compose-editor.ts
Adam 277f476575 cleanup on isle zero (#1699)
Ran oxc (https://oxc.rs/docs/guide/usage/linter.html#vscode-extension) and fixed all the issues that came up, set it up to run as a PR check and added steps to the README.md asking users to use it.

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **New Features**
  * Introduced JavaScript linting using oxlint in development guidelines and CI workflow for improved code quality.
  * Added oxlint configuration and dependencies to the project.

* **Bug Fixes**
  * Improved error logging in various components and utilities for better debugging.
  * Enhanced React list rendering by updating keys to use unique values instead of array indices, reducing rendering issues.
  * Replaced browser alerts with toast notifications for a smoother user experience.

* **Refactor**
  * Simplified component logic and state management by removing unused code, imports, props, and components across multiple files.
  * Updated function and component signatures for clarity and maintainability.
  * Improved efficiency of certain operations by switching from arrays to sets for membership checks.

* **Chores**
  * Cleaned up and reorganized import statements throughout the codebase.
  * Removed deprecated files, components, and middleware to streamline the codebase.

* **Documentation**
  * Updated contribution guidelines to include linting requirements for code submissions.

* **Style**
  * Minor formatting and readability improvements in JSX and code structure.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-10 10:59:40 -07:00

298 lines
7.4 KiB
TypeScript

import { useEditor, type KeyboardShortcutCommand, Extension, generateJSON } from '@tiptap/react';
import { AutoComplete } from '@/components/create/editor-autocomplete';
import { defaultExtensions } from '@/components/create/extensions';
import Emoji, { gitHubEmojis } from '@tiptap/extension-emoji';
import { FileHandler } from '@tiptap/extension-file-handler';
import Placeholder from '@tiptap/extension-placeholder';
import { Plugin, PluginKey } from '@tiptap/pm/state';
import { TextSelection } from 'prosemirror-state';
import { Image } from '@tiptap/extension-image';
import { Markdown } from 'tiptap-markdown';
import { isObjectType } from 'remeda';
import { cn } from '@/lib/utils';
const CustomModEnter = (onModEnter: KeyboardShortcutCommand) => {
return Extension.create({
name: 'handleModEnter',
addKeyboardShortcuts: () => {
return {
'Mod-Enter': (props) => {
return onModEnter(props);
},
};
},
});
};
const CustomModTab = (onTab: KeyboardShortcutCommand) => {
return Extension.create({
name: 'handleTab',
addKeyboardShortcuts: () => {
return {
Tab: (props) => {
return onTab(props);
},
};
},
});
};
const MouseDownSelection = Extension.create({
name: 'mouseDownSelection',
addProseMirrorPlugins: () => {
return [
new Plugin({
key: new PluginKey('mouseDownSelection'),
props: {
handleDOMEvents: {
mousedown: (view, event) => {
const coords = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
if (coords) {
const pos = coords.pos;
const tr = view.state.tr;
const selection = TextSelection.create(view.state.doc, pos);
tr.setSelection(selection);
view.dispatch(tr);
view.focus();
}
return false;
},
},
},
}),
];
},
});
const AutoCompleteExtension = ({
sender,
myInfo,
}: {
sender?: {
name?: string;
email?: string;
};
myInfo?: {
name?: string;
email?: string;
};
} = {}) => {
return AutoComplete.configure({
suggestions: {
openers: [
'Hi there,',
'Hello,',
'Dear',
'Greetings,',
'Good morning,',
'Good afternoon,',
'Good evening,',
],
closers: ['Best regards,', 'Kind regards,', 'Sincerely,', 'Thanks,', 'Thank you,', 'Cheers,'],
custom: [
'I hope this email finds you well.',
'I look forward to hearing from you.',
'Please let me know if you have any questions.',
],
},
sender,
myInfo,
});
};
const useComposeEditor = ({
initialValue,
isReadOnly,
placeholder,
onChange,
onLengthChange,
onBlur,
onFocus,
onKeydown,
onMousedown,
onModEnter,
onTab,
myInfo,
sender,
autofocus = false,
}: {
initialValue?: Record<string, unknown> | string | null;
isReadOnly?: boolean;
placeholder?: string;
// Events
onChange?: (content: Record<string, unknown>) => void | Promise<void>;
onAttachmentsChange?: (attachments: File[]) => void | Promise<void>;
onLengthChange?: (length: number) => void | Promise<void>;
onBlur?: NonNullable<Parameters<typeof useEditor>[0]>['onBlur'];
onFocus?: NonNullable<Parameters<typeof useEditor>[0]>['onFocus'];
onKeydown?: (event: KeyboardEvent) => void | Promise<void>;
onMousedown?: (event: MouseEvent) => void | Promise<void>;
// Keyboard Shortcuts
onModEnter?: KeyboardShortcutCommand;
onTab?: KeyboardShortcutCommand;
// State Information
myInfo?: {
name?: string;
email?: string;
};
sender?: {
name?: string;
email?: string;
};
autofocus?: boolean;
}) => {
const extensions = [
...defaultExtensions,
Markdown,
Image,
FileHandler.configure({
allowedMimeTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
onDrop: (currentEditor, files, pos) => {
files.forEach((file) => {
const fileReader = new FileReader();
fileReader.readAsDataURL(file);
fileReader.onload = () => {
currentEditor
.chain()
.insertContentAt(pos, {
type: 'image',
attrs: {
src: fileReader.result,
},
})
.focus()
.run();
};
});
},
onPaste: (currentEditor, files, htmlContent) => {
files.forEach((file) => {
if (htmlContent) {
console.log(htmlContent); // eslint-disable-line no-console
return false;
}
const fileReader = new FileReader();
fileReader.readAsDataURL(file);
fileReader.onload = () => {
currentEditor
.chain()
.insertContentAt(currentEditor.state.selection.anchor, {
type: 'image',
attrs: {
src: fileReader.result,
},
})
.focus()
.run();
};
});
},
}),
AutoCompleteExtension({
myInfo,
sender,
}),
...(onModEnter
? [
CustomModEnter((props) => {
return onModEnter(props);
}),
]
: []),
...(onTab
? [
CustomModTab((props) => {
return onTab(props);
}),
]
: []),
...(isReadOnly ? [] : [MouseDownSelection]),
Placeholder.configure({
placeholder,
}),
Emoji.configure({
emojis: gitHubEmojis,
enableEmoticons: true,
// suggestion,
}),
// breaks the image upload
// ...(onAttachmentsChange
// ? [
// PreventNavigateOnDragOver((files) => {
// onAttachmentsChange(files);
// }),
// ]
// : []),
];
return useEditor({
editable: !isReadOnly,
autofocus: autofocus ? 'end' : false,
onCreate: ({ editor }) => {
// if (onLengthChange) {
// const content = editor.getText();
// void onLengthChange(content.length);
// }
if (autofocus) {
setTimeout(() => {
editor.commands.focus('end');
}, 100);
}
},
onUpdate: ({ editor }) => {
if (onChange) {
void onChange(editor.getJSON());
}
if (onLengthChange) {
const content = editor.getText();
void onLengthChange(content.length);
}
},
content: initialValue
? isObjectType(initialValue)
? initialValue
: generateJSON(initialValue, extensions)
: undefined,
immediatelyRender: true,
shouldRerenderOnTransaction: false,
extensions,
onFocus: isReadOnly ? undefined : onFocus,
onBlur: isReadOnly ? undefined : onBlur,
editorProps: {
attributes: {
class: cn(
'prose dark:prose-invert prose-headings:font-title focus:outline-none max-w-full',
isReadOnly && 'pointer-events-none select-text',
),
},
handleDOMEvents: {
mousedown: (_, event) => {
if (onMousedown && !isReadOnly) {
void onMousedown(event);
}
return false;
},
keydown: (_, event) => {
if (onKeydown && !isReadOnly) {
void onKeydown(event);
}
return false;
},
},
},
});
};
export default useComposeEditor;