mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-19 02:56:23 +08:00
fix: firefox input (#14315)
fix #14296 fix #14289 #### PR Dependency Tree * **PR #14315** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Improved inline editor stability for selection edge cases and beforeinput handling, with better recovery and native-input protection. * Fixed potential crashes when deleting with selections outside the editor bounds, including Firefox-specific scenarios. * **Tests** * Added unit tests covering beforeinput behavior and added Firefox end-to-end regression tests. * **Chores** * Reduced CI test parallelism to streamline pipeline. <sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { IS_ANDROID } from '@blocksuite/global/env';
|
||||
import type { BaseTextAttributes } from '@blocksuite/store';
|
||||
|
||||
import { INLINE_ROOT_ATTR } from '../consts.js';
|
||||
import type { InlineEditor } from '../inline-editor.js';
|
||||
import type { InlineRange } from '../types.js';
|
||||
import {
|
||||
@@ -17,50 +18,121 @@ export class EventService<TextAttributes extends BaseTextAttributes> {
|
||||
|
||||
private _isComposing = false;
|
||||
|
||||
private readonly _getClosestInlineRoot = (node: Node): Element | null => {
|
||||
const el = node instanceof Element ? node : node.parentElement;
|
||||
return el?.closest(`[${INLINE_ROOT_ATTR}]`) ?? null;
|
||||
};
|
||||
|
||||
private readonly _isRangeCompletelyInRoot = (range: Range) => {
|
||||
if (range.commonAncestorContainer.ownerDocument !== document) return false;
|
||||
|
||||
const rootElement = this.editor.rootElement;
|
||||
if (!rootElement) return false;
|
||||
|
||||
const rootRange = document.createRange();
|
||||
rootRange.selectNode(rootElement);
|
||||
|
||||
if (
|
||||
range.startContainer.compareDocumentPosition(range.endContainer) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING
|
||||
) {
|
||||
return (
|
||||
rootRange.comparePoint(range.startContainer, range.startOffset) >= 0 &&
|
||||
rootRange.comparePoint(range.endContainer, range.endOffset) <= 0
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
rootRange.comparePoint(range.endContainer, range.startOffset) >= 0 &&
|
||||
rootRange.comparePoint(range.startContainer, range.endOffset) <= 0
|
||||
);
|
||||
}
|
||||
// Avoid `Range.comparePoint` here — Firefox/Chrome have subtle differences
|
||||
// around selection points in `contenteditable` and comment marker nodes.
|
||||
const containsStart =
|
||||
range.startContainer === rootElement ||
|
||||
rootElement.contains(range.startContainer);
|
||||
const containsEnd =
|
||||
range.endContainer === rootElement ||
|
||||
rootElement.contains(range.endContainer);
|
||||
return containsStart && containsEnd;
|
||||
};
|
||||
|
||||
private readonly _onBeforeInput = async (event: InputEvent) => {
|
||||
const range = this.editor.rangeService.getNativeRange();
|
||||
if (
|
||||
this.editor.isReadonly ||
|
||||
!range ||
|
||||
!this._isRangeCompletelyInRoot(range)
|
||||
)
|
||||
return;
|
||||
if (this.editor.isReadonly || !range) return;
|
||||
const rootElement = this.editor.rootElement;
|
||||
if (!rootElement) return;
|
||||
|
||||
let inlineRange = this.editor.toInlineRange(range);
|
||||
if (!inlineRange) return;
|
||||
const startInRoot =
|
||||
range.startContainer === rootElement ||
|
||||
rootElement.contains(range.startContainer);
|
||||
const endInRoot =
|
||||
range.endContainer === rootElement ||
|
||||
rootElement.contains(range.endContainer);
|
||||
|
||||
// Not this inline editor.
|
||||
if (!startInRoot && !endInRoot) return;
|
||||
|
||||
// If selection spans into another inline editor, let the range binding handle it.
|
||||
if (startInRoot !== endInRoot) {
|
||||
const otherNode = startInRoot ? range.endContainer : range.startContainer;
|
||||
const otherRoot = this._getClosestInlineRoot(otherNode);
|
||||
if (otherRoot && otherRoot !== rootElement) return;
|
||||
}
|
||||
|
||||
if (this._isComposing) {
|
||||
if (IS_ANDROID && event.inputType === 'insertCompositionText') {
|
||||
this._compositionInlineRange = inlineRange;
|
||||
const compositionInlineRange = this.editor.toInlineRange(range);
|
||||
if (compositionInlineRange) {
|
||||
this._compositionInlineRange = compositionInlineRange;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Always prevent native DOM mutations inside inline editor. Browsers (notably
|
||||
// Firefox) may remove Lit marker comment nodes during native edits, which
|
||||
// will crash subsequent Lit updates with `ChildPart has no parentNode`.
|
||||
event.preventDefault();
|
||||
|
||||
let inlineRange = this.editor.toInlineRange(range);
|
||||
if (!inlineRange) {
|
||||
// Some browsers may report selection points on non-text nodes inside
|
||||
// `contenteditable`. Prefer the target range if available.
|
||||
try {
|
||||
const targetRanges = event.getTargetRanges();
|
||||
if (targetRanges.length > 0) {
|
||||
const staticRange = targetRanges[0];
|
||||
const targetRange = document.createRange();
|
||||
targetRange.setStart(
|
||||
staticRange.startContainer,
|
||||
staticRange.startOffset
|
||||
);
|
||||
targetRange.setEnd(staticRange.endContainer, staticRange.endOffset);
|
||||
inlineRange = this.editor.toInlineRange(targetRange);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
if (!inlineRange && startInRoot !== endInRoot) {
|
||||
// Clamp a partially-outside selection to this editor so native editing
|
||||
// won't touch Lit marker nodes.
|
||||
const pointRange = document.createRange();
|
||||
if (startInRoot) {
|
||||
pointRange.setStart(range.startContainer, range.startOffset);
|
||||
pointRange.setEnd(range.startContainer, range.startOffset);
|
||||
const startPoint = this.editor.toInlineRange(pointRange);
|
||||
if (startPoint) {
|
||||
inlineRange = {
|
||||
index: startPoint.index,
|
||||
length: this.editor.yTextLength - startPoint.index,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
pointRange.setStart(range.endContainer, range.endOffset);
|
||||
pointRange.setEnd(range.endContainer, range.endOffset);
|
||||
const endPoint = this.editor.toInlineRange(pointRange);
|
||||
if (endPoint) {
|
||||
inlineRange = {
|
||||
index: 0,
|
||||
length: endPoint.index,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!inlineRange) {
|
||||
// Try to recover from an unexpected DOM/selection state by rebuilding the
|
||||
// editor DOM and retrying the range conversion.
|
||||
this.editor.rerenderWholeEditor();
|
||||
await this.editor.waitForUpdate();
|
||||
const newRange = this.editor.rangeService.getNativeRange();
|
||||
inlineRange = newRange ? this.editor.toInlineRange(newRange) : null;
|
||||
if (!inlineRange) return;
|
||||
}
|
||||
|
||||
let ifHandleTargetRange = true;
|
||||
|
||||
if (
|
||||
@@ -88,15 +160,17 @@ export class EventService<TextAttributes extends BaseTextAttributes> {
|
||||
range.setEnd(staticRange.endContainer, staticRange.endOffset);
|
||||
const targetInlineRange = this.editor.toInlineRange(range);
|
||||
|
||||
if (!isMaybeInlineRangeEqual(inlineRange, targetInlineRange)) {
|
||||
// Ignore an un-resolvable target range to avoid swallowing the input.
|
||||
if (
|
||||
targetInlineRange &&
|
||||
!isMaybeInlineRangeEqual(inlineRange, targetInlineRange)
|
||||
) {
|
||||
inlineRange = targetInlineRange;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!inlineRange) return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
if (IS_ANDROID) {
|
||||
this.editor.rerenderWholeEditor();
|
||||
await this.editor.waitForUpdate();
|
||||
|
||||
Reference in New Issue
Block a user