Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

focus: rework and untangle existing focus management logic in the sdk #3718

Merged
merged 19 commits into from
May 17, 2024
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,6 @@ export function BoardHistorySnapshot({
}}
overrides={[fileSystemUiOverrides]}
inferDarkMode
autoFocus
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is already the default

/>
</div>
<div className="board-history__restore">
Expand Down
20 changes: 8 additions & 12 deletions apps/dotcom/src/components/CursorChatBubble.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
useRef,
useState,
} from 'react'
import { preventDefault, track, useContainer, useEditor, useTranslation } from 'tldraw'
import { preventDefault, track, useEditor, useTranslation } from 'tldraw'

// todo:
// - not cleaning up
Expand All @@ -18,7 +18,6 @@ const CHAT_MESSAGE_TIMEOUT_CHATTING = 5000

export const CursorChatBubble = track(function CursorChatBubble() {
const editor = useEditor()
const container = useContainer()
const { isChatting, chatMessage } = editor.getInstanceState()

const rTimeout = useRef<any>(-1)
Expand All @@ -31,14 +30,14 @@ export const CursorChatBubble = track(function CursorChatBubble() {
rTimeout.current = setTimeout(() => {
editor.updateInstanceState({ chatMessage: '', isChatting: false })
setValue('')
container.focus()
editor.focus()
}, duration)
}

return () => {
clearTimeout(rTimeout.current)
}
}, [container, editor, chatMessage, isChatting])
}, [editor, chatMessage, isChatting])

