mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
448 lines
16 KiB
TypeScript
448 lines
16 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import * as AccessibilityCommands from '@app/features/accessibility/commands/AccessibilityCommands';
|
|
import Accessibility from '@app/features/accessibility/state/Accessibility';
|
|
import {UserSettingsModal} from '@app/features/app/components/dialogs/LoadableSettingsModals';
|
|
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
|
import Spellcheck from '@app/features/messaging/state/Spellcheck';
|
|
import {isEditableTextInput, replaceSelectedText} from '@app/features/messaging/utils/TextInputEditUtils';
|
|
import type {SpellcheckEngine} from '@app/features/platform/types/Electron';
|
|
import {CheckboxItem, MenuSeparator} from '@app/features/ui/action_menu/ContextMenu';
|
|
import {MenuGroup} from '@app/features/ui/action_menu/MenuGroup';
|
|
import {MenuItem} from '@app/features/ui/action_menu/MenuItem';
|
|
import {MenuItemRadio} from '@app/features/ui/action_menu/MenuItemRadio';
|
|
import {MenuItemSubmenu} from '@app/features/ui/action_menu/MenuItemSubmenu';
|
|
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
|
import {getElectronAPI, isElectron} from '@app/features/ui/utils/NativeUtils';
|
|
import {msg} from '@lingui/core/macro';
|
|
import {useLingui} from '@lingui/react/macro';
|
|
import {
|
|
ArrowClockwiseIcon,
|
|
ArrowCounterClockwiseIcon,
|
|
ClipboardTextIcon,
|
|
CopyIcon,
|
|
ScissorsIcon,
|
|
SelectionIcon,
|
|
} from '@phosphor-icons/react';
|
|
import {observer} from 'mobx-react-lite';
|
|
|
|
const REMOVE_FROM_DICTIONARY_DESCRIPTOR = msg({
|
|
message: 'Remove from dictionary',
|
|
comment:
|
|
'Textarea right-click context menu item that removes the selected misspelled word from the personal dictionary.',
|
|
});
|
|
const ADD_TO_DICTIONARY_DESCRIPTOR = msg({
|
|
message: 'Add to dictionary',
|
|
comment: 'Textarea right-click context menu item that adds the selected misspelled word to the personal dictionary.',
|
|
});
|
|
const UNDO_DESCRIPTOR = msg({
|
|
message: 'Undo',
|
|
comment: 'Textarea right-click context menu item that undoes the last edit.',
|
|
});
|
|
const REDO_DESCRIPTOR = msg({
|
|
message: 'Redo',
|
|
comment: 'Textarea right-click context menu item that redoes the last undone edit.',
|
|
});
|
|
const CUT_DESCRIPTOR = msg({
|
|
message: 'Cut',
|
|
comment: 'Textarea right-click context menu item that cuts the selected text.',
|
|
});
|
|
const COPY_DESCRIPTOR = msg({
|
|
message: 'Copy',
|
|
comment: 'Textarea right-click context menu item that copies the selected text.',
|
|
});
|
|
const PASTE_DESCRIPTOR = msg({
|
|
message: 'Paste',
|
|
comment: 'Textarea right-click context menu item that pastes clipboard contents.',
|
|
});
|
|
const SELECT_ALL_DESCRIPTOR = msg({
|
|
message: 'Select all',
|
|
comment: 'Textarea right-click context menu item that selects all text in the textarea.',
|
|
});
|
|
const SPELLCHECK_DESCRIPTOR = msg({
|
|
message: 'Spellcheck',
|
|
comment: 'Submenu label in the textarea right-click context menu for spellcheck preferences.',
|
|
});
|
|
const ENABLED_DESCRIPTOR = msg({
|
|
message: 'Enabled',
|
|
comment: 'Toggle item in the textarea spellcheck submenu. Master enable for spellcheck.',
|
|
});
|
|
const AUTO_DESCRIPTOR = msg({
|
|
message: 'Recommended',
|
|
comment:
|
|
'Spellcheck engine option in the textarea spellcheck submenu. Lets the app choose the best available engine.',
|
|
});
|
|
const IN_APP_HUNSPELL_DESCRIPTOR = msg({
|
|
message: 'In-app dictionaries',
|
|
comment: 'Spellcheck engine option in the textarea spellcheck submenu. The bundled in-app hunspell engine.',
|
|
});
|
|
const OPERATING_SYSTEM_DESCRIPTOR = msg({
|
|
message: 'Operating system',
|
|
comment: 'Spellcheck engine option in the textarea spellcheck submenu. Uses the host operating system spellchecker.',
|
|
});
|
|
const AUTO_DETECT_LANGUAGE_DESCRIPTOR = msg({
|
|
message: 'Detect while typing',
|
|
comment:
|
|
'Item in the textarea spellcheck language submenu that detects draft message language and switches dictionaries as the user types.',
|
|
});
|
|
const LANGUAGES_DESCRIPTOR = msg({
|
|
message: 'Languages',
|
|
comment: 'Submenu label in the textarea spellcheck submenu listing manual language overrides.',
|
|
});
|
|
const RELOAD_TO_APPLY_ENGINE_CHANGE_DESCRIPTOR = msg({
|
|
message: 'Reload to apply engine change',
|
|
comment: 'Hint in the textarea spellcheck submenu shown after switching engines.',
|
|
});
|
|
const SPELLCHECK_SETTINGS_DESCRIPTOR = msg({
|
|
message: '{productName} spellcheck settings…',
|
|
comment:
|
|
'Item in the textarea spellcheck submenu that opens the full settings page. Trailing horizontal ellipsis (…) indicates the action opens settings.',
|
|
});
|
|
|
|
const SHOW_SEND_BUTTON_DESCRIPTOR = msg({
|
|
message: 'Show send button',
|
|
comment: 'Short label in the channel and chat textarea plus menu. Keep it concise.',
|
|
});
|
|
|
|
export interface TextareaContextMenuEditFlags {
|
|
canUndo: boolean;
|
|
canRedo: boolean;
|
|
canCut: boolean;
|
|
canCopy: boolean;
|
|
canPaste: boolean;
|
|
canSelectAll: boolean;
|
|
}
|
|
|
|
interface TextareaContextMenuProps {
|
|
misspelledWord?: string;
|
|
suggestions?: Array<string>;
|
|
editFlags?: TextareaContextMenuEditFlags;
|
|
targetElement?: HTMLElement | null;
|
|
showSendButtonToggle?: boolean;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export const TextareaContextMenu = observer(
|
|
({
|
|
misspelledWord,
|
|
suggestions = [],
|
|
editFlags,
|
|
targetElement,
|
|
showSendButtonToggle = false,
|
|
onClose,
|
|
}: TextareaContextMenuProps) => {
|
|
const {i18n} = useLingui();
|
|
const showMessageSendButton = Accessibility.showMessageSendButton;
|
|
const electronAPI = isElectron() ? getElectronAPI() : null;
|
|
const focusTargetElement = () => {
|
|
if (!targetElement?.isConnected) {
|
|
return;
|
|
}
|
|
targetElement.focus({preventScroll: true});
|
|
};
|
|
const handleAddToDictionary = async () => {
|
|
if (!misspelledWord) return;
|
|
Spellcheck.addPersonalWord(misspelledWord);
|
|
if (electronAPI?.spellcheckAddWordToDictionary) {
|
|
await electronAPI.spellcheckAddWordToDictionary(misspelledWord);
|
|
}
|
|
onClose();
|
|
};
|
|
const handleRemoveFromDictionary = () => {
|
|
if (!misspelledWord) return;
|
|
Spellcheck.removePersonalWord(misspelledWord);
|
|
onClose();
|
|
};
|
|
const isInPersonalDict = misspelledWord ? Spellcheck.personalDictionary.includes(misspelledWord) : false;
|
|
const runAfterClose = (action: () => void) => {
|
|
onClose();
|
|
requestAnimationFrame(() => {
|
|
focusTargetElement();
|
|
requestAnimationFrame(action);
|
|
});
|
|
};
|
|
const handleReplaceMisspelling = (suggestion: string) => {
|
|
runAfterClose(() => {
|
|
void electronAPI?.spellcheckReplaceMisspelling?.(suggestion);
|
|
});
|
|
};
|
|
const execCommand = (command: string) => {
|
|
runAfterClose(() => {
|
|
document.execCommand(command);
|
|
});
|
|
};
|
|
const insertTextInActiveEditable = (text: string): boolean => {
|
|
if (!text) return true;
|
|
const active = document.activeElement;
|
|
if (isEditableTextInput(active)) {
|
|
return replaceSelectedText(active, text);
|
|
}
|
|
return false;
|
|
};
|
|
const handlePaste = () => {
|
|
runAfterClose(() => {
|
|
if (electronAPI?.pasteFromClipboard) {
|
|
void electronAPI.pasteFromClipboard();
|
|
return;
|
|
}
|
|
const readClipboard = electronAPI?.clipboardReadText;
|
|
if (readClipboard) {
|
|
void readClipboard()
|
|
.then((text: string) => {
|
|
insertTextInActiveEditable(text);
|
|
})
|
|
.catch(() => {});
|
|
return;
|
|
}
|
|
if (navigator.clipboard?.readText) {
|
|
void navigator.clipboard.readText().then((text) => {
|
|
insertTextInActiveEditable(text);
|
|
});
|
|
return;
|
|
}
|
|
});
|
|
};
|
|
const handleOpenSpellcheckSettings = () => {
|
|
ModalCommands.push(
|
|
ModalCommands.modal(
|
|
() => (
|
|
<UserSettingsModal
|
|
initialTab="language"
|
|
data-flx="channel.textarea.textarea-context-menu.handle-open-spellcheck-settings.user-settings-modal"
|
|
/>
|
|
),
|
|
'user-settings',
|
|
),
|
|
);
|
|
onClose();
|
|
};
|
|
const isPasswordTarget = targetElement instanceof HTMLInputElement && targetElement.type === 'password';
|
|
const spellcheckEnabled = Spellcheck.enabled;
|
|
const hasMisspelling = spellcheckEnabled && misspelledWord && suggestions.length > 0;
|
|
return (
|
|
<>
|
|
{hasMisspelling && (
|
|
<>
|
|
<MenuGroup data-flx="channel.textarea.textarea-context-menu.menu-group">
|
|
{suggestions.slice(0, 6).map((suggestion) => (
|
|
<MenuItem
|
|
key={suggestion}
|
|
onClick={() => handleReplaceMisspelling(suggestion)}
|
|
data-flx="channel.textarea.textarea-context-menu.menu-item.replace-misspelling"
|
|
>
|
|
{suggestion}
|
|
</MenuItem>
|
|
))}
|
|
</MenuGroup>
|
|
<MenuGroup data-flx="channel.textarea.textarea-context-menu.menu-group--2">
|
|
{isInPersonalDict ? (
|
|
<MenuItem
|
|
onClick={handleRemoveFromDictionary}
|
|
data-flx="channel.textarea.textarea-context-menu.menu-item.remove-from-dictionary"
|
|
>
|
|
{i18n._(REMOVE_FROM_DICTIONARY_DESCRIPTOR)}
|
|
</MenuItem>
|
|
) : (
|
|
<MenuItem
|
|
onClick={handleAddToDictionary}
|
|
data-flx="channel.textarea.textarea-context-menu.menu-item.add-to-dictionary"
|
|
>
|
|
{i18n._(ADD_TO_DICTIONARY_DESCRIPTOR)}
|
|
</MenuItem>
|
|
)}
|
|
</MenuGroup>
|
|
</>
|
|
)}
|
|
<MenuGroup data-flx="channel.textarea.textarea-context-menu.menu-group--3">
|
|
<MenuItem
|
|
icon={
|
|
<ArrowCounterClockwiseIcon data-flx="channel.textarea.textarea-context-menu.arrow-counter-clockwise-icon" />
|
|
}
|
|
onClick={() => execCommand('undo')}
|
|
disabled={!editFlags?.canUndo}
|
|
data-flx="channel.textarea.textarea-context-menu.menu-item.exec-command"
|
|
>
|
|
{i18n._(UNDO_DESCRIPTOR)}
|
|
</MenuItem>
|
|
<MenuItem
|
|
icon={<ArrowClockwiseIcon data-flx="channel.textarea.textarea-context-menu.arrow-clockwise-icon" />}
|
|
onClick={() => execCommand('redo')}
|
|
disabled={!editFlags?.canRedo}
|
|
data-flx="channel.textarea.textarea-context-menu.menu-item.exec-command--2"
|
|
>
|
|
{i18n._(REDO_DESCRIPTOR)}
|
|
</MenuItem>
|
|
</MenuGroup>
|
|
<MenuGroup data-flx="channel.textarea.textarea-context-menu.menu-group--4">
|
|
<MenuItem
|
|
icon={<ScissorsIcon data-flx="channel.textarea.textarea-context-menu.scissors-icon" />}
|
|
onClick={() => execCommand('cut')}
|
|
disabled={!editFlags?.canCut}
|
|
data-flx="channel.textarea.textarea-context-menu.menu-item.exec-command--3"
|
|
>
|
|
{i18n._(CUT_DESCRIPTOR)}
|
|
</MenuItem>
|
|
<MenuItem
|
|
icon={<CopyIcon data-flx="channel.textarea.textarea-context-menu.copy-icon" />}
|
|
onClick={() => execCommand('copy')}
|
|
disabled={!editFlags?.canCopy}
|
|
data-flx="channel.textarea.textarea-context-menu.menu-item.exec-command--4"
|
|
>
|
|
{i18n._(COPY_DESCRIPTOR)}
|
|
</MenuItem>
|
|
<MenuItem
|
|
icon={<ClipboardTextIcon data-flx="channel.textarea.textarea-context-menu.clipboard-text-icon" />}
|
|
onClick={() => handlePaste()}
|
|
disabled={!editFlags?.canPaste}
|
|
data-flx="channel.textarea.textarea-context-menu.menu-item.paste"
|
|
>
|
|
{i18n._(PASTE_DESCRIPTOR)}
|
|
</MenuItem>
|
|
<MenuItem
|
|
icon={<SelectionIcon data-flx="channel.textarea.textarea-context-menu.selection-icon" />}
|
|
onClick={() => execCommand('selectAll')}
|
|
disabled={!editFlags?.canSelectAll}
|
|
data-flx="channel.textarea.textarea-context-menu.menu-item.exec-command--5"
|
|
>
|
|
{i18n._(SELECT_ALL_DESCRIPTOR)}
|
|
</MenuItem>
|
|
</MenuGroup>
|
|
{!isPasswordTarget && (
|
|
<MenuGroup data-flx="channel.textarea.textarea-context-menu.menu-group--5">
|
|
<MenuItemSubmenu
|
|
label={i18n._(SPELLCHECK_DESCRIPTOR)}
|
|
render={() => (
|
|
<SpellcheckSubmenu
|
|
isElectron={isElectron()}
|
|
onOpenSpellcheckSettings={handleOpenSpellcheckSettings}
|
|
data-flx="channel.textarea.textarea-context-menu.spellcheck-submenu"
|
|
/>
|
|
)}
|
|
data-flx="channel.textarea.textarea-context-menu.menu-item-submenu"
|
|
/>
|
|
</MenuGroup>
|
|
)}
|
|
{showSendButtonToggle && (
|
|
<MenuGroup data-flx="channel.textarea.textarea-context-menu.menu-group--6">
|
|
<CheckboxItem
|
|
checked={showMessageSendButton}
|
|
onCheckedChange={(checked) => AccessibilityCommands.update({showMessageSendButton: checked})}
|
|
closeOnChange={false}
|
|
data-flx="channel.textarea.textarea-context-menu.checkbox-item.show-send-button"
|
|
>
|
|
{i18n._(SHOW_SEND_BUTTON_DESCRIPTOR)}
|
|
</CheckboxItem>
|
|
</MenuGroup>
|
|
)}
|
|
</>
|
|
);
|
|
},
|
|
);
|
|
|
|
interface SpellcheckSubmenuProps {
|
|
isElectron: boolean;
|
|
onOpenSpellcheckSettings: () => void;
|
|
}
|
|
|
|
const SpellcheckSubmenu = observer(({isElectron: electron, onOpenSpellcheckSettings}: SpellcheckSubmenuProps) => {
|
|
const {i18n} = useLingui();
|
|
const enabled = Spellcheck.enabled;
|
|
if (!electron) {
|
|
return (
|
|
<CheckboxItem
|
|
checked={enabled}
|
|
onCheckedChange={(value) => Spellcheck.setEnabled(value)}
|
|
data-flx="channel.textarea.textarea-context-menu.spellcheck-submenu.checkbox-item"
|
|
>
|
|
{i18n._(ENABLED_DESCRIPTOR)}
|
|
</CheckboxItem>
|
|
);
|
|
}
|
|
const engineLabels: Record<SpellcheckEngine, string> = {
|
|
auto: i18n._(AUTO_DESCRIPTOR),
|
|
hunspell: i18n._(IN_APP_HUNSPELL_DESCRIPTOR),
|
|
system: i18n._(OPERATING_SYSTEM_DESCRIPTOR),
|
|
};
|
|
const engineOrder: Array<SpellcheckEngine> = ['auto', 'hunspell', 'system'];
|
|
const selectedTagSet = new Set(Spellcheck.languages.map((l) => l.toLowerCase()));
|
|
const dictionaries = Spellcheck.bundledDictionaries;
|
|
const showLanguagesSubmenu = !Spellcheck.autoDetect && dictionaries.length > 0;
|
|
return (
|
|
<>
|
|
<CheckboxItem
|
|
checked={enabled}
|
|
onCheckedChange={(value) => Spellcheck.setEnabled(value)}
|
|
data-flx="channel.textarea.textarea-context-menu.spellcheck-submenu.checkbox-item--2"
|
|
>
|
|
{i18n._(ENABLED_DESCRIPTOR)}
|
|
</CheckboxItem>
|
|
{enabled && (
|
|
<>
|
|
<MenuSeparator data-flx="channel.textarea.textarea-context-menu.spellcheck-submenu.menu-separator" />
|
|
{engineOrder.map((engine) => (
|
|
<MenuItemRadio
|
|
key={engine}
|
|
selected={Spellcheck.engine === engine}
|
|
onSelect={() => Spellcheck.setEngine(engine)}
|
|
data-flx="channel.textarea.textarea-context-menu.spellcheck-submenu.menu-item-radio.set-engine"
|
|
>
|
|
{engineLabels[engine]}
|
|
</MenuItemRadio>
|
|
))}
|
|
<MenuSeparator data-flx="channel.textarea.textarea-context-menu.spellcheck-submenu.menu-separator--2" />
|
|
<CheckboxItem
|
|
checked={Spellcheck.autoDetect}
|
|
onCheckedChange={(value) => Spellcheck.setAutoDetect(value)}
|
|
data-flx="channel.textarea.textarea-context-menu.spellcheck-submenu.checkbox-item--3"
|
|
>
|
|
{i18n._(AUTO_DETECT_LANGUAGE_DESCRIPTOR)}
|
|
</CheckboxItem>
|
|
{showLanguagesSubmenu && (
|
|
<MenuItemSubmenu
|
|
label={i18n._(LANGUAGES_DESCRIPTOR)}
|
|
render={() => (
|
|
<>
|
|
{dictionaries.map((dict) => {
|
|
const checked = selectedTagSet.has(dict.tag.toLowerCase());
|
|
return (
|
|
<CheckboxItem
|
|
key={`${dict.package}-${dict.tag}`}
|
|
checked={checked}
|
|
onCheckedChange={() => Spellcheck.toggleLanguage(dict.tag)}
|
|
data-flx="channel.textarea.textarea-context-menu.spellcheck-submenu.checkbox-item--4"
|
|
>
|
|
{dict.nativeName} ({dict.tag})
|
|
</CheckboxItem>
|
|
);
|
|
})}
|
|
</>
|
|
)}
|
|
data-flx="channel.textarea.textarea-context-menu.spellcheck-submenu.menu-item-submenu"
|
|
/>
|
|
)}
|
|
{Spellcheck.reloadRequired && (
|
|
<>
|
|
<MenuSeparator data-flx="channel.textarea.textarea-context-menu.spellcheck-submenu.menu-separator--3" />
|
|
<MenuItem
|
|
onClick={() => {
|
|
if (typeof window !== 'undefined') window.location.reload();
|
|
}}
|
|
data-flx="channel.textarea.textarea-context-menu.spellcheck-submenu.menu-item"
|
|
>
|
|
{i18n._(RELOAD_TO_APPLY_ENGINE_CHANGE_DESCRIPTOR)}
|
|
</MenuItem>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
<MenuSeparator data-flx="channel.textarea.textarea-context-menu.spellcheck-submenu.menu-separator--4" />
|
|
<MenuItem
|
|
onClick={onOpenSpellcheckSettings}
|
|
data-flx="channel.textarea.textarea-context-menu.spellcheck-submenu.menu-item.open-spellcheck-settings"
|
|
>
|
|
{i18n._(SPELLCHECK_SETTINGS_DESCRIPTOR, {productName: PRODUCT_NAME})}
|
|
</MenuItem>
|
|
</>
|
|
);
|
|
});
|