(core) Detect when a page initial is an emoji, avoid repeating it, and style it better

Summary:
- Detecting emoji is surprisingly tricky; we use a fancy regex as a decent heuristic.
- Icons are a little larger than before.
- Styling tweaked for light and dark modes
- In case the OS doesn't render the emoji as one character, truncate what's
  shown in the icon box.

Test Plan: Added a test case.

Reviewers: jarek

Reviewed By: jarek

Differential Revision: https://phab.getgrist.com/D3904
This commit is contained in:
Dmitry S 2023-06-26 12:58:29 -04:00
parent 4ea748b1a3
commit b0aa17c932
6 changed files with 93 additions and 9 deletions

View File

@ -426,6 +426,9 @@ export const theme = {
undefined, colors.slate),
pageInitialsFg: new CustomProp('theme-left-panel-page-initials-fg', undefined, 'white'),
pageInitialsBg: new CustomProp('theme-left-panel-page-initials-bg', undefined, colors.slate),
pageInitialsEmojiBg: new CustomProp('theme-left-panel-page-emoji-fg', undefined, 'white'),
pageInitialsEmojiOutline: new CustomProp('theme-left-panel-page-emoji-outline', undefined,
colors.darkGrey),
/* Right Panel */
rightPanelTabFg: new CustomProp('theme-right-panel-tab-fg', undefined, colors.dark),

View File

