[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:
parent
adc53afbe3
commit
4f07e696e8
16 changed files with 234 additions and 10 deletions
|
@ -82,6 +82,8 @@
|
|||
"action.toggle-auto-size": "Toggle auto size",
|
||||
"action.toggle-dark-mode.menu": "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": "Toggle reduce motion",
|
||||
"action.toggle-edge-scrolling.menu": "Edge scrolling",
|
||||
|
|
|
@ -502,6 +502,7 @@ export const defaultUserPreferences: Readonly<{
|
|||
edgeScrollSpeed: 1;
|
||||
animationSpeed: 0 | 1;
|
||||
isSnapMode: false;
|
||||
isWrapMode: false;
|
||||
}>;
|
||||
|
||||
// @public
|
||||
|
@ -2590,6 +2591,8 @@ export interface TLUserPreferences {
|
|||
// (undocumented)
|
||||
isSnapMode?: boolean | null;
|
||||
// (undocumented)
|
||||
isWrapMode?: boolean | null;
|
||||
// (undocumented)
|
||||
locale?: null | string;
|
||||
// (undocumented)
|
||||
name?: null | string;
|
||||
|
|
|
@ -6826,7 +6826,7 @@
|
|||
},
|
||||
{
|
||||
"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",
|
||||
|
@ -41650,6 +41650,33 @@
|
|||
"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",
|
||||
"canonicalReference": "@tldraw/editor!TLUserPreferences#locale:member",
|
||||
|
|
|
@ -16,10 +16,11 @@ export interface TLUserPreferences {
|
|||
name?: string | null
|
||||
locale?: string | null
|
||||
color?: string | null
|
||||
isDarkMode?: boolean | null
|
||||
animationSpeed?: number | null
|
||||
edgeScrollSpeed?: number | null
|
||||
isDarkMode?: boolean | null
|
||||
isSnapMode?: boolean | null
|
||||
isWrapMode?: boolean | null
|
||||
}
|
||||
|
||||
interface UserDataSnapshot {
|
||||
|
@ -42,6 +43,7 @@ const userTypeValidator: T.Validator<TLUserPreferences> = T.object<TLUserPrefere
|
|||
animationSpeed: T.number.nullable().optional(),
|
||||
edgeScrollSpeed: T.number.nullable().optional(),
|
||||
isSnapMode: T.boolean.nullable().optional(),
|
||||
isWrapMode: T.boolean.nullable().optional(),
|
||||
})
|
||||
|
||||
const Versions = {
|
||||
|
@ -49,10 +51,11 @@ const Versions = {
|
|||
AddIsSnapMode: 2,
|
||||
MakeFieldsNullable: 3,
|
||||
AddEdgeScrollSpeed: 4,
|
||||
AddExcalidrawSelectMode: 5,
|
||||
} as const
|
||||
|
||||
const userMigrations = defineMigrations({
|
||||
currentVersion: Versions.AddEdgeScrollSpeed,
|
||||
currentVersion: Versions.AddExcalidrawSelectMode,
|
||||
migrators: {
|
||||
[Versions.AddAnimationSpeed]: {
|
||||
up: (user) => {
|
||||
|
@ -83,9 +86,10 @@ const userMigrations = defineMigrations({
|
|||
name: user.name ?? defaultUserPreferences.name,
|
||||
locale: user.locale ?? defaultUserPreferences.locale,
|
||||
color: user.color ?? defaultUserPreferences.color,
|
||||
isDarkMode: user.isDarkMode ?? defaultUserPreferences.isDarkMode,
|
||||
animationSpeed: user.animationSpeed ?? defaultUserPreferences.animationSpeed,
|
||||
isDarkMode: user.isDarkMode ?? defaultUserPreferences.isDarkMode,
|
||||
isSnapMode: user.isSnapMode ?? defaultUserPreferences.isSnapMode,
|
||||
isWrapMode: user.isWrapMode ?? defaultUserPreferences.isWrapMode,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
@ -100,6 +104,14 @@ const userMigrations = defineMigrations({
|
|||
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,
|
||||
animationSpeed: userPrefersReducedMotion() ? 0 : 1,
|
||||
isSnapMode: false,
|
||||
isWrapMode: false,
|
||||
}) satisfies Readonly<Omit<TLUserPreferences, 'id'>>
|
||||
|
||||
/** @public */
|
||||
|
|
|
@ -24,9 +24,10 @@ export class UserPreferencesManager {
|
|||
name: this.getName(),
|
||||
locale: this.getLocale(),
|
||||
color: this.getColor(),
|
||||
isDarkMode: this.getIsDarkMode(),
|
||||
animationSpeed: this.getAnimationSpeed(),
|
||||
isSnapMode: this.getIsSnapMode(),
|
||||
isDarkMode: this.getIsDarkMode(),
|
||||
isWrapMode: this.getIsWrapMode(),
|
||||
}
|
||||
}
|
||||
@computed getIsDarkMode() {
|
||||
|
@ -66,4 +67,8 @@ export class UserPreferencesManager {
|
|||
@computed getIsSnapMode() {
|
||||
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
|
@ -226,6 +226,7 @@ export {
|
|||
ToggleSnapModeItem,
|
||||
ToggleToolLockItem,
|
||||
ToggleTransparentBgMenuItem,
|
||||
ToggleWrapModeItem,
|
||||
UngroupMenuItem,
|
||||
UnlockAllMenuItem,
|
||||
ZoomTo100MenuItem,
|
||||
|
|
|
@ -28,6 +28,7 @@ export class Brushing extends StateNode {
|
|||
brush = new Box()
|
||||
initialSelectedShapeIds: TLShapeId[] = []
|
||||
excludedShapeIds = new Set<TLShapeId>()
|
||||
isWrapMode = false
|
||||
|
||||
// The shape that the brush started on
|
||||
initialStartShape: TLShape | null = null
|
||||
|
@ -35,6 +36,8 @@ export class Brushing extends StateNode {
|
|||
override onEnter = (info: TLPointerEventInfo & { target: 'canvas' }) => {
|
||||
const { altKey, currentPagePoint } = this.editor.inputs
|
||||
|
||||
this.isWrapMode = this.editor.user.getIsWrapMode()
|
||||
|
||||
if (altKey) {
|
||||
this.parent.transition('scribble_brushing', info)
|
||||
return
|
||||
|
@ -123,7 +126,9 @@ export class Brushing extends StateNode {
|
|||
// We'll be testing the corners of the brush against the shapes
|
||||
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++) {
|
||||
shape = currentPageShapes[i]
|
||||
|
@ -142,7 +147,7 @@ export class Brushing extends StateNode {
|
|||
// Should we even test for a single segment intersections? Only if
|
||||
// we're not holding the ctrl key for alternate selection mode
|
||||
// (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
|
||||
}
|
||||
|
||||
|
|
|
@ -22,6 +22,7 @@ import {
|
|||
ToggleSnapModeItem,
|
||||
ToggleToolLockItem,
|
||||
ToggleTransparentBgMenuItem,
|
||||
ToggleWrapModeItem,
|
||||
UngroupMenuItem,
|
||||
UnlockAllMenuItem,
|
||||
ZoomTo100MenuItem,
|
||||
|
@ -186,6 +187,7 @@ export function PreferencesGroup() {
|
|||
<ToggleSnapModeItem />
|
||||
<ToggleToolLockItem />
|
||||
<ToggleGridItem />
|
||||
<ToggleWrapModeItem />
|
||||
<ToggleDarkModeItem />
|
||||
<ToggleFocusModeItem />
|
||||
<ToggleEdgeScrollingItem />
|
||||
|
|
|
@ -492,6 +492,15 @@ export function ToggleGridItem() {
|
|||
const isGridMode = useValue('isGridMode', () => editor.getInstanceState().isGridMode, [editor])
|
||||
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 */
|
||||
export function ToggleDarkModeItem() {
|
||||
const actions = useActions()
|
||||
|
|
|
@ -1057,6 +1057,21 @@ export function ActionsProvider({ overrides, children }: ActionsProviderProps) {
|
|||
},
|
||||
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',
|
||||
label: {
|
||||
|
|
|
@ -84,6 +84,7 @@ export interface TLUiEventMap {
|
|||
'toggle-tool-lock': null
|
||||
'toggle-grid-mode': null
|
||||
'toggle-dark-mode': null
|
||||
'toggle-wrap-mode': null
|
||||
'toggle-focus-mode': null
|
||||
'toggle-debug-mode': null
|
||||
'toggle-lock': null
|
||||
|
|
|
@ -86,6 +86,8 @@ export type TLUiTranslationKey =
|
|||
| 'action.toggle-auto-size'
|
||||
| 'action.toggle-dark-mode.menu'
|
||||
| 'action.toggle-dark-mode'
|
||||
| 'action.toggle-wrap-mode.menu'
|
||||
| 'action.toggle-wrap-mode'
|
||||
| 'action.toggle-reduce-motion.menu'
|
||||
| 'action.toggle-reduce-motion'
|
||||
| 'action.toggle-edge-scrolling.menu'
|
||||
|
|
|
@ -86,6 +86,8 @@ export const DEFAULT_TRANSLATION = {
|
|||
'action.toggle-auto-size': 'Toggle auto size',
|
||||
'action.toggle-dark-mode.menu': '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': 'Toggle reduce motion',
|
||||
'action.toggle-edge-scrolling.menu': 'Edge scrolling',
|
||||
|
|
|
@ -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', () => {
|
||||
let box1: TLGeoShape
|
||||
beforeEach(() => {
|
||||
|
@ -684,8 +755,8 @@ describe('when a frame has multiple children', () => {
|
|||
editor.pointerDown()
|
||||
editor.pointerMove(30, 30)
|
||||
editor.expectToBeIn('select.brushing')
|
||||
editor.pointerUp()
|
||||
expect(editor.getSelectedShapeIds()).toEqual([ids.box1])
|
||||
editor.pointerUp()
|
||||
})
|
||||
|
||||
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', () => {
|
||||
beforeEach(() => {
|
||||
editor.user.updateUserPreferences({ isWrapMode: false })
|
||||
editor
|
||||
.createShapes([
|
||||
{ id: ids.box1, type: 'geo', x: 0, y: 0 },
|
||||
|
|
Loading…
Reference in a new issue