feat: replace window confirm with a dialog (#898)

* feat: replace window confirm with react dialog

* add dialog provider

* export func

* remove unused code

* changes

* Create file_open.tldr

* Update TldrawApp.ts

* clean, and add description

* add a custom container for alert dialog

* Fix fonts

* Fix logic for open project

* Style panel

* Improve styling

Co-authored-by: Steve Ruiz <steveruizok@gmail.com>
pull/915/head
Judicael 2022-08-16 00:45:48 +03:00 zatwierdzone przez GitHub
rodzic 6dccf26c43
commit 97b0b52a6e
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
11 zmienionych plików z 1487 dodań i 65 usunięć

Wyświetl plik

@ -6,11 +6,14 @@ import { ContextMenu } from '~components/ContextMenu'
import { ErrorFallback } from '~components/ErrorFallback' import { ErrorFallback } from '~components/ErrorFallback'
import { FocusButton } from '~components/FocusButton' import { FocusButton } from '~components/FocusButton'
import { Loading } from '~components/Loading' import { Loading } from '~components/Loading'
import { AlertDialog } from '~components/Primitives/AlertDialog'
import { ToolsPanel } from '~components/ToolsPanel' import { ToolsPanel } from '~components/ToolsPanel'
import { TopPanel } from '~components/TopPanel' import { TopPanel } from '~components/TopPanel'
import { GRID_SIZE } from '~constants' import { GRID_SIZE } from '~constants'
import { import {
AlertDialogContext,
ContainerContext, ContainerContext,
DialogState,
TldrawContext, TldrawContext,
useKeyboardShortcuts, useKeyboardShortcuts,
useStylesheet, useStylesheet,
@ -163,6 +166,21 @@ export function Tldraw({
return app return app
}) })
const [onCancel, setOnCancel] = React.useState<(() => void) | null>(null)
const [onYes, setOnYes] = React.useState<(() => void) | null>(null)
const [onNo, setOnNo] = React.useState<(() => void) | null>(null)
const [dialogState, setDialogState] = React.useState<DialogState | null>(null)
const openDialog = React.useCallback(
(dialogState: DialogState, onYes: () => void, onNo: () => void, onCancel: () => void) => {
setDialogState(() => dialogState)
setOnCancel(() => onCancel)
setOnYes(() => onYes)
setOnNo(() => onNo)
},
[]
)
// Create a new app if the `id` prop changes. // Create a new app if the `id` prop changes.
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
if (id === sId) return if (id === sId) return
@ -297,19 +315,23 @@ export function Tldraw({
// Use the `key` to ensure that new selector hooks are made when the id changes // Use the `key` to ensure that new selector hooks are made when the id changes
return ( return (
<TldrawContext.Provider value={app}> <TldrawContext.Provider value={app}>
<InnerTldraw <AlertDialogContext.Provider
key={sId || 'Tldraw'} value={{ onYes, onCancel, onNo, dialogState, setDialogState, openDialog }}
id={sId} >
autofocus={autofocus} <InnerTldraw
showPages={showPages} key={sId || 'Tldraw'}
showMenu={showMenu} id={sId}
showMultiplayerMenu={showMultiplayerMenu} autofocus={autofocus}
showStyles={showStyles} showPages={showPages}
showZoom={showZoom} showMenu={showMenu}
showTools={showTools} showMultiplayerMenu={showMultiplayerMenu}
showUI={showUI} showStyles={showStyles}
readOnly={readOnly} showZoom={showZoom}
/> showTools={showTools}
showUI={showUI}
readOnly={readOnly}
/>
</AlertDialogContext.Provider>
</TldrawContext.Provider> </TldrawContext.Provider>
) )
} }
@ -340,6 +362,7 @@ const InnerTldraw = React.memo(function InnerTldraw({
showUI, showUI,
}: InnerTldrawProps) { }: InnerTldrawProps) {
const app = useTldrawApp() const app = useTldrawApp()
const [dialogContainer, setDialogContainer] = React.useState<any>(null)
const rWrapper = React.useRef<HTMLDivElement>(null) const rWrapper = React.useRef<HTMLDivElement>(null)
@ -439,6 +462,7 @@ const InnerTldraw = React.memo(function InnerTldraw({
return ( return (
<ContainerContext.Provider value={rWrapper}> <ContainerContext.Provider value={rWrapper}>
<IntlProvider locale={translation.locale} messages={translation.messages}> <IntlProvider locale={translation.locale} messages={translation.messages}>
<AlertDialog container={dialogContainer} />
<StyledLayout ref={rWrapper} tabIndex={-0}> <StyledLayout ref={rWrapper} tabIndex={-0}>
<Loading /> <Loading />
<OneOff focusableRef={rWrapper} autofocus={autofocus} /> <OneOff focusableRef={rWrapper} autofocus={autofocus} />
@ -523,7 +547,7 @@ const InnerTldraw = React.memo(function InnerTldraw({
</ErrorBoundary> </ErrorBoundary>
</ContextMenu> </ContextMenu>
{showUI && ( {showUI && (
<StyledUI> <StyledUI ref={setDialogContainer}>
{settings.isFocusMode ? ( {settings.isFocusMode ? (
<FocusButton onSelect={app.toggleFocusMode} /> <FocusButton onSelect={app.toggleFocusMode} />
) : ( ) : (

Wyświetl plik

@ -0,0 +1,160 @@
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
import * as React from 'react'
import { DialogState, useDialog } from '~hooks'
import { styled } from '~styles'
interface ContentProps {
children: React.ReactNode
onClose?: () => void
container: any
}
function Content({ children, onClose, container }: ContentProps) {
const handleKeyDown = (event: React.KeyboardEvent) => {
switch (event.key) {
case 'Escape':
onClose?.()
break
}
}
return (
<AlertDialogPrimitive.Portal container={container}>
<StyledOverlay />
<StyledContent onKeyDown={handleKeyDown}>{children}</StyledContent>
</AlertDialogPrimitive.Portal>
)
}
const StyledDescription = styled(AlertDialogPrimitive.Description, {
marginBottom: 20,
color: '$text',
fontSize: '$2',
lineHeight: 1.5,
textAlign: 'center',
maxWidth: '62%',
minWidth: 0,
alignSelf: 'center',
})
export const AlertDialogRoot = AlertDialogPrimitive.Root
export const AlertDialogContent = Content
export const AlertDialogDescription = StyledDescription
export const AlertDialogAction = AlertDialogPrimitive.Action
export const AlertDialogCancel = AlertDialogPrimitive.Cancel
const descriptions: Record<DialogState, string> = {
saveFirstTime: 'Do you want to save your current project?',
saveAgain: 'Do you want to save changes to your current project?',
}
export const AlertDialog = ({ container }: { container: any }) => {
const { setDialogState, dialogState, onCancel, onNo, onYes } = useDialog()
return (
<AlertDialogRoot open={dialogState !== null}>
<AlertDialogContent onClose={() => setDialogState(null)} container={container}>
{dialogState && (
<AlertDialogDescription>{descriptions[dialogState]}</AlertDialogDescription>
)}
<div
style={{
width: '100%',
gap: '$6',
display: 'flex',
justifyContent: 'space-between',
}}
>
{onCancel && (
<AlertDialogCancel asChild>
<Button
css={{ color: '$text' }}
onClick={() => {
onCancel()
setDialogState(null)
}}
>
Cancel
</Button>
</AlertDialogCancel>
)}
<div style={{ flexShrink: 0 }}>
{onNo && (
<AlertDialogAction asChild>
<Button
onClick={() => {
onNo()
setDialogState(null)
}}
>
No
</Button>
</AlertDialogAction>
)}
{onYes && (
<AlertDialogAction asChild>
<Button
css={{ backgroundColor: '#2F80ED', color: 'White' }}
onClick={() => {
onYes()
setDialogState(null)
}}
>
Yes
</Button>
</AlertDialogAction>
)}
</div>
</div>
</AlertDialogContent>
</AlertDialogRoot>
)
}
const StyledOverlay = styled(AlertDialogPrimitive.Overlay, {
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(0, 0, 0, .15)',
pointerEvents: 'all',
})
export const StyledDialogOverlay = styled(AlertDialogPrimitive.Overlay, {
backgroundColor: 'rgba(0, 0, 0, .15)',
position: 'absolute',
pointerEvents: 'all',
inset: 0,
})
const StyledContent = styled(AlertDialogPrimitive.Content, {
position: 'fixed',
font: '$ui',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: 'max-content',
padding: '$3',
pointerEvents: 'all',
backgroundColor: '$panel',
borderRadius: '$3',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
fontFamily: '$ui',
border: '1px solid $panelContrast',
boxShadow: '$panel',
})
const Button = styled('button', {
all: 'unset',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '$2',
padding: '0 15px',
fontSize: '$1',
lineHeight: 1,
fontWeight: 'normal',
height: 36,
color: '$text',
cursor: 'pointer',
minWidth: 48,
})

Wyświetl plik

@ -0,0 +1 @@
export * from './AlertDialog'

Wyświetl plik

@ -29,7 +29,7 @@ export function DMContent({
const container = useContainer() const container = useContainer()
return ( return (
<DropdownMenu.Portal container={container.current} dir="ltr"> <DropdownMenu.Portal container={overflow ? undefined : container.current} dir="ltr">
<DropdownMenu.Content <DropdownMenu.Content
align={align} align={align}
alignOffset={alignOffset} alignOffset={alignOffset}

Wyświetl plik

@ -5,3 +5,4 @@ export * from './useStylesheet'
export * from './useTheme' export * from './useTheme'
export * from './useTldrawApp' export * from './useTldrawApp'
export * from './useTranslation' export * from './useTranslation'
export * from './useDialog'

Wyświetl plik

@ -0,0 +1,25 @@
import * as React from 'react'
export type DialogState = 'saveFirstTime' | 'saveAgain'
interface AlertDialogProps {
dialogState: DialogState | null
setDialogState: (dialogState: DialogState | null) => void
onYes: (() => void) | null
onNo: (() => void) | null
onCancel: (() => void) | null
openDialog: (
dialogState: DialogState,
onYes: () => void,
onNo: () => void,
onCancel: () => void
) => void
}
export const AlertDialogContext = React.createContext<AlertDialogProps>({} as AlertDialogProps)
export const useDialog = () => {
const context = React.useContext(AlertDialogContext)
if (!context) throw new Error('useCtx must be inside a Provider with a value')
return context
}

Wyświetl plik

@ -1,29 +1,72 @@
import * as React from 'react' import * as React from 'react'
import type { TldrawApp } from '~state' import type { TldrawApp } from '~state'
import { DialogState } from './useDialog'
export function useFileSystem() { export function useFileSystem() {
const promptSaveBeforeChange = React.useCallback(async (app: TldrawApp) => {
if (app.isDirty) {
if (app.fileSystemHandle) {
if (window.confirm('Do you want to save changes to your current project?')) {
await app.saveProject()
}
} else {
if (window.confirm('Do you want to save your current project?')) {
await app.saveProject()
}
}
}
}, [])
const onNewProject = React.useCallback( const onNewProject = React.useCallback(
async (app: TldrawApp) => { async (
if (window.confirm('Do you want to create a new project?')) { app: TldrawApp,
await promptSaveBeforeChange(app) openDialog: (
app.newProject() dialogState: DialogState,
} onYes: () => Promise<void>,
onNo: () => Promise<void>,
onCancel: () => Promise<void>
) => void
) => {
openDialog(
app.fileSystemHandle ? 'saveFirstTime' : 'saveAgain',
async () => {
// user pressed yes
try {
await app.saveProject()
app.newProject()
} catch (e) {
// noop
}
},
async () => {
// user pressed no
app.newProject()
},
async () => {
// user pressed cancel
}
)
}, },
[promptSaveBeforeChange] []
)
const onOpenProject = React.useCallback(
async (
app: TldrawApp,
openDialog: (
dialogState: DialogState,
onYes: () => Promise<void>,
onNo: () => Promise<void>,
onCancel: () => Promise<void>
) => void
) => {
openDialog(
app.fileSystemHandle ? 'saveFirstTime' : 'saveAgain',
async () => {
// user pressed yes
try {
await app.saveProject()
await app.openProject()
} catch (e) {
// noop
}
},
async () => {
// user pressed no
app.openProject()
},
async () => {
// user pressed cancel
}
)
},
[]
) )
const onSaveProject = React.useCallback((app: TldrawApp) => { const onSaveProject = React.useCallback((app: TldrawApp) => {
@ -34,14 +77,6 @@ export function useFileSystem() {
app.saveProjectAs() app.saveProjectAs()
}, []) }, [])
const onOpenProject = React.useCallback(
async (app: TldrawApp) => {
await promptSaveBeforeChange(app)
app.openProject()
},
[promptSaveBeforeChange]
)
const onOpenMedia = React.useCallback(async (app: TldrawApp) => { const onOpenMedia = React.useCallback(async (app: TldrawApp) => {
app.openAsset?.() app.openAsset?.()
}, []) }, [])

Wyświetl plik

@ -1,15 +1,17 @@
import * as React from 'react' import * as React from 'react'
import { useTldrawApp } from '~hooks' import { useDialog, useTldrawApp } from '~hooks'
export function useFileSystemHandlers() { export function useFileSystemHandlers() {
const app = useTldrawApp() const app = useTldrawApp()
const { openDialog } = useDialog()
const onNewProject = React.useCallback( const onNewProject = React.useCallback(
async (e?: React.MouseEvent | React.KeyboardEvent | KeyboardEvent) => { async (e?: React.MouseEvent | React.KeyboardEvent | KeyboardEvent) => {
if (e && app.callbacks.onOpenProject) e.preventDefault() if (e && app.callbacks.onOpenProject) e.preventDefault()
app.callbacks.onNewProject?.(app) app.callbacks.onNewProject?.(app, openDialog)
}, },
[app] [app, openDialog]
) )
const onSaveProject = React.useCallback( const onSaveProject = React.useCallback(
@ -31,9 +33,9 @@ export function useFileSystemHandlers() {
const onOpenProject = React.useCallback( const onOpenProject = React.useCallback(
async (e?: React.MouseEvent | React.KeyboardEvent | KeyboardEvent) => { async (e?: React.MouseEvent | React.KeyboardEvent | KeyboardEvent) => {
if (e && app.callbacks.onOpenProject) e.preventDefault() if (e && app.callbacks.onOpenProject) e.preventDefault()
app.callbacks.onOpenProject?.(app) app.callbacks.onOpenProject?.(app, openDialog)
}, },
[app] [app, openDialog]
) )
const onOpenMedia = React.useCallback( const onOpenMedia = React.useCallback(

Wyświetl plik

@ -199,8 +199,8 @@ export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
useHotkeys( useHotkeys(
'ctrl+n,⌘+n', 'ctrl+n,⌘+n',
(e) => { (e) => {
e.preventDefault()
if (!canHandleEvent()) return if (!canHandleEvent()) return
onNewProject(e) onNewProject(e)
}, },
undefined, undefined,

Wyświetl plik

@ -21,6 +21,7 @@ import {
USER_COLORS, USER_COLORS,
VIDEO_EXTENSIONS, VIDEO_EXTENSIONS,
} from '~constants' } from '~constants'
import { DialogState } from '~hooks'
import { shapeUtils } from '~state/shapes' import { shapeUtils } from '~state/shapes'
import { defaultStyle } from '~state/shapes/shared' import { defaultStyle } from '~state/shapes/shared'
import { import {
@ -97,7 +98,16 @@ export interface TDCallbacks {
/** /**
* (optional) A callback to run when the user creates a new project through the menu or through a keyboard shortcut. * (optional) A callback to run when the user creates a new project through the menu or through a keyboard shortcut.
*/ */
onNewProject?: (app: TldrawApp, e?: KeyboardEvent) => void onNewProject?: (
app: TldrawApp,
openDialog: (
dialogState: DialogState,
onYes: () => void,
onNo: () => void,
onCancel: () => void
) => void,
e?: KeyboardEvent
) => void
/** /**
* (optional) A callback to run when the user saves a project through the menu or through a keyboard shortcut. * (optional) A callback to run when the user saves a project through the menu or through a keyboard shortcut.
*/ */
@ -109,7 +119,16 @@ export interface TDCallbacks {
/** /**
* (optional) A callback to run when the user opens new project through the menu or through a keyboard shortcut. * (optional) A callback to run when the user opens new project through the menu or through a keyboard shortcut.
*/ */
onOpenProject?: (app: TldrawApp, e?: KeyboardEvent) => void onOpenProject?: (
app: TldrawApp,
openDialog: (
dialogState: DialogState,
onYes: () => void,
onNo: () => void,
onCancel: () => void
) => void,
e?: KeyboardEvent
) => void
/** /**
* (optional) A callback to run when the opens a file to upload. * (optional) A callback to run when the opens a file to upload.
*/ */
@ -1384,18 +1403,13 @@ export class TldrawApp extends StateManager<TDSnapshot> {
*/ */
saveProject = async () => { saveProject = async () => {
if (this.readOnly) return if (this.readOnly) return
try { const fileHandle = await saveToFileSystem(
const fileHandle = await saveToFileSystem( migrate(this.state, TldrawApp.version).document,
migrate(this.state, TldrawApp.version).document, this.fileSystemHandle
this.fileSystemHandle )
) this.fileSystemHandle = fileHandle
this.fileSystemHandle = fileHandle this.persist({})
this.persist({}) this.isDirty = false
this.isDirty = false
} catch (e: any) {
// Likely cancelled
console.error(e.message)
}
return this return this
} }
@ -2100,7 +2114,6 @@ export class TldrawApp extends StateManager<TDSnapshot> {
/** /**
* Copy one or more shapes as SVG. * Copy one or more shapes as SVG.
* @param ids The ids of the shapes to copy. * @param ids The ids of the shapes to copy.
* @param pageId The page from which to copy the shapes.
* @returns A string containing the JSON. * @returns A string containing the JSON.
*/ */
copySvg = async ( copySvg = async (
@ -2220,7 +2233,6 @@ export class TldrawApp extends StateManager<TDSnapshot> {
/** /**
* Copy one or more shapes as JSON. * Copy one or more shapes as JSON.
* @param ids The ids of the shapes to copy. * @param ids The ids of the shapes to copy.
* @param pageId The page from which to copy the shapes.
* @returns A string containing the JSON. * @returns A string containing the JSON.
*/ */
copyJson = (ids = this.selectedIds) => { copyJson = (ids = this.selectedIds) => {