From bd67af2fe92d9649fac55993e7a3c4f2cc8de98c Mon Sep 17 00:00:00 2001
From: Federico Ruggi <1081051+ruggi@users.noreply.github.com>
Date: Mon, 2 Oct 2023 09:24:23 +0200
Subject: [PATCH] Delete existing empty spans when leaving text edit mode
(#4266)
* 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
---
.../text-editor/text-editor.spec.browser2.tsx | 188 +++++++++++++++++-
.../components/text-editor/text-editor.tsx | 49 ++++-
2 files changed, 227 insertions(+), 10 deletions(-)
diff --git a/editor/src/components/text-editor/text-editor.spec.browser2.tsx b/editor/src/components/text-editor/text-editor.spec.browser2.tsx
index b1378b65fd2c..99da66b44416 100644
--- a/editor/src/components/text-editor/text-editor.spec.browser2.tsx
+++ b/editor/src/components/text-editor/text-editor.spec.browser2.tsx
@@ -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 = (
+
+
+ Hello
+
+
+ )
+ `),
+ '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 = (
+
+ )
+ `),
+ )
+ })
+ 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 = (
+
+ console.log('click')}
+ >
+ Hello
+
+
+ )
+ `),
+ '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 = (
+
+ console.log('click')}
+ />
+
+ )
+ `),
+ )
+ })
})
describe('collapses runs of text', () => {
it('only when the elements involved are eligible', async () => {
@@ -1797,23 +1906,26 @@ async function testModifierExpectingWayTooManySavesTheFirstTime(
return { before, after }
}
-async function enterTextEditMode(editor: EditorRenderResult): Promise {
+async function enterTextEditMode(
+ editor: EditorRenderResult,
+ where: 'start' | 'end' = 'end',
+ testId: string = 'div',
+): Promise {
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)
}
@@ -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 = (
+
+
+ Hello
+
+
+)
+`)
+
+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 = (
+
+
+ Hello
+
+
+ )
+ `)
+}
+
const emptyProject = formatTestProjectCode(`import * as React from 'react'
import { Storyboard } from 'utopia-api'
diff --git a/editor/src/components/text-editor/text-editor.tsx b/editor/src/components/text-editor/text-editor.tsx
index 1c15a6935aaa..91139ea6e20c 100644
--- a/editor/src/components/text-editor/text-editor.tsx
+++ b/editor/src/components/text-editor/text-editor.tsx
@@ -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,
@@ -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'
@@ -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(null)
@@ -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,
@@ -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) {
@@ -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) {
@@ -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
+}