From 7ec02fc8d84745cc227d25f4dd567a7ccd64901b Mon Sep 17 00:00:00 2001 From: Abdelrrahman Elhaddad <113056556+ab-elhaddad@users.noreply.github.com> Date: Sat, 20 Apr 2024 15:23:07 +0200 Subject: [PATCH] Desktop: Added search list for configuration font input fields (#10248) --- .eslintignore | 1 + .gitignore | 1 + .../gui/ConfigScreen/ConfigScreen.tsx | 52 +++- .../gui/ConfigScreen/FontSearch.tsx | 232 ++++++++++++++++++ .../app-desktop/gui/ConfigScreen/style.scss | 32 +++ packages/lib/models/Setting.ts | 4 + 6 files changed, 309 insertions(+), 13 deletions(-) create mode 100644 packages/app-desktop/gui/ConfigScreen/FontSearch.tsx diff --git a/.eslintignore b/.eslintignore index f87898932..acb6112e2 100644 --- a/.eslintignore +++ b/.eslintignore @@ -167,6 +167,7 @@ packages/app-desktop/gui/Button/Button.js packages/app-desktop/gui/ClipperConfigScreen.js packages/app-desktop/gui/ConfigScreen/ButtonBar.js packages/app-desktop/gui/ConfigScreen/ConfigScreen.js +packages/app-desktop/gui/ConfigScreen/FontSearch.js packages/app-desktop/gui/ConfigScreen/Sidebar.js packages/app-desktop/gui/ConfigScreen/controls/MissingPasswordHelpLink.js packages/app-desktop/gui/ConfigScreen/controls/ToggleAdvancedSettingsButton.js diff --git a/.gitignore b/.gitignore index 1fce8b43c..f3740fe61 100644 --- a/.gitignore +++ b/.gitignore @@ -147,6 +147,7 @@ packages/app-desktop/gui/Button/Button.js packages/app-desktop/gui/ClipperConfigScreen.js packages/app-desktop/gui/ConfigScreen/ButtonBar.js packages/app-desktop/gui/ConfigScreen/ConfigScreen.js +packages/app-desktop/gui/ConfigScreen/FontSearch.js packages/app-desktop/gui/ConfigScreen/Sidebar.js packages/app-desktop/gui/ConfigScreen/controls/MissingPasswordHelpLink.js packages/app-desktop/gui/ConfigScreen/controls/ToggleAdvancedSettingsButton.js diff --git a/packages/app-desktop/gui/ConfigScreen/ConfigScreen.tsx b/packages/app-desktop/gui/ConfigScreen/ConfigScreen.tsx index 20aa6795c..cc7c2c7a0 100644 --- a/packages/app-desktop/gui/ConfigScreen/ConfigScreen.tsx +++ b/packages/app-desktop/gui/ConfigScreen/ConfigScreen.tsx @@ -4,7 +4,7 @@ import ButtonBar from './ButtonBar'; import Button, { ButtonLevel, ButtonSize } from '../Button/Button'; import { _ } from '@joplin/lib/locale'; import bridge from '../../services/bridge'; -import Setting, { AppType, SyncStartupOperation } from '@joplin/lib/models/Setting'; +import Setting, { AppType, SettingItemSubType, SyncStartupOperation } from '@joplin/lib/models/Setting'; import control_PluginsStates from './controls/plugins/PluginsStates'; import EncryptionConfigScreen from '../EncryptionConfigScreen/EncryptionConfigScreen'; import { reg } from '@joplin/lib/registry'; @@ -20,12 +20,23 @@ import ToggleAdvancedSettingsButton from './controls/ToggleAdvancedSettingsButto import shouldShowMissingPasswordWarning from '@joplin/lib/components/shared/config/shouldShowMissingPasswordWarning'; import MacOSMissingPasswordHelpLink from './controls/MissingPasswordHelpLink'; const { KeymapConfigScreen } = require('../KeymapConfig/KeymapConfigScreen'); +import FontSearch from './FontSearch'; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied const settingKeyToControl: any = { 'plugins.states': control_PluginsStates, }; +interface Font { + family: string; +} + +declare global { + interface Window { + queryLocalFonts(): Promise; + } +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied class ConfigScreenComponent extends React.Component { @@ -44,6 +55,7 @@ class ConfigScreenComponent extends React.Component { screenName: '', changedSettingKeys: [], needRestart: false, + fonts: [], }; this.rowStyle_ = { @@ -78,12 +90,16 @@ class ConfigScreenComponent extends React.Component { this.setState({ settings: this.props.settings }); } - public componentDidMount() { + public async componentDidMount() { if (this.props.defaultSection) { this.setState({ selectedSectionName: this.props.defaultSection }, () => { void this.switchSection(this.props.defaultSection); }); } + + const fonts = (await window.queryLocalFonts()).map((font: Font) => font.family); + const uniqueFonts = [...new Set(fonts)]; + this.setState({ fonts: uniqueFonts }); } private async handleSettingButton(key: string) { @@ -591,22 +607,32 @@ class ConfigScreenComponent extends React.Component { const onTextChange = (event: any) => { updateSettingValue(key, event.target.value); }; - return (
- { - onTextChange(event); - }} - spellCheck={false} - /> + { + md.subType === SettingItemSubType.FontFamily || md.subType === SettingItemSubType.MonospaceFontFamily ? + updateSettingValue(key, fontFamily)} + subtype={md.subType} + /> : + { + onTextChange(event); + }} + spellCheck={false} + /> + }
{descriptionComp}
diff --git a/packages/app-desktop/gui/ConfigScreen/FontSearch.tsx b/packages/app-desktop/gui/ConfigScreen/FontSearch.tsx new file mode 100644 index 000000000..d80a0d192 --- /dev/null +++ b/packages/app-desktop/gui/ConfigScreen/FontSearch.tsx @@ -0,0 +1,232 @@ +import React = require('react'); +import { useMemo, useState, useCallback, CSSProperties, useEffect, useRef } from 'react'; +import { _ } from '@joplin/lib/locale'; +import { SettingItemSubType } from '@joplin/lib/models/Setting'; +import { focus } from '@joplin/lib/utils/focusHandler'; + +interface Props { + type: string; + style: CSSProperties; + value: string; + availableFonts: string[]; + onChange: (font: string)=> void; + subtype: string; +} + +const FontSearch = (props: Props) => { + const { type, style, value, availableFonts, onChange, subtype } = props; + const [filteredAvailableFonts, setFilteredAvailableFonts] = useState(availableFonts); + const [inputText, setInputText] = useState(value); + const [showList, setShowList] = useState(false); + const [isListHovered, setIsListHovered] = useState(false); + const [isFontSelected, setIsFontSelected] = useState(value !== ''); + const [visibleFonts, setVisibleFonts] = useState([]); + const [isMonoBoxChecked, setIsMonoBoxChecked] = useState(false); + const isLoadingFonts = filteredAvailableFonts.length === 0; + const fontInputRef = useRef(null); + + useEffect(() => { + if (subtype === SettingItemSubType.MonospaceFontFamily) { + setIsMonoBoxChecked(true); + } + }, [subtype]); + + useEffect(() => { + if (!isMonoBoxChecked) return setFilteredAvailableFonts(availableFonts); + const localMonospacedFonts = availableFonts.filter((font: string) => + monospaceKeywords.some((word: string) => font.toLowerCase().includes(word)) || + knownMonospacedFonts.includes(font.toLowerCase()), + ); + setFilteredAvailableFonts(localMonospacedFonts); + }, [isMonoBoxChecked, availableFonts]); + + const displayedFonts = useMemo(() => { + if (isFontSelected) return filteredAvailableFonts; + return filteredAvailableFonts.filter((font: string) => + font.toLowerCase().startsWith(inputText.toLowerCase()), + ); + }, [filteredAvailableFonts, inputText, isFontSelected]); + + useEffect(() => { + setVisibleFonts(displayedFonts.slice(0, 20)); + }, [displayedFonts]); + + // Lazy loading + const handleListScroll: React.UIEventHandler = useCallback((event) => { + const scrollTop = (event.target as HTMLDivElement).scrollTop; + const scrollHeight = (event.target as HTMLDivElement).scrollHeight; + const clientHeight = (event.target as HTMLDivElement).clientHeight; + + // Check if the user has scrolled to the bottom of the container + // A small buffer of 20 pixels is subtracted from the total scrollHeight to ensure new content starts loading slightly before the user reaches the absolute bottom, providing a smoother experience. + if (scrollTop + clientHeight >= scrollHeight - 20) { + // Load the next 20 fonts + const remainingFonts = displayedFonts.slice(visibleFonts.length, visibleFonts.length + 20); + + setVisibleFonts([...visibleFonts, ...remainingFonts]); + } + }, [displayedFonts, visibleFonts]); + + const handleTextChange: React.ChangeEventHandler = useCallback((event) => { + setIsFontSelected(false); + setInputText(event.target.value); + onChange(event.target.value); + }, [onChange]); + + const handleFocus: React.FocusEventHandler = useCallback(() => setShowList(true), []); + + const handleBlur: React.FocusEventHandler = useCallback(() => { + if (!isListHovered) { + setShowList(false); + } + }, [isListHovered]); + + const handleFontClick: React.MouseEventHandler = useCallback((event) => { + const font = (event.target as HTMLDivElement).innerText; + setInputText(font); + setShowList(false); + onChange(font); + setIsFontSelected(true); + }, [onChange]); + + const handleListHover: React.MouseEventHandler = useCallback(() => setIsListHovered(true), []); + + const handleListLeave: React.MouseEventHandler = useCallback(() => setIsListHovered(false), []); + + const handleMonoBoxCheck: React.ChangeEventHandler = useCallback(() => { + setIsMonoBoxChecked(!isMonoBoxChecked); + focus('FontSearch::fontInputRef', fontInputRef.current); + }, [isMonoBoxChecked]); + + return ( + <> + +
+ { + isLoadingFonts ?
{_('Loading...')}
: + <> +
+ + +
+ { + visibleFonts.map((font: string) => +
+ {font} +
, + ) + } + + } +
+ + ); +}; + +export default FontSearch; + +// Known monospaced fonts from wikipedia +// https://en.wikipedia.org/wiki/List_of_monospaced_typefaces +// https://en.wikipedia.org/wiki/Category:Monospaced_typefaces +// Make sure to add the fonts in lower case +// cSpell:disable +const knownMonospacedFonts = [ + 'andalé mono', + 'anonymous pro', + 'bitstream vera sans mono', + 'cascadia code', + 'century schoolbook monospace', + 'comic mono', + 'computer modern mono/typewriter', + 'consolas', + 'courier', + 'courier final draft', + 'courier new', + 'courier prime', + 'courier screenplay', + 'cousine', + 'dejavu sans mono', + 'droid sans mono', + 'envy code r', + 'everson mono', + 'fantasque sans mono', + 'fira code', + 'fira mono', + 'fixed', + 'fixedsys', + 'freemono', + 'go mono', + 'hack', + 'hyperfont', + 'ibm courier', + 'ibm plex mono', + 'inconsolata', + 'input', + 'iosevka', + 'jetbrains mono', + 'juliamono', + 'letter gothic', + 'liberation mono', + 'lucida console', + 'menlo', + 'monaco', + 'monofur', + 'monospace (unicode)', + 'nimbus mono l', + 'nk57 monospace', + 'noto mono', + 'ocr-a', + 'ocr-b', + 'operator mono', + 'overpass mono', + 'oxygen mono', + 'pragmatapro', + 'profont', + 'pt mono', + 'recursive mono', + 'roboto mono', + 'sf mono', + 'source code pro', + 'spleen', + 'terminal', + 'terminus', + 'tex gyre cursor', + 'ubuntu mono', + 'victor mono', + 'wumpus mono', +]; + +const monospaceKeywords = [ + 'mono', + 'code', + 'courier', + 'console', + 'source code', + 'terminal', + 'fixed', +]; diff --git a/packages/app-desktop/gui/ConfigScreen/style.scss b/packages/app-desktop/gui/ConfigScreen/style.scss index a89a4f04a..a6b5f367a 100644 --- a/packages/app-desktop/gui/ConfigScreen/style.scss +++ b/packages/app-desktop/gui/ConfigScreen/style.scss @@ -12,3 +12,35 @@ .config-screen-content > .section:last-child { border-bottom: 0; } + +.font-search-list { + background-color: var(--joplin-background-color); + max-height: 200px; + width: 50%; + min-width: 20em; + overflow-y: auto; + border: 1px solid var(--joplin-border-color4); + border-radius: 5px; + display: none; + margin-top: 10px; +} + +.font-search-list > div { + padding: 5px; +} + +.font-search-item { + border-bottom: 1px solid var(--joplin-border-color4); + cursor: pointer; +} + +.font-search-item:hover { + color: var(--joplin-background-color); + background-color: var(--joplin-color); +} + +.monospace-checkbox { + background-color: var(--joplin-background-color3); + display: flex; + align-items: center; +} \ No newline at end of file diff --git a/packages/lib/models/Setting.ts b/packages/lib/models/Setting.ts index 52da7d7d1..1af9be5fa 100644 --- a/packages/lib/models/Setting.ts +++ b/packages/lib/models/Setting.ts @@ -36,6 +36,8 @@ export enum SettingItemSubType { FilePathAndArgs = 'file_path_and_args', FilePath = 'file_path', // Not supported on mobile! DirectoryPath = 'directory_path', // Not supported on mobile! + FontFamily = 'font_family', + MonospaceFontFamily = 'monospace_font_family', } interface KeysOptions { @@ -1360,6 +1362,7 @@ class Setting extends BaseModel { _('Used for most text in the markdown editor. If not found, a generic proportional (variable width) font is used.'), storage: SettingStorage.File, isGlobal: true, + subType: SettingItemSubType.FontFamily, }, 'style.editor.monospaceFontFamily': { value: '', @@ -1372,6 +1375,7 @@ class Setting extends BaseModel { _('Used where a fixed width font is needed to lay out text legibly (e.g. tables, checkboxes, code). If not found, a generic monospace (fixed width) font is used.'), storage: SettingStorage.File, isGlobal: true, + subType: SettingItemSubType.MonospaceFontFamily, }, 'style.editor.contentMaxWidth': { value: 0, type: SettingItemType.Int, public: true, storage: SettingStorage.File, isGlobal: true, appTypes: [AppType.Desktop], section: 'appearance', label: () => _('Editor maximum width'), description: () => _('Set it to 0 to make it take the complete available space. Recommended width is 600.') },