From 2074bda8ff88d2cab6d42361b1eb1b1b7a520ce1 Mon Sep 17 00:00:00 2001 From: Flrande <1978616327@qq.com> Date: Fri, 3 Jan 2025 12:20:31 +0000 Subject: [PATCH] fix(editor): at menu position in split view (#9500) --- .../src/properties/title/text.ts | 2 +- .../affine/shared/src/commands/index.ts | 1 + .../commands/selection/get-selection-rects.ts | 35 +- .../shared/src/commands/selection/index.ts | 1 + .../widgets/keyboard-toolbar/config.ts | 5 +- .../root-block/widgets/linked-doc/config.ts | 1 + .../root-block/widgets/linked-doc/index.ts | 454 ++++++++---------- .../root-block/widgets/slash-menu/config.ts | 2 +- 8 files changed, 230 insertions(+), 271 deletions(-) diff --git a/blocksuite/affine/block-database/src/properties/title/text.ts b/blocksuite/affine/block-database/src/properties/title/text.ts index b3dd8804ad..080b1653b8 100644 --- a/blocksuite/affine/block-database/src/properties/title/text.ts +++ b/blocksuite/affine/block-database/src/properties/title/text.ts @@ -401,7 +401,7 @@ export class HeaderAreaTextCellEditing extends BaseTextCell { ? getViewportElement(this.topContenteditableElement.host) : null}" data-parent-flavour="affine:database" - class="data-view-header-area-rich-text can-link-doc" + class="data-view-header-area-rich-text" >`; } diff --git a/blocksuite/affine/shared/src/commands/index.ts b/blocksuite/affine/shared/src/commands/index.ts index a3dd37fa0f..5ba866847e 100644 --- a/blocksuite/affine/shared/src/commands/index.ts +++ b/blocksuite/affine/shared/src/commands/index.ts @@ -16,6 +16,7 @@ export { export { getBlockSelectionsCommand, getImageSelectionsCommand, + getRangeRects, getSelectionRectsCommand, getTextSelectionCommand, type SelectionRect, diff --git a/blocksuite/affine/shared/src/commands/selection/get-selection-rects.ts b/blocksuite/affine/shared/src/commands/selection/get-selection-rects.ts index ff9515935d..a3e5822f91 100644 --- a/blocksuite/affine/shared/src/commands/selection/get-selection-rects.ts +++ b/blocksuite/affine/shared/src/commands/selection/get-selection-rects.ts @@ -54,22 +54,8 @@ export const getSelectionRectsCommand: Command< const range = std.range.textSelectionToRange(textSelection); if (range) { - const nativeRects = Array.from(range.getClientRects()); - const rectsWithoutFiltered = nativeRects - .map(rect => ({ - width: rect.right - rect.left, - height: rect.bottom - rect.top, - top: - rect.top - (containerRect?.top ?? 0) + (container?.scrollTop ?? 0), - left: - rect.left - - (containerRect?.left ?? 0) + - (container?.scrollLeft ?? 0), - })) - .filter(rect => rect.width > 0 && rect.height > 0); - return next({ - selectionRects: filterCoveringRects(rectsWithoutFiltered), + selectionRects: getRangeRects(range, container), }); } } else if (blockSelections && blockSelections.length > 0) { @@ -198,3 +184,22 @@ export function filterCoveringRects(rects: SelectionRect[]): SelectionRect[] { return mergedRects; } + +export function getRangeRects( + range: Range, + container: HTMLElement | null +): SelectionRect[] { + const nativeRects = Array.from(range.getClientRects()); + const containerRect = container?.getBoundingClientRect(); + const rectsWithoutFiltered = nativeRects + .map(rect => ({ + width: rect.right - rect.left, + height: rect.bottom - rect.top, + top: rect.top - (containerRect?.top ?? 0) + (container?.scrollTop ?? 0), + left: + rect.left - (containerRect?.left ?? 0) + (container?.scrollLeft ?? 0), + })) + .filter(rect => rect.width > 0 && rect.height > 0); + + return filterCoveringRects(rectsWithoutFiltered); +} diff --git a/blocksuite/affine/shared/src/commands/selection/index.ts b/blocksuite/affine/shared/src/commands/selection/index.ts index 308e683ba5..bb3fd72659 100644 --- a/blocksuite/affine/shared/src/commands/selection/index.ts +++ b/blocksuite/affine/shared/src/commands/selection/index.ts @@ -1,6 +1,7 @@ export { getBlockSelectionsCommand } from './get-block-selections.js'; export { getImageSelectionsCommand } from './get-image-selections.js'; export { + getRangeRects, getSelectionRectsCommand, type SelectionRect, } from './get-selection-rects.js'; diff --git a/blocksuite/blocks/src/root-block/widgets/keyboard-toolbar/config.ts b/blocksuite/blocks/src/root-block/widgets/keyboard-toolbar/config.ts index 085fa55400..e927f0b0d3 100644 --- a/blocksuite/blocks/src/root-block/widgets/keyboard-toolbar/config.ts +++ b/blocksuite/blocks/src/root-block/widgets/keyboard-toolbar/config.ts @@ -324,7 +324,10 @@ const pageToolGroup: KeyboardToolPanelGroup = { const inlineEditor = getInlineEditorByModel(std.host, currentModel); // Wait for range to be updated inlineEditor?.slots.inlineRangeSync.once(() => { - linkedDocWidget.show('mobile'); + linkedDocWidget.show({ + mode: 'mobile', + addTriggerKey: true, + }); closeToolPanel(); }); }) diff --git a/blocksuite/blocks/src/root-block/widgets/linked-doc/config.ts b/blocksuite/blocks/src/root-block/widgets/linked-doc/config.ts index 06e226ab7f..62cec4c734 100644 --- a/blocksuite/blocks/src/root-block/widgets/linked-doc/config.ts +++ b/blocksuite/blocks/src/root-block/widgets/linked-doc/config.ts @@ -36,6 +36,7 @@ export interface LinkedWidgetConfig { */ convertTriggerKey: boolean; ignoreBlockTypes: (keyof BlockSuite.BlockModels)[]; + ignoreSelector: string; getMenus: ( query: string, abort: () => void, diff --git a/blocksuite/blocks/src/root-block/widgets/linked-doc/index.ts b/blocksuite/blocks/src/root-block/widgets/linked-doc/index.ts index 2d0bb508e0..7740174c0c 100644 --- a/blocksuite/blocks/src/root-block/widgets/linked-doc/index.ts +++ b/blocksuite/blocks/src/root-block/widgets/linked-doc/index.ts @@ -1,16 +1,17 @@ -import type { AffineInlineEditor } from '@blocksuite/affine-components/rich-text'; -import { getInlineEditorByModel } from '@blocksuite/affine-components/rich-text'; import type { RootBlockModel } from '@blocksuite/affine-model'; -import type { SelectionRect } from '@blocksuite/affine-shared/commands'; import { - getViewportElement, - matchFlavours, -} from '@blocksuite/affine-shared/utils'; -import type { UIEventStateContext } from '@blocksuite/block-std'; -import { WidgetComponent } from '@blocksuite/block-std'; + getRangeRects, + type SelectionRect, +} from '@blocksuite/affine-shared/commands'; +import { getViewportElement } from '@blocksuite/affine-shared/utils'; +import type { BlockComponent } from '@blocksuite/block-std'; +import { BLOCK_ID_ATTR, WidgetComponent } from '@blocksuite/block-std'; import { IS_MOBILE } from '@blocksuite/global/env'; -import type { Disposable } from '@blocksuite/global/utils'; -import { InlineEditor, type InlineRange } from '@blocksuite/inline'; +import { + INLINE_ROOT_ATTR, + type InlineEditor, + type InlineRootElement, +} from '@blocksuite/inline'; import { signal } from '@preact/signals-core'; import { html, nothing } from 'lit'; import { state } from 'lit/decorators.js'; @@ -35,122 +36,6 @@ export class AffineLinkedDocWidget extends WidgetComponent< > { static override styles = linkedDocWidgetStyles; - private _disposeObserveInputRects: Disposable | null = null; - - private readonly _getInlineEditor = ( - evt?: KeyboardEvent | CompositionEvent - ) => { - if (evt && evt.target instanceof HTMLElement) { - const editor = ( - evt.target.closest('.can-link-doc > .inline-editor') as { - inlineEditor?: AffineInlineEditor; - } - )?.inlineEditor; - if (editor instanceof InlineEditor) { - return editor; - } - } - - const text = this.host.selection.value.find(selection => - selection.is('text') - ); - if (!text) return null; - - const model = this.host.doc.getBlockById(text.blockId); - if (!model) return null; - - if (matchFlavours(model, this.config.ignoreBlockTypes)) { - return null; - } - - return getInlineEditorByModel(this.host, model); - }; - - private _inlineEditor: AffineInlineEditor | null = null; - - private readonly _observeInputRects = () => { - if (!this._inlineEditor) return; - - const updateInputRects = () => { - const blockId = - this.std.command.exec('getSelectedModels').selectedModels?.[0]?.id; - if (!blockId) return; - - if (!this._startRange) return; - const index = this._startRange.index - this._triggerKey.length; - if (index < 0) return; - - const currentRange = this._inlineEditor?.getInlineRange(); - if (!currentRange) return; - const length = currentRange.index + currentRange.length - index; - - const textSelection = this.std.selection.create('text', { - from: { blockId, index, length }, - to: null, - }); - - const { selectionRects } = this.std.command.exec('getSelectionRects', { - textSelection, - }); - - if (!selectionRects) return; - - this._inputRects = selectionRects; - }; - - updateInputRects(); - this._disposeObserveInputRects = - this._inlineEditor.slots.renderComplete.on(updateInputRects); - }; - - private readonly _onCompositionEnd = (ctx: UIEventStateContext) => { - const event = ctx.get('defaultState').event as CompositionEvent; - - const key = event.data; - - if ( - !key || - !this.config.triggerKeys.some(triggerKey => triggerKey.includes(key)) - ) - return; - - this._inlineEditor = this._getInlineEditor(event); - if (!this._inlineEditor) return; - - this._handleInput(true); - }; - - private readonly _onKeyDown = (ctx: UIEventStateContext) => { - const eventState = ctx.get('keyboardState'); - const event = eventState.raw; - - const key = event.key; - if ( - key === undefined || // in mac os, the key may be undefined - key === 'Process' || - event.isComposing - ) - return; - - if (!this.config.triggerKeys.some(triggerKey => triggerKey.includes(key))) - return; - - this._inlineEditor = this._getInlineEditor(event); - if (!this._inlineEditor) return; - - const inlineRange = this._inlineEditor.getInlineRange(); - if (!inlineRange) return; - - if (inlineRange.length > 0) { - // When select text and press `[[` should not trigger transform, - // since it will break the bracket complete. - // Expected `[[selected text]]` instead of `@selected text]]` - return; - } - - this._handleInput(false); - }; - private readonly _renderLinkedDocMenu = () => { if (!this.block.rootComponent) return nothing; @@ -166,123 +51,8 @@ export class AffineLinkedDocWidget extends WidgetComponent< >`; }; - private readonly _show$ = signal<'desktop' | 'mobile' | 'none'>('none'); - - private _startRange: InlineRange | null = null; - - close = () => { - this._disposeObserveInputRects?.dispose(); - this._disposeObserveInputRects = null; - this._inlineEditor = null; - this._triggerKey = ''; - this._show$.value = 'none'; - this._startRange = null; - }; - - show = (mode: 'desktop' | 'mobile' = 'desktop') => { - if (this._inlineEditor === null) { - this._inlineEditor = this._getInlineEditor(); - } - if (this._triggerKey === '') { - this._triggerKey = this.config.triggerKeys[0]; - } - - this._startRange = this._inlineEditor?.getInlineRange() ?? null; - - const enableMobile = this.doc.awarenessStore.getFlag( - 'enable_mobile_linked_doc_menu' - ); - - this._observeInputRects(); - - this._show$.value = enableMobile ? mode : 'desktop'; - }; - - private get _context(): LinkedDocContext { - return { - std: this.std, - inlineEditor: this._inlineEditor!, - startRange: this._startRange!, - triggerKey: this._triggerKey, - config: this.config, - close: this.close, - }; - } - - get config(): LinkedWidgetConfig { - return { - triggerKeys: ['@', '[[', '【【'], - ignoreBlockTypes: ['affine:code'], - convertTriggerKey: true, - getMenus, - mobile: { - useScreenHeight: false, - scrollContainer: getViewportElement(this.std.host) ?? window, - scrollTopOffset: 46, - }, - ...this.std.getConfig('affine:page')?.linkedWidget, - }; - } - - private _handleInput(isCompositionEnd: boolean) { - const primaryTriggerKey = this.config.triggerKeys[0]; - - const inlineEditor = this._inlineEditor; - if (!inlineEditor) return; - - const inlineRangeApplyCallback = (callback: () => void) => { - // the inline ranged updated in compositionEnd event before this event callback - if (isCompositionEnd) callback(); - else inlineEditor.slots.inlineRangeSync.once(callback); - }; - - inlineRangeApplyCallback(() => { - const inlineRange = inlineEditor.getInlineRange(); - if (!inlineRange) return; - const textPoint = inlineEditor.getTextPoint(inlineRange.index); - if (!textPoint) return; - const [leafStart, offsetStart] = textPoint; - - const text = leafStart.textContent - ? leafStart.textContent.slice(0, offsetStart) - : ''; - - const matchedKey = this.config.triggerKeys.find(triggerKey => - text.endsWith(triggerKey) - ); - if (!matchedKey) return; - - if (this.config.convertTriggerKey && primaryTriggerKey !== matchedKey) { - const inlineRange = inlineEditor.getInlineRange(); - if (!inlineRange) return; - - // Convert to the primary trigger key - // e.g. [[ -> @ - this._triggerKey = primaryTriggerKey; - const startIdxBeforeMatchKey = inlineRange.index - matchedKey.length; - inlineEditor.deleteText({ - index: startIdxBeforeMatchKey, - length: matchedKey.length, - }); - inlineEditor.insertText( - { index: startIdxBeforeMatchKey, length: 0 }, - primaryTriggerKey - ); - inlineEditor.setInlineRange({ - index: startIdxBeforeMatchKey + primaryTriggerKey.length, - length: 0, - }); - inlineEditor.slots.inlineRangeSync.once(() => { - this.show(IS_MOBILE ? 'mobile' : 'desktop'); - }); - return; - } else { - this._triggerKey = matchedKey; - this.show(IS_MOBILE ? 'mobile' : 'desktop'); - } - }); - } - + @state() + private accessor _inputRects: SelectionRect[] = []; private _renderInputMask() { return html`${repeat( this._inputRects, @@ -302,20 +72,204 @@ export class AffineLinkedDocWidget extends WidgetComponent< )}`; } + private readonly _mode$ = signal<'desktop' | 'mobile' | 'none'>('none'); + + get config(): LinkedWidgetConfig { + return { + triggerKeys: ['@', '[[', '【【'], + ignoreBlockTypes: ['affine:code'], + ignoreSelector: + 'edgeless-text-editor, edgeless-shape-text-editor, edgeless-group-title-editor, edgeless-frame-title-editor, edgeless-connector-label-editor', + convertTriggerKey: true, + getMenus, + mobile: { + useScreenHeight: false, + scrollContainer: getViewportElement(this.std.host) ?? window, + scrollTopOffset: 46, + }, + ...this.std.getConfig('affine:page')?.linkedWidget, + }; + } + + private _context: LinkedDocContext | null = null; override connectedCallback() { super.connectedCallback(); - this.handleEvent('keyDown', this._onKeyDown); - this.handleEvent('compositionEnd', this._onCompositionEnd); + + this.handleEvent('beforeInput', ctx => { + if (this._mode$.peek() !== 'none') return; + + const event = ctx.get('defaultState').event; + if (!(event instanceof InputEvent)) return; + + if (event.data === null) return; + + const host = this.std.host; + + const range = host.range.value; + if (!range || !range.collapsed) return; + + const containerElement = + range.commonAncestorContainer instanceof Element + ? range.commonAncestorContainer + : range.commonAncestorContainer.parentElement; + if (!containerElement) return; + + if (containerElement.closest(this.config.ignoreSelector)) return; + + const block = containerElement.closest( + `[${BLOCK_ID_ATTR}]` + ); + if ( + !block || + this.config.ignoreBlockTypes.includes( + block.flavour as keyof BlockSuite.BlockModels + ) + ) + return; + + const inlineRoot = containerElement.closest( + `[${INLINE_ROOT_ATTR}]` + ); + if (!inlineRoot) return; + + const inlineEditor = inlineRoot.inlineEditor; + const inlineRange = inlineEditor.getInlineRange(); + if (!inlineRange) return; + + const triggerKeys = this.config.triggerKeys; + const primaryTriggerKey = triggerKeys[0]; + const convertTriggerKey = this.config.convertTriggerKey; + if (primaryTriggerKey.length > inlineRange.index) return; + const matchedText = inlineEditor.yTextString.slice( + inlineRange.index - primaryTriggerKey.length, + inlineRange.index + ); + + let converted = false; + if (matchedText !== primaryTriggerKey && convertTriggerKey) { + for (const key of triggerKeys.slice(1)) { + if (key.length > inlineRange.index) continue; + const matchedText = inlineEditor.yTextString.slice( + inlineRange.index - key.length, + inlineRange.index + ); + if (matchedText === key) { + const startIdxBeforeMatchKey = inlineRange.index - key.length; + inlineEditor.deleteText({ + index: startIdxBeforeMatchKey, + length: key.length, + }); + inlineEditor.insertText( + { index: startIdxBeforeMatchKey, length: 0 }, + primaryTriggerKey + ); + inlineEditor.setInlineRange({ + index: startIdxBeforeMatchKey + primaryTriggerKey.length, + length: 0, + }); + converted = true; + break; + } + } + } + + if (matchedText !== primaryTriggerKey && !converted) return; + + inlineEditor + .waitForUpdate() + .then(() => { + this.show({ + inlineEditor, + primaryTriggerKey, + mode: IS_MOBILE ? 'mobile' : 'desktop', + }); + }) + .catch(console.error); + }); + } + + show(props?: { + inlineEditor?: InlineEditor; + primaryTriggerKey?: string; + mode?: 'desktop' | 'mobile'; + addTriggerKey?: boolean; + }) { + const host = this.host; + const { + primaryTriggerKey = '@', + mode = 'desktop', + addTriggerKey = false, + } = props ?? {}; + let inlineEditor: InlineEditor; + if (!props?.inlineEditor) { + const range = host.range.value; + if (!range || !range.collapsed) return; + const containerElement = + range.commonAncestorContainer instanceof Element + ? range.commonAncestorContainer + : range.commonAncestorContainer.parentElement; + if (!containerElement) return; + const inlineRoot = containerElement.closest( + `[${INLINE_ROOT_ATTR}]` + ); + if (!inlineRoot) return; + inlineEditor = inlineRoot.inlineEditor; + } else { + inlineEditor = props.inlineEditor; + } + + const inlineRange = inlineEditor.getInlineRange(); + if (!inlineRange) return; + + if (addTriggerKey) { + inlineEditor.insertText( + { index: inlineRange.index, length: 0 }, + primaryTriggerKey + ); + inlineEditor.setInlineRange({ + index: inlineRange.index + primaryTriggerKey.length, + length: 0, + }); + } + + const disposable = inlineEditor.slots.renderComplete.on(() => { + const currentInlineRange = inlineEditor.getInlineRange(); + if (!currentInlineRange) return; + const range = inlineEditor.toDomRange({ + index: inlineRange.index, + length: currentInlineRange.index - inlineRange.index, + }); + if (!range) return; + this._inputRects = getRangeRects(range, getViewportElement(host)); + }); + this._context = { + std: this.std, + inlineEditor, + startRange: inlineRange, + triggerKey: primaryTriggerKey, + config: this.config, + close: () => { + disposable.dispose(); + this._inputRects = []; + this._mode$.value = 'none'; + this._context = null; + }, + }; + + const enableMobile = this.doc.awarenessStore.getFlag( + 'enable_mobile_linked_doc_menu' + ); + this._mode$.value = enableMobile ? mode : 'desktop'; } override render() { - if (this._show$.value === 'none') return nothing; + if (this._mode$.value === 'none') return nothing; return html`${this._renderInputMask()} `; } - - @state() - private accessor _inputRects: SelectionRect[] = []; - - @state() - private accessor _triggerKey = ''; } declare global { diff --git a/blocksuite/blocks/src/root-block/widgets/slash-menu/config.ts b/blocksuite/blocks/src/root-block/widgets/slash-menu/config.ts index f4b99af2d2..929b343d01 100644 --- a/blocksuite/blocks/src/root-block/widgets/slash-menu/config.ts +++ b/blocksuite/blocks/src/root-block/widgets/slash-menu/config.ts @@ -236,7 +236,7 @@ export const defaultSlashMenuConfig: SlashMenuConfig = { const inlineEditor = getInlineEditorByModel(rootComponent.host, model); // Wait for range to be updated inlineEditor?.slots.inlineRangeSync.once(() => { - linkedDocWidget.show(); + linkedDocWidget.show({ addTriggerKey: true }); }); }, },