mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-22 00:37:05 +08:00
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:
@@ -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() {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(): {
|
||||
|
||||
@@ -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>
|
||||
`
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './citation-service';
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { TelemetryEvent } from './types';
|
||||
export type CitationEventType =
|
||||
| 'AICitationHoverSource'
|
||||
| 'AICitationExpandSource'
|
||||
| 'AICitationDelete'
|
||||
| 'AICitationEdit';
|
||||
|
||||
export type CitationEvents = Record<CitationEventType, TelemetryEvent>;
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './citation.js';
|
||||
export * from './database.js';
|
||||
export * from './link.js';
|
||||
export * from './telemetry-service.js';
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user