mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 21:05:19 +00:00
This PR performs a significant architectural refactoring by extracting rich text functionality into a dedicated package. Here are the key changes: 1. **New Package Creation** - Created a new package `@blocksuite/affine-rich-text` to house rich text related functionality - Moved rich text components, utilities, and types from `@blocksuite/affine-components` to this new package 2. **Dependency Updates** - Updated multiple block packages to include the new `@blocksuite/affine-rich-text` as a direct dependency: - block-callout - block-code - block-database - block-edgeless-text - block-embed - block-list - block-note - block-paragraph 3. **Import Path Updates** - Refactored all imports that previously referenced rich text functionality from `@blocksuite/affine-components/rich-text` to now use `@blocksuite/affine-rich-text` - Updated imports for components like: - DefaultInlineManagerExtension - RichText types and interfaces - Text manipulation utilities (focusTextModel, textKeymap, etc.) - Reference node components and providers 4. **Build Configuration Updates** - Added references to the new rich text package in the `tsconfig.json` files of all affected packages - Maintained workspace dependencies using the `workspace:*` version specifier The primary motivation appears to be: 1. Better separation of concerns by isolating rich text functionality 2. Improved maintainability through more modular package structure 3. Clearer dependencies between packages 4. Potential for better tree-shaking and bundle optimization This is primarily an architectural improvement that should make the codebase more maintainable and better organized.
398 lines
12 KiB
TypeScript
398 lines
12 KiB
TypeScript
import { LoadingIcon } from '@blocksuite/affine-block-image';
|
|
import type { IconButton } from '@blocksuite/affine-components/icon-button';
|
|
import {
|
|
cleanSpecifiedTail,
|
|
getTextContentFromInlineRange,
|
|
} from '@blocksuite/affine-rich-text';
|
|
import { unsafeCSSVar } from '@blocksuite/affine-shared/theme';
|
|
import {
|
|
createKeydownObserver,
|
|
getCurrentNativeRange,
|
|
getPopperPosition,
|
|
getViewportElement,
|
|
} from '@blocksuite/affine-shared/utils';
|
|
import { PropTypes, requiredProperties } from '@blocksuite/block-std';
|
|
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
|
|
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
|
import { MoreHorizontalIcon } from '@blocksuite/icons/lit';
|
|
import { effect } from '@preact/signals-core';
|
|
import { css, html, LitElement, nothing } from 'lit';
|
|
import { property, query, queryAll, state } from 'lit/decorators.js';
|
|
import { styleMap } from 'lit/directives/style-map.js';
|
|
import throttle from 'lodash-es/throttle';
|
|
|
|
import type { LinkedDocContext, LinkedMenuGroup } from './config.js';
|
|
import { linkedDocPopoverStyles } from './styles.js';
|
|
import { resolveSignal } from './utils.js';
|
|
|
|
@requiredProperties({
|
|
context: PropTypes.object,
|
|
})
|
|
export class LinkedDocPopover extends SignalWatcher(
|
|
WithDisposable(LitElement)
|
|
) {
|
|
static override styles = linkedDocPopoverStyles;
|
|
|
|
private readonly _abort = () => {
|
|
// remove popover dom
|
|
this.context.close();
|
|
// clear input query
|
|
cleanSpecifiedTail(
|
|
this.context.std.host,
|
|
this.context.inlineEditor,
|
|
this.context.triggerKey + (this._query || '')
|
|
);
|
|
};
|
|
|
|
private readonly _expanded = new Map<string, boolean>();
|
|
|
|
private _menusItemsEffectCleanup: () => void = () => {};
|
|
|
|
private readonly _updateLinkedDocGroup = async () => {
|
|
const query = this._query;
|
|
if (this._updateLinkedDocGroupAbortController) {
|
|
this._updateLinkedDocGroupAbortController.abort();
|
|
}
|
|
this._updateLinkedDocGroupAbortController = new AbortController();
|
|
|
|
if (query === null) {
|
|
this.context.close();
|
|
return;
|
|
}
|
|
this._linkedDocGroup = await this.context.config.getMenus(
|
|
query,
|
|
this._abort,
|
|
this.context.std.host,
|
|
this.context.inlineEditor,
|
|
this._updateLinkedDocGroupAbortController.signal
|
|
);
|
|
|
|
this._menusItemsEffectCleanup();
|
|
|
|
// need to rebind the effect because this._linkedDocGroup has changed.
|
|
this._menusItemsEffectCleanup = effect(() => {
|
|
this._updateAutoFocusedItem();
|
|
|
|
// wait for the next tick to ensure the items are rendered to DOM
|
|
setTimeout(() => {
|
|
this.scrollToFocusedItem();
|
|
});
|
|
});
|
|
};
|
|
|
|
private readonly _updateAutoFocusedItem = () => {
|
|
// Get the auto-focused item key from the config
|
|
const autoFocusedItemKey = this.context.config.autoFocusedItemKey?.(
|
|
this._linkedDocGroup,
|
|
this._query || '',
|
|
this._activatedItemKey,
|
|
this.context.std.host,
|
|
this.context.inlineEditor
|
|
);
|
|
|
|
if (autoFocusedItemKey) {
|
|
this._activatedItemKey = autoFocusedItemKey;
|
|
return;
|
|
}
|
|
|
|
// If no auto-focused item key is returned from the config and no item is currently focused,
|
|
// focus the first item in the flattened action list
|
|
if (!this._activatedItemKey && this._flattenActionList.length > 0) {
|
|
this._activatedItemKey = this._flattenActionList[0].key;
|
|
}
|
|
};
|
|
|
|
private _updateLinkedDocGroupAbortController: AbortController | null = null;
|
|
|
|
private get _actionGroup() {
|
|
return this._linkedDocGroup.map(group => {
|
|
return {
|
|
...group,
|
|
items: this._getActionItems(group),
|
|
};
|
|
});
|
|
}
|
|
|
|
private get _flattenActionList() {
|
|
return this._actionGroup
|
|
.map(group =>
|
|
group.items.map(item => ({ ...item, groupName: group.name }))
|
|
)
|
|
.flat();
|
|
}
|
|
|
|
private get _query() {
|
|
return getTextContentFromInlineRange(
|
|
this.context.inlineEditor,
|
|
this.context.startRange
|
|
);
|
|
}
|
|
|
|
private _getActionItems(group: LinkedMenuGroup) {
|
|
const isExpanded = !!this._expanded.get(group.name);
|
|
let items = resolveSignal(group.items);
|
|
|
|
const isOverflow = !!group.maxDisplay && items.length > group.maxDisplay;
|
|
|
|
items = isExpanded ? items : items.slice(0, group.maxDisplay);
|
|
|
|
if (isOverflow && !isExpanded && group.maxDisplay) {
|
|
items = items.concat({
|
|
key: `${group.name} More`,
|
|
name: resolveSignal(group.overflowText) || 'more',
|
|
icon: MoreHorizontalIcon({ width: '24px', height: '24px' }),
|
|
action: () => {
|
|
this._expanded.set(group.name, true);
|
|
this.requestUpdate();
|
|
},
|
|
});
|
|
}
|
|
|
|
return items;
|
|
}
|
|
|
|
private _isTextOverflowing(element: HTMLElement) {
|
|
return element.scrollWidth > element.clientWidth;
|
|
}
|
|
|
|
override connectedCallback() {
|
|
super.connectedCallback();
|
|
|
|
// init
|
|
this._updateLinkedDocGroup().catch(console.error);
|
|
this._disposables.addFromEvent(this, 'mousedown', e => {
|
|
// Prevent input from losing focus
|
|
e.preventDefault();
|
|
});
|
|
this._disposables.addFromEvent(window, 'mousedown', e => {
|
|
if (e.target === this) return;
|
|
// We don't clear the query when clicking outside the popover
|
|
this.context.close();
|
|
});
|
|
|
|
const keydownObserverAbortController = new AbortController();
|
|
this._disposables.add(() => keydownObserverAbortController.abort());
|
|
|
|
const { eventSource } = this.context.inlineEditor;
|
|
if (!eventSource) return;
|
|
|
|
createKeydownObserver({
|
|
target: eventSource,
|
|
signal: keydownObserverAbortController.signal,
|
|
onInput: isComposition => {
|
|
if (isComposition) {
|
|
this._updateLinkedDocGroup().catch(console.error);
|
|
} else {
|
|
this.context.inlineEditor.slots.renderComplete.once(
|
|
this._updateLinkedDocGroup
|
|
);
|
|
}
|
|
},
|
|
onPaste: () => {
|
|
setTimeout(() => {
|
|
this._updateLinkedDocGroup().catch(console.error);
|
|
}, 50);
|
|
},
|
|
onDelete: () => {
|
|
const curRange = this.context.inlineEditor.getInlineRange();
|
|
if (!this.context.startRange || !curRange) {
|
|
return;
|
|
}
|
|
if (curRange.index < this.context.startRange.index) {
|
|
this.context.close();
|
|
}
|
|
this.context.inlineEditor.slots.renderComplete.once(
|
|
this._updateLinkedDocGroup
|
|
);
|
|
},
|
|
onMove: step => {
|
|
const itemLen = this._flattenActionList.length;
|
|
const nextIndex = (itemLen + this._activatedItemIndex + step) % itemLen;
|
|
const item = this._flattenActionList[nextIndex];
|
|
if (item) {
|
|
this._activatedItemKey = item.key;
|
|
}
|
|
this.scrollToFocusedItem();
|
|
},
|
|
onConfirm: () => {
|
|
this._flattenActionList[this._activatedItemIndex]
|
|
.action()
|
|
?.catch(console.error);
|
|
},
|
|
onAbort: () => {
|
|
this.context.close();
|
|
},
|
|
});
|
|
}
|
|
|
|
override disconnectedCallback() {
|
|
super.disconnectedCallback();
|
|
this._menusItemsEffectCleanup();
|
|
}
|
|
|
|
override render() {
|
|
const MAX_HEIGHT = 380;
|
|
const style = this._position
|
|
? styleMap({
|
|
transform: `translate(${this._position.x}, ${this._position.y})`,
|
|
maxHeight: `${Math.min(this._position.height, MAX_HEIGHT)}px`,
|
|
})
|
|
: styleMap({
|
|
visibility: 'hidden',
|
|
});
|
|
|
|
const actionGroups = this._actionGroup.map(group => {
|
|
// Check if the group is loading
|
|
const isLoading = resolveSignal(group.loading);
|
|
return {
|
|
...group,
|
|
isLoading,
|
|
};
|
|
});
|
|
|
|
return html`<div class="linked-doc-popover" style="${style}">
|
|
${actionGroups
|
|
.filter(group => group.items.length || group.isLoading)
|
|
.map((group, idx) => {
|
|
return html`
|
|
<div class="divider" ?hidden=${idx === 0}></div>
|
|
<div class="group-title">
|
|
${group.name}
|
|
${group.isLoading
|
|
? html`<span class="loading-icon">${LoadingIcon}</span>`
|
|
: nothing}
|
|
</div>
|
|
<div class="group" style=${group.styles ?? ''}>
|
|
${group.items.map(({ key, name, icon, action }) => {
|
|
const tooltip = this._showTooltip
|
|
? html`<affine-tooltip
|
|
tip-position=${'right'}
|
|
.tooltipStyle=${css`
|
|
* {
|
|
color: ${unsafeCSSVar('white')} !important;
|
|
}
|
|
`}
|
|
>${name}</affine-tooltip
|
|
>`
|
|
: nothing;
|
|
return html`<icon-button
|
|
width="280px"
|
|
height="30px"
|
|
data-id=${key}
|
|
.text=${name}
|
|
hover=${this._activatedItemKey === key}
|
|
@click=${() => {
|
|
action()?.catch(console.error);
|
|
}}
|
|
@mousemove=${() => {
|
|
// Use `mousemove` instead of `mouseover` to avoid navigate conflict with keyboard
|
|
this._activatedItemKey = key;
|
|
// show tooltip whether text length overflows
|
|
for (const button of this.iconButtons.values()) {
|
|
if (button.dataset.id == key && button.textElement) {
|
|
const isOverflowing = this._isTextOverflowing(
|
|
button.textElement
|
|
);
|
|
this._showTooltip = isOverflowing;
|
|
break;
|
|
}
|
|
}
|
|
}}
|
|
>
|
|
${icon} ${tooltip}
|
|
</icon-button>`;
|
|
})}
|
|
</div>
|
|
`;
|
|
})}
|
|
</div>`;
|
|
}
|
|
|
|
override willUpdate() {
|
|
if (!this.hasUpdated) {
|
|
const curRange = getCurrentNativeRange();
|
|
if (!curRange) return;
|
|
|
|
const updatePosition = throttle(() => {
|
|
this._position = getPopperPosition(this, curRange);
|
|
}, 10);
|
|
|
|
this.disposables.addFromEvent(window, 'resize', updatePosition);
|
|
const scrollContainer = getViewportElement(this.context.std.host);
|
|
if (scrollContainer) {
|
|
// Note: in edgeless mode, the scroll container is not exist!
|
|
this.disposables.addFromEvent(
|
|
scrollContainer,
|
|
'scroll',
|
|
updatePosition,
|
|
{
|
|
passive: true,
|
|
}
|
|
);
|
|
}
|
|
|
|
const gfx = this.context.std.get(GfxControllerIdentifier);
|
|
this.disposables.add(gfx.viewport.viewportUpdated.on(updatePosition));
|
|
|
|
updatePosition();
|
|
}
|
|
}
|
|
|
|
private scrollToFocusedItem() {
|
|
const shadowRoot = this.shadowRoot;
|
|
if (!shadowRoot) {
|
|
return;
|
|
}
|
|
|
|
// If there's no active item key, don't try to scroll
|
|
if (!this._activatedItemKey) {
|
|
return;
|
|
}
|
|
|
|
const ele = shadowRoot.querySelector(
|
|
`icon-button[data-id="${this._activatedItemKey}"]`
|
|
);
|
|
|
|
// If the element doesn't exist, don't log a warning
|
|
if (!ele) {
|
|
return;
|
|
}
|
|
|
|
ele.scrollIntoView({
|
|
block: 'nearest',
|
|
});
|
|
}
|
|
|
|
get _activatedItemIndex() {
|
|
const index = this._flattenActionList.findIndex(
|
|
item => item.key === this._activatedItemKey
|
|
);
|
|
return index === -1 ? 0 : index;
|
|
}
|
|
|
|
@state()
|
|
private accessor _activatedItemKey: string | null = null;
|
|
|
|
@state()
|
|
private accessor _linkedDocGroup: LinkedMenuGroup[] = [];
|
|
|
|
@state()
|
|
private accessor _position: {
|
|
height: number;
|
|
x: string;
|
|
y: string;
|
|
} | null = null;
|
|
|
|
@state()
|
|
private accessor _showTooltip = false;
|
|
|
|
@property({ attribute: false })
|
|
accessor context!: LinkedDocContext;
|
|
|
|
@queryAll('icon-button')
|
|
accessor iconButtons!: NodeListOf<IconButton>;
|
|
|
|
@query('.linked-doc-popover')
|
|
accessor linkedDocElement: Element | null = null;
|
|
}
|