mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-25 14:28:51 +08:00
chore: merge blocksuite source code (#9213)
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
import type { BlockHtmlAdapterMatcher } from '@blocksuite/affine-shared/adapters';
|
||||
|
||||
export function createEmbedBlockHtmlAdapterMatcher(
|
||||
flavour: string,
|
||||
{
|
||||
toMatch = () => false,
|
||||
fromMatch = o => o.node.flavour === flavour,
|
||||
toBlockSnapshot = {},
|
||||
fromBlockSnapshot = {
|
||||
enter: (o, context) => {
|
||||
const { walkerContext } = context;
|
||||
// Parse as link
|
||||
if (
|
||||
typeof o.node.props.title !== 'string' ||
|
||||
typeof o.node.props.url !== 'string'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
walkerContext
|
||||
.openNode(
|
||||
{
|
||||
type: 'element',
|
||||
tagName: 'div',
|
||||
properties: {
|
||||
className: ['affine-paragraph-block-container'],
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
'children'
|
||||
)
|
||||
.openNode(
|
||||
{
|
||||
type: 'element',
|
||||
tagName: 'a',
|
||||
properties: {
|
||||
href: o.node.props.url,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
value: o.node.props.title,
|
||||
},
|
||||
],
|
||||
},
|
||||
'children'
|
||||
)
|
||||
.closeNode()
|
||||
.closeNode();
|
||||
},
|
||||
},
|
||||
}: {
|
||||
toMatch?: BlockHtmlAdapterMatcher['toMatch'];
|
||||
fromMatch?: BlockHtmlAdapterMatcher['fromMatch'];
|
||||
toBlockSnapshot?: BlockHtmlAdapterMatcher['toBlockSnapshot'];
|
||||
fromBlockSnapshot?: BlockHtmlAdapterMatcher['fromBlockSnapshot'];
|
||||
} = Object.create(null)
|
||||
): BlockHtmlAdapterMatcher {
|
||||
return {
|
||||
flavour,
|
||||
toMatch,
|
||||
fromMatch,
|
||||
toBlockSnapshot,
|
||||
fromBlockSnapshot,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { BlockMarkdownAdapterMatcher } from '@blocksuite/affine-shared/adapters';
|
||||
|
||||
export function createEmbedBlockMarkdownAdapterMatcher(
|
||||
flavour: string,
|
||||
{
|
||||
toMatch = () => false,
|
||||
fromMatch = o => o.node.flavour === flavour,
|
||||
toBlockSnapshot = {},
|
||||
fromBlockSnapshot = {
|
||||
enter: (o, context) => {
|
||||
const { walkerContext } = context;
|
||||
// Parse as link
|
||||
if (
|
||||
typeof o.node.props.title !== 'string' ||
|
||||
typeof o.node.props.url !== 'string'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
walkerContext
|
||||
.openNode(
|
||||
{
|
||||
type: 'paragraph',
|
||||
children: [],
|
||||
},
|
||||
'children'
|
||||
)
|
||||
.openNode(
|
||||
{
|
||||
type: 'link',
|
||||
url: o.node.props.url,
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
value: o.node.props.title,
|
||||
},
|
||||
],
|
||||
},
|
||||
'children'
|
||||
)
|
||||
.closeNode()
|
||||
.closeNode();
|
||||
},
|
||||
},
|
||||
}: {
|
||||
toMatch?: BlockMarkdownAdapterMatcher['toMatch'];
|
||||
fromMatch?: BlockMarkdownAdapterMatcher['fromMatch'];
|
||||
toBlockSnapshot?: BlockMarkdownAdapterMatcher['toBlockSnapshot'];
|
||||
fromBlockSnapshot?: BlockMarkdownAdapterMatcher['fromBlockSnapshot'];
|
||||
} = {}
|
||||
): BlockMarkdownAdapterMatcher {
|
||||
return {
|
||||
flavour,
|
||||
toMatch,
|
||||
fromMatch,
|
||||
toBlockSnapshot,
|
||||
fromBlockSnapshot,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { BlockPlainTextAdapterMatcher } from '@blocksuite/affine-shared/adapters';
|
||||
|
||||
export function createEmbedBlockPlainTextAdapterMatcher(
|
||||
flavour: string,
|
||||
{
|
||||
toMatch = () => false,
|
||||
fromMatch = o => o.node.flavour === flavour,
|
||||
toBlockSnapshot = {},
|
||||
fromBlockSnapshot = {
|
||||
enter: (o, context) => {
|
||||
const { textBuffer } = context;
|
||||
// Parse as link
|
||||
if (
|
||||
typeof o.node.props.title !== 'string' ||
|
||||
typeof o.node.props.url !== 'string'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const buffer = `[${o.node.props.title}](${o.node.props.url})`;
|
||||
if (buffer.length > 0) {
|
||||
textBuffer.content += buffer;
|
||||
textBuffer.content += '\n';
|
||||
}
|
||||
},
|
||||
},
|
||||
}: {
|
||||
toMatch?: BlockPlainTextAdapterMatcher['toMatch'];
|
||||
fromMatch?: BlockPlainTextAdapterMatcher['fromMatch'];
|
||||
toBlockSnapshot?: BlockPlainTextAdapterMatcher['toBlockSnapshot'];
|
||||
fromBlockSnapshot?: BlockPlainTextAdapterMatcher['fromBlockSnapshot'];
|
||||
} = {}
|
||||
): BlockPlainTextAdapterMatcher {
|
||||
return {
|
||||
flavour,
|
||||
toMatch,
|
||||
fromMatch,
|
||||
toBlockSnapshot,
|
||||
fromBlockSnapshot,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ReferenceParams } from '@blocksuite/affine-model';
|
||||
import { TextUtils } from '@blocksuite/affine-shared/adapters';
|
||||
|
||||
export function generateDocUrl(
|
||||
docBaseUrl: string,
|
||||
pageId: string,
|
||||
params: ReferenceParams
|
||||
) {
|
||||
const search = TextUtils.toURLSearchParams(params);
|
||||
const query = search?.size ? `?${search.toString()}` : '';
|
||||
const url = docBaseUrl ? `${docBaseUrl}/${pageId}${query}` : '';
|
||||
return url;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import {
|
||||
CaptionedBlockComponent,
|
||||
SelectedStyle,
|
||||
} from '@blocksuite/affine-components/caption';
|
||||
import type { EmbedCardStyle } from '@blocksuite/affine-model';
|
||||
import {
|
||||
EMBED_CARD_HEIGHT,
|
||||
EMBED_CARD_MIN_WIDTH,
|
||||
EMBED_CARD_WIDTH,
|
||||
} from '@blocksuite/affine-shared/consts';
|
||||
import {
|
||||
DocModeProvider,
|
||||
DragHandleConfigExtension,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
captureEventTarget,
|
||||
convertDragPreviewDocToEdgeless,
|
||||
convertDragPreviewEdgelessToDoc,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import { type BlockService, isGfxBlockComponent } from '@blocksuite/block-std';
|
||||
import type { GfxCompatibleProps } from '@blocksuite/block-std/gfx';
|
||||
import type { BlockModel } from '@blocksuite/store';
|
||||
import type { TemplateResult } from 'lit';
|
||||
import { html } from 'lit';
|
||||
import { query } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { type StyleInfo, styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
export const EmbedDragHandleOption = DragHandleConfigExtension({
|
||||
flavour: /affine:embed-*/,
|
||||
edgeless: true,
|
||||
onDragEnd: props => {
|
||||
const { state, draggingElements } = props;
|
||||
if (
|
||||
draggingElements.length !== 1 ||
|
||||
draggingElements[0].model.flavour.match(/affine:embed-*/) === null
|
||||
)
|
||||
return false;
|
||||
|
||||
const blockComponent = draggingElements[0] as EmbedBlockComponent;
|
||||
const isInSurface = isGfxBlockComponent(blockComponent);
|
||||
const target = captureEventTarget(state.raw.target);
|
||||
const isTargetEdgelessContainer =
|
||||
target?.classList.contains('edgeless-container');
|
||||
|
||||
if (isInSurface) {
|
||||
const style = blockComponent._cardStyle;
|
||||
const targetStyle =
|
||||
style === 'vertical' || style === 'cube' ? 'horizontal' : style;
|
||||
return convertDragPreviewEdgelessToDoc({
|
||||
blockComponent,
|
||||
style: targetStyle,
|
||||
...props,
|
||||
});
|
||||
} else if (isTargetEdgelessContainer) {
|
||||
const style = blockComponent._cardStyle;
|
||||
|
||||
return convertDragPreviewDocToEdgeless({
|
||||
blockComponent,
|
||||
cssSelector: '.embed-block-container',
|
||||
width: EMBED_CARD_WIDTH[style],
|
||||
height: EMBED_CARD_HEIGHT[style],
|
||||
...props,
|
||||
});
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
export class EmbedBlockComponent<
|
||||
Model extends BlockModel<GfxCompatibleProps> = BlockModel<GfxCompatibleProps>,
|
||||
Service extends BlockService = BlockService,
|
||||
WidgetName extends string = string,
|
||||
> extends CaptionedBlockComponent<Model, Service, WidgetName> {
|
||||
private _fetchAbortController = new AbortController();
|
||||
|
||||
_cardStyle: EmbedCardStyle = 'horizontal';
|
||||
|
||||
/**
|
||||
* The actual rendered scale of the embed card.
|
||||
* By default, it is set to 1.
|
||||
*/
|
||||
protected _scale = 1;
|
||||
|
||||
blockDraggable = true;
|
||||
|
||||
/**
|
||||
* The style of the embed card.
|
||||
* You can use this to change the height and width of the card.
|
||||
* By default, the height and width are set to `_cardHeight` and `_cardWidth` respectively.
|
||||
*/
|
||||
protected embedContainerStyle: StyleInfo = {};
|
||||
|
||||
renderEmbed = (content: () => TemplateResult) => {
|
||||
if (
|
||||
this._cardStyle === 'horizontal' ||
|
||||
this._cardStyle === 'horizontalThin' ||
|
||||
this._cardStyle === 'list'
|
||||
) {
|
||||
this.style.display = 'block';
|
||||
|
||||
const mode = this.std.get(DocModeProvider).getEditorMode();
|
||||
if (mode === 'edgeless') {
|
||||
this.style.minWidth = `${EMBED_CARD_MIN_WIDTH}px`;
|
||||
}
|
||||
}
|
||||
|
||||
const selected = !!this.selected?.is('block');
|
||||
return html`
|
||||
<div
|
||||
draggable="${this.blockDraggable ? 'true' : 'false'}"
|
||||
class=${classMap({
|
||||
'embed-block-container': true,
|
||||
'selected-style': selected,
|
||||
})}
|
||||
style=${styleMap({
|
||||
height: `${this._cardHeight}px`,
|
||||
width: '100%',
|
||||
...this.embedContainerStyle,
|
||||
})}
|
||||
>
|
||||
${content()}
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
/**
|
||||
* The height of the current embed card. Changes based on the card style.
|
||||
*/
|
||||
get _cardHeight() {
|
||||
return EMBED_CARD_HEIGHT[this._cardStyle];
|
||||
}
|
||||
|
||||
/**
|
||||
* The width of the current embed card. Changes based on the card style.
|
||||
*/
|
||||
get _cardWidth() {
|
||||
return EMBED_CARD_WIDTH[this._cardStyle];
|
||||
}
|
||||
|
||||
get fetchAbortController() {
|
||||
return this._fetchAbortController;
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
if (this._fetchAbortController.signal.aborted)
|
||||
this._fetchAbortController = new AbortController();
|
||||
|
||||
this.contentEditable = 'false';
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this._fetchAbortController.abort();
|
||||
}
|
||||
|
||||
protected override accessor blockContainerStyles: StyleInfo | undefined = {
|
||||
margin: '18px 0',
|
||||
};
|
||||
|
||||
@query('.embed-block-container')
|
||||
protected accessor embedBlock!: HTMLDivElement;
|
||||
|
||||
override accessor selectedStyle = SelectedStyle.Border;
|
||||
|
||||
override accessor useCaptionEditor = true;
|
||||
|
||||
override accessor useZeroWidth = true;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { css } from 'lit';
|
||||
|
||||
export const embedNoteContentStyles = css`
|
||||
.affine-embed-doc-content-note-blocks affine-divider,
|
||||
.affine-embed-doc-content-note-blocks affine-divider > * {
|
||||
margin-top: 0px !important;
|
||||
margin-bottom: 0px !important;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph,
|
||||
.affine-embed-doc-content-note-blocks affine-list {
|
||||
margin-top: 4px !important;
|
||||
margin-bottom: 4px !important;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph *,
|
||||
.affine-embed-doc-content-note-blocks affine-list * {
|
||||
margin-top: 0px !important;
|
||||
margin-bottom: 0px !important;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
line-height: 20px;
|
||||
font-size: var(--affine-font-xs);
|
||||
font-weight: 400;
|
||||
}
|
||||
.affine-embed-doc-content-note-blocks affine-list .affine-list-block__prefix {
|
||||
height: 20px;
|
||||
}
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph .quote {
|
||||
padding-left: 15px;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph:has(.h1),
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph:has(.h2),
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph:has(.h3),
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph:has(.h4),
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph:has(.h5),
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph:has(.h6) {
|
||||
margin-top: 6px !important;
|
||||
margin-bottom: 4px !important;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph:has(.h1) *,
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph:has(.h2) *,
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph:has(.h3) *,
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph:has(.h4) *,
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph:has(.h5) *,
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph:has(.h6) * {
|
||||
margin-top: 0px !important;
|
||||
margin-bottom: 0px !important;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
line-height: 20px;
|
||||
font-size: var(--affine-font-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.affine-embed-linked-doc-block.horizontal {
|
||||
affine-paragraph,
|
||||
affine-list {
|
||||
margin-top: 0 !important;
|
||||
margin-bottom: 0 !important;
|
||||
max-height: 40px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
}
|
||||
affine-paragraph .quote {
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
height: 28px;
|
||||
}
|
||||
affine-paragraph .quote::after {
|
||||
height: 20px;
|
||||
margin-top: 4px !important;
|
||||
margin-bottom: 4px !important;
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,81 @@
|
||||
import { SurfaceBlockComponent } from '@blocksuite/affine-block-surface';
|
||||
import type { EmbedCardStyle } from '@blocksuite/affine-model';
|
||||
import {
|
||||
EMBED_CARD_HEIGHT,
|
||||
EMBED_CARD_WIDTH,
|
||||
} from '@blocksuite/affine-shared/consts';
|
||||
import type { BlockStdScope } from '@blocksuite/block-std';
|
||||
import { Bound, Vec } from '@blocksuite/global/utils';
|
||||
|
||||
interface EmbedCardProperties {
|
||||
flavour: string;
|
||||
targetStyle: EmbedCardStyle;
|
||||
props: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function insertEmbedCard(
|
||||
std: BlockStdScope,
|
||||
properties: EmbedCardProperties
|
||||
) {
|
||||
const { host } = std;
|
||||
const { flavour, targetStyle, props } = properties;
|
||||
const selectionManager = host.selection;
|
||||
|
||||
let blockId: string | undefined;
|
||||
const textSelection = selectionManager.find('text');
|
||||
const blockSelection = selectionManager.find('block');
|
||||
const surfaceSelection = selectionManager.find('surface');
|
||||
if (textSelection) {
|
||||
blockId = textSelection.blockId;
|
||||
} else if (blockSelection) {
|
||||
blockId = blockSelection.blockId;
|
||||
} else if (surfaceSelection && surfaceSelection.editing) {
|
||||
blockId = surfaceSelection.blockId;
|
||||
}
|
||||
|
||||
if (blockId) {
|
||||
const block = host.view.getBlock(blockId);
|
||||
if (!block) return;
|
||||
const parent = host.doc.getParent(block.model);
|
||||
if (!parent) return;
|
||||
const index = parent.children.indexOf(block.model);
|
||||
host.doc.addBlock(flavour as never, props, parent, index + 1);
|
||||
} else {
|
||||
const rootId = std.doc.root?.id;
|
||||
if (!rootId) return;
|
||||
const edgelessRoot = std.view.getBlock(rootId);
|
||||
if (!edgelessRoot) return;
|
||||
|
||||
// @ts-expect-error TODO: fix after edgeless refactor
|
||||
edgelessRoot.service.viewport.smoothZoom(1);
|
||||
// @ts-expect-error TODO: fix after edgeless refactor
|
||||
const surfaceBlock = edgelessRoot.surface;
|
||||
if (!(surfaceBlock instanceof SurfaceBlockComponent)) return;
|
||||
const center = Vec.toVec(surfaceBlock.renderer.viewport.center);
|
||||
// @ts-expect-error TODO: fix after edgeless refactor
|
||||
const cardId = edgelessRoot.service.addBlock(
|
||||
flavour,
|
||||
{
|
||||
...props,
|
||||
xywh: Bound.fromCenter(
|
||||
center,
|
||||
EMBED_CARD_WIDTH[targetStyle],
|
||||
EMBED_CARD_HEIGHT[targetStyle]
|
||||
).serialize(),
|
||||
style: targetStyle,
|
||||
},
|
||||
surfaceBlock.model
|
||||
);
|
||||
|
||||
// @ts-expect-error TODO: fix after edgeless refactor
|
||||
edgelessRoot.service.selection.set({
|
||||
elements: [cardId],
|
||||
editing: false,
|
||||
});
|
||||
|
||||
// @ts-expect-error TODO: fix after edgeless refactor
|
||||
edgelessRoot.tools.setEdgelessTool({
|
||||
type: 'default',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { LinkPreviewData } from '@blocksuite/affine-model';
|
||||
import { DEFAULT_LINK_PREVIEW_ENDPOINT } from '@blocksuite/affine-shared/consts';
|
||||
import { isAbortError } from '@blocksuite/affine-shared/utils';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
|
||||
export type LinkPreviewResponseData = {
|
||||
url: string;
|
||||
title?: string;
|
||||
siteName?: string;
|
||||
description?: string;
|
||||
images?: string[];
|
||||
mediaType?: string;
|
||||
contentType?: string;
|
||||
charset?: string;
|
||||
videos?: string[];
|
||||
favicons?: string[];
|
||||
};
|
||||
|
||||
export class LinkPreviewer {
|
||||
private _endpoint = DEFAULT_LINK_PREVIEW_ENDPOINT;
|
||||
|
||||
query = async (
|
||||
url: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<Partial<LinkPreviewData>> => {
|
||||
if (
|
||||
(url.startsWith('https://x.com/') ||
|
||||
url.startsWith('https://www.x.com/') ||
|
||||
url.startsWith('https://www.twitter.com/') ||
|
||||
url.startsWith('https://twitter.com/')) &&
|
||||
url.includes('/status/')
|
||||
) {
|
||||
// use api.fxtwitter.com
|
||||
url =
|
||||
'https://api.fxtwitter.com/status/' + /\/status\/(.*)/.exec(url)?.[1];
|
||||
try {
|
||||
const { tweet } = await fetch(url, { signal }).then(res => res.json());
|
||||
return {
|
||||
title: tweet.author.name,
|
||||
icon: tweet.author.avatar_url,
|
||||
description: tweet.text,
|
||||
image: tweet.media?.photos?.[0].url || tweet.author.banner_url,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(`Failed to fetch tweet: ${url}`);
|
||||
console.error(e);
|
||||
return {};
|
||||
}
|
||||
} else {
|
||||
const response = await fetch(this._endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url,
|
||||
}),
|
||||
signal,
|
||||
})
|
||||
.then(r => {
|
||||
if (!r || !r.ok) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.DefaultRuntimeError,
|
||||
`Failed to fetch link preview: ${url}`
|
||||
);
|
||||
}
|
||||
return r;
|
||||
})
|
||||
.catch(err => {
|
||||
if (isAbortError(err)) return null;
|
||||
console.error(`Failed to fetch link preview: ${url}`);
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!response) return {};
|
||||
|
||||
const data: LinkPreviewResponseData = await response.json();
|
||||
return {
|
||||
title: data.title ? this._getStringFromHTML(data.title) : null,
|
||||
description: data.description
|
||||
? this._getStringFromHTML(data.description)
|
||||
: null,
|
||||
icon: data.favicons?.[0],
|
||||
image: data.images?.[0],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
setEndpoint = (endpoint: string) => {
|
||||
this._endpoint = endpoint;
|
||||
};
|
||||
|
||||
private _getStringFromHTML(html: string) {
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = html;
|
||||
return div.textContent;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import type { SurfaceBlockModel } from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
type DocMode,
|
||||
type ImageBlockModel,
|
||||
NoteDisplayMode,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { EMBED_CARD_HEIGHT } from '@blocksuite/affine-shared/consts';
|
||||
import { matchFlavours, SpecProvider } from '@blocksuite/affine-shared/utils';
|
||||
import { BlockStdScope } from '@blocksuite/block-std';
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
import {
|
||||
type BlockModel,
|
||||
BlockViewType,
|
||||
type Doc,
|
||||
type Query,
|
||||
} from '@blocksuite/store';
|
||||
import { render, type TemplateResult } from 'lit';
|
||||
|
||||
import type { EmbedLinkedDocBlockComponent } from '../embed-linked-doc-block/index.js';
|
||||
import type { EmbedSyncedDocCard } from '../embed-synced-doc-block/components/embed-synced-doc-card.js';
|
||||
|
||||
export function renderLinkedDocInCard(
|
||||
card: EmbedLinkedDocBlockComponent | EmbedSyncedDocCard
|
||||
) {
|
||||
const linkedDoc = card.linkedDoc;
|
||||
assertExists(
|
||||
linkedDoc,
|
||||
`Trying to load page ${card.model.pageId} in linked page block, but the page is not found.`
|
||||
);
|
||||
|
||||
// eslint-disable-next-line sonarjs/no-collapsible-if
|
||||
if ('bannerContainer' in card) {
|
||||
if (card.editorMode === 'page') {
|
||||
renderPageAsBanner(card).catch(e => {
|
||||
console.error(e);
|
||||
card.isError = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
renderNoteContent(card).catch(e => {
|
||||
console.error(e);
|
||||
card.isError = true;
|
||||
});
|
||||
}
|
||||
|
||||
async function renderPageAsBanner(card: EmbedSyncedDocCard) {
|
||||
const linkedDoc = card.linkedDoc;
|
||||
assertExists(
|
||||
linkedDoc,
|
||||
`Trying to load page ${card.model.pageId} in linked page block, but the page is not found.`
|
||||
);
|
||||
|
||||
const notes = getNotesFromDoc(linkedDoc);
|
||||
if (!notes) {
|
||||
card.isBannerEmpty = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const target = notes.flatMap(note =>
|
||||
note.children.filter(child => matchFlavours(child, ['affine:image']))
|
||||
)[0];
|
||||
|
||||
if (target) {
|
||||
await renderImageAsBanner(card, target);
|
||||
return;
|
||||
}
|
||||
|
||||
card.isBannerEmpty = true;
|
||||
}
|
||||
|
||||
async function renderImageAsBanner(
|
||||
card: EmbedSyncedDocCard,
|
||||
image: BlockModel
|
||||
) {
|
||||
const sourceId = (image as ImageBlockModel).sourceId;
|
||||
if (!sourceId) return;
|
||||
|
||||
const storage = card.linkedDoc?.blobSync;
|
||||
if (!storage) return;
|
||||
|
||||
const blob = await storage.get(sourceId);
|
||||
if (!blob) return;
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const $img = document.createElement('img');
|
||||
$img.src = url;
|
||||
await addCover(card, $img);
|
||||
|
||||
card.isBannerEmpty = false;
|
||||
}
|
||||
|
||||
async function addCover(
|
||||
card: EmbedSyncedDocCard,
|
||||
cover: HTMLElement | TemplateResult<1>
|
||||
) {
|
||||
const coverContainer = await card.bannerContainer;
|
||||
if (!coverContainer) return;
|
||||
while (coverContainer.firstChild) {
|
||||
coverContainer.firstChild.remove();
|
||||
}
|
||||
|
||||
if (cover instanceof HTMLElement) {
|
||||
coverContainer.append(cover);
|
||||
} else {
|
||||
render(cover, coverContainer);
|
||||
}
|
||||
}
|
||||
|
||||
async function renderNoteContent(
|
||||
card: EmbedLinkedDocBlockComponent | EmbedSyncedDocCard
|
||||
) {
|
||||
card.isNoteContentEmpty = true;
|
||||
|
||||
const doc = card.linkedDoc;
|
||||
assertExists(
|
||||
doc,
|
||||
`Trying to load page ${card.model.pageId} in linked page block, but the page is not found.`
|
||||
);
|
||||
|
||||
const notes = getNotesFromDoc(doc);
|
||||
if (!notes) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cardStyle = card.model.style;
|
||||
const isHorizontal = cardStyle === 'horizontal';
|
||||
const allowFlavours: (keyof BlockSuite.BlockModels)[] = isHorizontal
|
||||
? []
|
||||
: ['affine:image'];
|
||||
|
||||
const noteChildren = notes.flatMap(note =>
|
||||
note.children.filter(model => {
|
||||
if (matchFlavours(model, allowFlavours)) {
|
||||
return true;
|
||||
}
|
||||
return filterTextModel(model);
|
||||
})
|
||||
);
|
||||
|
||||
if (!noteChildren.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
card.isNoteContentEmpty = false;
|
||||
|
||||
const noteContainer = await card.noteContainer;
|
||||
|
||||
if (!noteContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
while (noteContainer.firstChild) {
|
||||
noteContainer.firstChild.remove();
|
||||
}
|
||||
|
||||
const noteBlocksContainer = document.createElement('div');
|
||||
noteBlocksContainer.classList.add('affine-embed-doc-content-note-blocks');
|
||||
noteBlocksContainer.contentEditable = 'false';
|
||||
noteContainer.append(noteBlocksContainer);
|
||||
|
||||
if (isHorizontal) {
|
||||
// When the card is horizontal, we only render the first block
|
||||
noteChildren.splice(1);
|
||||
} else {
|
||||
// Before rendering, we can not know the height of each block
|
||||
// But we can limit the number of blocks to render simply by the height of the card
|
||||
const cardHeight = EMBED_CARD_HEIGHT[cardStyle];
|
||||
const minSingleBlockHeight = 20;
|
||||
const maxBlockCount = Math.floor(cardHeight / minSingleBlockHeight);
|
||||
if (noteChildren.length > maxBlockCount) {
|
||||
noteChildren.splice(maxBlockCount);
|
||||
}
|
||||
}
|
||||
const childIds = noteChildren.map(child => child.id);
|
||||
const ids: string[] = [];
|
||||
childIds.forEach(block => {
|
||||
let parent: string | null = block;
|
||||
while (parent && !ids.includes(parent)) {
|
||||
ids.push(parent);
|
||||
parent = doc.blockCollection.crud.getParent(parent);
|
||||
}
|
||||
});
|
||||
const query: Query = {
|
||||
mode: 'strict',
|
||||
match: ids.map(id => ({ id, viewType: BlockViewType.Display })),
|
||||
};
|
||||
const previewDoc = doc.blockCollection.getDoc({ query });
|
||||
const previewSpec = SpecProvider.getInstance().getSpec('page:preview');
|
||||
const previewStd = new BlockStdScope({
|
||||
doc: previewDoc,
|
||||
extensions: previewSpec.value,
|
||||
});
|
||||
const previewTemplate = previewStd.render();
|
||||
const fragment = document.createDocumentFragment();
|
||||
render(previewTemplate, fragment);
|
||||
noteBlocksContainer.append(fragment);
|
||||
const contentEditableElements = noteBlocksContainer.querySelectorAll(
|
||||
'[contenteditable="true"]'
|
||||
);
|
||||
contentEditableElements.forEach(element => {
|
||||
(element as HTMLElement).contentEditable = 'false';
|
||||
});
|
||||
}
|
||||
|
||||
function filterTextModel(model: BlockModel) {
|
||||
if (matchFlavours(model, ['affine:paragraph', 'affine:list'])) {
|
||||
return !!model.text?.toString().length;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getNotesFromDoc(doc: Doc) {
|
||||
const notes = doc.root?.children.filter(
|
||||
child =>
|
||||
matchFlavours(child, ['affine:note']) &&
|
||||
child.displayMode !== NoteDisplayMode.EdgelessOnly
|
||||
);
|
||||
|
||||
if (!notes || !notes.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return notes;
|
||||
}
|
||||
|
||||
export function isEmptyDoc(doc: Doc | null, mode: DocMode) {
|
||||
if (!doc) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (mode === 'page') {
|
||||
const notes = getNotesFromDoc(doc);
|
||||
if (!notes || !notes.length) {
|
||||
return true;
|
||||
}
|
||||
return notes.every(note => isEmptyNote(note));
|
||||
} else {
|
||||
const surface = getSurfaceBlock(doc);
|
||||
if (surface?.elementModels.length || doc.blockSize > 2) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function isEmptyNote(note: BlockModel) {
|
||||
return note.children.every(block => {
|
||||
return (
|
||||
block.flavour === 'affine:paragraph' &&
|
||||
(!block.text || block.text.length === 0)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function getSurfaceBlock(doc: Doc) {
|
||||
const blocks = doc.getBlocksByFlavour('affine:surface');
|
||||
return blocks.length !== 0 ? (blocks[0].model as SurfaceBlockModel) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the document content with a max length.
|
||||
*/
|
||||
export function getDocContentWithMaxLength(doc: Doc, maxlength = 500) {
|
||||
const notes = getNotesFromDoc(doc);
|
||||
if (!notes) return;
|
||||
|
||||
const noteChildren = notes.flatMap(note =>
|
||||
note.children.filter(model => filterTextModel(model))
|
||||
);
|
||||
if (!noteChildren.length) return;
|
||||
|
||||
let count = 0;
|
||||
let reached = false;
|
||||
const texts = [];
|
||||
|
||||
for (const model of noteChildren) {
|
||||
let t = model.text?.toString();
|
||||
if (t?.length) {
|
||||
const c: number = count + Math.max(0, texts.length - 1);
|
||||
|
||||
if (t.length + c > maxlength) {
|
||||
t = t.substring(0, maxlength - c);
|
||||
reached = true;
|
||||
}
|
||||
|
||||
texts.push(t);
|
||||
count += t.length;
|
||||
|
||||
if (reached) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return texts.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
blockComponentSymbol,
|
||||
type BlockService,
|
||||
type GfxBlockComponent,
|
||||
GfxElementSymbol,
|
||||
toGfxBlockComponent,
|
||||
} from '@blocksuite/block-std';
|
||||
import type {
|
||||
GfxBlockElementModel,
|
||||
GfxCompatibleProps,
|
||||
} from '@blocksuite/block-std/gfx';
|
||||
import { Bound } from '@blocksuite/global/utils';
|
||||
import type { StyleInfo } from 'lit/directives/style-map.js';
|
||||
|
||||
import type { EmbedBlockComponent } from './embed-block-element.js';
|
||||
|
||||
export function toEdgelessEmbedBlock<
|
||||
Model extends GfxBlockElementModel<GfxCompatibleProps>,
|
||||
Service extends BlockService,
|
||||
WidgetName extends string,
|
||||
B extends typeof EmbedBlockComponent<Model, Service, WidgetName>,
|
||||
>(block: B) {
|
||||
return class extends toGfxBlockComponent(block) {
|
||||
_isDragging = false;
|
||||
|
||||
_isResizing = false;
|
||||
|
||||
_isSelected = false;
|
||||
|
||||
_showOverlay = false;
|
||||
|
||||
override [blockComponentSymbol] = true;
|
||||
|
||||
override blockDraggable = false;
|
||||
|
||||
protected override embedContainerStyle: StyleInfo = {};
|
||||
|
||||
override [GfxElementSymbol] = true;
|
||||
|
||||
get bound(): Bound {
|
||||
return Bound.deserialize(this.model.xywh);
|
||||
}
|
||||
|
||||
get rootService() {
|
||||
return this.std.getService('affine:page');
|
||||
}
|
||||
|
||||
_handleClick(_: MouseEvent): void {
|
||||
return;
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
const rootService = this.rootService;
|
||||
|
||||
this._disposables.add(
|
||||
// @ts-expect-error TODO: fix after edgeless slots are migrated to extension
|
||||
rootService.slots.elementResizeStart.on(() => {
|
||||
this._isResizing = true;
|
||||
this._showOverlay = true;
|
||||
})
|
||||
);
|
||||
|
||||
this._disposables.add(
|
||||
// @ts-expect-error TODO: fix after edgeless slots are migrated to extension
|
||||
rootService.slots.elementResizeEnd.on(() => {
|
||||
this._isResizing = false;
|
||||
this._showOverlay =
|
||||
this._isResizing || this._isDragging || !this._isSelected;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override renderGfxBlock() {
|
||||
const bound = Bound.deserialize(this.model.xywh);
|
||||
|
||||
this.embedContainerStyle.width = `${bound.w}px`;
|
||||
this.embedContainerStyle.height = `${bound.h}px`;
|
||||
this.blockContainerStyles = {
|
||||
width: `${bound.w}px`,
|
||||
};
|
||||
this._scale = bound.w / this._cardWidth;
|
||||
|
||||
return this.renderPageContent();
|
||||
}
|
||||
|
||||
protected override accessor blockContainerStyles: StyleInfo | undefined =
|
||||
undefined;
|
||||
} as B & {
|
||||
new (...args: any[]): GfxBlockComponent;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
DarkLoadingIcon,
|
||||
EmbedCardDarkBannerIcon,
|
||||
EmbedCardDarkCubeIcon,
|
||||
EmbedCardDarkHorizontalIcon,
|
||||
EmbedCardDarkListIcon,
|
||||
EmbedCardDarkVerticalIcon,
|
||||
EmbedCardLightBannerIcon,
|
||||
EmbedCardLightCubeIcon,
|
||||
EmbedCardLightHorizontalIcon,
|
||||
EmbedCardLightListIcon,
|
||||
EmbedCardLightVerticalIcon,
|
||||
LightLoadingIcon,
|
||||
} from '@blocksuite/affine-components/icons';
|
||||
import { ColorScheme } from '@blocksuite/affine-model';
|
||||
import type { TemplateResult } from 'lit';
|
||||
|
||||
type EmbedCardIcons = {
|
||||
LoadingIcon: TemplateResult<1>;
|
||||
EmbedCardBannerIcon: TemplateResult<1>;
|
||||
EmbedCardHorizontalIcon: TemplateResult<1>;
|
||||
EmbedCardListIcon: TemplateResult<1>;
|
||||
EmbedCardVerticalIcon: TemplateResult<1>;
|
||||
EmbedCardCubeIcon: TemplateResult<1>;
|
||||
};
|
||||
|
||||
export function getEmbedCardIcons(theme: ColorScheme): EmbedCardIcons {
|
||||
if (theme === ColorScheme.Light) {
|
||||
return {
|
||||
LoadingIcon: LightLoadingIcon,
|
||||
EmbedCardBannerIcon: EmbedCardLightBannerIcon,
|
||||
EmbedCardHorizontalIcon: EmbedCardLightHorizontalIcon,
|
||||
EmbedCardListIcon: EmbedCardLightListIcon,
|
||||
EmbedCardVerticalIcon: EmbedCardLightVerticalIcon,
|
||||
EmbedCardCubeIcon: EmbedCardLightCubeIcon,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
LoadingIcon: DarkLoadingIcon,
|
||||
EmbedCardBannerIcon: EmbedCardDarkBannerIcon,
|
||||
EmbedCardHorizontalIcon: EmbedCardDarkHorizontalIcon,
|
||||
EmbedCardListIcon: EmbedCardDarkListIcon,
|
||||
EmbedCardVerticalIcon: EmbedCardDarkVerticalIcon,
|
||||
EmbedCardCubeIcon: EmbedCardDarkCubeIcon,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user