mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-04 08:38:34 +00:00
chore: merge blocksuite source code (#9213)
This commit is contained in:
100
blocksuite/affine/all/package.json
Normal file
100
blocksuite/affine/all/package.json
Normal 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__"
|
||||
]
|
||||
}
|
||||
1
blocksuite/affine/all/src/block-std/gfx.ts
Normal file
1
blocksuite/affine/all/src/block-std/gfx.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from '@blocksuite/block-std/gfx';
|
||||
1
blocksuite/affine/all/src/block-std/index.ts
Normal file
1
blocksuite/affine/all/src/block-std/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from '@blocksuite/block-std';
|
||||
1
blocksuite/affine/all/src/blocks/index.ts
Normal file
1
blocksuite/affine/all/src/blocks/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from '@blocksuite/blocks';
|
||||
1
blocksuite/affine/all/src/blocks/schemas.ts
Normal file
1
blocksuite/affine/all/src/blocks/schemas.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from '@blocksuite/blocks/schemas';
|
||||
7
blocksuite/affine/all/src/effects.ts
Normal file
7
blocksuite/affine/all/src/effects.ts
Normal 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();
|
||||
}
|
||||
1
blocksuite/affine/all/src/global/di.ts
Normal file
1
blocksuite/affine/all/src/global/di.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from '@blocksuite/global/di';
|
||||
1
blocksuite/affine/all/src/global/env.ts
Normal file
1
blocksuite/affine/all/src/global/env.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from '@blocksuite/global/env';
|
||||
1
blocksuite/affine/all/src/global/exceptions.ts
Normal file
1
blocksuite/affine/all/src/global/exceptions.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from '@blocksuite/global/exceptions';
|
||||
1
blocksuite/affine/all/src/global/index.ts
Normal file
1
blocksuite/affine/all/src/global/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from '@blocksuite/global';
|
||||
1
blocksuite/affine/all/src/global/types.ts
Normal file
1
blocksuite/affine/all/src/global/types.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from '@blocksuite/global/types';
|
||||
1
blocksuite/affine/all/src/global/utils.ts
Normal file
1
blocksuite/affine/all/src/global/utils.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from '@blocksuite/global/utils';
|
||||
1
blocksuite/affine/all/src/index.ts
Normal file
1
blocksuite/affine/all/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export {};
|
||||
1
blocksuite/affine/all/src/inline/consts.ts
Normal file
1
blocksuite/affine/all/src/inline/consts.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from '@blocksuite/inline/consts';
|
||||
1
blocksuite/affine/all/src/inline/index.ts
Normal file
1
blocksuite/affine/all/src/inline/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from '@blocksuite/inline';
|
||||
1
blocksuite/affine/all/src/inline/types.ts
Normal file
1
blocksuite/affine/all/src/inline/types.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from '@blocksuite/inline/types';
|
||||
1
blocksuite/affine/all/src/presets/index.ts
Normal file
1
blocksuite/affine/all/src/presets/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from '@blocksuite/presets';
|
||||
5
blocksuite/affine/all/src/store/index.ts
Normal file
5
blocksuite/affine/all/src/store/index.ts
Normal 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';
|
||||
29
blocksuite/affine/all/tsconfig.json
Normal file
29
blocksuite/affine/all/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
4
blocksuite/affine/all/typedoc.json
Normal file
4
blocksuite/affine/all/typedoc.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": ["../../../typedoc.base.json"],
|
||||
"entryPoints": ["src/index.ts"]
|
||||
}
|
||||
30
blocksuite/affine/all/vitest.config.ts
Normal file
30
blocksuite/affine/all/vitest.config.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
43
blocksuite/affine/block-embed/package.json
Normal file
43
blocksuite/affine/block-embed/package.json
Normal 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__"
|
||||
]
|
||||
}
|
||||
66
blocksuite/affine/block-embed/src/common/adapters/html.ts
Normal file
66
blocksuite/affine/block-embed/src/common/adapters/html.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { BlockMarkdownAdapterMatcher } from '@blocksuite/affine-shared/adapters';
|
||||
|
||||
export function createEmbedBlockMarkdownAdapterMatcher(
|
||||
flavour: string,
|
||||
{
|
||||
toMatch = () => false,
|
||||
fromMatch = o => o.node.flavour === flavour,
|
||||
toBlockSnapshot = {},
|
||||
fromBlockSnapshot = {
|
||||
enter: (o, context) => {
|
||||
const { walkerContext } = context;
|
||||
// Parse as link
|
||||
if (
|
||||
typeof o.node.props.title !== 'string' ||
|
||||
typeof o.node.props.url !== 'string'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
walkerContext
|
||||
.openNode(
|
||||
{
|
||||
type: 'paragraph',
|
||||
children: [],
|
||||
},
|
||||
'children'
|
||||
)
|
||||
.openNode(
|
||||
{
|
||||
type: 'link',
|
||||
url: o.node.props.url,
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
value: o.node.props.title,
|
||||
},
|
||||
],
|
||||
},
|
||||
'children'
|
||||
)
|
||||
.closeNode()
|
||||
.closeNode();
|
||||
},
|
||||
},
|
||||
}: {
|
||||
toMatch?: BlockMarkdownAdapterMatcher['toMatch'];
|
||||
fromMatch?: BlockMarkdownAdapterMatcher['fromMatch'];
|
||||
toBlockSnapshot?: BlockMarkdownAdapterMatcher['toBlockSnapshot'];
|
||||
fromBlockSnapshot?: BlockMarkdownAdapterMatcher['fromBlockSnapshot'];
|
||||
} = {}
|
||||
): BlockMarkdownAdapterMatcher {
|
||||
return {
|
||||
flavour,
|
||||
toMatch,
|
||||
fromMatch,
|
||||
toBlockSnapshot,
|
||||
fromBlockSnapshot,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { BlockPlainTextAdapterMatcher } from '@blocksuite/affine-shared/adapters';
|
||||
|
||||
export function createEmbedBlockPlainTextAdapterMatcher(
|
||||
flavour: string,
|
||||
{
|
||||
toMatch = () => false,
|
||||
fromMatch = o => o.node.flavour === flavour,
|
||||
toBlockSnapshot = {},
|
||||
fromBlockSnapshot = {
|
||||
enter: (o, context) => {
|
||||
const { textBuffer } = context;
|
||||
// Parse as link
|
||||
if (
|
||||
typeof o.node.props.title !== 'string' ||
|
||||
typeof o.node.props.url !== 'string'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const buffer = `[${o.node.props.title}](${o.node.props.url})`;
|
||||
if (buffer.length > 0) {
|
||||
textBuffer.content += buffer;
|
||||
textBuffer.content += '\n';
|
||||
}
|
||||
},
|
||||
},
|
||||
}: {
|
||||
toMatch?: BlockPlainTextAdapterMatcher['toMatch'];
|
||||
fromMatch?: BlockPlainTextAdapterMatcher['fromMatch'];
|
||||
toBlockSnapshot?: BlockPlainTextAdapterMatcher['toBlockSnapshot'];
|
||||
fromBlockSnapshot?: BlockPlainTextAdapterMatcher['fromBlockSnapshot'];
|
||||
} = {}
|
||||
): BlockPlainTextAdapterMatcher {
|
||||
return {
|
||||
flavour,
|
||||
toMatch,
|
||||
fromMatch,
|
||||
toBlockSnapshot,
|
||||
fromBlockSnapshot,
|
||||
};
|
||||
}
|
||||
13
blocksuite/affine/block-embed/src/common/adapters/utils.ts
Normal file
13
blocksuite/affine/block-embed/src/common/adapters/utils.ts
Normal 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;
|
||||
}
|
||||
172
blocksuite/affine/block-embed/src/common/embed-block-element.ts
Normal file
172
blocksuite/affine/block-embed/src/common/embed-block-element.ts
Normal 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;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { css } from 'lit';
|
||||
|
||||
export const embedNoteContentStyles = css`
|
||||
.affine-embed-doc-content-note-blocks affine-divider,
|
||||
.affine-embed-doc-content-note-blocks affine-divider > * {
|
||||
margin-top: 0px !important;
|
||||
margin-bottom: 0px !important;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph,
|
||||
.affine-embed-doc-content-note-blocks affine-list {
|
||||
margin-top: 4px !important;
|
||||
margin-bottom: 4px !important;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph *,
|
||||
.affine-embed-doc-content-note-blocks affine-list * {
|
||||
margin-top: 0px !important;
|
||||
margin-bottom: 0px !important;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
line-height: 20px;
|
||||
font-size: var(--affine-font-xs);
|
||||
font-weight: 400;
|
||||
}
|
||||
.affine-embed-doc-content-note-blocks affine-list .affine-list-block__prefix {
|
||||
height: 20px;
|
||||
}
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph .quote {
|
||||
padding-left: 15px;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph:has(.h1),
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph:has(.h2),
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph:has(.h3),
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph:has(.h4),
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph:has(.h5),
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph:has(.h6) {
|
||||
margin-top: 6px !important;
|
||||
margin-bottom: 4px !important;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph:has(.h1) *,
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph:has(.h2) *,
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph:has(.h3) *,
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph:has(.h4) *,
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph:has(.h5) *,
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph:has(.h6) * {
|
||||
margin-top: 0px !important;
|
||||
margin-bottom: 0px !important;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
line-height: 20px;
|
||||
font-size: var(--affine-font-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.affine-embed-linked-doc-block.horizontal {
|
||||
affine-paragraph,
|
||||
affine-list {
|
||||
margin-top: 0 !important;
|
||||
margin-bottom: 0 !important;
|
||||
max-height: 40px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
}
|
||||
affine-paragraph .quote {
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
height: 28px;
|
||||
}
|
||||
affine-paragraph .quote::after {
|
||||
height: 20px;
|
||||
margin-top: 4px !important;
|
||||
margin-bottom: 4px !important;
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,81 @@
|
||||
import { SurfaceBlockComponent } from '@blocksuite/affine-block-surface';
|
||||
import type { EmbedCardStyle } from '@blocksuite/affine-model';
|
||||
import {
|
||||
EMBED_CARD_HEIGHT,
|
||||
EMBED_CARD_WIDTH,
|
||||
} from '@blocksuite/affine-shared/consts';
|
||||
import type { BlockStdScope } from '@blocksuite/block-std';
|
||||
import { Bound, Vec } from '@blocksuite/global/utils';
|
||||
|
||||
interface EmbedCardProperties {
|
||||
flavour: string;
|
||||
targetStyle: EmbedCardStyle;
|
||||
props: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function insertEmbedCard(
|
||||
std: BlockStdScope,
|
||||
properties: EmbedCardProperties
|
||||
) {
|
||||
const { host } = std;
|
||||
const { flavour, targetStyle, props } = properties;
|
||||
const selectionManager = host.selection;
|
||||
|
||||
let blockId: string | undefined;
|
||||
const textSelection = selectionManager.find('text');
|
||||
const blockSelection = selectionManager.find('block');
|
||||
const surfaceSelection = selectionManager.find('surface');
|
||||
if (textSelection) {
|
||||
blockId = textSelection.blockId;
|
||||
} else if (blockSelection) {
|
||||
blockId = blockSelection.blockId;
|
||||
} else if (surfaceSelection && surfaceSelection.editing) {
|
||||
blockId = surfaceSelection.blockId;
|
||||
}
|
||||
|
||||
if (blockId) {
|
||||
const block = host.view.getBlock(blockId);
|
||||
if (!block) return;
|
||||
const parent = host.doc.getParent(block.model);
|
||||
if (!parent) return;
|
||||
const index = parent.children.indexOf(block.model);
|
||||
host.doc.addBlock(flavour as never, props, parent, index + 1);
|
||||
} else {
|
||||
const rootId = std.doc.root?.id;
|
||||
if (!rootId) return;
|
||||
const edgelessRoot = std.view.getBlock(rootId);
|
||||
if (!edgelessRoot) return;
|
||||
|
||||
// @ts-expect-error TODO: fix after edgeless refactor
|
||||
edgelessRoot.service.viewport.smoothZoom(1);
|
||||
// @ts-expect-error TODO: fix after edgeless refactor
|
||||
const surfaceBlock = edgelessRoot.surface;
|
||||
if (!(surfaceBlock instanceof SurfaceBlockComponent)) return;
|
||||
const center = Vec.toVec(surfaceBlock.renderer.viewport.center);
|
||||
// @ts-expect-error TODO: fix after edgeless refactor
|
||||
const cardId = edgelessRoot.service.addBlock(
|
||||
flavour,
|
||||
{
|
||||
...props,
|
||||
xywh: Bound.fromCenter(
|
||||
center,
|
||||
EMBED_CARD_WIDTH[targetStyle],
|
||||
EMBED_CARD_HEIGHT[targetStyle]
|
||||
).serialize(),
|
||||
style: targetStyle,
|
||||
},
|
||||
surfaceBlock.model
|
||||
);
|
||||
|
||||
// @ts-expect-error TODO: fix after edgeless refactor
|
||||
edgelessRoot.service.selection.set({
|
||||
elements: [cardId],
|
||||
editing: false,
|
||||
});
|
||||
|
||||
// @ts-expect-error TODO: fix after edgeless refactor
|
||||
edgelessRoot.tools.setEdgelessTool({
|
||||
type: 'default',
|
||||
});
|
||||
}
|
||||
}
|
||||
99
blocksuite/affine/block-embed/src/common/link-previewer.ts
Normal file
99
blocksuite/affine/block-embed/src/common/link-previewer.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
297
blocksuite/affine/block-embed/src/common/render-linked-doc.ts
Normal file
297
blocksuite/affine/block-embed/src/common/render-linked-doc.ts
Normal 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');
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
47
blocksuite/affine/block-embed/src/common/utils.ts
Normal file
47
blocksuite/affine/block-embed/src/common/utils.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
131
blocksuite/affine/block-embed/src/effects.ts
Normal file
131
blocksuite/affine/block-embed/src/effects.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './html.js';
|
||||
export * from './markdown.js';
|
||||
export * from './plain-text.js';
|
||||
@@ -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
|
||||
);
|
||||
@@ -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);
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const figmaUrlRegex: RegExp =
|
||||
/https:\/\/[\w.-]+\.?figma.com\/([\w-]+)\/([0-9a-zA-Z]{22,128})(?:\/.*)?$/;
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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`;
|
||||
}),
|
||||
];
|
||||
@@ -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';
|
||||
229
blocksuite/affine/block-embed/src/embed-figma-block/styles.ts
Normal file
229
blocksuite/affine/block-embed/src/embed-figma-block/styles.ts
Normal 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>`;
|
||||
@@ -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
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './html.js';
|
||||
export * from './markdown.js';
|
||||
export * from './plain-text.js';
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const githubUrlRegex: RegExp =
|
||||
/^(?:https?:\/\/)?(?:www\.)?github\.com\/([^/]+)\/([^/]+)\/(issue|pull)s?\/(\d+)$/;
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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`;
|
||||
}),
|
||||
];
|
||||
@@ -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';
|
||||
513
blocksuite/affine/block-embed/src/embed-github-block/styles.ts
Normal file
513
blocksuite/affine/block-embed/src/embed-github-block/styles.ts
Normal 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>`;
|
||||
170
blocksuite/affine/block-embed/src/embed-github-block/utils.ts
Normal file
170
blocksuite/affine/block-embed/src/embed-github-block/utils.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`;
|
||||
}),
|
||||
];
|
||||
@@ -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';
|
||||
150
blocksuite/affine/block-embed/src/embed-html-block/styles.ts
Normal file
150
blocksuite/affine/block-embed/src/embed-html-block/styles.ts
Normal 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> `;
|
||||
@@ -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
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './html.js';
|
||||
export * from './markdown.js';
|
||||
export * from './plain-text.js';
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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`;
|
||||
}),
|
||||
];
|
||||
@@ -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';
|
||||
2247
blocksuite/affine/block-embed/src/embed-linked-doc-block/styles.ts
Normal file
2247
blocksuite/affine/block-embed/src/embed-linked-doc-block/styles.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './html.js';
|
||||
export * from './markdown.js';
|
||||
export * from './plain-text.js';
|
||||
@@ -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
|
||||
);
|
||||
@@ -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);
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const loomUrlRegex: RegExp =
|
||||
/(?:https?:\/\/)??(?:www\.)?loom\.com\/share\/([a-zA-Z0-9]+)/;
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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`;
|
||||
}),
|
||||
];
|
||||
@@ -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';
|
||||
221
blocksuite/affine/block-embed/src/embed-loom-block/styles.ts
Normal file
221
blocksuite/affine/block-embed/src/embed-loom-block/styles.ts
Normal 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>`;
|
||||
75
blocksuite/affine/block-embed/src/embed-loom-block/utils.ts
Normal file
75
blocksuite/affine/block-embed/src/embed-loom-block/utils.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './html.js';
|
||||
export * from './markdown.js';
|
||||
export * from './plain-text.js';
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`;
|
||||
}),
|
||||
];
|
||||
@@ -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';
|
||||
1129
blocksuite/affine/block-embed/src/embed-synced-doc-block/styles.ts
Normal file
1129
blocksuite/affine/block-embed/src/embed-synced-doc-block/styles.ts
Normal file
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
Reference in New Issue
Block a user