[feature] wrap mode (#2938)

By default, tldraw's brushing mode will select when the box intersects
an shape's geometry. A user can hold Command / Ctrl to require that the
selection box fully contain a shape's bounds instead.

Some people really prefer the opposite. Three years! Three years I've
been saying "no no no".

This PR adds a user preference to flip the logic. When `isWrapMode` is
true, selection requires that the box completely contain a shape before
it's added to the list of selecting shapes; and ctrl flips back to
intersection instead.

### Change Type

- [x] `minor` — New feature

### Test Plan

1. Turn on wrap mode in the user preferences menu.
2. Select stuff.
3. Use the ctrl key to except the behavior back to intersection.

- [x] Unit Tests

### Release Notes

- Added `isWrapMode` to user preferences.
- Added Wrap Mode toggle to user preferences menu.
This commit is contained in:
Steve Ruiz 2024-02-29 11:45:02 +00:00 committed by GitHub
parent adc53afbe3
commit 4f07e696e8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 234 additions and 10 deletions

View file

@ -82,6 +82,8 @@
"action.toggle-auto-size": "Toggle auto size", "action.toggle-auto-size": "Toggle auto size",
"action.toggle-dark-mode.menu": "Dark mode", "action.toggle-dark-mode.menu": "Dark mode",
"action.toggle-dark-mode": "Toggle dark mode", "action.toggle-dark-mode": "Toggle dark mode",
"action.toggle-wrap-mode.menu": "Select on wrap",
"action.toggle-wrap-mode": "Toggle Select on wrap",
"action.toggle-reduce-motion.menu": "Reduce motion", "action.toggle-reduce-motion.menu": "Reduce motion",
"action.toggle-reduce-motion": "Toggle reduce motion", "action.toggle-reduce-motion": "Toggle reduce motion",
"action.toggle-edge-scrolling.menu": "Edge scrolling", "action.toggle-edge-scrolling.menu": "Edge scrolling",

View file

@ -502,6 +502,7 @@ export const defaultUserPreferences: Readonly<{
edgeScrollSpeed: 1; edgeScrollSpeed: 1;
animationSpeed: 0 | 1; animationSpeed: 0 | 1;
isSnapMode: false; isSnapMode: false;
isWrapMode: false;
}>; }>;
// @public // @public
@ -2590,6 +2591,8 @@ export interface TLUserPreferences {
// (undocumented) // (undocumented)
isSnapMode?: boolean | null; isSnapMode?: boolean | null;
// (undocumented) // (undocumented)
isWrapMode?: boolean | null;
// (undocumented)
locale?: null | string; locale?: null | string;
// (undocumented) // (undocumented)
name?: null | string; name?: null | string;

View file

@ -6826,7 +6826,7 @@
}, },
{ {
"kind": "Content", "kind": "Content",
"text": "<{\n name: \"New User\";\n locale: \"ar\" | \"ca\" | \"cs\" | \"da\" | \"de\" | \"en\" | \"es\" | \"fa\" | \"fi\" | \"fr\" | \"gl\" | \"he\" | \"hi-in\" | \"hr\" | \"hu\" | \"it\" | \"ja\" | \"ko-kr\" | \"ku\" | \"my\" | \"ne\" | \"no\" | \"pl\" | \"pt-br\" | \"pt-pt\" | \"ro\" | \"ru\" | \"sl\" | \"sv\" | \"te\" | \"th\" | \"tr\" | \"uk\" | \"vi\" | \"zh-cn\" | \"zh-tw\";\n color: \"#02B1CC\" | \"#11B3A3\" | \"#39B178\" | \"#55B467\" | \"#7B66DC\" | \"#9D5BD2\" | \"#BD54C6\" | \"#E34BA9\" | \"#EC5E41\" | \"#F04F88\" | \"#F2555A\" | \"#FF802B\";\n isDarkMode: false;\n edgeScrollSpeed: 1;\n animationSpeed: 0 | 1;\n isSnapMode: false;\n}>" "text": "<{\n name: \"New User\";\n locale: \"ar\" | \"ca\" | \"cs\" | \"da\" | \"de\" | \"en\" | \"es\" | \"fa\" | \"fi\" | \"fr\" | \"gl\" | \"he\" | \"hi-in\" | \"hr\" | \"hu\" | \"it\" | \"ja\" | \"ko-kr\" | \"ku\" | \"my\" | \"ne\" | \"no\" | \"pl\" | \"pt-br\" | \"pt-pt\" | \"ro\" | \"ru\" | \"sl\" | \"sv\" | \"te\" | \"th\" | \"tr\" | \"uk\" | \"vi\" | \"zh-cn\" | \"zh-tw\";\n color: \"#02B1CC\" | \"#11B3A3\" | \"#39B178\" | \"#55B467\" | \"#7B66DC\" | \"#9D5BD2\" | \"#BD54C6\" | \"#E34BA9\" | \"#EC5E41\" | \"#F04F88\" | \"#F2555A\" | \"#FF802B\";\n isDarkMode: false;\n edgeScrollSpeed: 1;\n animationSpeed: 0 | 1;\n isSnapMode: false;\n isWrapMode: false;\n}>"
} }
], ],
"fileUrlPath": "packages/editor/src/lib/config/TLUserPreferences.ts", "fileUrlPath": "packages/editor/src/lib/config/TLUserPreferences.ts",
@ -41650,6 +41650,33 @@
"endIndex": 2 "endIndex": 2
} }
}, },
{
"kind": "PropertySignature",
"canonicalReference": "@tldraw/editor!TLUserPreferences#isWrapMode:member",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "isWrapMode?: "
},
{
"kind": "Content",
"text": "boolean | null"
},
{
"kind": "Content",
"text": ";"
}
],
"isReadonly": false,
"isOptional": true,
"releaseTag": "Public",
"name": "isWrapMode",
"propertyTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
}
},
{ {
"kind": "PropertySignature", "kind": "PropertySignature",
"canonicalReference": "@tldraw/editor!TLUserPreferences#locale:member", "canonicalReference": "@tldraw/editor!TLUserPreferences#locale:member",

View file

@ -16,10 +16,11 @@ export interface TLUserPreferences {
name?: string | null name?: string | null
locale?: string | null locale?: string | null
color?: string | null color?: string | null
isDarkMode?: boolean | null
animationSpeed?: number | null animationSpeed?: number | null
edgeScrollSpeed?: number | null edgeScrollSpeed?: number | null
isDarkMode?: boolean | null
isSnapMode?: boolean | null isSnapMode?: boolean | null
isWrapMode?: boolean | null
} }
interface UserDataSnapshot { interface UserDataSnapshot {
@ -42,6 +43,7 @@ const userTypeValidator: T.Validator<TLUserPreferences> = T.object<TLUserPrefere
animationSpeed: T.number.nullable().optional(), animationSpeed: T.number.nullable().optional(),
edgeScrollSpeed: T.number.nullable().optional(), edgeScrollSpeed: T.number.nullable().optional(),
isSnapMode: T.boolean.nullable().optional(), isSnapMode: T.boolean.nullable().optional(),
isWrapMode: T.boolean.nullable().optional(),
}) })
const Versions = { const Versions = {
@ -49,10 +51,11 @@ const Versions = {
AddIsSnapMode: 2, AddIsSnapMode: 2,
MakeFieldsNullable: 3, MakeFieldsNullable: 3,
AddEdgeScrollSpeed: 4, AddEdgeScrollSpeed: 4,
AddExcalidrawSelectMode: 5,
} as const } as const
const userMigrations = defineMigrations({ const userMigrations = defineMigrations({
currentVersion: Versions.AddEdgeScrollSpeed, currentVersion: Versions.AddExcalidrawSelectMode,
migrators: { migrators: {
[Versions.AddAnimationSpeed]: { [Versions.AddAnimationSpeed]: {
up: (user) => { up: (user) => {
@ -83,9 +86,10 @@ const userMigrations = defineMigrations({
name: user.name ?? defaultUserPreferences.name, name: user.name ?? defaultUserPreferences.name,
locale: user.locale ?? defaultUserPreferences.locale, locale: user.locale ?? defaultUserPreferences.locale,
color: user.color ?? defaultUserPreferences.color, color: user.color ?? defaultUserPreferences.color,
isDarkMode: user.isDarkMode ?? defaultUserPreferences.isDarkMode,
animationSpeed: user.animationSpeed ?? defaultUserPreferences.animationSpeed, animationSpeed: user.animationSpeed ?? defaultUserPreferences.animationSpeed,
isDarkMode: user.isDarkMode ?? defaultUserPreferences.isDarkMode,
isSnapMode: user.isSnapMode ?? defaultUserPreferences.isSnapMode, isSnapMode: user.isSnapMode ?? defaultUserPreferences.isSnapMode,
isWrapMode: user.isWrapMode ?? defaultUserPreferences.isWrapMode,
} }
}, },
}, },
@ -100,6 +104,14 @@ const userMigrations = defineMigrations({
return user return user
}, },
}, },
[Versions.AddExcalidrawSelectMode]: {
up: (user: TLUserPreferences) => {
return { ...user, isWrapMode: false }
},
down: ({ isWrapMode: _, ...user }: TLUserPreferences) => {
return user
},
},
}, },
}) })
@ -148,6 +160,7 @@ export const defaultUserPreferences = Object.freeze({
edgeScrollSpeed: 1, edgeScrollSpeed: 1,
animationSpeed: userPrefersReducedMotion() ? 0 : 1, animationSpeed: userPrefersReducedMotion() ? 0 : 1,
isSnapMode: false, isSnapMode: false,
isWrapMode: false,
}) satisfies Readonly<Omit<TLUserPreferences, 'id'>> }) satisfies Readonly<Omit<TLUserPreferences, 'id'>>
/** @public */ /** @public */

