Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Desktop: feat: Added search list for font input fields #10248

Merged
merged 27 commits into from
Apr 20, 2024

Conversation

ab-elhaddad
Copy link
Contributor

@ab-elhaddad ab-elhaddad commented Apr 2, 2024

Added a list showing the installed fonts, that acts as a search suggestion based on the user input, so the user can choose from them.

Notes:

  • I have moved the input field element with the list to a new component so the states changes don't re-render the whole configScreen component, but just re-render the input field and the list
font-list.mp4

Styling with various themes

dark-theme
solarised-light-theme solarised-dark-theme
dracula-theme

@PackElend label me please

Comment on lines 583 to 589
<FontSearch
_key={key}
updateSettingValue={updateSettingValue}
inputType={inputType}
inputStyle={inputStyle}
settings={this.state.settings}
fonts={this.state.fonts}
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't it mean that all text input fields are replaced with a font search field?

Copy link
Contributor Author

@ab-elhaddad ab-elhaddad Apr 2, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops, you are right. my bad
I have fixed it now.
bef11bb

cursor: 'pointer',
};

const FontSearch = (props: any) => {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No "any"

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed with 9832a9e

Comment on lines 87 to 90
const child = fork(join(__dirname, 'loadFonts'));
child.on('message', (fonts: string[]) => {
this.setState({ fonts }, () => child.kill());
});
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That will fail if the component is unmounted before the loadFonts script returns

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Killing the child in componentWillUnmount will prevent any failures, right?

Comment on lines 35 to 51
const showList = (display: boolean) => {
document.getElementById(`searchList-${key}`).style.display = display ? 'block' : 'none';
};
const onTextChange = (event: any) => {
setInputText(event.target.value);
updateSettingValue(key, event.target.value);
showList(true);
};
const onFocusHandle = (event: any) => {
setInputText(event.target.value); // To handle the case when the value is already set
showList(true);
};
const onFontClick = (font: string) => {
(document.getElementById(key) as HTMLDivElement).innerText = font;
updateSettingValue(key, font);
showList(false);
};
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All callback handler should use useCallback

onTextChange(event);
}}
onFocus={onFocusHandle}
onBlur={() => setTimeout(() => showList(false), 150)} // Delay the hiding of the list to allow the click event to fire
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be a useCallback too

Comment on lines 60 to 62
onChange={(event: any) => {
onTextChange(event);
}}
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useCallback

};

const FontSearch = (props: any) => {
const { _key: key, updateSettingValue, inputType, inputStyle, settings, fonts } = props;
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only pass what you need, and you don't need all settings here

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed with 9832a9e

Comment on lines 77 to 79
onClick={() => onFontClick(font)}
onMouseEnter={() => setHoveredFont(font)}
onMouseLeave={() => setHoveredFont('')}
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why the indirection? Just set onClick={onFontClick}, etc.

Comment on lines 67 to 76
<div id={`searchList-${key}`} style={searchListStyle} >
{
areFontsLoading ? <div style={{ padding: '5px' }}>Loading...</div> :
filteredFonts.map((font: string) =>
<div
key={font}
style={{ ...optionStyle, fontFamily: `"${font}"`,
color: hoveredFont === font ? 'var(--joplin-background-color)' : 'var(--joplin-color)',
backgroundColor: hoveredFont === font ? 'var(--joplin-color)' : 'var(--joplin-background-color)',
}}
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No dynamic styles, search for "rscss" in the doc

Comment on lines 1 to 5
import fontList = require('font-list');

fontList.getFonts({ disableQuoting: true })
.then((fonts: string[]) => process.send(fonts))
.catch((error: any) => console.error(error));
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use async/await. How does it actually handle errors? It's printed to the console?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok no problem
Yes, it just prints the error to the console and the list just keeps showing Loading... like this
error-state

@@ -161,6 +161,7 @@
"countable": "3.0.1",
"debounce": "1.2.1",
"electron-window-state": "5.0.3",
"font-list": "1.5.1",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than adding a dependency, consider using window.queryLocalFonts (which should be available in Electron >= 25).

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ab-elhaddad, indeed we would rather not add this font-list package, so please confirm if you can update the PR using the above API

Copy link
Contributor Author

@ab-elhaddad ab-elhaddad Apr 3, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, we can get the same result with the above API. Although typescript doesn't seem to recognize it, it exists and works efficiently.
updated with 1362f78

spellCheck={false}
/>
{
md.label() === 'Editor font family' || md.label() === 'Editor monospace font family' ?
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than comparing md.label() with a hardcoded string, consider adding a new setting subType to the font settings. This approach would be similar to how the file/directory chooser is currently handled.

Comparing the label with a string could be problematic if the label for one of the font settings is updated at some point in the future or if the user changes Joplin to a non-English language.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed with 67800cd

{
md.subType === SettingItemSubType.FontFamily ?
<FontSearch
_key={key}
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is _key needed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The main purpose of passing the key is to be able to update the settings value (updateSettingValue), as it takes key as the first argument. But if you are asking about the naming and why didn't I use just key because key is a reserved word in react components and when using it, I get this warning ``key`` is not a prop. Trying to access it will result in ``undefined`` being returned. If you need to access the same value within the child component, you should pass it as a different prop.

Copy link
Owner

@laurent22 laurent22 Apr 5, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The normal pattern for a React controlled component is to have a "value" and "onChange" (or similar) event. If you do that you won't have to pass around unnecessary properties and can create more reusable components.

So in this case, add an onChange event to your component which would pass to the parent the selected font. Then here just do onChange={fontFamily => updateSettingValue(key, fontFamily)}

I know there's a lot of code in this project and you can't know everything, but it often helps to see how things are done in the code near where you want to add something. For example you can see that all components here follow this value/onChange pattern. Maybe also read about controlled components and why they're generally preferred.

@@ -0,0 +1,82 @@
import React = require('react');
import { useMemo, useState, useCallback } from 'react';
import { CSSProperties } from 'styled-components';
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Import this from React - we don't use styled-components for new code

updateSettingValue={updateSettingValue}
inputType={inputType}
inputStyle={inputStyle}
fieldValue={this.state.settings[key]}
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fieldValue => value

<FontSearch
_key={key}
updateSettingValue={updateSettingValue}
inputType={inputType}
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

inputType => type

_key={key}
updateSettingValue={updateSettingValue}
inputType={inputType}
inputStyle={inputStyle}
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

inputStyle => style

}, [showList]);

const onBlurHandle = useCallback(() =>
setTimeout(() => showList(false), 150) // Delay the hiding of the list to allow the click event to fire
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why would we want the click event to fire on blur? And it looks like you're already handling the click event below. Please remove the requirement for this timeout in any case because it will randomly fail


const onFontClick = useCallback((event: any) => {
const font = event.target.innerText;
(document.getElementById(key) as HTMLDivElement).innerText = font;
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't use document.getElementById. Set the font as data-font on the element, then use event.currentTarget.getAttribute('data-font') to get it back

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was using getElementById to set the value in the input field when a font is clicked from the list, But I replaced it using the inputText state. But I am not sure if this is better or worse from the performance perspective. e86079f

packages/app-desktop/gui/ConfigScreen/FontSearch.tsx Outdated Show resolved Hide resolved
packages/app-desktop/gui/ConfigScreen/FontSearch.tsx Outdated Show resolved Hide resolved
packages/app-desktop/gui/ConfigScreen/FontSearch.tsx Outdated Show resolved Hide resolved

const settingKeyToControl: any = {
'plugins.states': control_PluginsStates,
};

interface FontData {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why "FontData"? Everything is data. It's just a Font

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought it would be better to define the type as it mentioned in the docs, but no problem I will change it

Comment on lines 30 to 34
family: string;
fullName: string;
postscriptName: string;
style: string;
blob: ()=> Promise<Blob>;
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my comment I mentioned defining the properties that you need and you don't need most of these. I think you only use .family?

When a type is not built-in we just define what we need, otherwise it's too much noise especially for the DOM which can have hundreds of properties per object.

{
md.subType === SettingItemSubType.FontFamily ?
<FontSearch
_key={key}
Copy link
Owner

@laurent22 laurent22 Apr 5, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The normal pattern for a React controlled component is to have a "value" and "onChange" (or similar) event. If you do that you won't have to pass around unnecessary properties and can create more reusable components.

So in this case, add an onChange event to your component which would pass to the parent the selected font. Then here just do onChange={fontFamily => updateSettingValue(key, fontFamily)}

I know there's a lot of code in this project and you can't know everything, but it often helps to see how things are done in the code near where you want to add something. For example you can see that all components here follow this value/onChange pattern. Maybe also read about controlled components and why they're generally preferred.

/>
<div className={'font-search-list'} style={{ display: showList ? 'block' : 'none' }}>
{
areFontsLoading ? <div>Loading...</div> :
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_('Loading...') - please always make sure that user-facing text can be localised.

setTimeout(() => setShowList(false), 150) // Delay the hiding of the list to allow the click event to fire
, []);

const onFontClick = useCallback((event: any) => {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No "any", please always use the correct types. It should be "onFontClick: React.MouseEventHandler", then you TypeScript should infer the type for "event"

);
}, [fonts, inputText]);

const onTextChange = useCallback((event: any) => {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no "any"

@laurent22
Copy link
Owner

There are UX issues:

  • When moving the cursor to the font field, it freezes for a second or two (could be 5 or 10 on slower computers, or with more fonts). And it doesn't display "Loading...". I think you need to use requestAnimationFrame so that the UI has time to update before you load the fonts

  • The monospace font gives as option non-monospace fonts, so that should be filtered

  • If I choose a font, then click on the field again, the only proposed font is the currently chosen one. i.e. it looks like this:

    image

    I can see why it's doing this (the fonts are filtered by the currently selected one), but that's not terribly useful and probably unexpected for most users who would instead expect to be able to see the fonts and scroll through them

@ab-elhaddad
Copy link
Contributor Author

About filtering the monospaced fonts, there is no straightforward way to do this because the querLocalFonts() doesn't expose whether the font is monospaced or not, like, for example, the font-manager package, which does provide an attribute declaring whether the font is monospaced or not.

To solve this issue, we have two options:

  1. Filtering OS fonts using static array containing the most popular monospaced fonts. which is a light solution and won't exhaust the renderer process but may introduce some bugs.
const monospacedFonts = [
    'Courier',
    'Courier New',
    'Lucida Console',
    'Monaco',
    'Consolas',
    'Andale Mono',
    'Monospace',
    'Roboto Mono',
    'Source Code Pro',
    // More monospaced fonts
];
  1. Implementing a function to calculate the width of two characters using the same font and see if they have the same width. This solution is very resource-exhausting, and it causes a freeze for a second or two, but it is guaranteed that it won't introduce any bugs.
const isMonospacedFont = (fontName: string) => {
  const testElement = document.createElement('span');
  testElement.style.fontFamily = fontName;
  testElement.style.fontSize = '16px';
  testElement.innerText = 'i';

  document.body.appendChild(testElement);
  const iWidth = testElement.getBoundingClientRect().width;

  testElement.innerText = 'm';
  const mWidth = testElement.getBoundingClientRect().width;

  return Math.abs(iWidth - mWidth) < 0.01; // Tolerance for minor variations
}

@laurent22
Copy link
Owner

laurent22 commented Apr 8, 2024

Filtering OS fonts using static array containing the most popular monospaced fonts. which is a light solution and won't exhaust the renderer process but may introduce some bugs.

Let's do this but please search for an existing list of monospaced fonts, as exhaustive as possible. Ideally something that's regularly updated and add the link as a comment on top of your list (so that we can update it later on).

You may also want to add some heuristics such as any font that certain keywords such as "mono", "courier", "source code", "console", etc. should be considered monospace.

@tomasz1986
Copy link

tomasz1986 commented Apr 8, 2024

Let's do this but please search for an existing list of monospaced fonts, as exhaustive as possible. Ideally something that's regularly updated and add the link as a comment on top of your list (so that we can update it later on).

I don't think it will be possible to find one really complete list. The reason is that if you search for "monospace font list" in the Web, you will find mainly fonts that are for English (and possibly other Western languages). However, those lists completely miss all monospace fonts that exist e.g. for East Asian languages. Also, the fonts may even be named in non-Latin scripts, so the generic keywords will likely not catch them.

In other words, what I want to say is that it would be so much more universal if the script could just list all installed monospace fonts as they are reported by the OS without looking for specific names 🙁.

@laurent22
Copy link
Owner

In other words, what I want to say is that it would be so much more universal if the script could just list all installed monospace fonts as they are reported by the OS

That's true, but unfortunately we can't do that. But I'm thinking there should be an option to show either only the monospace ones or all fonts as a fallback.

So @ab-elhaddad, would you mind adding a "Show monospace fonts only" checkbox to your list? It would be checked by default when picking the monospace font, and unchecked when picking the editor font.

@tomasz1986
Copy link

tomasz1986 commented Apr 8, 2024

That's true, but unfortunately we can't do that. But I'm thinking there should be an option to show either only the monospace ones or all fonts as a fallback.

Maybe it would still be better to use one of those 3rd party packages that can filter monospace fonts with no guesswork involved? It seems that querLocalFonts() is very limited in terms of the actual functionality…

@roman-r-m
Copy link
Collaborator

And if the list of all fonts is slow to load, can it be loaded once on startup and kept in memory? I don't suppose the memory overhead will be huge.

@ab-elhaddad
Copy link
Contributor Author

I think the main problem with using a third-party package is its maintenance. The only package I have found (until now) exposing whether the fonts are monospaced or not is the font-manager, which was last updated 4 years ago. So, I am not sure if using such a package is a good decision.

@laurent22
Copy link
Owner

I think the main problem with using a third-party package is its maintenance. The only package I have found (until now) exposing whether the fonts are monospaced or not is the font-manager, which was last updated 4 years ago. So, I am not sure if using such a package is a good decision.

We can't use this unfortunately. It's a binary package so will cause too many problems for little benefits.

@laurent22
Copy link
Owner

Thanks for the update @ab-elhaddad but the "Show monospace fonts only" checkbox needs to be within the font list component and only shows up when the list is open, like this:

image

Otherwise it's not clear at all what this "monospace" setting is for.

@ab-elhaddad
Copy link
Contributor Author

No problem, it's done

@laurent22
Copy link
Owner

Looks good now, thanks @ab-elhaddad!

@laurent22 laurent22 merged commit 7ec02fc into laurent22:dev Apr 20, 2024
10 checks passed
@ab-elhaddad
Copy link
Contributor Author

@laurent22
Thank you so much for your guidance and patience, I can't deny I have learned a lot through this PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants