From d811dd1ed49464e1469271b4348bdfbe46d003b7 Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Thu, 25 Jan 2024 15:48:44 -0800 Subject: [PATCH] Username UI Improvements Co-authored-by: Fedor Indutny <238531+indutny@users.noreply.github.com> --- _locales/en/messages.json | 4 + images/signal-qr-logo.svg | 1 - .../components/EditUsernameModalBody.scss | 22 +- .../components/UsernameLinkModalBody.scss | 11 +- ts/components/EditUsernameModalBody.tsx | 2 +- .../UsernameLinkModalBody.stories.tsx | 15 +- ts/components/UsernameLinkModalBody.tsx | 438 ++++++++++-------- ts/test-node/util/splitText_test.ts | 59 +++ ts/util/splitText.ts | 46 ++ 9 files changed, 379 insertions(+), 219 deletions(-) delete mode 100644 images/signal-qr-logo.svg create mode 100644 ts/test-node/util/splitText_test.ts create mode 100644 ts/util/splitText.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 5f1a9d20c0..6e0a8aaa0a 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -6839,6 +6839,10 @@ "messageformat": "Continue", "description": "Text of the primary button on username change confirmation modal" }, + "icu:UsernameLinkModalBody__hint": { + "messageformat": "Scan this QR code with your phone to chat with me on Signal.", + "descrption": "Text of the hint displayed below generated QR code on the printable image." + }, "icu:UsernameLinkModalBody__save": { "messageformat": "Save", "description": "Name of the button for saving username link QR code to disk in the username link modal" diff --git a/images/signal-qr-logo.svg b/images/signal-qr-logo.svg deleted file mode 100644 index 51ba45423b..0000000000 --- a/images/signal-qr-logo.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/stylesheets/components/EditUsernameModalBody.scss b/stylesheets/components/EditUsernameModalBody.scss index 7345416a64..344aaba813 100644 --- a/stylesheets/components/EditUsernameModalBody.scss +++ b/stylesheets/components/EditUsernameModalBody.scss @@ -69,16 +69,20 @@ &__error { @include font-body-2; - margin-block: 16px; - margin-inline: 0; + margin-block: 8px 12px; + margin-inline: 6px; + font-size: 12px; + line-height: 17px; color: $color-accent-red; } &__info { @include font-body-2; - margin-block: 16px; - margin-inline: 0; + font-size: 12px; + line-height: 17px; + margin-block: 12px; + margin-inline: 6px; @include light-theme { color: $color-gray-60; @@ -87,10 +91,10 @@ color: $color-gray-25; } - // To account for missing error section - 16px previous margin, 34px for - // 16px margin of error plus 18px line height. + // To account for missing error section: 8px top margin, 17px line height, + // 12px bottom margin. &--no-error { - margin-bottom: 50px; + margin-bottom: 37px; } } @@ -125,4 +129,8 @@ -webkit-mask: url(../images/icons/v2/hashtag-24.svg) no-repeat center; } } + + &__input__container.Input__container { + margin-block-end: 8px; + } } diff --git a/stylesheets/components/UsernameLinkModalBody.scss b/stylesheets/components/UsernameLinkModalBody.scss index 5d57b8cf2b..11446925ec 100644 --- a/stylesheets/components/UsernameLinkModalBody.scss +++ b/stylesheets/components/UsernameLinkModalBody.scss @@ -22,6 +22,7 @@ padding-block: 22px; padding-inline: 28px; + margin-block-start: 8px; background: var(--bg-color); border-radius: 18px; max-width: 204px; @@ -55,16 +56,6 @@ width: 100%; } - &__logo { - --size: 25px; - position: absolute; - top: calc(50% - var(--size) / 2); - inset-inline-start: calc(50% - var(--size) / 2); - width: var(--size); - height: var(--size); - @include color-svg('../images/signal-qr-logo.svg', var(--fg-color)); - } - &__error-icon { -webkit-mask-size: 100%; display: block; diff --git a/ts/components/EditUsernameModalBody.tsx b/ts/components/EditUsernameModalBody.tsx index efdb658af9..85b3b69770 100644 --- a/ts/components/EditUsernameModalBody.tsx +++ b/ts/components/EditUsernameModalBody.tsx @@ -273,7 +273,7 @@ export function EditUsernameModalBody({ = args => { - {attachment && printable qr code} + {attachment && ( + printable qr code + )} ); }; diff --git a/ts/components/UsernameLinkModalBody.tsx b/ts/components/UsernameLinkModalBody.tsx index e29176cfc1..1adf8e698c 100644 --- a/ts/components/UsernameLinkModalBody.tsx +++ b/ts/components/UsernameLinkModalBody.tsx @@ -16,6 +16,7 @@ import type { LocalizerType } from '../types/Util'; import { IMAGE_PNG } from '../types/MIME'; import { strictAssert } from '../util/assert'; import { drop } from '../util/drop'; +import { splitText } from '../util/splitText'; import { Button, ButtonVariant } from './Button'; import { Modal } from './Modal'; import { ConfirmationDialog } from './ConfirmationDialog'; @@ -39,59 +40,95 @@ export type PropsType = Readonly<{ export type ColorMapEntryType = Readonly<{ fg: string; bg: string; + tint: string; }>; const ColorEnum = Proto.AccountRecord.UsernameLink.Color; -const DEFAULT_PRESET: ColorMapEntryType = { fg: '#2449c0', bg: '#506ecd' }; +const DEFAULT_PRESET: ColorMapEntryType = { + fg: '#2449c0', + bg: '#506ecd', + tint: '#ecf0fb', +}; export const COLOR_MAP: ReadonlyMap = new Map([ [ColorEnum.BLUE, DEFAULT_PRESET], - [ColorEnum.WHITE, { fg: '#000000', bg: '#ffffff' }], - [ColorEnum.GREY, { fg: '#464852', bg: '#6a6c74' }], - [ColorEnum.OLIVE, { fg: '#73694f', bg: '#a89d7f' }], - [ColorEnum.GREEN, { fg: '#55733f', bg: '#829a6e' }], - [ColorEnum.ORANGE, { fg: '#d96b2d', bg: '#de7134' }], - [ColorEnum.PINK, { fg: '#bb617b', bg: '#e67899' }], - [ColorEnum.PURPLE, { fg: '#7651c5', bg: '#9c84cf' }], + [ColorEnum.WHITE, { fg: '#000000', bg: '#ffffff', tint: '#f5f5f5' }], + [ColorEnum.GREY, { fg: '#464852', bg: '#6a6c75', tint: '#f0f0f1' }], + [ColorEnum.OLIVE, { fg: '#73694f', bg: '#aa9c7c', tint: '#f6f5f2' }], + [ColorEnum.GREEN, { fg: '#55733f', bg: '#7c9b69', tint: '#f1f5f0' }], + [ColorEnum.ORANGE, { fg: '#d96b2d', bg: '#ee691a', tint: '#fef1ea' }], + [ColorEnum.PINK, { fg: '#bb617b', bg: '#f77099', tint: '#fef1f5' }], + [ColorEnum.PURPLE, { fg: '#7651c5', bg: '#a183d4', tint: '#f5f3fb' }], ]); +const LOGO_PATH = + 'M16.904 32.723V35a17.034 17.034 0 0 1-5.594-1.334l.595-2.22a14.763 14' + + '.763 0 0 0 5 1.277ZM9.119 33.064l.667-2.49-5.707 1.338 1.18-5.034-2.3' + + '82.209-1.22 5.204A1.7 1.7 0 0 0 3.7 34.334l5.419-1.27ZM3.28 19.159c.1' + + '5 1.91.671 3.77 1.53 5.477l-2.41.21a17.037 17.037 0 0 1-1.397-5.688H3' + + '.28ZM3.277 16.885H1c.146-2.223.727-4.4 1.712-6.403l1.972 1.139a14.765' + + ' 14.765 0 0 0-1.407 5.264ZM5.821 9.652 3.85 8.513a17.035 17.035 0 0 1' + + ' 4.69-4.68l1.138 1.972a14.763 14.763 0 0 0-3.856 3.847ZM11.648 4.672l' + + '-1.139-1.973c2-.978 4.172-1.556 6.395-1.699v2.277a14.762 14.762 0 0 0' + + '-5.256 1.395ZM19.177 3.283c1.816.145 3.593.625 5.24 1.42l1.137-1.973a' + + '17.034 17.034 0 0 0-6.377-1.725v2.278ZM29.795 9.118c.14.186.276.376.4' + + '07.568l1.971-1.139a17.035 17.035 0 0 0-4.654-4.675l-1.138 1.973a14.76' + + '3 14.763 0 0 1 3.414 3.273ZM32.52 15.322c.096.518.163 1.04.203 1.563H' + + '35a17.048 17.048 0 0 0-1.694-6.367l-1.973 1.14c.552 1.16.952 2.391 1.' + + '187 3.664ZM32.188 22.09a14.759 14.759 0 0 1-.871 2.287l1.972 1.139a17' + + '.032 17.032 0 0 0 1.708-6.357H32.72a14.768 14.768 0 0 1-.532 2.93ZM28' + + '.867 27.995a14.757 14.757 0 0 1-2.504 2.173l1.139 1.973a17.028 17.028' + + ' 0 0 0 4.65-4.657l-1.972-1.139c-.396.58-.835 1.13-1.313 1.65ZM23.259 ' + + '31.797c-1.314.5-2.69.809-4.082.92v2.278a17.033 17.033 0 0 0 6.358-1.7' + + '16l-1.139-1.972c-.371.179-.75.342-1.137.49Z M11.66 7.265a12.463 12.46' + + '3 0 0 1 11.9-.423 12.466 12.466 0 0 1 6.42 14.612 12.47 12.47 0 0 1-1' + + '3.21 8.954 12.462 12.462 0 0 1-5.411-1.857L6.246 29.75l1.199-5.115a12' + + '.47 12.47 0 0 1 4.216-17.37Z'; + const CLASS = 'UsernameLinkModalBody'; const AUTODETECT_TYPE_NUMBER = 0; const ERROR_CORRECTION_LEVEL = 'H'; -const CENTER_CUTAWAY_PERCENTAGE = 32 / 184; +const CENTER_CUTAWAY_PERCENTAGE = 30 / 184; +const CENTER_LOGO_PERCENTAGE = 38 / 184; +const QR_NATIVE_SIZE = 36; -const PRINT_WIDTH = 296; -const DEFAULT_PRINT_HEIGHT = 324; -const PRINT_SHADOW_BLUR = 4; -const PRINT_CARD_RADIUS = 24; -const PRINT_MAX_USERNAME_WIDTH = 222; -const PRINT_USERNAME_LINE_HEIGHT = 25; -const PRINT_USERNAME_Y = 269; +export const PRINT_WIDTH = 424; +export const PRINT_HEIGHT = 576; +const PRINT_PIXEL_RATIO = 3; const PRINT_QR_SIZE = 184; -const PRINT_QR_Y = 48; -const PRINT_QR_PADDING = 16; -const PRINT_QR_PADDING_RADIUS = 12; -const PRINT_DPI = 224; -const PRINT_LOGO_SIZE = 36; +const PRINT_DPI = 300; +const BASE_PILL_WIDTH = 296; +const BASE_PILL_HEIGHT = 324; +const USERNAME_TOP = 352; +const USERNAME_MAX_WIDTH = 222; +const USERNAME_LINE_HEIGHT = 26; +const USERNAME_FONT = `600 20px/${USERNAME_LINE_HEIGHT}px Inter`; +const USERNAME_LETTER_SPACING = -0.34; + +const HINT_BASE_TOP = 447; +const HINT_MAX_WIDTH = 296; +const HINT_LINE_HEIGHT = 17; +const HINT_FONT = `400 14px/${HINT_LINE_HEIGHT}px Inter`; +const HINT_LETTER_SPACING = 0; type BlotchesPropsType = Readonly<{ - className?: string; + size: number; link: string; color: string; }>; -function Blotches({ className, link, color }: BlotchesPropsType): JSX.Element { +function QRCode({ size, link, color }: BlotchesPropsType): JSX.Element { const qr = QR(AUTODETECT_TYPE_NUMBER, ERROR_CORRECTION_LEVEL); qr.addData(link); qr.make(); - const size = qr.getModuleCount(); - const center = size / 2; - const radius = CENTER_CUTAWAY_PERCENTAGE * size; + const moduleCount = qr.getModuleCount(); + const center = moduleCount / 2; + const radius = CENTER_CUTAWAY_PERCENTAGE * moduleCount; function hasPixel(x: number, y: number): boolean { - if (x < 0 || y < 0 || x >= size || y >= size) { + if (x < 0 || y < 0 || x >= moduleCount || y >= moduleCount) { return false; } @@ -100,7 +137,7 @@ function Blotches({ className, link, color }: BlotchesPropsType): JSX.Element { ); // Center and 1 dot away should remain clear for the logo placement. - if (Math.ceil(distanceFromCenter) <= radius + 2) { + if (Math.ceil(distanceFromCenter) <= radius + 3) { return false; } @@ -108,8 +145,8 @@ function Blotches({ className, link, color }: BlotchesPropsType): JSX.Element { } const path = []; - for (let y = 0; y < size; y += 1) { - for (let x = 0; x < size; x += 1) { + for (let y = 0; y < moduleCount; y += 1) { + for (let x = 0; x < moduleCount; x += 1) { if (!hasPixel(x, y)) { continue; } @@ -135,21 +172,89 @@ function Blotches({ className, link, color }: BlotchesPropsType): JSX.Element { } } + const QR_SCALE = size / 2 / moduleCount; + + const CENTER_X = size / 2; + const CENTER_Y = size / 2; + const LOGO_SIZE = CENTER_LOGO_PERCENTAGE * size; + const LOGO_X = CENTER_X - LOGO_SIZE / 2; + const LOGO_Y = CENTER_Y - LOGO_SIZE / 2; + const LOGO_SCALE = LOGO_SIZE / QR_NATIVE_SIZE; + + return ( + <> + + + + + + + + + + + ); +} + +type ExportedImagePropsType = Readonly<{ + link: string; + colorId: number; + usernameLines: number; +}>; + +function ExportedImage({ + link, + colorId, + usernameLines, +}: ExportedImagePropsType): JSX.Element { + const { fg, bg, tint } = COLOR_MAP.get(colorId) ?? DEFAULT_PRESET; + + const isWhiteBackground = colorId === ColorEnum.WHITE; + + const extraHeight = (usernameLines - 1) * USERNAME_LINE_HEIGHT; + const pillHeight = BASE_PILL_HEIGHT + extraHeight; + return ( - - + + + {/* QR + Username pill */} + + + + {/* QR code with a frame */} + + {isWhiteBackground ? ( + + ) : ( + + )} + + + + + + ); } @@ -157,39 +262,29 @@ function Blotches({ className, link, color }: BlotchesPropsType): JSX.Element { type CreateCanvasAndContextOptionsType = Readonly<{ width: number; height: number; - devicePixelRatio?: number; }>; function createCanvasAndContext({ width, height, - devicePixelRatio = window.devicePixelRatio, }: CreateCanvasAndContextOptionsType): [ OffscreenCanvas, OffscreenCanvasRenderingContext2D ] { const canvas = new OffscreenCanvas( - devicePixelRatio * width, - devicePixelRatio * height + PRINT_PIXEL_RATIO * width, + PRINT_PIXEL_RATIO * height ); const context = canvas.getContext('2d'); strictAssert(context, 'Failed to get 2d context'); // Retina support - context.scale(devicePixelRatio, devicePixelRatio); + context.scale(PRINT_PIXEL_RATIO, PRINT_PIXEL_RATIO); - // Font config - context.font = `600 20px/${PRINT_USERNAME_LINE_HEIGHT}px Inter`; + // Common font config context.textAlign = 'center'; context.textBaseline = 'top'; - - // Experimental Chrome APIs - ( - context as unknown as { - letterSpacing: number; - } - ).letterSpacing = -0.34; ( context as unknown as { textRendering: string; @@ -201,155 +296,74 @@ function createCanvasAndContext({ return [canvas, context]; } -type GetLogoCanvasOptionsType = Readonly<{ - fgColor: string; - imageUrl?: string; - devicePixelRatio?: number; +type CreateTextMeasurerOptionsType = Readonly<{ + font: string; + letterSpacing: number; + maxWidth: number; }>; -async function getLogoCanvas({ - fgColor, - imageUrl = 'images/signal-qr-logo.svg', - devicePixelRatio, -}: GetLogoCanvasOptionsType): Promise { - const img = new Image(); - await new Promise((resolve, reject) => { - img.addEventListener('load', resolve); - img.addEventListener('error', () => - reject(new Error('Failed to load image')) - ); - img.src = imageUrl; - }); - - const [canvas, context] = createCanvasAndContext({ - width: PRINT_LOGO_SIZE, - height: PRINT_LOGO_SIZE, - devicePixelRatio, - }); - - context.fillStyle = fgColor; - context.fillRect(0, 0, PRINT_LOGO_SIZE, PRINT_LOGO_SIZE); - context.globalCompositeOperation = 'destination-in'; - context.drawImage(img, 0, 0, PRINT_LOGO_SIZE, PRINT_LOGO_SIZE); - - return canvas; -} - -function splitUsername(username: string): Array { - const result = new Array(); - +function createTextMeasurer({ + font, + letterSpacing, + maxWidth, +}: CreateTextMeasurerOptionsType): (text: string) => boolean { const [, context] = createCanvasAndContext({ width: 1, height: 1 }); - // Compute number of lines and height of username - for (let i = 0, last = 0; i < username.length; i += 1) { - const part = username.slice(last, i); - if (context.measureText(part).width > PRINT_MAX_USERNAME_WIDTH) { - result.push(username.slice(last, i - 1)); - last = i - 1; - } else if (i === username.length - 1) { - result.push(username.slice(last)); + context.font = font; + // Experimental Chrome APIs + ( + context as unknown as { + letterSpacing: number; } - } + ).letterSpacing = letterSpacing; - return result; + return value => context.measureText(value).width > maxWidth; } type GenerateImageURLOptionsType = Readonly<{ link: string; username: string; + hint: string; colorId: number; - bgColor: string; - fgColor: string; - - // For testing - logoUrl?: string; - devicePixelRatio?: number; }>; // Exported for testing export async function _generateImageBlob({ link, username, + hint, colorId, - bgColor, - fgColor, - logoUrl, - devicePixelRatio, }: GenerateImageURLOptionsType): Promise { - const usernameLines = splitUsername(username); - const usernameHeight = PRINT_USERNAME_LINE_HEIGHT * usernameLines.length; - - const isWhiteBackground = colorId === ColorEnum.WHITE; - - const padding = isWhiteBackground ? PRINT_SHADOW_BLUR : 0; - - const totalHeight = - DEFAULT_PRINT_HEIGHT - PRINT_USERNAME_LINE_HEIGHT + usernameHeight; - const [canvas, context] = createCanvasAndContext({ - width: PRINT_WIDTH + 2 * padding, - height: totalHeight + 2 * padding, - devicePixelRatio, + const usernameLines = splitText(username, { + granularity: 'grapheme', + shouldBreak: createTextMeasurer({ + maxWidth: USERNAME_MAX_WIDTH, + font: USERNAME_FONT, + letterSpacing: USERNAME_LETTER_SPACING, + }), }); - // Draw card - context.save(); - if (isWhiteBackground) { - context.shadowColor = 'rgba(0, 0, 0, 0.08)'; - context.shadowBlur = PRINT_SHADOW_BLUR; - } - context.fillStyle = bgColor; - context.beginPath(); - context.roundRect( - padding, - padding, - PRINT_WIDTH, - totalHeight, - PRINT_CARD_RADIUS + const hintLines = splitText(hint, { + granularity: 'word', + shouldBreak: createTextMeasurer({ + maxWidth: HINT_MAX_WIDTH, + font: HINT_FONT, + letterSpacing: HINT_LETTER_SPACING, + }), + }); + + const [canvas, context] = createCanvasAndContext({ + width: PRINT_WIDTH, + height: PRINT_HEIGHT, + }); + + const svg = renderToStaticMarkup( + ); - context.fill(); - context.restore(); - - // Draw padding around QR code - context.save(); - context.fillStyle = '#fff'; - const sizeWithPadding = PRINT_QR_SIZE + 2 * PRINT_QR_PADDING; - context.beginPath(); - context.roundRect( - padding + (PRINT_WIDTH - sizeWithPadding) / 2, - padding + PRINT_QR_Y - PRINT_QR_PADDING, - sizeWithPadding, - sizeWithPadding, - PRINT_QR_PADDING_RADIUS - ); - context.fill(); - if (isWhiteBackground) { - context.lineWidth = 2; - context.strokeStyle = '#e9e9e9'; - context.stroke(); - } - context.restore(); - - // Draw username - context.fillStyle = isWhiteBackground ? '#000' : '#fff'; - for (const [i, line] of usernameLines.entries()) { - context.fillText( - line, - padding + PRINT_WIDTH / 2, - PRINT_USERNAME_Y + i * PRINT_USERNAME_LINE_HEIGHT - ); - } - - // Draw logo - context.drawImage( - await getLogoCanvas({ fgColor, imageUrl: logoUrl, devicePixelRatio }), - padding + (PRINT_WIDTH - PRINT_LOGO_SIZE) / 2, - padding + PRINT_QR_Y + (PRINT_QR_SIZE - PRINT_LOGO_SIZE) / 2, - PRINT_LOGO_SIZE, - PRINT_LOGO_SIZE - ); - - // Draw QR code - const svg = renderToStaticMarkup(Blotches({ link, color: fgColor })); const svgURL = `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`; const img = new Image(); @@ -361,13 +375,42 @@ export async function _generateImageBlob({ img.src = svgURL; }); - context.drawImage( - img, - padding + (PRINT_WIDTH - PRINT_QR_SIZE) / 2, - PRINT_QR_Y + padding, - PRINT_QR_SIZE, - PRINT_QR_SIZE - ); + context.drawImage(img, 0, 0, PRINT_WIDTH, PRINT_HEIGHT); + + const isWhiteBackground = colorId === ColorEnum.WHITE; + + context.save(); + context.font = USERNAME_FONT; + // Experimental Chrome APIs + ( + context as unknown as { + letterSpacing: number; + } + ).letterSpacing = USERNAME_LETTER_SPACING; + context.fillStyle = isWhiteBackground ? '#000' : '#fff'; + + const centerX = PRINT_WIDTH / 2; + for (const [i, line] of usernameLines.entries()) { + context.fillText(line, centerX, USERNAME_TOP + i * USERNAME_LINE_HEIGHT); + } + context.restore(); + + context.save(); + context.font = HINT_FONT; + // Experimental Chrome APIs + ( + context as unknown as { + letterSpacing: number; + } + ).letterSpacing = HINT_LETTER_SPACING; + context.fillStyle = 'rgba(60, 60, 69, 0.70)'; + + const hintTop = + HINT_BASE_TOP + (usernameLines.length - 1) * USERNAME_LINE_HEIGHT; + for (const [i, line] of hintLines.entries()) { + context.fillText(line, centerX, hintTop + i * HINT_LINE_HEIGHT); + } + context.restore(); const blob = await canvas.convertToBlob({ type: 'image/png' }); return changeDpiBlob(blob, PRINT_DPI); @@ -533,8 +576,7 @@ export function UsernameLinkModalBody({ link, username, colorId, - bgColor, - fgColor, + hint: i18n('icu:UsernameLinkModalBody__hint'), }); const arrayBuffer = await blob.arrayBuffer(); if (isAborted) { @@ -548,7 +590,7 @@ export function UsernameLinkModalBody({ return () => { isAborted = true; }; - }, [link, username, colorId, bgColor, fgColor]); + }, [i18n, link, username, colorId, bgColor, fgColor]); const onSave = useCallback( (e: React.MouseEvent) => { @@ -714,14 +756,14 @@ export function UsernameLinkModalBody({ let linkImage: JSX.Element | undefined; if (usernameLinkState === UsernameLinkState.Ready && link) { linkImage = ( - <> - -
- + + + ); } else if (usernameLinkState === UsernameLinkState.Error) { linkImage = ; diff --git a/ts/test-node/util/splitText_test.ts b/ts/test-node/util/splitText_test.ts new file mode 100644 index 0000000000..3a64a00967 --- /dev/null +++ b/ts/test-node/util/splitText_test.ts @@ -0,0 +1,59 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; + +import type { SplitTextOptionsType } from '../../util/splitText'; +import { splitText } from '../../util/splitText'; + +describe('splitText', () => { + describe('grapheme granularity', () => { + const options: SplitTextOptionsType = { + granularity: 'grapheme', + shouldBreak: x => x.length > 6, + }; + + it('splits text into one line', () => { + assert.deepEqual(splitText('signal', options), ['signal']); + }); + + it('splits text into two lines', () => { + assert.deepEqual(splitText('signal.0123', options), ['signal', '.0123']); + }); + + it('splits text into three lines', () => { + assert.deepEqual(splitText('signal.01234567', options), [ + 'signal', + '.01234', + '567', + ]); + }); + }); + + describe('word granularity', () => { + const options: SplitTextOptionsType = { + granularity: 'word', + shouldBreak: x => x.length > 6, + }; + + it('splits text into one line', () => { + assert.deepEqual(splitText('signal', options), ['signal']); + }); + + it('splits text into two lines', () => { + assert.deepEqual(splitText('signal.0123', options), ['signal.', '0123']); + }); + + it('splits text into three lines', () => { + assert.deepEqual(splitText('aaaaaa b b ccccc', options), [ + 'aaaaaa', + 'b b', + 'ccccc', + ]); + }); + + it('trims lines', () => { + assert.deepEqual(splitText('signa 0123', options), ['signa', '0123']); + }); + }); +}); diff --git a/ts/util/splitText.ts b/ts/util/splitText.ts new file mode 100644 index 0000000000..4306d42ebb --- /dev/null +++ b/ts/util/splitText.ts @@ -0,0 +1,46 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +export type SplitTextOptionsType = Readonly<{ + granularity: 'grapheme' | 'word'; + shouldBreak: (slice: string) => boolean; +}>; + +export function splitText( + text: string, + { granularity, shouldBreak }: SplitTextOptionsType +): Array { + const isWordBased = granularity === 'word'; + const segmenter = new Intl.Segmenter(undefined, { + granularity, + }); + + const result = new Array(); + + // Compute number of lines and height of text + let acc = ''; + let best = ''; + for (const { segment, isWordLike } of segmenter.segment(text)) { + acc += segment; + + // For "grapheme" segmenting, "isWordLike" is always "undefined" + if (isWordLike === false) { + best = acc; + continue; + } + + if (shouldBreak(isWordBased ? acc.trim() : acc)) { + result.push(best); + acc = acc.slice(best.length); + best = acc; + } else { + best = acc; + } + } + + if (best) { + result.push(best); + } + + return isWordBased ? result.map(x => x.trim()) : result; +}