feat(editor): add inline packages (#11048)

This commit is contained in:
Saul-Mirone
2025-03-20 13:47:35 +00:00
parent aa620af40f
commit e5e429e7b2
170 changed files with 1337 additions and 804 deletions
@@ -0,0 +1,241 @@
import { notifyLinkedDocSwitchedToEmbed } from '@blocksuite/affine-components/notification';
import {
ActionPlacement,
type ToolbarAction,
type ToolbarActionGroup,
type ToolbarModuleConfig,
} from '@blocksuite/affine-shared/services';
import {
cloneReferenceInfoWithoutAliases,
isInsideBlockByFlavour,
} from '@blocksuite/affine-shared/utils';
import { BlockSelection } from '@blocksuite/block-std';
import { DeleteIcon } from '@blocksuite/icons/lit';
import { signal } from '@preact/signals-core';
import { html } from 'lit-html';
import { keyed } from 'lit-html/directives/keyed.js';
import { AffineReference } from '../reference-node';
const trackBaseProps = {
segment: 'doc',
page: 'doc editor',
module: 'toolbar',
category: 'linked doc',
type: 'inline view',
};
export const builtinInlineReferenceToolbarConfig = {
actions: [
{
id: 'a.doc-title',
content(ctx) {
const target = ctx.message$.peek()?.element;
if (!(target instanceof AffineReference)) return null;
if (!target.referenceInfo.title) return null;
return html`<affine-linked-doc-title
.title=${target.docTitle}
.open=${(event: MouseEvent) => target.open({ event })}
></affine-linked-doc-title>`;
},
},
{
id: 'c.conversions',
actions: [
{
id: 'inline',
label: 'Inline view',
disabled: true,
},
{
id: 'card',
label: 'Card view',
run(ctx) {
const target = ctx.message$.peek()?.element;
if (!(target instanceof AffineReference)) return;
if (!target.block) return;
const {
block: { model },
referenceInfo,
inlineEditor,
selfInlineRange,
} = target;
const { parent } = model;
if (!inlineEditor || !selfInlineRange || !parent) return;
// Clears
ctx.reset();
const index = parent.children.indexOf(model);
const blockId = ctx.store.addBlock(
'affine:embed-linked-doc',
referenceInfo,
parent,
index + 1
);
const totalTextLength = inlineEditor.yTextLength;
const inlineTextLength = selfInlineRange.length;
if (totalTextLength === inlineTextLength) {
ctx.store.deleteBlock(model);
} else {
inlineEditor.insertText(selfInlineRange, target.docTitle);
}
ctx.select('note', [
ctx.selection.create(BlockSelection, { blockId }),
]);
ctx.track('SelectedView', {
...trackBaseProps,
control: 'select view',
type: 'card view',
});
},
},
{
id: 'embed',
label: 'Embed view',
disabled(ctx) {
const target = ctx.message$.peek()?.element;
if (!(target instanceof AffineReference)) return true;
if (!target.block) return true;
if (
isInsideBlockByFlavour(
ctx.store,
target.block.model,
'affine:edgeless-text'
)
)
return true;
// nesting is not supported
if (target.closest('affine-embed-synced-doc-block')) return true;
// same doc
if (target.referenceInfo.pageId === ctx.store.id) return true;
// linking to block
if (target.referenceToNode()) return true;
return false;
},
run(ctx) {
const target = ctx.message$.peek()?.element;
if (!(target instanceof AffineReference)) return;
if (!target.block) return;
const {
block: { model },
referenceInfo,
inlineEditor,
selfInlineRange,
} = target;
const { parent } = model;
if (!inlineEditor || !selfInlineRange || !parent) return;
// Clears
ctx.reset();
const index = parent.children.indexOf(model);
const blockId = ctx.store.addBlock(
'affine:embed-synced-doc',
cloneReferenceInfoWithoutAliases(referenceInfo),
parent,
index + 1
);
const totalTextLength = inlineEditor.yTextLength;
const inlineTextLength = selfInlineRange.length;
if (totalTextLength === inlineTextLength) {
ctx.store.deleteBlock(model);
} else {
inlineEditor.insertText(selfInlineRange, target.docTitle);
}
const hasTitleAlias = Boolean(referenceInfo.title);
if (hasTitleAlias) {
notifyLinkedDocSwitchedToEmbed(ctx.std);
}
ctx.select('note', [
ctx.selection.create(BlockSelection, { blockId }),
]);
ctx.track('SelectedView', {
...trackBaseProps,
control: 'select view',
type: 'embed view',
});
},
},
],
content(ctx) {
const target = ctx.message$.peek()?.element;
if (!(target instanceof AffineReference)) return null;
const actions = this.actions.map(action => ({ ...action }));
const viewType$ = signal(actions[0].label);
const onToggle = (e: CustomEvent<boolean>) => {
const opened = e.detail;
if (!opened) return;
ctx.track('OpenedViewSelector', {
...trackBaseProps,
control: 'switch view',
});
};
return html`${keyed(
target,
html`<affine-view-dropdown-menu
.actions=${actions}
.context=${ctx}
.onToggle=${onToggle}
.viewType$=${viewType$}
></affine-view-dropdown-menu>`
)}`;
},
when(ctx) {
const target = ctx.message$.peek()?.element;
if (!(target instanceof AffineReference)) return false;
if (!target.block) return false;
if (ctx.flags.isNative()) return false;
if (
target.block.closest('affine-database') ||
target.block.closest('affine-table')
)
return false;
return true;
},
} satisfies ToolbarActionGroup<ToolbarAction>,
{
placement: ActionPlacement.More,
id: 'c.delete',
label: 'Delete',
icon: DeleteIcon(),
variant: 'destructive',
run(ctx) {
const target = ctx.message$.peek()?.element;
if (!(target instanceof AffineReference)) return;
const { inlineEditor, selfInlineRange } = target;
if (!inlineEditor || !selfInlineRange) return;
if (!inlineEditor.isValidInlineRange(selfInlineRange)) return;
inlineEditor.deleteText(selfInlineRange);
},
},
],
} as const satisfies ToolbarModuleConfig;
@@ -0,0 +1,6 @@
export * from './reference-config';
export { AffineReference } from './reference-node';
export * from './reference-node-slots';
export { ReferencePopup } from './reference-popup/reference-popup';
export { toggleReferencePopup } from './reference-popup/toggle-reference-popup';
export type { DocLinkClickedEvent, RefNodeSlots } from './types';
@@ -0,0 +1,56 @@
import {
type BlockStdScope,
ConfigExtensionFactory,
} from '@blocksuite/block-std';
import type { TemplateResult } from 'lit';
import type { AffineReference } from './reference-node';
export interface ReferenceNodeConfig {
customContent?: (reference: AffineReference) => TemplateResult;
interactable?: boolean;
hidePopup?: boolean;
}
export const ReferenceNodeConfigExtension =
ConfigExtensionFactory<ReferenceNodeConfig>('AffineReferenceNodeConfig');
export class ReferenceNodeConfigProvider {
private _customContent:
| ((reference: AffineReference) => TemplateResult)
| undefined = undefined;
private _hidePopup = false;
private _interactable = true;
get customContent() {
return this._customContent;
}
get doc() {
return this.std.store;
}
get hidePopup() {
return this._hidePopup;
}
get interactable() {
return this._interactable;
}
constructor(readonly std: BlockStdScope) {}
setCustomContent(content: ReferenceNodeConfigProvider['_customContent']) {
this._customContent = content;
}
setHidePopup(hidePopup: boolean) {
this._hidePopup = hidePopup;
}
setInteractable(interactable: boolean) {
this._interactable = interactable;
}
}
@@ -0,0 +1,30 @@
import type { ReferenceInfo } from '@blocksuite/affine-model';
import type { OpenDocMode } from '@blocksuite/affine-shared/services';
import type { EditorHost } from '@blocksuite/block-std';
import { createIdentifier } from '@blocksuite/global/di';
import type { ExtensionType } from '@blocksuite/store';
import { Subject } from 'rxjs';
export type DocLinkClickedEvent = ReferenceInfo & {
// default is active view
openMode?: OpenDocMode;
event?: MouseEvent;
host: EditorHost;
};
export type RefNodeSlots = {
docLinkClicked: Subject<DocLinkClickedEvent>;
};
export const RefNodeSlotsProvider =
createIdentifier<RefNodeSlots>('AffineRefNodeSlots');
const slots: RefNodeSlots = {
docLinkClicked: new Subject(),
};
export const RefNodeSlotsExtension: ExtensionType = {
setup: di => {
di.addImpl(RefNodeSlotsProvider, () => slots);
},
};
@@ -0,0 +1,311 @@
import { whenHover } from '@blocksuite/affine-components/hover';
import { Peekable } from '@blocksuite/affine-components/peek';
import type { ReferenceInfo } from '@blocksuite/affine-model';
import {
DEFAULT_DOC_NAME,
REFERENCE_NODE,
} from '@blocksuite/affine-shared/consts';
import {
DocDisplayMetaProvider,
ToolbarRegistryIdentifier,
} from '@blocksuite/affine-shared/services';
import { affineTextStyles } from '@blocksuite/affine-shared/styles';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import {
cloneReferenceInfo,
referenceToNode,
} from '@blocksuite/affine-shared/utils';
import type { BlockComponent, BlockStdScope } from '@blocksuite/block-std';
import { BLOCK_ID_ATTR, ShadowlessElement } from '@blocksuite/block-std';
import {
INLINE_ROOT_ATTR,
type InlineRootElement,
ZERO_WIDTH_NON_JOINER,
ZERO_WIDTH_SPACE,
} from '@blocksuite/block-std/inline';
import { WithDisposable } from '@blocksuite/global/lit';
import { LinkedPageIcon } from '@blocksuite/icons/lit';
import type { DeltaInsert, DocMeta, Store } from '@blocksuite/store';
import { css, html, nothing } from 'lit';
import { property, state } from 'lit/decorators.js';
import { choose } from 'lit/directives/choose.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { styleMap } from 'lit/directives/style-map.js';
import type { ReferenceNodeConfigProvider } from './reference-config';
import { RefNodeSlotsProvider } from './reference-node-slots';
import type { DocLinkClickedEvent } from './types';
@Peekable({ action: false })
export class AffineReference extends WithDisposable(ShadowlessElement) {
static override styles = css`
.affine-reference {
white-space: normal;
word-break: break-word;
color: var(--affine-text-primary-color);
fill: var(--affine-icon-color);
border-radius: 4px;
text-decoration: none;
cursor: pointer;
user-select: none;
padding: 1px 2px 1px 0;
}
.affine-reference:hover {
background: var(--affine-hover-color);
}
.affine-reference[data-selected='true'] {
background: var(--affine-hover-color);
}
.affine-reference-title {
margin-left: 4px;
border-bottom: 0.5px solid var(--affine-divider-color);
transition: border 0.2s ease-out;
}
.affine-reference-title:hover {
border-bottom: 0.5px solid var(--affine-icon-color);
}
`;
get docTitle() {
return this.refMeta?.title ?? DEFAULT_DOC_NAME;
}
private readonly _updateRefMeta = (doc: Store) => {
const refAttribute = this.delta.attributes?.reference;
if (!refAttribute) {
return;
}
const refMeta = doc.workspace.meta.docMetas.find(
doc => doc.id === refAttribute.pageId
);
this.refMeta = refMeta
? {
...refMeta,
}
: undefined;
};
// Since the linked doc may be deleted, the `_refMeta` could be undefined.
@state()
accessor refMeta: DocMeta | undefined = undefined;
get _icon() {
const { pageId, params, title } = this.referenceInfo;
return this.std
.get(DocDisplayMetaProvider)
.icon(pageId, { params, title, referenced: true }).value;
}
get _title() {
const { pageId, params, title } = this.referenceInfo;
return (
this.std
.get(DocDisplayMetaProvider)
.title(pageId, { params, title, referenced: true }).value || title
);
}
get block() {
if (!this.inlineEditor?.rootElement) return null;
const block = this.inlineEditor.rootElement.closest<BlockComponent>(
`[${BLOCK_ID_ATTR}]`
);
return block;
}
get customContent() {
return this.config.customContent;
}
get doc() {
const doc = this.config.doc;
return doc;
}
get inlineEditor() {
const inlineRoot = this.closest<InlineRootElement<AffineTextAttributes>>(
`[${INLINE_ROOT_ATTR}]`
);
return inlineRoot?.inlineEditor;
}
get referenceInfo(): ReferenceInfo {
const reference = this.delta.attributes?.reference;
const id = this.doc?.id ?? '';
if (!reference) return { pageId: id };
return cloneReferenceInfo(reference);
}
get selfInlineRange() {
const selfInlineRange = this.inlineEditor?.getInlineRangeFromElement(this);
return selfInlineRange;
}
readonly open = (event?: Partial<DocLinkClickedEvent>) => {
if (!this.config.interactable) return;
this.std.getOptional(RefNodeSlotsProvider)?.docLinkClicked.next({
...this.referenceInfo,
...event,
host: this.std.host,
});
};
_whenHover = whenHover(
hovered => {
if (!this.config.interactable) return;
const message$ = this.std.get(ToolbarRegistryIdentifier).message$;
if (hovered) {
message$.value = {
flavour: 'affine:reference',
element: this,
setFloating: this._whenHover.setFloating,
};
return;
}
// Clears previous bindings
message$.value = null;
this._whenHover.setFloating();
},
{ enterDelay: 500 }
);
override connectedCallback() {
super.connectedCallback();
this._whenHover.setReference(this);
const message$ = this.std.get(ToolbarRegistryIdentifier).message$;
this._disposables.add(() => {
if (message$?.value) {
message$.value = null;
}
this._whenHover.dispose();
});
if (!this.config) {
console.error('`reference-node` need `ReferenceNodeConfig`.');
return;
}
if (this.delta.insert !== REFERENCE_NODE) {
console.error(
`Reference node must be initialized with '${REFERENCE_NODE}', but got '${this.delta.insert}'`
);
}
const doc = this.doc;
if (doc) {
this._disposables.add(
doc.workspace.slots.docListUpdated.subscribe(() =>
this._updateRefMeta(doc)
)
);
}
this.updateComplete
.then(() => {
if (!this.inlineEditor || !doc) return;
// observe yText update
this.disposables.add(
this.inlineEditor.slots.textChange.subscribe(() =>
this._updateRefMeta(doc)
)
);
})
.catch(console.error);
}
// reference to block/element
referenceToNode() {
return referenceToNode(this.referenceInfo);
}
override render() {
const refMeta = this.refMeta;
const isDeleted = !refMeta;
const attributes = this.delta.attributes;
const reference = attributes?.reference;
const type = reference?.type;
if (!attributes || !type) {
return nothing;
}
const title = this._title;
const icon = choose(type, [
['LinkedPage', () => this._icon],
[
'Subpage',
() =>
LinkedPageIcon({
width: '1.25em',
height: '1.25em',
style:
'user-select:none;flex-shrink:0;vertical-align:middle;font-size:inherit;margin-bottom:0.1em;',
}),
],
]);
const style = affineTextStyles(
attributes,
isDeleted
? {
color: 'var(--affine-text-disable-color)',
textDecoration: 'line-through',
fill: 'var(--affine-text-disable-color)',
}
: {}
);
const content = this.customContent
? this.customContent(this)
: html`${icon}<span
data-title=${ifDefined(title)}
class="affine-reference-title"
>${title}</span
>`;
// we need to add `<v-text .str=${ZERO_WIDTH_NON_JOINER}></v-text>` in an
// embed element to make sure inline range calculation is correct
return html`<span
data-selected=${this.selected}
class="affine-reference"
style=${styleMap(style)}
@click=${(event: MouseEvent) => this.open({ event })}
>${content}<v-text .str=${ZERO_WIDTH_NON_JOINER}></v-text
></span>`;
}
override willUpdate(_changedProperties: Map<PropertyKey, unknown>) {
super.willUpdate(_changedProperties);
const doc = this.doc;
if (doc) {
this._updateRefMeta(doc);
}
}
@property({ attribute: false })
accessor config!: ReferenceNodeConfigProvider;
@property({ type: Object })
accessor delta: DeltaInsert<AffineTextAttributes> = {
insert: ZERO_WIDTH_SPACE,
attributes: {},
};
@property({ type: Boolean })
accessor selected = false;
@property({ attribute: false })
accessor std!: BlockStdScope;
}
@@ -0,0 +1,284 @@
import type { EditorIconButton } from '@blocksuite/affine-components/toolbar';
import type { ReferenceInfo } from '@blocksuite/affine-model';
import { REFERENCE_NODE } from '@blocksuite/affine-shared/consts';
import {
type LinkEventType,
type TelemetryEvent,
TelemetryProvider,
} from '@blocksuite/affine-shared/services';
import { FONT_XS, PANEL_BASE } from '@blocksuite/affine-shared/styles';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { stopPropagation } from '@blocksuite/affine-shared/utils';
import { type BlockStdScope, ShadowlessElement } from '@blocksuite/block-std';
import type { InlineEditor, InlineRange } from '@blocksuite/block-std/inline';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { DoneIcon, ResetIcon } from '@blocksuite/icons/lit';
import { computePosition, inline, offset, shift } from '@floating-ui/dom';
import { signal } from '@preact/signals-core';
import { css, html } from 'lit';
import { property, query } from 'lit/decorators.js';
import { live } from 'lit/directives/live.js';
export class ReferencePopup extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
static override styles = css`
:host {
box-sizing: border-box;
}
.overlay-mask {
position: fixed;
z-index: var(--affine-z-index-popover);
top: 0;
left: 0;
width: 100vw;
height: 100vh;
}
.popover-container {
${PANEL_BASE};
position: absolute;
display: flex;
width: 321px;
height: 37px;
gap: 8px;
box-sizing: content-box;
justify-content: space-between;
align-items: center;
animation: affine-popover-fade-in 0.2s ease;
z-index: var(--affine-z-index-popover);
}
@keyframes affine-popover-fade-in {
from {
opacity: 0;
transform: translateY(-3px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
input {
display: flex;
flex: 1;
padding: 0;
border: none;
background: transparent;
color: var(--affine-text-primary-color);
${FONT_XS};
}
input::placeholder {
color: var(--affine-placeholder-color);
}
input:focus {
outline: none;
}
editor-icon-button.save .label {
${FONT_XS};
color: inherit;
text-transform: none;
}
`;
private readonly _onSave = () => {
const title = this.title$.value.trim();
if (!title) {
this.remove();
return;
}
this._setTitle(title);
track(this.std, 'SavedAlias', { control: 'save' });
this.remove();
};
private readonly _updateTitle = (e: InputEvent) => {
const target = e.target as HTMLInputElement;
const value = target.value;
this.title$.value = value;
};
private _onKeydown(e: KeyboardEvent) {
e.stopPropagation();
if (!e.isComposing) {
if (e.key === 'Escape') {
e.preventDefault();
this.remove();
return;
}
if (e.key === 'Enter') {
e.preventDefault();
this._onSave();
}
}
}
private _onReset() {
this.title$.value = this.docTitle;
this._setTitle();
track(this.std, 'ResetedAlias', { control: 'reset' });
this.remove();
}
private _setTitle(title?: string) {
const reference: AffineTextAttributes['reference'] = {
type: 'LinkedPage',
...this.referenceInfo,
};
if (title) {
reference.title = title;
} else {
delete reference.title;
delete reference.description;
}
this.inlineEditor.insertText(this.inlineRange, REFERENCE_NODE, {
reference,
});
this.inlineEditor.setInlineRange({
index: this.inlineRange.index + REFERENCE_NODE.length,
length: 0,
});
}
override connectedCallback() {
super.connectedCallback();
this.title$.value = this.referenceInfo.title ?? this.docTitle;
}
override firstUpdated() {
this.disposables.addFromEvent(this, 'keydown', this._onKeydown);
this.disposables.addFromEvent(this, 'copy', stopPropagation);
this.disposables.addFromEvent(this, 'cut', stopPropagation);
this.disposables.addFromEvent(this, 'paste', stopPropagation);
this.disposables.addFromEvent(this.overlayMask, 'click', e => {
e.stopPropagation();
this.remove();
});
this.inputElement.focus();
this.inputElement.select();
}
override render() {
return html`
<div class="overlay-root">
<div class="overlay-mask"></div>
<div class="popover-container">
<input
id="alias-title"
type="text"
placeholder="Add a custom title"
.value=${live(this.title$.value)}
@input=${this._updateTitle}
/>
<editor-icon-button
aria-label="Reset"
class="reset"
.iconContainerPadding=${4}
.tooltip=${'Reset'}
@click=${this._onReset}
>
${ResetIcon({ width: '16px', height: '16px' })}
</editor-icon-button>
<editor-toolbar-separator></editor-toolbar-separator>
<editor-icon-button
aria-label="Save"
class="save"
.active=${true}
@click=${this._onSave}
>
${DoneIcon({ width: '16px', height: '16px' })}
<span class="label">Save</span>
</editor-icon-button>
</div>
</div>
`;
}
override updated() {
const range = this.inlineEditor.toDomRange(this.inlineRange);
if (!range) return;
const visualElement = {
getBoundingClientRect: () => range.getBoundingClientRect(),
getClientRects: () => range.getClientRects(),
};
const popover = this.popoverContainer;
computePosition(visualElement, popover, {
middleware: [
offset(10),
inline(),
shift({
padding: 6,
}),
],
})
.then(({ x, y }) => {
popover.style.left = `${x}px`;
popover.style.top = `${y}px`;
})
.catch(console.error);
}
@property({ attribute: false })
accessor abortController!: AbortController;
@property({ attribute: false })
accessor docTitle!: string;
@property({ attribute: false })
accessor inlineEditor!: InlineEditor<AffineTextAttributes>;
@property({ attribute: false })
accessor inlineRange!: InlineRange;
@query('input#alias-title')
accessor inputElement!: HTMLInputElement;
@query('.overlay-mask')
accessor overlayMask!: HTMLDivElement;
@query('.popover-container')
accessor popoverContainer!: HTMLDivElement;
@property({ type: Object })
accessor referenceInfo!: ReferenceInfo;
@query('editor-icon-button.save')
accessor saveButton!: EditorIconButton;
@property({ attribute: false })
accessor std!: BlockStdScope;
accessor title$ = signal<string>('');
}
function track(
std: BlockStdScope,
event: LinkEventType,
props: Partial<TelemetryEvent>
) {
std.getOptional(TelemetryProvider)?.track(event, {
segment: 'doc',
page: 'doc editor',
module: 'toolbar',
category: 'linked doc',
type: 'inline view',
...props,
});
}
@@ -0,0 +1,27 @@
import type { ReferenceInfo } from '@blocksuite/affine-model';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import type { BlockStdScope } from '@blocksuite/block-std';
import type { InlineEditor, InlineRange } from '@blocksuite/block-std/inline';
import { ReferencePopup } from './reference-popup';
export function toggleReferencePopup(
std: BlockStdScope,
docTitle: string,
referenceInfo: ReferenceInfo,
inlineEditor: InlineEditor<AffineTextAttributes>,
inlineRange: InlineRange,
abortController: AbortController
): ReferencePopup {
const popup = new ReferencePopup();
popup.std = std;
popup.docTitle = docTitle;
popup.referenceInfo = referenceInfo;
popup.inlineEditor = inlineEditor;
popup.inlineRange = inlineRange;
popup.abortController = abortController;
document.body.append(popup);
return popup;
}
@@ -0,0 +1,15 @@
import type { ReferenceInfo } from '@blocksuite/affine-model';
import type { OpenDocMode } from '@blocksuite/affine-shared/services';
import type { EditorHost } from '@blocksuite/block-std';
import type { Subject } from 'rxjs';
export type DocLinkClickedEvent = ReferenceInfo & {
// default is active view
openMode?: OpenDocMode;
event?: MouseEvent;
host: EditorHost;
};
export type RefNodeSlots = {
docLinkClicked: Subject<DocLinkClickedEvent>;
};