View file

@ -24,9 +24,10 @@ export class UserPreferencesManager {
name: this.getName(), name: this.getName(),
locale: this.getLocale(), locale: this.getLocale(),
color: this.getColor(), color: this.getColor(),
isDarkMode: this.getIsDarkMode(),
animationSpeed: this.getAnimationSpeed(), animationSpeed: this.getAnimationSpeed(),
isSnapMode: this.getIsSnapMode(), isSnapMode: this.getIsSnapMode(),
isDarkMode: this.getIsDarkMode(),
isWrapMode: this.getIsWrapMode(),
} }
} }
@computed getIsDarkMode() { @computed getIsDarkMode() {
@ -66,4 +67,8 @@ export class UserPreferencesManager {
@computed getIsSnapMode() { @computed getIsSnapMode() {
return this.user.userPreferences.get().isSnapMode ?? defaultUserPreferences.isSnapMode return this.user.userPreferences.get().isSnapMode ?? defaultUserPreferences.isSnapMode
} }
@computed getIsWrapMode() {
return this.user.userPreferences.get().isWrapMode ?? defaultUserPreferences.isWrapMode
}
} }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -226,6 +226,7 @@ export {
ToggleSnapModeItem, ToggleSnapModeItem,
ToggleToolLockItem, ToggleToolLockItem,
ToggleTransparentBgMenuItem, ToggleTransparentBgMenuItem,
ToggleWrapModeItem,
UngroupMenuItem, UngroupMenuItem,
UnlockAllMenuItem, UnlockAllMenuItem,
ZoomTo100MenuItem, ZoomTo100MenuItem,

View file

@ -28,6 +28,7 @@ export class Brushing extends StateNode {
brush = new Box() brush = new Box()
initialSelectedShapeIds: TLShapeId[] = [] initialSelectedShapeIds: TLShapeId[] = []
excludedShapeIds = new Set<TLShapeId>() excludedShapeIds = new Set<TLShapeId>()
isWrapMode = false
// The shape that the brush started on // The shape that the brush started on
initialStartShape: TLShape | null = null initialStartShape: TLShape | null = null
@ -35,6 +36,8 @@ export class Brushing extends StateNode {
override onEnter = (info: TLPointerEventInfo & { target: 'canvas' }) => { override onEnter = (info: TLPointerEventInfo & { target: 'canvas' }) => {
const { altKey, currentPagePoint } = this.editor.inputs const { altKey, currentPagePoint } = this.editor.inputs
this.isWrapMode = this.editor.user.getIsWrapMode()
if (altKey) { if (altKey) {
this.parent.transition('scribble_brushing', info) this.parent.transition('scribble_brushing', info)
return return
@ -123,7 +126,9 @@ export class Brushing extends StateNode {
// We'll be testing the corners of the brush against the shapes // We'll be testing the corners of the brush against the shapes
const { corners } = this.brush const { corners } = this.brush
const { excludedShapeIds } = this const { excludedShapeIds, isWrapMode } = this
const isWrapping = isWrapMode ? !ctrlKey : ctrlKey
testAllShapes: for (let i = 0, n = currentPageShapes.length; i < n; i++) { testAllShapes: for (let i = 0, n = currentPageShapes.length; i < n; i++) {
shape = currentPageShapes[i] shape = currentPageShapes[i]
@ -142,7 +147,7 @@ export class Brushing extends StateNode {
// Should we even test for a single segment intersections? Only if // Should we even test for a single segment intersections? Only if
// we're not holding the ctrl key for alternate selection mode // we're not holding the ctrl key for alternate selection mode
// (only wraps count!), or if the shape is a frame. // (only wraps count!), or if the shape is a frame.
if (ctrlKey || this.editor.isShapeOfType<TLFrameShape>(shape, 'frame')) { if (isWrapping || this.editor.isShapeOfType<TLFrameShape>(shape, 'frame')) {
continue testAllShapes continue testAllShapes
} }

View file

@ -22,6 +22,7 @@ import {
ToggleSnapModeItem, ToggleSnapModeItem,
ToggleToolLockItem, ToggleToolLockItem,
ToggleTransparentBgMenuItem, ToggleTransparentBgMenuItem,
ToggleWrapModeItem,
UngroupMenuItem, UngroupMenuItem,
UnlockAllMenuItem, UnlockAllMenuItem,
ZoomTo100MenuItem, ZoomTo100MenuItem,
@ -186,6 +187,7 @@ export function PreferencesGroup() {
<ToggleSnapModeItem /> <ToggleSnapModeItem />
<ToggleToolLockItem /> <ToggleToolLockItem />
<ToggleGridItem /> <ToggleGridItem />
<ToggleWrapModeItem />
<ToggleDarkModeItem /> <ToggleDarkModeItem />
<ToggleFocusModeItem /> <ToggleFocusModeItem />
<ToggleEdgeScrollingItem /> <ToggleEdgeScrollingItem />

View file

@ -492,6 +492,15 @@ export function ToggleGridItem() {
const isGridMode = useValue('isGridMode', () => editor.getInstanceState().isGridMode, [editor]) const isGridMode = useValue('isGridMode', () => editor.getInstanceState().isGridMode, [editor])
return <TldrawUiMenuCheckboxItem {...actions['toggle-grid']} checked={isGridMode} /> return <TldrawUiMenuCheckboxItem {...actions['toggle-grid']} checked={isGridMode} />
} }
/** @public */
export function ToggleWrapModeItem() {
const actions = useActions()
const editor = useEditor()
const isWrapMode = useValue('isWrapMode', () => editor.user.getIsWrapMode(), [editor])
return <TldrawUiMenuCheckboxItem {...actions['toggle-wrap-mode']} checked={isWrapMode} />
}
/** @public */ /** @public */
export function ToggleDarkModeItem() { export function ToggleDarkModeItem() {
const actions = useActions() const actions = useActions()

View file

@ -1057,6 +1057,21 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
}, },
checkbox: true, checkbox: true,
}, },
{
id: 'toggle-wrap-mode',
label: {
default: 'action.toggle-wrap-mode',
menu: 'action.toggle-wrap-mode.menu',
},
readonlyOk: true,
onSelect(source) {
trackEvent('toggle-wrap-mode', { source })
editor.user.updateUserPreferences({
isWrapMode: !editor.user.getIsWrapMode(),
})
},
checkbox: true,
},
{ {
id: 'toggle-reduce-motion', id: 'toggle-reduce-motion',
label: { label: {

View file

@ -84,6 +84,7 @@ export interface TLUiEventMap {
'toggle-tool-lock': null 'toggle-tool-lock': null
'toggle-grid-mode': null 'toggle-grid-mode': null
'toggle-dark-mode': null 'toggle-dark-mode': null
'toggle-wrap-mode': null
'toggle-focus-mode': null 'toggle-focus-mode': null
'toggle-debug-mode': null 'toggle-debug-mode': null
'toggle-lock': null 'toggle-lock': null

View file

@ -86,6 +86,8 @@ export type TLUiTranslationKey =
| 'action.toggle-auto-size' | 'action.toggle-auto-size'
| 'action.toggle-dark-mode.menu' | 'action.toggle-dark-mode.menu'
| 'action.toggle-dark-mode' | 'action.toggle-dark-mode'
| 'action.toggle-wrap-mode.menu'
| 'action.toggle-wrap-mode'
| 'action.toggle-reduce-motion.menu' | 'action.toggle-reduce-motion.menu'
| 'action.toggle-reduce-motion' | 'action.toggle-reduce-motion'
| 'action.toggle-edge-scrolling.menu' | 'action.toggle-edge-scrolling.menu'

View file

@ -86,6 +86,8 @@ export const DEFAULT_TRANSLATION = {
'action.toggle-auto-size': 'Toggle auto size', 'action.toggle-auto-size': 'Toggle auto size',
'action.toggle-dark-mode.menu': 'Dark mode', 'action.toggle-dark-mode.menu': 'Dark mode',
'action.toggle-dark-mode': 'Toggle dark mode', 'action.toggle-dark-mode': 'Toggle dark mode',
'action.toggle-wrap-mode.menu': 'Select on wrap',
'action.toggle-wrap-mode': 'Toggle Select on wrap',
'action.toggle-reduce-motion.menu': 'Reduce motion', 'action.toggle-reduce-motion.menu': 'Reduce motion',
'action.toggle-reduce-motion': 'Toggle reduce motion', 'action.toggle-reduce-motion': 'Toggle reduce motion',
'action.toggle-edge-scrolling.menu': 'Edge scrolling', 'action.toggle-edge-scrolling.menu': 'Edge scrolling',

View file

@ -119,6 +119,77 @@ describe('Hovering shapes', () => {
}) })
}) })
describe('brushing', () => {
beforeEach(() => {
editor.createShapes([{ id: ids.box1, type: 'geo', props: { fill: 'solid', w: 50, h: 50 } }])
editor.user.updateUserPreferences({ isWrapMode: false })
})
afterAll(() => {
editor.user.updateUserPreferences({ isWrapMode: false })
})
it('brushes on wrap', () => {
editor.pointerMove(-50, -50)
editor.pointerDown()
editor.pointerMove(100, 100)
expect(editor.getSelectedShapeIds().length).toBe(1)
})
it('brushes on intersection', () => {
editor.pointerMove(-50, -50)
editor.pointerDown()
editor.pointerMove(10, 10)
expect(editor.getSelectedShapeIds().length).toBe(1)
})
it('brushes only on wrap when ctrl key is down', () => {
editor.pointerMove(-50, -50)
editor.pointerDown()
editor.pointerMove(10, 10)
editor.keyDown('Control')
expect(editor.getSelectedShapeIds().length).toBe(0)
editor.pointerMove(100, 100)
expect(editor.getSelectedShapeIds().length).toBe(1)
})
})
describe('brushing with wrap mode on', () => {
beforeEach(() => {
editor.createShapes([{ id: ids.box1, type: 'geo', props: { fill: 'solid', w: 50, h: 50 } }])
editor.user.updateUserPreferences({ isWrapMode: true })
})
afterAll(() => {
editor.user.updateUserPreferences({ isWrapMode: false })
})
it('brushes on wrap', () => {
editor.pointerMove(-50, -50)
editor.pointerDown()
editor.pointerMove(100, 100)
expect(editor.getSelectedShapeIds().length).toBe(1)
})
it('does not brush on intersection', () => {
editor.pointerMove(-50, -50)
editor.pointerDown()
editor.pointerMove(10, 10)
expect(editor.getSelectedShapeIds().length).toBe(0)
})
it('brushes on intersection when ctrl key is down', () => {
editor.pointerMove(-50, -50)
editor.pointerDown()
editor.pointerMove(10, 10)
expect(editor.getSelectedShapeIds().length).toBe(0)
editor.keyDown('Control')
expect(editor.getSelectedShapeIds().length).toBe(1)
editor.pointerMove(100, 100)
expect(editor.getSelectedShapeIds().length).toBe(1)
})
})
describe('when shape is filled', () => { describe('when shape is filled', () => {
let box1: TLGeoShape let box1: TLGeoShape
beforeEach(() => { beforeEach(() => {
@ -684,8 +755,8 @@ describe('when a frame has multiple children', () => {
editor.pointerDown() editor.pointerDown()
editor.pointerMove(30, 30) editor.pointerMove(30, 30)
editor.expectToBeIn('select.brushing') editor.expectToBeIn('select.brushing')
editor.pointerUp()
expect(editor.getSelectedShapeIds()).toEqual([ids.box1]) expect(editor.getSelectedShapeIds()).toEqual([ids.box1])
editor.pointerUp()
}) })
it('brush selects shapes when containing them in a drag from outside of the frame', () => { it('brush selects shapes when containing them in a drag from outside of the frame', () => {
@ -1473,6 +1544,7 @@ describe('When double clicking an editable shape', () => {
describe('shift brushes to add to the selection', () => { describe('shift brushes to add to the selection', () => {
beforeEach(() => { beforeEach(() => {
editor.user.updateUserPreferences({ isWrapMode: false })
editor editor
.createShapes([ .createShapes([
{ id: ids.box1, type: 'geo', x: 0, y: 0 }, { id: ids.box1, type: 'geo', x: 0, y: 0 },