[fix] Sticky text content / hovered shapes (#1808)
This PR improves the UX around sticky notes. It fixes were some bugs related to the editing / hovered shape after cloning a sticky note shape. ### Change Type - [x] `patch` — Bug fix ### Test Plan 1. Use the sticky note tool 2. Alt-drag to clone sticky notes 3. Use the Enter key to edit the selected shape. 4. Double click an editable shape and then click once to edit a shape of the same type. - [x] Unit Tests
This commit is contained in:
parent
22329c51fc
commit
1dc76fe32b
12 changed files with 201 additions and 142 deletions
|
@ -1806,22 +1806,23 @@ export class Editor extends EventEmitter<TLEventMap> {
|
||||||
* editor.setEditingShape(myShape.id)
|
* editor.setEditingShape(myShape.id)
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* @param shapes - The shape (or shape id) to set as editing.
|
* @param shape - The shape (or shape id) to set as editing.
|
||||||
*
|
*
|
||||||
* @public
|
* @public
|
||||||
*/
|
*/
|
||||||
setEditingShape(shape: TLShapeId | TLShape | null): this {
|
setEditingShape(shape: TLShapeId | TLShape | null): this {
|
||||||
const id = typeof shape === 'string' ? shape : shape?.id ?? null
|
const id = typeof shape === 'string' ? shape : shape?.id ?? null
|
||||||
if (!id) {
|
if (id !== this.editingShapeId) {
|
||||||
this._setInstancePageState({ editingShapeId: null })
|
if (id) {
|
||||||
} else {
|
const shape = this.getShape(id)
|
||||||
if (id !== this.editingShapeId) {
|
if (shape && this.getShapeUtil(shape).canEdit(shape)) {
|
||||||
const shape = this.getShape(id)!
|
this._setInstancePageState({ editingShapeId: id })
|
||||||
const util = this.getShapeUtil(shape)
|
return this
|
||||||
if (shape && util.canEdit(shape)) {
|
|
||||||
this._setInstancePageState({ editingShapeId: id, hoveredShapeId: null })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Either we just set the editing id to null, or the shape was missing or not editable
|
||||||
|
this._setInstancePageState({ editingShapeId: null })
|
||||||
}
|
}
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
|
@ -43,7 +43,7 @@ export const TextLabel = React.memo(function TextLabel<
|
||||||
rInput,
|
rInput,
|
||||||
isEmpty,
|
isEmpty,
|
||||||
isEditing,
|
isEditing,
|
||||||
isEditableFromHover,
|
isEditingSameShapeType,
|
||||||
handleFocus,
|
handleFocus,
|
||||||
handleChange,
|
handleChange,
|
||||||
handleKeyDown,
|
handleKeyDown,
|
||||||
|
@ -52,7 +52,7 @@ export const TextLabel = React.memo(function TextLabel<
|
||||||
handleDoubleClick,
|
handleDoubleClick,
|
||||||
} = useEditableText(id, type, text)
|
} = useEditableText(id, type, text)
|
||||||
|
|
||||||
const isInteractive = isEditing || isEditableFromHover
|
const isInteractive = isEditing || isEditingSameShapeType
|
||||||
const finalText = TextHelpers.normalizeTextForDom(text)
|
const finalText = TextHelpers.normalizeTextForDom(text)
|
||||||
const hasText = finalText.trim().length > 0
|
const hasText = finalText.trim().length > 0
|
||||||
const legacyAlign = isLegacyAlign(align)
|
const legacyAlign = isLegacyAlign(align)
|
||||||
|
|
|
@ -6,7 +6,6 @@ import {
|
||||||
getPointerInfo,
|
getPointerInfo,
|
||||||
preventDefault,
|
preventDefault,
|
||||||
stopEventPropagation,
|
stopEventPropagation,
|
||||||
transact,
|
|
||||||
useEditor,
|
useEditor,
|
||||||
useValue,
|
useValue,
|
||||||
} from '@tldraw/editor'
|
} from '@tldraw/editor'
|
||||||
|
@ -21,63 +20,49 @@ export function useEditableText<T extends Extract<TLShape, { props: { text: stri
|
||||||
const editor = useEditor()
|
const editor = useEditor()
|
||||||
|
|
||||||
const rInput = useRef<HTMLTextAreaElement>(null)
|
const rInput = useRef<HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
const isEditing = useValue('isEditing', () => editor.currentPageState.editingShapeId === id, [
|
|
||||||
editor,
|
|
||||||
id,
|
|
||||||
])
|
|
||||||
|
|
||||||
const rSkipSelectOnFocus = useRef(false)
|
const rSkipSelectOnFocus = useRef(false)
|
||||||
const rSelectionRanges = useRef<Range[] | null>()
|
const rSelectionRanges = useRef<Range[] | null>()
|
||||||
|
|
||||||
const isEditableFromHover = useValue(
|
const isEditing = useValue('isEditing', () => editor.editingShapeId === id, [editor, id])
|
||||||
'is editable hovering',
|
|
||||||
|
const isEditingSameShapeType = useValue(
|
||||||
|
'is editing same shape type',
|
||||||
() => {
|
() => {
|
||||||
const { hoveredShapeId, editingShape } = editor
|
const { editingShape } = editor
|
||||||
if (type === 'text' && editor.isIn('text') && hoveredShapeId === id) {
|
return editingShape && editingShape.type === type
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (editingShape) {
|
|
||||||
return (
|
|
||||||
// We must be hovering over this shape and not editing it
|
|
||||||
hoveredShapeId === id &&
|
|
||||||
editingShape.id !== id &&
|
|
||||||
// the editing shape must be the same type as this shape
|
|
||||||
editingShape.type === type &&
|
|
||||||
// and this shape must be capable of being editing in its current form
|
|
||||||
editor.getShapeUtil(editingShape).canEdit(editingShape)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
},
|
},
|
||||||
[type, id]
|
[type, id]
|
||||||
)
|
)
|
||||||
|
|
||||||
// When the label receives focus, set the value to the most
|
// If the shape is editing but the input element not focused, focus the element
|
||||||
// recent text value and select all of the text
|
useEffect(() => {
|
||||||
const handleFocus = useCallback(() => {
|
const elm = rInput.current
|
||||||
// We only want to do the select all thing if the shape
|
if (elm && isEditing && document.activeElement !== elm) {
|
||||||
// was the first shape to become editing. Switching from
|
elm.focus()
|
||||||
// one editing shape to another should not select all.
|
}
|
||||||
if (isEditableFromHover) return
|
}, [isEditing])
|
||||||
|
|
||||||
|
// When the label receives focus, set the value to the most recent text value and select all of the text
|
||||||
|
const handleFocus = useCallback(() => {
|
||||||
|
// Store and turn off the skipSelectOnFocus flag
|
||||||
|
const skipSelect = rSkipSelectOnFocus.current
|
||||||
|
rSkipSelectOnFocus.current = false
|
||||||
|
|
||||||
|
// On the next frame, if we're not skipping select AND we have text in the element, then focus the text
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const elm = rInput.current
|
const elm = rInput.current
|
||||||
if (!elm) return
|
if (!elm) return
|
||||||
|
|
||||||
const shape = editor.getShape<TLShape & { props: { text: string } }>(id)
|
const shape = editor.getShape<TLShape & { props: { text: string } }>(id)
|
||||||
|
|
||||||
if (shape) {
|
if (shape) {
|
||||||
elm.value = shape.props.text
|
elm.value = shape.props.text
|
||||||
if (elm.value.length && !rSkipSelectOnFocus.current) {
|
if (elm.value.length && !skipSelect) {
|
||||||
elm.select()
|
elm.select()
|
||||||
}
|
}
|
||||||
|
|
||||||
rSkipSelectOnFocus.current = false
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, [editor, id, isEditableFromHover])
|
}, [editor, id])
|
||||||
|
|
||||||
// When the label blurs, deselect all of the text and complete.
|
// When the label blurs, deselect all of the text and complete.
|
||||||
// This makes it so that the canvas does not have to be focused
|
// This makes it so that the canvas does not have to be focused
|
||||||
|
@ -87,11 +72,12 @@ export function useEditableText<T extends Extract<TLShape, { props: { text: stri
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const elm = rInput.current
|
const elm = rInput.current
|
||||||
|
const { editingShapeId } = editor
|
||||||
// Did we move to a different shape?
|
// Did we move to a different shape?
|
||||||
if (elm && editor.editingShapeId) {
|
if (elm && editingShapeId) {
|
||||||
// important! these ^v are two different things
|
// important! these ^v are two different things
|
||||||
// is that shape OUR shape?
|
// is that shape OUR shape?
|
||||||
if (editor.editingShapeId === id) {
|
if (editingShapeId === id) {
|
||||||
if (ranges) {
|
if (ranges) {
|
||||||
if (!ranges.length) {
|
if (!ranges.length) {
|
||||||
// If we don't have any ranges, restore selection
|
// If we don't have any ranges, restore selection
|
||||||
|
@ -122,6 +108,8 @@ export function useEditableText<T extends Extract<TLShape, { props: { text: stri
|
||||||
// When the user presses tab, indent or unindent the text.
|
// When the user presses tab, indent or unindent the text.
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (!isEditing) return
|
||||||
|
|
||||||
if (e.ctrlKey || e.metaKey) stopEventPropagation(e)
|
if (e.ctrlKey || e.metaKey) stopEventPropagation(e)
|
||||||
|
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
|
@ -142,12 +130,14 @@ export function useEditableText<T extends Extract<TLShape, { props: { text: stri
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[editor]
|
[editor, isEditing]
|
||||||
)
|
)
|
||||||
|
|
||||||
// When the text changes, update the text value.
|
// When the text changes, update the text value.
|
||||||
const handleChange = useCallback(
|
const handleChange = useCallback(
|
||||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (!isEditing) return
|
||||||
|
|
||||||
let text = TextHelpers.normalizeText(e.currentTarget.value)
|
let text = TextHelpers.normalizeText(e.currentTarget.value)
|
||||||
|
|
||||||
// ------- Bug fix ------------
|
// ------- Bug fix ------------
|
||||||
|
@ -166,12 +156,14 @@ export function useEditableText<T extends Extract<TLShape, { props: { text: stri
|
||||||
{ id, type, props: { text } },
|
{ id, type, props: { text } },
|
||||||
])
|
])
|
||||||
},
|
},
|
||||||
[editor, id, type]
|
[editor, id, type, isEditing]
|
||||||
)
|
)
|
||||||
|
|
||||||
const isEmpty = text.trim().length === 0
|
const isEmpty = text.trim().length === 0
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isEditing) return
|
||||||
|
|
||||||
const elm = rInput.current
|
const elm = rInput.current
|
||||||
if (elm) {
|
if (elm) {
|
||||||
function updateSelection() {
|
function updateSelection() {
|
||||||
|
@ -195,44 +187,40 @@ export function useEditableText<T extends Extract<TLShape, { props: { text: stri
|
||||||
document.removeEventListener('selectionchange', updateSelection)
|
document.removeEventListener('selectionchange', updateSelection)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [])
|
}, [isEditing])
|
||||||
|
|
||||||
const handleInputPointerDown = useCallback(
|
const handleInputPointerDown = useCallback(
|
||||||
(e: React.PointerEvent) => {
|
(e: React.PointerEvent) => {
|
||||||
if (isEditableFromHover) {
|
const { editingShape } = editor
|
||||||
transact(() => {
|
|
||||||
editor.setEditingShape(id)
|
if (editingShape) {
|
||||||
editor.setHoveredShape(id)
|
// If there's an editing shape and it's the same type as this shape,
|
||||||
editor.setSelectedShapes([id])
|
// then we can "deep edit" into this shape. Note that this won't work
|
||||||
})
|
// as expected with the note shape—in that case clicking outside of the
|
||||||
} else {
|
// input will not set skipSelectOnFocus to true, and so the input will
|
||||||
editor.dispatch({
|
// blur, re-select, and then re-select-all on a second tap.
|
||||||
...getPointerInfo(e),
|
rSkipSelectOnFocus.current = type === editingShape.type
|
||||||
type: 'pointer',
|
|
||||||
name: 'pointer_down',
|
|
||||||
target: 'shape',
|
|
||||||
shape: editor.getShape(id)!,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
stopEventPropagation(e)
|
editor.dispatch({
|
||||||
},
|
...getPointerInfo(e),
|
||||||
[editor, isEditableFromHover, id]
|
type: 'pointer',
|
||||||
)
|
name: 'pointer_down',
|
||||||
|
target: 'shape',
|
||||||
|
shape: editor.getShape(id)!,
|
||||||
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
stopEventPropagation(e) // we need to prevent blurring the input
|
||||||
const elm = rInput.current
|
},
|
||||||
if (elm && isEditing && document.activeElement !== elm) {
|
[editor, id, type]
|
||||||
elm.focus()
|
)
|
||||||
}
|
|
||||||
}, [isEditing])
|
|
||||||
|
|
||||||
const handleDoubleClick = stopEventPropagation
|
const handleDoubleClick = stopEventPropagation
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rInput,
|
rInput,
|
||||||
isEditing,
|
isEditing,
|
||||||
isEditableFromHover,
|
isEditingSameShapeType,
|
||||||
handleFocus,
|
handleFocus,
|
||||||
handleBlur,
|
handleBlur,
|
||||||
handleKeyDown,
|
handleKeyDown,
|
||||||
|
|
|
@ -77,7 +77,7 @@ export class TextShapeUtil extends ShapeUtil<TLTextShape> {
|
||||||
rInput,
|
rInput,
|
||||||
isEmpty,
|
isEmpty,
|
||||||
isEditing,
|
isEditing,
|
||||||
isEditableFromHover,
|
isEditingSameShapeType,
|
||||||
handleFocus,
|
handleFocus,
|
||||||
handleChange,
|
handleChange,
|
||||||
handleKeyDown,
|
handleKeyDown,
|
||||||
|
@ -107,7 +107,7 @@ export class TextShapeUtil extends ShapeUtil<TLTextShape> {
|
||||||
<div className="tl-text tl-text-content" dir="ltr">
|
<div className="tl-text tl-text-content" dir="ltr">
|
||||||
{text}
|
{text}
|
||||||
</div>
|
</div>
|
||||||
{isEditing || isEditableFromHover ? (
|
{isEditing || isEditingSameShapeType ? (
|
||||||
<textarea
|
<textarea
|
||||||
ref={rInput}
|
ref={rInput}
|
||||||
className="tl-text tl-text-input"
|
className="tl-text tl-text-input"
|
||||||
|
|
|
@ -1,10 +1,17 @@
|
||||||
import { StateNode, TLArrowShape, TLEventHandlers, TLGeoShape } from '@tldraw/editor'
|
import { Group2d, StateNode, TLArrowShape, TLEventHandlers, TLGeoShape } from '@tldraw/editor'
|
||||||
import { getHitShapeOnCanvasPointerDown } from '../../selection-logic/getHitShapeOnCanvasPointerDown'
|
import { getHitShapeOnCanvasPointerDown } from '../../selection-logic/getHitShapeOnCanvasPointerDown'
|
||||||
import { updateHoveredId } from '../../selection-logic/updateHoveredId'
|
import { updateHoveredId } from '../../selection-logic/updateHoveredId'
|
||||||
|
|
||||||
export class EditingShape extends StateNode {
|
export class EditingShape extends StateNode {
|
||||||
static override id = 'editing_shape'
|
static override id = 'editing_shape'
|
||||||
|
|
||||||
|
override onEnter = () => {
|
||||||
|
const { editingShape } = this.editor
|
||||||
|
if (!editingShape) throw Error('Entered editing state without an editing shape')
|
||||||
|
updateHoveredId(this.editor)
|
||||||
|
this.editor.select(editingShape)
|
||||||
|
}
|
||||||
|
|
||||||
override onExit = () => {
|
override onExit = () => {
|
||||||
const { editingShapeId } = this.editor.currentPageState
|
const { editingShapeId } = this.editor.currentPageState
|
||||||
if (!editingShapeId) return
|
if (!editingShapeId) return
|
||||||
|
@ -29,40 +36,10 @@ export class EditingShape extends StateNode {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
override onPointerDown: TLEventHandlers['onPointerDown'] = (info) => {
|
override onPointerDown: TLEventHandlers['onPointerDown'] = (info) => {
|
||||||
// This is pretty tricky.
|
|
||||||
|
|
||||||
// Most of the time, we wouldn't want pointer events inside of an editing
|
|
||||||
// shape to de-select the shape or change the editing state. We would just
|
|
||||||
// ignore those pointer events.
|
|
||||||
|
|
||||||
// The exception to this is shapes that have only parts of themselves that are
|
|
||||||
// editable, such as the label on a geo shape. In this case, we would want clicks
|
|
||||||
// that are outside of the label but inside of the shape to end the editing session
|
|
||||||
// and select the shape instead.
|
|
||||||
|
|
||||||
// What we'll do here (for now at least) is have the text label / input element
|
|
||||||
// have a pointer event handler (in useEditableText) that dispatches its own
|
|
||||||
// "shape" type event, which lets us know to ignore the event. If we instead get
|
|
||||||
// a "canvas" type event, then we'll check to see if the hovered shape is a geo
|
|
||||||
// shape and if so, we'll end the editing session and select the shape.
|
|
||||||
|
|
||||||
switch (info.target) {
|
switch (info.target) {
|
||||||
case 'shape': {
|
|
||||||
if (info.shape.id === this.editor.editingShapeId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'canvas': {
|
case 'canvas': {
|
||||||
const hitShape = getHitShapeOnCanvasPointerDown(this.editor)
|
const hitShape = getHitShapeOnCanvasPointerDown(this.editor)
|
||||||
|
if (hitShape) {
|
||||||
if (
|
|
||||||
hitShape &&
|
|
||||||
!(
|
|
||||||
this.editor.isShapeOfType<TLGeoShape>(hitShape, 'geo') ||
|
|
||||||
this.editor.isShapeOfType<TLArrowShape>(hitShape, 'arrow')
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
this.onPointerDown({
|
this.onPointerDown({
|
||||||
...info,
|
...info,
|
||||||
shape: hitShape,
|
shape: hitShape,
|
||||||
|
@ -70,11 +47,63 @@ export class EditingShape extends StateNode {
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'shape': {
|
||||||
|
const { shape } = info
|
||||||
|
const { editingShape } = this.editor
|
||||||
|
|
||||||
|
if (!editingShape) {
|
||||||
|
throw Error('Expected an editing shape!')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shape.type === editingShape.type) {
|
||||||
|
// clicked a shape of the same type as the editing shape
|
||||||
|
if (
|
||||||
|
this.editor.isShapeOfType<TLGeoShape>(shape, 'geo') ||
|
||||||
|
this.editor.isShapeOfType<TLArrowShape>(shape, 'arrow')
|
||||||
|
) {
|
||||||
|
// for shapes with labels, check to see if the click was inside of the shape's label
|
||||||
|
const geometry = this.editor.getShapeUtil(shape).getGeometry(shape) as Group2d
|
||||||
|
const labelGeometry = geometry.children[1]
|
||||||
|
if (labelGeometry) {
|
||||||
|
const pointInShapeSpace = this.editor.getPointInShapeSpace(
|
||||||
|
shape,
|
||||||
|
this.editor.inputs.currentPagePoint
|
||||||
|
)
|
||||||
|
if (labelGeometry.bounds.containsPoint(pointInShapeSpace)) {
|
||||||
|
// it's a hit to the label!
|
||||||
|
if (shape.id === editingShape.id) {
|
||||||
|
// If we clicked on the editing geo / arrow shape's label, do nothing
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
this.editor.setEditingShape(shape)
|
||||||
|
this.editor.select(shape)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (shape.id === editingShape.id) {
|
||||||
|
// If we clicked on the editing shape (which isn't a shape with a label), do nothing
|
||||||
|
} else {
|
||||||
|
// But if we clicked on a different shape of the same type, edit it instead
|
||||||
|
this.editor.setEditingShape(shape)
|
||||||
|
this.editor.select(shape)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// clicked a different kind of shape
|
||||||
|
}
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// still here? Cancel editing and transition back to select idle
|
||||||
this.parent.transition('idle', info)
|
this.parent.transition('idle', info)
|
||||||
this.parent.current.value?.onPointerDown?.(info)
|
// then feed the pointer down event back into the state chart as if it happened in that state
|
||||||
|
this.editor.root.handleEvent(info)
|
||||||
}
|
}
|
||||||
|
|
||||||
override onComplete: TLEventHandlers['onComplete'] = (info) => {
|
override onComplete: TLEventHandlers['onComplete'] = (info) => {
|
||||||
|
|
|
@ -23,6 +23,7 @@ export class Idle extends StateNode {
|
||||||
|
|
||||||
override onEnter = () => {
|
override onEnter = () => {
|
||||||
this.parent.currentToolIdMask = undefined
|
this.parent.currentToolIdMask = undefined
|
||||||
|
updateHoveredId(this.editor)
|
||||||
this.editor.updateInstanceState(
|
this.editor.updateInstanceState(
|
||||||
{ cursor: { type: 'default', rotation: 0 } },
|
{ cursor: { type: 'default', rotation: 0 } },
|
||||||
{ ephemeral: true }
|
{ ephemeral: true }
|
||||||
|
|
|
@ -144,8 +144,8 @@ export class PointingShape extends StateNode {
|
||||||
this.editor.batch(() => {
|
this.editor.batch(() => {
|
||||||
this.editor.mark('editing on pointer up')
|
this.editor.mark('editing on pointer up')
|
||||||
this.editor.select(selectingShape.id)
|
this.editor.select(selectingShape.id)
|
||||||
this.editor.setCurrentTool('select.editing_shape')
|
|
||||||
this.editor.setEditingShape(selectingShape.id)
|
this.editor.setEditingShape(selectingShape.id)
|
||||||
|
this.editor.setCurrentTool('select.editing_shape')
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -109,8 +109,7 @@ export class Resizing extends StateNode {
|
||||||
|
|
||||||
if (this.editAfterComplete && this.editor.onlySelectedShape) {
|
if (this.editAfterComplete && this.editor.onlySelectedShape) {
|
||||||
this.editor.setEditingShape(this.editor.onlySelectedShape.id)
|
this.editor.setEditingShape(this.editor.onlySelectedShape.id)
|
||||||
this.editor.setCurrentTool('select')
|
this.editor.setCurrentTool('select.editing_shape')
|
||||||
this.editor.root.current.value!.transition('editing_shape', {})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -55,7 +55,23 @@ export class Translating extends StateNode {
|
||||||
|
|
||||||
this.markId = isCreating ? `creating:${this.editor.onlySelectedShape!.id}` : 'translating'
|
this.markId = isCreating ? `creating:${this.editor.onlySelectedShape!.id}` : 'translating'
|
||||||
this.editor.mark(this.markId)
|
this.editor.mark(this.markId)
|
||||||
this.handleEnter(info)
|
this.isCloning = false
|
||||||
|
this.info = info
|
||||||
|
|
||||||
|
this.editor.setCursor({ type: 'move', rotation: 0 })
|
||||||
|
this.selectionSnapshot = getTranslatingSnapshot(this.editor)
|
||||||
|
|
||||||
|
// Don't clone on create; otherwise clone on altKey
|
||||||
|
if (!this.isCreating) {
|
||||||
|
if (this.editor.inputs.altKey) {
|
||||||
|
this.startCloning()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.snapshot = this.selectionSnapshot
|
||||||
|
this.handleStart()
|
||||||
|
this.updateShapes()
|
||||||
this.editor.on('tick', this.updateParent)
|
this.editor.on('tick', this.updateParent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -153,8 +169,7 @@ export class Translating extends StateNode {
|
||||||
const onlySelected = this.editor.onlySelectedShape
|
const onlySelected = this.editor.onlySelectedShape
|
||||||
if (onlySelected) {
|
if (onlySelected) {
|
||||||
this.editor.setEditingShape(onlySelected.id)
|
this.editor.setEditingShape(onlySelected.id)
|
||||||
this.editor.setCurrentTool('select')
|
this.editor.setCurrentTool('select.editing_shape')
|
||||||
this.editor.root.current.value!.transition('editing_shape', {})
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.parent.transition('idle', {})
|
this.parent.transition('idle', {})
|
||||||
|
@ -171,26 +186,6 @@ export class Translating extends StateNode {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected handleEnter(info: TLPointerEventInfo & { target: 'shape' }) {
|
|
||||||
this.isCloning = false
|
|
||||||
this.info = info
|
|
||||||
|
|
||||||
this.editor.setCursor({ type: 'move', rotation: 0 })
|
|
||||||
this.selectionSnapshot = getTranslatingSnapshot(this.editor)
|
|
||||||
|
|
||||||
// Don't clone on create; otherwise clone on altKey
|
|
||||||
if (!this.isCreating) {
|
|
||||||
if (this.editor.inputs.altKey) {
|
|
||||||
this.startCloning()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.snapshot = this.selectionSnapshot
|
|
||||||
this.handleStart()
|
|
||||||
this.updateShapes()
|
|
||||||
}
|
|
||||||
|
|
||||||
protected handleStart() {
|
protected handleStart() {
|
||||||
const { movingShapes } = this.snapshot
|
const { movingShapes } = this.snapshot
|
||||||
|
|
||||||
|
@ -207,6 +202,7 @@ export class Translating extends StateNode {
|
||||||
if (changes.length > 0) {
|
if (changes.length > 0) {
|
||||||
this.editor.updateShapes(changes)
|
this.editor.updateShapes(changes)
|
||||||
}
|
}
|
||||||
|
this.editor.setHoveredShape(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
protected handleEnd() {
|
protected handleEnd() {
|
||||||
|
|
|
@ -394,7 +394,8 @@ describe('When editing shapes', () => {
|
||||||
const shapeId = editor.selectedShapeIds[0]
|
const shapeId = editor.selectedShapeIds[0]
|
||||||
|
|
||||||
// Click another text shape
|
// Click another text shape
|
||||||
editor.click(50, 50, { target: 'shape', shape: editor.getShape(ids.text1) })
|
editor.pointerMove(50, 50)
|
||||||
|
editor.click()
|
||||||
expect(editor.selectedShapeIds.length).toBe(1)
|
expect(editor.selectedShapeIds.length).toBe(1)
|
||||||
expect(editor.currentPageShapes.length).toBe(5)
|
expect(editor.currentPageShapes.length).toBe(5)
|
||||||
expect(editor.getShape(shapeId)).toBe(undefined)
|
expect(editor.getShape(shapeId)).toBe(undefined)
|
||||||
|
|
|
@ -1326,8 +1326,13 @@ describe('When double clicking an editable shape', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('starts editing arrow on double click', () => {
|
it('starts editing arrow on double click', () => {
|
||||||
editor.pointerMove(250, 50).doubleClick()
|
editor.pointerMove(250, 50)
|
||||||
|
|
||||||
|
editor.doubleClick()
|
||||||
expect(editor.selectedShapeIds).toEqual([ids.box2])
|
expect(editor.selectedShapeIds).toEqual([ids.box2])
|
||||||
|
expect(editor.editingShapeId).toBe(ids.box2)
|
||||||
|
editor.expectToBeIn('select.editing_shape')
|
||||||
|
|
||||||
editor.doubleClick()
|
editor.doubleClick()
|
||||||
expect(editor.selectedShapeIds).toEqual([ids.box2])
|
expect(editor.selectedShapeIds).toEqual([ids.box2])
|
||||||
expect(editor.editingShapeId).toBe(ids.box2)
|
expect(editor.editingShapeId).toBe(ids.box2)
|
||||||
|
|
|
@ -171,6 +171,7 @@ describe('When cloning...', () => {
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('clones a single shape and restores when stopping cloning', () => {
|
it('clones a single shape and restores when stopping cloning', () => {
|
||||||
expect(editor.currentPageShapeIds.size).toBe(3)
|
expect(editor.currentPageShapeIds.size).toBe(3)
|
||||||
expect(editor.currentPageShapeIds.size).toBe(3)
|
expect(editor.currentPageShapeIds.size).toBe(3)
|
||||||
|
@ -1783,3 +1784,41 @@ describe('When dragging shapes', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('clones a single shape simply', () => {
|
||||||
|
editor
|
||||||
|
// create a note shape
|
||||||
|
.setCurrentTool('note')
|
||||||
|
.pointerMove(50, 50)
|
||||||
|
.click()
|
||||||
|
|
||||||
|
expect(editor.onlySelectedShape).toBe(editor.currentPageShapes[0])
|
||||||
|
expect(editor.hoveredShape).toBe(editor.currentPageShapes[0])
|
||||||
|
|
||||||
|
// click on the canvas to deselect
|
||||||
|
editor.pointerMove(200, 50).click()
|
||||||
|
|
||||||
|
expect(editor.onlySelectedShape).toBe(null)
|
||||||
|
expect(editor.hoveredShape).toBe(undefined)
|
||||||
|
|
||||||
|
// move back over the the shape
|
||||||
|
editor.pointerMove(50, 50)
|
||||||
|
|
||||||
|
expect(editor.onlySelectedShape).toBe(null)
|
||||||
|
expect(editor.hoveredShape).toBe(editor.currentPageShapes[0])
|
||||||
|
|
||||||
|
// start dragging the shape
|
||||||
|
editor
|
||||||
|
.pointerDown()
|
||||||
|
.pointerMove(50, 500)
|
||||||
|
// start cloning
|
||||||
|
.keyDown('Alt')
|
||||||
|
// stop dragging
|
||||||
|
.pointerUp()
|
||||||
|
|
||||||
|
expect(editor.currentPageShapes).toHaveLength(2)
|
||||||
|
const [, sticky2] = editor.currentPageShapes
|
||||||
|
expect(editor.onlySelectedShape).toBe(sticky2)
|
||||||
|
expect(editor.editingShape).toBe(undefined)
|
||||||
|
expect(editor.hoveredShape).toBe(sticky2)
|
||||||
|
})
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue