diff --git a/app/client/ui2018/cssVars.ts b/app/client/ui2018/cssVars.ts index 195cfda7..df027c76 100644 --- a/app/client/ui2018/cssVars.ts +++ b/app/client/ui2018/cssVars.ts @@ -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), diff --git a/app/client/ui2018/pages.ts b/app/client/ui2018/pages.ts index 296afa14..29484daf 100644 --- a/app/client/ui2018/pages.ts +++ b/app/client/ui2018/pages.ts @@ -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,17 +54,21 @@ export function buildPageDom(name: Observable, 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( { initialValue: name.get() || '', @@ -82,10 +86,11 @@ export function buildPageDom(name: Observable, 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', ` diff --git a/app/common/ThemePrefs.ts b/app/common/ThemePrefs.ts index e6f55aaf..39ae7193 100644 --- a/app/common/ThemePrefs.ts +++ b/app/common/ThemePrefs.ts @@ -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; diff --git a/app/common/themes/GristDark.ts b/app/common/themes/GristDark.ts index 84f1aa28..4d782a1d 100644 --- a/app/common/themes/GristDark.ts +++ b/app/common/themes/GristDark.ts @@ -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', diff --git a/app/common/themes/GristLight.ts b/app/common/themes/GristLight.ts index fc0af490..ecb969aa 100644 --- a/app/common/themes/GristLight.ts +++ b/app/common/themes/GristLight.ts @@ -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', diff --git a/test/nbrowser/Pages.ts b/test/nbrowser/Pages.ts index 81d98310..13969753 100644 --- a/test/nbrowser/Pages.ts +++ b/test/nbrowser/Pages.ts @@ -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();