if (isChatting)
return <CursorChatInput value={value} setValue={setValue} chatMessage={chatMessage} />
Expand Down Expand Up @@ -101,7 +100,6 @@ const CursorChatInput = track(function CursorChatInput({
}) {
const editor = useEditor()
const msg = useTranslation()
const container = useContainer()

const ref = useRef<HTMLInputElement>(null)
const placeholder = chatMessage || msg('cursor-chat.type-to-chat')
Expand All @@ -126,11 +124,9 @@ const CursorChatInput = track(function CursorChatInput({
}, [editor, value, placeholder])

useLayoutEffect(() => {
// Focus the editor
let raf = requestAnimationFrame(() => {
raf = requestAnimationFrame(() => {
ref.current?.focus()
})
// Focus the input
const raf = requestAnimationFrame(() => {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this double raf doesn't seem necessary... but maybe i'm missing something.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah your version works in chrome, safari and firefox. cc @TodePond do you remember if this double raf was intentional?

ref.current?.focus()
})

return () => {
Expand All @@ -140,8 +136,8 @@ const CursorChatInput = track(function CursorChatInput({

const stopChatting = useCallback(() => {
editor.updateInstanceState({ isChatting: false })
container.focus()
}, [editor, container])
editor.focus()
}, [editor])

// Update the chat message as the user types
const handleChange = useCallback(
Expand Down
1 change: 0 additions & 1 deletion apps/dotcom/src/components/LocalEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,6 @@ export function LocalEditor() {
assetUrls={assetUrls}
persistenceKey={SCRATCH_PERSISTENCE_KEY}
onMount={handleMount}
autoFocus
overrides={[sharingUiOverrides, fileSystemUiOverrides]}
onUiEvent={handleUiEvent}
components={components}
Expand Down
1 change: 0 additions & 1 deletion apps/dotcom/src/components/MultiplayerEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,6 @@ export function MultiplayerEditor({
initialState={isReadonly ? 'hand' : 'select'}
onUiEvent={handleUiEvent}
components={components}
autoFocus
inferDarkMode
>
<UrlStateSync />
Expand Down
4 changes: 2 additions & 2 deletions apps/dotcom/src/components/PeopleMenu/UserPresenceEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ export function UserPresenceEditor() {
onCancel={toggleEditingName}
onBlur={handleBlur}
shouldManuallyMaintainScrollPositionWhenFocused
autofocus
autoselect
autoFocus
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

drive-by rename to make autoFocus across the SDK consistent

autoSelect
/>
) : (
<>
Expand Down
1 change: 0 additions & 1 deletion apps/dotcom/src/components/SnapshotsEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,6 @@ export function SnapshotsEditor(props: SnapshotEditorProps) {
}}
components={components}
renderDebugMenuItems={() => <DebugMenuItems />}
autoFocus
inferDarkMode
>
<UrlStateSync />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export default function ExternalContentSourcesExample() {

return (
<div className="tldraw__editor">
<Tldraw autoFocus onMount={handleMount} shapeUtils={[DangerousHtmlExample]} />
<Tldraw onMount={handleMount} shapeUtils={[DangerousHtmlExample]} />
</div>
)
}
Expand Down
5 changes: 1 addition & 4 deletions apps/examples/src/examples/scroll/ScrollExample.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,7 @@ export default function ScrollExample() {
}}
>
<div style={{ width: '60vw', height: '80vh' }}>
<Tldraw
persistenceKey="scroll-example"
// autoFocus={false}
/>
<Tldraw persistenceKey="scroll-example" />
</div>
</div>
)
Expand Down
1 change: 0 additions & 1 deletion apps/vscode/editor/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,6 @@ function TldrawInner({ uri, assetSrc, isDarkMode, fileContents }: TLDrawInnerPro
persistenceKey={uri}
onMount={handleMount}
components={components}
autoFocus
>
{/* <DarkModeHandler themeKind={themeKind} /> */}

Expand Down
18 changes: 17 additions & 1 deletion packages/editor/api-report.md
Original file line number Diff line number Diff line change
Expand Up @@ -585,7 +585,7 @@ export class Edge2d extends Geometry2d {

// @public (undocumented)
export class Editor extends EventEmitter<TLEventMap> {
constructor({ store, user, shapeUtils, tools, getContainer, initialState, inferDarkMode, }: TLEditorOptions);
constructor({ store, user, shapeUtils, tools, getContainer, initialState, autoFocus, inferDarkMode, }: TLEditorOptions);
addOpenMenu(id: string): this;
alignShapes(shapes: TLShape[] | TLShapeId[], operation: 'bottom' | 'center-horizontal' | 'center-vertical' | 'left' | 'right' | 'top'): this;
animateShape(partial: null | TLShapePartial | undefined, animationOptions?: TLAnimationOptions): this;
Expand Down Expand Up @@ -671,6 +671,8 @@ export class Editor extends EventEmitter<TLEventMap> {
findCommonAncestor(shapes: TLShape[] | TLShapeId[], predicate?: (shape: TLShape) => boolean): TLShapeId | undefined;
findShapeAncestor(shape: TLShape | TLShapeId, predicate: (parent: TLShape) => boolean): TLShape | undefined;
flipShapes(shapes: TLShape[] | TLShapeId[], operation: 'horizontal' | 'vertical'): this;
focus(): this;
readonly focusManager: FocusManager;
getAncestorPageId(shape?: TLShape | TLShapeId): TLPageId | undefined;
getArrowInfo(shape: TLArrowShape | TLShapeId): TLArrowInfo | undefined;
getArrowsBoundTo(shapeId: TLShapeId): {
Expand Down Expand Up @@ -1027,6 +1029,19 @@ export function extractSessionStateFromLegacySnapshot(store: Record<string, Unkn
// @internal (undocumented)
export const featureFlags: Record<string, DebugFlag<boolean>>;

// @public
export class FocusManager {
constructor(editor: Editor, autoFocus?: boolean);
// (undocumented)
blur(): void;
// (undocumented)
dispose(): void;
// (undocumented)
editor: Editor;
// (undocumented)
focus(): void;
}

// @public (undocumented)
export type GapsSnapIndicator = {
direction: 'horizontal' | 'vertical';
Expand Down Expand Up @@ -2171,6 +2186,7 @@ export type TLEditorComponents = Partial<{

// @public (undocumented)
export interface TLEditorOptions {
autoFocus?: boolean;
getContainer: () => HTMLElement;
inferDarkMode?: boolean;
initialState?: string;
Expand Down
1 change: 1 addition & 0 deletions packages/editor/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ export {
type TLEditorOptions,
type TLResizeShapeOptions,
} from './lib/editor/Editor'
export type { FocusManager } from './lib/editor/managers/FocusManager'
export { HistoryManager } from './lib/editor/managers/HistoryManager'
export type {
SideEffectManager,
Expand Down
21 changes: 4 additions & 17 deletions packages/editor/src/lib/TldrawEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,8 @@ import {
useEditorComponents,
} from './hooks/useEditorComponents'
import { useEvent } from './hooks/useEvent'
import { useFocusEvents } from './hooks/useFocusEvents'
import { useForceUpdate } from './hooks/useForceUpdate'
import { useLocalStore } from './hooks/useLocalStore'
import { useSafariFocusOutFix } from './hooks/useSafariFocusOutFix'
import { useZoomCss } from './hooks/useZoomCss'
import { stopEventPropagation } from './utils/dom'
import { TLStoreWithStatus } from './utils/sync/StoreWithStatus'
Expand Down Expand Up @@ -285,14 +283,15 @@ function TldrawEditorWithReadyStore({
getContainer: () => container,
user,
initialState,
autoFocus,
inferDarkMode,
})
setEditor(editor)

return () => {
editor.dispose()
}
}, [container, shapeUtils, tools, store, user, initialState, inferDarkMode])
}, [container, shapeUtils, tools, store, user, initialState, autoFocus, inferDarkMode])

const crashingError = useSyncExternalStore(
useCallback(
Expand Down Expand Up @@ -333,30 +332,18 @@ function TldrawEditorWithReadyStore({
<Crash crashingError={crashingError} />
) : (
<EditorContext.Provider value={editor}>
<Layout autoFocus={autoFocus} onMount={onMount}>
{children ?? (Canvas ? <Canvas /> : null)}
</Layout>
<Layout onMount={onMount}>{children ?? (Canvas ? <Canvas /> : null)}</Layout>
</EditorContext.Provider>
)}
</OptionalErrorBoundary>
)
}

function Layout({
children,
onMount,
autoFocus,
}: {
children: ReactNode
autoFocus: boolean
onMount?: TLOnMountHandler
}) {
function Layout({ children, onMount }: { children: ReactNode; onMount?: TLOnMountHandler }) {
useZoomCss()
useCursor()
useDarkMode()
useSafariFocusOutFix()
useForceUpdate()
useFocusEvents(autoFocus)
useOnMount(onMount)

return <>{children}</>
Expand Down
31 changes: 31 additions & 0 deletions packages/editor/src/lib/editor/Editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ import { deriveShapeIdsInCurrentPage } from './derivations/shapeIdsInCurrentPage
import { getSvgJsx } from './getSvgJsx'
import { ClickManager } from './managers/ClickManager'
import { EnvironmentManager } from './managers/EnvironmentManager'
import { FocusManager } from './managers/FocusManager'
import { HistoryManager } from './managers/HistoryManager'
import { ScribbleManager } from './managers/ScribbleManager'
import { SideEffectManager } from './managers/SideEffectManager'
Expand Down Expand Up @@ -178,6 +179,10 @@ export interface TLEditorOptions {
* The editor's initial active tool (or other state node id).
*/
initialState?: string
/**
* Whether to automatically focus the editor when it mounts.
*/
autoFocus?: boolean
/**
* Whether to infer dark mode from the user's system preferences. Defaults to false.
*/
Expand All @@ -193,6 +198,7 @@ export class Editor extends EventEmitter<TLEventMap> {
tools,
getContainer,
initialState,
autoFocus,
inferDarkMode,
}: TLEditorOptions) {
super()
Expand Down Expand Up @@ -656,6 +662,9 @@ export class Editor extends EventEmitter<TLEventMap> {

this.root.enter(undefined, 'initial')

this.focusManager = new FocusManager(this, autoFocus)
this.disposables.add(this.focusManager.dispose)

if (this.getInstanceState().followingUserId) {
this.stopFollowingUser()
}
Expand Down Expand Up @@ -747,6 +756,13 @@ export class Editor extends EventEmitter<TLEventMap> {
*/
readonly sideEffects: SideEffectManager<this>

/**
* A manager for ensuring correct focus. See {@link FocusManager} for details.
*
* @public
*/
readonly focusManager: FocusManager

/**
* Dispose the editor.
*
Expand Down Expand Up @@ -7947,6 +7963,21 @@ export class Editor extends EventEmitter<TLEventMap> {
return this
}

/**
* Dispatch a focus event.
*
* @example
* ```ts
* editor.focus()
* ```
*
* @public
*/
focus(): this {
this.focusManager.focus()
return this
}
Comment on lines +8040 to +8053
Copy link
Collaborator

@ds300 ds300 May 13, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder whether this should set the instanceState.isFocused to true if it's not already?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or really maybe it should be the job of the focus manager

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think possibly: that it's tricky. if there are multiple editors on the page, i could see an argument that you might want to temporarily translate/move a shape on one editor (Editor A), but the focus remains on a separate editor (Editor B). and that just because you focus Editor A, it doesn't mean that you want Editor A to take over control from Editor B. but perhaps that's overthinking it?


/**
* A manager for recording multiple click events.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export class EnvironmentManager {
constructor(public editor: Editor) {
if (typeof window !== 'undefined' && 'navigator' in window) {
this.isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent)
this.isIos = !!navigator.userAgent.match(/iPad/i) || !!navigator.userAgent.match(/iPhone/i)
this.isIos = true //!!navigator.userAgent.match(/iPad/i) || !!navigator.userAgent.match(/iPhone/i)
this.isChromeForIos = /crios.*safari/i.test(navigator.userAgent)
this.isFirefox = /firefox/i.test(navigator.userAgent)
this.isAndroid = /android/i.test(navigator.userAgent)
Expand Down
50 changes: 50 additions & 0 deletions packages/editor/src/lib/editor/managers/FocusManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { Editor } from '../Editor'

/**
* A manager for ensuring correct focus across the editor.
* It will listen for changes in the instance state to make sure the
* container is focused when the editor is focused.
* Also, it will make sure that the focus is on things like text
* labels when the editor is in editing mode.
*
* @public
*/
export class FocusManager {
private disposeStoreListener?: () => void

constructor(
public editor: Editor,
autoFocus?: boolean
) {
this.disposeStoreListener = editor.store.listen(
mimecuvalo marked this conversation as resolved.
Show resolved Hide resolved
({ changes }) => {
for (const [prev, next] of Object.values(changes.updated)) {
if (prev.typeName !== 'instance' || next.typeName !== 'instance') continue

if (prev.isFocused !== next.isFocused) {
next.isFocused ? this.focus() : this.blur()
}
}
},
{ scope: 'session' }
)

const currentFocusState = editor.getInstanceState().isFocused
if (autoFocus !== currentFocusState) {
editor.updateInstanceState({ isFocused: autoFocus })
}
}

focus() {
this.editor.getContainer().focus()
}

blur() {
this.editor.complete() // stop any interaction
this.editor.getContainer().blur() // blur the container
}

dispose() {
this.disposeStoreListener?.()
}
}
Loading
Loading