fix(editor): at menu position in split view (#9500)

This commit is contained in:
Flrande
2025-01-03 12:20:31 +00:00
parent b17dac2201
commit 2074bda8ff
8 changed files with 230 additions and 271 deletions

View File

@@ -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();
});
})

View File

@@ -36,6 +36,7 @@ export interface LinkedWidgetConfig {
*/
convertTriggerKey: boolean;
ignoreBlockTypes: (keyof BlockSuite.BlockModels)[];
ignoreSelector: string;
getMenus: (
query: string,
abort: () => void,

View File

@@ -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<
></affine-linked-doc-popover>`;
};
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<BlockComponent>(
`[${BLOCK_ID_ATTR}]`
);
if (
!block ||
this.config.ignoreBlockTypes.includes(
block.flavour as keyof BlockSuite.BlockModels
)
)
return;
const inlineRoot = containerElement.closest<InlineRootElement>(
`[${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<InlineRootElement>(
`[${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()}
<blocksuite-portal
.shadowDom=${false}
.template=${choose(
this._show$.value,
this._mode$.value,
[
['desktop', this._renderLinkedDocPopover],
['mobile', this._renderLinkedDocMenu],
@@ -324,12 +278,6 @@ export class AffineLinkedDocWidget extends WidgetComponent<
)}
></blocksuite-portal>`;
}
@state()
private accessor _inputRects: SelectionRect[] = [];
@state()
private accessor _triggerKey = '';
}
declare global {

View File

@@ -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 });
});
},
},