From 01fbe871aa6a8742445f177f1e007c53314ccd92 Mon Sep 17 00:00:00 2001 From: George Gevoian Date: Mon, 27 Mar 2023 13:25:25 -0400 Subject: [PATCH] (core) Add April Fools easter egg Summary: What happens when you type "rr" instead of "r" in an anchor link's row number? Test Plan: Browser tests. Reviewers: jarek Reviewed By: jarek Differential Revision: https://phab.getgrist.com/D3829 --- .../components/BehavioralPromptsManager.ts | 21 ++- app/client/components/GristDoc.ts | 156 +++++++++++++++++- app/client/components/modals.ts | 17 +- app/client/ui/GristTooltips.ts | 20 ++- app/client/ui/OpenVideoTour.ts | 27 +-- app/client/ui/ShortcutKey.ts | 25 +++ app/client/ui/YouTubePlayer.ts | 115 +++++++++++++ app/client/ui/welcomeTour.ts | 43 ++--- app/common/Prefs.ts | 1 + app/common/gristUrls.ts | 31 +++- 10 files changed, 397 insertions(+), 59 deletions(-) create mode 100644 app/client/ui/ShortcutKey.ts create mode 100644 app/client/ui/YouTubePlayer.ts diff --git a/app/client/components/BehavioralPromptsManager.ts b/app/client/components/BehavioralPromptsManager.ts index 5a83594f..a7f7ec06 100644 --- a/app/client/components/BehavioralPromptsManager.ts +++ b/app/client/components/BehavioralPromptsManager.ts @@ -9,8 +9,16 @@ import {Computed, Disposable, dom, Observable} from 'grainjs'; import {IPopupOptions} from 'popweasel'; export interface AttachOptions { + /** Defaults to false. */ + forceShow?: boolean; /** Defaults to false. */ hideArrow?: boolean; + /** Defaults to false. */ + hideDontShowTips?: boolean; + /** Defaults to true. */ + markAsSeen?: boolean; + /** Defaults to false. */ + showOnMobile?: boolean; popupOptions?: IPopupOptions; onDispose?(): void; } @@ -60,11 +68,11 @@ export class BehavioralPromptsManager extends Disposable { // Don't show tips if surveying is disabled. // TODO: Move this into a dedicated variable - this is only a short-term fix for hiding // tips in grist-core. - !getGristConfig().survey || - // Or on mobile - the design currently isn't mobile-friendly. - isNarrowScreen() || + (!getGristConfig().survey && prompt !== 'rickRow') || + // Or if this tip shouldn't be shown on mobile. + (isNarrowScreen() && !options.showOnMobile) || // Or if "Don't show tips" was checked in the past. - this._prefs.get().dontShowTips || + (this._prefs.get().dontShowTips && !options.forceShow) || // Or if this tip has been shown and dismissed in the past. this.hasSeenTip(prompt) ) { @@ -88,15 +96,16 @@ export class BehavioralPromptsManager extends Disposable { } }; - const {hideArrow = false, onDispose, popupOptions} = options; + const {hideArrow, hideDontShowTips, markAsSeen = true, onDispose, popupOptions} = options; const {title, content} = GristBehavioralPrompts[prompt]; const ctl = showBehavioralPrompt(refElement, title(), content(), { onClose: (dontShowTips) => { if (dontShowTips) { this._dontShowTips(); } - this._markAsSeen(prompt); + if (markAsSeen) { this._markAsSeen(prompt); } }, hideArrow, popupOptions, + hideDontShowTips, }); ctl.onDispose(() => { diff --git a/app/client/components/GristDoc.ts b/app/client/components/GristDoc.ts index 8a11040c..ceb7e243 100644 --- a/app/client/components/GristDoc.ts +++ b/app/client/components/GristDoc.ts @@ -36,7 +36,7 @@ import {DocData} from 'app/client/models/DocData'; import {DocInfoRec, DocModel, ViewFieldRec, ViewRec, ViewSectionRec} from 'app/client/models/DocModel'; import {DocPageModel} from 'app/client/models/DocPageModel'; import {UserError} from 'app/client/models/errors'; -import {urlState} from 'app/client/models/gristUrlState'; +import {getMainOrgUrl, urlState} from 'app/client/models/gristUrlState'; import {getFilterFunc, QuerySetManager} from 'app/client/models/QuerySet'; import {getUserOrgPrefObs, getUserOrgPrefsObs, markAsSeen} from 'app/client/models/UserPrefs'; import {App} from 'app/client/ui/App'; @@ -48,8 +48,10 @@ import {IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker'; import {linkFromId, selectBy} from 'app/client/ui/selectBy'; import {startWelcomeTour} from 'app/client/ui/welcomeTour'; import {IWidgetType} from 'app/client/ui/widgetTypes'; -import {isNarrowScreen, mediaSmall, testId} from 'app/client/ui2018/cssVars'; +import {PlayerState, YouTubePlayer} from 'app/client/ui/YouTubePlayer'; +import {isNarrowScreen, mediaSmall, mediaXSmall, testId, theme} from 'app/client/ui2018/cssVars'; import {IconName} from 'app/client/ui2018/IconList'; +import {icon} from 'app/client/ui2018/icons'; import {invokePrompt} from 'app/client/ui2018/modals'; import {FieldEditor} from "app/client/widgets/FieldEditor"; import {DiscussionPanel} from 'app/client/widgets/DiscussionEditor'; @@ -78,6 +80,7 @@ import { Holder, IDisposable, IDomComponent, + keyframes, Observable, styled, subscribe, @@ -87,6 +90,8 @@ import * as ko from 'knockout'; import cloneDeepWith = require('lodash/cloneDeepWith'); import isEqual = require('lodash/isEqual'); +const RICK_ROLL_YOUTUBE_EMBED_ID = 'dQw4w9WgXcQ'; + const t = makeT('GristDoc'); const G = getBrowserGlobals('document', 'window'); @@ -190,6 +195,9 @@ export class GristDoc extends DisposableWithEvents { private _rawSectionOptions: Observable = Observable.create(this, null); private _activeContent: Computed; private _docTutorialHolder = Holder.create(this); + private _isRickRowing: Observable = Observable.create(this, false); + private _showBackgroundVideoPlayer: Observable = Observable.create(this, false); + private _backgroundVideoPlayerHolder: Holder = Holder.create(this); constructor( @@ -280,6 +288,40 @@ export class GristDoc extends DisposableWithEvents { const cursorPos = this._getCursorPosFromHash(state.hash); await this.recursiveMoveToCursorPos(cursorPos, true); } + if (state.hash.rickRow && !this._isRickRowing.get()) { + YouTubePlayer.create(this._backgroundVideoPlayerHolder, RICK_ROLL_YOUTUBE_EMBED_ID, { + height: '100%', + width: '100%', + origin: getMainOrgUrl(), + playerVars: { + controls: 0, + disablekb: 1, + fs: 0, + iv_load_policy: 3, + modestbranding: 1, + }, + onPlayerStateChange: (_player, event) => { + if (event.data === PlayerState.Playing) { + this._isRickRowing.set(true); + } + }, + }, cssYouTubePlayer.cls('')); + this._showBackgroundVideoPlayer.set(true); + this._waitForView() + .then(() => { + const cursor = document.querySelector('.selected_cursor.active_cursor'); + if (cursor) { + this.behavioralPromptsManager.showTip(cursor, 'rickRow', { + forceShow: true, + hideDontShowTips: true, + markAsSeen: false, + showOnMobile: true, + onDispose: () => this.playRickRollVideo(), + }); + } + }) + .catch(reportError); + } } catch (e) { reportError(e); } finally { @@ -482,6 +524,14 @@ export class GristDoc extends DisposableWithEvents { return cssViewContentPane( testId('gristdoc'), cssViewContentPane.cls("-contents", isPopup), + dom.maybe(this._isRickRowing, () => cssStopRickRowingButton( + cssCloseIcon('CrossBig'), + dom.on('click', () => { + this._isRickRowing.set(false); + this._showBackgroundVideoPlayer.set(false); + }), + testId('gristdoc-stop-rick-rowing'), + )), dom.domComputed(this._activeContent, (content) => { return ( content === 'code' ? dom.create(CodeEditorPanel, this) : @@ -504,6 +554,13 @@ export class GristDoc extends DisposableWithEvents { }) ); }), + dom.maybe(this._showBackgroundVideoPlayer, () => [ + cssBackgroundVideo( + this._backgroundVideoPlayerHolder.get()?.buildDom(), + cssBackgroundVideo.cls('-fade-in-and-out', this._isRickRowing), + testId('gristdoc-background-video'), + ), + ]), ); } @@ -1123,6 +1180,41 @@ export class GristDoc extends DisposableWithEvents { } } + /** + * Starts playing the music video for Never Gonna Give You Up in the background. + */ + public async playRickRollVideo() { + const backgroundVideoPlayer = this._backgroundVideoPlayerHolder.get(); + if (!backgroundVideoPlayer) { return; } + + await backgroundVideoPlayer.isLoaded(); + backgroundVideoPlayer.play(); + + const setVolume = async (start: number, end: number, step: number) => { + let volume: number; + const condition = start <= end + ? () => volume <= end + : () => volume >= end; + const afterthought = start <= end + ? () => volume += step + : () => volume -= step; + for (volume = start; condition(); afterthought()) { + backgroundVideoPlayer.setVolume(volume); + await delay(250); + } + }; + + await setVolume(0, 100, 5); + + await delay(190 * 1000); + if (!this._isRickRowing.get()) { return; } + + await setVolume(100, 0, 5); + + this._isRickRowing.set(false); + this._showBackgroundVideoPlayer.set(false); + } + /** * Waits for a view to be ready */ @@ -1448,3 +1540,63 @@ const cssViewContentPane = styled('div', ` overflow: hidden; } `); + +const fadeInAndOut = keyframes(` + 0% { + opacity: 0.01; + } + 5%, 95% { + opacity: 0.2; + } + 100% { + opacity: 0.01; + } +`); + +const cssBackgroundVideo = styled('div', ` + position: fixed; + top: 0; + right: 0; + height: 100%; + width: 100%; + opacity: 0; + pointer-events: none; + + &-fade-in-and-out { + animation: ${fadeInAndOut} 200s; + } +`); + +const cssYouTubePlayer = styled('div', ` + position: absolute; + width: 450%; + height: 450%; + top: -175%; + left: -175%; + + @media ${mediaXSmall} { + & { + width: 450%; + height: 450%; + top: -175%; + left: -175%; + } + } +`); + +const cssStopRickRowingButton = styled('div', ` + position: fixed; + top: 0; + right: 0; + padding: 8px; + margin: 16px; + border-radius: 24px; + background-color: ${theme.toastBg}; + cursor: pointer; +`); + +const cssCloseIcon = styled(icon, ` + height: 24px; + width: 24px; + --icon-color: ${theme.toastControlFg}; +`); diff --git a/app/client/components/modals.ts b/app/client/components/modals.ts index 79e4d89e..58b2464e 100644 --- a/app/client/components/modals.ts +++ b/app/client/components/modals.ts @@ -4,7 +4,7 @@ import {FocusLayer} from 'app/client/lib/FocusLayer'; import {reportSuccess} from 'app/client/models/errors'; import {basicButton, bigPrimaryButton, primaryButton} from 'app/client/ui2018/buttons'; import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox'; -import {testId, theme, vars} from 'app/client/ui2018/cssVars'; +import {mediaXSmall, testId, theme, vars} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; import {cssModalTooltip, modalTooltip} from 'app/client/ui2018/modals'; import {dom, DomContents, keyframes, observable, styled, svg} from 'grainjs'; @@ -138,6 +138,8 @@ export interface ShowBehavioralPromptOptions { onClose: (dontShowTips: boolean) => void; /** Defaults to false. */ hideArrow?: boolean; + /** Defaults to false. */ + hideDontShowTips?: boolean; popupOptions?: IPopupOptions; } @@ -147,7 +149,7 @@ export function showBehavioralPrompt( content: DomContents, options: ShowBehavioralPromptOptions ) { - const {onClose, hideArrow, popupOptions} = options; + const {onClose, hideArrow = false, hideDontShowTips = false, popupOptions} = options; const arrow = hideArrow ? null : buildArrow(); const dontShowTips = observable(false); const tooltip = modalTooltip(refElement, @@ -180,6 +182,7 @@ export function showBehavioralPrompt( cssSkipTipsCheckboxLabel("Don't show tips"), testId('behavioral-prompt-dont-show-tips') ), + dom.style('visibility', hideDontShowTips ? 'hidden' : ''), ), cssDismissPromptButton('Got it', testId('behavioral-prompt-dismiss'), dom.on('click', () => { onClose(dontShowTips.get()); ctl.close(); }) @@ -194,6 +197,10 @@ export function showBehavioralPrompt( offset: { offset: '0,12', }, + preventOverflow: { + boundariesElement: 'window', + padding: 32, + }, } }) ); @@ -340,6 +347,12 @@ const cssBehavioralPromptModal = styled('div', ` &[x-placement^=right] { animation-name: ${cssFadeInFromRight}; } + + @media ${mediaXSmall} { + & { + width: 320px; + } + } `); const cssBehavioralPromptContainer = styled(cssTheme, ` diff --git a/app/client/ui/GristTooltips.ts b/app/client/ui/GristTooltips.ts index f92d4a1c..8645cbd7 100644 --- a/app/client/ui/GristTooltips.ts +++ b/app/client/ui/GristTooltips.ts @@ -1,9 +1,11 @@ +import * as commands from 'app/client/components/commands'; +import {makeT} from 'app/client/lib/localization'; +import {ShortcutKey, ShortcutKeyContent} from 'app/client/ui/ShortcutKey'; +import {icon} from 'app/client/ui2018/icons'; import {cssLink} from 'app/client/ui2018/links'; import {commonUrls} from 'app/common/gristUrls'; import {BehavioralPrompt} from 'app/common/Prefs'; import {dom, DomContents, DomElementArg, styled} from 'grainjs'; -import {icon} from 'app/client/ui2018/icons'; -import {makeT} from 'app/client/lib/localization'; const t = makeT('GristTooltips'); @@ -202,4 +204,18 @@ export const GristBehavioralPrompts: Record t('Anchor Links'), + content: (...args: DomElementArg[]) => cssTooltipContent( + dom('div', + t('To make an anchor link that takes the user to a specific cell, click on' + + ' a row and press {{shortcut}}.', + { + shortcut: ShortcutKey(ShortcutKeyContent(commands.allCommands.copyLink.humanKeys[0])), + } + ), + ), + ...args, + ), + } }; diff --git a/app/client/ui/OpenVideoTour.ts b/app/client/ui/OpenVideoTour.ts index a8308b0d..ae1e8015 100644 --- a/app/client/ui/OpenVideoTour.ts +++ b/app/client/ui/OpenVideoTour.ts @@ -1,16 +1,20 @@ import * as commands from 'app/client/components/commands'; import {makeT} from 'app/client/lib/localization'; +import {getMainOrgUrl} from 'app/client/models/gristUrlState'; import {cssLinkText, cssPageEntryMain, cssPageIcon, cssPageLink} from 'app/client/ui/LeftPanelCommon'; +import {YouTubePlayer} from 'app/client/ui/YouTubePlayer'; import {theme} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; import {cssModalCloseButton, modal} from 'app/client/ui2018/modals'; -import {commonUrls, shouldHideUiElement} from 'app/common/gristUrls'; +import {shouldHideUiElement} from 'app/common/gristUrls'; import {dom, makeTestId, styled} from 'grainjs'; const t = makeT('OpenVideoTour'); const testId = makeTestId('test-video-tour-'); +const VIDEO_TOUR_YOUTUBE_EMBED_ID = 'qnr2Pfnxdlc'; + /** * Opens a modal containing a video tour of Grist. */ @@ -24,15 +28,16 @@ const testId = makeTestId('test-video-tour-'); dom.on('click', () => ctl.close()), testId('close'), ), - cssVideoWrap( - cssVideo( + cssYouTubePlayerContainer( + dom.create(YouTubePlayer, + VIDEO_TOUR_YOUTUBE_EMBED_ID, { - src: commonUrls.videoTour, - title: t("YouTube video player"), - frameborder: '0', - allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture', - allowfullscreen: '', + onPlayerReady: (player) => player.playVideo(), + height: '100%', + width: '100%', + origin: getMainOrgUrl(), }, + cssYouTubePlayer.cls(''), ), ), testId('modal'), @@ -94,18 +99,16 @@ const cssModal = styled('div', ` max-width: 864px; `); -const cssVideoWrap = styled('div', ` +const cssYouTubePlayerContainer = styled('div', ` position: relative; padding-bottom: 56.25%; height: 0; `); -const cssVideo = styled('iframe', ` +const cssYouTubePlayer = styled('div', ` position: absolute; top: 0; left: 0; - width: 100%; - height: 100%; `); const cssVideoTourTextButton = styled('div', ` diff --git a/app/client/ui/ShortcutKey.ts b/app/client/ui/ShortcutKey.ts new file mode 100644 index 00000000..4f46f527 --- /dev/null +++ b/app/client/ui/ShortcutKey.ts @@ -0,0 +1,25 @@ +import {theme} from 'app/client/ui2018/cssVars'; +import {styled} from 'grainjs'; + +export const ShortcutKeyContent = styled('span', ` + font-style: normal; + font-family: inherit; + color: ${theme.shortcutKeyPrimaryFg}; +`); + +export const ShortcutKeyContentStrong = styled(ShortcutKeyContent, ` + font-weight: 700; +`); + +export const ShortcutKey = styled('div', ` + display: inline-block; + padding: 2px 5px; + border-radius: 4px; + margin: 0px 2px; + border: 1px solid ${theme.shortcutKeyBorder}; + color: ${theme.shortcutKeyFg}; + background-color: ${theme.shortcutKeyBg}; + font-family: inherit; + font-style: normal; + white-space: nowrap; +`); diff --git a/app/client/ui/YouTubePlayer.ts b/app/client/ui/YouTubePlayer.ts new file mode 100644 index 00000000..6192a096 --- /dev/null +++ b/app/client/ui/YouTubePlayer.ts @@ -0,0 +1,115 @@ +import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals'; +import {waitObs} from 'app/common/gutil'; +import {Disposable, dom, DomElementArg} from 'grainjs'; +import ko from 'knockout'; + +export interface Player { + playVideo(): void; + pauseVideo(): void; + stopVideo(): void; + mute(): void; + unMute(): void; + setVolume(volume: number): void; +} + +export interface PlayerOptions { + height?: string; + width?: string; + origin?: string; + playerVars?: PlayerVars; + onPlayerReady?(player: Player): void + onPlayerStateChange?(player: Player, event: PlayerStateChangeEvent): void; +} + +export interface PlayerVars { + controls?: 0 | 1; + disablekb?: 0 | 1; + fs?: 0 | 1; + iv_load_policy?: 1 | 3; + modestbranding?: 0 | 1; +} + +export interface PlayerStateChangeEvent { + data: PlayerState; +} + +export enum PlayerState { + Unstarted = -1, + Ended = 0, + Playing = 1, + Paused = 2, + Buffering = 3, + VideoCued = 5, +} + +const G = getBrowserGlobals('document', 'window'); + +/** + * Wrapper component for the YouTube IFrame Player API. + * + * Fetches the JavaScript code for the API if needed, and creates an iframe that + * points to a YouTube video with the specified id. + * + * For more documentation, see https://developers.google.com/youtube/iframe_api_reference. + */ +export class YouTubePlayer extends Disposable { + private _domArgs: DomElementArg[]; + private _isLoading: ko.Observable = ko.observable(true); + private _playerId = `youtube-player-${this._videoId}`; + private _player: Player; + + constructor( + private _videoId: string, + private _options: PlayerOptions, + ...domArgs: DomElementArg[] + ) { + super(); + + this._domArgs = domArgs; + + if (!G.window.YT) { + const tag = document.createElement('script'); + + tag.src = 'https://www.youtube.com/iframe_api'; + const firstScriptTag = document.getElementsByTagName('script')[0]; + firstScriptTag?.parentNode?.insertBefore(tag, firstScriptTag); + + G.window.onYouTubeIframeAPIReady = () => this._handleYouTubeIframeAPIReady(); + } else { + setTimeout(() => this._handleYouTubeIframeAPIReady(), 0); + } + } + + public isLoaded() { + return waitObs(this._isLoading, (val) => !val); + } + + public play() { + this._player.playVideo(); + } + + public setVolume(volume: number) { + this._player.setVolume(volume); + } + + public buildDom() { + return dom('div', {id: this._playerId}, ...this._domArgs); + } + + private _handleYouTubeIframeAPIReady() { + const {onPlayerReady, onPlayerStateChange, playerVars, ...otherOptions} = this._options; + this._player = new G.window.YT.Player(this._playerId, { + videoId: this._videoId, + playerVars, + events: { + onReady: () => { + this._isLoading(false); + onPlayerReady?.(this._player); + }, + onStateChange: (event: PlayerStateChangeEvent) => + onPlayerStateChange?.(this._player, event), + }, + ...otherOptions, + }); + } +} diff --git a/app/client/ui/welcomeTour.ts b/app/client/ui/welcomeTour.ts index dea0e4ab..e2128642 100644 --- a/app/client/ui/welcomeTour.ts +++ b/app/client/ui/welcomeTour.ts @@ -2,6 +2,7 @@ import { makeT } from 'app/client/lib/localization'; import * as commands from 'app/client/components/commands'; import { urlState } from 'app/client/models/gristUrlState'; import { IOnBoardingMsg, startOnBoarding } from "app/client/ui/OnBoardingPopups"; +import { ShortcutKey, ShortcutKeyContent } from 'app/client/ui/ShortcutKey'; import { theme } from 'app/client/ui2018/cssVars'; import { icon } from "app/client/ui2018/icons"; import { cssLink } from "app/client/ui2018/links"; @@ -14,8 +15,12 @@ export const welcomeTour: IOnBoardingMsg[] = [ title: t('Editing Data'), body: () => [ dom('p', - t('Double-click or hit {{enter}} on a cell to edit it. ', {enter: Key(KeyContent(t('Enter')))}), - t('Start with {{equal}} to enter a formula.', { equal: Key(KeyStrong('=')) })) + t('Double-click or hit {{enter}} on a cell to edit it. ', { + enter: ShortcutKey(ShortcutKeyContent(t('Enter'))), + }), + t('Start with {{equal}} to enter a formula.', { + equal: ShortcutKey(ShortcutKeyContent('=')), + })), ], selector: '.field_clip', placement: 'bottom', @@ -39,8 +44,9 @@ export const welcomeTour: IOnBoardingMsg[] = [ dom('p', t('Set formatting options, formulas, or column types, such as dates, choices, or attachments. ')), dom('p', - t('Make it relational! Use the {{ref}} type to link tables. ', {ref: Key(t('Reference'))}), - ) + t('Make it relational! Use the {{ref}} type to link tables. ', { + ref: ShortcutKey(t('Reference')), + })), ], placement: 'right', }, @@ -48,7 +54,9 @@ export const welcomeTour: IOnBoardingMsg[] = [ selector: '.tour-add-new', title: t('Building up'), body: () => [ - dom('p', t('Use {{addNew}} to add widgets, pages, or import more data. ', {addNew: Key(t('Add New'))})) + dom('p', t('Use {{addNew}} to add widgets, pages, or import more data. ', { + addNew: ShortcutKey(t('Add New')), + })), ], placement: 'right', }, @@ -67,7 +75,7 @@ export const welcomeTour: IOnBoardingMsg[] = [ title: t('Flying higher'), body: () => [ dom('p', t('Use {{helpCenter}} for documentation or questions.', - {helpCenter: Key(GreyIcon('Help'), t('Help Center'))})), + {helpCenter: ShortcutKey(GreyIcon('Help'), t('Help Center'))})) ], placement: 'right', }, @@ -92,29 +100,6 @@ export function startWelcomeTour(onFinishCB: () => void) { startOnBoarding(welcomeTour, onFinishCB); } -const KeyContent = styled('span', ` - font-style: normal; - font-family: inherit; - color: ${theme.shortcutKeyPrimaryFg}; -`); - -const KeyStrong = styled(KeyContent, ` - font-weight: 700; -`); - -const Key = styled('div', ` - display: inline-block; - padding: 2px 5px; - border-radius: 4px; - margin: 0px 2px; - border: 1px solid ${theme.shortcutKeyBorder}; - color: ${theme.shortcutKeyFg}; - background-color: ${theme.shortcutKeyBg}; - font-family: inherit; - font-style: normal; - white-space: nowrap; -`); - const TopBarButtonIcon = styled(icon, ` --icon-color: ${theme.topBarButtonPrimaryFg}; `); diff --git a/app/common/Prefs.ts b/app/common/Prefs.ts index 4a74be0c..1a676782 100644 --- a/app/common/Prefs.ts +++ b/app/common/Prefs.ts @@ -85,6 +85,7 @@ export const BehavioralPrompt = StringUnion( 'pageWidgetPickerSelectBy', 'editCardLayout', 'addNew', + 'rickRow', ); export type BehavioralPrompt = typeof BehavioralPrompt.type; diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts index 0e9f683e..8aae2399 100644 --- a/app/common/gristUrls.ts +++ b/app/common/gristUrls.ts @@ -77,7 +77,6 @@ export const commonUrls = { efcrConnect: 'https://efc-r.com/connect', efcrHelp: 'https://www.nioxus.info/eFCR-Help', - videoTour: 'https://www.youtube.com/embed/qnr2Pfnxdlc?autoplay=1', }; /** @@ -261,7 +260,11 @@ export function encodeUrl(gristConfig: Partial, const hash = state.hash; hashParts.push(state.hash?.popup ? 'a2' : `a1`); for (const key of ['sectionId', 'rowId', 'colRef'] as Array) { - if (hash[key]) { hashParts.push(`${key[0]}${hash[key]}`); } + const partValue = hash[key]; + if (partValue) { + const partKey = key === 'rowId' && state.hash?.rickRow ? 'rr' : key[0]; + hashParts.push(`${partKey}${partValue}`); + } } } const queryStr = encodeQueryParams(queryParams); @@ -379,13 +382,28 @@ export function decodeUrl(gristConfig: Partial, location: Locat const hashParts = hash.split('.'); const hashMap = new Map(); for (const part of hashParts) { - hashMap.set(part.slice(0, 1), part.slice(1)); + if (part.startsWith('rr')) { + hashMap.set(part.slice(0, 2), part.slice(2)); + } else { + hashMap.set(part.slice(0, 1), part.slice(1)); + } } if (hashMap.has('#') && ['a1', 'a2'].includes(hashMap.get('#') || '')) { const link: HashLink = {}; - for (const key of ['sectionId', 'rowId', 'colRef'] as Array>) { - const ch = key.substr(0, 1); - if (!hashMap.has(ch)) { continue; } + const keys = [ + 'sectionId', + 'rowId', + 'colRef', + ] as Array>; + for (const key of keys) { + let ch: string; + if (key === 'rowId' && hashMap.has('rr')) { + ch = 'rr'; + link.rickRow = true; + } else { + ch = key.substr(0, 1); + if (!hashMap.has(ch)) { continue; } + } const value = hashMap.get(ch); if (key === 'rowId' && value === 'new') { link[key] = 'new'; @@ -819,6 +837,7 @@ export interface HashLink { rowId?: UIRowId; colRef?: number; popup?: boolean; + rickRow?: boolean; } // Check whether a urlId is a prefix of the docId, and adequately long to be