diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html index e3c5d8274010..aacd84c9ee63 100644 --- a/.storybook/preview-head.html +++ b/.storybook/preview-head.html @@ -45,4 +45,5 @@ hasCustomTitleBar: () => false, }, }; + window.getPreferredSystemLocales = () => ['en']; diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 9add33b68c78..d59faf9faadb 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -11,6 +11,7 @@ import { ClassyProvider } from '../ts/components/PopperRootContext'; import { I18n } from '../sticker-creator/util/i18n'; import { StorybookThemeContext } from './StorybookThemeContext'; import { ThemeType } from '../ts/types/Util'; +import { setupI18n } from '../ts/util/setupI18n'; export const globalTypes = { mode: { @@ -37,6 +38,8 @@ export const globalTypes = { }, }; +window.i18n = setupI18n('en', messages); + const withModeAndThemeProvider = (Story, context) => { const theme = context.globals.theme === 'light' ? ThemeType.light : ThemeType.dark; diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 86e6f20a7210..ebc91187548e 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -2579,6 +2579,18 @@ "message": "Recently used stickers will appear here.", "description": "Shown in the sticker picker when there are no recent stickers to show." }, + "icu:stickers__StickerPicker__recent": { + "messageformat": "Recents", + "description": "Title for all of the recent stickers" + }, + "icu:stickers__StickerPicker__featured": { + "messageformat": "Featured", + "description": "Title for featured stickers" + }, + "icu:stickers__StickerPicker__analog-time": { + "messageformat": "Analog time", + "description": "aria-label for the analog time sticker" + }, "stickers--StickerPreview--Title": { "message": "Sticker Pack", "description": "The title that appears in the sticker pack preview modal." @@ -5619,6 +5631,10 @@ "message": "There was an error when saving your settings. Please try again.", "description": "Shown if there is an error when saving your preferred reaction settings. Should be very rare to see this message." }, + "icu:MediaEditor__clock-more-styles": { + "messageformat": "More styles", + "description": "Action button for switching up the clock styles" + }, "MediaEditor__control--draw": { "message": "Draw", "description": "Label for the draw button in the media editor" diff --git a/fonts/stories/Hatsuishi-Regular.woff2 b/fonts/stories/Hatsuishi-Regular.woff2 new file mode 100644 index 000000000000..dabfd78be32b Binary files /dev/null and b/fonts/stories/Hatsuishi-Regular.woff2 differ diff --git a/images/analog-time/4-center.svg b/images/analog-time/4-center.svg new file mode 100644 index 000000000000..74117dbf40e2 --- /dev/null +++ b/images/analog-time/4-center.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/analog-time/Arabic-hour.svg b/images/analog-time/Arabic-hour.svg new file mode 100644 index 000000000000..30701e9da636 --- /dev/null +++ b/images/analog-time/Arabic-hour.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/analog-time/Arabic-minute.svg b/images/analog-time/Arabic-minute.svg new file mode 100644 index 000000000000..369bec9dd7aa --- /dev/null +++ b/images/analog-time/Arabic-minute.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/analog-time/Arabic.svg b/images/analog-time/Arabic.svg new file mode 100644 index 000000000000..167552f888fd --- /dev/null +++ b/images/analog-time/Arabic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/analog-time/Baton-hour.svg b/images/analog-time/Baton-hour.svg new file mode 100644 index 000000000000..1acdedc851de --- /dev/null +++ b/images/analog-time/Baton-hour.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/analog-time/Baton-minute.svg b/images/analog-time/Baton-minute.svg new file mode 100644 index 000000000000..5915910c02a8 --- /dev/null +++ b/images/analog-time/Baton-minute.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/analog-time/Baton.svg b/images/analog-time/Baton.svg new file mode 100644 index 000000000000..29e7d5cb04bc --- /dev/null +++ b/images/analog-time/Baton.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/analog-time/Dive-hour.svg b/images/analog-time/Dive-hour.svg new file mode 100644 index 000000000000..95d15a752ebb --- /dev/null +++ b/images/analog-time/Dive-hour.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/analog-time/Dive-minute.svg b/images/analog-time/Dive-minute.svg new file mode 100644 index 000000000000..53a92812b46a --- /dev/null +++ b/images/analog-time/Dive-minute.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/analog-time/Dive.svg b/images/analog-time/Dive.svg new file mode 100644 index 000000000000..1f7017dfb840 --- /dev/null +++ b/images/analog-time/Dive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/analog-time/Explorer-hour.svg b/images/analog-time/Explorer-hour.svg new file mode 100644 index 000000000000..db0621303626 --- /dev/null +++ b/images/analog-time/Explorer-hour.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/analog-time/Explorer-minute.svg b/images/analog-time/Explorer-minute.svg new file mode 100644 index 000000000000..81d4093e329e --- /dev/null +++ b/images/analog-time/Explorer-minute.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/analog-time/Explorer.svg b/images/analog-time/Explorer.svg new file mode 100644 index 000000000000..561e8c003d2b --- /dev/null +++ b/images/analog-time/Explorer.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/stylesheets/_fontfaces.scss b/stylesheets/_fontfaces.scss index a66ae37b5653..f246d2a830f0 100644 --- a/stylesheets/_fontfaces.scss +++ b/stylesheets/_fontfaces.scss @@ -54,3 +54,8 @@ font-family: 'EB Garamond'; src: url('../fonts/stories/EBGaramond-Regular.ttf'); } + +@font-face { + font-family: 'Hatsuishi'; + src: url('../fonts/stories/Hatsuishi-Regular.woff2'); +} diff --git a/stylesheets/_mixins.scss b/stylesheets/_mixins.scss index 6a09168c9cfe..8941b21e536d 100644 --- a/stylesheets/_mixins.scss +++ b/stylesheets/_mixins.scss @@ -3,8 +3,7 @@ // Fonts -@mixin font-family { - font-family: $inter; +@mixin localized-fonts { /* Japanese */ &:lang(ja) { font-family: 'SF Pro JP', 'Hiragino Kaku Gothic Pro', 'ヒラギノ角ゴ Pro W3', @@ -18,6 +17,16 @@ } } +@mixin font-family { + font-family: $inter; + @include localized-fonts; +} + +@mixin time-fonts { + font-family: Hatsuishi, $inter; + @include localized-fonts; +} + @mixin font-title-1 { @include font-family; font-weight: 600; diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 0911c1f3b711..b9d5995507a5 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -5433,6 +5433,10 @@ button.module-image__border-overlay:focus { } } +.module-sticker-picker__recents--title { + color: $color-gray-05; +} + .module-sticker-picker__header__button { width: 28px; height: 28px; @@ -5616,15 +5620,18 @@ button.module-image__border-overlay:focus { .module-sticker-picker__body { position: relative; + &__grid { + display: grid; + grid-gap: 8px; + grid-template-columns: repeat(4, 1fr); + grid-auto-rows: 68px; + } + &__content { width: 332px; height: 356px; padding: 8px 13px 16px 13px; overflow-y: auto; - display: grid; - grid-gap: 8px; - grid-template-columns: repeat(4, 1fr); - grid-auto-rows: 68px; &--under-text { height: 320px; @@ -5721,6 +5728,45 @@ button.module-image__border-overlay:focus { } } +.module-sticker-picker__time--digital { + @include time-fonts; + color: $color-white; + font-size: 28px; + line-height: 0px; +} + +.module-sticker-picker__time--analog { + background: url(../images/analog-time/Arabic.svg) center no-repeat; + background-size: contain; + height: 64px; + position: relative; + width: 64px; +} + +.module-sticker-picker__time--analog__hour { + background: url(../images/analog-time/Arabic-hour.svg) center no-repeat; + height: 14px; + left: 50%; + margin-left: -1px; + margin-top: -14px; + position: absolute; + top: 50%; + transform-origin: 50% 100%; + width: 2px; +} + +.module-sticker-picker__time--analog__minute { + background: url(../images/analog-time/Arabic-minute.svg) center no-repeat; + height: 22px; + left: 50%; + margin-left: -1px; + margin-top: -22px; + position: absolute; + top: 50%; + transform-origin: 50% 100%; + width: 2px; +} + // Module: Sticker button (launches the sticker picker) .sticker-button-wrapper { diff --git a/ts/components/MediaEditor.tsx b/ts/components/MediaEditor.tsx index ce63ff0478a6..fdf0e8d96efa 100644 --- a/ts/components/MediaEditor.tsx +++ b/ts/components/MediaEditor.tsx @@ -27,9 +27,11 @@ import { useFabricHistory } from '../mediaEditor/useFabricHistory'; import { usePortal } from '../hooks/usePortal'; import { useUniqueId } from '../hooks/useUniqueId'; -import { MediaEditorFabricPencilBrush } from '../mediaEditor/MediaEditorFabricPencilBrush'; +import { MediaEditorFabricAnalogTimeSticker } from '../mediaEditor/MediaEditorFabricAnalogTimeSticker'; import { MediaEditorFabricCropRect } from '../mediaEditor/MediaEditorFabricCropRect'; +import { MediaEditorFabricDigitalTimeSticker } from '../mediaEditor/MediaEditorFabricDigitalTimeSticker'; import { MediaEditorFabricIText } from '../mediaEditor/MediaEditorFabricIText'; +import { MediaEditorFabricPencilBrush } from '../mediaEditor/MediaEditorFabricPencilBrush'; import { MediaEditorFabricSticker } from '../mediaEditor/MediaEditorFabricSticker'; import { fabricEffectListener } from '../mediaEditor/fabricEffectListener'; import { getRGBA, getHSL } from '../mediaEditor/util/color'; @@ -1062,6 +1064,53 @@ export function MediaEditor({ fabricCanvas.setActiveObject(sticker); setEditMode(undefined); }} + onPickTimeSticker={(style: 'analog' | 'digital') => { + if (!fabricCanvas) { + return; + } + + if (style === 'digital') { + const sticker = new MediaEditorFabricDigitalTimeSticker( + Date.now() + ); + sticker.setPositionByOrigin( + new fabric.Point( + imageState.width / 2, + imageState.height / 2 + ), + 'center', + 'center' + ); + sticker.setCoords(); + + fabricCanvas.add(sticker); + fabricCanvas.setActiveObject(sticker); + } + + if (style === 'analog') { + const sticker = new MediaEditorFabricAnalogTimeSticker(); + const STICKER_SIZE_RELATIVE_TO_CANVAS = 4; + const size = + Math.min(imageState.width, imageState.height) / + STICKER_SIZE_RELATIVE_TO_CANVAS; + + sticker.scaleToHeight(size); + sticker.setPositionByOrigin( + new fabric.Point( + imageState.width / 2, + imageState.height / 2 + ), + 'center', + 'center' + ); + sticker.setCoords(); + + fabricCanvas.add(sticker); + fabricCanvas.setActiveObject(sticker); + } + + setEditMode(undefined); + }} receivedPacks={[]} recentStickers={recentStickers} showPickerHint={false} @@ -1247,7 +1296,7 @@ function getNewImageStateFromCrop( function cloneFabricCanvas(original: fabric.Canvas): Promise { return new Promise(resolve => { - original.clone(resolve); + original.clone(resolve, ['data']); }); } diff --git a/ts/components/stickers/StickerButton.tsx b/ts/components/stickers/StickerButton.tsx index 242ed2522216..d6c77c163930 100644 --- a/ts/components/stickers/StickerButton.tsx +++ b/ts/components/stickers/StickerButton.tsx @@ -35,6 +35,7 @@ export type OwnProps = { stickerId: number, url: string ) => unknown; + readonly onPickTimeSticker?: (style: 'analog' | 'digital') => unknown; readonly showIntroduction?: boolean; readonly clearShowIntroduction: () => unknown; readonly showPickerHint: boolean; @@ -51,6 +52,7 @@ export const StickerButton = React.memo(function StickerButtonInner({ clearInstalledStickerPack, onClickAddPack, onPickSticker, + onPickTimeSticker, recentStickers, onOpenStateChanged, receivedPacks, @@ -111,6 +113,14 @@ export const StickerButton = React.memo(function StickerButtonInner({ [setOpen, onPickSticker] ); + const handlePickTimeSticker = React.useCallback( + (style: 'analog' | 'digital') => { + setOpen(false); + onPickTimeSticker?.(style); + }, + [setOpen, onPickTimeSticker] + ); + const handleClose = React.useCallback(() => { setOpen(false); }, [setOpen]); @@ -355,6 +365,9 @@ export const StickerButton = React.memo(function StickerButtonInner({ onClickAddPack ? handleClickAddPack : undefined } onPickSticker={handlePickSticker} + onPickTimeSticker={ + onPickTimeSticker ? handlePickTimeSticker : undefined + } recentStickers={recentStickers} showPickerHint={showPickerHint} /> diff --git a/ts/components/stickers/StickerPicker.tsx b/ts/components/stickers/StickerPicker.tsx index e9e4d162471d..36cd98893c3e 100644 --- a/ts/components/stickers/StickerPicker.tsx +++ b/ts/components/stickers/StickerPicker.tsx @@ -8,6 +8,7 @@ import FocusTrap from 'focus-trap-react'; import { useRestoreFocus } from '../../hooks/useRestoreFocus'; import type { StickerPackType, StickerType } from '../../state/ducks/stickers'; import type { LocalizerType } from '../../types/Util'; +import { getAnalogTime } from '../../util/getAnalogTime'; export type OwnProps = { readonly i18n: LocalizerType; @@ -18,6 +19,7 @@ export type OwnProps = { stickerId: number, url: string ) => unknown; + readonly onPickTimeSticker?: (style: 'analog' | 'digital') => unknown; readonly packs: ReadonlyArray; readonly recentStickers: ReadonlyArray; readonly showPickerHint?: boolean; @@ -71,6 +73,7 @@ export const StickerPicker = React.memo( onClose, onClickAddPack, onPickSticker, + onPickTimeSticker, showPickerHint, style, }: Props, @@ -131,7 +134,10 @@ export const StickerPicker = React.memo( // Focus popup on after initial render, restore focus on teardown const [focusRef] = useRestoreFocus(); - const isEmpty = stickers.length === 0; + const hasPacks = packs.length > 0; + const isRecents = hasPacks && currentTab === 'recents'; + const hasTimeStickers = isRecents && onPickTimeSticker; + const isEmpty = stickers.length === 0 && !hasTimeStickers; const addPackRef = isEmpty ? focusRef : undefined; const downloadError = selectedPack && @@ -142,14 +148,13 @@ export const StickerPicker = React.memo( ? selectedPack.stickerCount - stickers.length : 0; - const hasPacks = packs.length > 0; - const isRecents = hasPacks && currentTab === 'recents'; const showPendingText = pendingCount > 0; - const showDownlaodErrorText = downloadError; + const showDownloadErrorText = downloadError; const showEmptyText = !downloadError && isEmpty; const showText = - showPendingText || showDownlaodErrorText || showEmptyText; + showPendingText || showDownloadErrorText || showEmptyText; const showLongText = showPickerHint; + const analogTime = getAnalogTime(); return ( ) : null} {!isEmpty ? ( -
+ {isRecents && onPickTimeSticker && ( +
+ + {i18n('icu:stickers__StickerPicker__featured')} + +
+ + + +
+ {stickers.length > 0 && ( + + {i18n('icu:stickers__StickerPicker__recent')} + + )} +
+ )} +
- {stickers.map(({ packId, id, url }, index: number) => { - const maybeFocusRef = index === 0 ? focusRef : undefined; + })} + > + {stickers.map(({ packId, id, url }, index: number) => { + const maybeFocusRef = index === 0 ? focusRef : undefined; - return ( - + ); + })} + {Array(pendingCount) + .fill(0) + .map((_, i) => ( +
- - ); - })} - {Array(pendingCount) - .fill(0) - .map((_, i) => ( -
- ))} + ))} +
) : null}
diff --git a/ts/mediaEditor/MediaEditorFabricAnalogTimeSticker.ts b/ts/mediaEditor/MediaEditorFabricAnalogTimeSticker.ts new file mode 100644 index 000000000000..30ae2f2472f1 --- /dev/null +++ b/ts/mediaEditor/MediaEditorFabricAnalogTimeSticker.ts @@ -0,0 +1,289 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { get } from 'lodash'; +import { fabric } from 'fabric'; +import { customFabricObjectControls } from './util/customFabricObjectControls'; +import { getAnalogTime } from '../util/getAnalogTime'; +import { strictAssert } from '../util/assert'; +import { moreStyles } from './util/moreStyles'; + +export enum AnalogClockStickerStyle { + Arabic = 'Arabic', + Baton = 'Baton', + Explorer = 'Explorer', + Dive = 'Dive', +} + +const HOUR_LENGTH = 0.44; +const MIN_LENGTH = 0.69; + +type ClockAsset = { + dial: HTMLImageElement; + hour: HTMLImageElement; + minute: HTMLImageElement; +}; + +const ASSETS = new Map(); + +function hydrateAssets(): void { + if (ASSETS.size) { + return; + } + + const path = 'images/analog-time'; + + const clocks = [ + AnalogClockStickerStyle.Arabic, + AnalogClockStickerStyle.Baton, + AnalogClockStickerStyle.Explorer, + AnalogClockStickerStyle.Dive, + ]; + + clocks.forEach(name => { + const dial = new Image(); + const hour = new Image(); + const minute = new Image(); + + dial.src = `${path}/${name}.svg`; + hour.src = `${path}/${name}-hour.svg`; + minute.src = `${path}/${name}-minute.svg`; + + ASSETS.set(name, { + dial, + hour, + minute, + }); + }); +} + +function degToRad(deg: number): number { + return deg * (Math.PI / 180); +} + +type HandDimensions = { + rad: number; + length: number; + width: number; +}; + +function drawHands( + ctx: CanvasRenderingContext2D, + clock: ClockAsset, + hourDimensions: HandDimensions, + minuteDimensions: HandDimensions, + offset = 0 +): void { + ctx.rotate(hourDimensions.rad); + ctx.drawImage( + clock.hour, + 0 - hourDimensions.width / 2, + 0 - hourDimensions.length + offset, + hourDimensions.width, + hourDimensions.length + ); + ctx.rotate(-hourDimensions.rad); + + ctx.rotate(minuteDimensions.rad); + ctx.drawImage( + clock.minute, + 0 - minuteDimensions.width / 2, + 0 - minuteDimensions.length + offset, + minuteDimensions.width, + minuteDimensions.length + ); + ctx.rotate(-minuteDimensions.rad); +} + +export class MediaEditorFabricAnalogTimeSticker extends fabric.Image { + static getNextStyle( + style?: AnalogClockStickerStyle + ): AnalogClockStickerStyle { + if (style === AnalogClockStickerStyle.Dive) { + return AnalogClockStickerStyle.Arabic; + } + + if (style === AnalogClockStickerStyle.Explorer) { + return AnalogClockStickerStyle.Dive; + } + + if (style === AnalogClockStickerStyle.Baton) { + return AnalogClockStickerStyle.Explorer; + } + + return AnalogClockStickerStyle.Baton; + } + + constructor(options: fabric.IImageOptions = {}) { + if (!ASSETS.size) { + hydrateAssets(); + } + + let style: AnalogClockStickerStyle = AnalogClockStickerStyle.Arabic; + ASSETS.forEach((asset, styleName) => { + if (get(options, 'src') === asset.dial.src) { + style = styleName; + } + }); + + const clock = ASSETS.get(style); + strictAssert(clock, 'expected clock not found'); + + super(clock.dial, { + ...options, + data: { stickerStyle: style, timeDeg: getAnalogTime() }, + }); + + this.on('modified', () => this.canvas?.bringToFront(this)); + } + + override render(ctx: CanvasRenderingContext2D): void { + super.render(ctx); + + const { stickerStyle, timeDeg } = this.data; + + const { x, y } = this.getCenterPoint(); + const radius = this.getScaledHeight() / 2; + const flip = this.flipX || this.flipY ? 180 : 0; + const rawAngle = (this.angle ?? 0) - flip; + + const timeRad = { + hour: degToRad(timeDeg.hour + rawAngle), + minute: degToRad(timeDeg.minute + rawAngle), + }; + + ctx.save(); + ctx.translate(x, y); + + const clock = ASSETS.get(stickerStyle); + strictAssert(clock, 'expected clock not found'); + + if (stickerStyle === AnalogClockStickerStyle.Arabic) { + const offset = radius * 0.106; + + drawHands( + ctx, + clock, + { + rad: timeRad.hour, + length: radius * HOUR_LENGTH, + width: radius * 0.049, + }, + { + rad: timeRad.minute, + length: radius * MIN_LENGTH, + width: radius * 0.036, + }, + offset + ); + } + + if (stickerStyle === AnalogClockStickerStyle.Baton) { + const offset = radius * 0.106; + + drawHands( + ctx, + clock, + { + rad: timeRad.hour, + length: radius * HOUR_LENGTH, + width: radius * 0.09, + }, + { + rad: timeRad.minute, + length: radius * MIN_LENGTH, + width: radius * 0.09, + }, + offset + ); + } + + if (stickerStyle === AnalogClockStickerStyle.Explorer) { + drawHands( + ctx, + clock, + { + rad: timeRad.hour, + length: radius * HOUR_LENGTH, + width: radius * 0.07, + }, + { + rad: timeRad.minute, + length: radius * MIN_LENGTH, + width: radius * 0.07, + } + ); + } + + if (stickerStyle === AnalogClockStickerStyle.Dive) { + drawHands( + ctx, + clock, + { + rad: timeRad.hour, + length: radius * 0.47, + width: radius * 0.095, + }, + { + rad: timeRad.minute, + length: radius * 0.89, + width: radius * 0.095, + } + ); + + // Circle + const circleSize = radius * 0.08; + ctx.fillStyle = '#d1ffc1'; + ctx.strokeStyle = '#d1ffc1'; + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.arc(0, 0, circleSize, 0, 2 * Math.PI); + ctx.closePath(); + ctx.fill(); + } + + ctx.restore(); + } + + static fromObject( + // eslint-disable-next-line max-len + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types + options: any, + callback: (_: MediaEditorFabricAnalogTimeSticker) => unknown + ): void { + callback(new MediaEditorFabricAnalogTimeSticker(options)); + } +} + +const moreStylesControl = new fabric.Control({ + ...moreStyles, + mouseUpHandler: (_eventData, { target }) => { + const stickerStyle = MediaEditorFabricAnalogTimeSticker.getNextStyle( + target.data.stickerStyle + ); + + target.setOptions({ + data: { + ...target.data, + stickerStyle, + }, + }); + + const clock = ASSETS.get(stickerStyle); + strictAssert(clock, 'expected clock not found'); + const img = target as fabric.Image; + img.setElement(clock.dial); + + target.setCoords(); + target.canvas?.requestRenderAll(); + return true; + }, +}); + +MediaEditorFabricAnalogTimeSticker.prototype.type = + 'MediaEditorFabricAnalogTimeSticker'; +MediaEditorFabricAnalogTimeSticker.prototype.borderColor = '#ffffff'; +MediaEditorFabricAnalogTimeSticker.prototype.controls = { + ...customFabricObjectControls, + mb: moreStylesControl, +}; diff --git a/ts/mediaEditor/MediaEditorFabricDigitalTimeSticker.ts b/ts/mediaEditor/MediaEditorFabricDigitalTimeSticker.ts new file mode 100644 index 000000000000..d6b8560151b3 --- /dev/null +++ b/ts/mediaEditor/MediaEditorFabricDigitalTimeSticker.ts @@ -0,0 +1,214 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { fabric } from 'fabric'; +import { customFabricObjectControls } from './util/customFabricObjectControls'; +import { moreStyles } from './util/moreStyles'; + +export enum DigitalClockStickerStyle { + White = 'White', + Black = 'Black', + Light = 'Light', + Dark = 'Dark', + Orange = 'Orange', +} + +function getTextStyle(style: DigitalClockStickerStyle): { + fill: string; + textBackgroundColor: string; +} { + if (style === DigitalClockStickerStyle.Black) { + return { + fill: '#000', + textBackgroundColor: '', + }; + } + + if (style === DigitalClockStickerStyle.Light) { + return { + fill: '#fff', + textBackgroundColor: 'rgba(255, 255, 255, 0.4)', + }; + } + + if (style === DigitalClockStickerStyle.Dark) { + return { + fill: '#fff', + textBackgroundColor: 'rgba(0, 0, 0, 0.4)', + }; + } + + if (style === DigitalClockStickerStyle.Orange) { + return { + fill: '#ff7629', + textBackgroundColor: 'rgba(0, 0, 0, 0.6)', + }; + } + + return { + fill: '#fff', + textBackgroundColor: '', + }; +} + +const TEXT_PROPS = { + editable: false, + fontWeight: '400', + left: 0, + lockScalingFlip: true, + originX: 'center', + originY: 'center', + textAlign: 'center', + top: 0, +}; + +export class MediaEditorFabricDigitalTimeSticker extends fabric.Group { + static getNextStyle( + style?: DigitalClockStickerStyle + ): DigitalClockStickerStyle { + if (style === DigitalClockStickerStyle.White) { + return DigitalClockStickerStyle.Black; + } + + if (style === DigitalClockStickerStyle.Black) { + return DigitalClockStickerStyle.Light; + } + + if (style === DigitalClockStickerStyle.Light) { + return DigitalClockStickerStyle.Dark; + } + + if (style === DigitalClockStickerStyle.Dark) { + return DigitalClockStickerStyle.Orange; + } + + return DigitalClockStickerStyle.White; + } + + constructor( + timestamp: number, + style: DigitalClockStickerStyle = DigitalClockStickerStyle.White, + options: fabric.IGroupOptions = {} + ) { + const parts = new Intl.DateTimeFormat(window.getPreferredSystemLocales(), { + hour: 'numeric', + minute: 'numeric', + }).formatToParts(timestamp); + const { fill } = getTextStyle(style); + + let dayPeriodText = ''; + const timeText = parts.reduce((acc, part) => { + if (part.type === 'dayPeriod') { + dayPeriodText = part.value; + return acc; + } + return `${acc}${part.value}`; + }, ''); + + const timeTextNode = new fabric.IText(timeText.trim(), { + ...TEXT_PROPS, + fill, + fontSize: 72, + fontFamily: '"Hatsuishi Large", Hatsuishi, Inter', + }); + + const dayPeriodTextNode = new fabric.IText(dayPeriodText, { + ...TEXT_PROPS, + fill, + fontSize: 11, + fontFamily: 'Inter', + }); + + const dayPeriodBounds = dayPeriodTextNode.getBoundingRect(); + const timeBounds = timeTextNode.getBoundingRect(); + const totalWidth = dayPeriodBounds.width + timeBounds.width; + + dayPeriodTextNode.set({ + left: totalWidth / 2 + dayPeriodBounds.width / 2, + top: timeBounds.height / 2 - dayPeriodBounds.height * 1.66, + }); + + super([timeTextNode, dayPeriodTextNode], { + ...options, + data: { stickerStyle: style, timestamp }, + }); + + this.set('width', totalWidth * 2); + } + + static override fromObject( + // eslint-disable-next-line max-len + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types + options: any, + callback: (_: MediaEditorFabricDigitalTimeSticker) => unknown + ): MediaEditorFabricDigitalTimeSticker { + const timestamp = options?.data.timestamp ?? Date.now(); + + const result = new MediaEditorFabricDigitalTimeSticker( + timestamp, + options.data?.stickerStyle, + options + ); + callback(result); + return result; + } + + override render(ctx: CanvasRenderingContext2D): void { + const { textBackgroundColor } = getTextStyle(this.data.stickerStyle); + + if (textBackgroundColor) { + const bounds = this.getBoundingRect(); + const zoom = this.canvas?.getZoom() || 1; + const height = bounds.height / zoom; + const left = bounds.left / zoom; + const top = bounds.top / zoom; + const width = bounds.width / zoom; + + ctx.save(); + + ctx.fillStyle = textBackgroundColor; + ctx.beginPath(); + ctx.roundRect(left, top, width, height, 14); + ctx.closePath(); + ctx.fill(); + + ctx.restore(); + } + + super.render(ctx); + } +} + +const moreStylesControl = new fabric.Control({ + ...moreStyles, + mouseUpHandler: (_eventData, { target }) => { + const stickerStyle = MediaEditorFabricDigitalTimeSticker.getNextStyle( + target.data.stickerStyle + ); + + target.setOptions({ + data: { + ...target.data, + stickerStyle, + }, + }); + + const styleAttrs = getTextStyle(stickerStyle); + + const group = target as fabric.Group; + group.getObjects().forEach(textObject => { + textObject.set({ fill: styleAttrs.fill }); + }); + + target.setCoords(); + target.canvas?.requestRenderAll(); + return true; + }, +}); + +MediaEditorFabricDigitalTimeSticker.prototype.type = + 'MediaEditorFabricDigitalTimeSticker'; +MediaEditorFabricDigitalTimeSticker.prototype.controls = { + ...customFabricObjectControls, + mb: moreStylesControl, +}; diff --git a/ts/mediaEditor/useFabricHistory.ts b/ts/mediaEditor/useFabricHistory.ts index 0eca4270059b..cb7c6b21348d 100644 --- a/ts/mediaEditor/useFabricHistory.ts +++ b/ts/mediaEditor/useFabricHistory.ts @@ -7,6 +7,8 @@ import { fabric } from 'fabric'; import * as log from '../logging/log'; import type { ImageStateType } from './ImageStateType'; +import { MediaEditorFabricAnalogTimeSticker } from './MediaEditorFabricAnalogTimeSticker'; +import { MediaEditorFabricDigitalTimeSticker } from './MediaEditorFabricDigitalTimeSticker'; import { MediaEditorFabricIText } from './MediaEditorFabricIText'; import { MediaEditorFabricPath } from './MediaEditorFabricPath'; import { MediaEditorFabricSticker } from './MediaEditorFabricSticker'; @@ -152,6 +154,8 @@ export function useFabricHistory({ // doesn't make it easy to deserialize into a custom class without polluting the // global namespace. See . Object.assign(fabric, { + MediaEditorFabricAnalogTimeSticker, + MediaEditorFabricDigitalTimeSticker, MediaEditorFabricIText, MediaEditorFabricPath, MediaEditorFabricSticker, @@ -216,7 +220,7 @@ export function useFabricHistory({ } function getCanvasState(fabricCanvas: fabric.Canvas): string { - return JSON.stringify(fabricCanvas.toDatalessJSON()); + return JSON.stringify(fabricCanvas.toDatalessJSON(['data'])); } function getIsTimeTraveling({ diff --git a/ts/mediaEditor/util/moreStyles.ts b/ts/mediaEditor/util/moreStyles.ts new file mode 100644 index 000000000000..78183364e3a0 --- /dev/null +++ b/ts/mediaEditor/util/moreStyles.ts @@ -0,0 +1,46 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +function render( + ctx: CanvasRenderingContext2D, + left: number, + top: number +): void { + ctx.save(); + + ctx.font = '11px Inter'; + const text = window.i18n('icu:MediaEditor__clock-more-styles'); + const textMetrics = ctx.measureText(text); + + const boxHeight = textMetrics.fontBoundingBoxAscent * 2; + const boxWidth = textMetrics.width * 1.5; + const boxX = left - boxWidth / 2; + const textX = left - textMetrics.width / 2; + const textY = top + boxHeight / 1.5; + + // box + ctx.fillStyle = '#000000'; + ctx.strokeStyle = '#000000'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.roundRect(boxX, top, boxWidth, boxHeight, 4); + ctx.closePath(); + ctx.fill(); + + // text + ctx.fillStyle = '#fff'; + ctx.fillText(text, textX, textY); + + ctx.restore(); +} + +export const moreStyles = { + cursorStyleHandler: (): 'pointer' => 'pointer', + offsetY: 20, + render, + sizeX: 100, + sizeY: 33, + withConnection: true, + x: 0, + y: 0.5, +}; diff --git a/ts/util/getAnalogTime.ts b/ts/util/getAnalogTime.ts new file mode 100644 index 000000000000..bfe338066ea6 --- /dev/null +++ b/ts/util/getAnalogTime.ts @@ -0,0 +1,16 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +const HOURS = [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330]; +const NEXT_HOUR_DEG = 30; + +export function getAnalogTime(): { hour: number; minute: number } { + const date = new Date(); + const minutesBy60 = 60 / date.getMinutes(); + const minute = 360 / minutesBy60; + const hourIndex = date.getHours() % 12; + const currentHour = HOURS[hourIndex] ?? 0; + const hour = currentHour + NEXT_HOUR_DEG / minutesBy60; + + return { hour, minute }; +}