diff --git a/.changeset/red-poems-wave.md b/.changeset/red-poems-wave.md new file mode 100644 index 0000000000..82f672dc52 --- /dev/null +++ b/.changeset/red-poems-wave.md @@ -0,0 +1,5 @@ +--- +'slate-react': minor +--- + +Fix Safari selection inside Shadow DOM. diff --git a/.yarnrc.yml b/.yarnrc.yml index ce930ec4f0..5307f8e3ab 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -3,12 +3,12 @@ compressionLevel: mixed packageExtensions: eslint-module-utils@*: dependencies: - eslint-import-resolver-node: "*" + eslint-import-resolver-node: '*' next@*: dependencies: - eslint-import-resolver-node: "*" + eslint-import-resolver-node: '*' react-error-boundary@*: dependencies: - prop-types: "*" + prop-types: '*' yarnPath: .yarn/releases/yarn-4.0.2.cjs diff --git a/packages/slate-react/src/components/editable.tsx b/packages/slate-react/src/components/editable.tsx index 69a76545e6..5b1f9b621f 100644 --- a/packages/slate-react/src/components/editable.tsx +++ b/packages/slate-react/src/components/editable.tsx @@ -34,6 +34,7 @@ import { DOMElement, DOMRange, DOMText, + getActiveElement, getDefaultView, isDOMElement, isDOMNode, @@ -50,6 +51,7 @@ import { IS_WEBKIT, IS_UC_MOBILE, IS_WECHATBROWSER, + IS_SAFARI_LEGACY, } from '../utils/environment' import Hotkeys from '../utils/hotkeys' import { @@ -156,6 +158,7 @@ export const Editable = (props: EditableProps) => { const [placeholderHeight, setPlaceholderHeight] = useState< number | undefined >() + const processing = useRef(false) const { onUserInput, receivedUserInput } = useTrackUserInput() @@ -202,6 +205,29 @@ export const Editable = (props: EditableProps) => { const onDOMSelectionChange = useMemo( () => throttle(() => { + const el = ReactEditor.toDOMNode(editor, editor) + const root = el.getRootNode() + + if ( + IS_SAFARI_LEGACY && + !processing.current && + IS_WEBKIT && + root instanceof ShadowRoot + ) { + processing.current = true + + const active = getActiveElement() + + if (active) { + document.execCommand('indent') + } else { + Transforms.deselect(editor) + } + + processing.current = false + return + } + const androidInputManager = androidInputManagerRef.current if ( (IS_ANDROID || !ReactEditor.isComposing(editor)) && @@ -471,6 +497,35 @@ export const Editable = (props: EditableProps) => { // https://github.com/facebook/react/issues/11211 const onDOMBeforeInput = useCallback( (event: InputEvent) => { + const el = ReactEditor.toDOMNode(editor, editor) + const root = el.getRootNode() + + if ( + IS_SAFARI_LEGACY && + processing?.current && + IS_WEBKIT && + root instanceof ShadowRoot + ) { + const ranges = event.getTargetRanges() + const range = ranges[0] + + const newRange = new window.Range() + + newRange.setStart(range.startContainer, range.startOffset) + newRange.setEnd(range.endContainer, range.endOffset) + + // Translate the DOM Range into a Slate Range + const slateRange = ReactEditor.toSlateRange(editor, newRange, { + exactMatch: false, + suppressThrow: false, + }) + + Transforms.select(editor, slateRange) + + event.preventDefault() + event.stopImmediatePropagation() + return + } onUserInput() if ( diff --git a/packages/slate-react/src/utils/dom.ts b/packages/slate-react/src/utils/dom.ts index 01eb55870b..ea3d898061 100644 --- a/packages/slate-react/src/utils/dom.ts +++ b/packages/slate-react/src/utils/dom.ts @@ -314,3 +314,16 @@ export const isTrackedMutation = ( // Target add/remove is tracked. Track the mutation if we track the parent mutation. return isTrackedMutation(editor, parentMutation, batch) } + +/** + * Retrieves the deepest active element in the DOM, considering nested shadow DOMs. + */ +export const getActiveElement = () => { + let activeElement = document.activeElement + + while (activeElement?.shadowRoot && activeElement.shadowRoot?.activeElement) { + activeElement = activeElement?.shadowRoot?.activeElement + } + + return activeElement +} diff --git a/packages/slate-react/src/utils/environment.ts b/packages/slate-react/src/utils/environment.ts index f5727f8e2f..e6592ca606 100644 --- a/packages/slate-react/src/utils/environment.ts +++ b/packages/slate-react/src/utils/environment.ts @@ -66,6 +66,15 @@ export const CAN_USE_DOM = !!( typeof window.document.createElement !== 'undefined' ) +// Check if the browser is Safari and older than 17 +export const IS_SAFARI_LEGACY = + typeof navigator !== 'undefined' && + /Safari/.test(navigator.userAgent) && + /Version\/(\d+)/.test(navigator.userAgent) && + (navigator.userAgent.match(/Version\/(\d+)/)?.[1] + ? parseInt(navigator.userAgent.match(/Version\/(\d+)/)?.[1]!, 10) < 17 + : false) + // COMPAT: Firefox/Edge Legacy don't support the `beforeinput` event // Chrome Legacy doesn't support `beforeinput` correctly export const HAS_BEFORE_INPUT_SUPPORT = diff --git a/playwright/integration/examples/shadow-dom.test.ts b/playwright/integration/examples/shadow-dom.test.ts index 4a30229a28..304c810bd3 100644 --- a/playwright/integration/examples/shadow-dom.test.ts +++ b/playwright/integration/examples/shadow-dom.test.ts @@ -12,4 +12,22 @@ test.describe('shadow-dom example', () => { await expect(innerShadow.getByRole('textbox')).toHaveCount(1) }) + + test('renders slate editor inside nested shadow and edits content', async ({ + page, + }) => { + const outerShadow = page.locator('[data-cy="outer-shadow-root"]') + const innerShadow = outerShadow.locator('> div') + const textbox = innerShadow.getByRole('textbox') + + // Ensure the textbox is present + await expect(textbox).toHaveCount(1) + + // Clear any existing text and type new text into the textbox + await textbox.fill('') // Clears the textbox + await textbox.type('Hello, Playwright!') + + // Assert that the textbox contains the correct text + await expect(textbox).toHaveValue('Hello, Playwright!') + }) })