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

View file

@ -86,7 +86,7 @@
"page-menu.submenu.duplicate-page": "Duplicar", "page-menu.submenu.duplicate-page": "Duplicar",
"page-menu.submenu.delete": "Deletar", "page-menu.submenu.delete": "Deletar",
"share-menu.copy-link": "Copiar Link de Convite", "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", "help-menu.keyboard-shortcuts": "Atalhos de Teclado",
"edit-link-dialog.cancel": "Cancelar", "edit-link-dialog.cancel": "Cancelar",
"embed-dialog.cancel": "Cancelar", "embed-dialog.cancel": "Cancelar",

View file

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

View file

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

View file

@ -10,6 +10,7 @@ import React, {
useSyncExternalStore, useSyncExternalStore,
} from 'react' } from 'react'
import classNames from 'classnames'
import { Canvas } from './components/Canvas' import { Canvas } from './components/Canvas'
import { OptionalErrorBoundary } from './components/ErrorBoundary' import { OptionalErrorBoundary } from './components/ErrorBoundary'
import { DefaultErrorFallback } from './components/default-components/DefaultErrorFallback' 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). * The editor's initial state (usually the id of the first active tool).
*/ */
initialState?: string 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({ export const TldrawEditor = memo(function TldrawEditor({
store, store,
components, components,
className,
...rest ...rest
}: TldrawEditorProps) { }: TldrawEditorProps) {
const [container, setContainer] = React.useState<HTMLDivElement | null>(null) const [container, rContainer] = React.useState<HTMLDivElement | null>(null)
const user = useMemo(() => createTLUser(), []) const user = useMemo(() => createTLUser(), [])
const ErrorFallback = const ErrorFallback =
@ -137,7 +144,12 @@ export const TldrawEditor = memo(function TldrawEditor({
} }
return ( 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 <OptionalErrorBoundary
fallback={ErrorFallback} fallback={ErrorFallback}
onError={(error) => annotateError(error, { tags: { origin: 'react.tldraw-before-app' } })} 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, [ const isChangingStyle = useValue('isChangingStyle', () => editor.instanceState.isChangingStyle, [
editor, 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) const handles = editor.getHandles(onlySelectedShape)

View file

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

View file

@ -233,15 +233,17 @@ export class SnapManager {
constructor(public readonly editor: Editor) {} constructor(public readonly editor: Editor) {}
@computed get snapPointsCache() { @computed get snapPointsCache() {
return this.editor.store.createComputedCache<SnapPoint[], TLShape>('snapPoints', (shape) => { const { editor } = this
const pageTransfrorm = this.editor.getPageTransformById(shape.id) return editor.store.createComputedCache<SnapPoint[], TLShape>('snapPoints', (shape) => {
const pageTransfrorm = editor.getPageTransformById(shape.id)
if (!pageTransfrorm) return undefined if (!pageTransfrorm) return undefined
const util = this.editor.getShapeUtil(shape) return editor
const snapPoints = util.snapPoints(shape) .getShapeUtil(shape)
return snapPoints.map((point, i) => { .snapPoints(shape)
const { x, y } = Matrix2d.applyToPoint(pageTransfrorm, point) .map((point, i) => {
return { x, y, id: `${shape.id}:${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) { function onTouchStart(e: React.TouchEvent) {
;(e as any).isKilled = true ;(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) preventDefault(e)
} }

View file

@ -32,6 +32,7 @@ export function useDocumentEvents() {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if ( if (
e.altKey && 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')) && (editor.isIn('zoom') || !editor.root.path.value.endsWith('.idle')) &&
!isFocusingInput() !isFocusingInput()
) { ) {
@ -45,21 +46,14 @@ export function useDocumentEvents() {
;(e as any).isKilled = true ;(e as any).isKilled = true
switch (e.key) { switch (e.key) {
case '=': { case '=':
if (e.metaKey || e.ctrlKey) { case '-':
preventDefault(e)
return
}
break
}
case '-': {
if (e.metaKey || e.ctrlKey) {
preventDefault(e)
return
}
break
}
case '0': { 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) { if (e.metaKey || e.ctrlKey) {
preventDefault(e) preventDefault(e)
return return
@ -73,6 +67,9 @@ export function useDocumentEvents() {
break break
} }
case ',': { 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()) { if (!isFocusingInput()) {
preventDefault(e) preventDefault(e)
if (!editor.inputs.keys.has('Comma')) { if (!editor.inputs.keys.has('Comma')) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -142,7 +142,11 @@ function CommonStylePickerSet({
return ( 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 : ( {color === undefined ? null : (
<ButtonPicker <ButtonPicker
title={msg('style-panel.color')} title={msg('style-panel.color')}

View file

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

View file

@ -1,6 +1,6 @@
import { track, useEditor } from '@tldraw/editor' import { track, useEditor } from '@tldraw/editor'
import { useActions } from '../hooks/useActions' import { useActions } from '../hooks/useActions'
import { useReadOnly } from '../hooks/useReadOnly' import { useReadonly } from '../hooks/useReadonly'
import { useTranslation } from '../hooks/useTranslation/useTranslation' import { useTranslation } from '../hooks/useTranslation/useTranslation'
import { Button } from './primitives/Button' import { Button } from './primitives/Button'
import { kbdStr } from './primitives/shared' import { kbdStr } from './primitives/shared'
@ -11,7 +11,7 @@ export const TrashButton = track(function TrashButton() {
const msg = useTranslation() const msg = useTranslation()
const action = actions['delete'] const action = actions['delete']
const isReadonly = useReadOnly() const isReadonly = useReadonly()
if (isReadonly) return null 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 hotkeys from 'hotkeys-js'
import { useEffect } from 'react' import { useEffect } from 'react'
import { useActions } from './useActions' import { useActions } from './useActions'
import { useReadOnly } from './useReadOnly' import { useReadonly } from './useReadonly'
import { useTools } from './useTools' import { useTools } from './useTools'
const SKIP_KBDS = [ const SKIP_KBDS = [
@ -18,11 +18,14 @@ const SKIP_KBDS = [
export function useKeyboardShortcuts() { export function useKeyboardShortcuts() {
const editor = useEditor() const editor = useEditor()
const isReadonly = useReadOnly() const isReadonly = useReadonly()
const actions = useActions() const actions = useActions()
const tools = useTools() const tools = useTools()
const isFocused = useValue('is focused', () => editor.instanceState.isFocused, [editor])
useEffect(() => { useEffect(() => {
if (!isFocused) return
const container = editor.getContainer() const container = editor.getContainer()
hotkeys.setScope(editor.store.id) hotkeys.setScope(editor.store.id)
@ -34,9 +37,7 @@ export function useKeyboardShortcuts() {
// Add hotkeys for actions and tools. // Add hotkeys for actions and tools.
// Except those that in SKIP_KBDS! // Except those that in SKIP_KBDS!
const areShortcutsDisabled = () => const areShortcutsDisabled = () =>
(editor.instanceState.isFocused && editor.isMenuOpen) || editor.isMenuOpen || editor.editingId !== null || editor.crashingError
editor.editingId !== null ||
editor.crashingError
for (const action of Object.values(actions)) { for (const action of Object.values(actions)) {
if (!action.kbd) continue if (!action.kbd) continue
@ -51,7 +52,9 @@ export function useKeyboardShortcuts() {
} }
for (const tool of Object.values(tools)) { 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 if (SKIP_KBDS.includes(tool.id)) continue
@ -65,7 +68,7 @@ export function useKeyboardShortcuts() {
return () => { return () => {
hotkeys.deleteScope(editor.store.id) hotkeys.deleteScope(editor.store.id)
} }
}, [actions, tools, isReadonly, editor]) }, [actions, tools, isReadonly, editor, isFocused])
} }
function getHotkeysStringFromKbd(kbd: string) { 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', () => { it('Is hand for readonly mode', () => {
editor = new TestEditor() editor = new TestEditor()
editor.updateInstanceState({ isReadOnly: true }) editor.updateInstanceState({ isReadonly: true })
editor.setCurrentTool('hand') editor.setCurrentTool('hand')
expect(editor.currentToolId).toBe('hand') expect(editor.currentToolId).toBe('hand')
}) })
@ -369,37 +369,42 @@ describe('isFocused', () => {
}) })
it('becomes true when the container div receives a focus event', () => { it('becomes true when the container div receives a focus event', () => {
jest.advanceTimersByTime(100)
expect(editor.instanceState.isFocused).toBe(false) expect(editor.instanceState.isFocused).toBe(false)
editor.elm.focus() editor.elm.focus()
jest.advanceTimersByTime(100)
expect(editor.instanceState.isFocused).toBe(true) expect(editor.instanceState.isFocused).toBe(true)
}) })
it('becomes false when the container div receives a blur event', () => { it('becomes false when the container div receives a blur event', () => {
editor.elm.focus() editor.elm.focus()
jest.advanceTimersByTime(100)
expect(editor.instanceState.isFocused).toBe(true) expect(editor.instanceState.isFocused).toBe(true)
editor.elm.blur() editor.elm.blur()
jest.advanceTimersByTime(100)
expect(editor.instanceState.isFocused).toBe(false) 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() editor.elm.blur()
const child = document.createElement('div') const child = document.createElement('div')
editor.elm.appendChild(child) editor.elm.appendChild(child)
jest.advanceTimersByTime(100)
expect(editor.instanceState.isFocused).toBe(false) expect(editor.instanceState.isFocused).toBe(false)
child.dispatchEvent(new FocusEvent('focusin', { bubbles: true })) child.dispatchEvent(new FocusEvent('focusin', { bubbles: true }))
jest.advanceTimersByTime(100)
expect(editor.instanceState.isFocused).toBe(true) expect(editor.instanceState.isFocused).toBe(true)
child.dispatchEvent(new FocusEvent('focusout', { bubbles: true })) child.dispatchEvent(new FocusEvent('focusout', { bubbles: true }))
jest.advanceTimersByTime(100)
expect(editor.instanceState.isFocused).toBe(false) expect(editor.instanceState.isFocused).toBe(false)
}) })
@ -413,6 +418,7 @@ describe('isFocused', () => {
child.dispatchEvent(new FocusEvent('focusout', { bubbles: true })) child.dispatchEvent(new FocusEvent('focusout', { bubbles: true }))
jest.advanceTimersByTime(100)
expect(editor.instanceState.isFocused).toBe(false) expect(editor.instanceState.isFocused).toBe(false)
}) })

View file

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

View file

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

View file

@ -1020,7 +1020,7 @@ export interface TLInstance extends BaseRecord<'instance', TLInstanceId> {
// (undocumented) // (undocumented)
isPenMode: boolean; isPenMode: boolean;
// (undocumented) // (undocumented)
isReadOnly: boolean; isReadonly: boolean;
// (undocumented) // (undocumented)
isToolLocked: boolean; isToolLocked: boolean;
// (undocumented) // (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 --- */ /* --- PUT YOUR MIGRATIONS TESTS ABOVE HERE --- */
for (const migrator of allMigrators) { for (const migrator of allMigrators) {

View file

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