chore: add clipper extension package (#2281)

This commit is contained in:
Aman Harwara
2023-04-11 22:14:02 +05:30
committed by GitHub
parent 0b0466c9fa
commit 4f5e634685
214 changed files with 3163 additions and 355 deletions

View File

@@ -410,8 +410,8 @@ export class WebApplication extends SNApplication implements WebApplicationInter
return this.getViewControllerManager().subscriptionController.hasValidSubscription()
}
openPurchaseFlow(): void {
this.getViewControllerManager().purchaseFlowController.openPurchaseFlow()
async openPurchaseFlow() {
await this.getViewControllerManager().purchaseFlowController.openPurchaseFlow()
}
addNativeMobileEventListener = (listener: NativeMobileEventListener) => {

View File

@@ -9,7 +9,7 @@ import PreferencesViewWrapper from '@/Components/Preferences/PreferencesViewWrap
import ChallengeModal from '@/Components/ChallengeModal/ChallengeModal'
import NotesContextMenu from '@/Components/NotesContextMenu/NotesContextMenu'
import PurchaseFlowWrapper from '@/Components/PurchaseFlow/PurchaseFlowWrapper'
import { FunctionComponent, useCallback, useEffect, useMemo, useState } from 'react'
import { FunctionComponent, useCallback, useEffect, useMemo, useState, lazy } from 'react'
import RevisionHistoryModal from '@/Components/RevisionHistoryModal/RevisionHistoryModal'
import PremiumModalProvider from '@/Hooks/usePremiumModal'
import ConfirmSignoutContainer from '@/Components/ConfirmSignoutModal/ConfirmSignoutModal'
@@ -36,6 +36,8 @@ type Props = {
mainApplicationGroup: ApplicationGroup
}
const LazyLoadedClipperView = lazy(() => import('../ClipperView/ClipperView'))
const ApplicationView: FunctionComponent<Props> = ({ application, mainApplicationGroup }) => {
const platformString = getPlatformString()
const [launched, setLaunched] = useState(false)
@@ -174,6 +176,40 @@ const ApplicationView: FunctionComponent<Props> = ({ application, mainApplicatio
return <AndroidBackHandlerProvider application={application}>{renderChallenges()}</AndroidBackHandlerProvider>
}
const route = application.routeService.getRoute()
if (route.type === RouteType.AppViewRoute && route.appViewRouteParam === 'extension') {
return (
<ApplicationProvider application={application}>
<CommandProvider service={application.keyboardService}>
<AndroidBackHandlerProvider application={application}>
<ResponsivePaneProvider paneController={application.getViewControllerManager().paneController}>
<PremiumModalProvider
application={application}
featuresController={viewControllerManager.featuresController}
>
<LinkingControllerProvider controller={viewControllerManager.linkingController}>
<FileDragNDropProvider
application={application}
featuresController={viewControllerManager.featuresController}
filesController={viewControllerManager.filesController}
>
<LazyLoadedClipperView
viewControllerManager={viewControllerManager}
applicationGroup={mainApplicationGroup}
/>
<ToastContainer />
{renderChallenges()}
</FileDragNDropProvider>
</LinkingControllerProvider>
</PremiumModalProvider>
</ResponsivePaneProvider>
</AndroidBackHandlerProvider>
</CommandProvider>
</ApplicationProvider>
)
}
return (
<ApplicationProvider application={application}>
<CommandProvider service={application.keyboardService}>
@@ -208,7 +244,6 @@ const ApplicationView: FunctionComponent<Props> = ({ application, mainApplicatio
{renderChallenges()}
<>
<NotesContextMenu
application={application}
navigationController={viewControllerManager.navigationController}
notesController={viewControllerManager.notesController}
linkingController={viewControllerManager.linkingController}

View File

@@ -1,4 +1,3 @@
import { WebApplication } from '@/Application/Application'
import { ViewControllerManager } from '@/Controllers/ViewControllerManager'
import { observer } from 'mobx-react-lite'
import { FunctionComponent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
@@ -7,18 +6,16 @@ import Popover from '../Popover/Popover'
import RoundIconButton from '../Button/RoundIconButton'
import { getIconAndTintForNoteType } from '@/Utils/Items/Icons/getIconAndTintForNoteType'
import { CHANGE_EDITOR_COMMAND, keyboardStringForShortcut } from '@standardnotes/ui-services'
import { useApplication } from '../ApplicationProvider'
type Props = {
application: WebApplication
viewControllerManager: ViewControllerManager
onClickPreprocessing?: () => Promise<void>
}
const ChangeEditorButton: FunctionComponent<Props> = ({
application,
viewControllerManager,
onClickPreprocessing,
}: Props) => {
const ChangeEditorButton: FunctionComponent<Props> = ({ viewControllerManager, onClickPreprocessing }: Props) => {
const application = useApplication()
const note = viewControllerManager.notesController.firstSelectedNote
const [isOpen, setIsOpen] = useState(false)
const buttonRef = useRef<HTMLButtonElement>(null)

View File

@@ -0,0 +1,125 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useApplication } from '../ApplicationProvider'
import Icon from '../Icon/Icon'
import { confirmDialog } from '@standardnotes/ui-services'
import { BlocksEditorComposer } from '../SuperEditor/BlocksEditorComposer'
import { BlocksEditor } from '../SuperEditor/BlocksEditor'
import { SNNote } from '@standardnotes/snjs'
import { NoteSyncController } from '@/Controllers/NoteSyncController'
import LinkedItemBubblesContainer from '../LinkedItems/LinkedItemBubblesContainer'
import { LinkingController } from '@/Controllers/LinkingController'
import Button from '../Button/Button'
import Spinner from '../Spinner/Spinner'
const ClippedNoteView = ({
note,
linkingController,
clearClip,
isFirefoxPopup,
}: {
note: SNNote
linkingController: LinkingController
clearClip: () => void
isFirefoxPopup: boolean
}) => {
const application = useApplication()
const syncController = useRef(new NoteSyncController(application, note))
useEffect(() => {
const currentController = syncController.current
return () => {
currentController.deinit()
}
}, [])
const [title, setTitle] = useState(() => note.title)
useEffect(() => {
void syncController.current.saveAndAwaitLocalPropagation({
title,
isUserModified: true,
dontGeneratePreviews: true,
})
}, [application.items, title])
const handleChange = useCallback(async (value: string, preview: string) => {
void syncController.current.saveAndAwaitLocalPropagation({
text: value,
isUserModified: true,
previews: {
previewPlain: preview,
previewHtml: undefined,
},
})
}, [])
const [isDiscarding, setIsDiscarding] = useState(false)
const discardNote = useCallback(async () => {
if (
await confirmDialog({
text: 'Are you sure you want to discard this clip?',
confirmButtonText: 'Discard',
confirmButtonStyle: 'danger',
})
) {
setIsDiscarding(true)
application.mutator
.deleteItem(note)
.then(() => {
if (isFirefoxPopup) {
window.close()
}
clearClip()
})
.catch(console.error)
.finally(() => setIsDiscarding(false))
}
}, [application.mutator, clearClip, isFirefoxPopup, note])
return (
<div className="">
<div className="border-b border-border p-3">
<div className="mb-3 flex w-full items-center gap-3">
{!isFirefoxPopup && (
<Button className="flex items-center justify-center" fullWidth onClick={clearClip} disabled={isDiscarding}>
<Icon type="arrow-left" className="mr-2" />
Back
</Button>
)}
<Button
className="flex items-center justify-center"
fullWidth
primary
colorStyle="danger"
onClick={discardNote}
disabled={isDiscarding}
>
{isDiscarding ? (
<Spinner className="h-6 w-6 text-danger-contrast" />
) : (
<>
<Icon type="trash-filled" className="mr-2" />
Discard
</>
)}
</Button>
</div>
<input
className="w-full text-base font-semibold"
type="text"
value={title}
onChange={(event) => {
setTitle(event.target.value)
}}
/>
<LinkedItemBubblesContainer linkingController={linkingController} item={note} hideToggle />
</div>
<div className="p-3">
<BlocksEditorComposer initialValue={note.text}>
<BlocksEditor onChange={handleChange}></BlocksEditor>
</BlocksEditorComposer>
</div>
</div>
)
}
export default ClippedNoteView

View File

@@ -0,0 +1,327 @@
import { ApplicationGroup } from '@/Application/ApplicationGroup'
import { ViewControllerManager } from '@/Controllers/ViewControllerManager'
import { SNLogoFull } from '@standardnotes/icons'
import { useCallback, useEffect, useState } from 'react'
import { AccountMenuPane } from '../AccountMenu/AccountMenuPane'
import MenuPaneSelector from '../AccountMenu/MenuPaneSelector'
import { useApplication } from '../ApplicationProvider'
import Icon from '../Icon/Icon'
import Menu from '../Menu/Menu'
import MenuItem from '../Menu/MenuItem'
import { storage as extensionStorage, windows } from 'webextension-polyfill'
import sendMessageToActiveTab from '@standardnotes/clipper/src/utils/sendMessageToActiveTab'
import { ClipPayload, RuntimeMessageTypes } from '@standardnotes/clipper/src/types/message'
import { confirmDialog } from '@standardnotes/ui-services'
import {
ApplicationEvent,
ContentType,
FeatureIdentifier,
FeatureStatus,
NoteContent,
NoteType,
SNNote,
} from '@standardnotes/snjs'
import { addToast, ToastType } from '@standardnotes/toast'
import { getSuperJSONFromClipPayload } from './getSuperJSONFromClipHTML'
import ClippedNoteView from './ClippedNoteView'
import { PremiumFeatureIconClass, PremiumFeatureIconName } from '../Icon/PremiumFeatureIcon'
import Button from '../Button/Button'
import { openSubscriptionDashboard } from '@/Utils/ManageSubscription'
import { useStateRef } from '@/Hooks/useStateRef'
const Header = () => (
<div className="flex items-center border-b border-border p-1 px-3 py-2 text-base font-semibold text-info-contrast">
<SNLogoFull className="h-7" />
</div>
)
const ClipperView = ({
viewControllerManager,
applicationGroup,
}: {
viewControllerManager: ViewControllerManager
applicationGroup: ApplicationGroup
}) => {
const application = useApplication()
const [currentWindow, setCurrentWindow] = useState<Awaited<ReturnType<typeof windows.getCurrent>>>()
useEffect(() => {
windows
.getCurrent({
populate: true,
})
.then((window) => {
setCurrentWindow(window)
})
.catch(console.error)
}, [])
const isFirefoxPopup = !!currentWindow && currentWindow.type === 'popup' && currentWindow.incognito === false
const [user, setUser] = useState(() => application.getUser())
const [isEntitledToExtension, setIsEntitled] = useState(
() => application.features.getFeatureStatus(FeatureIdentifier.Extension) === FeatureStatus.Entitled,
)
const isEntitledRef = useStateRef(isEntitledToExtension)
const hasSubscription = application.hasValidSubscription()
useEffect(() => {
return application.addEventObserver(async (event) => {
switch (event) {
case ApplicationEvent.SignedIn:
case ApplicationEvent.SignedOut:
case ApplicationEvent.UserRolesChanged:
setUser(application.getUser())
setIsEntitled(application.features.getFeatureStatus(FeatureIdentifier.Extension) === FeatureStatus.Entitled)
break
case ApplicationEvent.FeaturesUpdated:
setIsEntitled(application.features.getFeatureStatus(FeatureIdentifier.Extension) === FeatureStatus.Entitled)
break
}
})
}, [application])
const [menuPane, setMenuPane] = useState<AccountMenuPane>()
const activateRegisterPane = useCallback(() => {
setMenuPane(AccountMenuPane.Register)
}, [setMenuPane])
const activateSignInPane = useCallback(() => {
setMenuPane(AccountMenuPane.SignIn)
}, [setMenuPane])
const showSignOutConfirmation = useCallback(async () => {
if (
await confirmDialog({
title: 'Sign Out',
text: 'Are you sure you want to sign out?',
confirmButtonText: 'Sign Out',
confirmButtonStyle: 'danger',
cancelButtonText: 'Cancel',
})
) {
await application.user.signOut()
}
}, [application.user])
const [hasSelection, setHasSelection] = useState(false)
useEffect(() => {
if (!user) {
return
}
try {
const checkIfPageHasSelection = async () => {
setHasSelection(Boolean(await sendMessageToActiveTab(RuntimeMessageTypes.HasSelection)))
}
void checkIfPageHasSelection()
} catch (error) {
console.error(error)
}
}, [user])
const [clipPayload, setClipPayload] = useState<ClipPayload>()
useEffect(() => {
const getClipFromStorage = async () => {
const result = await extensionStorage.local.get('clip')
if (!result.clip) {
return
}
setClipPayload(result.clip)
void extensionStorage.local.remove('clip')
}
void getClipFromStorage()
}, [])
const clearClip = useCallback(() => {
setClipPayload(undefined)
}, [])
const [clippedNote, setClippedNote] = useState<SNNote>()
useEffect(() => {
if (!isEntitledRef.current) {
return
}
async function createNoteFromClip() {
if (!clipPayload) {
setClippedNote(undefined)
return
}
if (!clipPayload.content) {
addToast({
type: ToastType.Error,
message: 'No content to clip',
})
return
}
const editorStateJSON = await getSuperJSONFromClipPayload(clipPayload)
const note = application.items.createTemplateItem<NoteContent, SNNote>(ContentType.Note, {
title: clipPayload.title,
text: editorStateJSON,
editorIdentifier: FeatureIdentifier.SuperEditor,
noteType: NoteType.Super,
references: [],
})
void application.items.insertItem(note).then((note) => {
setClippedNote(note as SNNote)
addToast({
type: ToastType.Success,
message: 'Note clipped successfully',
})
void application.sync.sync()
})
}
void createNoteFromClip()
}, [application.items, application.sync, clipPayload, isEntitledRef])
const upgradePlan = useCallback(async () => {
if (hasSubscription) {
await openSubscriptionDashboard(application)
} else {
await application.openPurchaseFlow()
}
window.close()
}, [application, hasSubscription])
if (user && !isEntitledToExtension) {
return (
<>
<Header />
<div className="px-3 py-3">
<div
className="mx-auto mb-5 flex h-24 w-24 items-center justify-center rounded-[50%] bg-contrast"
aria-hidden={true}
>
<Icon className={`h-12 w-12 ${PremiumFeatureIconClass}`} size={'custom'} type={PremiumFeatureIconName} />
</div>
<div className="mb-1 text-center text-lg font-bold">Enable Advanced Features</div>
<div className="mb-3 text-center">
To take advantage of <span className="font-semibold">Web Clipper</span> and other advanced features, upgrade
your current plan.
</div>
<Button className="mb-2" fullWidth primary onClick={upgradePlan}>
Upgrade
</Button>
<Button fullWidth onClick={showSignOutConfirmation}>
Sign out
</Button>
</div>
</>
)
}
if (clippedNote) {
return (
<>
<Header />
<ClippedNoteView
note={clippedNote}
key={clippedNote.uuid}
linkingController={viewControllerManager.linkingController}
clearClip={clearClip}
isFirefoxPopup={isFirefoxPopup}
/>
</>
)
}
if (!user) {
return (
<>
<Header />
{menuPane ? (
<div className="py-1">
<MenuPaneSelector
viewControllerManager={viewControllerManager}
application={application}
mainApplicationGroup={applicationGroup}
menuPane={menuPane}
setMenuPane={setMenuPane}
closeMenu={() => setMenuPane(undefined)}
/>
</div>
) : (
<Menu a11yLabel="User account menu" isOpen={true}>
<MenuItem onClick={activateRegisterPane}>
<Icon type="user" className="mr-2 h-6 w-6 text-neutral md:h-5 md:w-5" />
Create free account
</MenuItem>
<MenuItem onClick={activateSignInPane}>
<Icon type="signIn" className="mr-2 h-6 w-6 text-neutral md:h-5 md:w-5" />
Sign in
</MenuItem>
</Menu>
)}
</>
)
}
return (
<>
<Header />
<div>
<Menu a11yLabel="Extension menu" isOpen={true} className="pb-1">
<MenuItem
onClick={async () => {
const payload = await sendMessageToActiveTab(RuntimeMessageTypes.GetFullPage)
if (!payload) {
return
}
setClipPayload(payload)
}}
>
Clip full page
</MenuItem>
<MenuItem
onClick={async () => {
const payload = await sendMessageToActiveTab(RuntimeMessageTypes.GetArticle)
if (!payload) {
return
}
setClipPayload(payload)
}}
>
Clip article
</MenuItem>
<MenuItem
disabled={!hasSelection}
onClick={async () => {
const payload = await sendMessageToActiveTab(RuntimeMessageTypes.GetSelection)
if (!payload) {
return
}
setClipPayload(payload)
}}
>
Clip current selection
</MenuItem>
<MenuItem
onClick={async () => {
void sendMessageToActiveTab(RuntimeMessageTypes.StartNodeSelection)
window.close()
}}
>
Select elements to clip
</MenuItem>
<div className="border-t border-border px-3 pt-3 pb-1 text-base text-foreground">
<div>You're signed in as:</div>
<div className="wrap my-0.5 font-bold">{user.email}</div>
<span className="text-neutral">{application.getHost()}</span>
</div>
<MenuItem onClick={showSignOutConfirmation}>
<Icon type="signOut" className="mr-2 h-6 w-6 text-neutral" />
Sign out
</MenuItem>
</Menu>
</div>
</>
)
}
export default ClipperView

View File

@@ -0,0 +1,59 @@
import { $createParagraphNode, $getRoot, $insertNodes, LexicalNode } from 'lexical'
import { $generateNodesFromDOM } from '../SuperEditor/Lexical/Utils/generateNodesFromDOM'
import { createHeadlessEditor } from '@lexical/headless'
import { BlockEditorNodes } from '../SuperEditor/Lexical/Nodes/AllNodes'
import BlocksEditorTheme from '../SuperEditor/Lexical/Theme/Theme'
import { ClipPayload } from '@standardnotes/clipper/src/types/message'
export const getSuperJSONFromClipPayload = async (clipPayload: ClipPayload) => {
const editor = createHeadlessEditor({
namespace: 'BlocksEditor',
theme: BlocksEditorTheme,
editable: false,
onError: (error: Error) => console.error(error),
nodes: [...BlockEditorNodes],
})
await new Promise<void>((resolve) => {
editor.update(() => {
const parser = new DOMParser()
const clipSourceDOM = parser.parseFromString(
`<p>Clip source: <a href="${clipPayload.url}">${clipPayload.url}</a></p>`,
'text/html',
)
const clipSourceParagraphNode = $generateNodesFromDOM(editor, clipSourceDOM).concat(
$createParagraphNode(),
$createParagraphNode(),
)
$getRoot().select()
$insertNodes(clipSourceParagraphNode)
const dom = parser.parseFromString(clipPayload.content, 'text/html')
const generatedNodes = $generateNodesFromDOM(editor, dom)
const nodesToInsert: LexicalNode[] = []
generatedNodes.forEach((node) => {
const type = node.getType()
// Wrap text & link nodes with paragraph since they can't
// be top-level nodes in Super
if (type === 'text' || type === 'link') {
const paragraphNode = $createParagraphNode()
paragraphNode.append(node)
nodesToInsert.push(paragraphNode)
return
} else {
nodesToInsert.push(node)
}
nodesToInsert.push($createParagraphNode())
})
$getRoot().selectEnd()
$insertNodes(nodesToInsert.concat($createParagraphNode()))
resolve()
})
})
return JSON.stringify(editor.getEditorState().toJSON())
}

View File

@@ -55,7 +55,6 @@ const ContextMenuCell = ({
notesController: NotesController
historyModalController: HistoryModalController
}) => {
const application = useApplication()
const [contextMenuVisible, setContextMenuVisible] = useState(false)
const anchorElementRef = useRef<HTMLButtonElement>(null)
@@ -112,7 +111,6 @@ const ContextMenuCell = ({
{allItemsAreNotes && (
<NotesOptions
notes={items as SNNote[]}
application={application}
navigationController={navigationController}
notesController={notesController}
linkingController={linkingController}
@@ -461,7 +459,6 @@ const ContentTableView = ({
<Menu className="select-none" a11yLabel="Note context menu" isOpen={true}>
<NotesOptions
notes={[contextMenuItem]}
application={application}
navigationController={navigationController}
notesController={notesController}
linkingController={linkingController}

View File

@@ -19,7 +19,7 @@ const UpgradeNow = ({ application, featuresController, subscriptionContoller }:
if (hasAccount && application.isNativeIOS()) {
application.showPremiumModal()
} else {
application.openPurchaseFlow()
void application.openPurchaseFlow()
}
}, [application, hasAccount])

View File

@@ -108,6 +108,7 @@ export const IconNameToSvgMapping = {
pencil: icons.PencilIcon,
pin: icons.PinIcon,
restore: icons.RestoreIcon,
save: icons.SaveIcon,
search: icons.SearchIcon,
security: icons.SecurityIcon,
server: icons.ServerIcon,

View File

@@ -17,14 +17,21 @@ import RoundIconButton from '../Button/RoundIconButton'
type Props = {
linkingController: LinkingController
item: DecryptedItemInterface
hideToggle?: boolean
}
const LinkedItemBubblesContainer = ({ item, linkingController }: Props) => {
const LinkedItemBubblesContainer = ({ item, linkingController, hideToggle = false }: Props) => {
const { toggleAppPane } = useResponsiveAppPane()
const commandService = useCommandService()
const { unlinkItemFromSelectedItem: unlinkItem, activateItem } = linkingController
const { unlinkItems, activateItem } = linkingController
const unlinkItem = useCallback(
async (itemToUnlink: LinkableItem) => {
void unlinkItems(item, itemToUnlink)
},
[item, unlinkItems],
)
const { notesLinkedToItem, filesLinkedToItem, tagsLinkedToItem, notesLinkingToItem, filesLinkingToItem } =
useItemLinks(item)
@@ -143,7 +150,7 @@ const LinkedItemBubblesContainer = ({ item, linkingController }: Props) => {
item={item}
/>
</div>
{itemsToDisplay.length > 0 && (
{itemsToDisplay.length > 0 && !hideToggle && (
<RoundIconButton
id="toggle-linking-container"
label="Toggle linked items container"

View File

@@ -21,7 +21,7 @@ type MenuItemProps = {
const MenuItem = forwardRef(
(
{ children, className = '', icon, iconClassName, tabIndex, shortcut, ...props }: MenuItemProps,
{ children, className = '', icon, iconClassName, tabIndex, shortcut, disabled, ...props }: MenuItemProps,
ref: Ref<HTMLButtonElement>,
) => {
return (
@@ -34,9 +34,11 @@ const MenuItem = forwardRef(
'flex w-full cursor-pointer select-none border-0 bg-transparent px-3 py-2 text-left md:py-1.5',
'text-mobile-menu-item text-text hover:bg-contrast hover:text-foreground',
'focus:bg-info-backdrop focus:shadow-none md:text-tablet-menu-item lg:text-menu-item',
'disabled:cursor-not-allowed disabled:opacity-60',
className,
className.includes('items-') ? '' : 'items-center',
)}
disabled={disabled}
{...props}
>
{shortcut && <KeyboardShortcutIndicator className="mr-2" shortcut={shortcut} />}

View File

@@ -47,7 +47,6 @@ const MultipleSelectedNotes = ({
<PinNoteButton notesController={notesController} />
</div>
<NotesOptionsPanel
application={application}
navigationController={navigationController}
notesController={notesController}
linkingController={linkingController}

View File

@@ -19,7 +19,7 @@ const NoSubscriptionBanner = ({
if (application.isNativeIOS()) {
application.showPremiumModal()
} else {
application.openPurchaseFlow()
void application.openPurchaseFlow()
}
}

View File

@@ -1,31 +1,19 @@
import { WebApplication } from '@/Application/Application'
import { noteTypeForEditorIdentifier } from '@standardnotes/features'
import { InfoStrings } from '@standardnotes/services'
import {
NoteMutator,
SNNote,
SNTag,
NoteContent,
DecryptedItemInterface,
PayloadEmitSource,
PrefKey,
} from '@standardnotes/models'
import { SNNote, SNTag, NoteContent, DecryptedItemInterface, PayloadEmitSource, PrefKey } from '@standardnotes/models'
import { UuidString } from '@standardnotes/snjs'
import { removeFromArray, Deferred } from '@standardnotes/utils'
import { removeFromArray } from '@standardnotes/utils'
import { ContentType } from '@standardnotes/common'
import { ItemViewControllerInterface } from './ItemViewControllerInterface'
import { TemplateNoteViewControllerOptions } from './TemplateNoteViewControllerOptions'
import { EditorSaveTimeoutDebounce } from './EditorSaveTimeoutDebounce'
import { log, LoggingDomain } from '@/Logging'
import { NoteSaveFunctionParams, NoteSyncController } from '../../../Controllers/NoteSyncController'
export type EditorValues = {
title: string
text: string
}
const StringEllipses = '...'
const NotePreviewCharLimit = 160
export class NoteViewController implements ItemViewControllerInterface {
public item!: SNNote
public dealloced = false
@@ -35,10 +23,10 @@ export class NoteViewController implements ItemViewControllerInterface {
private innerValueChangeObservers: ((note: SNNote, source: PayloadEmitSource) => void)[] = []
private disposers: (() => void)[] = []
private saveTimeout?: ReturnType<typeof setTimeout>
private defaultTagUuid: UuidString | undefined
private defaultTag?: SNTag
private savingLocallyPromise: ReturnType<typeof Deferred<void>> | null = null
private syncController: NoteSyncController
constructor(
private application: WebApplication,
@@ -56,15 +44,17 @@ export class NoteViewController implements ItemViewControllerInterface {
if (this.defaultTagUuid) {
this.defaultTag = this.application.items.findItem(this.defaultTagUuid) as SNTag
}
this.syncController = new NoteSyncController(this.application, this.item)
}
deinit(): void {
if (!this.savingLocallyPromise) {
if (!this.syncController.savingLocallyPromise) {
this.performDeinitSafely()
return
}
void this.savingLocallyPromise.promise.then(() => {
void this.syncController.savingLocallyPromise.promise.then(() => {
this.performDeinitSafely()
})
}
@@ -80,8 +70,6 @@ export class NoteViewController implements ItemViewControllerInterface {
;(this.item as unknown) = undefined
this.innerValueChangeObservers.length = 0
this.saveTimeout = undefined
}
async initialize(): Promise<void> {
@@ -185,59 +173,11 @@ export class NoteViewController implements ItemViewControllerInterface {
}
}
public async saveAndAwaitLocalPropagation(params: {
title?: string
text?: string
isUserModified: boolean
bypassDebouncer?: boolean
dontGeneratePreviews?: boolean
previews?: { previewPlain: string; previewHtml?: string }
customMutate?: (mutator: NoteMutator) => void
}): Promise<void> {
public async saveAndAwaitLocalPropagation(params: NoteSaveFunctionParams): Promise<void> {
if (this.needsInit) {
throw Error('NoteViewController not initialized')
}
this.savingLocallyPromise = Deferred<void>()
if (this.saveTimeout) {
clearTimeout(this.saveTimeout)
}
const noDebounce = params.bypassDebouncer || this.application.noAccount()
const syncDebouceMs = noDebounce
? EditorSaveTimeoutDebounce.ImmediateChange
: this.application.isNativeMobileWeb()
? EditorSaveTimeoutDebounce.NativeMobileWeb
: EditorSaveTimeoutDebounce.Desktop
return new Promise((resolve) => {
this.saveTimeout = setTimeout(() => {
void this.undebouncedSave({
...params,
onLocalPropagationComplete: () => {
if (this.savingLocallyPromise) {
this.savingLocallyPromise.resolve()
}
resolve()
},
})
}, syncDebouceMs)
})
}
private async undebouncedSave(params: {
title?: string
text?: string
bypassDebouncer?: boolean
isUserModified?: boolean
dontGeneratePreviews?: boolean
previews?: { previewPlain: string; previewHtml?: string }
customMutate?: (mutator: NoteMutator) => void
onLocalPropagationComplete?: () => void
onRemoteSyncComplete?: () => void
}): Promise<void> {
log(LoggingDomain.NoteView, 'Saving note', params)
const isTemplate = this.isTemplateNote
@@ -246,46 +186,6 @@ export class NoteViewController implements ItemViewControllerInterface {
await this.insertTemplatedNote()
}
if (!this.application.items.findItem(this.item.uuid)) {
void this.application.alertService.alert(InfoStrings.InvalidNote)
return
}
await this.application.mutator.changeItem(
this.item,
(mutator) => {
const noteMutator = mutator as NoteMutator
if (params.customMutate) {
params.customMutate(noteMutator)
}
if (params.title != undefined) {
noteMutator.title = params.title
}
if (params.text != undefined) {
noteMutator.text = params.text
}
if (params.previews) {
noteMutator.preview_plain = params.previews.previewPlain
noteMutator.preview_html = params.previews.previewHtml
} else if (!params.dontGeneratePreviews && params.text != undefined) {
const noteText = params.text || ''
const truncate = noteText.length > NotePreviewCharLimit
const substring = noteText.substring(0, NotePreviewCharLimit)
const previewPlain = substring + (truncate ? StringEllipses : '')
noteMutator.preview_plain = previewPlain
noteMutator.preview_html = undefined
}
},
params.isUserModified,
)
void this.application.sync.sync().then(() => {
params.onRemoteSyncComplete?.()
})
params.onLocalPropagationComplete?.()
await this.syncController.saveAndAwaitLocalPropagation(params)
}
}

View File

@@ -1,38 +1,37 @@
import { FunctionComponent } from 'react'
import { FunctionComponent, useState } from 'react'
import Icon from '../Icon/Icon'
type Props = {
onMouseLeave: () => void
onMouseOver: () => void
onClick: () => void
showLockedIcon: boolean
lockText: string
noteLocked: boolean
}
const EditingDisabledBanner: FunctionComponent<Props> = ({
onMouseLeave,
onMouseOver,
onClick,
showLockedIcon,
lockText,
}) => {
const background = showLockedIcon ? 'bg-warning-faded' : 'bg-info-faded'
const iconColor = showLockedIcon ? 'text-accessory-tint-3' : 'text-accessory-tint-1'
const textColor = showLockedIcon ? 'text-warning' : 'text-accessory-tint-1'
const EditingDisabledBanner: FunctionComponent<Props> = ({ onClick, noteLocked }) => {
const [showDisabledCopy, setShowDisabledCopy] = useState(() => noteLocked)
const background = showDisabledCopy ? 'bg-warning-faded' : 'bg-info-faded'
const iconColor = showDisabledCopy ? 'text-accessory-tint-3' : 'text-accessory-tint-1'
const textColor = showDisabledCopy ? 'text-warning' : 'text-accessory-tint-1'
const text = showDisabledCopy ? 'Note editing disabled.' : 'Enable editing'
return (
<div
className={`relative flex items-center ${background} cursor-pointer px-3.5 py-2 text-sm`}
onMouseLeave={onMouseLeave}
onMouseOver={onMouseOver}
onMouseLeave={() => {
setShowDisabledCopy(true)
}}
onMouseOver={() => {
setShowDisabledCopy(false)
}}
onClick={onClick}
>
{showLockedIcon ? (
{showDisabledCopy ? (
<Icon type="pencil-off" className={`${iconColor} mr-3 flex fill-current`} />
) : (
<Icon type="pencil" className={`${iconColor} mr-3 flex fill-current`} />
)}
<span className={textColor}>{lockText}</span>
<span className={textColor}>{text}</span>
</div>
)
}

View File

@@ -46,7 +46,6 @@ import { NoteViewController } from './Controller/NoteViewController'
import { PlainEditor, PlainEditorInterface } from './PlainEditor/PlainEditor'
const MinimumStatusDuration = 400
const NoteEditingDisabledText = 'Note editing disabled.'
function sortAlphabetically(array: SNComponent[]): SNComponent[] {
return array.sort((a, b) => (a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1))
@@ -59,12 +58,10 @@ type State = {
editorStateDidLoad: boolean
editorTitle: string
isDesktop?: boolean
lockText: string
marginResizersEnabled?: boolean
noteLocked: boolean
noteStatus?: NoteStatus
saveError?: boolean
showLockedIcon: boolean
showProtectedWarning: boolean
spellcheck: boolean
stackComponentViewers: ComponentViewerInterface[]
@@ -116,10 +113,8 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
editorStateDidLoad: false,
editorTitle: '',
isDesktop: isDesktopApplication(),
lockText: NoteEditingDisabledText,
noteStatus: undefined,
noteLocked: this.controller.item.locked,
showLockedIcon: true,
showProtectedWarning: false,
spellcheck: true,
stackComponentViewers: [],
@@ -830,21 +825,8 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
{this.state.noteLocked && (
<EditingDisabledBanner
onMouseLeave={() => {
this.setState({
lockText: NoteEditingDisabledText,
showLockedIcon: true,
})
}}
onMouseOver={() => {
this.setState({
lockText: 'Enable editing',
showLockedIcon: false,
})
}}
onClick={() => this.viewControllerManager.notesController.setLockSelectedNotes(!this.state.noteLocked)}
showLockedIcon={this.state.showLockedIcon}
lockText={this.state.lockText}
noteLocked={this.state.noteLocked}
/>
)}
@@ -886,7 +868,6 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
featuresController={this.viewControllerManager.featuresController}
/>
<ChangeEditorButton
application={this.application}
viewControllerManager={this.viewControllerManager}
onClickPreprocessing={this.ensureNoteIsInsertedBeforeUIAction}
/>
@@ -895,7 +876,6 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
onClickPreprocessing={this.ensureNoteIsInsertedBeforeUIAction}
/>
<NotesOptionsPanel
application={this.application}
navigationController={this.viewControllerManager.navigationController}
notesController={this.viewControllerManager.notesController}
linkingController={this.viewControllerManager.linkingController}

View File

@@ -1,7 +1,6 @@
import { observer } from 'mobx-react-lite'
import NotesOptions from '@/Components/NotesOptions/NotesOptions'
import { useCallback, useState } from 'react'
import { WebApplication } from '@/Application/Application'
import { NotesController } from '@/Controllers/NotesController/NotesController'
import { NavigationController } from '@/Controllers/Navigation/NavigationController'
import { HistoryModalController } from '@/Controllers/NoteHistory/HistoryModalController'
@@ -10,7 +9,6 @@ import { LinkingController } from '@/Controllers/LinkingController'
import Menu from '../Menu/Menu'
type Props = {
application: WebApplication
navigationController: NavigationController
notesController: NotesController
linkingController: LinkingController
@@ -18,7 +16,6 @@ type Props = {
}
const NotesContextMenu = ({
application,
navigationController,
notesController,
linkingController,
@@ -49,7 +46,6 @@ const NotesContextMenu = ({
<Menu className="select-none" a11yLabel="Note context menu" isOpen={contextMenuOpen}>
<NotesOptions
notes={notesController.selectedNotes}
application={application}
navigationController={navigationController}
notesController={notesController}
linkingController={linkingController}

View File

@@ -34,6 +34,7 @@ import MenuSwitchButtonItem from '../Menu/MenuSwitchButtonItem'
import MenuItem from '../Menu/MenuItem'
import ModalOverlay from '../Modal/ModalOverlay'
import SuperExportModal from './SuperExportModal'
import { useApplication } from '../ApplicationProvider'
const iconSize = MenuItemIconSize
const iconClassDanger = `text-danger mr-2 ${iconSize}`
@@ -42,13 +43,14 @@ const iconClassSuccess = `text-success mr-2 ${iconSize}`
const NotesOptions = ({
notes,
application,
navigationController,
notesController,
linkingController,
historyModalController,
closeMenu,
}: NotesOptionsProps) => {
const application = useApplication()
const [altKeyDown, setAltKeyDown] = useState(false)
const { toggleAppPane } = useResponsiveAppPane()
const commandService = useCommandService()

View File

@@ -1,7 +1,6 @@
import { useCallback, useRef, useState } from 'react'
import { observer } from 'mobx-react-lite'
import NotesOptions from './NotesOptions'
import { WebApplication } from '@/Application/Application'
import { NotesController } from '@/Controllers/NotesController/NotesController'
import { NavigationController } from '@/Controllers/Navigation/NavigationController'
import { HistoryModalController } from '@/Controllers/NoteHistory/HistoryModalController'
@@ -11,7 +10,6 @@ import RoundIconButton from '../Button/RoundIconButton'
import Menu from '../Menu/Menu'
type Props = {
application: WebApplication
navigationController: NavigationController
notesController: NotesController
linkingController: LinkingController
@@ -20,7 +18,6 @@ type Props = {
}
const NotesOptionsPanel = ({
application,
navigationController,
notesController,
linkingController,
@@ -57,7 +54,6 @@ const NotesOptionsPanel = ({
<Menu a11yLabel="Note options menu" isOpen={isOpen}>
<NotesOptions
notes={notesController.selectedNotes}
application={application}
navigationController={navigationController}
notesController={notesController}
linkingController={linkingController}

View File

@@ -1,4 +1,3 @@
import { WebApplication } from '@/Application/Application'
import { HistoryModalController } from '@/Controllers/NoteHistory/HistoryModalController'
import { NavigationController } from '@/Controllers/Navigation/NavigationController'
import { NotesController } from '@/Controllers/NotesController/NotesController'
@@ -7,7 +6,6 @@ import { SNNote } from '@standardnotes/snjs'
export type NotesOptionsProps = {
notes: SNNote[]
application: WebApplication
navigationController: NavigationController
notesController: NotesController
linkingController: LinkingController

View File

@@ -19,7 +19,7 @@ const NoProSubscription: FunctionComponent<Props> = ({ application, text }) => {
if (application.isNativeIOS()) {
application.showPremiumModal()
} else {
application.openPurchaseFlow()
void application.openPurchaseFlow()
}
} catch (e) {
setPurchaseFlowError(errorMessage)

View File

@@ -18,7 +18,7 @@ const NoSubscription: FunctionComponent<Props> = ({ application }) => {
if (application.isNativeIOS()) {
application.showPremiumModal()
} else {
application.openPurchaseFlow()
void application.openPurchaseFlow()
}
} catch (e) {
setPurchaseFlowError(errorMessage)

View File

@@ -89,7 +89,7 @@ const CreateAccount: FunctionComponent<Props> = ({ viewControllerManager, applic
await application.register(email, password)
viewControllerManager.purchaseFlowController.closePurchaseFlow()
viewControllerManager.purchaseFlowController.openPurchaseFlow()
void viewControllerManager.purchaseFlowController.openPurchaseFlow()
} catch (err) {
console.error(err)
application.alertService.alert(err as string).catch(console.error)

View File

@@ -75,7 +75,7 @@ const SignIn: FunctionComponent<Props> = ({ viewControllerManager, application }
throw new Error(response.data.error?.message)
} else {
viewControllerManager.purchaseFlowController.closePurchaseFlow()
viewControllerManager.purchaseFlowController.openPurchaseFlow()
void viewControllerManager.purchaseFlowController.openPurchaseFlow()
}
} catch (err) {
console.error(err)

View File

@@ -45,7 +45,7 @@ const PurchaseFlowView: FunctionComponent<PurchaseFlowViewProps> = ({ viewContro
>
<Icon type="close" className="text-neutral" />
</button>
<SNLogoFull className="mb-5" />
<SNLogoFull className="mb-5 h-7" />
<PurchaseFlowPaneSelector
currentPane={currentPane}
viewControllerManager={viewControllerManager}

View File

@@ -0,0 +1,135 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import { $isElementNode, DOMChildConversion, DOMConversion, DOMConversionFn, LexicalEditor, LexicalNode } from 'lexical'
/**
* How you parse your html string to get a document is left up to you. In the browser you can use the native
* DOMParser API to generate a document (see clipboard.ts), but to use in a headless environment you can use JSDom
* or an equivilant library and pass in the document here.
*/
export function $generateNodesFromDOM(editor: LexicalEditor, dom: Document): Array<LexicalNode> {
let lexicalNodes: Array<LexicalNode> = []
const elements = dom.body ? dom.body.childNodes : []
for (let i = 0; i < elements.length; i++) {
const element = elements[i]
if (!IGNORE_TAGS.has(element.nodeName)) {
const lexicalNode = $createNodesFromDOM(element, editor)
if (lexicalNode !== null) {
lexicalNodes = lexicalNodes.concat(lexicalNode)
}
}
}
return lexicalNodes
}
function getConversionFunction(domNode: Node, editor: LexicalEditor): DOMConversionFn | null {
const { nodeName } = domNode
const cachedConversions = editor._htmlConversions.get(nodeName.toLowerCase())
let currentConversion: DOMConversion | null = null
if (cachedConversions !== undefined) {
for (const cachedConversion of cachedConversions) {
const domConversion = cachedConversion(domNode)
if (
domConversion !== null &&
(currentConversion === null || currentConversion.priority < domConversion.priority)
) {
currentConversion = domConversion
}
}
}
return currentConversion !== null ? currentConversion.conversion : null
}
const IGNORE_TAGS = new Set(['STYLE', 'SCRIPT'])
function $createNodesFromDOM(
node: Node,
editor: LexicalEditor,
forChildMap: Map<string, DOMChildConversion> = new Map(),
parentLexicalNode?: LexicalNode | null | undefined,
preformatted = false,
): Array<LexicalNode> {
let lexicalNodes: Array<LexicalNode> = []
if (IGNORE_TAGS.has(node.nodeName)) {
return lexicalNodes
}
let currentLexicalNode = null
const transformFunction = getConversionFunction(node, editor)
const transformOutput = transformFunction ? transformFunction(node as HTMLElement, undefined, preformatted) : null
let postTransform = null
if (transformOutput !== null) {
postTransform = transformOutput.after
currentLexicalNode = transformOutput.node
if (currentLexicalNode !== null) {
for (const [, forChildFunction] of forChildMap) {
currentLexicalNode = forChildFunction(currentLexicalNode, parentLexicalNode)
if (!currentLexicalNode) {
break
}
}
if (currentLexicalNode) {
lexicalNodes.push(currentLexicalNode)
}
}
if (transformOutput.forChild != null) {
forChildMap.set(node.nodeName, transformOutput.forChild)
}
}
// If the DOM node doesn't have a transformer, we don't know what
// to do with it but we still need to process any childNodes.
const children = node.childNodes
let childLexicalNodes = []
for (let i = 0; i < children.length; i++) {
childLexicalNodes.push(
...$createNodesFromDOM(
children[i],
editor,
new Map(forChildMap),
currentLexicalNode,
preformatted || (transformOutput && transformOutput.preformatted) === true,
),
)
}
if (postTransform != null) {
childLexicalNodes = postTransform(childLexicalNodes)
}
if (currentLexicalNode == null) {
// If it hasn't been converted to a LexicalNode, we hoist its children
// up to the same level as it.
lexicalNodes = lexicalNodes.concat(childLexicalNodes)
} else {
if ($isElementNode(currentLexicalNode)) {
// If the current node is a ElementNode after conversion,
// we can append all the children to it.
currentLexicalNode.append(...childLexicalNodes)
}
}
return lexicalNodes
}

View File

@@ -112,7 +112,7 @@ export class CollapsibleContainerNode extends ElementNode {
}
getOpen(): boolean {
return this.getLatest().__open
return this.__open
}
toggleOpen(): void {

View File

@@ -1,20 +1,22 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { useEffect } from 'react'
import { $convertFromMarkdownString, TRANSFORMERS } from '@lexical/markdown'
import { $generateNodesFromDOM } from '@lexical/html'
import { $createParagraphNode, $createRangeSelection } from 'lexical'
import { $createParagraphNode, $createRangeSelection, LexicalEditor } from 'lexical'
import { handleEditorChange } from '../../Utils'
import { SuperNotePreviewCharLimit } from '../../SuperEditor'
import { $generateNodesFromDOM } from '../../Lexical/Utils/generateNodesFromDOM'
/** Note that markdown conversion does not insert new lines. See: https://github.com/facebook/lexical/issues/2815 */
export default function ImportPlugin({
text,
format,
onChange,
customImportFunction,
}: {
text: string
format: 'md' | 'html'
onChange: (value: string, preview: string) => void
customImportFunction?: (editor: LexicalEditor, text: string) => void
}): JSX.Element | null {
const [editor] = useLexicalComposerContext()
@@ -24,19 +26,24 @@ export default function ImportPlugin({
return
}
if (customImportFunction) {
customImportFunction(editor, text)
return
}
editor.update(() => {
if (format === 'md') {
$convertFromMarkdownString(text, [...TRANSFORMERS])
} else {
const parser = new DOMParser()
const dom = parser.parseFromString(text, 'text/html')
const nodes = $generateNodesFromDOM(editor, dom)
const nodesToInsert = $generateNodesFromDOM(editor, dom)
const selection = $createRangeSelection()
const newLineNode = $createParagraphNode()
selection.insertNodes([newLineNode, ...nodes])
selection.insertNodes([newLineNode, ...nodesToInsert])
}
})
}, [editor, text, format])
}, [editor, text, format, customImportFunction])
useEffect(() => {
return editor.registerUpdateListener(({ editorState }) => {

View File

@@ -0,0 +1,114 @@
import { WebApplication } from '@/Application/Application'
import { NoteMutator, SNNote } from '@standardnotes/models'
import { InfoStrings } from '@standardnotes/snjs'
import { Deferred } from '@standardnotes/utils'
import { EditorSaveTimeoutDebounce } from '../Components/NoteView/Controller/EditorSaveTimeoutDebounce'
const NotePreviewCharLimit = 160
export type NoteSaveFunctionParams = {
title?: string
text?: string
bypassDebouncer?: boolean
isUserModified?: boolean
dontGeneratePreviews?: boolean
previews?: { previewPlain: string; previewHtml?: string }
customMutate?: (mutator: NoteMutator) => void
onLocalPropagationComplete?: () => void
onRemoteSyncComplete?: () => void
}
export class NoteSyncController {
savingLocallyPromise: ReturnType<typeof Deferred<void>> | null = null
private saveTimeout?: ReturnType<typeof setTimeout>
constructor(private application: WebApplication, private item: SNNote) {}
deinit() {
if (this.saveTimeout) {
clearTimeout(this.saveTimeout)
}
if (this.savingLocallyPromise) {
this.savingLocallyPromise.reject()
}
this.savingLocallyPromise = null
this.saveTimeout = undefined
;(this.application as unknown) = undefined
;(this.item as unknown) = undefined
}
public async saveAndAwaitLocalPropagation(params: NoteSaveFunctionParams): Promise<void> {
this.savingLocallyPromise = Deferred<void>()
if (this.saveTimeout) {
clearTimeout(this.saveTimeout)
}
const noDebounce = params.bypassDebouncer || this.application.noAccount()
const syncDebouceMs = noDebounce
? EditorSaveTimeoutDebounce.ImmediateChange
: this.application.isNativeMobileWeb()
? EditorSaveTimeoutDebounce.NativeMobileWeb
: EditorSaveTimeoutDebounce.Desktop
return new Promise((resolve) => {
this.saveTimeout = setTimeout(() => {
void this.undebouncedSave({
...params,
onLocalPropagationComplete: () => {
if (this.savingLocallyPromise) {
this.savingLocallyPromise.resolve()
}
resolve()
},
})
}, syncDebouceMs)
})
}
private async undebouncedSave(params: NoteSaveFunctionParams): Promise<void> {
if (!this.application.items.findItem(this.item.uuid)) {
void this.application.alertService.alert(InfoStrings.InvalidNote)
return
}
await this.application.mutator.changeItem(
this.item,
(mutator) => {
const noteMutator = mutator as NoteMutator
if (params.customMutate) {
params.customMutate(noteMutator)
}
if (params.title != undefined) {
noteMutator.title = params.title
}
if (params.text != undefined) {
noteMutator.text = params.text
}
if (params.previews) {
noteMutator.preview_plain = params.previews.previewPlain
noteMutator.preview_html = params.previews.previewHtml
} else if (!params.dontGeneratePreviews && params.text != undefined) {
const noteText = params.text || ''
const truncate = noteText.length > NotePreviewCharLimit
const substring = noteText.substring(0, NotePreviewCharLimit)
const previewPlain = substring + (truncate ? '...' : '')
noteMutator.preview_plain = previewPlain
noteMutator.preview_html = undefined
}
},
params.isUserModified,
)
void this.application.sync.sync().then(() => {
params.onRemoteSyncComplete?.()
})
params.onLocalPropagationComplete?.()
}
}

View File

@@ -27,7 +27,7 @@ export class PurchaseFlowController extends AbstractViewController {
this.currentPane = currentPane
}
openPurchaseFlow = (plan = AppleIAPProductId.ProPlanYearly): void => {
openPurchaseFlow = async (plan = AppleIAPProductId.ProPlanYearly) => {
const user = this.application.getUser()
if (!user) {
this.isOpen = true
@@ -35,9 +35,9 @@ export class PurchaseFlowController extends AbstractViewController {
}
if (this.application.isNativeIOS()) {
void this.beginIosIapPurchaseFlow(plan)
await this.beginIosIapPurchaseFlow(plan)
} else {
loadPurchaseFlowUrl(this.application).catch(console.error)
await loadPurchaseFlowUrl(this.application)
}
}

View File

@@ -45,7 +45,7 @@ export class ApplicationEventObserver implements EventObserverInterface {
const route = this.routeService.getRoute()
switch (route.type) {
case RouteType.Purchase:
this.purchaseFlowController.openPurchaseFlow()
void this.purchaseFlowController.openPurchaseFlow()
break
case RouteType.Settings: {

View File

@@ -19,6 +19,34 @@ export const useListKeyboardNavigation = (
}
}, [])
const getNextFocusableIndex = useCallback((currentIndex: number, items: HTMLButtonElement[]) => {
let nextIndex = currentIndex + 1
if (nextIndex > items.length - 1) {
nextIndex = 0
}
while (items[nextIndex].disabled) {
nextIndex++
if (nextIndex > items.length - 1) {
nextIndex = 0
}
}
return nextIndex
}, [])
const getPreviousFocusableIndex = useCallback((currentIndex: number, items: HTMLButtonElement[]) => {
let previousIndex = currentIndex - 1
if (previousIndex < 0) {
previousIndex = items.length - 1
}
while (items[previousIndex].disabled) {
previousIndex--
if (previousIndex < 0) {
previousIndex = items.length - 1
}
}
return previousIndex
}, [])
useEffect(() => {
if (container.current) {
container.current.tabIndex = FOCUSABLE_BUT_NOT_TABBABLE
@@ -37,22 +65,16 @@ export const useListKeyboardNavigation = (
listItems.current = Array.from(container.current?.querySelectorAll('button') as NodeListOf<HTMLButtonElement>)
if (e.key === KeyboardKey.Up) {
let previousIndex = focusedItemIndex.current - 1
if (previousIndex < 0) {
previousIndex = listItems.current.length - 1
}
const previousIndex = getPreviousFocusableIndex(focusedItemIndex.current, listItems.current)
focusItemWithIndex(previousIndex)
}
if (e.key === KeyboardKey.Down) {
let nextIndex = focusedItemIndex.current + 1
if (nextIndex > listItems.current.length - 1) {
nextIndex = 0
}
const nextIndex = getNextFocusableIndex(focusedItemIndex.current, listItems.current)
focusItemWithIndex(nextIndex)
}
},
[container, focusItemWithIndex],
[container, focusItemWithIndex, getNextFocusableIndex, getPreviousFocusableIndex],
)
const FIRST_ITEM_FOCUS_TIMEOUT = 20
@@ -66,12 +88,13 @@ export const useListKeyboardNavigation = (
}
const selectedItemIndex = Array.from(items).findIndex((item) => item.dataset.selected)
const indexToFocus = selectedItemIndex > -1 ? selectedItemIndex : initialFocus
let indexToFocus = selectedItemIndex > -1 ? selectedItemIndex : initialFocus
indexToFocus = getNextFocusableIndex(indexToFocus, items)
setTimeout(() => {
focusItemWithIndex(indexToFocus, items)
}, FIRST_ITEM_FOCUS_TIMEOUT)
}, [container, focusItemWithIndex, initialFocus])
}, [container, focusItemWithIndex, getNextFocusableIndex, initialFocus])
useEffect(() => {
if (shouldAutoFocus) {