Skip to content

Commit

Permalink
Delete existing empty spans when leaving text edit mode (#4266)
Browse files Browse the repository at this point in the history
* delete existing empty spans

* only remove spans

* helper

* delete if not styled and has no event handlers

* style/handlers constraints

* update helper

* when content changes

* drop style constraint

* drop outdated test
  • Loading branch information
ruggi authored Oct 2, 2023
1 parent 7b4cd65 commit bd67af2
Show file tree
Hide file tree
Showing 2 changed files with 227 additions and 10 deletions.
188 changes: 180 additions & 8 deletions editor/src/components/text-editor/text-editor.spec.browser2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,115 @@ describe('Use the text editor', () => {
)`),
)
})
it('deletes existing span elements', async () => {
const editor = await renderTestEditorWithCode(
formatTestProjectCode(`
import * as React from 'react'
import { Storyboard } from 'utopia-api'
export var storyboard = (
<Storyboard data-uid='sb'>
<span
data-uid='span'
data-testid='span'
style={{
position: 'absolute',
wordBreak: 'break-word',
left: 0,
top: 0,
width: 'max-content',
height: 'max-content',
}}
>
Hello
</span>
</Storyboard>
)
`),
'await-first-dom-report',
)

await enterTextEditMode(editor, 'start', 'span')

deleteTypedText()

await closeTextEditor()
await editor.getDispatchFollowUpActionsFinished()

expect(editor.getEditorState().editor.mode.type).toEqual('select')
expect(getPrintedUiJsCode(editor.getEditorState())).toEqual(
formatTestProjectCode(`
import * as React from 'react'
import { Storyboard } from 'utopia-api'
export var storyboard = (
<Storyboard data-uid='sb' />
)
`),
)
})
it('does not delete span elements with event handlers', async () => {
const editor = await renderTestEditorWithCode(
formatTestProjectCode(`
import * as React from 'react'
import { Storyboard } from 'utopia-api'
export var storyboard = (
<Storyboard data-uid='sb'>
<span
data-uid='span'
data-testid='span'
style={{
position: 'absolute',
wordBreak: 'break-word',
left: 0,
top: 0,
width: 'max-content',
height: 'max-content',
}}
onClick={() => console.log('click')}
>
Hello
</span>
</Storyboard>
)
`),
'await-first-dom-report',
)

await enterTextEditMode(editor, 'start', 'span')

deleteTypedText()

await closeTextEditor()
await editor.getDispatchFollowUpActionsFinished()

expect(editor.getEditorState().editor.mode.type).toEqual('select')
expect(getPrintedUiJsCode(editor.getEditorState())).toEqual(
formatTestProjectCode(`
import * as React from 'react'
import { Storyboard } from 'utopia-api'
export var storyboard = (
<Storyboard data-uid='sb'>
<span
data-uid='span'
data-testid='span'
style={{
position: 'absolute',
wordBreak: 'break-word',
left: 0,
top: 0,
width: 'max-content',
height: 'max-content',
}}
onClick={() => console.log('click')}
/>
</Storyboard>
)
`),
)
})
})
describe('collapses runs of text', () => {
it('only when the elements involved are eligible', async () => {
Expand Down Expand Up @@ -1797,23 +1906,26 @@ async function testModifierExpectingWayTooManySavesTheFirstTime(
return { before, after }
}

async function enterTextEditMode(editor: EditorRenderResult): Promise<void> {
async function enterTextEditMode(
editor: EditorRenderResult,
where: 'start' | 'end' = 'end',
testId: string = 'div',
): Promise<void> {
const canvasControlsLayer = editor.renderedDOM.getByTestId(CanvasControlsContainerID)
const div = editor.renderedDOM.getByTestId('div')
const divBounds = div.getBoundingClientRect()
const divCorner = {
x: divBounds.x + 50,
y: divBounds.y + 40,
const element = editor.renderedDOM.getByTestId(testId)
const bounds = element.getBoundingClientRect()
const corner = {
x: bounds.x + (where === 'start' ? 1 : 50),
y: bounds.y + (where === 'start' ? 1 : 40),
}

FOR_TESTS_setNextGeneratedUid('text-span')

await pressKey('t')
await editor.getDispatchFollowUpActionsFinished()
await mouseClickAtPoint(canvasControlsLayer, divCorner)
await mouseClickAtPoint(canvasControlsLayer, corner)
await editor.getDispatchFollowUpActionsFinished()
}

function typeText(text: string) {
document.execCommand('insertText', false, text)
}
Expand Down Expand Up @@ -1923,6 +2035,66 @@ function projectWithoutTextWithExtraStyle(extraStyleProps: { [prop: string]: str
`)
}

