feat(editor): add experimental feature citation (#11984)

Closes: [BS-3122](https://linear.app/affine-design/issue/BS-3122/footnote-definition-adapter-适配)
Closes: [BS-3123](https://linear.app/affine-design/issue/BS-3123/几个-block-card-view-适配-footnote-态)

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

- **New Features**
  - Introduced a new citation card component and web element for displaying citations.
  - Added support for citation-style rendering in attachment, bookmark, and linked document blocks.
  - Enabled citation parsing from footnote definitions in markdown for attachments, bookmarks, and linked docs.
  - Added a feature flag to enable or disable citation features.
  - Provided new toolbar logic to disable downloads for citation-style attachments.

- **Improvements**
  - Updated block models and properties to support citation identifiers.
  - Added localization and settings for the citation experimental feature.
  - Enhanced markdown adapters to recognize and process citation footnotes.
  - Included new constants and styles for citation card display.

- **Bug Fixes**
  - Ensured readonly state is respected in block interactions and rendering for citation blocks.

- **Documentation**
  - Added exports and effects for new citation components and features.

- **Tests**
  - Updated snapshots to include citation-related properties in block data.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
donteatfriedrice
2025-04-29 06:59:27 +00:00
parent a326eac1bb
commit 83670ab335
58 changed files with 832 additions and 151 deletions

View File

@@ -226,6 +226,7 @@
"./components/block-zero-width": "./src/components/block-zero-width.ts",
"./components/caption": "./src/components/caption.ts",
"./components/card-style-dropdown-menu": "./src/components/card-style-dropdown-menu.ts",
"./components/citation": "./src/components/citation.ts",
"./components/color-picker": "./src/components/color-picker.ts",
"./components/context-menu": "./src/components/context-menu.ts",
"./components/date-picker": "./src/components/date-picker.ts",

View File

@@ -1,3 +1,4 @@
import { AttachmentBlockMarkdownAdapterExtension } from '@blocksuite/affine-block-attachment';
import { BookmarkBlockMarkdownAdapterExtension } from '@blocksuite/affine-block-bookmark';
import { CodeBlockMarkdownAdapterExtension } from '@blocksuite/affine-block-code';
import { DatabaseBlockMarkdownAdapterExtension } from '@blocksuite/affine-block-database';
@@ -38,4 +39,5 @@ export const defaultBlockMarkdownAdapterMatchers = [
DividerBlockMarkdownAdapterExtension,
ImageBlockMarkdownAdapterExtension,
LatexBlockMarkdownAdapterExtension,
AttachmentBlockMarkdownAdapterExtension,
];

View File

@@ -21,6 +21,7 @@ import { BlockSelection } from '@blocksuite/affine-components/block-selection';
import { BlockZeroWidth } from '@blocksuite/affine-components/block-zero-width';
import { effects as componentCaptionEffects } from '@blocksuite/affine-components/caption';
import { effects as componentCardStyleDropdownMenuEffects } from '@blocksuite/affine-components/card-style-dropdown-menu';
import { effects as componentCitationEffects } from '@blocksuite/affine-components/citation';
import { effects as componentColorPickerEffects } from '@blocksuite/affine-components/color-picker';
import { effects as componentContextMenuEffects } from '@blocksuite/affine-components/context-menu';
import { effects as componentDatePickerEffects } from '@blocksuite/affine-components/date-picker';
@@ -154,6 +155,7 @@ export function effects() {
componentEmbedCardModalEffects();
componentLinkPreviewEffects();
componentLinkedDocTitleEffects();
componentCitationEffects();
componentCardStyleDropdownMenuEffects();
componentHighlightDropdownMenuEffects();
componentViewDropdownMenuEffects();

View File

@@ -0,0 +1,9 @@
import type { ExtensionType } from '@blocksuite/store';
import { AttachmentBlockMarkdownAdapterExtension } from './markdown.js';
import { AttachmentBlockNotionHtmlAdapterExtension } from './notion-html.js';
export const AttachmentBlockAdapterExtensions: ExtensionType[] = [
AttachmentBlockNotionHtmlAdapterExtension,
AttachmentBlockMarkdownAdapterExtension,
];

View File

@@ -0,0 +1,2 @@
export * from './markdown.js';
export * from './notion-html.js';

View File

@@ -0,0 +1,93 @@
import {
AttachmentBlockSchema,
FootNoteReferenceParamsSchema,
} from '@blocksuite/affine-model';
import {
BlockMarkdownAdapterExtension,
type BlockMarkdownAdapterMatcher,
FOOTNOTE_DEFINITION_PREFIX,
getFootnoteDefinitionText,
isFootnoteDefinitionNode,
type MarkdownAST,
} from '@blocksuite/affine-shared/adapters';
import { FeatureFlagService } from '@blocksuite/affine-shared/services';
import { nanoid } from '@blocksuite/store';
const isAttachmentFootnoteDefinitionNode = (node: MarkdownAST) => {
if (!isFootnoteDefinitionNode(node)) return false;
const footnoteDefinition = getFootnoteDefinitionText(node);
try {
const footnoteDefinitionJson = FootNoteReferenceParamsSchema.parse(
JSON.parse(footnoteDefinition)
);
return (
footnoteDefinitionJson.type === 'attachment' &&
!!footnoteDefinitionJson.blobId
);
} catch {
return false;
}
};
export const attachmentBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher =
{
flavour: AttachmentBlockSchema.model.flavour,
toMatch: o => isAttachmentFootnoteDefinitionNode(o.node),
fromMatch: o => o.node.flavour === AttachmentBlockSchema.model.flavour,
toBlockSnapshot: {
enter: (o, context) => {
const { provider } = context;
let enableCitation = false;
try {
const featureFlagService = provider?.get(FeatureFlagService);
enableCitation = !!featureFlagService?.getFlag('enable_citation');
} catch {
enableCitation = false;
}
if (!isFootnoteDefinitionNode(o.node) || !enableCitation) {
return;
}
const { walkerContext, configs } = context;
const footnoteIdentifier = o.node.identifier;
const footnoteDefinitionKey = `${FOOTNOTE_DEFINITION_PREFIX}${footnoteIdentifier}`;
const footnoteDefinition = configs.get(footnoteDefinitionKey);
if (!footnoteDefinition) {
return;
}
try {
const footnoteDefinitionJson = FootNoteReferenceParamsSchema.parse(
JSON.parse(footnoteDefinition)
);
const { blobId, fileName } = footnoteDefinitionJson;
if (!blobId || !fileName) {
return;
}
walkerContext
.openNode(
{
type: 'block',
id: nanoid(),
flavour: AttachmentBlockSchema.model.flavour,
props: {
name: fileName,
sourceId: blobId,
footnoteIdentifier,
},
children: [],
},
'children'
)
.closeNode();
walkerContext.skipAllChildren();
} catch (err) {
console.warn('Failed to parse attachment footnote definition:', err);
return;
}
},
},
fromBlockSnapshot: {},
};
export const AttachmentBlockMarkdownAdapterExtension =
BlockMarkdownAdapterExtension(attachmentBlockMarkdownAdapterMatcher);

View File

@@ -53,6 +53,10 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent<Attachment
return this.std.store.get(FileSizeLimitService).maxFileSize;
}
get isCitation() {
return !!this.model.props.footnoteIdentifier;
}
convertTo = () => {
return this.std
.get(AttachmentEmbedProvider)
@@ -147,7 +151,7 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent<Attachment
})
);
if (!this.model.props.style) {
if (!this.model.props.style && !this.doc.readonly) {
this.doc.withoutTransact(() => {
this.doc.updateBlock(this.model, {
style: AttachmentBlockStyles[1],
@@ -322,6 +326,18 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent<Attachment
);
};
private readonly _renderCitation = () => {
const { name, footnoteIdentifier } = this.model.props;
const fileType = name.split('.').pop() ?? '';
const fileTypeIcon = getAttachmentFileIcon(fileType);
return html`<affine-citation-card
.icon=${fileTypeIcon}
.citationTitle=${name}
.citationIdentifier=${footnoteIdentifier}
.active=${this.selected$.value}
></affine-citation-card>`;
};
override renderBlock() {
return html`
<div
@@ -332,12 +348,17 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent<Attachment
style=${this.containerStyleMap}
>
${when(
this.embedView,
this.isCitation,
() => this._renderCitation(),
() =>
html`<div class="affine-attachment-embed-container">
${this.embedView}
</div>`,
this.renderCard
when(
this.embedView,
() =>
html`<div class="affine-attachment-embed-container">
${this.embedView}
</div>`,
this.renderCard
)
)}
</div>
`;

View File

@@ -4,7 +4,7 @@ import { BlockViewExtension, FlavourExtension } from '@blocksuite/std';
import type { ExtensionType } from '@blocksuite/store';
import { literal } from 'lit/static-html.js';
import { AttachmentBlockNotionHtmlAdapterExtension } from './adapters/notion-html.js';
import { AttachmentBlockAdapterExtensions } from './adapters/extension.js';
import { AttachmentDropOption } from './attachment-service.js';
import { attachmentSlashMenuConfig } from './configs/slash-menu.js';
import { createBuiltinToolbarConfigExtension } from './configs/toolbar';
@@ -25,7 +25,7 @@ export const AttachmentBlockSpec: ExtensionType[] = [
AttachmentDropOption,
AttachmentEmbedConfigExtension(),
AttachmentEmbedService,
AttachmentBlockNotionHtmlAdapterExtension,
AttachmentBlockAdapterExtensions,
createBuiltinToolbarConfigExtension(flavour),
SlashMenuConfigExtension(flavour, attachmentSlashMenuConfig),
].flat();

View File

@@ -139,6 +139,12 @@ const downloadAction = {
const block = ctx.getCurrentBlockByType(AttachmentBlockComponent);
block?.download();
},
when: ctx => {
const model = ctx.getCurrentModelByType(AttachmentBlockModel);
if (!model) return false;
// Current citation attachment block does not support download
return model.props.style !== 'citation' && !model.props.footnoteIdentifier;
},
} as const satisfies ToolbarAction;
const captionAction = {
@@ -331,7 +337,6 @@ const builtinSurfaceToolbarConfig = {
id: 'e.caption',
},
],
when: ctx => ctx.getSurfaceModelsByType(AttachmentBlockModel).length === 1,
} as const satisfies ToolbarModuleConfig;

View File

@@ -1,4 +1,4 @@
export * from './adapters/notion-html';
export * from './adapters';
export * from './attachment-block';
export * from './attachment-service';
export * from './attachment-spec';

View File

@@ -4,7 +4,7 @@ import {
} from '@blocksuite/affine-ext-loader';
import { AttachmentBlockSchemaExtension } from '@blocksuite/affine-model';
import { AttachmentBlockNotionHtmlAdapterExtension } from './adapters/notion-html';
import { AttachmentBlockAdapterExtensions } from './adapters/extension';
export class AttachmentStoreExtension extends StoreExtensionProvider {
override name = 'affine-attachment-block';
@@ -12,6 +12,6 @@ export class AttachmentStoreExtension extends StoreExtensionProvider {
override setup(context: StoreExtensionContext) {
super.setup(context);
context.register(AttachmentBlockSchemaExtension);
context.register(AttachmentBlockNotionHtmlAdapterExtension);
context.register(AttachmentBlockAdapterExtensions);
}
}

View File

@@ -1,9 +1,102 @@
import { createEmbedBlockMarkdownAdapterMatcher } from '@blocksuite/affine-block-embed';
import { BookmarkBlockSchema } from '@blocksuite/affine-model';
import { BlockMarkdownAdapterExtension } from '@blocksuite/affine-shared/adapters';
import {
BookmarkBlockSchema,
FootNoteReferenceParamsSchema,
} from '@blocksuite/affine-model';
import {
BlockMarkdownAdapterExtension,
FOOTNOTE_DEFINITION_PREFIX,
getFootnoteDefinitionText,
isFootnoteDefinitionNode,
type MarkdownAST,
} from '@blocksuite/affine-shared/adapters';
import { FeatureFlagService } from '@blocksuite/affine-shared/services';
import { nanoid } from '@blocksuite/store';
const isUrlFootnoteDefinitionNode = (node: MarkdownAST) => {
if (!isFootnoteDefinitionNode(node)) return false;
const footnoteDefinition = getFootnoteDefinitionText(node);
try {
const footnoteDefinitionJson = FootNoteReferenceParamsSchema.parse(
JSON.parse(footnoteDefinition)
);
return (
footnoteDefinitionJson.type === 'url' && !!footnoteDefinitionJson.url
);
} catch {
return false;
}
};
export const bookmarkBlockMarkdownAdapterMatcher =
createEmbedBlockMarkdownAdapterMatcher(BookmarkBlockSchema.model.flavour);
createEmbedBlockMarkdownAdapterMatcher(BookmarkBlockSchema.model.flavour, {
toMatch: o => isUrlFootnoteDefinitionNode(o.node),
toBlockSnapshot: {
enter: (o, context) => {
const { provider } = context;
let enableCitation = false;
try {
const featureFlagService = provider?.get(FeatureFlagService);
enableCitation = !!featureFlagService?.getFlag('enable_citation');
} catch {
enableCitation = false;
}
if (!isFootnoteDefinitionNode(o.node) || !enableCitation) {
return;
}
const { walkerContext, configs } = context;
const footnoteIdentifier = o.node.identifier;
const footnoteDefinitionKey = `${FOOTNOTE_DEFINITION_PREFIX}${footnoteIdentifier}`;
const footnoteDefinition = configs.get(footnoteDefinitionKey);
if (!footnoteDefinition) {
return;
}
let footnoteDefinitionJson;
try {
footnoteDefinitionJson = FootNoteReferenceParamsSchema.parse(
JSON.parse(footnoteDefinition)
);
// If the footnote definition contains url, decode it
if (footnoteDefinitionJson.url) {
footnoteDefinitionJson.url = decodeURIComponent(
footnoteDefinitionJson.url
);
}
if (footnoteDefinitionJson.favicon) {
footnoteDefinitionJson.favicon = decodeURIComponent(
footnoteDefinitionJson.favicon
);
}
} catch (err) {
console.warn('Failed to parse or decode footnote definition:', err);
return;
}
const { url, favicon, title, description } = footnoteDefinitionJson;
walkerContext
.openNode(
{
type: 'block',
id: nanoid(),
flavour: BookmarkBlockSchema.model.flavour,
props: {
url,
footnoteIdentifier,
icon: favicon,
title,
description,
style: 'citation',
},
children: [],
},
'children'
)
.closeNode();
walkerContext.skipAllChildren();
},
},
});
export const BookmarkBlockMarkdownAdapterExtension =
BlockMarkdownAdapterExtension(bookmarkBlockMarkdownAdapterMatcher);

View File

@@ -4,6 +4,7 @@ import {
} from '@blocksuite/affine-components/caption';
import type { BookmarkBlockModel } from '@blocksuite/affine-model';
import { DocModeProvider } from '@blocksuite/affine-shared/services';
import { BlockSelection } from '@blocksuite/std';
import { computed, type ReadonlySignal } from '@preact/signals-core';
import { html } from 'lit';
import { property, query } from 'lit/decorators.js';
@@ -27,6 +28,14 @@ export class BookmarkBlockComponent extends CaptionedBlockComponent<BookmarkBloc
protected containerStyleMap!: ReturnType<typeof styleMap>;
selectBlock = () => {
const selectionManager = this.std.selection;
const blockSelection = selectionManager.create(BlockSelection, {
blockId: this.blockId,
});
selectionManager.setGroup('note', [blockSelection]);
};
open = () => {
let link = this.model.props.url;
if (!link.match(/^[a-zA-Z]+:\/\//)) {
@@ -41,6 +50,37 @@ export class BookmarkBlockComponent extends CaptionedBlockComponent<BookmarkBloc
);
};
get isCitation() {
return (
!!this.model.props.footnoteIdentifier &&
this.model.props.style === 'citation'
);
}
private readonly _renderCitationView = () => {
const { title, description, url, icon, footnoteIdentifier } =
this.model.props;
return html`
<affine-citation-card
.icon=${icon}
.citationTitle=${title || url}
.citationContent=${description}
.citationIdentifier=${footnoteIdentifier}
.onClickCallback=${this.selectBlock}
.onDoubleClickCallback=${this.open}
.active=${this.selected$.value}
></affine-citation-card>
`;
};
private readonly _renderCardView = () => {
return html`<bookmark-card
.bookmark=${this}
.loading=${this.loading}
.error=${this.error}
></bookmark-card>`;
};
override connectedCallback() {
super.connectedCallback();
@@ -58,6 +98,9 @@ export class BookmarkBlockComponent extends CaptionedBlockComponent<BookmarkBloc
this.contentEditable = 'false';
if (!this.model.props.description && !this.model.props.title) {
if (this.doc.readonly) {
return;
}
this.refreshData();
}
@@ -85,11 +128,7 @@ export class BookmarkBlockComponent extends CaptionedBlockComponent<BookmarkBloc
})}
style=${this.containerStyleMap}
>
<bookmark-card
.bookmark=${this}
.loading=${this.loading}
.error=${this.error}
></bookmark-card>
${this.isCitation ? this._renderCitationView() : this._renderCardView()}
</div>
`;
}

View File

@@ -5,11 +5,7 @@ import { ThemeProvider } from '@blocksuite/affine-shared/services';
import { getHostName } from '@blocksuite/affine-shared/utils';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { OpenInNewIcon } from '@blocksuite/icons/lit';
import {
BlockSelection,
isGfxBlockComponent,
ShadowlessElement,
} from '@blocksuite/std';
import { isGfxBlockComponent, ShadowlessElement } from '@blocksuite/std';
import { html } from 'lit';
import { property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
@@ -27,7 +23,7 @@ export class BookmarkCard extends SignalWatcher(
const model = this.bookmark.model;
if (model.parent?.flavour !== 'affine:surface') {
this._selectBlock();
this.bookmark.selectBlock();
}
}
@@ -36,14 +32,6 @@ export class BookmarkCard extends SignalWatcher(
this.bookmark.open();
}
private _selectBlock() {
const selectionManager = this.bookmark.host.selection;
const blockSelection = selectionManager.create(BlockSelection, {
blockId: this.bookmark.blockId,
});
selectionManager.setGroup('note', [blockSelection]);
}
override connectedCallback(): void {
super.connectedCallback();

View File

@@ -1,16 +1,91 @@
import { EmbedLinkedDocBlockSchema } from '@blocksuite/affine-model';
import {
EmbedLinkedDocBlockSchema,
FootNoteReferenceParamsSchema,
} from '@blocksuite/affine-model';
import {
AdapterTextUtils,
BlockMarkdownAdapterExtension,
type BlockMarkdownAdapterMatcher,
FOOTNOTE_DEFINITION_PREFIX,
getFootnoteDefinitionText,
isFootnoteDefinitionNode,
type MarkdownAST,
} from '@blocksuite/affine-shared/adapters';
import { FeatureFlagService } from '@blocksuite/affine-shared/services';
import { nanoid } from '@blocksuite/store';
const isLinkedDocFootnoteDefinitionNode = (node: MarkdownAST) => {
if (!isFootnoteDefinitionNode(node)) return false;
const footnoteDefinition = getFootnoteDefinitionText(node);
try {
const footnoteDefinitionJson = FootNoteReferenceParamsSchema.parse(
JSON.parse(footnoteDefinition)
);
return (
footnoteDefinitionJson.type === 'doc' && !!footnoteDefinitionJson.docId
);
} catch {
return false;
}
};
export const embedLinkedDocBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher =
{
flavour: EmbedLinkedDocBlockSchema.model.flavour,
toMatch: () => false,
toMatch: o => isLinkedDocFootnoteDefinitionNode(o.node),
fromMatch: o => o.node.flavour === EmbedLinkedDocBlockSchema.model.flavour,
toBlockSnapshot: {},
toBlockSnapshot: {
enter: (o, context) => {
const { provider } = context;
let enableCitation = false;
try {
const featureFlagService = provider?.get(FeatureFlagService);
enableCitation = !!featureFlagService?.getFlag('enable_citation');
} catch {
enableCitation = false;
}
if (!isFootnoteDefinitionNode(o.node) || !enableCitation) {
return;
}
const { walkerContext, configs } = context;
const footnoteIdentifier = o.node.identifier;
const footnoteDefinitionKey = `${FOOTNOTE_DEFINITION_PREFIX}${footnoteIdentifier}`;
const footnoteDefinition = configs.get(footnoteDefinitionKey);
if (!footnoteDefinition) {
return;
}
try {
const footnoteDefinitionJson = FootNoteReferenceParamsSchema.parse(
JSON.parse(footnoteDefinition)
);
const { docId } = footnoteDefinitionJson;
if (!docId) {
return;
}
walkerContext
.openNode(
{
type: 'block',
id: nanoid(),
flavour: EmbedLinkedDocBlockSchema.model.flavour,
props: {
pageId: docId,
footnoteIdentifier,
style: 'citation',
},
children: [],
},
'children'
)
.closeNode();
walkerContext.skipAllChildren();
} catch (err) {
console.warn('Failed to parse linked doc footnote definition:', err);
return;
}
},
},
fromBlockSnapshot: {
enter: (o, context) => {
const { configs, walkerContext } = context;

View File

@@ -53,11 +53,11 @@ export class EmbedEdgelessLinkedDocBlockComponent extends toEdgelessEmbedBlock(
doc.deleteBlock(this.model);
};
protected override _handleClick(evt: MouseEvent): void {
protected override _handleClick = (evt: MouseEvent): void => {
if (isNewTabTrigger(evt)) {
this.open({ openMode: 'open-in-new-tab', event: evt });
} else if (isNewViewTrigger(evt)) {
this.open({ openMode: 'open-in-new-view', event: evt });
}
}
};
}

View File

@@ -55,6 +55,11 @@ export class EmbedLinkedDocBlockComponent extends EmbedBlockComponent<EmbedLinke
static override styles = styles;
private readonly _load = async () => {
// If this is a citation linked doc block, we don't need to load the linked doc and render linked doc content in card
if (this.isCitation) {
return;
}
const {
loading = true,
isError = false,
@@ -243,6 +248,17 @@ export class EmbedLinkedDocBlockComponent extends EmbedBlockComponent<EmbedLinke
return doc?.getStore({ id: this.model.props.pageId });
}
get readonly() {
return this.doc.readonly;
}
get isCitation() {
return (
!!this.model.props.footnoteIdentifier &&
this.model.props.style === 'citation'
);
}
private _handleDoubleClick(event: MouseEvent) {
event.stopPropagation();
const openDocService = this.std.get(OpenDocExtensionIdentifier);
@@ -264,105 +280,42 @@ export class EmbedLinkedDocBlockComponent extends EmbedBlockComponent<EmbedLinke
return !!linkedDoc && this.isNoteContentEmpty && this.isBannerEmpty;
}
protected _handleClick(event: MouseEvent) {
protected _handleClick = (event: MouseEvent) => {
if (isNewTabTrigger(event)) {
this.open({ openMode: 'open-in-new-tab', event });
} else if (isNewViewTrigger(event)) {
this.open({ openMode: 'open-in-new-view', event });
}
this._selectBlock();
}
override connectedCallback() {
super.connectedCallback();
this._cardStyle = this.model.props.style;
this._referenceToNode = referenceToNode(this.model.props);
this._load().catch(e => {
console.error(e);
this.isError = true;
});
const linkedDoc = this.linkedDoc;
if (linkedDoc) {
this.disposables.add(
linkedDoc.workspace.slots.docListUpdated.subscribe(() => {
this._load().catch(e => {
console.error(e);
this.isError = true;
});
})
);
// Should throttle the blockUpdated event to avoid too many re-renders
// Because the blockUpdated event is triggered too frequently at some cases
this.disposables.add(
linkedDoc.slots.blockUpdated.subscribe(
throttle(payload => {
if (
payload.type === 'update' &&
['', 'caption', 'xywh'].includes(payload.props.key)
) {
return;
}
if (payload.type === 'add' && payload.init) {
return;
}
this._load().catch(e => {
console.error(e);
this.isError = true;
});
}, RENDER_CARD_THROTTLE_MS)
)
);
this._setDocUpdatedAt();
this.disposables.add(
this.doc.workspace.slots.docListUpdated.subscribe(() => {
this._setDocUpdatedAt();
})
);
if (this._referenceToNode) {
this._linkedDocMode = this.model.props.params?.mode ?? 'page';
} else {
const docMode = this.std.get(DocModeProvider);
this._linkedDocMode = docMode.getPrimaryMode(this.model.props.pageId);
this.disposables.add(
docMode.onPrimaryModeChange(mode => {
this._linkedDocMode = mode;
}, this.model.props.pageId)
);
}
if (this.readonly) {
return;
}
this._selectBlock();
};
this.disposables.add(
this.model.propsUpdated.subscribe(({ key }) => {
if (key === 'style') {
this._cardStyle = this.model.props.style;
}
if (key === 'pageId' || key === 'style') {
this._load().catch(e => {
console.error(e);
this.isError = true;
});
}
})
);
}
private readonly _renderCitationView = () => {
const { footnoteIdentifier } = this.model.props;
return html`<div
draggable="${this.blockDraggable ? 'true' : 'false'}"
class=${classMap({
'embed-block-container': true,
...this.selectedStyle$?.value,
})}
style=${styleMap({
...this.embedContainerStyle,
})}
>
<affine-citation-card
.icon=${this.icon$.value}
.citationTitle=${this.title$.value}
.citationIdentifier=${footnoteIdentifier}
.active=${this.selected$.value}
.onClickCallback=${this._handleClick}
></affine-citation-card>
</div> `;
};
getInitialState(): {
loading?: boolean;
isError?: boolean;
isNoteContentEmpty?: boolean;
isBannerEmpty?: boolean;
} {
return {};
}
override renderBlock() {
private readonly _renderEmbedView = () => {
const linkedDoc = this.linkedDoc;
const isDeleted = !linkedDoc;
const isLoading = this._loading;
@@ -502,9 +455,107 @@ export class EmbedLinkedDocBlockComponent extends EmbedBlockComponent<EmbedLinke
</div>
`
);
};
override connectedCallback() {
super.connectedCallback();
this._cardStyle = this.model.props.style;
this._referenceToNode = referenceToNode(this.model.props);
this._load().catch(e => {
console.error(e);
this.isError = true;
});
const linkedDoc = this.linkedDoc;
if (linkedDoc) {
this.disposables.add(
linkedDoc.workspace.slots.docListUpdated.subscribe(() => {
this._load().catch(e => {
console.error(e);
this.isError = true;
});
})
);
// Should throttle the blockUpdated event to avoid too many re-renders
// Because the blockUpdated event is triggered too frequently at some cases
this.disposables.add(
linkedDoc.slots.blockUpdated.subscribe(
throttle(payload => {
if (
payload.type === 'update' &&
['', 'caption', 'xywh'].includes(payload.props.key)
) {
return;
}
if (payload.type === 'add' && payload.init) {
return;
}
this._load().catch(e => {
console.error(e);
this.isError = true;
});
}, RENDER_CARD_THROTTLE_MS)
)
);
this._setDocUpdatedAt();
this.disposables.add(
this.doc.workspace.slots.docListUpdated.subscribe(() => {
this._setDocUpdatedAt();
})
);
if (this._referenceToNode) {
this._linkedDocMode = this.model.props.params?.mode ?? 'page';
} else {
const docMode = this.std.get(DocModeProvider);
this._linkedDocMode = docMode.getPrimaryMode(this.model.props.pageId);
this.disposables.add(
docMode.onPrimaryModeChange(mode => {
this._linkedDocMode = mode;
}, this.model.props.pageId)
);
}
}
this.disposables.add(
this.model.propsUpdated.subscribe(({ key }) => {
if (key === 'style') {
this._cardStyle = this.model.props.style;
}
if (key === 'pageId' || key === 'style') {
this._load().catch(e => {
console.error(e);
this.isError = true;
});
}
})
);
}
getInitialState(): {
loading?: boolean;
isError?: boolean;
isNoteContentEmpty?: boolean;
isBannerEmpty?: boolean;
} {
return {};
}
override renderBlock() {
return this.isCitation
? this._renderCitationView()
: this._renderEmbedView();
}
override updated() {
if (this.readonly) {
return;
}
// update card style when linked doc deleted
const linkedDoc = this.linkedDoc;
const { xywh, style } = this.model.props;

View File

@@ -3,14 +3,13 @@ import {
BlockMarkdownAdapterExtension,
type BlockMarkdownAdapterMatcher,
FOOTNOTE_DEFINITION_PREFIX,
isFootnoteDefinitionNode,
type MarkdownAST,
} from '@blocksuite/affine-shared/adapters';
import type { FootnoteDefinition, Root } from 'mdast';
import { FeatureFlagService } from '@blocksuite/affine-shared/services';
import type { Root } from 'mdast';
const isRootNode = (node: MarkdownAST): node is Root => node.type === 'root';
const isFootnoteDefinitionNode = (
node: MarkdownAST
): node is FootnoteDefinition => node.type === 'footnoteDefinition';
const createFootnoteDefinition = (
identifier: string,
@@ -67,10 +66,35 @@ const createNoteBlockMarkdownAdapterMatcher = (
}
});
// Remove the footnoteDefinition node from the noteAst
noteAst.children = noteAst.children.filter(
child => !isFootnoteDefinitionNode(child)
);
const { provider } = context;
let enableCitation = false;
try {
const featureFlagService = provider?.get(FeatureFlagService);
enableCitation = !!featureFlagService?.getFlag('enable_citation');
} catch {
enableCitation = false;
}
if (enableCitation) {
// if there are footnoteDefinition nodes, add a heading node to the noteAst before the first footnoteDefinition node
const footnoteDefinitionIndex = noteAst.children.findIndex(child =>
isFootnoteDefinitionNode(child)
);
if (footnoteDefinitionIndex !== -1) {
noteAst.children.splice(footnoteDefinitionIndex, 0, {
type: 'heading',
depth: 6,
data: {
collapsed: true,
},
children: [{ type: 'text', value: 'Sources' }],
});
}
} else {
// Remove the footnoteDefinition node from the noteAst
noteAst.children = noteAst.children.filter(
child => !isFootnoteDefinitionNode(child)
);
}
},
},
fromBlockSnapshot: {

View File

@@ -9,6 +9,15 @@ import type { DeltaInsert } from '@blocksuite/store';
import { nanoid } from '@blocksuite/store';
import type { Heading } from 'mdast';
/**
* Extend the HeadingData type to include the collapsed property
*/
declare module 'mdast' {
interface HeadingData {
collapsed?: boolean;
}
}
const PARAGRAPH_MDAST_TYPE = new Set(['paragraph', 'heading', 'blockquote']);
const isParagraphMDASTType = (node: MarkdownAST) =>
@@ -46,6 +55,7 @@ export const paragraphBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher =
break;
}
case 'heading': {
const isCollapsed = !!o.node.data?.collapsed;
walkerContext
.openNode(
{
@@ -54,6 +64,7 @@ export const paragraphBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher =
flavour: 'affine:paragraph',
props: {
type: `h${o.node.depth}`,
collapsed: isCollapsed,
text: {
'$blocksuite:internal:text$': true,
delta: deltaConverter.astToDelta(o.node),

View File

@@ -63,6 +63,7 @@
"./linked-doc-title": "./src/linked-doc-title/index.ts",
"./view-dropdown-menu": "./src/view-dropdown-menu/index.ts",
"./card-style-dropdown-menu": "./src/card-style-dropdown-menu/index.ts",
"./citation": "./src/citation/index.ts",
"./highlight-dropdown-menu": "./src/highlight-dropdown-menu/index.ts",
"./tooltip-content-with-shortcut": "./src/tooltip-content-with-shortcut/index.ts",
"./size-dropdown-menu": "./src/size-dropdown-menu/index.ts",

View File

@@ -0,0 +1,167 @@
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { baseTheme } from '@toeverything/theme';
import {
css,
html,
LitElement,
nothing,
type TemplateResult,
unsafeCSS,
} from 'lit';
import { property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
export class CitationCard extends SignalWatcher(WithDisposable(LitElement)) {
static override styles = css`
.citation-container {
width: 100%;
box-sizing: border-box;
border-radius: 8px;
display: flex;
gap: 2px;
flex-direction: column;
align-items: flex-start;
align-self: stretch;
padding: 4px 8px;
background-color: ${unsafeCSSVarV2('layer/background/primary')};
border: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
cursor: pointer;
}
.citation-header {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
box-sizing: border-box;
.citation-icon {
display: flex;
align-items: center;
justify-content: center;
height: 16px;
width: 16px;
color: ${unsafeCSSVarV2('icon/primary')};
border-radius: 4px;
svg,
img {
width: 16px;
height: 16px;
fill: ${unsafeCSSVarV2('icon/primary')};
}
}
.citation-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: left;
line-height: 22px;
color: ${unsafeCSSVarV2('text/primary')};
font-size: var(--affine-font-sm);
font-weight: 500;
}
.citation-identifier {
display: flex;
width: 14px;
height: 14px;
justify-content: center;
align-items: center;
border-radius: 36px;
background: ${unsafeCSSVarV2('block/footnote/numberBg')};
color: ${unsafeCSSVarV2('text/primary')};
text-align: center;
font-size: 10px;
font-style: normal;
font-weight: 400;
line-height: 22px; /* 220% */
transition: background-color 0.3s ease-in-out;
}
}
.citation-container:hover .citation-identifier,
.citation-identifier.active {
background: ${unsafeCSSVarV2('button/primary')};
color: ${unsafeCSSVarV2('button/pureWhiteText')};
}
.citation-content {
width: 100%;
box-sizing: border-box;
overflow: hidden;
color: ${unsafeCSSVarV2('text/primary')};
font-feature-settings:
'liga' off,
'clig' off;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--affine-font-xs);
font-style: normal;
font-weight: 400;
line-height: 20px; /* 166.667% */
}
`;
private readonly _IconTemplate = (icon: TemplateResult | string) => {
if (typeof icon === 'string') {
return html`<img src="${icon}" alt="favicon" />`;
}
return icon;
};
override render() {
const citationIdentifierClasses = classMap({
'citation-identifier': true,
active: this.active,
});
return html`
<div
class="citation-container"
@click=${this.onClickCallback}
@dblclick=${this.onDoubleClickCallback}
>
<div class="citation-header">
${this.icon
? html`<div class="citation-icon">
${this._IconTemplate(this.icon)}
</div>`
: nothing}
<div class="citation-title">${this.citationTitle}</div>
<div class=${citationIdentifierClasses}>
${this.citationIdentifier}
</div>
</div>
${this.citationContent
? html`<div class="citation-content">${this.citationContent}</div>`
: nothing}
</div>
`;
}
@property({ attribute: false })
accessor icon: TemplateResult | string | undefined = undefined;
@property({ attribute: false })
accessor citationTitle: string = '';
@property({ attribute: false })
accessor citationContent: string | undefined = undefined;
@property({ attribute: false })
accessor citationIdentifier: string = '';
@property({ attribute: false })
accessor onClickCallback: ((e: MouseEvent) => void) | undefined = undefined;
@property({ attribute: false })
accessor onDoubleClickCallback: ((e: MouseEvent) => void) | undefined =
undefined;
@property({ attribute: false })
accessor active: boolean = false;
}

View File

@@ -0,0 +1,7 @@
import { CitationCard } from './citation';
export * from './citation';
export function effects() {
customElements.define('affine-citation-card', CitationCard);
}

View File

@@ -1,3 +1,4 @@
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { WithDisposable } from '@blocksuite/global/lit';
import { ToggleDownIcon, ToggleRightIcon } from '@blocksuite/icons/lit';
import { ShadowlessElement } from '@blocksuite/std';
@@ -43,6 +44,12 @@ export class ToggleButton extends WithDisposable(ShadowlessElement) {
.with-drag-handle .affine-block-children-container .toggle-icon {
opacity: 0;
}
.toggle-icon {
svg {
color: ${unsafeCSSVarV2('icon/primary', '#77757D')};
}
}
`;
override render() {
@@ -55,7 +62,6 @@ export class ToggleButton extends WithDisposable(ShadowlessElement) {
${ToggleDownIcon({
width: '16px',
height: '16px',
style: 'color: #77757D',
})}
</div>
`;
@@ -70,7 +76,6 @@ export class ToggleButton extends WithDisposable(ShadowlessElement) {
${ToggleRightIcon({
width: '16px',
height: '16px',
style: 'color: #77757D',
})}
</div>
`;

View File

@@ -2,6 +2,7 @@ import { BlockSelection } from '@blocksuite/affine-components/block-selection';
import { BlockZeroWidth } from '@blocksuite/affine-components/block-zero-width';
import { effects as componentCaptionEffects } from '@blocksuite/affine-components/caption';
import { effects as componentCardStyleDropdownMenuEffects } from '@blocksuite/affine-components/card-style-dropdown-menu';
import { effects as componentCitationEffects } from '@blocksuite/affine-components/citation';
import { effects as componentColorPickerEffects } from '@blocksuite/affine-components/color-picker';
import { effects as componentContextMenuEffects } from '@blocksuite/affine-components/context-menu';
import { effects as componentDatePickerEffects } from '@blocksuite/affine-components/date-picker';
@@ -46,6 +47,7 @@ export function effects() {
componentLinkPreviewEffects();
componentLinkedDocTitleEffects();
componentCardStyleDropdownMenuEffects();
componentCitationEffects();
componentHighlightDropdownMenuEffects();
componentViewDropdownMenuEffects();
componentTooltipContentWithShortcutEffects();

View File

@@ -54,6 +54,7 @@ export class AffineFootnoteNode extends WithDisposable(ShadowlessElement) {
text-overflow: ellipsis;
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
transition: background 0.3s ease-in-out;
transform: translateY(-0.2em);
}
}
@@ -137,10 +138,6 @@ export class AffineFootnoteNode extends WithDisposable(ShadowlessElement) {
};
private readonly _handleDocReference = (docId: string) => {
if (this.readonly) {
return;
}
this.std
.getOptional(PeekViewProvider)
?.peek({

View File

@@ -55,6 +55,8 @@ export type AttachmentBlockProps = {
embed: boolean | BackwardCompatibleUndefined;
style?: (typeof AttachmentBlockStyles)[number];
footnoteIdentifier: string | null;
} & Omit<GfxCommonBlockProps, 'scale'> &
BlockMeta;
@@ -74,6 +76,7 @@ export const defaultAttachmentProps: AttachmentBlockProps = {
'meta:updatedAt': undefined,
'meta:createdBy': undefined,
'meta:updatedBy': undefined,
footnoteIdentifier: null,
};
export const AttachmentBlockSchema = defineBlockSchema({

View File

@@ -20,12 +20,14 @@ export const BookmarkStyles: EmbedCardStyle[] = [
'horizontal',
'list',
'cube',
'citation',
] as const;
export type BookmarkBlockProps = {
style: (typeof BookmarkStyles)[number];
url: string;
caption: string | null;
footnoteIdentifier: string | null;
} & LinkPreviewData &
Omit<GfxCommonBlockProps, 'scale'> &
BlockMeta;
@@ -48,6 +50,8 @@ const defaultBookmarkProps: BookmarkBlockProps = {
'meta:updatedAt': undefined,
'meta:createdBy': undefined,
'meta:updatedBy': undefined,
footnoteIdentifier: null,
};
export const BookmarkBlockSchema = defineBlockSchema({

View File

@@ -10,11 +10,13 @@ export const EmbedLinkedDocStyles: EmbedCardStyle[] = [
'list',
'cube',
'horizontalThin',
'citation',
];
export type EmbedLinkedDocBlockProps = {
style: EmbedCardStyle;
caption: string | null;
footnoteIdentifier: string | null;
} & ReferenceInfo;
export class EmbedLinkedDocModel extends defineEmbedModel<EmbedLinkedDocBlockProps>(

View File

@@ -14,6 +14,8 @@ const defaultEmbedLinkedDocBlockProps: EmbedLinkedDocBlockProps = {
// title & description aliases
title: undefined,
description: undefined,
footnoteIdentifier: null,
};
export const EmbedLinkedDocBlockSchema = createEmbedBlockSchema({

View File

@@ -17,7 +17,8 @@ export type EmbedCardStyle =
| 'figma'
| 'html'
| 'syncedDoc'
| 'pdf';
| 'pdf'
| 'citation';
export type LinkPreviewData = {
description: string | null;

View File

@@ -22,10 +22,12 @@ export {
type BlockMarkdownAdapterMatcher,
BlockMarkdownAdapterMatcherIdentifier,
FOOTNOTE_DEFINITION_PREFIX,
getFootnoteDefinitionText,
IN_PARAGRAPH_NODE_CONTEXT_KEY,
InlineDeltaToMarkdownAdapterExtension,
type InlineDeltaToMarkdownAdapterMatcher,
InlineDeltaToMarkdownAdapterMatcherIdentifier,
isFootnoteDefinitionNode,
isMarkdownAST,
type Markdown,
MarkdownAdapter,

View File

@@ -1,4 +1,4 @@
import type { Root, RootContentMap } from 'mdast';
import type { FootnoteDefinition, Root, RootContentMap } from 'mdast';
export type Markdown = string;
@@ -16,5 +16,17 @@ export const isMarkdownAST = (node: unknown): node is MarkdownAST =>
'type' in (node as object) &&
(node as MarkdownAST).type !== undefined;
export const isFootnoteDefinitionNode = (
node: MarkdownAST
): node is FootnoteDefinition => node.type === 'footnoteDefinition';
export const getFootnoteDefinitionText = (node: FootnoteDefinition) => {
const childNode = node.children[0];
if (childNode.type !== 'paragraph') return '';
const paragraph = childNode.children[0];
if (paragraph.type !== 'text') return '';
return paragraph.value;
};
export const FOOTNOTE_DEFINITION_PREFIX = 'footnoteDefinition:';
export const IN_PARAGRAPH_NODE_CONTEXT_KEY = 'mdast:paragraph';

View File

@@ -31,6 +31,7 @@ export const EMBED_CARD_WIDTH: Record<EmbedCardStyle, number> = {
html: 752,
syncedDoc: 800,
pdf: 537 + 24 + 2,
citation: 752,
};
export const EMBED_CARD_HEIGHT: Record<EmbedCardStyle, number> = {
@@ -45,6 +46,7 @@ export const EMBED_CARD_HEIGHT: Record<EmbedCardStyle, number> = {
html: 544,
syncedDoc: 455,
pdf: 759 + 46 + 24 + 2,
citation: 52,
};
export const EMBED_BLOCK_FLAVOUR_LIST = [

View File

@@ -20,6 +20,7 @@ export interface BlockSuiteFlags {
enable_edgeless_scribbled_style: boolean;
enable_embed_doc_with_alias: boolean;
enable_turbo_renderer: boolean;
enable_citation: boolean;
}
export class FeatureFlagService extends StoreExtension {
@@ -44,6 +45,7 @@ export class FeatureFlagService extends StoreExtension {
enable_edgeless_scribbled_style: false,
enable_embed_doc_with_alias: false,
enable_turbo_renderer: false,
enable_citation: false,
});
setFlag(key: keyof BlockSuiteFlags, value: boolean) {