feat(editor): track citation events (#12664)

Closes: [BS-3551](https://linear.app/affine-design/issue/BS-3551/citation埋点)

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Enhanced citation tracking across attachments, bookmarks, embedded documents, paragraphs, footnotes, rename modals, and toolbars for actions like editing, deleting, expanding, and hovering on citations.
  - Introduced a centralized citation service to unify citation detection and telemetry event management.
- **Chores**
  - Updated service exports and telemetry modules to include the new citation service and citation-related event types.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
donteatfriedrice
2025-06-05 03:09:24 +00:00
parent 053efb61f0
commit 1bdccdbd57
14 changed files with 249 additions and 11 deletions

View File

@@ -17,6 +17,7 @@ import {
AttachmentBlockStyles,
} from '@blocksuite/affine-model';
import {
CitationProvider,
DocModeProvider,
FileSizeLimitProvider,
TelemetryProvider,
@@ -37,6 +38,7 @@ import { type ClassInfo, classMap } from 'lit/directives/class-map.js';
import { guard } from 'lit/directives/guard.js';
import { styleMap } from 'lit/directives/style-map.js';
import { when } from 'lit/directives/when.js';
import { filter } from 'rxjs/operators';
import { AttachmentEmbedProvider } from './embed';
import { styles } from './styles';
@@ -79,8 +81,12 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent<Attachment
return this.std.get(FileSizeLimitProvider).maxFileSize;
}
get citationService() {
return this.std.get(CitationProvider);
}
get isCitation() {
return !!this.model.props.footnoteIdentifier;
return this.citationService.isCitationModel(this.model);
}
convertTo = () => {
@@ -139,6 +145,34 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent<Attachment
selectionManager.setGroup('note', [blockSelection]);
}
private readonly _trackCitationDeleteEvent = () => {
// Check citation delete event
this._disposables.add(
this.std.store.slots.blockUpdated
.pipe(
filter(payload => {
if (!payload.isLocal) return false;
const { flavour, id, type } = payload;
if (
type !== 'delete' ||
flavour !== this.model.flavour ||
id !== this.model.id
)
return false;
const { model } = payload;
if (!this.citationService.isCitationModel(model)) return false;
return true;
})
)
.subscribe(() => {
this.citationService.trackEvent('Delete');
})
);
};
override connectedCallback() {
super.connectedCallback();
@@ -162,6 +196,8 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent<Attachment
});
});
}
this._trackCitationDeleteEvent();
}
override firstUpdated() {

View File

@@ -1,6 +1,7 @@
import { ConfirmIcon } from '@blocksuite/affine-components/icons';
import { toast } from '@blocksuite/affine-components/toast';
import type { AttachmentBlockModel } from '@blocksuite/affine-model';
import { CitationProvider } from '@blocksuite/affine-shared/services';
import type { EditorHost } from '@blocksuite/std';
import { html } from 'lit';
import { createRef, ref } from 'lit/directives/ref.js';
@@ -33,6 +34,7 @@ export const RenameModal = ({
let fileName = includeExtension ? nameWithoutExtension : originalName;
const extension = includeExtension ? originalExtension : '';
const citationService = editorHost.std.get(CitationProvider);
const abort = () => abortController.abort();
const onConfirm = () => {
@@ -44,6 +46,9 @@ export const RenameModal = ({
model.store.updateBlock(model, {
name: newFileName,
});
if (citationService.isCitationModel(model)) {
citationService.trackEvent('Edit');
}
abort();
};
const onInput = (e: InputEvent) => {

View File

@@ -8,6 +8,7 @@ import type {
} from '@blocksuite/affine-model';
import { ImageProxyService } from '@blocksuite/affine-shared/adapters';
import {
CitationProvider,
DocModeProvider,
LinkPreviewServiceIdentifier,
} from '@blocksuite/affine-shared/services';
@@ -18,6 +19,7 @@ import { html } from 'lit';
import { property, query } from 'lit/decorators.js';
import { type ClassInfo, classMap } from 'lit/directives/class-map.js';
import { type StyleInfo, styleMap } from 'lit/directives/style-map.js';
import { filter } from 'rxjs/operators';
import { refreshBookmarkUrlData } from './utils.js';
@@ -114,11 +116,12 @@ export class BookmarkBlockComponent extends CaptionedBlockComponent<BookmarkBloc
);
};
get citationService() {
return this.std.get(CitationProvider);
}
get isCitation() {
return (
!!this.model.props.footnoteIdentifier &&
this.model.props.style === 'citation'
);
return this.citationService.isCitationModel(this.model);
}
get imageProxyService() {
@@ -166,6 +169,31 @@ export class BookmarkBlockComponent extends CaptionedBlockComponent<BookmarkBloc
></bookmark-card>`;
};
private readonly _trackCitationDeleteEvent = () => {
// Check citation delete event
this._disposables.add(
this.std.store.slots.blockUpdated
.pipe(
filter(payload => {
if (!payload.isLocal) return false;
const { flavour, id, type } = payload;
if (
type !== 'delete' ||
flavour !== this.model.flavour ||
id !== this.model.id
)
return false;
const { model } = payload;
if (!this.citationService.isCitationModel(model)) return false;
return true;
})
)
.subscribe(() => {
this.citationService.trackEvent('Delete');
})
);
};
override connectedCallback() {
super.connectedCallback();
@@ -203,6 +231,8 @@ export class BookmarkBlockComponent extends CaptionedBlockComponent<BookmarkBloc
}
})
);
this._trackCitationDeleteEvent();
}
override disconnectedCallback(): void {

View File

@@ -17,6 +17,7 @@ import {
REFERENCE_NODE,
} from '@blocksuite/affine-shared/consts';
import {
CitationProvider,
DocDisplayMetaProvider,
DocModeProvider,
OpenDocExtensionIdentifier,
@@ -43,6 +44,7 @@ import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';
import { when } from 'lit/directives/when.js';
import throttle from 'lodash-es/throttle';
import { filter } from 'rxjs/operators';
import * as Y from 'yjs';
import { renderLinkedDocInCard } from '../common/render-linked-doc';
@@ -254,11 +256,12 @@ export class EmbedLinkedDocBlockComponent extends EmbedBlockComponent<EmbedLinke
return this.store.readonly;
}
get citationService() {
return this.std.get(CitationProvider);
}
get isCitation() {
return (
!!this.model.props.footnoteIdentifier &&
this.model.props.style === 'citation'
);
return this.citationService.isCitationModel(this.model);
}
private readonly _handleDoubleClick = (event: MouseEvent) => {
@@ -454,6 +457,31 @@ export class EmbedLinkedDocBlockComponent extends EmbedBlockComponent<EmbedLinke
);
};
private readonly _trackCitationDeleteEvent = () => {
// Check citation delete event
this._disposables.add(
this.std.store.slots.blockUpdated
.pipe(
filter(payload => {
if (!payload.isLocal) return false;
const { flavour, id, type } = payload;
if (
type !== 'delete' ||
flavour !== this.model.flavour ||
id !== this.model.id
)
return false;
const { model } = payload;
if (!this.citationService.isCitationModel(model)) return false;
return true;
})
)
.subscribe(() => {
this.citationService.trackEvent('Delete');
})
);
};
override connectedCallback() {
super.connectedCallback();
@@ -532,6 +560,8 @@ export class EmbedLinkedDocBlockComponent extends EmbedBlockComponent<EmbedLinke
}
})
);
this._trackCitationDeleteEvent();
}
getInitialState(): {

View File

@@ -7,7 +7,10 @@ import {
BLOCK_CHILDREN_CONTAINER_PADDING_LEFT,
EDGELESS_TOP_CONTENTEDITABLE_SELECTOR,
} from '@blocksuite/affine-shared/consts';
import { DocModeProvider } from '@blocksuite/affine-shared/services';
import {
CitationProvider,
DocModeProvider,
} from '@blocksuite/affine-shared/services';
import {
calculateCollapsedSiblings,
getNearestHeadingBefore,
@@ -63,6 +66,10 @@ export class ParagraphBlockComponent extends CaptionedBlockComponent<ParagraphBl
?.getPlaceholder(this.model);
}
get citationService() {
return this.std.get(CitationProvider);
}
get attributeRenderer() {
return this.inlineManager.getRenderer();
}
@@ -94,6 +101,12 @@ export class ParagraphBlockComponent extends CaptionedBlockComponent<ParagraphBl
return this.std.get(DefaultInlineManagerExtension.identifier);
}
get hasCitationSiblings() {
return this.collapsedSiblings.some(sibling =>
this.citationService.isCitationModel(sibling)
);
}
override get topContenteditableElement() {
if (this.std.get(DocModeProvider).getEditorMode() === 'edgeless') {
return this.closest<BlockComponent>(
@@ -286,6 +299,13 @@ export class ParagraphBlockComponent extends CaptionedBlockComponent<ParagraphBl
collapsed: value,
});
}
if (this.hasCitationSiblings) {
this.citationService.trackEvent('Expand', {
control: 'Source Button',
type: value ? 'Hide' : 'Show',
});
}
}}
></blocksuite-toggle-button>
`

View File

@@ -9,6 +9,7 @@ import {
} from '@blocksuite/affine-ext-loader';
import {
AutoClearSelectionService,
CitationService,
DefaultOpenDocExtension,
DNDAPIExtension,
DocDisplayMetaService,
@@ -76,6 +77,7 @@ export class FoundationViewExtension extends ViewExtensionProvider<FoundationVie
FileSizeLimitService,
LinkPreviewCache,
LinkPreviewService,
CitationService,
]);
context.register(clipboardConfigs);
if (this.isEdgeless(context.scope)) {

View File

@@ -1,6 +1,7 @@
import { HoverController } from '@blocksuite/affine-components/hover';
import { PeekViewProvider } from '@blocksuite/affine-components/peek';
import type { FootNote } from '@blocksuite/affine-model';
import { CitationProvider } from '@blocksuite/affine-shared/services';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { WithDisposable } from '@blocksuite/global/lit';
@@ -117,6 +118,10 @@ export class AffineFootnoteNode extends WithDisposable(ShadowlessElement) {
return this.std.store.readonly;
}
get citationService() {
return this.std.get(CitationProvider);
}
onFootnoteClick = () => {
if (!this.footnote) {
return;
@@ -215,6 +220,10 @@ export class AffineFootnoteNode extends WithDisposable(ShadowlessElement) {
return null;
}
this.citationService.trackEvent('Hover', {
control: 'Source Footnote',
});
return {
template: this._FootNotePopup(footnote, abortController),
container: this.std.host,

View File

@@ -0,0 +1,84 @@
import { type Container, createIdentifier } from '@blocksuite/global/di';
import { type BlockStdScope, StdIdentifier } from '@blocksuite/std';
import { type BlockModel, Extension } from '@blocksuite/store';
import { DocModeProvider } from '../doc-mode-service';
import type {
CitationEvents,
CitationEventType,
} from '../telemetry-service/citation';
import { TelemetryProvider } from '../telemetry-service/telemetry-service';
const CitationEventTypeMap = {
Hover: 'AICitationHoverSource',
Expand: 'AICitationExpandSource',
Delete: 'AICitationDelete',
Edit: 'AICitationEdit',
} as const;
type EventType = keyof typeof CitationEventTypeMap;
type EventTypeMapping = {
[K in EventType]: CitationEventType;
};
export interface CitationViewService {
/**
* Tracks citation-related events
* @param type - The type of citation event to track
* @param properties - The properties of the event
*/
trackEvent<T extends EventType>(
type: T,
properties?: CitationEvents[EventTypeMapping[T]]
): void;
/**
* Checks if the model is a citation model
* @param model - The model to check
* @returns True if the model is a citation model, false otherwise
*/
isCitationModel(model: BlockModel): boolean;
}
export const CitationProvider =
createIdentifier<CitationViewService>('CitationService');
export class CitationService extends Extension implements CitationViewService {
constructor(private readonly std: BlockStdScope) {
super();
}
static override setup(di: Container) {
di.addImpl(CitationProvider, CitationService, [StdIdentifier]);
}
get docModeService() {
return this.std.getOptional(DocModeProvider);
}
get telemetryService() {
return this.std.getOptional(TelemetryProvider);
}
isCitationModel = (model: BlockModel) => {
return (
'footnoteIdentifier' in model.props &&
!!model.props.footnoteIdentifier &&
'style' in model.props &&
model.props.style === 'citation'
);
};
trackEvent<T extends EventType>(
type: T,
properties?: CitationEvents[EventTypeMapping[T]]
) {
const editorMode = this.docModeService?.getEditorMode() ?? 'page';
this.telemetryService?.track(CitationEventTypeMap[type], {
page: editorMode === 'page' ? 'doc editor' : 'whiteboard editor',
module: 'AI Result',
control: 'Source',
...properties,
});
}
}

View File

@@ -0,0 +1 @@
export * from './citation-service';

View File

@@ -1,5 +1,6 @@
export * from './auto-clear-selection-service';
export * from './block-meta-service';
export * from './citation-service';
export * from './doc-display-meta-service';
export * from './doc-mode-service';
export * from './drag-handle-config';

View File

@@ -0,0 +1,8 @@
import type { TelemetryEvent } from './types';
export type CitationEventType =
| 'AICitationHoverSource'
| 'AICitationExpandSource'
| 'AICitationDelete'
| 'AICitationEdit';
export type CitationEvents = Record<CitationEventType, TelemetryEvent>;

View File

@@ -1,3 +1,4 @@
export * from './citation.js';
export * from './database.js';
export * from './link.js';
export * from './telemetry-service.js';

View File

@@ -1,6 +1,7 @@
import { createIdentifier } from '@blocksuite/global/di';
import type { ExtensionType } from '@blocksuite/store';
import type { CitationEvents } from './citation.js';
import type { CodeBlockEvents } from './code-block.js';
import type { OutDatabaseAllEvents } from './database.js';
import type { LinkToolbarEvents } from './link.js';
@@ -28,7 +29,8 @@ export type TelemetryEventMap = OutDatabaseAllEvents &
LinkToolbarEvents &
SlashMenuEvents &
CodeBlockEvents &
NoteEvents & {
NoteEvents &
CitationEvents & {
DocCreated: DocCreatedEvent;
Link: TelemetryEvent;
LinkedDocCreated: LinkedDocCreatedEvent;