mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-17 10:06:17 +08:00
feat(editor): add inline packages (#11048)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
+284
@@ -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,
|
||||
});
|
||||
}
|
||||
+27
@@ -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>;
|
||||
};
|
||||
Reference in New Issue
Block a user