chore: merge blocksuite source code (#9213)

This commit is contained in:
Mirone
2024-12-20 15:38:06 +08:00
committed by GitHub
parent 2c9ef916f4
commit 30200ff86d
2031 changed files with 238888 additions and 229 deletions

View File

@@ -0,0 +1,100 @@
{
"name": "@blocksuite/affine",
"description": "BlockSuite for Affine",
"type": "module",
"scripts": {
"build": "tsc --build --verbose",
"test:unit": "nx vite:test --run --passWithNoTests",
"test:unit:coverage": "nx vite:test --run --coverage",
"test:e2e": "playwright test"
},
"sideEffects": false,
"keywords": [],
"author": "toeverything",
"license": "MIT",
"dependencies": {
"@blocksuite/block-std": "workspace:*",
"@blocksuite/blocks": "workspace:*",
"@blocksuite/global": "workspace:*",
"@blocksuite/inline": "workspace:*",
"@blocksuite/presets": "workspace:*",
"@blocksuite/store": "workspace:*"
},
"exports": {
".": "./src/index.ts",
"./effects": "./src/effects.ts",
"./block-std": "./src/block-std/index.ts",
"./block-std/gfx": "./src/block-std/gfx.ts",
"./global": "./src/global/index.ts",
"./global/utils": "./src/global/utils.ts",
"./global/env": "./src/global/env.ts",
"./global/exceptions": "./src/global/exceptions.ts",
"./global/di": "./src/global/di.ts",
"./global/types": "./src/global/types.ts",
"./store": "./src/store/index.ts",
"./inline": "./src/inline/index.ts",
"./inline/consts": "./src/inline/consts.ts",
"./inline/types": "./src/inline/types.ts",
"./presets": "./src/presets/index.ts",
"./blocks": "./src/blocks/index.ts",
"./blocks/schemas": "./src/blocks/schemas.ts"
},
"typesVersions": {
"*": {
"effects": [
"dist/effects.d.ts"
],
"block-std": [
"dist/block-std/index.d.ts"
],
"block-std/gfx": [
"dist/block-std/gfx.d.ts"
],
"global": [
"dist/global/index.d.ts"
],
"global/utils": [
"dist/global/utils.d.ts"
],
"global/env": [
"dist/global/env.d.ts"
],
"global/exceptions": [
"dist/global/exceptions.d.ts"
],
"global/di": [
"dist/global/di.d.ts"
],
"global/types": [
"dist/global/types.d.ts"
],
"store": [
"dist/store/index.d.ts"
],
"inline": [
"dist/inline/index.d.ts"
],
"inline/consts": [
"dist/inline/consts.d.ts"
],
"inline/types": [
"dist/inline/types.d.ts"
],
"presets": [
"dist/presets/index.d.ts"
],
"blocks": [
"dist/blocks/index.d.ts"
],
"blocks/schemas": [
"dist/blocks/schemas.d.ts"
]
}
},
"files": [
"src",
"dist",
"!src/__tests__",
"!dist/__tests__"
]
}

View File

@@ -0,0 +1 @@
export * from '@blocksuite/block-std/gfx';

View File

@@ -0,0 +1 @@
export * from '@blocksuite/block-std';

View File

@@ -0,0 +1 @@
export * from '@blocksuite/blocks';

View File

@@ -0,0 +1 @@
export * from '@blocksuite/blocks/schemas';

View File

@@ -0,0 +1,7 @@
import { effects as blocksEffects } from '@blocksuite/blocks/effects';
import { effects as presetsEffects } from '@blocksuite/presets/effects';
export function effects() {
blocksEffects();
presetsEffects();
}

View File

@@ -0,0 +1 @@
export * from '@blocksuite/global/di';

View File

@@ -0,0 +1 @@
export * from '@blocksuite/global/env';

View File

@@ -0,0 +1 @@
export * from '@blocksuite/global/exceptions';

View File

@@ -0,0 +1 @@
export * from '@blocksuite/global';

View File

@@ -0,0 +1 @@
export * from '@blocksuite/global/types';

View File

@@ -0,0 +1 @@
export * from '@blocksuite/global/utils';

View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1 @@
export * from '@blocksuite/inline/consts';

View File

@@ -0,0 +1 @@
export * from '@blocksuite/inline';

View File

@@ -0,0 +1 @@
export * from '@blocksuite/inline/types';

View File

@@ -0,0 +1 @@
export * from '@blocksuite/presets';

View File

@@ -0,0 +1,5 @@
/* eslint-disable @typescript-eslint/no-restricted-imports */
// oxlint-disable-next-line
// @ts-ignore FIXME: typecheck error
export * from '@blocksuite/store';

View File

@@ -0,0 +1,29 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src/",
"outDir": "./dist/",
"noEmit": false
},
"include": ["./src"],
"references": [
{
"path": "../../framework/global"
},
{
"path": "../../framework/store"
},
{
"path": "../../framework/block-std"
},
{
"path": "../../framework/inline"
},
{
"path": "../../blocks"
},
{
"path": "../../presets"
}
]
}

View File

@@ -0,0 +1,4 @@
{
"extends": ["../../../typedoc.base.json"],
"entryPoints": ["src/index.ts"]
}

View File

@@ -0,0 +1,30 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
esbuild: {
target: 'es2018',
},
test: {
globalSetup: '../../../scripts/vitest-global.ts',
include: ['src/__tests__/**/*.unit.spec.ts'],
testTimeout: 1000,
coverage: {
provider: 'istanbul', // or 'c8'
reporter: ['lcov'],
reportsDirectory: '../../../.coverage/affine',
},
/**
* Custom handler for console.log in tests.
*
* Return `false` to ignore the log.
*/
onConsoleLog(log, type) {
if (log.includes('https://lit.dev/msg/dev-mode')) {
return false;
}
console.warn(`Unexpected ${type} log`, log);
throw new Error(log);
},
environment: 'happy-dom',
},
});

View File

@@ -0,0 +1,43 @@
{
"name": "@blocksuite/affine-block-embed",
"description": "Embed blocks for BlockSuite.",
"type": "module",
"scripts": {
"build": "tsc",
"test:unit": "nx vite:test --run --passWithNoTests",
"test:unit:coverage": "nx vite:test --run --coverage",
"test:e2e": "playwright test"
},
"sideEffects": false,
"keywords": [],
"author": "toeverything",
"license": "MIT",
"dependencies": {
"@blocksuite/affine-block-surface": "workspace:*",
"@blocksuite/affine-components": "workspace:*",
"@blocksuite/affine-model": "workspace:*",
"@blocksuite/affine-shared": "workspace:*",
"@blocksuite/block-std": "workspace:*",
"@blocksuite/global": "workspace:*",
"@blocksuite/icons": "^2.1.75",
"@blocksuite/inline": "workspace:*",
"@blocksuite/store": "workspace:*",
"@floating-ui/dom": "^1.6.10",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.1",
"lit": "^3.2.0",
"minimatch": "^10.0.1",
"zod": "^3.23.8"
},
"exports": {
".": "./src/index.ts",
"./effects": "./src/effects.ts"
},
"files": [
"src",
"dist",
"!src/__tests__",
"!dist/__tests__"
]
}

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}
}
`;

View File

@@ -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',
});
}
}

View File

@@ -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;
}
}

View File

@@ -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');
}

View File

@@ -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;
};
}

View File

@@ -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,
};
}
}

View File

@@ -0,0 +1,131 @@
import { EmbedEdgelessBlockComponent } from './embed-figma-block/embed-edgeless-figma-block.js';
import type { EmbedFigmaBlockService } from './embed-figma-block/embed-figma-service.js';
import { EmbedFigmaBlockComponent } from './embed-figma-block/index.js';
import { EmbedEdgelessGithubBlockComponent } from './embed-github-block/embed-edgeless-github-block.js';
import {
EmbedGithubBlockComponent,
type EmbedGithubBlockService,
} from './embed-github-block/index.js';
import { EmbedHtmlFullscreenToolbar } from './embed-html-block/components/fullscreen-toolbar.js';
import { EmbedEdgelessHtmlBlockComponent } from './embed-html-block/embed-edgeless-html-block.js';
import { EmbedHtmlBlockComponent } from './embed-html-block/index.js';
import type { insertEmbedLinkedDocCommand } from './embed-linked-doc-block/commands/insert-embed-linked-doc.js';
import type {
InsertedLinkType,
insertLinkByQuickSearchCommand,
} from './embed-linked-doc-block/commands/insert-link-by-quick-search.js';
import { EmbedEdgelessLinkedDocBlockComponent } from './embed-linked-doc-block/embed-edgeless-linked-doc-block.js';
import type { EmbedLinkedDocBlockConfig } from './embed-linked-doc-block/embed-linked-doc-config.js';
import { EmbedLinkedDocBlockComponent } from './embed-linked-doc-block/index.js';
import { EmbedEdgelessLoomBlockComponent } from './embed-loom-block/embed-edgeless-loom-bock.js';
import {
EmbedLoomBlockComponent,
type EmbedLoomBlockService,
} from './embed-loom-block/index.js';
import { EmbedSyncedDocCard } from './embed-synced-doc-block/components/embed-synced-doc-card.js';
import { EmbedEdgelessSyncedDocBlockComponent } from './embed-synced-doc-block/embed-edgeless-synced-doc-block.js';
import { EmbedSyncedDocBlockComponent } from './embed-synced-doc-block/index.js';
import { EmbedEdgelessYoutubeBlockComponent } from './embed-youtube-block/embed-edgeless-youtube-block.js';
import {
EmbedYoutubeBlockComponent,
type EmbedYoutubeBlockService,
} from './embed-youtube-block/index.js';
export function effects() {
customElements.define(
'affine-embed-edgeless-figma-block',
EmbedEdgelessBlockComponent
);
customElements.define('affine-embed-figma-block', EmbedFigmaBlockComponent);
customElements.define('affine-embed-html-block', EmbedHtmlBlockComponent);
customElements.define(
'affine-embed-edgeless-html-block',
EmbedEdgelessHtmlBlockComponent
);
customElements.define(
'embed-html-fullscreen-toolbar',
EmbedHtmlFullscreenToolbar
);
customElements.define(
'affine-embed-edgeless-github-block',
EmbedEdgelessGithubBlockComponent
);
customElements.define('affine-embed-github-block', EmbedGithubBlockComponent);
customElements.define(
'affine-embed-edgeless-youtube-block',
EmbedEdgelessYoutubeBlockComponent
);
customElements.define(
'affine-embed-youtube-block',
EmbedYoutubeBlockComponent
);
customElements.define(
'affine-embed-edgeless-loom-block',
EmbedEdgelessLoomBlockComponent
);
customElements.define('affine-embed-loom-block', EmbedLoomBlockComponent);
customElements.define('affine-embed-synced-doc-card', EmbedSyncedDocCard);
customElements.define(
'affine-embed-edgeless-linked-doc-block',
EmbedEdgelessLinkedDocBlockComponent
);
customElements.define(
'affine-embed-linked-doc-block',
EmbedLinkedDocBlockComponent
);
customElements.define(
'affine-embed-edgeless-synced-doc-block',
EmbedEdgelessSyncedDocBlockComponent
);
customElements.define(
'affine-embed-synced-doc-block',
EmbedSyncedDocBlockComponent
);
}
declare global {
interface HTMLElementTagNameMap {
'affine-embed-figma-block': EmbedFigmaBlockComponent;
'affine-embed-edgeless-figma-block': EmbedEdgelessBlockComponent;
'affine-embed-github-block': EmbedGithubBlockComponent;
'affine-embed-edgeless-github-block': EmbedEdgelessGithubBlockComponent;
'affine-embed-html-block': EmbedHtmlBlockComponent;
'affine-embed-edgeless-html-block': EmbedEdgelessHtmlBlockComponent;
'embed-html-fullscreen-toolbar': EmbedHtmlFullscreenToolbar;
'affine-embed-edgeless-loom-block': EmbedEdgelessLoomBlockComponent;
'affine-embed-loom-block': EmbedLoomBlockComponent;
'affine-embed-youtube-block': EmbedYoutubeBlockComponent;
'affine-embed-edgeless-youtube-block': EmbedEdgelessYoutubeBlockComponent;
'affine-embed-synced-doc-card': EmbedSyncedDocCard;
'affine-embed-synced-doc-block': EmbedSyncedDocBlockComponent;
'affine-embed-edgeless-synced-doc-block': EmbedEdgelessSyncedDocBlockComponent;
'affine-embed-linked-doc-block': EmbedLinkedDocBlockComponent;
'affine-embed-edgeless-linked-doc-block': EmbedEdgelessLinkedDocBlockComponent;
}
namespace BlockSuite {
interface BlockServices {
'affine:embed-figma': EmbedFigmaBlockService;
'affine:embed-github': EmbedGithubBlockService;
'affine:embed-loom': EmbedLoomBlockService;
'affine:embed-youtube': EmbedYoutubeBlockService;
}
interface BlockConfigs {
'affine:embed-linked-doc': EmbedLinkedDocBlockConfig;
}
interface CommandContext {
insertedLinkType?: Promise<InsertedLinkType>;
}
interface Commands {
insertEmbedLinkedDoc: typeof insertEmbedLinkedDocCommand;
insertLinkByQuickSearch: typeof insertLinkByQuickSearchCommand;
}
}
}

View File

@@ -0,0 +1,11 @@
import { EmbedFigmaBlockSchema } from '@blocksuite/affine-model';
import { BlockHtmlAdapterExtension } from '@blocksuite/affine-shared/adapters';
import { createEmbedBlockHtmlAdapterMatcher } from '../../common/adapters/html.js';
export const embedFigmaBlockHtmlAdapterMatcher =
createEmbedBlockHtmlAdapterMatcher(EmbedFigmaBlockSchema.model.flavour);
export const EmbedFigmaBlockHtmlAdapterExtension = BlockHtmlAdapterExtension(
embedFigmaBlockHtmlAdapterMatcher
);

View File

@@ -0,0 +1,3 @@
export * from './html.js';
export * from './markdown.js';
export * from './plain-text.js';

View File

@@ -0,0 +1,11 @@
import { EmbedFigmaBlockSchema } from '@blocksuite/affine-model';
import { BlockMarkdownAdapterExtension } from '@blocksuite/affine-shared/adapters';
import { createEmbedBlockMarkdownAdapterMatcher } from '../../common/adapters/markdown.js';
export const embedFigmaBlockMarkdownAdapterMatcher =
createEmbedBlockMarkdownAdapterMatcher(EmbedFigmaBlockSchema.model.flavour);
export const EmbedFigmaMarkdownAdapterExtension = BlockMarkdownAdapterExtension(
embedFigmaBlockMarkdownAdapterMatcher
);

View File

@@ -0,0 +1,10 @@
import { EmbedFigmaBlockSchema } from '@blocksuite/affine-model';
import { BlockPlainTextAdapterExtension } from '@blocksuite/affine-shared/adapters';
import { createEmbedBlockPlainTextAdapterMatcher } from '../../common/adapters/plain-text.js';
export const embedFigmaBlockPlainTextAdapterMatcher =
createEmbedBlockPlainTextAdapterMatcher(EmbedFigmaBlockSchema.model.flavour);
export const EmbedFigmaBlockPlainTextAdapterExtension =
BlockPlainTextAdapterExtension(embedFigmaBlockPlainTextAdapterMatcher);

View File

@@ -0,0 +1,6 @@
import { toEdgelessEmbedBlock } from '../common/to-edgeless-embed-block.js';
import { EmbedFigmaBlockComponent } from './embed-figma-block.js';
export class EmbedEdgelessBlockComponent extends toEdgelessEmbedBlock(
EmbedFigmaBlockComponent
) {}

View File

@@ -0,0 +1,166 @@
import { OpenIcon } from '@blocksuite/affine-components/icons';
import type {
EmbedFigmaModel,
EmbedFigmaStyles,
} from '@blocksuite/affine-model';
import { html } from 'lit';
import { state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { styleMap } from 'lit/directives/style-map.js';
import { EmbedBlockComponent } from '../common/embed-block-element.js';
import type { EmbedFigmaBlockService } from './embed-figma-service.js';
import { FigmaIcon, styles } from './styles.js';
export class EmbedFigmaBlockComponent extends EmbedBlockComponent<
EmbedFigmaModel,
EmbedFigmaBlockService
> {
static override styles = styles;
override _cardStyle: (typeof EmbedFigmaStyles)[number] = 'figma';
protected _isDragging = false;
protected _isResizing = false;
open = () => {
let link = this.model.url;
if (!link.match(/^[a-zA-Z]+:\/\//)) {
link = 'https://' + link;
}
window.open(link, '_blank');
};
refreshData = () => {};
private _handleDoubleClick(event: MouseEvent) {
event.stopPropagation();
this.open();
}
private _selectBlock() {
const selectionManager = this.host.selection;
const blockSelection = selectionManager.create('block', {
blockId: this.blockId,
});
selectionManager.setGroup('note', [blockSelection]);
}
protected _handleClick(event: MouseEvent) {
event.stopPropagation();
this._selectBlock();
}
override connectedCallback() {
super.connectedCallback();
this._cardStyle = this.model.style;
if (!this.model.description && !this.model.title) {
this.doc.withoutTransact(() => {
this.doc.updateBlock(this.model, {
title: 'Figma',
description: this.model.url,
});
});
}
this.disposables.add(
this.model.propsUpdated.on(({ key }) => {
if (key === 'url') {
this.refreshData();
}
})
);
// this is required to prevent iframe from capturing pointer events
this.disposables.add(
this.std.selection.slots.changed.on(() => {
this._isSelected =
!!this.selected?.is('block') || !!this.selected?.is('surface');
this._showOverlay =
this._isResizing || this._isDragging || !this._isSelected;
})
);
// this is required to prevent iframe from capturing pointer events
this.handleEvent('dragStart', () => {
this._isDragging = true;
this._showOverlay =
this._isResizing || this._isDragging || !this._isSelected;
});
this.handleEvent('dragEnd', () => {
this._isDragging = false;
this._showOverlay =
this._isResizing || this._isDragging || !this._isSelected;
});
}
override renderBlock() {
const { title, description, url } = this.model;
const titleText = title ?? 'Figma';
const descriptionText = description ?? url;
return this.renderEmbed(
() => html`
<div
class=${classMap({
'affine-embed-figma-block': true,
selected: this._isSelected,
})}
style=${styleMap({
transform: `scale(${this._scale})`,
transformOrigin: '0 0',
})}
@click=${this._handleClick}
@dblclick=${this._handleDoubleClick}
>
<div class="affine-embed-figma">
<div class="affine-embed-figma-iframe-container">
<iframe
src=${`https://www.figma.com/embed?embed_host=blocksuite&url=${url}`}
allowfullscreen
loading="lazy"
></iframe>
<div
class=${classMap({
'affine-embed-figma-iframe-overlay': true,
hide: !this._showOverlay,
})}
></div>
</div>
</div>
<div class="affine-embed-figma-content">
<div class="affine-embed-figma-content-header">
<div class="affine-embed-figma-content-title-icon">
${FigmaIcon}
</div>
<div class="affine-embed-figma-content-title-text">
${titleText}
</div>
</div>
<div class="affine-embed-figma-content-description">
${descriptionText}
</div>
<div class="affine-embed-figma-content-url" @click=${this.open}>
<span>www.figma.com</span>
<div class="affine-embed-figma-content-url-icon">${OpenIcon}</div>
</div>
</div>
</div>
`
);
}
@state()
protected accessor _isSelected = false;
@state()
protected accessor _showOverlay = true;
}

View File

@@ -0,0 +1,2 @@
export const figmaUrlRegex: RegExp =
/https:\/\/[\w.-]+\.?figma.com\/([\w-]+)\/([0-9a-zA-Z]{22,128})(?:\/.*)?$/;

View File

@@ -0,0 +1,23 @@
import {
EmbedFigmaBlockSchema,
EmbedFigmaStyles,
} from '@blocksuite/affine-model';
import { EmbedOptionProvider } from '@blocksuite/affine-shared/services';
import { BlockService } from '@blocksuite/block-std';
import { figmaUrlRegex } from './embed-figma-model.js';
export class EmbedFigmaBlockService extends BlockService {
static override readonly flavour = EmbedFigmaBlockSchema.model.flavour;
override mounted() {
super.mounted();
this.std.get(EmbedOptionProvider).registerEmbedBlockOptions({
flavour: this.flavour,
urlRegex: figmaUrlRegex,
styles: EmbedFigmaStyles,
viewType: 'embed',
});
}
}

View File

@@ -0,0 +1,18 @@
import {
BlockViewExtension,
type ExtensionType,
FlavourExtension,
} from '@blocksuite/block-std';
import { literal } from 'lit/static-html.js';
import { EmbedFigmaBlockService } from './embed-figma-service.js';
export const EmbedFigmaBlockSpec: ExtensionType[] = [
FlavourExtension('affine:embed-figma'),
EmbedFigmaBlockService,
BlockViewExtension('affine:embed-figma', model => {
return model.parent?.flavour === 'affine:surface'
? literal`affine-embed-edgeless-figma-block`
: literal`affine-embed-figma-block`;
}),
];

View File

@@ -0,0 +1,5 @@
export * from './adapters/index.js';
export * from './embed-figma-block.js';
export * from './embed-figma-model.js';
export * from './embed-figma-spec.js';
export { FigmaIcon } from './styles.js';

View File

@@ -0,0 +1,229 @@
import {
EMBED_CARD_HEIGHT,
EMBED_CARD_WIDTH,
} from '@blocksuite/affine-shared/consts';
import { css, html } from 'lit';
export const styles = css`
.affine-embed-figma-block {
width: ${EMBED_CARD_WIDTH.figma}px;
display: flex;
flex-direction: column;
gap: 20px;
padding: 12px;
border-radius: 8px;
border: 1px solid var(--affine-background-tertiary-color);
opacity: var(--add, 1);
background: var(--affine-background-primary-color);
user-select: none;
aspect-ratio: ${EMBED_CARD_WIDTH.figma} / ${EMBED_CARD_HEIGHT.figma};
}
.affine-embed-figma {
flex-grow: 1;
width: 100%;
opacity: var(--add, 1);
}
.affine-embed-figma img,
.affine-embed-figma object,
.affine-embed-figma svg {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 4px 4px var(--1, 0px) var(--1, 0px);
}
.affine-embed-figma-iframe-container {
height: 100%;
position: relative;
}
.affine-embed-figma-iframe-container > iframe {
width: 100%;
height: 100%;
border-radius: 4px 4px var(--1, 0px) var(--1, 0px);
border: none;
}
.affine-embed-figma-iframe-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.affine-embed-figma-iframe-overlay.hide {
display: none;
}
.affine-embed-figma-content {
display: block;
flex-direction: column;
width: 100%;
height: fit-content;
border-radius: var(--1, 0px);
opacity: var(--add, 1);
}
.affine-embed-figma-content-header {
display: flex;
flex-direction: row;
gap: 8px;
align-items: center;
align-self: stretch;
padding: var(--1, 0px);
border-radius: var(--1, 0px);
opacity: var(--add, 1);
}
.affine-embed-figma-content-title-icon {
display: flex;
width: 20px;
height: 20px;
justify-content: center;
align-items: center;
}
.affine-embed-figma-content-title-icon img,
.affine-embed-figma-content-title-icon object,
.affine-embed-figma-content-title-icon svg {
width: 20px;
height: 20px;
fill: var(--affine-background-primary-color);
}
.affine-embed-figma-content-title-text {
flex: 1 0 0;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
word-break: break-word;
overflow: hidden;
text-overflow: ellipsis;
color: var(--affine-text-primary-color);
font-family: var(--affine-font-family);
font-size: var(--affine-font-sm);
font-style: normal;
font-weight: 600;
line-height: 22px;
}
.affine-embed-figma-content-description {
height: 40px;
position: relative;
word-break: break-word;
white-space: normal;
overflow: hidden;
text-overflow: ellipsis;
color: var(--affine-text-primary-color);
font-family: var(--affine-font-family);
font-size: var(--affine-font-xs);
font-style: normal;
font-weight: 400;
line-height: 20px;
}
.affine-embed-figma-content-description::after {
content: '...';
position: absolute;
right: 0;
bottom: 0;
background-color: var(--affine-background-primary-color);
}
.affine-embed-figma-content-url {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 4px;
width: max-content;
max-width: 100%;
cursor: pointer;
}
.affine-embed-figma-content-url > span {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
word-break: break-all;
white-space: normal;
overflow: hidden;
text-overflow: ellipsis;
color: var(--affine-text-secondary-color);
font-family: var(--affine-font-family);
font-size: var(--affine-font-xs);
font-style: normal;
font-weight: 400;
line-height: 20px;
}
.affine-embed-figma-content-url:hover > span {
color: var(--affine-link-color);
}
.affine-embed-figma-content-url:hover .open-icon {
fill: var(--affine-link-color);
}
.affine-embed-figma-content-url-icon {
display: flex;
align-items: center;
justify-content: center;
width: 12px;
height: 12px;
}
.affine-embed-figma-content-url-icon .open-icon {
height: 12px;
width: 12px;
fill: var(--affine-text-secondary-color);
}
.affine-embed-figma-block.selected {
.affine-embed-figma-content-url > span {
color: var(--affine-link-color);
}
.affine-embed-figma-content-url .open-icon {
fill: var(--affine-link-color);
}
}
`;
export const FigmaIcon = html`<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7.66898 17.9165C9.00426 17.9165 10.088 16.7342 10.088 15.2776V12.6387H7.66898C6.3337 12.6387 5.25 13.8209 5.25 15.2776C5.25 16.7342 6.3337 17.9165 7.66898 17.9165Z"
fill="#0ACF83"
/>
<path
d="M5.25 10.0002C5.25 8.54355 6.3337 7.36133 7.66898 7.36133H10.088V12.6391H7.66898C6.3337 12.6391 5.25 11.4569 5.25 10.0002Z"
fill="#A259FF"
/>
<path
d="M5.25 4.72238C5.25 3.26572 6.3337 2.0835 7.66898 2.0835H10.088V7.36127H7.66898C6.3337 7.36127 5.25 6.17905 5.25 4.72238Z"
fill="#F24E1E"
/>
<path
d="M10.0879 2.0835H12.5069C13.8421 2.0835 14.9259 3.26572 14.9259 4.72238C14.9259 6.17905 13.8421 7.36127 12.5069 7.36127H10.0879V2.0835Z"
fill="#FF7262"
/>
<path
d="M14.9259 10.0002C14.9259 11.4569 13.8421 12.6391 12.5069 12.6391C11.1716 12.6391 10.0879 11.4569 10.0879 10.0002C10.0879 8.54355 11.1716 7.36133 12.5069 7.36133C13.8421 7.36133 14.9259 8.54355 14.9259 10.0002Z"
fill="#1ABCFE"
/>
</svg>`;

View File

@@ -0,0 +1,11 @@
import { EmbedGithubBlockSchema } from '@blocksuite/affine-model';
import { BlockHtmlAdapterExtension } from '@blocksuite/affine-shared/adapters';
import { createEmbedBlockHtmlAdapterMatcher } from '../../common/adapters/html.js';
export const embedGithubBlockHtmlAdapterMatcher =
createEmbedBlockHtmlAdapterMatcher(EmbedGithubBlockSchema.model.flavour);
export const EmbedGithubBlockHtmlAdapterExtension = BlockHtmlAdapterExtension(
embedGithubBlockHtmlAdapterMatcher
);

View File

@@ -0,0 +1,3 @@
export * from './html.js';
export * from './markdown.js';
export * from './plain-text.js';

View File

@@ -0,0 +1,10 @@
import { EmbedGithubBlockSchema } from '@blocksuite/affine-model';
import { BlockMarkdownAdapterExtension } from '@blocksuite/affine-shared/adapters';
import { createEmbedBlockMarkdownAdapterMatcher } from '../../common/adapters/markdown.js';
export const embedGithubBlockMarkdownAdapterMatcher =
createEmbedBlockMarkdownAdapterMatcher(EmbedGithubBlockSchema.model.flavour);
export const EmbedGithubMarkdownAdapterExtension =
BlockMarkdownAdapterExtension(embedGithubBlockMarkdownAdapterMatcher);

View File

@@ -0,0 +1,10 @@
import { EmbedGithubBlockSchema } from '@blocksuite/affine-model';
import { BlockPlainTextAdapterExtension } from '@blocksuite/affine-shared/adapters';
import { createEmbedBlockPlainTextAdapterMatcher } from '../../common/adapters/plain-text.js';
export const embedGithubBlockPlainTextAdapterMatcher =
createEmbedBlockPlainTextAdapterMatcher(EmbedGithubBlockSchema.model.flavour);
export const EmbedGithubBlockPlainTextAdapterExtension =
BlockPlainTextAdapterExtension(embedGithubBlockPlainTextAdapterMatcher);

View File

@@ -0,0 +1,6 @@
import { toEdgelessEmbedBlock } from '../common/to-edgeless-embed-block.js';
import { EmbedGithubBlockComponent } from './embed-github-block.js';
export class EmbedEdgelessGithubBlockComponent extends toEdgelessEmbedBlock(
EmbedGithubBlockComponent
) {}

View File

@@ -0,0 +1,275 @@
import { OpenIcon } from '@blocksuite/affine-components/icons';
import type {
EmbedGithubModel,
EmbedGithubStyles,
} from '@blocksuite/affine-model';
import { ThemeProvider } from '@blocksuite/affine-shared/services';
import { html, nothing } from 'lit';
import { property, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';
import { EmbedBlockComponent } from '../common/embed-block-element.js';
import { getEmbedCardIcons } from '../common/utils.js';
import { githubUrlRegex } from './embed-github-model.js';
import type { EmbedGithubBlockService } from './embed-github-service.js';
import { GithubIcon, styles } from './styles.js';
import {
getGithubStatusIcon,
refreshEmbedGithubStatus,
refreshEmbedGithubUrlData,
} from './utils.js';
export class EmbedGithubBlockComponent extends EmbedBlockComponent<
EmbedGithubModel,
EmbedGithubBlockService
> {
static override styles = styles;
override _cardStyle: (typeof EmbedGithubStyles)[number] = 'horizontal';
open = () => {
let link = this.model.url;
if (!link.match(/^[a-zA-Z]+:\/\//)) {
link = 'https://' + link;
}
window.open(link, '_blank');
};
refreshData = () => {
refreshEmbedGithubUrlData(this, this.fetchAbortController.signal).catch(
console.error
);
};
refreshStatus = () => {
refreshEmbedGithubStatus(this, this.fetchAbortController.signal).catch(
console.error
);
};
private _handleAssigneeClick(assignee: string) {
const link = `https://www.github.com/${assignee}`;
window.open(link, '_blank');
}
private _handleDoubleClick(event: MouseEvent) {
event.stopPropagation();
this.open();
}
private _selectBlock() {
const selectionManager = this.host.selection;
const blockSelection = selectionManager.create('block', {
blockId: this.blockId,
});
selectionManager.setGroup('note', [blockSelection]);
}
protected _handleClick(event: MouseEvent) {
event.stopPropagation();
this._selectBlock();
}
override connectedCallback() {
super.connectedCallback();
this._cardStyle = this.model.style;
if (!this.model.owner || !this.model.repo || !this.model.githubId) {
this.doc.withoutTransact(() => {
const url = this.model.url;
const urlMatch = url.match(githubUrlRegex);
if (urlMatch) {
const [, owner, repo, githubType, githubId] = urlMatch;
this.doc.updateBlock(this.model, {
owner,
repo,
githubType: githubType === 'issue' ? 'issue' : 'pr',
githubId,
});
}
});
}
this.doc.withoutTransact(() => {
if (!this.model.description && !this.model.title) {
this.refreshData();
} else {
this.refreshStatus();
}
});
this.disposables.add(
this.model.propsUpdated.on(({ key }) => {
if (key === 'url') {
this.refreshData();
}
})
);
this.disposables.add(
this.selection.slots.changed.on(() => {
this._isSelected =
!!this.selected?.is('block') || !!this.selected?.is('surface');
})
);
}
override renderBlock() {
const {
title = 'GitHub',
githubType,
status,
statusReason,
owner,
repo,
createdAt,
assignees,
description,
image,
style,
} = this.model;
const loading = this.loading;
const theme = this.std.get(ThemeProvider).theme;
const { LoadingIcon, EmbedCardBannerIcon } = getEmbedCardIcons(theme);
const titleIcon = loading ? LoadingIcon : GithubIcon;
const statusIcon = status
? getGithubStatusIcon(githubType, status, statusReason)
: nothing;
const statusText = loading ? '' : status;
const titleText = loading ? 'Loading...' : title;
const descriptionText = loading ? '' : description;
const bannerImage =
!loading && image
? html`<object type="image/webp" data=${image} draggable="false">
${EmbedCardBannerIcon}
</object>`
: EmbedCardBannerIcon;
let dateText = '';
if (createdAt) {
const date = new Date(createdAt);
dateText = date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
});
const day = date.getDate();
const suffix =
['th', 'st', 'nd', 'rd'][((day / 10) | 0) !== 1 ? day % 10 : 4] || 'th';
dateText = dateText.replace(/\d+/, `${day}${suffix}`);
}
return this.renderEmbed(
() => html`
<div
class=${classMap({
'affine-embed-github-block': true,
loading,
[style]: true,
selected: this._isSelected,
})}
style=${styleMap({
transform: `scale(${this._scale})`,
transformOrigin: '0 0 ',
})}
@click=${this._handleClick}
@dblclick=${this._handleDoubleClick}
>
<div class="affine-embed-github-banner">${bannerImage}</div>
<div class="affine-embed-github-content">
<div class="affine-embed-github-content-title">
<div class="affine-embed-github-content-title-icons">
<div class="affine-embed-github-content-title-site-icon">
${titleIcon}
</div>
${status && statusText
? html`<div
class=${classMap({
'affine-embed-github-content-title-status-icon': true,
[githubType]: true,
[status]: true,
success: statusReason === 'completed',
failure: statusReason === 'not_planned',
})}
>
${statusIcon}
<span>${statusText}</span>
</div>`
: nothing}
</div>
<div class="affine-embed-github-content-title-text">
${titleText}
</div>
</div>
<div class="affine-embed-github-content-description">
${descriptionText}
</div>
${githubType === 'issue' && assignees
? html`
<div class="affine-embed-github-content-assignees">
<div
class="affine-embed-github-content-assignees-text label"
>
Assignees
</div>
<div
class="affine-embed-github-content-assignees-text users"
>
${assignees.length === 0
? html`<span
class="affine-embed-github-content-assignees-text-users placeholder"
>No one</span
>`
: repeat(
assignees,
assignee => assignee,
(assignee, index) =>
html`<span
class="affine-embed-github-content-assignees-text-users user"
@click=${() =>
this._handleAssigneeClick(assignee)}
>${`@${assignee}`}</span
>
${index === assignees.length - 1 ? '' : `, `}`
)}
</div>
</div>
`
: nothing}
<div class="affine-embed-github-content-url" @click=${this.open}>
<span class="affine-embed-github-content-repo"
>${`${owner}/${repo} |`}</span
>
${createdAt
? html`<span class="affine-embed-github-content-date"
>${dateText} |</span
>`
: nothing}
<span>github.com</span>
<div class="affine-embed-github-content-url-icon">
${OpenIcon}
</div>
</div>
</div>
</div>
`
);
}
@state()
private accessor _isSelected = false;
@property({ attribute: false })
accessor loading = false;
}

View File

@@ -0,0 +1,2 @@
export const githubUrlRegex: RegExp =
/^(?:https?:\/\/)?(?:www\.)?github\.com\/([^/]+)\/([^/]+)\/(issue|pull)s?\/(\d+)$/;

View File

@@ -0,0 +1,43 @@
import {
EmbedGithubBlockSchema,
type EmbedGithubModel,
EmbedGithubStyles,
} from '@blocksuite/affine-model';
import { EmbedOptionProvider } from '@blocksuite/affine-shared/services';
import { BlockService } from '@blocksuite/block-std';
import { LinkPreviewer } from '../common/link-previewer.js';
import { githubUrlRegex } from './embed-github-model.js';
import { queryEmbedGithubApiData, queryEmbedGithubData } from './utils.js';
export class EmbedGithubBlockService extends BlockService {
static override readonly flavour = EmbedGithubBlockSchema.model.flavour;
private static readonly linkPreviewer = new LinkPreviewer();
static setLinkPreviewEndpoint =
EmbedGithubBlockService.linkPreviewer.setEndpoint;
queryApiData = (embedGithubModel: EmbedGithubModel, signal?: AbortSignal) => {
return queryEmbedGithubApiData(embedGithubModel, signal);
};
queryUrlData = (embedGithubModel: EmbedGithubModel, signal?: AbortSignal) => {
return queryEmbedGithubData(
embedGithubModel,
EmbedGithubBlockService.linkPreviewer,
signal
);
};
override mounted() {
super.mounted();
this.std.get(EmbedOptionProvider).registerEmbedBlockOptions({
flavour: this.flavour,
urlRegex: githubUrlRegex,
styles: EmbedGithubStyles,
viewType: 'card',
});
}
}

View File

@@ -0,0 +1,18 @@
import {
BlockViewExtension,
type ExtensionType,
FlavourExtension,
} from '@blocksuite/block-std';
import { literal } from 'lit/static-html.js';
import { EmbedGithubBlockService } from './embed-github-service.js';
export const EmbedGithubBlockSpec: ExtensionType[] = [
FlavourExtension('affine:embed-github'),
EmbedGithubBlockService,
BlockViewExtension('affine:embed-github', model => {
return model.parent?.flavour === 'affine:surface'
? literal`affine-embed-edgeless-github-block`
: literal`affine-embed-github-block`;
}),
];

View File

@@ -0,0 +1,5 @@
export * from './adapters/index.js';
export * from './embed-github-block.js';
export * from './embed-github-service.js';
export * from './embed-github-spec.js';
export { GithubIcon } from './styles.js';

View File

@@ -0,0 +1,513 @@
import {
EMBED_CARD_HEIGHT,
EMBED_CARD_WIDTH,
} from '@blocksuite/affine-shared/consts';
import { css, html } from 'lit';
export const styles = css`
.affine-embed-github-block {
box-sizing: border-box;
display: flex;
width: 100%;
height: ${EMBED_CARD_HEIGHT.horizontal}px;
border-radius: 8px;
border: 1px solid var(--affine-background-tertiary-color);
opacity: var(--add, 1);
background: var(--affine-background-primary-color);
user-select: none;
}
.affine-embed-github-content {
display: flex;
flex-grow: 1;
flex-direction: column;
align-self: stretch;
gap: 4px;
padding: 12px;
border-radius: var(--1, 0px);
opacity: var(--add, 1);
}
.affine-embed-github-content-title {
display: flex;
min-height: 22px;
flex-direction: row;
gap: 8px;
align-items: center;
align-self: stretch;
padding: var(--1, 0px);
border-radius: var(--1, 0px);
opacity: var(--add, 1);
}
.affine-embed-github-content-title-icons {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
}
.affine-embed-github-content-title-icons img,
.affine-embed-github-content-title-icons object,
.affine-embed-github-content-title-icons svg {
width: 16px;
height: 16px;
color: var(--affine-pure-white);
}
.affine-embed-github-content-title-site-icon {
display: flex;
width: 16px;
height: 16px;
justify-content: center;
align-items: center;
.github-icon {
fill: var(--affine-black);
color: var(--affine-black);
}
}
.affine-embed-github-content-title-status-icon {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 6px;
border-radius: 20px;
color: var(--affine-pure-white);
leading-trim: both;
text-edge: cap;
font-feature-settings:
'clig' off,
'liga' off;
text-transform: capitalize;
font-family: var(--affine-font-family);
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 16px;
}
.affine-embed-github-content-title-status-icon.issue.open {
background: #238636;
}
.affine-embed-github-content-title-status-icon.issue.closed.success {
background: #8957e5;
}
.affine-embed-github-content-title-status-icon.issue.closed.failure {
background: #6e7681;
}
.affine-embed-github-content-title-status-icon.pr.open {
background: #238636;
}
.affine-embed-github-content-title-status-icon.pr.draft {
background: #6e7681;
}
.affine-embed-github-content-title-status-icon.pr.merged {
background: #8957e5;
}
.affine-embed-github-content-title-status-icon.pr.closed {
background: #c03737;
}
.affine-embed-github-content-title-status-icon > svg {
height: 16px;
width: 16px;
padding: 2px;
}
.affine-embed-github-content-title-status-icon > span {
padding: 0px 1.5px;
}
.affine-embed-github-content-title-text {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
word-break: break-word;
overflow: hidden;
text-overflow: ellipsis;
color: var(--affine-text-primary-color);
font-family: var(--affine-font-family);
font-size: var(--affine-font-sm);
font-style: normal;
font-weight: 600;
line-height: 22px;
}
.affine-embed-github-content-description {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
flex-grow: 1;
word-break: break-word;
white-space: normal;
overflow: hidden;
text-overflow: ellipsis;
color: var(--affine-text-primary-color);
font-family: var(--affine-font-family);
font-size: var(--affine-font-xs);
font-style: normal;
font-weight: 400;
line-height: 20px;
}
.affine-embed-github-content-assignees {
display: none;
}
.affine-embed-github-content-url {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 4px;
width: max-content;
max-width: 100%;
cursor: pointer;
}
.affine-embed-github-content-url > span {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
word-break: break-all;
white-space: normal;
overflow: hidden;
text-overflow: ellipsis;
color: var(--affine-text-secondary-color);
font-family: var(--affine-font-family);
font-size: var(--affine-font-xs);
font-style: normal;
font-weight: 400;
line-height: 20px;
}
.affine-embed-github-content-url:hover > span {
color: var(--affine-link-color);
}
.affine-embed-github-content-url:hover .open-icon {
fill: var(--affine-link-color);
}
.affine-embed-github-content-url-icon {
display: flex;
align-items: center;
justify-content: center;
width: 12px;
height: 12px;
}
.affine-embed-github-content-url-icon .open-icon {
height: 12px;
width: 12px;
fill: var(--affine-text-secondary-color);
}
.affine-embed-github-banner {
margin: 12px 0px 0px 12px;
width: 204px;
height: 102px;
opacity: var(--add, 1);
}
.affine-embed-github-banner img,
.affine-embed-github-banner object,
.affine-embed-github-banner svg {
width: 204px;
height: 102px;
object-fit: cover;
border-radius: 4px 4px var(--1, 0px) var(--1, 0px);
}
.affine-embed-github-block.loading {
.affine-embed-github-content-title-text {
color: var(--affine-placeholder-color);
}
}
.affine-embed-github-block.selected {
.affine-embed-github-content-url > span {
color: var(--affine-link-color);
}
.affine-embed-github-content-url .open-icon {
fill: var(--affine-link-color);
}
}
.affine-embed-github-block.list {
height: ${EMBED_CARD_HEIGHT.list}px;
width: ${EMBED_CARD_WIDTH.list}px;
.affine-embed-github-content {
width: 100%;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.affine-embed-github-content-title {
width: 660px;
}
.affine-embed-github-content-repo {
display: none;
}
.affine-embed-github-content-date {
display: none;
}
.affine-embed-github-content-url {
width: 90px;
justify-content: flex-end;
}
.affine-embed-github-content-description {
display: none;
}
.affine-embed-github-banner {
display: none;
}
}
.affine-embed-github-block.horizontal {
width: ${EMBED_CARD_WIDTH.horizontal}px;
height: ${EMBED_CARD_HEIGHT.horizontal}px;
}
.affine-embed-github-block.vertical {
width: ${EMBED_CARD_WIDTH.vertical}px;
height: ${EMBED_CARD_HEIGHT.vertical}px;
flex-direction: column;
.affine-embed-github-content {
width: 100%;
}
.affine-embed-github-content-description {
-webkit-line-clamp: 6;
}
.affine-embed-github-content-assignees {
display: flex;
padding: var(--1, 0px);
align-items: center;
justify-content: flex-start;
gap: 2px;
align-self: stretch;
}
.affine-embed-github-content-assignees-text {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
font-family: var(--affine-font-family);
font-size: var(--affine-font-xs);
font-style: normal;
font-weight: 600;
line-height: 20px;
}
.affine-embed-github-content-assignees-text.label {
width: 72px;
color: var(--affine-text-primary-color);
font-weight: 600;
}
.affine-embed-github-content-assignees-text.users {
width: calc(100% - 72px);
word-break: break-all;
white-space: normal;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 400;
}
.affine-embed-github-content-assignees-text-users.user {
color: var(--affine-link-color);
cursor: pointer;
}
.affine-embed-github-content-assignees-text-users.placeholder {
color: var(--affine-placeholder-color);
}
.affine-embed-github-banner {
width: 340px;
height: 170px;
margin-left: 12px;
}
.affine-embed-github-banner img,
.affine-embed-github-banner object,
.affine-embed-github-banner svg {
width: 340px;
height: 170px;
}
}
.affine-embed-github-block.cube {
width: ${EMBED_CARD_WIDTH.cube}px;
height: ${EMBED_CARD_HEIGHT.cube}px;
.affine-embed-github-content {
width: 100%;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
}
.affine-embed-github-content-title {
flex-direction: column;
gap: 4px;
align-items: flex-start;
}
.affine-embed-github-content-title-text {
-webkit-line-clamp: 2;
}
.affine-embed-github-content-description {
display: none;
}
.affine-embed-github-banner {
display: none;
}
.affine-embed-github-content-repo {
display: none;
}
.affine-embed-github-content-date {
display: none;
}
}
`;
export const GithubIcon = html`<svg
class="github-icon"
width="20"
height="20"
viewBox="0 0 16 16"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M8.00016 1.33334C4.31683 1.33334 1.3335 4.39214 1.3335 8.16864C1.3335 11.1933 3.24183 13.7479 5.89183 14.6536C6.22516 14.7134 6.35016 14.5084 6.35016 14.3289C6.35016 14.1666 6.34183 13.6283 6.34183 13.0559C4.66683 13.372 4.2335 12.6372 4.10016 12.2527C4.02516 12.0562 3.70016 11.4496 3.41683 11.2872C3.1835 11.1591 2.85016 10.8429 3.4085 10.8344C3.9335 10.8259 4.3085 11.33 4.4335 11.535C5.0335 12.5689 5.99183 12.2784 6.37516 12.0989C6.4335 11.6546 6.6085 11.3556 6.80016 11.1847C5.31683 11.0138 3.76683 10.4243 3.76683 7.80978C3.76683 7.06644 4.02516 6.45127 4.45016 5.9728C4.3835 5.80192 4.15016 5.1013 4.51683 4.16145C4.51683 4.16145 5.07516 3.98202 6.35016 4.86206C6.8835 4.70827 7.45016 4.63137 8.01683 4.63137C8.5835 4.63137 9.15016 4.70827 9.6835 4.86206C10.9585 3.97348 11.5168 4.16145 11.5168 4.16145C11.8835 5.1013 11.6502 5.80192 11.5835 5.9728C12.0085 6.45127 12.2668 7.0579 12.2668 7.80978C12.2668 10.4328 10.7085 11.0138 9.22516 11.1847C9.46683 11.3983 9.67516 11.8084 9.67516 12.4492C9.67516 13.3635 9.66683 14.0983 9.66683 14.3289C9.66683 14.5084 9.79183 14.722 10.1252 14.6536C11.4486 14.1955 12.5986 13.3234 13.4133 12.1601C14.228 10.9968 14.6664 9.60079 14.6668 8.16864C14.6668 4.39214 11.6835 1.33334 8.00016 1.33334Z"
/>
</svg> `;
export const GithubIssueOpenIcon = html`<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"></path>
<path
d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Z"
></path>
</svg>`;
export const GithubIssueClosedSuccessIcon = html`<svg
aria-hidden="true"
height="16"
viewBox="0 0 16 16"
version="1.1"
width="16"
data-view-component="true"
class="octicon octicon-issue-closed flex-items-center mr-1"
fill="currentColor"
>
<path
d="M11.28 6.78a.75.75 0 0 0-1.06-1.06L7.25 8.69 5.78 7.22a.75.75 0 0 0-1.06 1.06l2 2a.75.75 0 0 0 1.06 0l3.5-3.5Z"
></path>
<path
d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0Zm-1.5 0a6.5 6.5 0 1 0-13 0 6.5 6.5 0 0 0 13 0Z"
></path>
</svg>`;
export const GithubIssueClosedFailureIcon = html`<svg
aria-hidden="true"
height="16"
viewBox="0 0 16 16"
version="1.1"
width="16"
data-view-component="true"
class="octicon octicon-skip flex-items-center mr-1"
fill="currentColor"
>
<path
d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm9.78-2.22-5.5 5.5a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l5.5-5.5a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042Z"
></path>
</svg>`;
export const GithubPROpenIcon = html`<svg
height="16"
class="octicon octicon-git-pull-request"
viewBox="0 0 16 16"
version="1.1"
width="16"
aria-hidden="true"
fill="currentColor"
>
<path
d="M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm8.25.75a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Z"
></path>
</svg>`;
export const GithubPRDraftIcon = html`<svg
height="16"
class="octicon octicon-git-pull-request-draft"
viewBox="0 0 16 16"
version="1.1"
width="16"
aria-hidden="true"
fill="currentColor"
>
<path
d="M3.25 1A2.25 2.25 0 0 1 4 5.372v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.251 2.251 0 0 1 3.25 1Zm9.5 14a2.25 2.25 0 1 1 0-4.5 2.25 2.25 0 0 1 0 4.5ZM2.5 3.25a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0ZM3.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm9.5 0a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM14 7.5a1.25 1.25 0 1 1-2.5 0 1.25 1.25 0 0 1 2.5 0Zm0-4.25a1.25 1.25 0 1 1-2.5 0 1.25 1.25 0 0 1 2.5 0Z"
></path>
</svg>`;
export const GithubPRMergedIcon = html`<svg
height="16"
class="octicon octicon-git-merge"
viewBox="0 0 16 16"
version="1.1"
width="16"
aria-hidden="true"
fill="currentColor"
>
<path
d="M5.45 5.154A4.25 4.25 0 0 0 9.25 7.5h1.378a2.251 2.251 0 1 1 0 1.5H9.25A5.734 5.734 0 0 1 5 7.123v3.505a2.25 2.25 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.95-.218ZM4.25 13.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm8.5-4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5ZM5 3.25a.75.75 0 1 0 0 .005V3.25Z"
></path>
</svg>`;
export const GithubPRClosedIcon = html`<svg
height="16"
class="octicon octicon-git-pull-request-closed"
viewBox="0 0 16 16"
version="1.1"
width="16"
aria-hidden="true"
fill="currentColor"
>
<path
d="M3.25 1A2.25 2.25 0 0 1 4 5.372v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.251 2.251 0 0 1 3.25 1Zm9.5 5.5a.75.75 0 0 1 .75.75v3.378a2.251 2.251 0 1 1-1.5 0V7.25a.75.75 0 0 1 .75-.75Zm-2.03-5.273a.75.75 0 0 1 1.06 0l.97.97.97-.97a.748.748 0 0 1 1.265.332.75.75 0 0 1-.205.729l-.97.97.97.97a.751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018l-.97-.97-.97.97a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l.97-.97-.97-.97a.75.75 0 0 1 0-1.06ZM2.5 3.25a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0ZM3.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm9.5 0a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"
></path>
</svg>`;

View File

@@ -0,0 +1,170 @@
import type {
EmbedGithubBlockUrlData,
EmbedGithubModel,
} from '@blocksuite/affine-model';
import { isAbortError } from '@blocksuite/affine-shared/utils';
import { assertExists } from '@blocksuite/global/utils';
import { nothing } from 'lit';
import type { LinkPreviewer } from '../common/link-previewer.js';
import type { EmbedGithubBlockComponent } from './embed-github-block.js';
import {
GithubIssueClosedFailureIcon,
GithubIssueClosedSuccessIcon,
GithubIssueOpenIcon,
GithubPRClosedIcon,
GithubPRDraftIcon,
GithubPRMergedIcon,
GithubPROpenIcon,
} from './styles.js';
export async function queryEmbedGithubData(
embedGithubModel: EmbedGithubModel,
linkPreviewer: LinkPreviewer,
signal?: AbortSignal
): Promise<Partial<EmbedGithubBlockUrlData>> {
const [githubApiData, openGraphData] = await Promise.all([
queryEmbedGithubApiData(embedGithubModel, signal),
linkPreviewer.query(embedGithubModel.url, signal),
]);
return { ...githubApiData, ...openGraphData };
}
export async function queryEmbedGithubApiData(
embedGithubModel: EmbedGithubModel,
signal?: AbortSignal
): Promise<Partial<EmbedGithubBlockUrlData>> {
const { owner, repo, githubType, githubId } = embedGithubModel;
let githubApiData: Partial<EmbedGithubBlockUrlData> = {};
// github's public api has a rate limit of 60 requests per hour
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/${
githubType === 'issue' ? 'issues' : 'pulls'
}/${githubId}`;
const githubApiResponse = await fetch(apiUrl, {
cache: 'no-cache',
signal,
}).catch(() => null);
if (githubApiResponse && githubApiResponse.ok) {
const githubApiJson = await githubApiResponse.json();
const { state, state_reason, draft, merged, created_at, assignees } =
githubApiJson;
const assigneeLogins = assignees.map(
(assignee: { login: string }) => assignee.login
);
let status = state;
if (merged) {
status = 'merged';
} else if (state === 'open' && draft) {
status = 'draft';
}
githubApiData = {
status,
statusReason: state_reason,
createdAt: created_at,
assignees: assigneeLogins,
};
}
return githubApiData;
}
export async function refreshEmbedGithubUrlData(
embedGithubElement: EmbedGithubBlockComponent,
signal?: AbortSignal
): Promise<void> {
let image = null,
status = null,
statusReason = null,
title = null,
description = null,
createdAt = null,
assignees = null;
try {
embedGithubElement.loading = true;
const queryUrlData = embedGithubElement.service?.queryUrlData;
assertExists(queryUrlData);
const githubUrlData = await queryUrlData(embedGithubElement.model);
({
image = null,
status = null,
statusReason = null,
title = null,
description = null,
createdAt = null,
assignees = null,
} = githubUrlData);
if (signal?.aborted) return;
embedGithubElement.doc.updateBlock(embedGithubElement.model, {
image,
status,
statusReason,
title,
description,
createdAt,
assignees,
});
} catch (error) {
if (signal?.aborted || isAbortError(error)) return;
throw Error;
} finally {
embedGithubElement.loading = false;
}
}
export async function refreshEmbedGithubStatus(
embedGithubElement: EmbedGithubBlockComponent,
signal?: AbortSignal
) {
const queryApiData = embedGithubElement.service?.queryApiData;
assertExists(queryApiData);
const githubApiData = await queryApiData(embedGithubElement.model, signal);
if (!githubApiData.status || signal?.aborted) return;
embedGithubElement.doc.updateBlock(embedGithubElement.model, {
status: githubApiData.status,
statusReason: githubApiData.statusReason,
createdAt: githubApiData.createdAt,
assignees: githubApiData.assignees,
});
}
export function getGithubStatusIcon(
type: 'issue' | 'pr',
status: string,
statusReason: string | null
) {
if (type === 'issue') {
if (status === 'open') {
return GithubIssueOpenIcon;
} else if (status === 'closed' && statusReason === 'completed') {
return GithubIssueClosedSuccessIcon;
} else if (status === 'closed' && statusReason === 'not_planned') {
return GithubIssueClosedFailureIcon;
} else {
return nothing;
}
} else if (type === 'pr') {
if (status === 'open') {
return GithubPROpenIcon;
} else if (status === 'draft') {
return GithubPRDraftIcon;
} else if (status === 'merged') {
return GithubPRMergedIcon;
} else if (status === 'closed') {
return GithubPRClosedIcon;
}
}
return nothing;
}

View File

@@ -0,0 +1,171 @@
import {
menu,
popMenu,
popupTargetFromElement,
} from '@blocksuite/affine-components/context-menu';
import { EditPropsStore } from '@blocksuite/affine-shared/services';
import {
CopyIcon,
DoneIcon,
ExpandCloseIcon,
SettingsIcon,
} from '@blocksuite/icons/lit';
import { autoPlacement, flip, offset } from '@floating-ui/dom';
import { css, html, LitElement } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import type { EmbedEdgelessHtmlBlockComponent } from '../embed-edgeless-html-block.js';
export class EmbedHtmlFullscreenToolbar extends LitElement {
static override styles = css`
:host {
box-sizing: border-box;
position: absolute;
z-index: 1;
left: 50%;
transform: translateX(-50%);
bottom: 0;
-webkit-user-select: none;
user-select: none;
}
.toolbar-toggle-control {
padding-bottom: 20px;
}
.toolbar-toggle-control[data-auto-hide='true'] {
transition: 0.27s ease;
padding-top: 100px;
transform: translateY(100px);
}
.toolbar-toggle-control[data-auto-hide='true']:hover {
padding-top: 0;
transform: translateY(0);
}
.fullscreen-toolbar-container {
background: var(--affine-background-overlay-panel-color);
box-shadow: var(--affine-menu-shadow);
border: 1px solid var(--affine-border-color);
border-radius: 40px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 0 20px;
height: 64px;
}
.short-v-divider {
display: inline-block;
background-color: var(--affine-border-color);
width: 1px;
height: 36px;
}
`;
private _popSettings = () => {
this._popperVisible = true;
popMenu(popupTargetFromElement(this._fullScreenToolbarContainer), {
options: {
items: [
() =>
html` <div class="settings-header">
<span>Settings</span>
</div>`,
menu.group({
name: 'thing',
items: [
menu.toggleSwitch({
name: 'Hide toolbar',
on: this.autoHideToolbar,
onChange: on => {
this.autoHideToolbar = on;
},
}),
],
}),
],
onClose: () => {
this._popperVisible = false;
},
},
middleware: [
autoPlacement({ allowedPlacements: ['top-end'] }),
flip(),
offset({ mainAxis: 4, crossAxis: -40 }),
],
container: this.embedHtml.iframeWrapper,
});
};
copyCode = () => {
if (this._copied) return;
this.embedHtml.std.clipboard
.writeToClipboard(items => {
items['text/plain'] = this.embedHtml.model.html ?? '';
return items;
})
.then(() => {
this._copied = true;
setTimeout(() => (this._copied = false), 1500);
})
.catch(console.error);
};
private get autoHideToolbar() {
return (
this.embedHtml.std
.get(EditPropsStore)
.getStorage('autoHideEmbedHTMLFullScreenToolbar') ?? false
);
}
private set autoHideToolbar(val: boolean) {
this.embedHtml.std
.get(EditPropsStore)
.setStorage('autoHideEmbedHTMLFullScreenToolbar', val);
}
override render() {
const hideToolbar = !this._popperVisible && this.autoHideToolbar;
return html`
<div data-auto-hide="${hideToolbar}" class="toolbar-toggle-control">
<div class="fullscreen-toolbar-container">
<icon-button @click="${this.embedHtml.close}"
>${ExpandCloseIcon()}
</icon-button>
<icon-button
@click="${this._popSettings}"
hover="${this._popperVisible}"
>${SettingsIcon()}
</icon-button>
<div class="short-v-divider"></div>
<icon-button class="copy-button" @click="${this.copyCode}"
>${this._copied ? DoneIcon() : CopyIcon()}
</icon-button>
</div>
</div>
`;
}
@state()
private accessor _copied = false;
@query('.fullscreen-toolbar-container')
private accessor _fullScreenToolbarContainer!: HTMLElement;
@state()
private accessor _popperVisible = false;
@property({ attribute: false })
accessor embedHtml!: EmbedEdgelessHtmlBlockComponent;
}

View File

@@ -0,0 +1,6 @@
import { toEdgelessEmbedBlock } from '../common/to-edgeless-embed-block.js';
import { EmbedHtmlBlockComponent } from './embed-html-block.js';
export class EmbedEdgelessHtmlBlockComponent extends toEdgelessEmbedBlock(
EmbedHtmlBlockComponent
) {}

View File

@@ -0,0 +1,145 @@
import type { EmbedHtmlModel, EmbedHtmlStyles } from '@blocksuite/affine-model';
import { html } from 'lit';
import { query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { type StyleInfo, styleMap } from 'lit/directives/style-map.js';
import { EmbedBlockComponent } from '../common/embed-block-element.js';
import { HtmlIcon, styles } from './styles.js';
export class EmbedHtmlBlockComponent extends EmbedBlockComponent<EmbedHtmlModel> {
static override styles = styles;
override _cardStyle: (typeof EmbedHtmlStyles)[number] = 'html';
protected _isDragging = false;
protected _isResizing = false;
close = () => {
document.exitFullscreen().catch(console.error);
};
protected embedHtmlStyle: StyleInfo = {};
open = () => {
this.iframeWrapper?.requestFullscreen().catch(console.error);
};
refreshData = () => {};
private _handleDoubleClick(event: MouseEvent) {
event.stopPropagation();
this.open();
}
private _selectBlock() {
const selectionManager = this.host.selection;
const blockSelection = selectionManager.create('block', {
blockId: this.blockId,
});
selectionManager.setGroup('note', [blockSelection]);
}
protected _handleClick(event: MouseEvent) {
event.stopPropagation();
this._selectBlock();
}
override connectedCallback() {
super.connectedCallback();
this._cardStyle = this.model.style;
// this is required to prevent iframe from capturing pointer events
this.disposables.add(
this.std.selection.slots.changed.on(() => {
this._isSelected =
!!this.selected?.is('block') || !!this.selected?.is('surface');
this._showOverlay =
this._isResizing || this._isDragging || !this._isSelected;
})
);
// this is required to prevent iframe from capturing pointer events
this.handleEvent('dragStart', () => {
this._isDragging = true;
this._showOverlay =
this._isResizing || this._isDragging || !this._isSelected;
});
this.handleEvent('dragEnd', () => {
this._isDragging = false;
this._showOverlay =
this._isResizing || this._isDragging || !this._isSelected;
});
}
override renderBlock(): unknown {
const titleText = 'Basic HTML Page Structure';
const htmlSrc = `
<style>
body {
margin: 0;
}
</style>
${this.model.html}
`;
return this.renderEmbed(() => {
if (!this.model.html) {
return html` <div class="affine-html-empty">Empty</div>`;
}
return html`
<div
class=${classMap({
'affine-embed-html-block': true,
selected: this._isSelected,
})}
style=${styleMap(this.embedHtmlStyle)}
@click=${this._handleClick}
@dblclick=${this._handleDoubleClick}
>
<div class="affine-embed-html">
<div class="affine-embed-html-iframe-container">
<div class="embed-html-block-iframe-wrapper" allowfullscreen>
<iframe
class="embed-html-block-iframe"
sandbox="allow-scripts"
scrolling="no"
.srcdoc=${htmlSrc}
loading="lazy"
></iframe>
<embed-html-fullscreen-toolbar
.embedHtml=${this}
></embed-html-fullscreen-toolbar>
</div>
<div
class=${classMap({
'affine-embed-html-iframe-overlay': true,
hide: !this._showOverlay,
})}
></div>
</div>
</div>
<div class="affine-embed-html-title">
<div class="affine-embed-html-title-icon">${HtmlIcon}</div>
<div class="affine-embed-html-title-text">${titleText}</div>
</div>
</div>
`;
});
}
@state()
protected accessor _isSelected = false;
@state()
protected accessor _showOverlay = true;
@query('.embed-html-block-iframe-wrapper')
accessor iframeWrapper!: HTMLDivElement;
}

View File

@@ -0,0 +1,10 @@
import { BlockViewExtension, type ExtensionType } from '@blocksuite/block-std';
import { literal } from 'lit/static-html.js';
export const EmbedHtmlBlockSpec: ExtensionType[] = [
BlockViewExtension('affine:embed-html', model => {
return model.parent?.flavour === 'affine:surface'
? literal`affine-embed-edgeless-html-block`
: literal`affine-embed-html-block`;
}),
];

View File

@@ -0,0 +1,7 @@
export * from './embed-html-block.js';
export * from './embed-html-spec.js';
export {
EMBED_HTML_MIN_HEIGHT,
EMBED_HTML_MIN_WIDTH,
HtmlIcon,
} from './styles.js';

View File

@@ -0,0 +1,150 @@
import { css, html } from 'lit';
export const EMBED_HTML_MIN_WIDTH = 370;
export const EMBED_HTML_MIN_HEIGHT = 80;
export const styles = css`
.affine-embed-html-block {
box-sizing: border-box;
width: 100%;
height: 100%;
display: flex;
padding: 12px;
flex-direction: column;
align-items: flex-start;
gap: 20px;
border-radius: 12px;
border: 1px solid var(--affine-background-tertiary-color);
opacity: var(--add, 1);
background: var(--affine-background-primary-color);
user-select: none;
}
.affine-embed-html {
flex-grow: 1;
width: 100%;
opacity: var(--add, 1);
}
.affine-embed-html img,
.affine-embed-html object,
.affine-embed-html svg {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 4px 4px var(--1, 0px) var(--1, 0px);
}
.affine-embed-html-iframe-container {
position: relative;
width: 100%;
height: 100%;
border-radius: 4px 4px 0px 0px;
box-shadow: var(--affine-shadow-1);
overflow: hidden;
}
.embed-html-block-iframe-wrapper {
position: relative;
width: 100%;
height: 100%;
}
.embed-html-block-iframe-wrapper > iframe {
width: 100%;
height: 100%;
border: none;
}
.embed-html-block-iframe-wrapper affine-menu {
min-width: 296px;
}
.embed-html-block-iframe-wrapper affine-menu .settings-header {
padding: 7px 12px;
font-weight: 500;
font-size: var(--affine-font-xs);
color: var(--affine-text-secondary-color);
}
.embed-html-block-iframe-wrapper > embed-html-fullscreen-toolbar {
visibility: hidden;
}
.embed-html-block-iframe-wrapper:fullscreen > embed-html-fullscreen-toolbar {
visibility: visible;
}
.affine-embed-html-iframe-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.affine-embed-html-iframe-overlay.hide {
display: none;
}
.affine-embed-html-title {
height: fit-content;
display: flex;
align-items: center;
gap: 8px;
padding: var(--1, 0px);
border-radius: var(--1, 0px);
opacity: var(--add, 1);
}
.affine-embed-html-title-icon {
display: flex;
width: 20px;
height: 20px;
justify-content: center;
align-items: center;
}
.affine-embed-html-title-icon img,
.affine-embed-html-title-icon object,
.affine-embed-html-title-icon svg {
width: 20px;
height: 20px;
fill: var(--affine-background-primary-color);
}
.affine-embed-html-title-text {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
word-break: break-word;
overflow: hidden;
text-overflow: ellipsis;
color: var(--affine-text-primary-color);
font-family: var(--affine-font-family);
font-size: var(--affine-font-sm);
font-style: normal;
font-weight: 600;
line-height: 22px;
}
`;
export const HtmlIcon = html`<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M6.66667 1.875C5.40101 1.875 4.375 2.90101 4.375 4.16667V6.66667C4.375 7.01184 4.65482 7.29167 5 7.29167C5.34518 7.29167 5.625 7.01184 5.625 6.66667V4.16667C5.625 3.59137 6.09137 3.125 6.66667 3.125H12.9349C13.2563 3.125 13.5598 3.27341 13.7571 3.52714L15.8222 6.18232C15.9645 6.36517 16.0417 6.5902 16.0417 6.82185V15C16.0417 15.5753 15.5753 16.0417 15 16.0417H6.66667C6.09137 16.0417 5.625 15.5753 5.625 15V13.75C5.625 13.4048 5.34518 13.125 5 13.125C4.65482 13.125 4.375 13.4048 4.375 13.75V15C4.375 16.2657 5.40101 17.2917 6.66667 17.2917H15C16.2657 17.2917 17.2917 16.2657 17.2917 15V6.82185C17.2917 6.31223 17.1218 5.81716 16.8089 5.4149L14.7438 2.75972C14.3096 2.2015 13.642 1.875 12.9349 1.875H6.66667ZM2.30713 11.4758C2.30713 11.7936 2.47945 11.9727 2.78158 11.9727C3.0837 11.9727 3.25602 11.7936 3.25602 11.4758V10.6679H4.3929V11.4758C4.3929 11.7936 4.56523 11.9727 4.86735 11.9727C5.16947 11.9727 5.3418 11.7936 5.3418 11.4758V9.12821C5.3418 8.81043 5.16947 8.63139 4.86735 8.63139C4.56523 8.63139 4.3929 8.81043 4.3929 9.12821V9.91374H3.25602V9.12821C3.25602 8.81043 3.0837 8.63139 2.78158 8.63139C2.47945 8.63139 2.30713 8.81043 2.30713 9.12821V11.4758ZM6.51672 11.4758C6.51672 11.7936 6.68905 11.9727 6.99117 11.9727C7.29329 11.9727 7.46562 11.7936 7.46562 11.4758V9.44377H7.9423C8.19295 9.44377 8.3608 9.30725 8.3608 9.06555C8.3608 8.82385 8.19743 8.68734 7.9423 8.68734H6.04004C5.78491 8.68734 5.62154 8.82385 5.62154 9.06555C5.62154 9.30725 5.78939 9.44377 6.04004 9.44377H6.51672V11.4758ZM9.05457 11.9727C8.79049 11.9727 8.64054 11.8138 8.64054 11.534V9.25354C8.64054 8.85518 8.85986 8.63139 9.25598 8.63139C9.58944 8.63139 9.76624 8.76343 9.90051 9.11479L10.46 10.5717H10.4779L11.0352 9.11479C11.1694 8.76343 11.3462 8.63139 11.6797 8.63139C12.0758 8.63139 12.2951 8.85518 12.2951 9.25354V11.534C12.2951 11.8138 12.1452 11.9727 11.8811 11.9727C11.617 11.9727 11.4671 11.8138 11.4671 11.534V10.0458H11.4492L10.8069 11.6638C10.742 11.8272 10.639 11.901 10.4712 11.901C10.3011 11.901 10.1914 11.825 10.1288 11.6638L9.48649 10.0458H9.46859V11.534C9.46859 11.8138 9.31864 11.9727 9.05457 11.9727ZM12.745 11.4199C12.745 11.7377 12.9173 11.9167 13.2194 11.9167H14.5868C14.8419 11.9167 15.0053 11.7802 15.0053 11.5385C15.0053 11.2968 14.8374 11.1603 14.5868 11.1603H13.6938V9.12821C13.6938 8.81043 13.5215 8.63139 13.2194 8.63139C12.9173 8.63139 12.745 8.81043 12.745 9.12821V11.4199Z"
fill="#77757D"
/>
</svg> `;

View File

@@ -0,0 +1,64 @@
import { EmbedLinkedDocBlockSchema } from '@blocksuite/affine-model';
import {
BlockHtmlAdapterExtension,
type BlockHtmlAdapterMatcher,
} from '@blocksuite/affine-shared/adapters';
import { generateDocUrl } from '../../common/adapters/utils.js';
export const embedLinkedDocBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = {
flavour: EmbedLinkedDocBlockSchema.model.flavour,
toMatch: () => false,
fromMatch: o => o.node.flavour === EmbedLinkedDocBlockSchema.model.flavour,
toBlockSnapshot: {},
fromBlockSnapshot: {
enter: (o, context) => {
const { configs, walkerContext } = context;
// Parse as link
if (!o.node.props.pageId) {
return;
}
const title = configs.get('title:' + o.node.props.pageId) ?? 'untitled';
const url = generateDocUrl(
configs.get('docLinkBaseUrl') ?? '',
String(o.node.props.pageId),
o.node.props.params ?? Object.create(null)
);
walkerContext
.openNode(
{
type: 'element',
tagName: 'div',
properties: {
className: ['affine-paragraph-block-container'],
},
children: [],
},
'children'
)
.openNode(
{
type: 'element',
tagName: 'a',
properties: {
href: url,
},
children: [
{
type: 'text',
value: title,
},
],
},
'children'
)
.closeNode()
.closeNode();
},
},
};
export const EmbedLinkedDocHtmlAdapterExtension = BlockHtmlAdapterExtension(
embedLinkedDocBlockHtmlAdapterMatcher
);

View File

@@ -0,0 +1,3 @@
export * from './html.js';
export * from './markdown.js';
export * from './plain-text.js';

View File

@@ -0,0 +1,57 @@
import { EmbedLinkedDocBlockSchema } from '@blocksuite/affine-model';
import {
BlockMarkdownAdapterExtension,
type BlockMarkdownAdapterMatcher,
} from '@blocksuite/affine-shared/adapters';
import { generateDocUrl } from '../../common/adapters/utils.js';
export const embedLinkedDocBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher =
{
flavour: EmbedLinkedDocBlockSchema.model.flavour,
toMatch: () => false,
fromMatch: o => o.node.flavour === EmbedLinkedDocBlockSchema.model.flavour,
toBlockSnapshot: {},
fromBlockSnapshot: {
enter: (o, context) => {
const { configs, walkerContext } = context;
// Parse as link
if (!o.node.props.pageId) {
return;
}
const title = configs.get('title:' + o.node.props.pageId) ?? 'untitled';
const url = generateDocUrl(
configs.get('docLinkBaseUrl') ?? '',
String(o.node.props.pageId),
o.node.props.params ?? Object.create(null)
);
walkerContext
.openNode(
{
type: 'paragraph',
children: [],
},
'children'
)
.openNode(
{
type: 'link',
url,
title: o.node.props.caption as string | null,
children: [
{
type: 'text',
value: title,
},
],
},
'children'
)
.closeNode()
.closeNode();
},
},
};
export const EmbedLinkedDocMarkdownAdapterExtension =
BlockMarkdownAdapterExtension(embedLinkedDocBlockMarkdownAdapterMatcher);

View File

@@ -0,0 +1,34 @@
import { EmbedLinkedDocBlockSchema } from '@blocksuite/affine-model';
import {
BlockPlainTextAdapterExtension,
type BlockPlainTextAdapterMatcher,
} from '@blocksuite/affine-shared/adapters';
import { generateDocUrl } from '../../common/adapters/utils.js';
export const embedLinkedDocBlockPlainTextAdapterMatcher: BlockPlainTextAdapterMatcher =
{
flavour: EmbedLinkedDocBlockSchema.model.flavour,
toMatch: () => false,
fromMatch: o => o.node.flavour === EmbedLinkedDocBlockSchema.model.flavour,
toBlockSnapshot: {},
fromBlockSnapshot: {
enter: (o, context) => {
const { configs, textBuffer } = context;
// Parse as link
if (!o.node.props.pageId) {
return;
}
const title = configs.get('title:' + o.node.props.pageId) ?? 'untitled';
const url = generateDocUrl(
configs.get('docLinkBaseUrl') ?? '',
String(o.node.props.pageId),
o.node.props.params ?? Object.create(null)
);
textBuffer.content += `${title}: ${url}\n`;
},
},
};
export const EmbedLinkedDocBlockPlainTextAdapterExtension =
BlockPlainTextAdapterExtension(embedLinkedDocBlockPlainTextAdapterMatcher);

View File

@@ -0,0 +1,9 @@
import type { BlockCommands } from '@blocksuite/block-std';
import { insertEmbedLinkedDocCommand } from './insert-embed-linked-doc.js';
import { insertLinkByQuickSearchCommand } from './insert-link-by-quick-search.js';
export const commands: BlockCommands = {
insertEmbedLinkedDoc: insertEmbedLinkedDocCommand,
insertLinkByQuickSearch: insertLinkByQuickSearchCommand,
};

View File

@@ -0,0 +1,21 @@
import type { EmbedCardStyle, ReferenceParams } from '@blocksuite/affine-model';
import type { Command } from '@blocksuite/block-std';
import { insertEmbedCard } from '../../common/insert-embed-card.js';
export const insertEmbedLinkedDocCommand: Command<
never,
'insertedLinkType',
{
docId: string;
params?: ReferenceParams;
}
> = (ctx, next) => {
const { docId, params, std } = ctx;
const flavour = 'affine:embed-linked-doc';
const targetStyle: EmbedCardStyle = 'vertical';
const props: Record<string, unknown> = { pageId: docId };
if (params) props.params = params;
insertEmbedCard(std, { flavour, targetStyle, props });
next();
};

View File

@@ -0,0 +1,48 @@
import { QuickSearchProvider } from '@blocksuite/affine-shared/services';
import type { Command } from '@blocksuite/block-std';
export type InsertedLinkType = {
flavour?: 'affine:bookmark' | 'affine:embed-linked-doc';
} | null;
export const insertLinkByQuickSearchCommand: Command<
never,
'insertedLinkType'
> = (ctx, next) => {
const { std } = ctx;
const quickSearchService = std.getOptional(QuickSearchProvider);
if (!quickSearchService) {
next();
return;
}
const insertedLinkType: Promise<InsertedLinkType> = quickSearchService
.openQuickSearch()
.then(result => {
if (!result) return null;
// add linked doc
if ('docId' in result) {
std.command.exec('insertEmbedLinkedDoc', {
docId: result.docId,
params: result.params,
});
return {
flavour: 'affine:embed-linked-doc',
};
}
// add normal link;
if ('externalUrl' in result) {
// @ts-expect-error TODO: fix after bookmark refactor
std.command.exec('insertBookmark', { url: result.externalUrl });
return {
flavour: 'affine:bookmark',
};
}
return null;
});
next({ insertedLinkType });
};

View File

@@ -0,0 +1,72 @@
import {
EMBED_CARD_HEIGHT,
EMBED_CARD_WIDTH,
} from '@blocksuite/affine-shared/consts';
import { cloneReferenceInfoWithoutAliases } from '@blocksuite/affine-shared/utils';
import { Bound } from '@blocksuite/global/utils';
import { toEdgelessEmbedBlock } from '../common/to-edgeless-embed-block.js';
import { EmbedLinkedDocBlockComponent } from './embed-linked-doc-block.js';
export class EmbedEdgelessLinkedDocBlockComponent extends toEdgelessEmbedBlock(
EmbedLinkedDocBlockComponent
) {
override convertToEmbed = () => {
const { id, doc, caption, xywh } = this.model;
// synced doc entry controlled by awareness flag
const isSyncedDocEnabled = doc.awarenessStore.getFlag(
'enable_synced_doc_block'
);
if (!isSyncedDocEnabled) {
return;
}
const style = 'syncedDoc';
const bound = Bound.deserialize(xywh);
bound.w = EMBED_CARD_WIDTH[style];
bound.h = EMBED_CARD_HEIGHT[style];
const edgelessService = this.rootService;
if (!edgelessService) {
return;
}
// @ts-expect-error TODO: fix after edgeless refactor
const newId = edgelessService.addBlock(
'affine:embed-synced-doc',
{
xywh: bound.serialize(),
caption,
...cloneReferenceInfoWithoutAliases(this.referenceInfo$.peek()),
},
// @ts-expect-error TODO: fix after edgeless refactor
edgelessService.surface
);
this.std.command.exec('reassociateConnectors', {
oldId: id,
newId,
});
// @ts-expect-error TODO: fix after edgeless refactor
edgelessService.selection.set({
editing: false,
elements: [newId],
});
doc.deleteBlock(this.model);
};
get rootService() {
return this.std.getService('affine:page');
}
protected override _handleClick(evt: MouseEvent): void {
if (this.config.handleClick) {
this.config.handleClick(evt, this.host, this.referenceInfo$.peek());
return;
}
}
}

View File

@@ -0,0 +1,557 @@
import { isPeekable, Peekable } from '@blocksuite/affine-components/peek';
import {
REFERENCE_NODE,
RefNodeSlotsProvider,
} from '@blocksuite/affine-components/rich-text';
import type {
DocMode,
EmbedLinkedDocModel,
EmbedLinkedDocStyles,
} from '@blocksuite/affine-model';
import {
EMBED_CARD_HEIGHT,
EMBED_CARD_WIDTH,
} from '@blocksuite/affine-shared/consts';
import {
DocDisplayMetaProvider,
DocModeProvider,
ThemeProvider,
} from '@blocksuite/affine-shared/services';
import {
cloneReferenceInfo,
cloneReferenceInfoWithoutAliases,
matchFlavours,
referenceToNode,
} from '@blocksuite/affine-shared/utils';
import { Bound } from '@blocksuite/global/utils';
import { DocCollection } from '@blocksuite/store';
import { computed } from '@preact/signals-core';
import { html, nothing } from 'lit';
import { property, queryAsync, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { styleMap } from 'lit/directives/style-map.js';
import { when } from 'lit/directives/when.js';
import { EmbedBlockComponent } from '../common/embed-block-element.js';
import { renderLinkedDocInCard } from '../common/render-linked-doc.js';
import { SyncedDocErrorIcon } from '../embed-synced-doc-block/styles.js';
import {
type EmbedLinkedDocBlockConfig,
EmbedLinkedDocBlockConfigIdentifier,
} from './embed-linked-doc-config.js';
import { styles } from './styles.js';
import { getEmbedLinkedDocIcons } from './utils.js';
@Peekable({
enableOn: ({ doc }: EmbedLinkedDocBlockComponent) => !doc.readonly,
})
export class EmbedLinkedDocBlockComponent extends EmbedBlockComponent<EmbedLinkedDocModel> {
static override styles = styles;
private _load = async () => {
const {
loading = true,
isError = false,
isBannerEmpty = true,
isNoteContentEmpty = true,
} = this.getInitialState();
this._loading = loading;
this.isError = isError;
this.isBannerEmpty = isBannerEmpty;
this.isNoteContentEmpty = isNoteContentEmpty;
if (!this._loading) {
return;
}
const linkedDoc = this.linkedDoc;
if (!linkedDoc) {
this._loading = false;
return;
}
if (!linkedDoc.loaded) {
try {
linkedDoc.load();
} catch (e) {
console.error(e);
this.isError = true;
}
}
if (!this.isError && !linkedDoc.root) {
await new Promise<void>(resolve => {
linkedDoc.slots.rootAdded.once(() => {
resolve();
});
});
}
this._loading = false;
// If it is a link to a block or element, the content will not be rendered.
if (this._referenceToNode) {
return;
}
if (!this.isError) {
const cardStyle = this.model.style;
if (cardStyle === 'horizontal' || cardStyle === 'vertical') {
renderLinkedDocInCard(this);
}
}
};
private _selectBlock = () => {
const selectionManager = this.host.selection;
const blockSelection = selectionManager.create('block', {
blockId: this.blockId,
});
selectionManager.setGroup('note', [blockSelection]);
};
private _setDocUpdatedAt = () => {
const meta = this.doc.collection.meta.getDocMeta(this.model.pageId);
if (meta) {
const date = meta.updatedDate || meta.createDate;
this._docUpdatedAt = new Date(date);
}
};
override _cardStyle: (typeof EmbedLinkedDocStyles)[number] = 'horizontal';
convertToEmbed = () => {
if (this._referenceToNode) return;
const { doc, caption } = this.model;
// synced doc entry controlled by awareness flag
const isSyncedDocEnabled = doc.awarenessStore.getFlag(
'enable_synced_doc_block'
);
if (!isSyncedDocEnabled) {
return;
}
const parent = doc.getParent(this.model);
if (!parent) {
return;
}
const index = parent.children.indexOf(this.model);
doc.addBlock(
'affine:embed-synced-doc',
{
caption,
...cloneReferenceInfoWithoutAliases(this.referenceInfo$.peek()),
},
parent,
index
);
this.std.selection.setGroup('note', []);
doc.deleteBlock(this.model);
};
covertToInline = () => {
const { doc } = this.model;
const parent = doc.getParent(this.model);
if (!parent) {
return;
}
const index = parent.children.indexOf(this.model);
const yText = new DocCollection.Y.Text();
yText.insert(0, REFERENCE_NODE);
yText.format(0, REFERENCE_NODE.length, {
reference: {
type: 'LinkedPage',
...this.referenceInfo$.peek(),
},
});
const text = new doc.Text(yText);
doc.addBlock(
'affine:paragraph',
{
text,
},
parent,
index
);
doc.deleteBlock(this.model);
};
referenceInfo$ = computed(() => {
const { pageId, params, title$, description$ } = this.model;
return cloneReferenceInfo({
pageId,
params,
title: title$.value,
description: description$.value,
});
});
icon$ = computed(() => {
const { pageId, params, title } = this.referenceInfo$.value;
return this.std
.get(DocDisplayMetaProvider)
.icon(pageId, { params, title, referenced: true }).value;
});
open = () => {
this.std
.getOptional(RefNodeSlotsProvider)
?.docLinkClicked.emit(this.referenceInfo$.peek());
};
refreshData = () => {
this._load().catch(e => {
console.error(e);
this.isError = true;
});
};
title$ = computed(() => {
const { pageId, params, title } = this.referenceInfo$.value;
return (
title ||
this.std
.get(DocDisplayMetaProvider)
.title(pageId, { params, title, referenced: true })
);
});
get config(): EmbedLinkedDocBlockConfig {
return (
this.std.provider.getOptional(EmbedLinkedDocBlockConfigIdentifier) || {}
);
}
get docTitle() {
return this.model.title || this.linkedDoc?.meta?.title || 'Untitled';
}
get editorMode() {
return this._linkedDocMode;
}
get linkedDoc() {
return this.std.collection.getDoc(this.model.pageId);
}
private _handleDoubleClick(event: MouseEvent) {
if (this.config.handleDoubleClick) {
this.config.handleDoubleClick(
event,
this.host,
this.referenceInfo$.peek()
);
if (event.defaultPrevented) {
return;
}
}
if (isPeekable(this)) {
return;
}
event.stopPropagation();
this.open();
}
private _isDocEmpty() {
const linkedDoc = this.linkedDoc;
if (!linkedDoc) {
return false;
}
return !!linkedDoc && this.isNoteContentEmpty && this.isBannerEmpty;
}
protected _handleClick(event: MouseEvent) {
if (this.config.handleClick) {
this.config.handleClick(event, this.host, this.referenceInfo$.peek());
if (event.defaultPrevented) {
return;
}
}
this._selectBlock();
}
override connectedCallback() {
super.connectedCallback();
this._cardStyle = this.model.style;
this._referenceToNode = referenceToNode(this.model);
this._load().catch(e => {
console.error(e);
this.isError = true;
});
const linkedDoc = this.linkedDoc;
if (linkedDoc) {
this.disposables.add(
linkedDoc.collection.meta.docMetaUpdated.on(() => {
this._load().catch(e => {
console.error(e);
this.isError = true;
});
})
);
this.disposables.add(
linkedDoc.slots.blockUpdated.on(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;
});
})
);
this._setDocUpdatedAt();
this.disposables.add(
this.doc.collection.meta.docMetaUpdated.on(() => {
this._setDocUpdatedAt();
})
);
if (this._referenceToNode) {
this._linkedDocMode = this.model.params?.mode ?? 'page';
} else {
const docMode = this.std.get(DocModeProvider);
this._linkedDocMode = docMode.getPrimaryMode(this.model.pageId);
this.disposables.add(
docMode.onPrimaryModeChange(mode => {
this._linkedDocMode = mode;
}, this.model.pageId)
);
}
}
this.disposables.add(
this.model.propsUpdated.on(({ key }) => {
if (key === 'style') {
this._cardStyle = this.model.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() {
const linkedDoc = this.linkedDoc;
const isDeleted = !linkedDoc;
const isLoading = this._loading;
const isError = this.isError;
const isEmpty = this._isDocEmpty() && this.isBannerEmpty;
const inCanvas = matchFlavours(this.model.parent, ['affine:surface']);
const cardClassMap = classMap({
loading: isLoading,
error: isError,
deleted: isDeleted,
empty: isEmpty,
'banner-empty': this.isBannerEmpty,
'note-empty': this.isNoteContentEmpty,
'in-canvas': inCanvas,
[this._cardStyle]: true,
});
const theme = this.std.get(ThemeProvider).theme;
const {
LoadingIcon,
ReloadIcon,
LinkedDocDeletedBanner,
LinkedDocEmptyBanner,
SyncedDocErrorBanner,
} = getEmbedLinkedDocIcons(theme, this._linkedDocMode, this._cardStyle);
const icon = isError
? SyncedDocErrorIcon
: isLoading
? LoadingIcon
: this.icon$.value;
const title = isLoading ? 'Loading...' : this.title$;
const description = this.model.description$;
const showDefaultNoteContent = isError || isLoading || isDeleted || isEmpty;
const defaultNoteContent = isError
? 'This linked doc failed to load.'
: isLoading
? ''
: isDeleted
? 'This linked doc is deleted.'
: isEmpty
? 'Preview of the doc will be displayed here.'
: '';
const dateText =
this._cardStyle === 'cube'
? this._docUpdatedAt.toLocaleTimeString()
: this._docUpdatedAt.toLocaleString();
const showDefaultBanner = isError || isLoading || isDeleted || isEmpty;
const defaultBanner = isError
? SyncedDocErrorBanner
: isLoading
? LinkedDocEmptyBanner
: isDeleted
? LinkedDocDeletedBanner
: LinkedDocEmptyBanner;
const hasDescriptionAlias = Boolean(description.value);
return this.renderEmbed(
() => html`
<div
class="affine-embed-linked-doc-block ${cardClassMap}"
style=${styleMap({
transform: `scale(${this._scale})`,
transformOrigin: '0 0',
})}
@click=${this._handleClick}
@dblclick=${this._handleDoubleClick}
>
<div class="affine-embed-linked-doc-content">
<div class="affine-embed-linked-doc-content-title">
<div class="affine-embed-linked-doc-content-title-icon">
${icon}
</div>
<div class="affine-embed-linked-doc-content-title-text">
${title}
</div>
</div>
${when(
hasDescriptionAlias,
() =>
html`<div class="affine-embed-linked-doc-content-note alias">
${description}
</div>`,
() =>
when(
showDefaultNoteContent,
() => html`
<div class="affine-embed-linked-doc-content-note default">
${defaultNoteContent}
</div>
`,
() => html`
<div
class="affine-embed-linked-doc-content-note render"
></div>
`
)
)}
${isError
? html`
<div class="affine-embed-linked-doc-card-content-reload">
<div
class="affine-embed-linked-doc-card-content-reload-button"
@click=${this.refreshData}
>
${ReloadIcon} <span>Reload</span>
</div>
</div>
`
: html`
<div class="affine-embed-linked-doc-content-date">
<span>Updated</span>
<span>${dateText}</span>
</div>
`}
</div>
${showDefaultBanner
? html`
<div class="affine-embed-linked-doc-banner default">
${defaultBanner}
</div>
`
: nothing}
</div>
`
);
}
override updated() {
// update card style when linked doc deleted
const linkedDoc = this.linkedDoc;
const { xywh, style } = this.model;
const bound = Bound.deserialize(xywh);
if (linkedDoc && style === 'horizontalThin') {
bound.w = EMBED_CARD_WIDTH.horizontal;
bound.h = EMBED_CARD_HEIGHT.horizontal;
this.doc.withoutTransact(() => {
this.doc.updateBlock(this.model, {
xywh: bound.serialize(),
style: 'horizontal',
});
});
} else if (!linkedDoc && style === 'horizontal') {
bound.w = EMBED_CARD_WIDTH.horizontalThin;
bound.h = EMBED_CARD_HEIGHT.horizontalThin;
this.doc.withoutTransact(() => {
this.doc.updateBlock(this.model, {
xywh: bound.serialize(),
style: 'horizontalThin',
});
});
}
}
@state()
private accessor _docUpdatedAt: Date = new Date();
@state()
private accessor _linkedDocMode: DocMode = 'page';
@state()
private accessor _loading = false;
// reference to block/element
@state()
private accessor _referenceToNode = false;
@property({ attribute: false })
accessor isBannerEmpty = false;
@property({ attribute: false })
accessor isError = false;
@property({ attribute: false })
accessor isNoteContentEmpty = false;
@queryAsync('.affine-embed-linked-doc-content-note.render')
accessor noteContainer!: Promise<HTMLDivElement | null>;
}

View File

@@ -0,0 +1,29 @@
import type { ReferenceInfo } from '@blocksuite/affine-model';
import type { EditorHost, ExtensionType } from '@blocksuite/block-std';
import { createIdentifier } from '@blocksuite/global/di';
export interface EmbedLinkedDocBlockConfig {
handleClick?: (
e: MouseEvent,
host: EditorHost,
referenceInfo: ReferenceInfo
) => void;
handleDoubleClick?: (
e: MouseEvent,
host: EditorHost,
referenceInfo: ReferenceInfo
) => void;
}
export const EmbedLinkedDocBlockConfigIdentifier =
createIdentifier<EmbedLinkedDocBlockConfig>('EmbedLinkedDocBlockConfig');
export function EmbedLinkedDocBlockConfigExtension(
config: EmbedLinkedDocBlockConfig
): ExtensionType {
return {
setup: di => {
di.addImpl(EmbedLinkedDocBlockConfigIdentifier, () => config);
},
};
}

View File

@@ -0,0 +1,17 @@
import {
BlockViewExtension,
CommandExtension,
type ExtensionType,
} from '@blocksuite/block-std';
import { literal } from 'lit/static-html.js';
import { commands } from './commands/index.js';
export const EmbedLinkedDocBlockSpec: ExtensionType[] = [
CommandExtension(commands),
BlockViewExtension('affine:embed-linked-doc', model => {
return model.parent?.flavour === 'affine:surface'
? literal`affine-embed-edgeless-linked-doc-block`
: literal`affine-embed-linked-doc-block`;
}),
];

View File

@@ -0,0 +1,4 @@
export * from './adapters/index.js';
export * from './embed-linked-doc-block.js';
export * from './embed-linked-doc-config.js';
export * from './embed-linked-doc-spec.js';

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,115 @@
import {
DarkLoadingIcon,
EmbedEdgelessIcon,
EmbedPageIcon,
LightLoadingIcon,
ReloadIcon,
} from '@blocksuite/affine-components/icons';
import {
ColorScheme,
type EmbedLinkedDocStyles,
} from '@blocksuite/affine-model';
import type { TemplateResult } from 'lit';
import {
DarkSyncedDocErrorBanner,
LightSyncedDocErrorBanner,
} from '../embed-synced-doc-block/styles.js';
import {
DarkLinkedEdgelessDeletedLargeBanner,
DarkLinkedEdgelessDeletedSmallBanner,
DarkLinkedEdgelessEmptyLargeBanner,
DarkLinkedEdgelessEmptySmallBanner,
DarkLinkedPageDeletedLargeBanner,
DarkLinkedPageDeletedSmallBanner,
DarkLinkedPageEmptyLargeBanner,
DarkLinkedPageEmptySmallBanner,
LightLinkedEdgelessDeletedLargeBanner,
LightLinkedEdgelessDeletedSmallBanner,
LightLinkedEdgelessEmptyLargeBanner,
LightLinkedEdgelessEmptySmallBanner,
LightLinkedPageDeletedLargeBanner,
LightLinkedPageDeletedSmallBanner,
LightLinkedPageEmptyLargeBanner,
LightLinkedPageEmptySmallBanner,
LinkedDocDeletedIcon,
} from './styles.js';
type EmbedCardImages = {
LoadingIcon: TemplateResult<1>;
ReloadIcon: TemplateResult<1>;
LinkedDocIcon: TemplateResult<1>;
LinkedDocDeletedIcon: TemplateResult<1>;
LinkedDocEmptyBanner: TemplateResult<1>;
LinkedDocDeletedBanner: TemplateResult<1>;
SyncedDocErrorBanner: TemplateResult<1>;
};
export function getEmbedLinkedDocIcons(
theme: ColorScheme,
editorMode: 'page' | 'edgeless',
style: (typeof EmbedLinkedDocStyles)[number]
): EmbedCardImages {
const small = style !== 'vertical';
if (editorMode === 'page') {
if (theme === ColorScheme.Light) {
return {
LoadingIcon: LightLoadingIcon,
ReloadIcon,
LinkedDocIcon: EmbedPageIcon,
LinkedDocDeletedIcon,
LinkedDocEmptyBanner: small
? LightLinkedPageEmptySmallBanner
: LightLinkedPageEmptyLargeBanner,
LinkedDocDeletedBanner: small
? LightLinkedPageDeletedSmallBanner
: LightLinkedPageDeletedLargeBanner,
SyncedDocErrorBanner: LightSyncedDocErrorBanner,
};
} else {
return {
ReloadIcon,
LoadingIcon: DarkLoadingIcon,
LinkedDocIcon: EmbedPageIcon,
LinkedDocDeletedIcon,
LinkedDocEmptyBanner: small
? DarkLinkedPageEmptySmallBanner
: DarkLinkedPageEmptyLargeBanner,
LinkedDocDeletedBanner: small
? DarkLinkedPageDeletedSmallBanner
: DarkLinkedPageDeletedLargeBanner,
SyncedDocErrorBanner: DarkSyncedDocErrorBanner,
};
}
} else {
if (theme === ColorScheme.Light) {
return {
ReloadIcon,
LoadingIcon: LightLoadingIcon,
LinkedDocIcon: EmbedEdgelessIcon,
LinkedDocDeletedIcon,
LinkedDocEmptyBanner: small
? LightLinkedEdgelessEmptySmallBanner
: LightLinkedEdgelessEmptyLargeBanner,
LinkedDocDeletedBanner: small
? LightLinkedEdgelessDeletedSmallBanner
: LightLinkedEdgelessDeletedLargeBanner,
SyncedDocErrorBanner: LightSyncedDocErrorBanner,
};
} else {
return {
ReloadIcon,
LoadingIcon: DarkLoadingIcon,
LinkedDocIcon: EmbedEdgelessIcon,
LinkedDocDeletedIcon,
LinkedDocEmptyBanner: small
? DarkLinkedEdgelessEmptySmallBanner
: DarkLinkedEdgelessEmptyLargeBanner,
LinkedDocDeletedBanner: small
? DarkLinkedEdgelessDeletedSmallBanner
: DarkLinkedEdgelessDeletedLargeBanner,
SyncedDocErrorBanner: DarkSyncedDocErrorBanner,
};
}
}
}

View File

@@ -0,0 +1,11 @@
import { EmbedLoomBlockSchema } from '@blocksuite/affine-model';
import { BlockHtmlAdapterExtension } from '@blocksuite/affine-shared/adapters';
import { createEmbedBlockHtmlAdapterMatcher } from '../../common/adapters/html.js';
export const embedLoomBlockHtmlAdapterMatcher =
createEmbedBlockHtmlAdapterMatcher(EmbedLoomBlockSchema.model.flavour);
export const EmbedLoomBlockHtmlAdapterExtension = BlockHtmlAdapterExtension(
embedLoomBlockHtmlAdapterMatcher
);

View File

@@ -0,0 +1,3 @@
export * from './html.js';
export * from './markdown.js';
export * from './plain-text.js';

View File

@@ -0,0 +1,11 @@
import { EmbedLoomBlockSchema } from '@blocksuite/affine-model';
import { BlockMarkdownAdapterExtension } from '@blocksuite/affine-shared/adapters';
import { createEmbedBlockMarkdownAdapterMatcher } from '../../common/adapters/markdown.js';
export const embedLoomBlockMarkdownAdapterMatcher =
createEmbedBlockMarkdownAdapterMatcher(EmbedLoomBlockSchema.model.flavour);
export const EmbedLoomMarkdownAdapterExtension = BlockMarkdownAdapterExtension(
embedLoomBlockMarkdownAdapterMatcher
);

View File

@@ -0,0 +1,10 @@
import { EmbedLoomBlockSchema } from '@blocksuite/affine-model';
import { BlockPlainTextAdapterExtension } from '@blocksuite/affine-shared/adapters';
import { createEmbedBlockPlainTextAdapterMatcher } from '../../common/adapters/plain-text.js';
export const embedLoomBlockPlainTextAdapterMatcher =
createEmbedBlockPlainTextAdapterMatcher(EmbedLoomBlockSchema.model.flavour);
export const EmbedLoomBlockPlainTextAdapterExtension =
BlockPlainTextAdapterExtension(embedLoomBlockPlainTextAdapterMatcher);

View File

@@ -0,0 +1,6 @@
import { toEdgelessEmbedBlock } from '../common/to-edgeless-embed-block.js';
import { EmbedLoomBlockComponent } from './embed-loom-block.js';
export class EmbedEdgelessLoomBlockComponent extends toEdgelessEmbedBlock(
EmbedLoomBlockComponent
) {}

View File

@@ -0,0 +1,202 @@
import { OpenIcon } from '@blocksuite/affine-components/icons';
import type { EmbedLoomModel, EmbedLoomStyles } from '@blocksuite/affine-model';
import { ThemeProvider } from '@blocksuite/affine-shared/services';
import { html } from 'lit';
import { property, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { styleMap } from 'lit/directives/style-map.js';
import { EmbedBlockComponent } from '../common/embed-block-element.js';
import { getEmbedCardIcons } from '../common/utils.js';
import { loomUrlRegex } from './embed-loom-model.js';
import type { EmbedLoomBlockService } from './embed-loom-service.js';
import { LoomIcon, styles } from './styles.js';
import { refreshEmbedLoomUrlData } from './utils.js';
export class EmbedLoomBlockComponent extends EmbedBlockComponent<
EmbedLoomModel,
EmbedLoomBlockService
> {
static override styles = styles;
override _cardStyle: (typeof EmbedLoomStyles)[number] = 'video';
protected _isDragging = false;
protected _isResizing = false;
open = () => {
let link = this.model.url;
if (!link.match(/^[a-zA-Z]+:\/\//)) {
link = 'https://' + link;
}
window.open(link, '_blank');
};
refreshData = () => {
refreshEmbedLoomUrlData(this, this.fetchAbortController.signal).catch(
console.error
);
};
private _handleDoubleClick(event: MouseEvent) {
event.stopPropagation();
this.open();
}
private _selectBlock() {
const selectionManager = this.host.selection;
const blockSelection = selectionManager.create('block', {
blockId: this.blockId,
});
selectionManager.setGroup('note', [blockSelection]);
}
protected _handleClick(event: MouseEvent) {
event.stopPropagation();
this._selectBlock();
}
override connectedCallback() {
super.connectedCallback();
this._cardStyle = this.model.style;
if (!this.model.videoId) {
this.doc.withoutTransact(() => {
const url = this.model.url;
const urlMatch = url.match(loomUrlRegex);
if (urlMatch) {
const [, videoId] = urlMatch;
this.doc.updateBlock(this.model, {
videoId,
});
}
});
}
if (!this.model.description && !this.model.title) {
this.doc.withoutTransact(() => {
this.refreshData();
});
}
this.disposables.add(
this.model.propsUpdated.on(({ key }) => {
this.requestUpdate();
if (key === 'url') {
this.refreshData();
}
})
);
// this is required to prevent iframe from capturing pointer events
this.disposables.add(
this.std.selection.slots.changed.on(() => {
this._isSelected =
!!this.selected?.is('block') || !!this.selected?.is('surface');
this._showOverlay =
this._isResizing || this._isDragging || !this._isSelected;
})
);
// this is required to prevent iframe from capturing pointer events
this.handleEvent('dragStart', () => {
this._isDragging = true;
this._showOverlay =
this._isResizing || this._isDragging || !this._isSelected;
});
this.handleEvent('dragEnd', () => {
this._isDragging = false;
this._showOverlay =
this._isResizing || this._isDragging || !this._isSelected;
});
}
override renderBlock() {
const { image, title = 'Loom', description, videoId } = this.model;
const loading = this.loading;
const theme = this.std.get(ThemeProvider).theme;
const { LoadingIcon, EmbedCardBannerIcon } = getEmbedCardIcons(theme);
const titleIcon = loading ? LoadingIcon : LoomIcon;
const titleText = loading ? 'Loading...' : title;
const descriptionText = loading ? '' : description;
const bannerImage =
!loading && image
? html`<object type="image/webp" data=${image} draggable="false">
${EmbedCardBannerIcon}
</object>`
: EmbedCardBannerIcon;
return this.renderEmbed(
() => html`
<div
class=${classMap({
'affine-embed-loom-block': true,
loading,
selected: this._isSelected,
})}
style=${styleMap({
transform: `scale(${this._scale})`,
transformOrigin: '0 0',
})}
@click=${this._handleClick}
@dblclick=${this._handleDoubleClick}
>
<div class="affine-embed-loom-video">
${videoId
? html`
<div class="affine-embed-loom-video-iframe-container">
<iframe
src=${`https://www.loom.com/embed/${videoId}?hide_title=true`}
frameborder="0"
allow="fullscreen; accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
loading="lazy"
></iframe>
<div
class=${classMap({
'affine-embed-loom-video-iframe-overlay': true,
hide: !this._showOverlay,
})}
></div>
</div>
`
: bannerImage}
</div>
<div class="affine-embed-loom-content">
<div class="affine-embed-loom-content-header">
<div class="affine-embed-loom-content-title-icon">
${titleIcon}
</div>
<div class="affine-embed-loom-content-title-text">
${titleText}
</div>
</div>
<div class="affine-embed-loom-content-description">
${descriptionText}
</div>
<div class="affine-embed-loom-content-url" @click=${this.open}>
<span>loom.com</span>
<div class="affine-embed-loom-content-url-icon">${OpenIcon}</div>
</div>
</div>
</div>
`
);
}
@state()
protected accessor _isSelected = false;
@state()
protected accessor _showOverlay = true;
@property({ attribute: false })
accessor loading = false;
}

View File

@@ -0,0 +1,2 @@
export const loomUrlRegex: RegExp =
/(?:https?:\/\/)??(?:www\.)?loom\.com\/share\/([a-zA-Z0-9]+)/;

View File

@@ -0,0 +1,35 @@
import {
EmbedLoomBlockSchema,
type EmbedLoomModel,
EmbedLoomStyles,
} from '@blocksuite/affine-model';
import { EmbedOptionProvider } from '@blocksuite/affine-shared/services';
import { BlockService } from '@blocksuite/block-std';
import { LinkPreviewer } from '../common/link-previewer.js';
import { loomUrlRegex } from './embed-loom-model.js';
import { queryEmbedLoomData } from './utils.js';
export class EmbedLoomBlockService extends BlockService {
static override readonly flavour = EmbedLoomBlockSchema.model.flavour;
private static readonly linkPreviewer = new LinkPreviewer();
static setLinkPreviewEndpoint =
EmbedLoomBlockService.linkPreviewer.setEndpoint;
queryUrlData = (embedLoomModel: EmbedLoomModel, signal?: AbortSignal) => {
return queryEmbedLoomData(embedLoomModel, signal);
};
override mounted() {
super.mounted();
this.std.get(EmbedOptionProvider).registerEmbedBlockOptions({
flavour: this.flavour,
urlRegex: loomUrlRegex,
styles: EmbedLoomStyles,
viewType: 'embed',
});
}
}

View File

@@ -0,0 +1,18 @@
import {
BlockViewExtension,
type ExtensionType,
FlavourExtension,
} from '@blocksuite/block-std';
import { literal } from 'lit/static-html.js';
import { EmbedLoomBlockService } from './embed-loom-service.js';
export const EmbedLoomBlockSpec: ExtensionType[] = [
FlavourExtension('affine:embed-loom'),
EmbedLoomBlockService,
BlockViewExtension('affine:embed-loom', model => {
return model.parent?.flavour === 'affine:surface'
? literal`affine-embed-edgeless-loom-block`
: literal`affine-embed-loom-block`;
}),
];

View File

@@ -0,0 +1,6 @@
export * from './adapters/index.js';
export * from './embed-loom-block.js';
export * from './embed-loom-model.js';
export * from './embed-loom-service.js';
export * from './embed-loom-spec.js';
export { LoomIcon } from './styles.js';

View File

@@ -0,0 +1,221 @@
import {
EMBED_CARD_HEIGHT,
EMBED_CARD_WIDTH,
} from '@blocksuite/affine-shared/consts';
import { css, html } from 'lit';
export const styles = css`
.affine-embed-loom-block {
box-sizing: border-box;
width: ${EMBED_CARD_WIDTH.video}px;
display: flex;
flex-direction: column;
gap: 20px;
padding: 12px;
border-radius: 8px;
border: 1px solid var(--affine-background-tertiary-color);
opacity: var(--add, 1);
background: var(--affine-background-primary-color);
user-select: none;
aspect-ratio: ${EMBED_CARD_WIDTH.video} / ${EMBED_CARD_HEIGHT.video};
}
.affine-embed-loom-video {
flex-grow: 1;
width: 100%;
opacity: var(--add, 1);
}
.affine-embed-loom-video img,
.affine-embed-loom-video object,
.affine-embed-loom-video svg {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 4px 4px var(--1, 0px) var(--1, 0px);
}
.affine-embed-loom-video-iframe-container {
position: relative;
height: 100%;
}
.affine-embed-loom-video-iframe-container > iframe {
width: 100%;
height: 100%;
border-radius: 4px 4px var(--1, 0px) var(--1, 0px);
}
.affine-embed-loom-video-iframe-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.affine-embed-loom-video-iframe-overlay.hide {
display: none;
}
.affine-embed-loom-content {
display: flex;
flex-direction: column;
width: 100%;
height: fit-content;
border-radius: var(--1, 0px);
opacity: var(--add, 1);
}
.affine-embed-loom-content-header {
display: flex;
flex-direction: row;
gap: 8px;
align-items: center;
align-self: stretch;
padding: var(--1, 0px);
border-radius: var(--1, 0px);
opacity: var(--add, 1);
}
.affine-embed-loom-content-title-icon {
display: flex;
width: 20px;
height: 20px;
justify-content: center;
align-items: center;
}
.affine-embed-loom-content-title-icon img,
.affine-embed-loom-content-title-icon object,
.affine-embed-loom-content-title-icon svg {
width: 20px;
height: 20px;
fill: var(--affine-background-primary-color);
}
.affine-embed-loom-content-title-text {
flex: 1 0 0;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
word-break: break-word;
overflow: hidden;
text-overflow: ellipsis;
color: var(--affine-text-primary-color);
font-family: var(--affine-font-family);
font-size: var(--affine-font-sm);
font-style: normal;
font-weight: 600;
line-height: 22px;
}
.affine-embed-loom-content-description {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
flex: 1 0 0;
align-self: stretch;
word-break: break-word;
white-space: normal;
overflow: hidden;
text-overflow: ellipsis;
color: var(--affine-text-primary-color);
font-family: var(--affine-font-family);
font-size: var(--affine-font-xs);
font-style: normal;
font-weight: 400;
line-height: 20px;
}
.affine-embed-loom-content-url {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 4px;
width: max-content;
max-width: 100%;
cursor: pointer;
}
.affine-embed-loom-content-url > span {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
word-break: break-all;
white-space: normal;
overflow: hidden;
text-overflow: ellipsis;
color: var(--affine-text-secondary-color);
font-family: var(--affine-font-family);
font-size: var(--affine-font-xs);
font-style: normal;
font-weight: 400;
line-height: 20px;
}
.affine-embed-loom-content-url:hover > span {
color: var(--affine-link-color);
}
.affine-embed-loom-content-url:hover .open-icon {
fill: var(--affine-link-color);
}
.affine-embed-loom-content-url-icon {
display: flex;
align-items: center;
justify-content: center;
width: 12px;
height: 12px;
}
.affine-embed-loom-content-url-icon .open-icon {
height: 12px;
width: 12px;
fill: var(--affine-text-secondary-color);
}
.affine-embed-loom-block.loading {
.affine-embed-loom-content-title-text {
color: var(--affine-placeholder-color);
}
}
.affine-embed-loom-block.selected {
.affine-embed-loom-content-url > span {
color: var(--affine-link-color);
}
.affine-embed-loom-content-url .open-icon {
fill: var(--affine-link-color);
}
}
`;
export const LoomIcon = html`<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clip0_1780_25276)">
<path
d="M18.3333 9.07327H13.4597L17.6805 6.63642L16.7536 5.03052L12.5328 7.46736L14.9691 3.24695L13.3632 2.3195L10.9269 6.5399V1.66669H9.073V6.54037L6.63577 2.3195L5.03036 3.24648L7.46713 7.4669L3.24638 5.03052L2.31942 6.63596L6.54017 9.07281H1.66663V10.9268H6.53971L2.31942 13.3636L3.24638 14.9695L7.46667 12.5331L5.0299 16.7535L6.63577 17.6805L9.07254 13.4597V18.3334H10.9265V13.4601L13.3628 17.6805L14.9686 16.7535L12.5319 12.5327L16.7526 14.9695L17.6796 13.3636L13.4593 10.9272H18.3323V9.07327H18.3333ZM9.99996 12.5215C8.60206 12.5215 7.469 11.3884 7.469 9.99047C7.469 8.59253 8.60206 7.45943 9.99996 7.45943C11.3979 7.45943 12.5309 8.59253 12.5309 9.99047C12.5309 11.3884 11.3979 12.5215 9.99996 12.5215Z"
fill="#625DF5"
/>
</g>
<defs>
<clipPath id="clip0_1780_25276">
<rect width="20" height="20" fill="white" />
</clipPath>
</defs>
</svg>`;

View File

@@ -0,0 +1,75 @@
import type {
EmbedLoomBlockUrlData,
EmbedLoomModel,
} from '@blocksuite/affine-model';
import { isAbortError } from '@blocksuite/affine-shared/utils';
import type { EmbedLoomBlockComponent } from './embed-loom-block.js';
const LoomOEmbedEndpoint = 'https://www.loom.com/v1/oembed';
export async function queryEmbedLoomData(
embedLoomModel: EmbedLoomModel,
signal?: AbortSignal
): Promise<Partial<EmbedLoomBlockUrlData>> {
const url = embedLoomModel.url;
const loomEmbedData: Partial<EmbedLoomBlockUrlData> =
await queryLoomOEmbedData(url, signal);
return loomEmbedData;
}
export async function queryLoomOEmbedData(
url: string,
signal?: AbortSignal
): Promise<Partial<EmbedLoomBlockUrlData>> {
let loomOEmbedData: Partial<EmbedLoomBlockUrlData> = {};
const oEmbedUrl = `${LoomOEmbedEndpoint}?url=${url}`;
const oEmbedResponse = await fetch(oEmbedUrl, { signal }).catch(() => null);
if (oEmbedResponse && oEmbedResponse.ok) {
const oEmbedJson = await oEmbedResponse.json();
const { title, description, thumbnail_url: image } = oEmbedJson;
loomOEmbedData = {
title,
description,
image,
};
}
return loomOEmbedData;
}
export async function refreshEmbedLoomUrlData(
embedLoomElement: EmbedLoomBlockComponent,
signal?: AbortSignal
): Promise<void> {
let title = null,
description = null,
image = null;
try {
embedLoomElement.loading = true;
const queryUrlData = embedLoomElement.service?.queryUrlData;
if (!queryUrlData) return;
const loomUrlData = await queryUrlData(embedLoomElement.model);
({ title = null, description = null, image = null } = loomUrlData);
if (signal?.aborted) return;
embedLoomElement.doc.updateBlock(embedLoomElement.model, {
title,
description,
image,
});
} catch (error) {
if (signal?.aborted || isAbortError(error)) return;
} finally {
embedLoomElement.loading = false;
}
}

View File

@@ -0,0 +1,88 @@
import { EmbedSyncedDocBlockSchema } from '@blocksuite/affine-model';
import {
BlockHtmlAdapterExtension,
type BlockHtmlAdapterMatcher,
} from '@blocksuite/affine-shared/adapters';
export const embedSyncedDocBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = {
flavour: EmbedSyncedDocBlockSchema.model.flavour,
toMatch: () => false,
fromMatch: o => o.node.flavour === EmbedSyncedDocBlockSchema.model.flavour,
toBlockSnapshot: {},
fromBlockSnapshot: {
enter: async (o, context) => {
const { configs, walker, walkerContext, job } = context;
const type = configs.get('embedSyncedDocExportType');
// this context is used for nested sync block
if (
walkerContext.getGlobalContext('embed-synced-doc-counter') === undefined
) {
walkerContext.setGlobalContext('embed-synced-doc-counter', 0);
}
let counter = walkerContext.getGlobalContext(
'embed-synced-doc-counter'
) as number;
walkerContext.setGlobalContext('embed-synced-doc-counter', ++counter);
if (type === 'content') {
const syncedDocId = o.node.props.pageId as string;
const syncedDoc = job.collection.getDoc(syncedDocId);
walkerContext.setGlobalContext('hast:html-root-doc', false);
if (!syncedDoc) return;
if (counter === 1) {
const syncedSnapshot = job.docToSnapshot(syncedDoc);
if (syncedSnapshot) {
await walker.walkONode(syncedSnapshot.blocks);
}
} else {
walkerContext
.openNode(
{
type: 'element',
tagName: 'div',
properties: {
className: ['affine-paragraph-block-container'],
},
children: [],
},
'children'
)
.openNode(
{
type: 'element',
tagName: 'p',
properties: {},
children: [
{ type: 'text', value: syncedDoc.meta?.title ?? '' },
],
},
'children'
)
.closeNode()
.closeNode();
}
}
},
leave: (_, context) => {
const { walkerContext } = context;
const counter = walkerContext.getGlobalContext(
'embed-synced-doc-counter'
) as number;
const currentCounter = counter - 1;
walkerContext.setGlobalContext(
'embed-synced-doc-counter',
currentCounter
);
// When leave the last embed synced doc block, we need to set the html root doc context to true
walkerContext.setGlobalContext(
'hast:html-root-doc',
currentCounter === 0
);
},
},
};
export const EmbedSyncedDocBlockHtmlAdapterExtension =
BlockHtmlAdapterExtension(embedSyncedDocBlockHtmlAdapterMatcher);

View File

@@ -0,0 +1,3 @@
export * from './html.js';
export * from './markdown.js';
export * from './plain-text.js';

View File

@@ -0,0 +1,64 @@
import { EmbedSyncedDocBlockSchema } from '@blocksuite/affine-model';
import {
BlockMarkdownAdapterExtension,
type BlockMarkdownAdapterMatcher,
} from '@blocksuite/affine-shared/adapters';
export const embedSyncedDocBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher =
{
flavour: EmbedSyncedDocBlockSchema.model.flavour,
toMatch: () => false,
fromMatch: o => o.node.flavour === EmbedSyncedDocBlockSchema.model.flavour,
toBlockSnapshot: {},
fromBlockSnapshot: {
enter: async (o, context) => {
const { configs, walker, walkerContext, job } = context;
const type = configs.get('embedSyncedDocExportType');
// this context is used for nested sync block
if (
walkerContext.getGlobalContext('embed-synced-doc-counter') ===
undefined
) {
walkerContext.setGlobalContext('embed-synced-doc-counter', 0);
}
let counter = walkerContext.getGlobalContext(
'embed-synced-doc-counter'
) as number;
walkerContext.setGlobalContext('embed-synced-doc-counter', ++counter);
if (type === 'content') {
const syncedDocId = o.node.props.pageId as string;
const syncedDoc = job.collection.getDoc(syncedDocId);
if (!syncedDoc) return;
if (counter === 1) {
const syncedSnapshot = job.docToSnapshot(syncedDoc);
if (syncedSnapshot) {
await walker.walkONode(syncedSnapshot.blocks);
}
} else {
// TODO(@L-Sun) may be use the nested content
walkerContext
.openNode({
type: 'paragraph',
children: [
{ type: 'text', value: syncedDoc.meta?.title ?? '' },
],
})
.closeNode();
}
}
},
leave: (_, context) => {
const { walkerContext } = context;
const counter = walkerContext.getGlobalContext(
'embed-synced-doc-counter'
) as number;
walkerContext.setGlobalContext('embed-synced-doc-counter', counter - 1);
},
},
};
export const EmbedSyncedDocBlockMarkdownAdapterExtension =
BlockMarkdownAdapterExtension(embedSyncedDocBlockMarkdownAdapterMatcher);

View File

@@ -0,0 +1,62 @@
import { EmbedSyncedDocBlockSchema } from '@blocksuite/affine-model';
import {
BlockPlainTextAdapterExtension,
type BlockPlainTextAdapterMatcher,
} from '@blocksuite/affine-shared/adapters';
export const embedSyncedDocBlockPlainTextAdapterMatcher: BlockPlainTextAdapterMatcher =
{
flavour: EmbedSyncedDocBlockSchema.model.flavour,
toMatch: () => false,
fromMatch: o => o.node.flavour === EmbedSyncedDocBlockSchema.model.flavour,
toBlockSnapshot: {},
fromBlockSnapshot: {
enter: async (o, context) => {
const { configs, walker, walkerContext, job, textBuffer } = context;
const type = configs.get('embedSyncedDocExportType');
// this context is used for nested sync block
if (
walkerContext.getGlobalContext('embed-synced-doc-counter') ===
undefined
) {
walkerContext.setGlobalContext('embed-synced-doc-counter', 0);
}
let counter = walkerContext.getGlobalContext(
'embed-synced-doc-counter'
) as number;
walkerContext.setGlobalContext('embed-synced-doc-counter', ++counter);
let buffer = '';
if (type === 'content') {
const syncedDocId = o.node.props.pageId as string;
const syncedDoc = job.collection.getDoc(syncedDocId);
if (!syncedDoc) return;
if (counter === 1) {
const syncedSnapshot = job.docToSnapshot(syncedDoc);
if (syncedSnapshot) {
await walker.walkONode(syncedSnapshot.blocks);
}
} else {
buffer = syncedDoc.meta?.title ?? '';
if (buffer) {
buffer += '\n';
}
}
}
textBuffer.content += buffer;
},
leave: (_, context) => {
const { walkerContext } = context;
const counter = walkerContext.getGlobalContext(
'embed-synced-doc-counter'
) as number;
walkerContext.setGlobalContext('embed-synced-doc-counter', counter - 1);
},
},
};
export const EmbedSyncedDocBlockPlainTextAdapterExtension =
BlockPlainTextAdapterExtension(embedSyncedDocBlockPlainTextAdapterMatcher);

View File

@@ -0,0 +1,251 @@
import { ThemeProvider } from '@blocksuite/affine-shared/services';
import { isGfxBlockComponent, ShadowlessElement } from '@blocksuite/block-std';
import { WithDisposable } from '@blocksuite/global/utils';
import { html, nothing } from 'lit';
import { property, queryAsync } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { renderLinkedDocInCard } from '../../common/render-linked-doc.js';
import type { EmbedSyncedDocBlockComponent } from '../embed-synced-doc-block.js';
import { cardStyles } from '../styles.js';
import { getSyncedDocIcons } from '../utils.js';
export class EmbedSyncedDocCard extends WithDisposable(ShadowlessElement) {
static override styles = cardStyles;
private _dragging = false;
get blockState() {
return this.block.blockState;
}
get editorMode() {
return this.block.editorMode;
}
get host() {
return this.block.host;
}
get linkedDoc() {
return this.block.syncedDoc;
}
get model() {
return this.block.model;
}
get std() {
return this.block.std;
}
private _handleClick(event: MouseEvent) {
event.stopPropagation();
if (!isGfxBlockComponent(this.block)) {
this._selectBlock();
}
}
private _isDocEmpty() {
const syncedDoc = this.block.syncedDoc;
if (!syncedDoc) {
return false;
}
return (
!!syncedDoc &&
!syncedDoc.meta?.title.length &&
this.isNoteContentEmpty &&
this.isBannerEmpty
);
}
private _selectBlock() {
const selectionManager = this.host.selection;
const blockSelection = selectionManager.create('block', {
blockId: this.block.blockId,
});
selectionManager.setGroup('note', [blockSelection]);
}
override connectedCallback() {
super.connectedCallback();
this.block.handleEvent(
'dragStart',
() => {
this._dragging = true;
},
{ global: true }
);
this.block.handleEvent(
'dragEnd',
() => {
this._dragging = false;
},
{ global: true }
);
const { isCycle } = this.block.blockState;
const syncedDoc = this.block.syncedDoc;
if (isCycle && syncedDoc) {
if (syncedDoc.root) {
renderLinkedDocInCard(this);
} else {
syncedDoc.slots.rootAdded.once(() => {
renderLinkedDocInCard(this);
});
}
this.disposables.add(
syncedDoc.collection.meta.docMetaUpdated.on(() => {
renderLinkedDocInCard(this);
})
);
this.disposables.add(
syncedDoc.slots.blockUpdated.on(payload => {
if (this._dragging) {
return;
}
if (
payload.type === 'update' &&
['', 'caption', 'xywh'].includes(payload.props.key)
) {
return;
}
renderLinkedDocInCard(this);
})
);
}
}
override render() {
const { isLoading, isDeleted, isError, isCycle } = this.blockState;
const error = this.isError || isError;
const isEmpty = this._isDocEmpty() && this.isBannerEmpty;
const cardClassMap = classMap({
loading: isLoading,
error,
deleted: isDeleted,
cycle: isCycle,
surface: isGfxBlockComponent(this.block),
empty: isEmpty,
'banner-empty': this.isBannerEmpty,
'note-empty': this.isNoteContentEmpty,
});
const theme = this.std.get(ThemeProvider).theme;
const {
LoadingIcon,
SyncedDocErrorIcon,
ReloadIcon,
SyncedDocEmptyBanner,
SyncedDocErrorBanner,
SyncedDocDeletedBanner,
} = getSyncedDocIcons(theme, this.editorMode);
const icon = error
? SyncedDocErrorIcon
: isLoading
? LoadingIcon
: this.block.icon$.value;
const title = isLoading ? 'Loading...' : this.block.title$;
const showDefaultNoteContent = isLoading || error || isDeleted || isEmpty;
const defaultNoteContent = error
? 'This linked doc failed to load.'
: isLoading
? ''
: isDeleted
? 'This linked doc is deleted.'
: isEmpty
? 'Preview of the page will be displayed here.'
: '';
const dateText = this.block.docUpdatedAt.toLocaleString();
const showDefaultBanner = isLoading || error || isDeleted || isEmpty;
const defaultBanner = isLoading
? SyncedDocEmptyBanner
: error
? SyncedDocErrorBanner
: isDeleted
? SyncedDocDeletedBanner
: SyncedDocEmptyBanner;
return html`
<div
class="affine-embed-synced-doc-card ${cardClassMap}"
@click=${this._handleClick}
>
<div class="affine-embed-synced-doc-card-content">
<div class="affine-embed-synced-doc-card-content-title">
<div class="affine-embed-synced-doc-card-content-title-icon">
${icon}
</div>
<div class="affine-embed-synced-doc-card-content-title-text">
${title}
</div>
</div>
${showDefaultNoteContent
? html`<div class="affine-embed-synced-doc-content-note default">
${defaultNoteContent}
</div>`
: nothing}
<div class="affine-embed-synced-doc-content-note render"></div>
${error
? html`
<div class="affine-embed-synced-doc-card-content-reload">
<div
class="affine-embed-synced-doc-card-content-reload-button"
@click=${() => this.block.refreshData()}
>
${ReloadIcon} <span>Reload</span>
</div>
</div>
`
: html`
<div class="affine-embed-synced-doc-card-content-date">
<span>Updated</span>
<span>${dateText}</span>
</div>
`}
</div>
<div class="affine-embed-synced-doc-card-banner render"></div>
${showDefaultBanner
? html`
<div class="affine-embed-synced-doc-card-banner default">
${defaultBanner}
</div>
`
: nothing}
</div>
`;
}
@queryAsync('.affine-embed-synced-doc-card-banner.render')
accessor bannerContainer!: Promise<HTMLDivElement>;
@property({ attribute: false })
accessor block!: EmbedSyncedDocBlockComponent;
@property({ attribute: false })
accessor isBannerEmpty = false;
@property({ attribute: false })
accessor isError = false;
@property({ attribute: false })
accessor isNoteContentEmpty = false;
@queryAsync('.affine-embed-synced-doc-content-note.render')
accessor noteContainer!: Promise<HTMLDivElement>;
}

View File

@@ -0,0 +1,180 @@
import type { AliasInfo } from '@blocksuite/affine-model';
import {
EMBED_CARD_HEIGHT,
EMBED_CARD_WIDTH,
} from '@blocksuite/affine-shared/consts';
import {
ThemeExtensionIdentifier,
ThemeProvider,
} from '@blocksuite/affine-shared/services';
import { BlockStdScope } from '@blocksuite/block-std';
import { assertExists, Bound } from '@blocksuite/global/utils';
import { html } from 'lit';
import { choose } from 'lit/directives/choose.js';
import { classMap } from 'lit/directives/class-map.js';
import { guard } from 'lit/directives/guard.js';
import { styleMap } from 'lit/directives/style-map.js';
import { toEdgelessEmbedBlock } from '../common/to-edgeless-embed-block.js';
import { EmbedSyncedDocBlockComponent } from './embed-synced-doc-block.js';
export class EmbedEdgelessSyncedDocBlockComponent extends toEdgelessEmbedBlock(
EmbedSyncedDocBlockComponent
) {
protected override _renderSyncedView = () => {
const { syncedDoc, editorMode } = this;
assertExists(syncedDoc, 'Doc should exist');
let containerStyleMap = styleMap({
position: 'relative',
width: '100%',
});
const modelScale = this.model.scale ?? 1;
const bound = Bound.deserialize(this.model.xywh);
const width = bound.w / modelScale;
const height = bound.h / modelScale;
containerStyleMap = styleMap({
width: `${width}px`,
height: `${height}px`,
minHeight: `${height}px`,
transform: `scale(${modelScale})`,
transformOrigin: '0 0',
});
const themeService = this.std.get(ThemeProvider);
const themeExtension = this.std.getOptional(ThemeExtensionIdentifier);
const appTheme = themeService.app$.value;
let edgelessTheme = themeService.edgeless$.value;
if (themeExtension?.getEdgelessTheme && this.syncedDoc?.id) {
edgelessTheme = themeExtension.getEdgelessTheme(this.syncedDoc.id).value;
}
const theme = this.isPageMode ? appTheme : edgelessTheme;
const isSelected = !!this.selected?.is('block');
const scale = this.model.scale ?? 1;
this.dataset.nestedEditor = '';
const renderEditor = () => {
return choose(editorMode, [
[
'page',
() => html`
<div class="affine-page-viewport" data-theme=${appTheme}>
${new BlockStdScope({
doc: syncedDoc,
extensions: this._buildPreviewSpec('page:preview'),
}).render()}
</div>
`,
],
[
'edgeless',
() => html`
<div class="affine-edgeless-viewport" data-theme=${edgelessTheme}>
${new BlockStdScope({
doc: syncedDoc,
extensions: this._buildPreviewSpec('edgeless:preview'),
}).render()}
</div>
`,
],
]);
};
return this.renderEmbed(
() => html`
<div
class=${classMap({
'affine-embed-synced-doc-container': true,
[editorMode]: true,
[theme]: true,
selected: isSelected,
surface: true,
})}
@click=${this._handleClick}
style=${containerStyleMap}
?data-scale=${scale}
>
<div class="affine-embed-synced-doc-editor">
${this.isPageMode && this._isEmptySyncedDoc
? html`
<div class="affine-embed-synced-doc-editor-empty">
<span>
This is a linked doc, you can add content here.
</span>
</div>
`
: guard([editorMode, syncedDoc], renderEditor)}
</div>
<div class="affine-embed-synced-doc-editor-overlay"></div>
</div>
`
);
};
override convertToCard = (aliasInfo?: AliasInfo) => {
const { id, doc, caption, xywh } = this.model;
const edgelessService = this.rootService;
const style = 'vertical';
const bound = Bound.deserialize(xywh);
bound.w = EMBED_CARD_WIDTH[style];
bound.h = EMBED_CARD_HEIGHT[style];
if (!edgelessService) {
return;
}
// @ts-expect-error TODO: fix after edgeless refactor
const newId = edgelessService.addBlock(
'affine:embed-linked-doc',
{
xywh: bound.serialize(),
style,
caption,
...this.referenceInfo,
...aliasInfo,
},
// @ts-expect-error TODO: fix after edgeless refactor
edgelessService.surface
);
this.std.command.exec('reassociateConnectors', {
oldId: id,
newId,
});
// @ts-expect-error TODO: fix after edgeless refactor
edgelessService.selection.set({
editing: false,
elements: [newId],
});
doc.deleteBlock(this.model);
};
get rootService() {
return this.std.getService('affine:page');
}
override renderGfxBlock() {
const { style, xywh } = this.model;
const bound = Bound.deserialize(xywh);
this.embedContainerStyle.width = `${bound.w}px`;
this.embedContainerStyle.height = `${bound.h}px`;
this.cardStyleMap = {
display: 'block',
width: `${EMBED_CARD_WIDTH[style]}px`,
height: `${EMBED_CARD_WIDTH[style]}px`,
transform: `scale(${bound.w / EMBED_CARD_WIDTH[style]}, ${bound.h / EMBED_CARD_HEIGHT[style]})`,
transformOrigin: '0 0',
};
return this.renderPageContent();
}
override accessor useCaptionEditor = true;
}

View File

@@ -0,0 +1,596 @@
import { Peekable } from '@blocksuite/affine-components/peek';
import {
REFERENCE_NODE,
RefNodeSlotsProvider,
} from '@blocksuite/affine-components/rich-text';
import {
type AliasInfo,
type DocMode,
type EmbedSyncedDocModel,
NoteDisplayMode,
type ReferenceInfo,
} from '@blocksuite/affine-model';
import {
DocDisplayMetaProvider,
DocModeProvider,
ThemeExtensionIdentifier,
ThemeProvider,
} from '@blocksuite/affine-shared/services';
import {
cloneReferenceInfo,
SpecProvider,
} from '@blocksuite/affine-shared/utils';
import {
BlockServiceWatcher,
BlockStdScope,
type EditorHost,
} from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import { assertExists, Bound, getCommonBound } from '@blocksuite/global/utils';
import {
BlockViewType,
DocCollection,
type GetDocOptions,
type Query,
} from '@blocksuite/store';
import { computed } from '@preact/signals-core';
import { html, type PropertyValues } from 'lit';
import { query, state } from 'lit/decorators.js';
import { choose } from 'lit/directives/choose.js';
import { classMap } from 'lit/directives/class-map.js';
import { guard } from 'lit/directives/guard.js';
import { type StyleInfo, styleMap } from 'lit/directives/style-map.js';
import { EmbedBlockComponent } from '../common/embed-block-element.js';
import { isEmptyDoc } from '../common/render-linked-doc.js';
import type { EmbedSyncedDocCard } from './components/embed-synced-doc-card.js';
import { blockStyles } from './styles.js';
@Peekable({
enableOn: ({ doc }: EmbedSyncedDocBlockComponent) => !doc.readonly,
})
export class EmbedSyncedDocBlockComponent extends EmbedBlockComponent<EmbedSyncedDocModel> {
static override styles = blockStyles;
// Caches total bounds, includes all blocks and elements.
private _cachedBounds: Bound | null = null;
private _initEdgelessFitEffect = () => {
const fitToContent = () => {
if (this.isPageMode) return;
const controller = this.syncedDocEditorHost?.std.getOptional(
GfxControllerIdentifier
);
if (!controller) return;
const viewport = controller.viewport;
if (!viewport) return;
if (!this._cachedBounds) {
this._cachedBounds = getCommonBound([
...controller.layer.blocks.map(block =>
Bound.deserialize(block.xywh)
),
...controller.layer.canvasElements,
]);
}
viewport.onResize();
const { centerX, centerY, zoom } = viewport.getFitToScreenData(
this._cachedBounds
);
viewport.setCenter(centerX, centerY);
viewport.setZoom(zoom);
};
const observer = new ResizeObserver(fitToContent);
const block = this.embedBlock;
observer.observe(block);
this._disposables.add(() => {
observer.disconnect();
});
this.syncedDocEditorHost?.updateComplete
.then(() => fitToContent())
.catch(() => {});
};
private _pageFilter: Query = {
mode: 'loose',
match: [
{
flavour: 'affine:note',
props: {
displayMode: NoteDisplayMode.EdgelessOnly,
},
viewType: BlockViewType.Hidden,
},
],
};
protected _buildPreviewSpec = (name: 'page:preview' | 'edgeless:preview') => {
const nextDepth = this.depth + 1;
const previewSpecBuilder = SpecProvider.getInstance().getSpec(name);
const currentDisposables = this.disposables;
class EmbedSyncedDocWatcher extends BlockServiceWatcher {
static override readonly flavour = 'affine:embed-synced-doc';
override mounted() {
const disposableGroup = this.blockService.disposables;
const slots = this.blockService.specSlots;
disposableGroup.add(
slots.viewConnected.on(({ component }) => {
const nextComponent = component as EmbedSyncedDocBlockComponent;
nextComponent.depth = nextDepth;
currentDisposables.add(() => {
nextComponent.depth = 0;
});
})
);
disposableGroup.add(
slots.viewDisconnected.on(({ component }) => {
const nextComponent = component as EmbedSyncedDocBlockComponent;
nextComponent.depth = 0;
})
);
}
}
previewSpecBuilder.extend([EmbedSyncedDocWatcher]);
return previewSpecBuilder.value;
};
protected _renderSyncedView = () => {
const syncedDoc = this.syncedDoc;
const editorMode = this.editorMode;
const isPageMode = this.isPageMode;
assertExists(syncedDoc);
if (isPageMode) {
this.dataset.pageMode = '';
}
const containerStyleMap = styleMap({
position: 'relative',
width: '100%',
});
const themeService = this.std.get(ThemeProvider);
const themeExtension = this.std.getOptional(ThemeExtensionIdentifier);
const appTheme = themeService.app$.value;
let edgelessTheme = themeService.edgeless$.value;
if (themeExtension?.getEdgelessTheme && this.syncedDoc?.id) {
edgelessTheme = themeExtension.getEdgelessTheme(this.syncedDoc.id).value;
}
const theme = isPageMode ? appTheme : edgelessTheme;
const isSelected = !!this.selected?.is('block');
this.dataset.nestedEditor = '';
const renderEditor = () => {
return choose(editorMode, [
[
'page',
() => html`
<div class="affine-page-viewport" data-theme=${appTheme}>
${new BlockStdScope({
doc: syncedDoc,
extensions: this._buildPreviewSpec('page:preview'),
}).render()}
</div>
`,
],
[
'edgeless',
() => html`
<div class="affine-edgeless-viewport" data-theme=${edgelessTheme}>
${new BlockStdScope({
doc: syncedDoc,
extensions: this._buildPreviewSpec('edgeless:preview'),
}).render()}
</div>
`,
],
]);
};
return this.renderEmbed(
() => html`
<div
class=${classMap({
'affine-embed-synced-doc-container': true,
[editorMode]: true,
[theme]: true,
selected: isSelected,
surface: false,
})}
@click=${this._handleClick}
style=${containerStyleMap}
?data-scale=${undefined}
>
<div class="affine-embed-synced-doc-editor">
${isPageMode && this._isEmptySyncedDoc
? html`
<div class="affine-embed-synced-doc-editor-empty">
<span>
This is a linked doc, you can add content here.
</span>
</div>
`
: guard([editorMode, syncedDoc], renderEditor)}
</div>
<div
class=${classMap({
'affine-embed-synced-doc-header-wrapper': true,
selected: isSelected,
})}
>
<div class="affine-embed-synced-doc-header">
<span class="affine-embed-synced-doc-icon"
>${this.icon$.value}</span
>
<span class="affine-embed-synced-doc-title">${this.title$}</span>
</div>
</div>
</div>
`
);
};
protected cardStyleMap = styleMap({
position: 'relative',
display: 'block',
width: '100%',
});
convertToCard = (aliasInfo?: AliasInfo) => {
const { doc, caption } = this.model;
const parent = doc.getParent(this.model);
assertExists(parent);
const index = parent.children.indexOf(this.model);
doc.addBlock(
'affine:embed-linked-doc',
{ caption, ...this.referenceInfo, ...aliasInfo },
parent,
index
);
this.std.selection.setGroup('note', []);
doc.deleteBlock(this.model);
};
covertToInline = () => {
const { doc } = this.model;
const parent = doc.getParent(this.model);
assertExists(parent);
const index = parent.children.indexOf(this.model);
const yText = new DocCollection.Y.Text();
yText.insert(0, REFERENCE_NODE);
yText.format(0, REFERENCE_NODE.length, {
reference: {
type: 'LinkedPage',
...this.referenceInfo,
},
});
const text = new doc.Text(yText);
doc.addBlock(
'affine:paragraph',
{
text,
},
parent,
index
);
doc.deleteBlock(this.model);
};
protected override embedContainerStyle: StyleInfo = {
height: 'unset',
};
icon$ = computed(() => {
const { pageId, params } = this.model;
return this.std
.get(DocDisplayMetaProvider)
.icon(pageId, { params, referenced: true }).value;
});
open = () => {
const pageId = this.model.pageId;
if (pageId === this.doc.id) return;
this.std.getOptional(RefNodeSlotsProvider)?.docLinkClicked.emit({ pageId });
};
refreshData = () => {
this._load().catch(e => {
console.error(e);
this._error = true;
});
};
title$ = computed(() => {
const { pageId, params } = this.model;
return this.std
.get(DocDisplayMetaProvider)
.title(pageId, { params, referenced: true });
});
private get _rootService() {
return this.std.getService('affine:page');
}
get blockState() {
return {
isLoading: this._loading,
isError: this._error,
isDeleted: this._deleted,
isCycle: this._cycle,
};
}
get docTitle() {
return this.syncedDoc?.meta?.title || 'Untitled';
}
get docUpdatedAt() {
return this._docUpdatedAt;
}
get editorMode() {
return this.linkedMode ?? this.syncedDocMode;
}
protected get isPageMode() {
return this.editorMode === 'page';
}
get linkedMode() {
return this.referenceInfo.params?.mode;
}
get referenceInfo(): ReferenceInfo {
return cloneReferenceInfo(this.model);
}
get syncedDoc() {
const options: GetDocOptions = { readonly: true };
if (this.isPageMode) options.query = this._pageFilter;
return this.std.collection.getDoc(this.model.pageId, options);
}
private _checkCycle() {
let editorHost: EditorHost | null = this.host;
while (editorHost && !this._cycle) {
this._cycle = !!editorHost && editorHost.doc.id === this.model.pageId;
editorHost =
editorHost.parentElement?.closest<EditorHost>('editor-host') ?? null;
}
}
private _isClickAtBorder(
event: MouseEvent,
element: HTMLElement,
tolerance = 8
): boolean {
const { x, y } = event;
const rect = element.getBoundingClientRect();
if (!rect) {
return false;
}
return (
Math.abs(x - rect.left) < tolerance ||
Math.abs(x - rect.right) < tolerance ||
Math.abs(y - rect.top) < tolerance ||
Math.abs(y - rect.bottom) < tolerance
);
}
private async _load() {
this._loading = true;
this._error = false;
this._deleted = false;
this._cycle = false;
const syncedDoc = this.syncedDoc;
if (!syncedDoc) {
this._deleted = true;
this._loading = false;
return;
}
this._checkCycle();
if (!syncedDoc.loaded) {
try {
syncedDoc.load();
} catch (e) {
console.error(e);
this._error = true;
}
}
if (!this._error && !syncedDoc.root) {
await new Promise<void>(resolve => {
syncedDoc.slots.rootAdded.once(() => resolve());
});
}
this._loading = false;
}
private _selectBlock() {
const selectionManager = this.host.selection;
const blockSelection = selectionManager.create('block', {
blockId: this.blockId,
});
selectionManager.setGroup('note', [blockSelection]);
}
private _setDocUpdatedAt() {
const meta = this.doc.collection.meta.getDocMeta(this.model.pageId);
if (meta) {
const date = meta.updatedDate || meta.createDate;
this._docUpdatedAt = new Date(date);
}
}
protected _handleClick(_event: MouseEvent) {
this._selectBlock();
}
override connectedCallback() {
super.connectedCallback();
this._cardStyle = this.model.style;
this.style.display = 'block';
this._load().catch(e => {
console.error(e);
this._error = true;
});
this.contentEditable = 'false';
this.disposables.add(
this.model.propsUpdated.on(({ key }) => {
if (key === 'pageId' || key === 'style') {
this._load().catch(e => {
console.error(e);
this._error = true;
});
}
})
);
this._setDocUpdatedAt();
this.disposables.add(
this.doc.collection.meta.docMetaUpdated.on(() => {
this._setDocUpdatedAt();
})
);
if (this._rootService && !this.linkedMode) {
const docMode = this._rootService.std.get(DocModeProvider);
this.syncedDocMode = docMode.getPrimaryMode(this.model.pageId);
this._isEmptySyncedDoc = isEmptyDoc(this.syncedDoc, this.editorMode);
this.disposables.add(
docMode.onPrimaryModeChange(mode => {
this.syncedDocMode = mode;
this._isEmptySyncedDoc = isEmptyDoc(this.syncedDoc, this.editorMode);
}, this.model.pageId)
);
}
this.syncedDoc &&
this.disposables.add(
this.syncedDoc.slots.blockUpdated.on(() => {
this._isEmptySyncedDoc = isEmptyDoc(this.syncedDoc, this.editorMode);
})
);
}
override firstUpdated() {
this.disposables.addFromEvent(this, 'click', e => {
e.stopPropagation();
if (this._isClickAtBorder(e, this)) {
e.preventDefault();
this._selectBlock();
}
});
// Forward docLinkClicked event from the synced doc
const refNodeProvider =
this.syncedDocEditorHost?.std.getOptional(RefNodeSlotsProvider);
if (refNodeProvider) {
this.disposables.add(
refNodeProvider.docLinkClicked.on(args => {
this.std.getOptional(RefNodeSlotsProvider)?.docLinkClicked.emit(args);
})
);
}
this._initEdgelessFitEffect();
}
override renderBlock() {
delete this.dataset.nestedEditor;
const syncedDoc = this.syncedDoc;
const { isLoading, isError, isDeleted, isCycle } = this.blockState;
const isCardOnly = this.depth >= 1;
if (
isLoading ||
isError ||
isDeleted ||
isCardOnly ||
isCycle ||
!syncedDoc
) {
return this.renderEmbed(
() => html`
<affine-embed-synced-doc-card
style=${this.cardStyleMap}
.block=${this}
></affine-embed-synced-doc-card>
`
);
}
return this._renderSyncedView();
}
override updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
this.syncedDocCard?.requestUpdate();
}
@state()
private accessor _cycle = false;
@state()
private accessor _deleted = false;
@state()
private accessor _docUpdatedAt: Date = new Date();
@state()
private accessor _error = false;
@state()
protected accessor _isEmptySyncedDoc: boolean = true;
@state()
private accessor _loading = false;
@state()
accessor depth = 0;
@query(
':scope > .affine-block-component > .embed-block-container > affine-embed-synced-doc-card'
)
accessor syncedDocCard: EmbedSyncedDocCard | null = null;
@query(
':scope > .affine-block-component > .embed-block-container > .affine-embed-synced-doc-container > .affine-embed-synced-doc-editor > div > editor-host'
)
accessor syncedDocEditorHost: EditorHost | null = null;
@state()
accessor syncedDocMode: DocMode = 'page';
override accessor useCaptionEditor = true;
}

View File

@@ -0,0 +1,6 @@
import { EmbedSyncedDocBlockSchema } from '@blocksuite/affine-model';
import { BlockService } from '@blocksuite/block-std';
export class EmbedSyncedDocBlockService extends BlockService {
static override readonly flavour = EmbedSyncedDocBlockSchema.model.flavour;
}

View File

@@ -0,0 +1,18 @@
import {
BlockViewExtension,
type ExtensionType,
FlavourExtension,
} from '@blocksuite/block-std';
import { literal } from 'lit/static-html.js';
import { EmbedSyncedDocBlockService } from './embed-synced-doc-service.js';
export const EmbedSyncedDocBlockSpec: ExtensionType[] = [
FlavourExtension('affine:embed-synced-doc'),
EmbedSyncedDocBlockService,
BlockViewExtension('affine:embed-synced-doc', model => {
return model.parent?.flavour === 'affine:surface'
? literal`affine-embed-edgeless-synced-doc-block`
: literal`affine-embed-synced-doc-block`;
}),
];

View File

@@ -0,0 +1,4 @@
export * from './adapters/index.js';
export * from './embed-synced-doc-block.js';
export * from './embed-synced-doc-spec.js';
export { SYNCED_MIN_HEIGHT, SYNCED_MIN_WIDTH } from './styles.js';

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More