const projectWithTextSpan = formatTestProjectCode(`
import * as React from 'react'
import { Storyboard } from 'utopia-api'
export var storyboard = (
<Storyboard data-uid='sb'>
<span
data-testid='span'
style={{
position: 'absolute',
wordBreak: 'break-word',
left: 0,
top: 0,
width: 'max-content',
height: 'max-content',
}}
>
Hello
</span>
</Storyboard>
)
`)

function projectWithoutTextSpanWithExtraStyle(extraStyleProps: { [prop: string]: string }) {
const styleProps = {
backgroundColor: '#0091FFAA',
position: 'absolute',
left: 0,
top: 0,
width: 288,
height: 362,
...extraStyleProps,
}

return formatTestProjectCode(`
import * as React from 'react'
import { Storyboard } from 'utopia-api'
export var storyboard = (
<Storyboard data-uid='sb'>
<span
data-testid='span'
style={{
position: 'absolute',
wordBreak: 'break-word',
left: 0,
top: 0,
width: 'max-content',
height: 'max-content',
}}
>
Hello
</span>
</Storyboard>
)
`)
}

const emptyProject = formatTestProjectCode(`import * as React from 'react'
import { Storyboard } from 'utopia-api'
Expand Down
49 changes: 47 additions & 2 deletions editor/src/components/text-editor/text-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import { EditorModes } from '../editor/editor-modes'
import { useDispatch } from '../editor/store/dispatch-context'
import { MainEditorStoreProvider } from '../editor/store/store-context-providers'
import { Substores, useEditorState, useRefEditorState } from '../editor/store/store-hook'
import { printCSSNumber } from '../inspector/common/css-utils'
import { DOMEventHandlerNames, printCSSNumber } from '../inspector/common/css-utils'
import {
toggleTextBold,
toggleTextItalic,
Expand All @@ -43,6 +43,8 @@ import { mapArrayToDictionary } from '../../core/shared/array-utils'
import { TextRelatedProperties } from '../../core/properties/css-properties'
import { assertNever } from '../../core/shared/utils'
import { notice } from '../common/notice'
import type { AllElementProps } from '../editor/store/editor-state'
import { toString } from '../../core/shared/element-path'

export const TextEditorSpanId = 'text-editor'

Expand Down Expand Up @@ -305,6 +307,7 @@ const TextEditor = React.memo((props: TextEditorProps) => {
)

const metadataRef = useRefEditorState((store) => store.editor.jsxMetadata)
const allElementPropsRef = useRefEditorState((store) => store.editor.allElementProps)

const savedContentRef = React.useRef<string | null>(null)

Expand All @@ -330,6 +333,7 @@ const TextEditor = React.memo((props: TextEditorProps) => {

currentElement.focus()
savedContentRef.current = currentElement.textContent
const initialText = currentElement.textContent

const elementCanvasFrame = MetadataUtils.getFrameOrZeroRectInCanvasCoords(
elementPath,
Expand All @@ -339,6 +343,12 @@ const TextEditor = React.memo((props: TextEditorProps) => {
currentElement.style.minWidth = '0.5px'
}

const canDeleteWhenEmpty = canDeleteElementWhenEmpty(
metadataRef.current,
elementPath,
allElementPropsRef.current,
)

return () => {
const content = currentElement.textContent
if (content != null) {
Expand All @@ -349,10 +359,19 @@ const TextEditor = React.memo((props: TextEditorProps) => {
savedContentRef.current = content
requestAnimationFrame(() => dispatch([getSaveAction(elementPath, content, textProp)]))
}
// remove dangling empty spans
if (
content != null &&
initialText !== content &&
content.replace(/^\n/, '').length === 0 &&
canDeleteWhenEmpty
) {
requestAnimationFrame(() => dispatch([deleteView(elementPath)]))
}
}
}
}
}, [dispatch, elementPath, elementState, metadataRef, textProp])
}, [dispatch, elementPath, elementState, textProp, metadataRef, allElementPropsRef])

React.useLayoutEffect(() => {
if (myElement.current == null) {
Expand Down Expand Up @@ -593,3 +612,29 @@ function getSaveAction(
): EditorAction {
return updateText(elementPath, escapeHTML(content, textProp), textProp)
}

function canDeleteElementWhenEmpty(
jsxMetadata: ElementInstanceMetadataMap,
path: ElementPath,
allElementProps: AllElementProps,
): boolean {
const element = MetadataUtils.findElementByElementPath(jsxMetadata, path)
if (element == null) {
return false
}
if (!MetadataUtils.isSpan(element)) {
return false
}

const elementProps = allElementProps[toString(path)]
if (elementProps == null) {
return false
}

// it must not have defined event handlers
if (Object.keys(elementProps).some((prop) => DOMEventHandlerNames.includes(prop as any))) {
return false
}

return true
}

0 comments on commit bd67af2

Please sign in to comment.