@ -6,7 +6,7 @@ import { theme } from "app/client/ui2018/cssVars";
import { icon } from "app/client/ui2018/icons";
import { hoverTooltip, overflowTooltip } from 'app/client/ui/tooltips';
import { menu, menuItem, menuText } from "app/client/ui2018/menus";
import { dom, domComputed, DomElementArg, makeTestId, observable, Observable, styled } from "grainjs";
import { Computed, dom, domComputed, DomElementArg, makeTestId, observable, Observable, styled } from "grainjs";
const t = makeT('pages');
@ -54,16 +54,20 @@ export function buildPageDom(name: Observable<string>, actions: PageActions, ...
}
});
const splitName = Computed.create(null, name, (use, _name) => splitPageInitial(_name));
return pageElem = dom(
'div',
dom.autoDispose(lis),
dom.autoDispose(splitName),
domComputed((use) => use(name) === '', blank => blank ? dom('div', '-') :
domComputed(isRenaming, (isrenaming) => (
isrenaming ?
cssPageItem(
cssPageInitial(
testId('initial'),
dom.text((use) => Array.from(use(name))[0])
dom.text((use) => use(splitName).initial),
cssPageInitial.cls('-emoji', (use) => use(splitName).hasEmoji),
),
cssEditorInput(
{
@ -82,10 +86,11 @@ export function buildPageDom(name: Observable<string>, actions: PageActions, ...
cssPageItem(
cssPageInitial(
testId('initial'),
dom.text((use) => Array.from(use(name))[0]),
dom.text((use) => use(splitName).initial),
cssPageInitial.cls('-emoji', (use) => use(splitName).hasEmoji),
),
cssPageName(
dom.text(name),
dom.text((use) => use(splitName).displayName),
testId('label'),
dom.on('click', (ev) => isTargetSelected(ev.target as HTMLElement) && isRenaming.set(true)),
overflowTooltip(),
@ -122,6 +127,24 @@ export function buildCensoredPage() {
);
}
// This crazy expression matches all "possible emoji" and comes from a very official source:
// https://unicode.org/reports/tr51/#EBNF_and_Regex (linked from
// https://stackoverflow.com/a/68146409/328565). It is processed from the original by replacing \x
// with \u, removing whitespace, and factoring out a long subexpression.
const emojiPart = /(?:\p{RI}\p{RI}|\p{Emoji}(?:\p{EMod}|\u{FE0F}\u{20E3}?|[\u{E0020}-\u{E007E}]+\u{E007F})?)/u;
const pageInitialRegex = new RegExp(`^${emojiPart.source}(?:\\u{200D}${emojiPart.source})*`, "u");
// Divide up the page name into an "initial" and "displayName", where an emoji initial, if
// present, is omitted from the displayName, but a regular character used as the initial is kept.
function splitPageInitial(name: string): {initial: string, displayName: string, hasEmoji: boolean} {
const m = name.match(pageInitialRegex);
if (m) {
return {initial: m[0], displayName: name.slice(m[0].length).trim(), hasEmoji: true};
} else {
return {initial: Array.from(name)[0], displayName: name.trim(), hasEmoji: false};
}
}
const cssPageItem = styled('a', `
display: flex;
flex-direction: row;
@ -129,7 +152,8 @@ const cssPageItem = styled('a', `
align-items: center;
flex-grow: 1;
.${treeViewContainer.className}-close & {
margin-left: 16px;
display: flex;
justify-content: center;
}
&, &:hover, &:focus {
text-decoration: none;
@ -143,10 +167,25 @@ const cssPageInitial = styled('div', `
color: ${theme.pageInitialsFg};
border-radius: 3px;
background-color: ${theme.pageInitialsBg};
width: 16px;
height: 16px;
text-align: center;
width: 20px;
height: 20px;
margin-right: 8px;
display: flex;
justify-content: center;
align-items: center;
&-emoji {
background-color: ${theme.pageInitialsEmojiBg};
box-shadow: 0 0 0 1px var(--grist-theme-left-panel-page-emoji-outline, var(--grist-color-dark-grey));
font-size: 15px;
overflow: hidden;
}
.${treeViewContainer.className}-close & {
margin-right: 0;
}
.${itemHeader.className}.selected &-emoji {
box-shadow: none;
}
`);
const cssPageName = styled('div', `

View File

@ -241,6 +241,8 @@ export interface ThemeColors {
'left-panel-page-options-selected-hover-bg': string;
'left-panel-page-initials-fg': string;
'left-panel-page-initials-bg': string;
'left-panel-page-emoji-fg': string;
'left-panel-page-emoji-outline': string;
/* Right Panel */
'right-panel-tab-fg': string;

View File

@ -220,6 +220,8 @@ export const GristDark: ThemeColors = {
'left-panel-page-options-selected-hover-bg': '#A4A4A4',
'left-panel-page-initials-fg': 'white',
'left-panel-page-initials-bg': '#929299',
'left-panel-page-emoji-fg': 'black',
'left-panel-page-emoji-outline': '#69697D',
/* Right Panel */
'right-panel-tab-fg': '#EFEFEF',

View File

@ -220,6 +220,8 @@ export const GristLight: ThemeColors = {
'left-panel-page-options-selected-hover-bg': '#929299',
'left-panel-page-initials-fg': 'white',
'left-panel-page-initials-bg': '#929299',
'left-panel-page-emoji-fg': 'white',
'left-panel-page-emoji-outline': '#BDBDBD',
/* Right Panel */
'right-panel-tab-fg': '#262633',

View File

@ -259,6 +259,42 @@ describe('Pages', function() {
assert.include(await gu.getPageNames(), 'People');
});
it('should pull out emoji from page names', async () => {
// A regular character is used as an initial AND kept in the name.
assert.deepEqual(await getInitialAndName(/People/), ['P', 'People']);
// It looks like our version of Chromedriver does not support sending emojis using sendKeys
// (issue mentioned here https://stackoverflow.com/a/59139690), so we'll use executeScript to
// rename pages.
async function renamePage(origName: string, newName: string) {
await gu.openPageMenu(origName);
await driver.find('.test-docpage-rename').doClick();
const editor = await driver.find('.test-docpage-editor');
await driver.executeScript((el: HTMLInputElement, text: string) => { el.value = text; }, editor, newName);
await editor.sendKeys(Key.ENTER);
await gu.waitForServer();
}
async function getInitialAndName(pageName: string|RegExp): Promise<[string, string]> {
return await driver.findContent('.test-treeview-itemHeader', pageName)
.findAll('.test-docpage-initial, .test-docpage-label', el => el.getText()) as [string,
string];
}
// An emoji is pulled into the initial, and is removed from the name.
await renamePage('People', '👥 People');
assert.deepEqual(await getInitialAndName(/People/), ['👥', 'People']);
// Two complex emojis -- the first one is the pulled-out initial.
await renamePage('People', '👨👩👧👦👨👩👧Guest List');
assert.deepEqual(await getInitialAndName(/Guest List/),
['👨‍👩‍👧‍👦', '👨👩👧Guest List']);
await gu.undo(2);
assert.deepEqual(await getInitialAndName(/People/), ['P', 'People']);
});
it('should show tooltip for long page names on hover', async () => {
await gu.openPageMenu('People');
await driver.find('.test-docpage-rename').doClick();