Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix shadow DOM - drag-and-drop text throws error #5754

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion packages/slate-react/src/custom-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,11 @@ declare global {
}

interface Document {
caretPositionFromPoint(x: number, y: number): CaretPosition | null
caretPositionFromPoint(
x: number,
y: number,
options?: { shadowRoots?: ShadowRoot[] }
): CaretPosition | null
}

interface Node {
Expand Down
71 changes: 63 additions & 8 deletions packages/slate-react/src/plugin/react-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ import {
NODE_TO_KEY,
NODE_TO_PARENT,
} from '../utils/weak-maps'
import {
CursorPoint,
findClosestCharacterToCursorPoint,
} from '../utils/find-closest-character-in-element-by-cursor-point'

/**
* A React and DOM-specific version of the `Editor` interface.
Expand Down Expand Up @@ -97,6 +101,12 @@ export interface ReactEditorInterface {
*/
deselect: (editor: ReactEditor) => void

findEventRangeInShadowRoot: (
shadowRoot: ShadowRoot,
cursorPoint: CursorPoint
) => DOMRange | null

createDomRangeFromCaretPoint: (cursorPoint: CaretPosition) => DOMRange
/**
* Find the DOM node that implements DocumentOrShadowRoot for the editor.
*/
Expand Down Expand Up @@ -310,6 +320,43 @@ export const ReactEditor: ReactEditorInterface = {
return el.ownerDocument
},

findEventRangeInShadowRoot: (
shadowRoot: ShadowRoot,
cursorPoint: CursorPoint
): DOMRange | null => {
const { x, y } = cursorPoint
// The third parameter in caretPositionFromPoint is not compatible with all browsers.
// https://developer.mozilla.org/en-US/docs/Web/API/Document/caretPositionFromPoint#browser_compatibility
if (document?.caretPositionFromPoint?.length === 3) {
const options = { shadowRoots: [shadowRoot] }
const position = document.caretPositionFromPoint(x, y, options)
if (position) return ReactEditor.createDomRangeFromCaretPoint(position)
}

const elementUnderCursor = shadowRoot.elementFromPoint(x, y)
const closestChar = elementUnderCursor
? findClosestCharacterToCursorPoint(elementUnderCursor, cursorPoint)
: null
if (!closestChar) return null

const { textNode, offset } = closestChar

const selection = getSelection(shadowRoot)
selection?.setBaseAndExtent(textNode, offset, textNode, offset)
const range = selection?.getRangeAt(0)
return range || null
},

createDomRangeFromCaretPoint: (cursorPoint: CaretPosition): DOMRange => {
const { offsetNode, offset } = cursorPoint

const range = document.createRange()
range.setStart(offsetNode, offset)
range.setEnd(offsetNode, offset)

return range
},

findEventRange: (editor, event) => {
if ('nativeEvent' in event) {
event = event.nativeEvent
Expand Down Expand Up @@ -349,17 +396,25 @@ export const ReactEditor: ReactEditorInterface = {
// Else resolve a range from the caret position where the drop occured.
let domRange
const { document } = ReactEditor.getWindow(editor)
const shadowRootOrDocument = ReactEditor.toDOMNode(
editor,
editor
).getRootNode()
const shadowRoot =
shadowRootOrDocument instanceof ShadowRoot ? shadowRootOrDocument : null

// COMPAT: In Firefox, `caretRangeFromPoint` doesn't exist. (2016/07/25)
if (document.caretRangeFromPoint) {
domRange = document.caretRangeFromPoint(x, y)
if (shadowRoot) {
domRange = ReactEditor.findEventRangeInShadowRoot(shadowRoot, { x, y })
} else {
const position = document.caretPositionFromPoint(x, y)
// COMPAT: In Firefox, `caretRangeFromPoint` doesn't exist. (2016/07/25)
if (document.caretRangeFromPoint) {
domRange = document.caretRangeFromPoint(x, y)
} else {
const position = document.caretPositionFromPoint(x, y)

if (position) {
domRange = document.createRange()
domRange.setStart(position.offsetNode, position.offset)
domRange.setEnd(position.offsetNode, position.offset)
if (position) {
domRange = ReactEditor.createDomRangeFromCaretPoint(position)
}
}
}

Expand Down
7 changes: 7 additions & 0 deletions packages/slate-react/src/utils/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,3 +355,10 @@ export const isAfter = (node: DOMNode, otherNode: DOMNode): boolean =>
node.compareDocumentPosition(otherNode) &
DOMNode.DOCUMENT_POSITION_FOLLOWING
)

export const findTextNodesInNode = (node: Node): Text[] => {
if (isDOMText(node)) {
return [node as Text]
}
return Array.from(node.childNodes).flatMap(findTextNodesInNode)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { findTextNodesInNode } from './dom'

export type CursorPoint = {
x: number
y: number
}

export type ClosestCharacter = {
offset: number
distance: number
textNode: Text
}

type CharDistanceFromCursorPoint = {
distanceX: number
distanceY: number
charCenterX: number
charCenterY: number
isCursorOnSameLine: boolean
}

const calculateCharDistance = (
cursorPoint: CursorPoint,
range: Range
): CharDistanceFromCursorPoint => {
const rect = range.getBoundingClientRect()
const charCenterX = (rect.left + rect.right) / 2
const charCenterY = (rect.top + rect.bottom) / 2

return {
distanceX: Math.abs(cursorPoint.x - charCenterX),
distanceY: Math.abs(cursorPoint.y - charCenterY),
charCenterX,
charCenterY,
isCursorOnSameLine:
rect.top <= cursorPoint.y && rect.bottom >= cursorPoint.y,
}
}

const findClosestCharacterInSingleTextNode = (
textNode: Text,
cursorPoint: CursorPoint
): ClosestCharacter | null => {
let closestChar: ClosestCharacter | null = null
const textLength = textNode.textContent?.length ?? 0

for (let i = 0; i < textLength; i++) {
const range = document.createRange()
range.setStart(textNode, i)
range.setEnd(textNode, i + 1)

const { distanceX, charCenterX, isCursorOnSameLine } =
calculateCharDistance(cursorPoint, range)

if (isCursorOnSameLine) {
const prevDistance: number = closestChar?.distance ?? Infinity
const newDistance: number = distanceX
const isCloser = newDistance < prevDistance

const isOnLeftSideOfCursor = cursorPoint.x < charCenterX
const offset = isOnLeftSideOfCursor ? i : i + 1

closestChar = isCloser
? { offset, distance: newDistance, textNode }
: closestChar
}
}

return closestChar
}

const findClosestCharacterForTextNodes = (
textNodes: Text[],
cursorPoint: CursorPoint
): ClosestCharacter | null => {
let closestInNode = null

for (const textNode of textNodes) {
const newResult = findClosestCharacterInSingleTextNode(
textNode,
cursorPoint
)
const prevDistance: number = closestInNode?.distance ?? Infinity
const newDistance: number = newResult?.distance ?? Infinity
const isCloser = newDistance < prevDistance

closestInNode = isCloser ? newResult : closestInNode
}

return closestInNode
}

export const findClosestCharacterToCursorPoint = (
elementUnderCursor: Element,
cursorPoint: CursorPoint
): ClosestCharacter | null => {
const textNodes = findTextNodesInNode(elementUnderCursor)
return findClosestCharacterForTextNodes(textNodes, cursorPoint)
}
22 changes: 22 additions & 0 deletions playwright/integration/examples/shadow-dom.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { test, expect } from '@playwright/test'
import { dispatchDropEvent } from '../../playwrightTestHelpers'

test.describe('shadow-dom example', () => {
test.beforeEach(
Expand Down Expand Up @@ -29,4 +30,25 @@ test.describe('shadow-dom example', () => {
// Assert that the textbox contains the correct text
await expect(textbox).toHaveText('Hello, Playwright!')
})
test('drag and drop text above the textbox', async ({ page }) => {
const outerShadow = page.locator('[data-cy="outer-shadow-root"]')
const innerShadow = outerShadow.locator('> div')

const textbox = innerShadow.getByRole('textbox')
await textbox.fill('test ')

const droppedText = 'droppedText'
const textboxEl = (await textbox.elementHandle()) as HTMLElement
const { x, y, width, height } = await textbox.boundingBox()
await dispatchDropEvent({
Copy link
Author

@Hentuloo Hentuloo Oct 28, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also tried it in a slightly different way, but unfortunately, it didn't work with Firefox.

import { test, expect } from '@playwright/test'

test.describe('shadow-dom example', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('http://localhost:3000/examples/shadow-dom')
  })

  test('drag and drop text above the textbox', async ({ page }) => {
    const outerShadow = page.locator('[data-cy="outer-shadow-root"]')
    const innerShadow = outerShadow.locator('> div')

    // Locate the textbox within the shadow DOM
    const textbox = innerShadow.getByRole('textbox')
    await textbox.fill('test1 test2')

    await page.waitForTimeout(1500)
    // Locate the span element that contains "test1 test2"
    const textSpan = innerShadow.locator('span[data-slate-string="true"]')

    // Wait for the content to be available
    await textSpan.waitFor()
    await textSpan.hover()

    await page.waitForTimeout(100)

    // Locate the text nodes for "test1" and "test2"
    const test1 = textSpan.locator('text=test1')

    const test1BoundingBox = await test1.boundingBox()
    console.log('test1BoundingBox:', test1BoundingBox)
    // Check if the bounding box was retrieved successfully
    if (test1BoundingBox) {
      // Drag "test1" and drop it after "test2"
      await page.mouse.click(
        test1BoundingBox.x,
        test1BoundingBox.y + test1BoundingBox.height / 2,
        { force: true }
      )

      await page.mouse.down()

      await page.waitForTimeout(100)

      await page.waitForTimeout(100)
      await page.mouse.move(
        test1BoundingBox.x + test1BoundingBox.width / 2,
        test1BoundingBox.y + test1BoundingBox.height / 2
      )
      await page.mouse.up()

      await page.waitForTimeout(100)

      await page.mouse.move(
        test1BoundingBox.x,
        test1BoundingBox.y + test1BoundingBox.height / 2
      )
      await page.mouse.down()

      await page.waitForTimeout(100)
      await page.mouse.move(
        test1BoundingBox.x + test1BoundingBox.width,
        test1BoundingBox.y + test1BoundingBox.height / 2
      )
      await page.mouse.up()

      await page.waitForTimeout(100)

      await expect(textbox).toHaveText('test2test1')
    }
  })
})```

page,
element: textboxEl,
droppedText: droppedText,
clientX: x + width - 5,
clientY: y + height / 2,
})

const expectedText = `test ${droppedText}`
await expect(textbox).toHaveText(expectedText)
})
})
43 changes: 43 additions & 0 deletions playwright/playwrightTestHelpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Page } from '@playwright/test'

export type DisptachDropEvent = {
page: Page
element: Element
droppedText: string
clientX: number
clientY: number
}

export const dispatchDropEvent = async ({
page,
element,
droppedText,
clientX,
clientY,
}: DisptachDropEvent) =>
page.evaluate(
({
element,
droppedText,
clientX,
clientY,
}: Omit<DisptachDropEvent, 'page'>) => {
const fragmentData = JSON.stringify([{ type: 'text', text: droppedText }])
const encodedFragment = btoa(encodeURIComponent(fragmentData))

const dataTransfer = new DataTransfer()
dataTransfer.setData(`application/x-slate-fragment`, encodedFragment)
const eventInit: DragEventInit = {
bubbles: true,
cancelable: true,
clientX,
clientY,
dataTransfer: dataTransfer,
}

const event = new DragEvent('drop', eventInit)

element.dispatchEvent(event)
},
{ element, droppedText, clientX, clientY }
)