More cleanup, focus bug fixes (#1749)

This PR is another grab bag:
- renames `readOnly` to `readonly` throughout editor
- fixes a regression related to focus and keyboard shortcuts
- adds a small outline for focused editors

### Change Type

- [x] `major`

### Test Plan

- [x] End to end tests
This commit is contained in:
Steve Ruiz 2023-07-19 11:52:21 +01:00 committed by GitHub
parent 6309cbe6a5
commit b22ea7cd4e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
39 changed files with 350 additions and 176 deletions

View file

@ -0,0 +1,119 @@
import test, { expect } from '@playwright/test'
declare const __tldraw_editor_events: any[]
// We're just testing the events, not the actual results.
test.describe('Focus', () => {
test('focus events', async ({ page }) => {
await page.goto('http://localhost:5420/multiple')
await page.waitForSelector('.tl-canvas')
// Component A has autofocus
// Component B does not
const EditorA = (await page.$(`.A`))!
const EditorB = (await page.$(`.B`))!
expect(EditorA).toBeTruthy()
expect(EditorB).toBeTruthy()
await (await page.$('body'))?.click()
expect(await EditorA.evaluate((node) => document.activeElement === node)).toBe(true)
expect(await EditorB.evaluate((node) => document.activeElement === node)).toBe(false)
await (await page.$('body'))?.click()
expect(await EditorA.evaluate((node) => document.activeElement === node)).toBe(false)
expect(await EditorB.evaluate((node) => document.activeElement === node)).toBe(false)
await EditorA.click()
expect(await EditorA.evaluate((node) => document.activeElement === node)).toBe(true)
expect(await EditorB.evaluate((node) => document.activeElement === node)).toBe(false)
await EditorA.click()
expect(await EditorA.evaluate((node) => document.activeElement === node)).toBe(false)
expect(await EditorB.evaluate((node) => document.activeElement === node)).toBe(false)
expect(await EditorA.evaluate((node) => node.contains(document.activeElement))).toBe(true)
await EditorB.click()
expect(await EditorA.evaluate((node) => document.activeElement === node)).toBe(false)
expect(await EditorB.evaluate((node) => document.activeElement === node)).toBe(false)
expect(await EditorA.evaluate((node) => node.contains(document.activeElement))).toBe(true)
// Escape does not break focus
await page.keyboard.press('Escape')
expect(await EditorA.evaluate((node) => node.contains(document.activeElement))).toBe(true)
})
test('kbds when focused', async ({ page }) => {
await page.goto('http://localhost:5420/multiple')
await page.waitForSelector('.tl-canvas')
const EditorA = (await page.$(`.A`))!
const EditorB = (await page.$(`.B`))!
expect(EditorA).toBeTruthy()
expect(EditorB).toBeTruthy()
await (await page.$('body'))?.click()
expect(await EditorA.evaluate((node) => document.activeElement === node)).toBe(true)
expect(await EditorB.evaluate((node) => document.activeElement === node)).toBe(false)
expect(await EditorA.$('.tlui-button[data-testid="tools.draw"][data-state="selected"]')).toBe(
null
)
expect(await EditorB.$('.tlui-button[data-testid="tools.draw"][data-state="selected"]')).toBe(
null
)
await page.keyboard.press('d')
expect(
await EditorA.$('.tlui-button[data-testid="tools.draw"][data-state="selected"]')
).not.toBe(null)
expect(await EditorB.$('.tlui-button[data-testid="tools.draw"][data-state="selected"]')).toBe(
null
)
await EditorB.click()
await page.waitForTimeout(100) // takes 30ms or so to focus
await page.keyboard.press('d')
expect(
await EditorA.$('.tlui-button[data-testid="tools.draw"][data-state="selected"]')
).not.toBe(null)
expect(
await EditorB.$('.tlui-button[data-testid="tools.draw"][data-state="selected"]')
).not.toBe(null)
})
test('kbds after clicking on ui elements', async ({ page }) => {
await page.goto('http://localhost:5420/end-to-end')
await page.waitForSelector('.tl-canvas')
const EditorA = (await page.$(`.tl-container`))!
expect(EditorA).toBeTruthy()
const drawButton = await EditorA.$('.tlui-button[data-testid="tools.draw"]')
// select button should be selected, not the draw button
expect(await EditorA.$('.tlui-button[data-testid="tools.draw"][data-state="selected"]')).toBe(
null
)
await drawButton?.click()
// draw button should be selected now
expect(
await EditorA.$('.tlui-button[data-testid="tools.draw"][data-state="selected"]')
).not.toBe(null)
await page.keyboard.press('v')
// select button should be selected again
expect(await EditorA.$('.tlui-button[data-testid="tools.draw"][data-state="selected"]')).toBe(
null
)
})
})

View file

@ -12,7 +12,7 @@ export default function MultipleExample() {
<h2>First Example</h2>
<p>This is the second example.</p>
<div style={{ width: '100%', height: '600px', padding: 32 }} tabIndex={-1}>
<Tldraw persistenceKey="steve" autoFocus />
<Tldraw persistenceKey="steve" className="A" autoFocus />
</div>
<textarea defaultValue="type in me" style={{ margin: 10 }}></textarea>
@ -20,7 +20,7 @@ export default function MultipleExample() {
<h2>Second Example</h2>
<p>This is the second example.</p>
<div style={{ width: '100%', height: '600px' }} tabIndex={-1}>
<Tldraw persistenceKey="david" autoFocus={false} />
<Tldraw persistenceKey="david" className="B" />
</div>
<div

View file

@ -86,7 +86,7 @@
"page-menu.submenu.duplicate-page": "Duplicar",
"page-menu.submenu.delete": "Deletar",
"share-menu.copy-link": "Copiar Link de Convite",
"share-menu.copy-readonly-link": "Copiar Link ReadOnly",
"share-menu.copy-readonly-link": "Copiar Link Readonly",
"help-menu.keyboard-shortcuts": "Atalhos de Teclado",
"edit-link-dialog.cancel": "Cancelar",
"embed-dialog.cancel": "Cancelar",

View file

@ -1643,6 +1643,7 @@ export const TldrawEditor: React_2.NamedExoticComponent<TldrawEditorProps>;
export interface TldrawEditorBaseProps {
autoFocus?: boolean;
children?: any;
className?: string;
components?: Partial<TLEditorComponents>;
initialState?: string;
onMount?: TLOnMountHandler;

View file

@ -191,6 +191,10 @@
-webkit-touch-callout: initial;
}
.tl-container:focus-within {
outline: 1px solid var(--color-low);
}
input,
*[contenteditable],
*[contenteditable] * {

View file

@ -10,6 +10,7 @@ import React, {
useSyncExternalStore,
} from 'react'
import classNames from 'classnames'
import { Canvas } from './components/Canvas'
import { OptionalErrorBoundary } from './components/ErrorBoundary'
import { DefaultErrorFallback } from './components/default-components/DefaultErrorFallback'
@ -92,6 +93,11 @@ export interface TldrawEditorBaseProps {
* The editor's initial state (usually the id of the first active tool).
*/
initialState?: string
/**
* A classname to pass to the editor's container.
*/
className?: string
}
/**
@ -119,9 +125,10 @@ const EMPTY_TOOLS_ARRAY = [] as const
export const TldrawEditor = memo(function TldrawEditor({
store,
components,
className,
...rest
}: TldrawEditorProps) {
const [container, setContainer] = React.useState<HTMLDivElement | null>(null)
const [container, rContainer] = React.useState<HTMLDivElement | null>(null)
const user = useMemo(() => createTLUser(), [])
const ErrorFallback =
@ -137,7 +144,12 @@ export const TldrawEditor = memo(function TldrawEditor({
}
return (
<div ref={setContainer} draggable={false} className="tl-container tl-theme__light" tabIndex={0}>
<div
ref={rContainer}
draggable={false}
className={classNames('tl-container tl-theme__light', className)}
tabIndex={0}
>
<OptionalErrorBoundary
fallback={ErrorFallback}
onError={(error) => annotateError(error, { tags: { origin: 'react.tldraw-before-app' } })}

View file

@ -188,9 +188,9 @@ function HandlesWrapper() {
const isChangingStyle = useValue('isChangingStyle', () => editor.instanceState.isChangingStyle, [
editor,
])
const isReadOnly = useValue('isChangingStyle', () => editor.instanceState.isReadOnly, [editor])
const isReadonly = useValue('isChangingStyle', () => editor.instanceState.isReadonly, [editor])
if (!Handles || !onlySelectedShape || isChangingStyle || isReadOnly) return null
if (!Handles || !onlySelectedShape || isChangingStyle || isReadonly) return null
const handles = editor.getHandles(onlySelectedShape)

View file

@ -43,6 +43,7 @@ import {
annotateError,
assert,
compact,
debounce,
dedupe,
deepCopy,
getOwnProperty,
@ -306,19 +307,32 @@ export class Editor extends EventEmitter<TLEventMap> {
const container = this.getContainer()
const handleFocus = () => this.updateInstanceState({ isFocused: true })
const handleBlur = () => this.updateInstanceState({ isFocused: false })
// We need to debounce this because when focus changes, the body
// becomes focused for a brief moment. Debouncing means that we
// check only when focus stops changing: when it settles, what
// has it settled on? If it's settled on the container or something
// inside of the container, then focus or preserve the current focus;
// if not, then turn off focus. Turning off focus is a trigger to
// also turn off keyboard shortcuts and other things.
const updateFocus = debounce(() => {
const { activeElement } = document
const { isFocused } = this.instanceState
const hasFocus = container === activeElement || container.contains(activeElement)
if ((!isFocused && hasFocus) || (isFocused && !hasFocus)) {
this.updateInstanceState({ isFocused: hasFocus })
}
}, 32)
container.addEventListener('focusin', handleFocus)
container.addEventListener('focus', handleFocus)
container.addEventListener('focusout', handleBlur)
container.addEventListener('blur', handleBlur)
container.addEventListener('focusin', updateFocus)
container.addEventListener('focus', updateFocus)
container.addEventListener('focusout', updateFocus)
container.addEventListener('blur', updateFocus)
this.disposables.add(() => {
container.removeEventListener('focusin', handleFocus)
container.removeEventListener('focus', handleFocus)
container.removeEventListener('focusout', handleBlur)
container.removeEventListener('blur', handleBlur)
container.removeEventListener('focusin', updateFocus)
container.removeEventListener('focus', updateFocus)
container.removeEventListener('focusout', updateFocus)
container.removeEventListener('blur', updateFocus)
})
this.store.ensureStoreIsUsable()
@ -1046,26 +1060,6 @@ export class Editor extends EventEmitter<TLEventMap> {
return paths.some((path) => this.isIn(path))
}
/**
* The id of the current selected tool.
*
* @public
*/
@computed get currentToolId(): string {
const { currentTool } = this
if (!currentTool) return ''
return currentTool.currentToolIdMask ?? currentTool.id
}
/**
* The current selected tool.
*
* @public
*/
@computed get currentTool(): StateNode | undefined {
return this.root.current.value
}
/**
* Set the selected tool.
*
@ -1084,6 +1078,25 @@ export class Editor extends EventEmitter<TLEventMap> {
this.root.transition(id, info)
return this
}
/**
* The current selected tool.
*
* @public
*/
@computed get currentTool(): StateNode | undefined {
return this.root.current.value
}
/**
* The id of the current selected tool.
*
* @public
*/
@computed get currentToolId(): string {
const { currentTool } = this
if (!currentTool) return ''
return currentTool.currentToolIdMask ?? currentTool.id
}
/**
* Get a descendant by its path.
@ -3276,7 +3289,7 @@ export class Editor extends EventEmitter<TLEventMap> {
private _updatePage = this.history.createCommand(
'updatePage',
(partial: RequiredKeys<TLPage, 'id'>, squashing = false) => {
if (this.instanceState.isReadOnly) return null
if (this.instanceState.isReadonly) return null
const prev = this.getPageById(partial.id)
@ -3326,7 +3339,7 @@ export class Editor extends EventEmitter<TLEventMap> {
private _createPage = this.history.createCommand(
'createPage',
(title: string, id: TLPageId = PageRecordType.createId(), belowPageIndex?: string) => {
if (this.instanceState.isReadOnly) return null
if (this.instanceState.isReadonly) return null
if (this.pages.length >= MAX_PAGES) return null
const pageInfo = this.pages
const topIndex = belowPageIndex ?? pageInfo[pageInfo.length - 1]?.index ?? 'a1'
@ -3408,7 +3421,7 @@ export class Editor extends EventEmitter<TLEventMap> {
private _deletePage = this.history.createCommand(
'delete_page',
(id: TLPageId) => {
if (this.instanceState.isReadOnly) return null
if (this.instanceState.isReadonly) return null
const { pages } = this
if (pages.length === 1) return null
@ -3492,7 +3505,7 @@ export class Editor extends EventEmitter<TLEventMap> {
* @public
*/
renamePage(id: TLPageId, name: string, squashing = false) {
if (this.instanceState.isReadOnly) return this
if (this.instanceState.isReadonly) return this
this.updatePage({ id, name }, squashing)
return this
}
@ -3533,7 +3546,7 @@ export class Editor extends EventEmitter<TLEventMap> {
private _createAssets = this.history.createCommand(
'createAssets',
(assets: TLAsset[]) => {
if (this.instanceState.isReadOnly) return null
if (this.instanceState.isReadonly) return null
if (assets.length <= 0) return null
return { data: { assets } }
@ -3569,7 +3582,7 @@ export class Editor extends EventEmitter<TLEventMap> {
private _updateAssets = this.history.createCommand(
'updateAssets',
(assets: TLAssetPartial[]) => {
if (this.instanceState.isReadOnly) return
if (this.instanceState.isReadonly) return
if (assets.length <= 0) return
const snapshots: Record<string, TLAsset> = {}
@ -3616,7 +3629,7 @@ export class Editor extends EventEmitter<TLEventMap> {
private _deleteAssets = this.history.createCommand(
'deleteAssets',
(ids: TLAssetId[]) => {
if (this.instanceState.isReadOnly) return
if (this.instanceState.isReadonly) return
if (ids.length <= 0) return
const prev = compact(ids.map((id) => this.store.get(id)))
@ -5236,7 +5249,7 @@ export class Editor extends EventEmitter<TLEventMap> {
*/
moveShapesToPage(ids: TLShapeId[], pageId: TLPageId): this {
if (ids.length === 0) return this
if (this.instanceState.isReadOnly) return this
if (this.instanceState.isReadonly) return this
const { currentPageId } = this
@ -5293,7 +5306,7 @@ export class Editor extends EventEmitter<TLEventMap> {
* @public
*/
toggleLock(ids: TLShapeId[] = this.selectedIds): this {
if (this.instanceState.isReadOnly || ids.length === 0) return this
if (this.instanceState.isReadonly || ids.length === 0) return this
let allLocked = true,
allUnlocked = true
@ -5414,7 +5427,7 @@ export class Editor extends EventEmitter<TLEventMap> {
* @public
*/
flipShapes(operation: 'horizontal' | 'vertical', ids: TLShapeId[] = this.selectedIds) {
if (this.instanceState.isReadOnly) return this
if (this.instanceState.isReadonly) return this
let shapes = compact(ids.map((id) => this.getShapeById(id)))
@ -5478,7 +5491,7 @@ export class Editor extends EventEmitter<TLEventMap> {
ids: TLShapeId[] = this.currentPageState.selectedIds,
gap?: number
) {
if (this.instanceState.isReadOnly) return this
if (this.instanceState.isReadonly) return this
const shapes = compact(ids.map((id) => this.getShapeById(id))).filter((shape) => {
if (!shape) return false
@ -5605,7 +5618,7 @@ export class Editor extends EventEmitter<TLEventMap> {
* @param padding - The padding to apply to the packed shapes.
*/
packShapes(ids: TLShapeId[] = this.currentPageState.selectedIds, padding = 16) {
if (this.instanceState.isReadOnly) return this
if (this.instanceState.isReadonly) return this
if (ids.length < 2) return this
const shapes = compact(
@ -5764,7 +5777,7 @@ export class Editor extends EventEmitter<TLEventMap> {
operation: 'left' | 'center-horizontal' | 'right' | 'top' | 'center-vertical' | 'bottom',
ids: TLShapeId[] = this.currentPageState.selectedIds
) {
if (this.instanceState.isReadOnly) return this
if (this.instanceState.isReadonly) return this
if (ids.length < 2) return this
const shapes = compact(ids.map((id) => this.getShapeById(id)))
@ -5851,7 +5864,7 @@ export class Editor extends EventEmitter<TLEventMap> {
operation: 'horizontal' | 'vertical',
ids: TLShapeId[] = this.currentPageState.selectedIds
) {
if (this.instanceState.isReadOnly) return this
if (this.instanceState.isReadonly) return this
if (ids.length < 3) return this
const len = ids.length
@ -5936,7 +5949,7 @@ export class Editor extends EventEmitter<TLEventMap> {
operation: 'horizontal' | 'vertical',
ids: TLShapeId[] = this.currentPageState.selectedIds
) {
if (this.instanceState.isReadOnly) return this
if (this.instanceState.isReadonly) return this
if (ids.length < 2) return this
const shapes = compact(ids.map((id) => this.getShapeById(id)))
@ -6024,7 +6037,7 @@ export class Editor extends EventEmitter<TLEventMap> {
mode?: TLResizeMode
} = {}
) {
if (this.instanceState.isReadOnly) return this
if (this.instanceState.isReadonly) return this
if (!Number.isFinite(scale.x)) scale = new Vec2d(1, scale.y)
if (!Number.isFinite(scale.y)) scale = new Vec2d(scale.x, 1)
@ -6296,7 +6309,7 @@ export class Editor extends EventEmitter<TLEventMap> {
private _createShapes = this.history.createCommand(
'createShapes',
(partials: TLShapePartial[], select = false) => {
if (this.instanceState.isReadOnly) return null
if (this.instanceState.isReadonly) return null
if (partials.length <= 0) return null
const { currentPageShapeIds: shapeIds, selectedIds } = this
@ -6584,7 +6597,7 @@ export class Editor extends EventEmitter<TLEventMap> {
* @public
*/
groupShapes(ids: TLShapeId[] = this.selectedIds, groupId = createShapeId()) {
if (this.instanceState.isReadOnly) return this
if (this.instanceState.isReadonly) return this
if (ids.length <= 1) return this
@ -6639,7 +6652,7 @@ export class Editor extends EventEmitter<TLEventMap> {
* @public
*/
ungroupShapes(ids: TLShapeId[] = this.selectedIds) {
if (this.instanceState.isReadOnly) return this
if (this.instanceState.isReadonly) return this
if (ids.length === 0) return this
// Only ungroup when the select tool is active
@ -6730,7 +6743,7 @@ export class Editor extends EventEmitter<TLEventMap> {
private _updateShapes = this.history.createCommand(
'updateShapes',
(_partials: (TLShapePartial | null | undefined)[], squashing = false) => {
if (this.instanceState.isReadOnly) return null
if (this.instanceState.isReadonly) return null
const partials = compact(_partials)
@ -6853,7 +6866,7 @@ export class Editor extends EventEmitter<TLEventMap> {
private _deleteShapes = this.history.createCommand(
'delete_shapes',
(ids: TLShapeId[]) => {
if (this.instanceState.isReadOnly) return null
if (this.instanceState.isReadonly) return null
if (ids.length === 0) return null
const prevSelectedIds = [...this.currentPageState.selectedIds]
@ -7444,7 +7457,7 @@ export class Editor extends EventEmitter<TLEventMap> {
preserveIds?: boolean
} = {}
): this {
if (this.instanceState.isReadOnly) return this
if (this.instanceState.isReadonly) return this
if (!content.schema) {
throw Error('Could not put content:\ncontent is missing a schema.')

View file

@ -233,12 +233,14 @@ export class SnapManager {
constructor(public readonly editor: Editor) {}
@computed get snapPointsCache() {
return this.editor.store.createComputedCache<SnapPoint[], TLShape>('snapPoints', (shape) => {
const pageTransfrorm = this.editor.getPageTransformById(shape.id)
const { editor } = this
return editor.store.createComputedCache<SnapPoint[], TLShape>('snapPoints', (shape) => {
const pageTransfrorm = editor.getPageTransformById(shape.id)
if (!pageTransfrorm) return undefined
const util = this.editor.getShapeUtil(shape)
const snapPoints = util.snapPoints(shape)
return snapPoints.map((point, i) => {
return editor
.getShapeUtil(shape)
.snapPoints(shape)
.map((point, i) => {
const { x, y } = Matrix2d.applyToPoint(pageTransfrorm, point)
return { x, y, id: `${shape.id}:${i}` }
})

View file

@ -1,21 +0,0 @@
import { T } from '@tldraw/validate'
export type TLinstanceState = {
canMoveCamera: boolean
isFocused: boolean
devicePixelRatio: number
isCoarsePointer: boolean
openMenus: string[]
isChangingStyle: boolean
isReadOnly: boolean
}
export const instanceStateValidator = T.object<TLinstanceState>({
canMoveCamera: T.boolean,
isFocused: T.boolean,
devicePixelRatio: T.number,
isCoarsePointer: T.boolean,
openMenus: T.arrayOf(T.string),
isChangingStyle: T.boolean,
isReadOnly: T.boolean,
})

View file

@ -80,7 +80,9 @@ export function useCanvasEvents() {
function onTouchStart(e: React.TouchEvent) {
;(e as any).isKilled = true
document.body.click() // god damn it, but necessary for long presses to open the context menu
// todo: investigate whether this effects keyboard shortcuts
// god damn it, but necessary for long presses to open the context menu
document.body.click()
preventDefault(e)
}

View file

@ -32,6 +32,7 @@ export function useDocumentEvents() {
const handleKeyDown = (e: KeyboardEvent) => {
if (
e.altKey &&
// todo: When should we allow the alt key to be used? Perhaps states should declare which keys matter to them?
(editor.isIn('zoom') || !editor.root.path.value.endsWith('.idle')) &&
!isFocusingInput()
) {
@ -45,21 +46,14 @@ export function useDocumentEvents() {
;(e as any).isKilled = true
switch (e.key) {
case '=': {
if (e.metaKey || e.ctrlKey) {
preventDefault(e)
return
}
break
}
case '-': {
if (e.metaKey || e.ctrlKey) {
preventDefault(e)
return
}
break
}
case '=':
case '-':
case '0': {
// These keys are used for zooming. Technically we only use
// the + - and 0 keys, however it's common for them to be
// paired with modifier keys (command / control) so we need
// to prevent the browser's regular actions (i.e. zooming
// the page). A user can zoom by unfocusing the editor.
if (e.metaKey || e.ctrlKey) {
preventDefault(e)
return
@ -73,6 +67,9 @@ export function useDocumentEvents() {
break
}
case ',': {
// todo: extract to extension
// This seems very fragile; the comma key here is used to send pointer events,
// but that means it also needs to know about pen mode, hovered ids, etc.
if (!isFocusingInput()) {
preventDefault(e)
if (!editor.inputs.keys.has('Comma')) {

View file

@ -796,7 +796,7 @@ export function useMenuSchema(): TLUiMenuSchema;
export function useNativeClipboardEvents(): void;
// @public (undocumented)
export function useReadOnly(): boolean;
export function useReadonly(): boolean;
// @public (undocumented)
export function useToasts(): TLUiToastsContextType;

View file

@ -83,7 +83,7 @@ export {
type TLUiMenuSchemaContextType,
type TLUiMenuSchemaProviderProps,
} from './lib/ui/hooks/useMenuSchema'
export { useReadOnly } from './lib/ui/hooks/useReadOnly'
export { useReadonly } from './lib/ui/hooks/useReadonly'
export {
useToasts,
type TLUiToast,

View file

@ -12,7 +12,7 @@ import {
} from '@tldraw/editor'
import classNames from 'classnames'
import { useRef } from 'react'
import { useReadOnly } from '../ui/hooks/useReadOnly'
import { useReadonly } from '../ui/hooks/useReadonly'
import { CropHandles } from './CropHandles'
const IS_FIREFOX =
@ -25,7 +25,7 @@ export const TldrawSelectionForeground: TLSelectionForegroundComponent = track(
const editor = useEditor()
const rSvg = useRef<SVGSVGElement>(null)
const isReadonlyMode = useReadOnly()
const isReadonlyMode = useReadonly()
const topEvents = useSelectionEvents('top')
const rightEvents = useSelectionEvents('right')
const bottomEvents = useSelectionEvents('bottom')

View file

@ -557,7 +557,7 @@ export class ArrowShapeUtil extends ShapeUtil<TLArrowShape> {
'select.pointing_handle',
'select.dragging_handle',
'arrow.dragging'
) && !this.editor.instanceState.isReadOnly
) && !this.editor.instanceState.isReadonly
const info = this.editor.getArrowInfo(shape)
const bounds = this.editor.getBounds(shape)

View file

@ -87,7 +87,7 @@ export class Idle extends StateNode {
break
}
case 'handle': {
if (this.editor.instanceState.isReadOnly) break
if (this.editor.instanceState.isReadonly) break
if (this.editor.inputs.altKey) {
this.parent.transition('pointing_shape', info)
} else {
@ -142,12 +142,12 @@ export class Idle extends StateNode {
switch (info.target) {
case 'canvas': {
// Create text shape and transition to editing_shape
if (this.editor.instanceState.isReadOnly) break
if (this.editor.instanceState.isReadonly) break
this.handleDoubleClickOnCanvas(info)
break
}
case 'selection': {
if (this.editor.instanceState.isReadOnly) break
if (this.editor.instanceState.isReadonly) break
const { onlySelectedShape } = this.editor
if (onlySelectedShape) {
@ -191,7 +191,7 @@ export class Idle extends StateNode {
if (
shape.type !== 'video' &&
shape.type !== 'embed' &&
this.editor.instanceState.isReadOnly
this.editor.instanceState.isReadonly
)
break
@ -221,7 +221,7 @@ export class Idle extends StateNode {
break
}
case 'handle': {
if (this.editor.instanceState.isReadOnly) break
if (this.editor.instanceState.isReadonly) break
const { shape, handle } = info
const util = this.editor.getShapeUtil(shape)
@ -307,7 +307,7 @@ export class Idle extends StateNode {
}
override onKeyUp = (info: TLKeyboardEventInfo) => {
if (this.editor.instanceState.isReadOnly) {
if (this.editor.instanceState.isReadonly) {
switch (info.code) {
case 'Enter': {
if (this.shouldStartEditingShape() && this.editor.onlySelectedShape) {

View file

@ -18,7 +18,7 @@ export class PointingSelection extends StateNode {
override onPointerMove: TLEventHandlers['onPointerMove'] = (info) => {
if (this.editor.inputs.isDragging) {
if (this.editor.instanceState.isReadOnly) return
if (this.editor.instanceState.isReadonly) return
this.parent.transition('translating', info)
}
}

View file

@ -121,7 +121,7 @@ export class PointingShape extends StateNode {
override onPointerMove: TLEventHandlers['onPointerMove'] = (info) => {
if (this.editor.inputs.isDragging) {
if (this.editor.instanceState.isReadOnly) return
if (this.editor.instanceState.isReadonly) return
this.parent.transition('translating', info)
}
}

View file

@ -1,5 +1,5 @@
import { ToastProvider } from '@radix-ui/react-toast'
import { useEditor, useValue } from '@tldraw/editor'
import { preventDefault, useEditor, useValue } from '@tldraw/editor'
import classNames from 'classnames'
import React, { ReactNode } from 'react'
import { TldrawUiContextProvider, TldrawUiContextProviderProps } from './TldrawUiContextProvider'
@ -119,7 +119,7 @@ const TldrawUiContent = React.memo(function TldrawUI({
const editor = useEditor()
const msg = useTranslation()
const breakpoint = useBreakpoint()
const isReadonlyMode = useValue('isReadOnlyMode', () => editor.instanceState.isReadOnly, [editor])
const isReadonlyMode = useValue('isReadonlyMode', () => editor.instanceState.isReadonly, [editor])
const isFocusMode = useValue('focus', () => editor.instanceState.isFocusMode, [editor])
const isDebugMode = useValue('debug', () => editor.instanceState.isDebugMode, [editor])
@ -135,6 +135,7 @@ const TldrawUiContent = React.memo(function TldrawUI({
className={classNames('tlui-layout', {
'tlui-layout__mobile': breakpoint < 5,
})}
onPointerDown={preventDefault}
>
{isFocusMode ? (
<div className="tlui-layout__top">

View file

@ -3,7 +3,7 @@ import { useContainer } from '@tldraw/editor'
import { memo } from 'react'
import { TLUiMenuChild } from '../hooks/menuHelpers'
import { useActionsMenuSchema } from '../hooks/useActionsMenuSchema'
import { useReadOnly } from '../hooks/useReadOnly'
import { useReadonly } from '../hooks/useReadonly'
import { useTranslation } from '../hooks/useTranslation/useTranslation'
import { Button } from './primitives/Button'
import { Popover, PopoverTrigger } from './primitives/Popover'
@ -13,7 +13,7 @@ export const ActionsMenu = memo(function ActionsMenu() {
const msg = useTranslation()
const container = useContainer()
const menuSchema = useActionsMenuSchema()
const isReadonly = useReadOnly()
const isReadonly = useReadonly()
function getActionMenuItem(item: TLUiMenuChild) {
if (isReadonly && !item.readonlyOk) return null

View file

@ -6,7 +6,7 @@ import { TLUiMenuChild } from '../hooks/menuHelpers'
import { useBreakpoint } from '../hooks/useBreakpoint'
import { useContextMenuSchema } from '../hooks/useContextMenuSchema'
import { useMenuIsOpen } from '../hooks/useMenuIsOpen'
import { useReadOnly } from '../hooks/useReadOnly'
import { useReadonly } from '../hooks/useReadonly'
import { useTranslation } from '../hooks/useTranslation/useTranslation'
import { MoveToPageMenu } from './MoveToPageMenu'
import { Button } from './primitives/Button'
@ -66,7 +66,7 @@ export const ContextMenu = function ContextMenu({ children }: { children: any })
const [_, handleOpenChange] = useMenuIsOpen('context menu', cb)
// If every item in the menu is readonly, then we don't want to show the menu
const isReadonly = useReadOnly()
const isReadonly = useReadonly()
const noItemsToShow =
contextTLUiMenuSchema.length === 0 ||
@ -98,7 +98,7 @@ function ContextMenuContent() {
const menuSchema = useContextMenuSchema()
const [_, handleSubOpenChange] = useMenuIsOpen('context menu sub')
const isReadonly = useReadOnly()
const isReadonly = useReadonly()
const breakpoint = useBreakpoint()
const container = useContainer()

View file

@ -4,7 +4,7 @@ import * as React from 'react'
import { TLUiMenuChild } from '../hooks/menuHelpers'
import { useHelpMenuSchema } from '../hooks/useHelpMenuSchema'
import { useMenuIsOpen } from '../hooks/useMenuIsOpen'
import { useReadOnly } from '../hooks/useReadOnly'
import { useReadonly } from '../hooks/useReadonly'
import { TLUiTranslationKey } from '../hooks/useTranslation/TLUiTranslationKey'
import { useTranslation } from '../hooks/useTranslation/useTranslation'
import { TLUiIconType } from '../icon-types'
@ -59,7 +59,7 @@ export const HelpMenu = React.memo(function HelpMenu() {
function HelpMenuContent() {
const menuSchema = useHelpMenuSchema()
const isReadonly = useReadOnly()
const isReadonly = useReadonly()
function getHelpMenuItem(item: TLUiMenuChild) {
if (isReadonly && !item.readonlyOk) return null

View file

@ -1,6 +1,6 @@
import { TLUiMenuChild } from '../hooks/menuHelpers'
import { useKeyboardShortcutsSchema } from '../hooks/useKeyboardShortcutsSchema'
import { useReadOnly } from '../hooks/useReadOnly'
import { useReadonly } from '../hooks/useReadonly'
import { TLUiTranslationKey } from '../hooks/useTranslation/TLUiTranslationKey'
import { useTranslation } from '../hooks/useTranslation/useTranslation'
import * as Dialog from './primitives/Dialog'
@ -8,7 +8,7 @@ import { Kbd } from './primitives/Kbd'
export const KeyboardShortcutsDialog = () => {
const msg = useTranslation()
const isReadonly = useReadOnly()
const isReadonly = useReadonly()
const shortcutsItems = useKeyboardShortcutsSchema()
function getKeyboardShortcutItem(item: TLUiMenuChild) {

View file

@ -3,7 +3,7 @@ import * as React from 'react'
import { TLUiMenuChild } from '../hooks/menuHelpers'
import { useBreakpoint } from '../hooks/useBreakpoint'
import { useMenuSchema } from '../hooks/useMenuSchema'
import { useReadOnly } from '../hooks/useReadOnly'
import { useReadonly } from '../hooks/useReadonly'
import { useTranslation } from '../hooks/useTranslation/useTranslation'
import { LanguageMenu } from './LanguageMenu'
import { Button } from './primitives/Button'
@ -35,7 +35,7 @@ function MenuContent() {
const msg = useTranslation()
const menuSchema = useMenuSchema()
const breakpoint = useBreakpoint()
const isReadonly = useReadOnly()
const isReadonly = useReadonly()
function getMenuItem(
editor: Editor,

View file

@ -1,6 +1,6 @@
import { track, useEditor } from '@tldraw/editor'
import { useBreakpoint } from '../hooks/useBreakpoint'
import { useReadOnly } from '../hooks/useReadOnly'
import { useReadonly } from '../hooks/useReadonly'
import { ActionsMenu } from './ActionsMenu'
import { DuplicateButton } from './DuplicateButton'
import { Menu } from './Menu'
@ -13,7 +13,7 @@ export const MenuZone = track(function MenuZone() {
const editor = useEditor()
const breakpoint = useBreakpoint()
const isReadonly = useReadOnly()
const isReadonly = useReadonly()
const showQuickActions = !isReadonly && !editor.isInAny('hand', 'zoom', 'eraser')

View file

@ -10,7 +10,7 @@ import {
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useBreakpoint } from '../../hooks/useBreakpoint'
import { useMenuIsOpen } from '../../hooks/useMenuIsOpen'
import { useReadOnly } from '../../hooks/useReadOnly'
import { useReadonly } from '../../hooks/useReadonly'
import { useTranslation } from '../../hooks/useTranslation/useTranslation'
import { Button } from '../primitives/Button'
import { Icon } from '../primitives/Icon'
@ -36,7 +36,7 @@ export const PageMenu = function PageMenu() {
const currentPage = useValue('currentPage', () => editor.currentPage, [editor])
// When in readonly mode, we don't allow a user to edit the pages
const isReadonlyMode = useReadOnly()
const isReadonlyMode = useReadonly()
// If the user has reached the max page count, we disable the "add page" button
const maxPageCountReached = useValue(

View file

@ -142,7 +142,11 @@ function CommonStylePickerSet({
return (
<>
<div className="tlui-style-panel__section__common" aria-label="style panel styles">
<div
tabIndex={-1}
className="tlui-style-panel__section__common"
aria-label="style panel styles"
>
{color === undefined ? null : (
<ButtonPicker
title={msg('style-panel.color')}

View file

@ -2,7 +2,7 @@ import { GeoShapeGeoStyle, preventDefault, track, useEditor, useValue } from '@t
import classNames from 'classnames'
import React, { memo } from 'react'
import { useBreakpoint } from '../../hooks/useBreakpoint'
import { useReadOnly } from '../../hooks/useReadOnly'
import { useReadonly } from '../../hooks/useReadonly'
import { TLUiToolbarItem, useToolbarSchema } from '../../hooks/useToolbarSchema'
import { TLUiToolItem } from '../../hooks/useTools'
import { useTranslation } from '../../hooks/useTranslation/useTranslation'
@ -25,7 +25,7 @@ export const Toolbar = memo(function Toolbar() {
const rMostRecentlyActiveDropdownItem = React.useRef<TLUiToolbarItem | undefined>(undefined)
const isReadOnly = useReadOnly()
const isReadonly = useReadonly()
const toolbarItems = useToolbarSchema()
const laserTool = toolbarItems.find((item) => item.toolItem.id === 'laser')
@ -36,8 +36,8 @@ export const Toolbar = memo(function Toolbar() {
editor,
])
const showEditingTools = !isReadOnly
const showExtraActions = !(isReadOnly || isHandTool)
const showEditingTools = !isReadonly
const showExtraActions = !(isReadonly || isHandTool)
const getTitle = (item: TLUiToolItem) =>
item.label ? `${msg(item.label)} ${item.kbd ? kbdStr(item.kbd) : ''}` : ''
@ -110,7 +110,7 @@ export const Toolbar = memo(function Toolbar() {
<div className="tlui-toolbar">
<div className="tlui-toolbar__inner">
<div className="tlui-toolbar__left">
{!isReadOnly && (
{!isReadonly && (
<div
className={classNames('tlui-toolbar__extras', {
'tlui-toolbar__extras__hidden': !showExtraActions,
@ -144,7 +144,7 @@ export const Toolbar = memo(function Toolbar() {
/>
)
})}
{isReadOnly && laserTool && (
{isReadonly && laserTool && (
<ToolbarButton
key={laserTool.toolItem.id}
item={laserTool.toolItem}
@ -208,7 +208,7 @@ export const Toolbar = memo(function Toolbar() {
)}
</div>
</div>
{breakpoint < 5 && !isReadOnly && (
{breakpoint < 5 && !isReadonly && (
<div className="tlui-toolbar__tools">
<MobileStylePanel />
</div>

View file

@ -1,6 +1,6 @@
import { track, useEditor } from '@tldraw/editor'
import { useActions } from '../hooks/useActions'
import { useReadOnly } from '../hooks/useReadOnly'
import { useReadonly } from '../hooks/useReadonly'
import { useTranslation } from '../hooks/useTranslation/useTranslation'
import { Button } from './primitives/Button'
import { kbdStr } from './primitives/shared'
@ -11,7 +11,7 @@ export const TrashButton = track(function TrashButton() {
const msg = useTranslation()
const action = actions['delete']
const isReadonly = useReadOnly()
const isReadonly = useReadonly()
if (isReadonly) return null

View file

@ -1,8 +1,8 @@
import { preventDefault, useEditor } from '@tldraw/editor'
import { preventDefault, useEditor, useValue } from '@tldraw/editor'
import hotkeys from 'hotkeys-js'
import { useEffect } from 'react'
import { useActions } from './useActions'
import { useReadOnly } from './useReadOnly'
import { useReadonly } from './useReadonly'
import { useTools } from './useTools'
const SKIP_KBDS = [
@ -18,11 +18,14 @@ const SKIP_KBDS = [
export function useKeyboardShortcuts() {
const editor = useEditor()
const isReadonly = useReadOnly()
const isReadonly = useReadonly()
const actions = useActions()
const tools = useTools()
const isFocused = useValue('is focused', () => editor.instanceState.isFocused, [editor])
useEffect(() => {
if (!isFocused) return
const container = editor.getContainer()
hotkeys.setScope(editor.store.id)
@ -34,9 +37,7 @@ export function useKeyboardShortcuts() {
// Add hotkeys for actions and tools.
// Except those that in SKIP_KBDS!
const areShortcutsDisabled = () =>
(editor.instanceState.isFocused && editor.isMenuOpen) ||
editor.editingId !== null ||
editor.crashingError
editor.isMenuOpen || editor.editingId !== null || editor.crashingError
for (const action of Object.values(actions)) {
if (!action.kbd) continue
@ -51,7 +52,9 @@ export function useKeyboardShortcuts() {
}
for (const tool of Object.values(tools)) {
if (!tool.kbd || (!tool.readonlyOk && editor.instanceState.isReadOnly)) continue
if (!tool.kbd || (!tool.readonlyOk && editor.instanceState.isReadonly)) {
continue
}
if (SKIP_KBDS.includes(tool.id)) continue
@ -65,7 +68,7 @@ export function useKeyboardShortcuts() {
return () => {
hotkeys.deleteScope(editor.store.id)
}
}, [actions, tools, isReadonly, editor])
}, [actions, tools, isReadonly, editor, isFocused])
}
function getHotkeysStringFromKbd(kbd: string) {

View file

@ -1,7 +0,0 @@
import { useEditor, useValue } from '@tldraw/editor'
/** @public */
export function useReadOnly() {
const editor = useEditor()
return useValue('isReadOnlyMode', () => editor.instanceState.isReadOnly, [editor])
}

View file

@ -0,0 +1,7 @@
import { useEditor, useValue } from '@tldraw/editor'
/** @public */
export function useReadonly() {
const editor = useEditor()
return useValue('isReadonlyMode', () => editor.instanceState.isReadonly, [editor])
}

View file

@ -285,7 +285,7 @@ describe("App's default tool", () => {
})
it('Is hand for readonly mode', () => {
editor = new TestEditor()
editor.updateInstanceState({ isReadOnly: true })
editor.updateInstanceState({ isReadonly: true })
editor.setCurrentTool('hand')
expect(editor.currentToolId).toBe('hand')
})
@ -369,37 +369,42 @@ describe('isFocused', () => {
})
it('becomes true when the container div receives a focus event', () => {
jest.advanceTimersByTime(100)
expect(editor.instanceState.isFocused).toBe(false)
editor.elm.focus()
jest.advanceTimersByTime(100)
expect(editor.instanceState.isFocused).toBe(true)
})
it('becomes false when the container div receives a blur event', () => {
editor.elm.focus()
jest.advanceTimersByTime(100)
expect(editor.instanceState.isFocused).toBe(true)
editor.elm.blur()
jest.advanceTimersByTime(100)
expect(editor.instanceState.isFocused).toBe(false)
})
it('becomes true when a child of the app container div receives a focusin event', () => {
it.skip('becomes true when a child of the app container div receives a focusin event', () => {
// We need to skip this one because it's not actually true: the focusin event will bubble
// to the document.body, resulting in that being the active element. In reality, the editor's
// container would also have received a focus event, and after the editor's debounce ends,
// the container (or one of its descendants) will be the focused element.
editor.elm.blur()
const child = document.createElement('div')
editor.elm.appendChild(child)
jest.advanceTimersByTime(100)
expect(editor.instanceState.isFocused).toBe(false)
child.dispatchEvent(new FocusEvent('focusin', { bubbles: true }))
jest.advanceTimersByTime(100)
expect(editor.instanceState.isFocused).toBe(true)
child.dispatchEvent(new FocusEvent('focusout', { bubbles: true }))
jest.advanceTimersByTime(100)
expect(editor.instanceState.isFocused).toBe(false)
})
@ -413,6 +418,7 @@ describe('isFocused', () => {
child.dispatchEvent(new FocusEvent('focusout', { bubbles: true }))
jest.advanceTimersByTime(100)
expect(editor.instanceState.isFocused).toBe(false)
})

View file

@ -412,7 +412,7 @@ describe('When in readonly mode', () => {
props: { w: 100, h: 100, url: '' },
},
])
editor.updateInstanceState({ isReadOnly: true })
editor.updateInstanceState({ isReadonly: true })
editor.setCurrentTool('hand')
editor.setCurrentTool('select')
})
@ -420,7 +420,7 @@ describe('When in readonly mode', () => {
it('Begins editing embed when double clicked', () => {
expect(editor.editingId).toBe(null)
expect(editor.selectedIds.length).toBe(0)
expect(editor.instanceState.isReadOnly).toBe(true)
expect(editor.instanceState.isReadonly).toBe(true)
const shape = editor.getShapeById(ids.embed1)
editor.doubleClick(100, 100, { target: 'shape', shape })
@ -430,7 +430,7 @@ describe('When in readonly mode', () => {
it('Begins editing embed when pressing Enter on a selected embed', () => {
expect(editor.editingId).toBe(null)
expect(editor.selectedIds.length).toBe(0)
expect(editor.instanceState.isReadOnly).toBe(true)
expect(editor.instanceState.isReadonly).toBe(true)
editor.setSelectedIds([ids.embed1])
expect(editor.selectedIds.length).toBe(1)

View file

@ -279,7 +279,7 @@ describe('creating groups', () => {
// │ A │ │ B │ │ C │
// └───┘ └───┘ └───┘
editor.createShapes([box(ids.boxA, 0, 0), box(ids.boxB, 20, 0), box(ids.boxC, 40, 0)])
editor.updateInstanceState({ isReadOnly: true })
editor.updateInstanceState({ isReadonly: true })
editor.setCurrentTool('hand')
editor.selectAll()
expect(editor.selectedIds.length).toBe(3)
@ -491,7 +491,7 @@ describe('ungrouping shapes', () => {
expect(editor.selectedIds.length).toBe(3)
editor.groupShapes()
expect(editor.selectedIds.length).toBe(1)
editor.updateInstanceState({ isReadOnly: true })
editor.updateInstanceState({ isReadonly: true })
editor.setCurrentTool('hand')
editor.ungroupShapes()

View file

@ -1020,7 +1020,7 @@ export interface TLInstance extends BaseRecord<'instance', TLInstanceId> {
// (undocumented)
isPenMode: boolean;
// (undocumented)
isReadOnly: boolean;
isReadonly: boolean;
// (undocumented)
isToolLocked: boolean;
// (undocumented)

View file

@ -1341,6 +1341,22 @@ describe('adds lonely properties', () => {
})
})
describe('rename isReadOnly to isReadonly', () => {
const { up, down } = instanceMigrations.migrators[instanceVersions.ReadOnlyReadonly]
test('up works as expected', () => {
expect(up({ isReadOnly: false })).toStrictEqual({
isReadonly: false,
})
})
test('down works as expected', () => {
expect(down({ isReadonly: false })).toStrictEqual({
isReadOnly: false,
})
})
})
/* --- PUT YOUR MIGRATIONS TESTS ABOVE HERE --- */
for (const migrator of allMigrators) {

View file

@ -42,7 +42,7 @@ export interface TLInstance extends BaseRecord<'instance', TLInstanceId> {
isCoarsePointer: boolean
openMenus: string[]
isChangingStyle: boolean
isReadOnly: boolean
isReadonly: boolean
meta: JsonObject
}
@ -87,7 +87,7 @@ export function createInstanceRecordType(stylesById: Map<string, StyleProp<unkno
isCoarsePointer: T.boolean,
openMenus: T.arrayOf(T.string),
isChangingStyle: T.boolean,
isReadOnly: T.boolean,
isReadonly: T.boolean,
meta: T.jsonValue as T.ObjectValidator<JsonObject>,
})
)
@ -124,7 +124,7 @@ export function createInstanceRecordType(stylesById: Map<string, StyleProp<unkno
isCoarsePointer: false,
openMenus: [] as string[],
isChangingStyle: false,
isReadOnly: false,
isReadonly: false,
meta: {},
})
)
@ -151,11 +151,12 @@ export const instanceVersions = {
AddMeta: 17,
RemoveCursorColor: 18,
AddLonelyProperties: 19,
ReadOnlyReadonly: 20,
} as const
/** @public */
export const instanceMigrations = defineMigrations({
currentVersion: instanceVersions.AddLonelyProperties,
currentVersion: instanceVersions.ReadOnlyReadonly,
migrators: {
[instanceVersions.AddTransparentExportBgs]: {
up: (instance: TLInstance) => {
@ -438,6 +439,20 @@ export const instanceMigrations = defineMigrations({
}
},
},
[instanceVersions.ReadOnlyReadonly]: {
up: ({ isReadOnly: _isReadOnly, ...record }) => {
return {
...record,
isReadonly: _isReadOnly,
}
},
down: ({ isReadonly: _isReadonly, ...record }) => {
return {
...record,
isReadOnly: _isReadonly,
}
},
},
},
})