snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 4s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Analyze (javascript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Some checks failed
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 4s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Analyze (javascript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
This commit is contained in:
41
blocksuite/affine/blocks/embed/package.json
Normal file
41
blocksuite/affine/blocks/embed/package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "@blocksuite/affine-block-embed",
|
||||
"description": "Embed blocks for BlockSuite.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "yarn run -T tsc"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"keywords": [],
|
||||
"author": "toeverything",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@blocksuite/affine-block-surface": "workspace:*",
|
||||
"@blocksuite/affine-components": "workspace:*",
|
||||
"@blocksuite/affine-ext-loader": "workspace:*",
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/affine-widget-slash-menu": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.17",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"lit": "^3.2.0",
|
||||
"yjs": "^13.6.27"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./view": "./src/view.ts",
|
||||
"./store": "./src/store.ts"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.27.5"
|
||||
}
|
||||
66
blocksuite/affine/blocks/embed/src/common/adapters/html.ts
Normal file
66
blocksuite/affine/blocks/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,89 @@
|
||||
import {
|
||||
type BlockNotionHtmlAdapterMatcher,
|
||||
HastUtils,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import { nanoid } from '@blocksuite/store';
|
||||
|
||||
export function createEmbedBlockNotionHtmlAdapterMatcher(
|
||||
flavour: string,
|
||||
urlRegex: RegExp,
|
||||
{
|
||||
toMatch = o => {
|
||||
const isFigure =
|
||||
HastUtils.isElement(o.node) && o.node.tagName === 'figure';
|
||||
const embededFigureWrapper = HastUtils.querySelector(o.node, '.source');
|
||||
if (!isFigure || !embededFigureWrapper) {
|
||||
return false;
|
||||
}
|
||||
const embededURL = HastUtils.querySelector(embededFigureWrapper, 'a')
|
||||
?.properties.href;
|
||||
|
||||
if (!embededURL || typeof embededURL !== 'string') {
|
||||
return false;
|
||||
}
|
||||
// To avoid polynomial regular expression used on uncontrolled data
|
||||
// https://codeql.github.com/codeql-query-help/javascript/js-polynomial-redos/
|
||||
if (embededURL.length > 1000) {
|
||||
return false;
|
||||
}
|
||||
return urlRegex.test(embededURL);
|
||||
},
|
||||
fromMatch = o => o.node.flavour === flavour,
|
||||
toBlockSnapshot = {
|
||||
enter: (o, context) => {
|
||||
if (!HastUtils.isElement(o.node)) {
|
||||
return;
|
||||
}
|
||||
const { assets, walkerContext } = context;
|
||||
if (!assets) {
|
||||
return;
|
||||
}
|
||||
|
||||
const embededFigureWrapper = HastUtils.querySelector(o.node, '.source');
|
||||
if (!embededFigureWrapper) {
|
||||
return;
|
||||
}
|
||||
|
||||
let embededURL = '';
|
||||
const embedA = HastUtils.querySelector(embededFigureWrapper, 'a');
|
||||
embededURL =
|
||||
typeof embedA?.properties.href === 'string'
|
||||
? embedA.properties.href
|
||||
: '';
|
||||
if (!embededURL) {
|
||||
return;
|
||||
}
|
||||
|
||||
walkerContext
|
||||
.openNode(
|
||||
{
|
||||
type: 'block',
|
||||
id: nanoid(),
|
||||
flavour,
|
||||
props: {
|
||||
url: embededURL,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
'children'
|
||||
)
|
||||
.closeNode();
|
||||
walkerContext.skipAllChildren();
|
||||
},
|
||||
},
|
||||
fromBlockSnapshot = {},
|
||||
}: {
|
||||
toMatch?: BlockNotionHtmlAdapterMatcher['toMatch'];
|
||||
fromMatch?: BlockNotionHtmlAdapterMatcher['fromMatch'];
|
||||
toBlockSnapshot?: BlockNotionHtmlAdapterMatcher['toBlockSnapshot'];
|
||||
fromBlockSnapshot?: BlockNotionHtmlAdapterMatcher['fromBlockSnapshot'];
|
||||
} = Object.create(null)
|
||||
): BlockNotionHtmlAdapterMatcher {
|
||||
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,
|
||||
};
|
||||
}
|
||||
207
blocksuite/affine/blocks/embed/src/common/embed-block-element.ts
Normal file
207
blocksuite/affine/blocks/embed/src/common/embed-block-element.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import {
|
||||
CaptionedBlockComponent,
|
||||
SelectedStyle,
|
||||
} from '@blocksuite/affine-components/caption';
|
||||
import type { EmbedCardStyle, EmbedProps } from '@blocksuite/affine-model';
|
||||
import {
|
||||
EMBED_CARD_HEIGHT,
|
||||
EMBED_CARD_MIN_WIDTH,
|
||||
EMBED_CARD_WIDTH,
|
||||
} from '@blocksuite/affine-shared/consts';
|
||||
import {
|
||||
BlockElementCommentManager,
|
||||
DocModeProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
import { findAncestorModel } from '@blocksuite/affine-shared/utils';
|
||||
import type { BlockService } from '@blocksuite/std';
|
||||
import {
|
||||
GfxViewInteractionExtension,
|
||||
type ResizeConstraint,
|
||||
} from '@blocksuite/std/gfx';
|
||||
import type { BlockModel } from '@blocksuite/store';
|
||||
import { computed, type ReadonlySignal, signal } from '@preact/signals-core';
|
||||
import type { TemplateResult } from 'lit';
|
||||
import { html } from 'lit';
|
||||
import { query } from 'lit/decorators.js';
|
||||
import { type ClassInfo, classMap } from 'lit/directives/class-map.js';
|
||||
import { type StyleInfo, styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
export class EmbedBlockComponent<
|
||||
Model extends BlockModel<EmbedProps> = BlockModel<EmbedProps>,
|
||||
Service extends BlockService = BlockService,
|
||||
WidgetName extends string = string,
|
||||
> extends CaptionedBlockComponent<Model, Service, WidgetName> {
|
||||
selectedStyle$: ReadonlySignal<ClassInfo> | null = computed<ClassInfo>(
|
||||
() => ({
|
||||
'selected-style': this.selected$.value,
|
||||
})
|
||||
);
|
||||
|
||||
readonly isDraggingOnHost$ = signal(false);
|
||||
readonly isResizing$ = signal(false);
|
||||
// show overlay to prevent the iframe from capturing pointer events
|
||||
// when the block is dragging, resizing, or not selected
|
||||
readonly showOverlay$ = computed(
|
||||
() =>
|
||||
this.isDraggingOnHost$.value ||
|
||||
this.isResizing$.value ||
|
||||
!this.selected$.value
|
||||
);
|
||||
|
||||
private _fetchAbortController = new AbortController();
|
||||
|
||||
_cardStyle: EmbedCardStyle = 'horizontal';
|
||||
|
||||
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 = {};
|
||||
|
||||
get isCommentHighlighted() {
|
||||
return (
|
||||
this.std
|
||||
.getOptional(BlockElementCommentManager)
|
||||
?.isBlockCommentHighlighted(this.model) ?? false
|
||||
);
|
||||
}
|
||||
|
||||
renderEmbed = (content: () => TemplateResult) => {
|
||||
if (
|
||||
this._cardStyle === 'horizontal' ||
|
||||
this._cardStyle === 'horizontalThin' ||
|
||||
this._cardStyle === 'list'
|
||||
) {
|
||||
this.style.display = 'block';
|
||||
|
||||
const insideNote = findAncestorModel(
|
||||
this.model,
|
||||
m => m.flavour === 'affine:note'
|
||||
);
|
||||
|
||||
if (
|
||||
!insideNote &&
|
||||
this.std.get(DocModeProvider).getEditorMode() === 'edgeless'
|
||||
) {
|
||||
this.style.minWidth = `${EMBED_CARD_MIN_WIDTH}px`;
|
||||
}
|
||||
}
|
||||
|
||||
return html`
|
||||
<div
|
||||
draggable="${this.blockDraggable ? 'true' : 'false'}"
|
||||
class=${classMap({
|
||||
'embed-block-container': true,
|
||||
...this.selectedStyle$?.value,
|
||||
})}
|
||||
style=${styleMap({
|
||||
height: `${this._cardHeight}px`,
|
||||
width: '100%',
|
||||
...(this.isCommentHighlighted
|
||||
? {
|
||||
border: `2px solid ${unsafeCSSVarV2('block/comment/highlightUnderline')}`,
|
||||
}
|
||||
: {}),
|
||||
...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';
|
||||
|
||||
// subscribe the editor host global dragging event
|
||||
// to show the overlay for the dragging area or other pointer events
|
||||
this.handleEvent(
|
||||
'dragStart',
|
||||
() => {
|
||||
this.isDraggingOnHost$.value = true;
|
||||
},
|
||||
{ global: true }
|
||||
);
|
||||
|
||||
this.handleEvent(
|
||||
'dragEnd',
|
||||
() => {
|
||||
this.isDraggingOnHost$.value = false;
|
||||
},
|
||||
{ global: true }
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export const createEmbedEdgelessBlockInteraction = (
|
||||
flavour: string,
|
||||
config?: {
|
||||
resizeConstraint?: ResizeConstraint;
|
||||
}
|
||||
) => {
|
||||
const resizeConstraint = Object.assign(
|
||||
{
|
||||
lockRatio: true,
|
||||
},
|
||||
config?.resizeConstraint ?? {}
|
||||
);
|
||||
const rotateConstraint = {
|
||||
rotatable: false,
|
||||
};
|
||||
|
||||
return GfxViewInteractionExtension(flavour, {
|
||||
resizeConstraint,
|
||||
handleRotate() {
|
||||
return {
|
||||
beforeRotate(context) {
|
||||
context.set(rotateConstraint);
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
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-doc-content-note-blocks inline-comment {
|
||||
background-color: unset !important;
|
||||
border-bottom: unset !important;
|
||||
}
|
||||
|
||||
.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,94 @@
|
||||
import {
|
||||
DefaultTool,
|
||||
EdgelessCRUDIdentifier,
|
||||
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 { Bound, Vec } from '@blocksuite/global/gfx';
|
||||
import {
|
||||
BlockSelection,
|
||||
type BlockStdScope,
|
||||
SurfaceSelection,
|
||||
TextSelection,
|
||||
} from '@blocksuite/std';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
|
||||
|
||||
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(TextSelection);
|
||||
const blockSelection = selectionManager.find(BlockSelection);
|
||||
const surfaceSelection = selectionManager.find(SurfaceSelection);
|
||||
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.store.getParent(block.model);
|
||||
if (!parent) return;
|
||||
const index = parent.children.indexOf(block.model);
|
||||
const cardId = host.store.addBlock(
|
||||
flavour as never,
|
||||
props,
|
||||
parent,
|
||||
index + 1
|
||||
);
|
||||
return cardId;
|
||||
} else {
|
||||
const rootId = std.store.root?.id;
|
||||
if (!rootId) return;
|
||||
const edgelessRoot = std.view.getBlock(rootId);
|
||||
if (!edgelessRoot) return;
|
||||
|
||||
const gfx = std.get(GfxControllerIdentifier);
|
||||
const crud = std.get(EdgelessCRUDIdentifier);
|
||||
|
||||
gfx.viewport.smoothZoom(1);
|
||||
const surfaceBlock = gfx.surfaceComponent;
|
||||
if (!(surfaceBlock instanceof SurfaceBlockComponent)) return;
|
||||
const center = Vec.toVec(surfaceBlock.renderer.viewport.center);
|
||||
const cardId = crud.addBlock(
|
||||
flavour,
|
||||
{
|
||||
...props,
|
||||
xywh: Bound.fromCenter(
|
||||
center,
|
||||
EMBED_CARD_WIDTH[targetStyle],
|
||||
EMBED_CARD_HEIGHT[targetStyle]
|
||||
).serialize(),
|
||||
style: targetStyle,
|
||||
},
|
||||
surfaceBlock.model
|
||||
);
|
||||
|
||||
gfx.tool.setTool(DefaultTool);
|
||||
gfx.selection.set({
|
||||
elements: [cardId],
|
||||
editing: false,
|
||||
});
|
||||
|
||||
return cardId;
|
||||
}
|
||||
}
|
||||
205
blocksuite/affine/blocks/embed/src/common/render-linked-doc.ts
Normal file
205
blocksuite/affine/blocks/embed/src/common/render-linked-doc.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { getSurfaceBlock } from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
type DocMode,
|
||||
ListBlockModel,
|
||||
NoteBlockModel,
|
||||
NoteDisplayMode,
|
||||
ParagraphBlockModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { NotificationProvider } from '@blocksuite/affine-shared/services';
|
||||
import { matchModels } from '@blocksuite/affine-shared/utils';
|
||||
import type { BlockStdScope } from '@blocksuite/std';
|
||||
import {
|
||||
type BlockModel,
|
||||
type BlockSnapshot,
|
||||
type DraftModel,
|
||||
Slice,
|
||||
type Store,
|
||||
Text,
|
||||
} from '@blocksuite/store';
|
||||
|
||||
// Throttle delay for block updates to reduce unnecessary re-renders
|
||||
// - Prevents rapid-fire updates when multiple blocks are updated in quick succession
|
||||
// - Ensures UI remains responsive while maintaining performance
|
||||
// - Small enough to feel instant to users, large enough to batch updates effectively
|
||||
export const RENDER_CARD_THROTTLE_MS = 60;
|
||||
|
||||
function filterTextModel(model: BlockModel) {
|
||||
if (matchModels(model, [ParagraphBlockModel, ListBlockModel])) {
|
||||
return !!model.text?.toString().length;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getNotesFromDoc(doc: Store) {
|
||||
const notes = doc.root?.children.filter(
|
||||
child =>
|
||||
matchModels(child, [NoteBlockModel]) &&
|
||||
child.props.displayMode !== NoteDisplayMode.EdgelessOnly
|
||||
);
|
||||
|
||||
if (!notes || !notes.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return notes;
|
||||
}
|
||||
|
||||
export function isEmptyDoc(doc: Store | 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)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the document content with a max length.
|
||||
*/
|
||||
export function getDocContentWithMaxLength(doc: Store, 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');
|
||||
}
|
||||
|
||||
export function getTitleFromSelectedModels(selectedModels: DraftModel[]) {
|
||||
const firstBlock = selectedModels[0];
|
||||
const isParagraph = (
|
||||
model: DraftModel
|
||||
): model is DraftModel<ParagraphBlockModel> =>
|
||||
model.flavour === 'affine:paragraph';
|
||||
if (isParagraph(firstBlock) && firstBlock.props.type.startsWith('h')) {
|
||||
return firstBlock.props.text.toString();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function promptDocTitle(std: BlockStdScope, autofill?: string) {
|
||||
const notification = std.getOptional(NotificationProvider);
|
||||
if (!notification) return Promise.resolve(undefined);
|
||||
|
||||
return notification.prompt({
|
||||
title: 'Create linked doc',
|
||||
message: 'Enter a title for the new doc.',
|
||||
placeholder: 'Untitled',
|
||||
autofill,
|
||||
confirmText: 'Confirm',
|
||||
cancelText: 'Cancel',
|
||||
});
|
||||
}
|
||||
|
||||
export function notifyDocCreated(std: BlockStdScope) {
|
||||
std.getOptional(NotificationProvider)?.notifyWithUndoAction({
|
||||
title: 'Linked doc created',
|
||||
message: 'You can click undo to recovery block content',
|
||||
accent: 'info',
|
||||
duration: 10 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function convertSelectedBlocksToLinkedDoc(
|
||||
std: BlockStdScope,
|
||||
doc: Store,
|
||||
selectedModels: DraftModel[] | Promise<DraftModel[]>,
|
||||
docTitle?: string
|
||||
) {
|
||||
const models = await selectedModels;
|
||||
const slice = std.clipboard.sliceToSnapshot(Slice.fromModels(doc, models));
|
||||
if (!slice) {
|
||||
return;
|
||||
}
|
||||
const firstBlock = models[0];
|
||||
if (!firstBlock) {
|
||||
return;
|
||||
}
|
||||
// if title undefined, use the first heading block content as doc title
|
||||
const title = docTitle || getTitleFromSelectedModels(models);
|
||||
const linkedDoc = createLinkedDocFromSlice(std, doc, slice.content, title);
|
||||
// insert linked doc card
|
||||
doc.addSiblingBlocks(
|
||||
doc.getBlock(firstBlock.id)!.model,
|
||||
[
|
||||
{
|
||||
flavour: 'affine:embed-linked-doc',
|
||||
pageId: linkedDoc.id,
|
||||
},
|
||||
],
|
||||
'before'
|
||||
);
|
||||
// delete selected elements
|
||||
models.forEach(model => doc.deleteBlock(model.id));
|
||||
return linkedDoc;
|
||||
}
|
||||
|
||||
export function createLinkedDocFromSlice(
|
||||
std: BlockStdScope,
|
||||
doc: Store,
|
||||
snapshots: BlockSnapshot[],
|
||||
docTitle?: string
|
||||
) {
|
||||
const _doc = doc.workspace.createDoc();
|
||||
const linkedDoc = _doc.getStore();
|
||||
linkedDoc.load(() => {
|
||||
const rootId = linkedDoc.addBlock('affine:page', {
|
||||
title: new Text(docTitle),
|
||||
});
|
||||
linkedDoc.addBlock('affine:surface', {}, rootId);
|
||||
const noteId = linkedDoc.addBlock('affine:note', {}, rootId);
|
||||
snapshots.forEach(snapshot => {
|
||||
std.clipboard
|
||||
.pasteBlockSnapshot(snapshot, linkedDoc, noteId)
|
||||
.catch(console.error);
|
||||
});
|
||||
});
|
||||
return linkedDoc;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { EdgelessLegacySlotIdentifier } from '@blocksuite/affine-block-surface';
|
||||
import type { EmbedProps } from '@blocksuite/affine-model';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
import {
|
||||
blockComponentSymbol,
|
||||
type BlockService,
|
||||
type GfxBlockComponent,
|
||||
GfxElementSymbol,
|
||||
toGfxBlockComponent,
|
||||
} from '@blocksuite/std';
|
||||
import type { GfxBlockElementModel } from '@blocksuite/std/gfx';
|
||||
import type { StyleInfo } from 'lit/directives/style-map.js';
|
||||
|
||||
import type { EmbedBlockComponent } from './embed-block-element.js';
|
||||
|
||||
export function toEdgelessEmbedBlock<
|
||||
Model extends GfxBlockElementModel<EmbedProps>,
|
||||
Service extends BlockService,
|
||||
WidgetName extends string,
|
||||
B extends typeof EmbedBlockComponent<Model, Service, WidgetName>,
|
||||
>(block: B) {
|
||||
return class extends toGfxBlockComponent(block) {
|
||||
override selectedStyle$ = null;
|
||||
|
||||
override [blockComponentSymbol] = true;
|
||||
|
||||
override blockDraggable = false;
|
||||
|
||||
protected override embedContainerStyle: StyleInfo = {};
|
||||
|
||||
override [GfxElementSymbol] = true;
|
||||
|
||||
get bound(): Bound {
|
||||
return Bound.deserialize(this.model.xywh);
|
||||
}
|
||||
|
||||
_handleClick(_: MouseEvent): void {
|
||||
return;
|
||||
}
|
||||
|
||||
get edgelessSlots() {
|
||||
return this.std.get(EdgelessLegacySlotIdentifier);
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
this._disposables.add(
|
||||
this.edgelessSlots.elementResizeStart.subscribe(() => {
|
||||
this.isResizing$.value = true;
|
||||
})
|
||||
);
|
||||
|
||||
this._disposables.add(
|
||||
this.edgelessSlots.elementResizeEnd.subscribe(() => {
|
||||
this.isResizing$.value = false;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
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`,
|
||||
};
|
||||
|
||||
return this.renderPageContent();
|
||||
}
|
||||
|
||||
protected override accessor blockContainerStyles: StyleInfo | undefined =
|
||||
undefined;
|
||||
} as B & {
|
||||
new (...args: any[]): GfxBlockComponent;
|
||||
};
|
||||
}
|
||||
49
blocksuite/affine/blocks/embed/src/common/utils.ts
Normal file
49
blocksuite/affine/blocks/embed/src/common/utils.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
EmbedCardDarkBannerIcon,
|
||||
EmbedCardDarkCubeIcon,
|
||||
EmbedCardDarkHorizontalIcon,
|
||||
EmbedCardDarkListIcon,
|
||||
EmbedCardDarkVerticalIcon,
|
||||
EmbedCardLightBannerIcon,
|
||||
EmbedCardLightCubeIcon,
|
||||
EmbedCardLightHorizontalIcon,
|
||||
EmbedCardLightListIcon,
|
||||
EmbedCardLightVerticalIcon,
|
||||
} from '@blocksuite/affine-components/icons';
|
||||
import { ColorScheme } from '@blocksuite/affine-model';
|
||||
import { EmbedOptionProvider } from '@blocksuite/affine-shared/services';
|
||||
import type { BlockStdScope } from '@blocksuite/std';
|
||||
import type { TemplateResult } from 'lit';
|
||||
|
||||
type EmbedCardIcons = {
|
||||
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 {
|
||||
EmbedCardBannerIcon: EmbedCardLightBannerIcon,
|
||||
EmbedCardHorizontalIcon: EmbedCardLightHorizontalIcon,
|
||||
EmbedCardListIcon: EmbedCardLightListIcon,
|
||||
EmbedCardVerticalIcon: EmbedCardLightVerticalIcon,
|
||||
EmbedCardCubeIcon: EmbedCardLightCubeIcon,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
EmbedCardBannerIcon: EmbedCardDarkBannerIcon,
|
||||
EmbedCardHorizontalIcon: EmbedCardDarkHorizontalIcon,
|
||||
EmbedCardListIcon: EmbedCardDarkListIcon,
|
||||
EmbedCardVerticalIcon: EmbedCardDarkVerticalIcon,
|
||||
EmbedCardCubeIcon: EmbedCardDarkCubeIcon,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function canEmbedAsEmbedBlock(std: BlockStdScope, url: string) {
|
||||
const options = std.get(EmbedOptionProvider).getEmbedBlockOptions(url);
|
||||
return options?.viewType === 'embed';
|
||||
}
|
||||
681
blocksuite/affine/blocks/embed/src/configs/toolbar.ts
Normal file
681
blocksuite/affine/blocks/embed/src/configs/toolbar.ts
Normal file
@@ -0,0 +1,681 @@
|
||||
import { reassociateConnectorsCommand } from '@blocksuite/affine-block-surface';
|
||||
import { toast } from '@blocksuite/affine-components/toast';
|
||||
import {
|
||||
BookmarkStyles,
|
||||
type EmbedCardStyle,
|
||||
EmbedGithubModel,
|
||||
EmbedGithubStyles,
|
||||
isExternalEmbedModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
EMBED_CARD_HEIGHT,
|
||||
EMBED_CARD_WIDTH,
|
||||
} from '@blocksuite/affine-shared/consts';
|
||||
import {
|
||||
ActionPlacement,
|
||||
EmbedOptionProvider,
|
||||
type LinkEventType,
|
||||
type ToolbarAction,
|
||||
type ToolbarActionGroup,
|
||||
type ToolbarContext,
|
||||
type ToolbarModuleConfig,
|
||||
ToolbarModuleExtension,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { getBlockProps } from '@blocksuite/affine-shared/utils';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
import {
|
||||
CaptionIcon,
|
||||
CopyIcon,
|
||||
DeleteIcon,
|
||||
DuplicateIcon,
|
||||
ResetIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import { BlockFlavourIdentifier, BlockSelection } from '@blocksuite/std';
|
||||
import { type ExtensionType, Slice, Text } from '@blocksuite/store';
|
||||
import { computed, signal } from '@preact/signals-core';
|
||||
import { html } from 'lit';
|
||||
import { keyed } from 'lit/directives/keyed.js';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import type { EmbedFigmaBlockComponent } from '../embed-figma-block';
|
||||
import type { EmbedGithubBlockComponent } from '../embed-github-block';
|
||||
import type { EmbedLoomBlockComponent } from '../embed-loom-block';
|
||||
import type { EmbedYoutubeBlockComponent } from '../embed-youtube-block';
|
||||
|
||||
const trackBaseProps = {
|
||||
category: 'link',
|
||||
type: 'card view',
|
||||
};
|
||||
|
||||
const previewAction = {
|
||||
id: 'a.preview',
|
||||
content(ctx) {
|
||||
const model = ctx.getCurrentModel();
|
||||
if (!model || !isExternalEmbedModel(model)) return null;
|
||||
|
||||
const { url } = model.props;
|
||||
const options = ctx.std.get(EmbedOptionProvider).getEmbedBlockOptions(url);
|
||||
|
||||
if (options?.viewType !== 'card') return null;
|
||||
|
||||
return html`<affine-link-preview .url=${url}></affine-link-preview>`;
|
||||
},
|
||||
} satisfies ToolbarAction;
|
||||
|
||||
const createOnToggleFn =
|
||||
(
|
||||
ctx: ToolbarContext,
|
||||
name: Extract<
|
||||
LinkEventType,
|
||||
| 'OpenedViewSelector'
|
||||
| 'OpenedCardStyleSelector'
|
||||
| 'OpenedCardScaleSelector'
|
||||
>,
|
||||
control: 'switch view' | 'switch card style' | 'switch card scale'
|
||||
) =>
|
||||
(e: CustomEvent<boolean>) => {
|
||||
e.stopPropagation();
|
||||
const opened = e.detail;
|
||||
if (!opened) return;
|
||||
|
||||
ctx.track(name, { ...trackBaseProps, control });
|
||||
};
|
||||
|
||||
// External embed blocks
|
||||
function createBuiltinToolbarConfigForExternal(
|
||||
klass:
|
||||
| typeof EmbedGithubBlockComponent
|
||||
| typeof EmbedFigmaBlockComponent
|
||||
| typeof EmbedLoomBlockComponent
|
||||
| typeof EmbedYoutubeBlockComponent
|
||||
) {
|
||||
return {
|
||||
actions: [
|
||||
previewAction,
|
||||
{
|
||||
id: 'b.conversions',
|
||||
actions: [
|
||||
{
|
||||
id: 'inline',
|
||||
label: 'Inline view',
|
||||
run(ctx) {
|
||||
const model = ctx.getCurrentModel();
|
||||
if (!model || !isExternalEmbedModel(model)) return;
|
||||
|
||||
const { title, caption, url: link } = model.props;
|
||||
const { parent } = model;
|
||||
const index = parent?.children.indexOf(model);
|
||||
|
||||
const yText = new Y.Text();
|
||||
const insert = title || caption || link;
|
||||
yText.insert(0, insert);
|
||||
yText.format(0, insert.length, { link });
|
||||
|
||||
const text = new Text(yText);
|
||||
|
||||
ctx.store.addBlock('affine:paragraph', { text }, parent, index);
|
||||
|
||||
ctx.store.deleteBlock(model);
|
||||
|
||||
// Clears
|
||||
ctx.select('note');
|
||||
ctx.reset();
|
||||
|
||||
ctx.track('SelectedView', {
|
||||
...trackBaseProps,
|
||||
control: 'select view',
|
||||
type: 'inline view',
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'card',
|
||||
label: 'Card view',
|
||||
disabled(ctx) {
|
||||
const model = ctx.getCurrentModel();
|
||||
if (!model || !isExternalEmbedModel(model)) return true;
|
||||
|
||||
const { url } = model.props;
|
||||
const options = ctx.std
|
||||
.get(EmbedOptionProvider)
|
||||
.getEmbedBlockOptions(url);
|
||||
|
||||
return options?.viewType === 'card';
|
||||
},
|
||||
run(ctx) {
|
||||
const model = ctx.getCurrentModel();
|
||||
if (!model || !isExternalEmbedModel(model)) return;
|
||||
|
||||
const { url, caption } = model.props;
|
||||
const { parent } = model;
|
||||
const index = parent?.children.indexOf(model);
|
||||
const options = ctx.std
|
||||
.get(EmbedOptionProvider)
|
||||
.getEmbedBlockOptions(url);
|
||||
|
||||
let style: EmbedCardStyle = model.props.style;
|
||||
let flavour = 'affine:bookmark';
|
||||
|
||||
if (options?.viewType === 'card') {
|
||||
flavour = options.flavour;
|
||||
if (!options.styles.includes(style)) {
|
||||
style = options.styles[0];
|
||||
}
|
||||
} else {
|
||||
style =
|
||||
BookmarkStyles.find(s => s !== 'vertical' && s !== 'cube') ??
|
||||
BookmarkStyles[1];
|
||||
}
|
||||
|
||||
const blockId = ctx.store.addBlock(
|
||||
flavour,
|
||||
{ url, caption, style },
|
||||
parent,
|
||||
index
|
||||
);
|
||||
|
||||
ctx.store.deleteBlock(model);
|
||||
|
||||
// Selects new block
|
||||
ctx.select('note', [
|
||||
ctx.selection.create(BlockSelection, { blockId }),
|
||||
]);
|
||||
|
||||
ctx.track('SelectedView', {
|
||||
...trackBaseProps,
|
||||
control: 'select view',
|
||||
type: 'card view',
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'embed',
|
||||
label: 'Embed view',
|
||||
disabled(ctx) {
|
||||
const model = ctx.getCurrentModel();
|
||||
if (!model || !isExternalEmbedModel(model)) return false;
|
||||
|
||||
const { url } = model.props;
|
||||
const options = ctx.std
|
||||
.get(EmbedOptionProvider)
|
||||
.getEmbedBlockOptions(url);
|
||||
|
||||
return options?.viewType === 'embed';
|
||||
},
|
||||
when(ctx) {
|
||||
const model = ctx.getCurrentModel();
|
||||
if (!model || !isExternalEmbedModel(model)) return false;
|
||||
|
||||
const { url } = model.props;
|
||||
const options = ctx.std
|
||||
.get(EmbedOptionProvider)
|
||||
.getEmbedBlockOptions(url);
|
||||
|
||||
return options?.viewType === 'embed';
|
||||
},
|
||||
run(ctx) {
|
||||
const model = ctx.getCurrentModel();
|
||||
if (!model || !isExternalEmbedModel(model)) return;
|
||||
|
||||
const { url, caption } = model.props;
|
||||
const { parent } = model;
|
||||
const index = parent?.children.indexOf(model);
|
||||
const options = ctx.std
|
||||
.get(EmbedOptionProvider)
|
||||
.getEmbedBlockOptions(url);
|
||||
|
||||
if (options?.viewType !== 'embed') return;
|
||||
|
||||
const { flavour, styles } = options;
|
||||
let style: EmbedCardStyle = model.props.style;
|
||||
|
||||
if (!styles.includes(style)) {
|
||||
style =
|
||||
styles.find(s => s !== 'vertical' && s !== 'cube') ??
|
||||
styles[0];
|
||||
}
|
||||
|
||||
const blockId = ctx.store.addBlock(
|
||||
flavour,
|
||||
{ url, caption, style },
|
||||
parent,
|
||||
index
|
||||
);
|
||||
|
||||
ctx.store.deleteBlock(model);
|
||||
|
||||
// Selects new block
|
||||
ctx.select('note', [
|
||||
ctx.selection.create(BlockSelection, { blockId }),
|
||||
]);
|
||||
|
||||
ctx.track('SelectedView', {
|
||||
...trackBaseProps,
|
||||
control: 'select view',
|
||||
type: 'embed view',
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
content(ctx) {
|
||||
const model = ctx.getCurrentModel();
|
||||
if (!model || !isExternalEmbedModel(model)) return null;
|
||||
|
||||
const { url } = model.props;
|
||||
const viewType =
|
||||
ctx.std.get(EmbedOptionProvider).getEmbedBlockOptions(url)
|
||||
?.viewType ?? 'card';
|
||||
const actions = this.actions.map(action => ({ ...action }));
|
||||
const viewType$ = signal(
|
||||
`${viewType === 'card' ? 'Card' : 'Embed'} view`
|
||||
);
|
||||
const onToggle = createOnToggleFn(
|
||||
ctx,
|
||||
'OpenedViewSelector',
|
||||
'switch view'
|
||||
);
|
||||
|
||||
return html`${keyed(
|
||||
model,
|
||||
html`<affine-view-dropdown-menu
|
||||
@toggle=${onToggle}
|
||||
.actions=${actions}
|
||||
.context=${ctx}
|
||||
.viewTypeSignal=${viewType$}
|
||||
></affine-view-dropdown-menu>`
|
||||
)}`;
|
||||
},
|
||||
} satisfies ToolbarActionGroup<ToolbarAction>,
|
||||
{
|
||||
id: 'c.style',
|
||||
actions: [
|
||||
{
|
||||
id: 'horizontal',
|
||||
label: 'Large horizontal style',
|
||||
},
|
||||
{
|
||||
id: 'list',
|
||||
label: 'Small horizontal style',
|
||||
},
|
||||
],
|
||||
when(ctx) {
|
||||
return Boolean(ctx.getCurrentModelByType(EmbedGithubModel));
|
||||
},
|
||||
content(ctx) {
|
||||
const model = ctx.getCurrentModelByType(EmbedGithubModel);
|
||||
if (!model) return null;
|
||||
|
||||
const actions = this.actions.map(action => ({
|
||||
...action,
|
||||
run: ({ store }) => {
|
||||
store.updateBlock(model, { style: action.id });
|
||||
|
||||
ctx.track('SelectedCardStyle', {
|
||||
...trackBaseProps,
|
||||
control: 'select card style',
|
||||
type: action.id,
|
||||
});
|
||||
},
|
||||
})) satisfies ToolbarAction[];
|
||||
const onToggle = createOnToggleFn(
|
||||
ctx,
|
||||
'OpenedCardStyleSelector',
|
||||
'switch card style'
|
||||
);
|
||||
|
||||
return html`${keyed(
|
||||
model,
|
||||
html`<affine-card-style-dropdown-menu
|
||||
@toggle=${onToggle}
|
||||
.actions=${actions}
|
||||
.context=${ctx}
|
||||
.styleSignal=${model.props.style$}
|
||||
></affine-card-style-dropdown-menu>`
|
||||
)}`;
|
||||
},
|
||||
} satisfies ToolbarActionGroup<ToolbarAction>,
|
||||
{
|
||||
id: 'd.caption',
|
||||
tooltip: 'Caption',
|
||||
icon: CaptionIcon(),
|
||||
run(ctx) {
|
||||
const block = ctx.getCurrentBlockByType(klass);
|
||||
block?.captionEditor?.show();
|
||||
|
||||
ctx.track('OpenedCaptionEditor', {
|
||||
...trackBaseProps,
|
||||
control: 'add caption',
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
placement: ActionPlacement.More,
|
||||
id: 'a.clipboard',
|
||||
actions: [
|
||||
{
|
||||
id: 'copy',
|
||||
label: 'Copy',
|
||||
icon: CopyIcon(),
|
||||
run(ctx) {
|
||||
const model = ctx.getCurrentBlockByType(klass)?.model;
|
||||
if (!model || !isExternalEmbedModel(model)) return;
|
||||
|
||||
const slice = Slice.fromModels(ctx.store, [model]);
|
||||
ctx.clipboard
|
||||
.copySlice(slice)
|
||||
.then(() => toast(ctx.host, 'Copied to clipboard'))
|
||||
.catch(console.error);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'duplicate',
|
||||
label: 'Duplicate',
|
||||
icon: DuplicateIcon(),
|
||||
run(ctx) {
|
||||
const model = ctx.getCurrentBlockByType(klass)?.model;
|
||||
if (!model || !isExternalEmbedModel(model)) return;
|
||||
|
||||
const { flavour, parent } = model;
|
||||
const props = getBlockProps(model);
|
||||
const index = parent?.children.indexOf(model);
|
||||
|
||||
ctx.store.addBlock(flavour, props, parent, index);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
placement: ActionPlacement.More,
|
||||
id: 'b.reload',
|
||||
label: 'Reload',
|
||||
icon: ResetIcon(),
|
||||
run(ctx) {
|
||||
const block = ctx.getCurrentBlockByType(klass);
|
||||
block?.refreshData();
|
||||
},
|
||||
},
|
||||
{
|
||||
placement: ActionPlacement.More,
|
||||
id: 'c.delete',
|
||||
label: 'Delete',
|
||||
icon: DeleteIcon(),
|
||||
variant: 'destructive',
|
||||
run(ctx) {
|
||||
const model = ctx.getCurrentBlockByType(klass)?.model;
|
||||
if (!model || !isExternalEmbedModel(model)) return;
|
||||
|
||||
ctx.store.deleteBlock(model.id);
|
||||
|
||||
// Clears
|
||||
ctx.select('note');
|
||||
ctx.reset();
|
||||
},
|
||||
},
|
||||
],
|
||||
} as const satisfies ToolbarModuleConfig;
|
||||
}
|
||||
|
||||
const createBuiltinSurfaceToolbarConfigForExternal = (
|
||||
klass:
|
||||
| typeof EmbedGithubBlockComponent
|
||||
| typeof EmbedFigmaBlockComponent
|
||||
| typeof EmbedLoomBlockComponent
|
||||
| typeof EmbedYoutubeBlockComponent
|
||||
) => {
|
||||
return {
|
||||
actions: [
|
||||
previewAction,
|
||||
{
|
||||
id: 'b.conversions',
|
||||
actions: [
|
||||
{
|
||||
id: 'card',
|
||||
label: 'Card view',
|
||||
run(ctx) {
|
||||
const model = ctx.getCurrentBlockByType(klass)?.model;
|
||||
if (!model || !isExternalEmbedModel(model)) return;
|
||||
|
||||
const { id: oldId, xywh, parent } = model;
|
||||
const { url, caption } = model.props;
|
||||
|
||||
let { style } = model.props;
|
||||
let flavour = 'affine:bookmark';
|
||||
|
||||
if (
|
||||
!BookmarkStyles.includes(
|
||||
style as (typeof BookmarkStyles)[number]
|
||||
)
|
||||
) {
|
||||
style = BookmarkStyles[0];
|
||||
}
|
||||
|
||||
const bounds = Bound.deserialize(xywh);
|
||||
bounds.w = EMBED_CARD_WIDTH[style];
|
||||
bounds.h = EMBED_CARD_HEIGHT[style];
|
||||
|
||||
const newId = ctx.store.addBlock(
|
||||
flavour,
|
||||
{ url, caption, style, xywh: bounds.serialize() },
|
||||
parent
|
||||
);
|
||||
|
||||
ctx.command.exec(reassociateConnectorsCommand, { oldId, newId });
|
||||
|
||||
ctx.store.deleteBlock(model);
|
||||
|
||||
// Selects new block
|
||||
ctx.gfx.selection.set({ editing: false, elements: [newId] });
|
||||
|
||||
ctx.track('SelectedView', {
|
||||
...trackBaseProps,
|
||||
control: 'select view',
|
||||
type: 'card view',
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'embed',
|
||||
label: 'Embed view',
|
||||
disabled: true,
|
||||
},
|
||||
],
|
||||
when(ctx) {
|
||||
const model = ctx.getCurrentBlockByType(klass)?.model;
|
||||
if (!model || !isExternalEmbedModel(model)) return false;
|
||||
|
||||
const { url } = model.props;
|
||||
const options = ctx.std
|
||||
.get(EmbedOptionProvider)
|
||||
.getEmbedBlockOptions(url);
|
||||
|
||||
return options?.viewType === 'embed';
|
||||
},
|
||||
content(ctx) {
|
||||
const model = ctx.getCurrentBlockByType(klass)?.model;
|
||||
if (!model || !isExternalEmbedModel(model)) return null;
|
||||
|
||||
const { url } = model.props;
|
||||
const viewType =
|
||||
ctx.std.get(EmbedOptionProvider).getEmbedBlockOptions(url)
|
||||
?.viewType ?? 'card';
|
||||
const actions = this.actions.map(action => ({ ...action }));
|
||||
const viewType$ = signal(
|
||||
`${viewType === 'card' ? 'Card' : 'Embed'} view`
|
||||
);
|
||||
const onToggle = createOnToggleFn(
|
||||
ctx,
|
||||
'OpenedViewSelector',
|
||||
'switch view'
|
||||
);
|
||||
|
||||
return html`${keyed(
|
||||
model,
|
||||
html`<affine-view-dropdown-menu
|
||||
@toggle=${onToggle}
|
||||
.actions=${actions}
|
||||
.context=${ctx}
|
||||
.viewTypeSignal=${viewType$}
|
||||
></affine-view-dropdown-menu>`
|
||||
)}`;
|
||||
},
|
||||
} satisfies ToolbarActionGroup<ToolbarAction>,
|
||||
{
|
||||
id: 'c.style',
|
||||
actions: (
|
||||
[
|
||||
{
|
||||
id: 'horizontal',
|
||||
label: 'Large horizontal style',
|
||||
},
|
||||
{
|
||||
id: 'list',
|
||||
label: 'Small horizontal style',
|
||||
},
|
||||
{
|
||||
id: 'vertical',
|
||||
label: 'Large vertical style',
|
||||
},
|
||||
{
|
||||
id: 'cube',
|
||||
label: 'Small vertical style',
|
||||
},
|
||||
] as const
|
||||
).filter(action => EmbedGithubStyles.includes(action.id)),
|
||||
when(ctx) {
|
||||
return Boolean(ctx.getCurrentModelByType(EmbedGithubModel));
|
||||
},
|
||||
content(ctx) {
|
||||
const model = ctx.getCurrentBlockByType(klass)?.model;
|
||||
if (!model) return null;
|
||||
|
||||
const actions = this.actions.map(action => ({
|
||||
...action,
|
||||
run: ({ store }) => {
|
||||
const style = action.id as EmbedCardStyle;
|
||||
const bounds = Bound.deserialize(model.xywh);
|
||||
bounds.w = EMBED_CARD_WIDTH[style];
|
||||
bounds.h = EMBED_CARD_HEIGHT[style];
|
||||
const xywh = bounds.serialize();
|
||||
|
||||
store.updateBlock(model, { style, xywh });
|
||||
|
||||
ctx.track('SelectedCardStyle', {
|
||||
...trackBaseProps,
|
||||
control: 'select card style',
|
||||
type: style,
|
||||
});
|
||||
},
|
||||
})) satisfies ToolbarAction[];
|
||||
const style$ = model.props.style$;
|
||||
const onToggle = createOnToggleFn(
|
||||
ctx,
|
||||
'OpenedCardStyleSelector',
|
||||
'switch card style'
|
||||
);
|
||||
|
||||
return html`${keyed(
|
||||
model,
|
||||
html`<affine-card-style-dropdown-menu
|
||||
@toggle=${onToggle}
|
||||
.actions=${actions}
|
||||
.context=${ctx}
|
||||
.styleSignal=${style$}
|
||||
></affine-card-style-dropdown-menu>`
|
||||
)}`;
|
||||
},
|
||||
} satisfies ToolbarActionGroup<ToolbarAction>,
|
||||
{
|
||||
id: 'd.caption',
|
||||
tooltip: 'Caption',
|
||||
icon: CaptionIcon(),
|
||||
run(ctx) {
|
||||
const block = ctx.getCurrentBlockByType(klass);
|
||||
block?.captionEditor?.show();
|
||||
|
||||
ctx.track('OpenedCaptionEditor', {
|
||||
...trackBaseProps,
|
||||
control: 'add caption',
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'e.scale',
|
||||
content(ctx) {
|
||||
const model = ctx.getCurrentBlockByType(klass)?.model;
|
||||
if (!model) return null;
|
||||
|
||||
const scale$ = computed(() => {
|
||||
const {
|
||||
xywh$: { value: xywh },
|
||||
} = model;
|
||||
const {
|
||||
style$: { value: style },
|
||||
} = model.props;
|
||||
const bounds = Bound.deserialize(xywh);
|
||||
const height = EMBED_CARD_HEIGHT[style];
|
||||
return Math.round(100 * (bounds.h / height));
|
||||
});
|
||||
const onSelect = (e: CustomEvent<number>) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const scale = e.detail / 100;
|
||||
|
||||
const bounds = Bound.deserialize(model.xywh);
|
||||
const style = model.props.style;
|
||||
bounds.h = EMBED_CARD_HEIGHT[style] * scale;
|
||||
bounds.w = EMBED_CARD_WIDTH[style] * scale;
|
||||
const xywh = bounds.serialize();
|
||||
|
||||
ctx.store.updateBlock(model, { xywh });
|
||||
|
||||
ctx.track('SelectedCardScale', {
|
||||
...trackBaseProps,
|
||||
control: 'select card scale',
|
||||
});
|
||||
};
|
||||
const onToggle = createOnToggleFn(
|
||||
ctx,
|
||||
'OpenedCardScaleSelector',
|
||||
'switch card scale'
|
||||
);
|
||||
const format = (value: number) => `${value}%`;
|
||||
|
||||
return html`${keyed(
|
||||
model,
|
||||
html`<affine-size-dropdown-menu
|
||||
@select=${onSelect}
|
||||
@toggle=${onToggle}
|
||||
.format=${format}
|
||||
.sizeSignal=${scale$}
|
||||
></affine-size-dropdown-menu>`
|
||||
)}`;
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
when: ctx => ctx.getSurfaceBlocksByType(klass).length === 1,
|
||||
} as const satisfies ToolbarModuleConfig;
|
||||
};
|
||||
|
||||
export const createBuiltinToolbarConfigExtension = (
|
||||
flavour: string,
|
||||
klass:
|
||||
| typeof EmbedGithubBlockComponent
|
||||
| typeof EmbedFigmaBlockComponent
|
||||
| typeof EmbedLoomBlockComponent
|
||||
| typeof EmbedYoutubeBlockComponent
|
||||
): ExtensionType[] => {
|
||||
const name = flavour.split(':').pop();
|
||||
|
||||
return [
|
||||
ToolbarModuleExtension({
|
||||
id: BlockFlavourIdentifier(flavour),
|
||||
config: createBuiltinToolbarConfigForExternal(klass),
|
||||
}),
|
||||
|
||||
ToolbarModuleExtension({
|
||||
id: BlockFlavourIdentifier(`affine:surface:${name}`),
|
||||
config: createBuiltinSurfaceToolbarConfigForExternal(klass),
|
||||
}),
|
||||
];
|
||||
};
|
||||
96
blocksuite/affine/blocks/embed/src/effects.ts
Normal file
96
blocksuite/affine/blocks/embed/src/effects.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { EmbedFigmaBlockComponent } from './embed-figma-block';
|
||||
import { EmbedEdgelessBlockComponent } from './embed-figma-block/embed-edgeless-figma-block';
|
||||
import { EmbedGithubBlockComponent } from './embed-github-block';
|
||||
import { EmbedEdgelessGithubBlockComponent } from './embed-github-block/embed-edgeless-github-block';
|
||||
import { EmbedHtmlBlockComponent } from './embed-html-block';
|
||||
import { EmbedHtmlFullscreenToolbar } from './embed-html-block/components/fullscreen-toolbar';
|
||||
import { EmbedEdgelessHtmlBlockComponent } from './embed-html-block/embed-edgeless-html-block';
|
||||
import { EmbedIframeErrorCard } from './embed-iframe-block/components/embed-iframe-error-card';
|
||||
import { EmbedIframeIdleCard } from './embed-iframe-block/components/embed-iframe-idle-card';
|
||||
import { EmbedIframeLinkEditPopup } from './embed-iframe-block/components/embed-iframe-link-edit-popup';
|
||||
import { EmbedIframeLinkInputPopup } from './embed-iframe-block/components/embed-iframe-link-input-popup';
|
||||
import { EmbedIframeLoadingCard } from './embed-iframe-block/components/embed-iframe-loading-card';
|
||||
import { EmbedEdgelessIframeBlockComponent } from './embed-iframe-block/embed-edgeless-iframe-block';
|
||||
import { EmbedIframeBlockComponent } from './embed-iframe-block/embed-iframe-block';
|
||||
import { EmbedLoomBlockComponent } from './embed-loom-block';
|
||||
import { EmbedEdgelessLoomBlockComponent } from './embed-loom-block/embed-edgeless-loom-bock';
|
||||
import { EmbedYoutubeBlockComponent } from './embed-youtube-block';
|
||||
import { EmbedEdgelessYoutubeBlockComponent } from './embed-youtube-block/embed-edgeless-youtube-block';
|
||||
|
||||
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-edgeless-iframe-block',
|
||||
EmbedEdgelessIframeBlockComponent
|
||||
);
|
||||
customElements.define('affine-embed-iframe-block', EmbedIframeBlockComponent);
|
||||
customElements.define(
|
||||
'embed-iframe-link-input-popup',
|
||||
EmbedIframeLinkInputPopup
|
||||
);
|
||||
customElements.define('embed-iframe-loading-card', EmbedIframeLoadingCard);
|
||||
customElements.define('embed-iframe-error-card', EmbedIframeErrorCard);
|
||||
customElements.define('embed-iframe-idle-card', EmbedIframeIdleCard);
|
||||
customElements.define(
|
||||
'embed-iframe-link-edit-popup',
|
||||
EmbedIframeLinkEditPopup
|
||||
);
|
||||
}
|
||||
|
||||
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-iframe-block': EmbedIframeBlockComponent;
|
||||
'embed-iframe-link-input-popup': EmbedIframeLinkInputPopup;
|
||||
'embed-iframe-loading-card': EmbedIframeLoadingCard;
|
||||
'embed-iframe-error-card': EmbedIframeErrorCard;
|
||||
'embed-iframe-idle-card': EmbedIframeIdleCard;
|
||||
'embed-iframe-link-edit-popup': EmbedIframeLinkEditPopup;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
|
||||
import { EmbedFigmaBlockHtmlAdapterExtension } from './html.js';
|
||||
import { EmbedFigmaMarkdownAdapterExtension } from './markdown.js';
|
||||
import { EmbedFigmaBlockNotionHtmlAdapterExtension } from './notion-html.js';
|
||||
import { EmbedFigmaBlockPlainTextAdapterExtension } from './plain-text.js';
|
||||
|
||||
export const EmbedFigmaBlockAdapterExtensions: ExtensionType[] = [
|
||||
EmbedFigmaBlockHtmlAdapterExtension,
|
||||
EmbedFigmaMarkdownAdapterExtension,
|
||||
EmbedFigmaBlockPlainTextAdapterExtension,
|
||||
EmbedFigmaBlockNotionHtmlAdapterExtension,
|
||||
];
|
||||
@@ -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,4 @@
|
||||
export * from './html.js';
|
||||
export * from './markdown.js';
|
||||
export * from './notion-html.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,14 @@
|
||||
import { EmbedFigmaBlockSchema } from '@blocksuite/affine-model';
|
||||
import { BlockNotionHtmlAdapterExtension } from '@blocksuite/affine-shared/adapters';
|
||||
|
||||
import { createEmbedBlockNotionHtmlAdapterMatcher } from '../../common/adapters/notion-html.js';
|
||||
import { figmaUrlRegex } from '../embed-figma-model.js';
|
||||
|
||||
export const embedFigmaBlockNotionHtmlAdapterMatcher =
|
||||
createEmbedBlockNotionHtmlAdapterMatcher(
|
||||
EmbedFigmaBlockSchema.model.flavour,
|
||||
figmaUrlRegex
|
||||
);
|
||||
|
||||
export const EmbedFigmaBlockNotionHtmlAdapterExtension =
|
||||
BlockNotionHtmlAdapterExtension(embedFigmaBlockNotionHtmlAdapterMatcher);
|
||||
@@ -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,47 @@
|
||||
import { DefaultTool } from '@blocksuite/affine-block-surface';
|
||||
import { toggleEmbedCardCreateModal } from '@blocksuite/affine-components/embed-card-modal';
|
||||
import type { SlashMenuConfig } from '@blocksuite/affine-widget-slash-menu';
|
||||
import { FigmaDuotoneIcon } from '@blocksuite/icons/lit';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
|
||||
|
||||
import { FigmaTooltip } from './tooltips';
|
||||
|
||||
export const embedFigmaSlashMenuConfig: SlashMenuConfig = {
|
||||
items: [
|
||||
{
|
||||
name: 'Figma',
|
||||
description: 'Embed a Figma document.',
|
||||
icon: FigmaDuotoneIcon(),
|
||||
tooltip: {
|
||||
figure: FigmaTooltip,
|
||||
caption: 'Figma',
|
||||
},
|
||||
group: '4_Content & Media@8',
|
||||
when: ({ model }) =>
|
||||
model.store.schema.flavourSchemaMap.has('affine:embed-figma'),
|
||||
action: ({ std, model }) => {
|
||||
(async () => {
|
||||
const { host } = std;
|
||||
const parentModel = host.store.getParent(model);
|
||||
if (!parentModel) {
|
||||
return;
|
||||
}
|
||||
const index = parentModel.children.indexOf(model) + 1;
|
||||
await toggleEmbedCardCreateModal(
|
||||
host,
|
||||
'Figma',
|
||||
'The added Figma link will be displayed as an embed view.',
|
||||
{ mode: 'page', parentModel, index },
|
||||
({ mode }) => {
|
||||
if (mode === 'edgeless') {
|
||||
const gfx = std.get(GfxControllerIdentifier);
|
||||
gfx.tool.setTool(DefaultTool);
|
||||
}
|
||||
}
|
||||
);
|
||||
if (model.text?.length === 0) std.store.deleteBlock(model);
|
||||
})().catch(console.error);
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,25 @@
|
||||
import { EdgelessClipboardConfig } from '@blocksuite/affine-block-surface';
|
||||
import { type BlockSnapshot } from '@blocksuite/store';
|
||||
|
||||
export class EdgelessClipboardEmbedFigmaConfig extends EdgelessClipboardConfig {
|
||||
static override readonly key = 'affine:embed-figma';
|
||||
|
||||
override createBlock(figmaEmbed: BlockSnapshot): string | null {
|
||||
if (!this.surface) return null;
|
||||
const { xywh, style, url, caption, title, description } = figmaEmbed.props;
|
||||
|
||||
const embedFigmaId = this.crud.addBlock(
|
||||
'affine:embed-figma',
|
||||
{
|
||||
xywh,
|
||||
style,
|
||||
url,
|
||||
caption,
|
||||
title,
|
||||
description,
|
||||
},
|
||||
this.surface.model.id
|
||||
);
|
||||
return embedFigmaId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { EmbedFigmaBlockSchema } from '@blocksuite/affine-model';
|
||||
|
||||
import { createEmbedEdgelessBlockInteraction } from '../common/embed-block-element.js';
|
||||
import { toEdgelessEmbedBlock } from '../common/to-edgeless-embed-block.js';
|
||||
import { EmbedFigmaBlockComponent } from './embed-figma-block.js';
|
||||
|
||||
export class EmbedEdgelessBlockComponent extends toEdgelessEmbedBlock(
|
||||
EmbedFigmaBlockComponent
|
||||
) {}
|
||||
|
||||
export const EmbedFigmaBlockInteraction = createEmbedEdgelessBlockInteraction(
|
||||
EmbedFigmaBlockSchema.model.flavour
|
||||
);
|
||||
@@ -0,0 +1,129 @@
|
||||
import { OpenIcon } from '@blocksuite/affine-components/icons';
|
||||
import type {
|
||||
EmbedFigmaModel,
|
||||
EmbedFigmaStyles,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { BlockSelection } from '@blocksuite/std';
|
||||
import { html, nothing } from 'lit';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
|
||||
import { EmbedBlockComponent } from '../common/embed-block-element.js';
|
||||
import { FigmaIcon, styles } from './styles.js';
|
||||
|
||||
export class EmbedFigmaBlockComponent extends EmbedBlockComponent<EmbedFigmaModel> {
|
||||
static override styles = styles;
|
||||
|
||||
override _cardStyle: (typeof EmbedFigmaStyles)[number] = 'figma';
|
||||
|
||||
open = () => {
|
||||
let link = this.model.props.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(BlockSelection, {
|
||||
blockId: this.blockId,
|
||||
});
|
||||
selectionManager.setGroup('note', [blockSelection]);
|
||||
}
|
||||
|
||||
protected _handleClick(event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
this._selectBlock();
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._cardStyle = this.model.props.style;
|
||||
|
||||
if (!this.model.props.title) {
|
||||
this.store.withoutTransact(() => {
|
||||
this.store.updateBlock(this.model, {
|
||||
title: 'Figma',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
this.disposables.add(
|
||||
this.model.propsUpdated.subscribe(({ key }) => {
|
||||
if (key === 'url') {
|
||||
this.refreshData();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override renderBlock() {
|
||||
const { title, description, url } = this.model.props;
|
||||
const titleText = title ?? 'Figma';
|
||||
|
||||
return this.renderEmbed(
|
||||
() => html`
|
||||
<div
|
||||
class=${classMap({
|
||||
'affine-embed-figma-block': true,
|
||||
selected: this.selected$.value,
|
||||
})}
|
||||
@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}`}
|
||||
sandbox="allow-same-origin allow-scripts allow-presentation"
|
||||
allow="fullscreen"
|
||||
loading="lazy"
|
||||
credentialless
|
||||
></iframe>
|
||||
|
||||
<!-- overlay to prevent the iframe from capturing pointer events -->
|
||||
<div
|
||||
class=${classMap({
|
||||
'affine-embed-figma-iframe-overlay': true,
|
||||
hide: !this.showOverlay$.value,
|
||||
})}
|
||||
></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>
|
||||
|
||||
${
|
||||
description
|
||||
? html`<div class="affine-embed-figma-content-description">
|
||||
${description}
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
|
||||
<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>
|
||||
`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const figmaUrlRegex: RegExp =
|
||||
/https:\/\/[\w.-]+\.?figma.com\/([\w-]+)\/([0-9a-zA-Z]{22,128})(?:\/.*)?$/;
|
||||
@@ -0,0 +1,14 @@
|
||||
import {
|
||||
EmbedFigmaBlockSchema,
|
||||
EmbedFigmaStyles,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { EmbedOptionConfig } from '@blocksuite/affine-shared/services';
|
||||
|
||||
import { figmaUrlRegex } from './embed-figma-model.js';
|
||||
|
||||
export const EmbedFigmaBlockOptionConfig = EmbedOptionConfig({
|
||||
flavour: EmbedFigmaBlockSchema.model.flavour,
|
||||
urlRegex: figmaUrlRegex,
|
||||
styles: EmbedFigmaStyles,
|
||||
viewType: 'embed',
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { EmbedFigmaBlockSchema } from '@blocksuite/affine-model';
|
||||
import { SlashMenuConfigExtension } from '@blocksuite/affine-widget-slash-menu';
|
||||
import { BlockViewExtension, FlavourExtension } from '@blocksuite/std';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
import { literal } from 'lit/static-html.js';
|
||||
|
||||
import { createBuiltinToolbarConfigExtension } from '../configs/toolbar';
|
||||
import { embedFigmaSlashMenuConfig } from './configs/slash-menu';
|
||||
import { EmbedFigmaBlockInteraction } from './embed-edgeless-figma-block';
|
||||
import { EmbedFigmaBlockComponent } from './embed-figma-block';
|
||||
import { EmbedFigmaBlockOptionConfig } from './embed-figma-service';
|
||||
|
||||
const flavour = EmbedFigmaBlockSchema.model.flavour;
|
||||
|
||||
export const EmbedFigmaViewExtensions: ExtensionType[] = [
|
||||
FlavourExtension(flavour),
|
||||
BlockViewExtension(flavour, model => {
|
||||
return model.parent?.flavour === 'affine:surface'
|
||||
? literal`affine-embed-edgeless-figma-block`
|
||||
: literal`affine-embed-figma-block`;
|
||||
}),
|
||||
EmbedFigmaBlockOptionConfig,
|
||||
createBuiltinToolbarConfigExtension(flavour, EmbedFigmaBlockComponent),
|
||||
SlashMenuConfigExtension(flavour, embedFigmaSlashMenuConfig),
|
||||
EmbedFigmaBlockInteraction,
|
||||
].flat();
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './adapters/index.js';
|
||||
export * from './edgeless-clipboard-config';
|
||||
export * from './embed-figma-block.js';
|
||||
export * from './embed-figma-model.js';
|
||||
export * from './embed-figma-spec.js';
|
||||
export { FigmaIcon } from './styles.js';
|
||||
218
blocksuite/affine/blocks/embed/src/embed-figma-block/styles.ts
Normal file
218
blocksuite/affine/blocks/embed/src/embed-figma-block/styles.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
import { css, html } from 'lit';
|
||||
|
||||
export const styles = css`
|
||||
.affine-embed-figma-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding: 12px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
border-radius: 8px;
|
||||
border: 1px solid ${unsafeCSSVarV2('layer/background/tertiary')};
|
||||
|
||||
background: ${unsafeCSSVarV2('layer/background/primary')};
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.affine-embed-figma {
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.affine-embed-figma img,
|
||||
.affine-embed-figma object,
|
||||
.affine-embed-figma svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.affine-embed-figma-iframe-container {
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.affine-embed-figma-iframe-container > iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
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;
|
||||
}
|
||||
|
||||
.affine-embed-figma-content-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.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: ${unsafeCSSVarV2('icon/primary')};
|
||||
|
||||
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 svg {
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
fill: ${unsafeCSSVarV2('icon/primary')};
|
||||
}
|
||||
|
||||
.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,13 @@
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
|
||||
import { EmbedGithubBlockHtmlAdapterExtension } from './html.js';
|
||||
import { EmbedGithubMarkdownAdapterExtension } from './markdown.js';
|
||||
import { EmbedGithubBlockNotionHtmlAdapterExtension } from './notion-html.js';
|
||||
import { EmbedGithubBlockPlainTextAdapterExtension } from './plain-text.js';
|
||||
|
||||
export const EmbedGithubBlockAdapterExtensions: ExtensionType[] = [
|
||||
EmbedGithubBlockHtmlAdapterExtension,
|
||||
EmbedGithubMarkdownAdapterExtension,
|
||||
EmbedGithubBlockPlainTextAdapterExtension,
|
||||
EmbedGithubBlockNotionHtmlAdapterExtension,
|
||||
];
|
||||
@@ -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,4 @@
|
||||
export * from './html.js';
|
||||
export * from './markdown.js';
|
||||
export * from './notion-html.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,14 @@
|
||||
import { EmbedGithubBlockSchema } from '@blocksuite/affine-model';
|
||||
import { BlockNotionHtmlAdapterExtension } from '@blocksuite/affine-shared/adapters';
|
||||
|
||||
import { createEmbedBlockNotionHtmlAdapterMatcher } from '../../common/adapters/notion-html.js';
|
||||
import { githubUrlRegex } from '../embed-github-model.js';
|
||||
|
||||
export const embedGithubBlockNotionHtmlAdapterMatcher =
|
||||
createEmbedBlockNotionHtmlAdapterMatcher(
|
||||
EmbedGithubBlockSchema.model.flavour,
|
||||
githubUrlRegex
|
||||
);
|
||||
|
||||
export const EmbedGithubBlockNotionHtmlAdapterExtension =
|
||||
BlockNotionHtmlAdapterExtension(embedGithubBlockNotionHtmlAdapterMatcher);
|
||||
@@ -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,47 @@
|
||||
import { DefaultTool } from '@blocksuite/affine-block-surface';
|
||||
import { toggleEmbedCardCreateModal } from '@blocksuite/affine-components/embed-card-modal';
|
||||
import type { SlashMenuConfig } from '@blocksuite/affine-widget-slash-menu';
|
||||
import { GithubDuotoneIcon } from '@blocksuite/icons/lit';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
|
||||
|
||||
import { GithubRepoTooltip } from './tooltips';
|
||||
|
||||
export const embedGithubSlashMenuConfig: SlashMenuConfig = {
|
||||
items: [
|
||||
{
|
||||
name: 'GitHub',
|
||||
description: 'Link to a GitHub repository.',
|
||||
icon: GithubDuotoneIcon(),
|
||||
tooltip: {
|
||||
figure: GithubRepoTooltip,
|
||||
caption: 'GitHub Repo',
|
||||
},
|
||||
group: '4_Content & Media@7',
|
||||
when: ({ model }) =>
|
||||
model.store.schema.flavourSchemaMap.has('affine:embed-github'),
|
||||
action: ({ std, model }) => {
|
||||
(async () => {
|
||||
const { host } = std;
|
||||
const parentModel = host.store.getParent(model);
|
||||
if (!parentModel) {
|
||||
return;
|
||||
}
|
||||
const index = parentModel.children.indexOf(model) + 1;
|
||||
await toggleEmbedCardCreateModal(
|
||||
host,
|
||||
'GitHub',
|
||||
'The added GitHub issue or pull request link will be displayed as a card view.',
|
||||
{ mode: 'page', parentModel, index },
|
||||
({ mode }) => {
|
||||
if (mode === 'edgeless') {
|
||||
const gfx = std.get(GfxControllerIdentifier);
|
||||
gfx.tool.setTool(DefaultTool);
|
||||
}
|
||||
}
|
||||
);
|
||||
if (model.text?.length === 0) std.store.deleteBlock(model);
|
||||
})().catch(console.error);
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { html } from 'lit';
|
||||
|
||||
// prettier-ignore
|
||||
export const GithubRepoTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<rect width="170" height="68" rx="2" fill="white"/>
|
||||
<mask id="mask0_16460_1028" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
|
||||
<rect width="170" height="68" rx="2" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_16460_1028)">
|
||||
<rect x="6.5" y="28.5" width="169" height="67" rx="3.5" fill="white" stroke="#E3E2E4"/>
|
||||
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="9" letter-spacing="0em"><tspan x="18" y="46.7727">toeverything/</tspan></text>
|
||||
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="9" font-weight="bold" letter-spacing="0em"><tspan x="75.041" y="46.7727">AFFiNE</tspan></text>
|
||||
<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="7" letter-spacing="0em"><tspan x="18" y="57.5455">Write, Draw and Plan All at Once.</tspan></text>
|
||||
<rect x="146" y="38" width="24" height="24" fill="url(#pattern0_16460_1028)"/>
|
||||
<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="10" y="18.6364">Link to a GitHub repository.</tspan></text>
|
||||
</g>
|
||||
<defs>
|
||||
<pattern id="pattern0_16460_1028" patternContentUnits="objectBoundingBox" width="1" height="1">
|
||||
<use xlink:href="#image0_16460_1028" transform="scale(0.0208333)"/>
|
||||
</pattern>
|
||||
<image id="image0_16460_1028" width="48" height="48" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAACXBIWXMAABYlAAAWJQFJUiTwAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAgNSURBVHgB7VldbBzVFf7unZmd/ct61xjHxEkU4jhRbYeQxAlp2tBC+4Cg/1LpHy8VqipVVaU+9K3vfe5zJaSqrdq0Qg1UoQoEKlBjKMQtYBKbn/zbBsf2Ouv17s7uzJ3bc+54nYBc8M8saaQcabOzk5k75zvnO985dyw0GW5hk7jF7TaAm223AazEWqkTLQHADgdBsPTb9/2WgYgfADmq4WNuvkrHMJ9i2YsOWmAtAhDgwmQNIR+HwNiVKh2HaIXFDICcFAJ1ZePVkRloqRAKjZGxWVR9gVZYzAC0Icr5yQr+9UYFQUi50AJvjfl470oZrbBYAXCdhuTw0JsfoLRg48J4BdMzFbw/F+DU6SmEYWCStPhPLGYjRtM6gBdIDJ0uQ8kkhkdLyCUtNCwHw2drKFZCtGcbkDoBLUKIGOIXbwZouaefH0dxJgdt2Xhp+BqGRkoU8BSulhw89fwk6ZNvBEkgQBwWK4DJGYWn/1mkVQVspXHxisK/x6pIEHU0FfOzLxcxMcneK7raQhy2LgCh+TSgUEXD1/jTM1dQKgtytkFOajQ8D1XuAUQXdrdaTeDo8XF4LKlU4EZjORNcPGtsE+sCIMh5QZIZKhfH/vE+hl6vUfBFpP/cDeoe/HqdjgiUtkhWBV45M4+jz0wRCCo/TY8PGVozK586AAVFzv7thXH84fgU6jrSeiGi74+OD4oyEYgcjp2Yxx+fHUMtrJtipoUok2tzZX0UouI8evIDPPH3OTBJpP7wcgxEyuY5DYsAylDAtzz89USVKDeFaoP/h6m0tkYnPn5LyUuraHGOLlNVsHqQxk9U8eRz4zj1nwCBjBwzkddMmNBQolKu0bEHJ9cJh2kvdLQWUQ9hBglae3d/HY8+vA27tibNGpLWjuLAMiuicUoroiYrvlwlAG5MHBwaCRhKSCvPzZPSvDCB46/M0MSZgVb6hsXogbJh6CB0Bk6CHK3V4DkuuVWjxZJYijQXsGTQQEp4+PJn8/jWQ9tQyFAmEQE1TOQPAYCwsFyWlgXAp8xpVpJQYbasce5yCS9Thx15dwHX5vImGoJnHVKTJuclj9EyNJKZoPNffTiLnJPF756aIFBtpuhvlJuQC54pRg5aJAZpp4E9A8Dn9nZSRrK4I2/DlrR+6NAz5NJzbrRlOzFfyPN8qdzAeRoHRi+W8M54A+cuaZS9LJQVwA4jFZEfUg9FD6PIYwFfOuDiO/dvQ9JRmJpdwIlTNUPDxTI3QBiwFSy6QdSs+Qpvvk014s1h4lIDvdvS2Lo5jXwuhGsLiJVm4IZURNngpNK9vvIxRmBOUod98dVrKE1X4Nc8KEWkITo4dgbZDhuPfbMb375/E/2OJDKoA8dOX8bvnyyiOF0y0hpSZoW0kHDS2JCz8MAXuvHFwTb0390GV3J+OeJRNsPo17JlvoJZiMSSFlmoK7x7oYQ3Rucx+lYZtp9A2tXwKLUqYGcEkokEOZ3G6DtzuLqvgI2FDD24hko9gbOvF6FI8x3XNXSIasCC66YhEzTBvldCwaZgEdgdPVlsSArSNSpqipylRNS4l0HwPzPA28BiuYqLVys0DhD/X7uG8kKa+qZNFHKpoCskixxgZ2ldCYqsZqcVejYF+OVPeimDPn71xHmcvUyOk9NEafPh+tJoqhJpHTlIzRwu3duVBQ7fm0P/LgebujbgjpxLVHSJritUoaUiNuuHRiYrVY3hM7N48bVpjLxNsz42mCLki1h9WPRsViHBcw/XhcKePgsdKRcnTzMJA7peGndZTYXp2KHZvVFekKRo9G6X+PzBDtzXl0Fb2jEqxMIquVvLVWZgOWM9JrLgzKUKfvOXc9QLQjMaMxNDETYXRFNpAt8zD7WcFN3LsqiXmpY2A0akQD1dHh772k7s39UGy2JwCtc9/vgGtyoAHNXQTJYW5omrLw1N4c/PTWK6noUTNGBRhK+PBFQ3pSIC6hP59o5FIaA80QzEIDm6jljA976+BQ8eaEMhTYwXjrnvulp+cndeXQZgGBVNj0RkgoNhKr5f/3YU5fmNNBcp0xuiR2vMXZ0ys9KdnXeR80Qi8swKudd6yCTn8bPHB7B3Z4o6shUNdaaoVjfdrOpqTr82VSiMQzx57usp4BeP34PshlnTsaMldTR6YHGsWKJMJIVpt4Kf/mgPBnfl4ZjdGY0P1trm6VUBWFJj0WSnpmhKDNzt4uc/7ENKVnlko3pQ0TU8v1ihOcezEQcg5Xr48Q96cXBHJlJGKRbXkljLbLmuaVQvvrmylMTe7Ql8/xubCdAC9Q3b9A7XzSCVyi5CZSlXeOSBAg7tcY2+x2Hr3A/YphaEpJmFCvvBAwVsvdM3VGb3cnmJVJofoYzD3XkPjxzZSFpPxSrjeTOxvj1xM4rmy6E3EAKPfmUHFSXRhjS+7zNpHN5fiIgWNvDdh7bTWwkijnYRl60PgGguERWFFAkMDuTRszVhSHNodwFH7i2A971buoAj+9tN4Zus6f+DTf1HjXmesBQO7U/SOO1jb28e/T15OlfCfYMFWHZommGcFu+rRVIai/R8cKATWzZpdLU7yGds9HSncHh3pxkfRHNnFdOr0pjfjUaC2N2ewsF7kkZ0uUz29aWwpTOJlYwGq7VYAQij98KoUP/OTjO1cS3s7uuAJW+Ft9Nm1oga0+aNWQOA5fOujiRa4/6qh7lPMLOPFubLD+hFC8k9N7lAKPPKRVrxw4gXwNIGJcJi1HVx84IW5SBmAJ++3f478c222wButv0X5BSXyMVazj4AAAAASUVORK5CYII="/>
|
||||
</defs>
|
||||
</svg>
|
||||
`;
|
||||
@@ -0,0 +1,51 @@
|
||||
import { EdgelessClipboardConfig } from '@blocksuite/affine-block-surface';
|
||||
import { type BlockSnapshot } from '@blocksuite/store';
|
||||
|
||||
export class EdgelessClipboardEmbedGithubConfig extends EdgelessClipboardConfig {
|
||||
static override readonly key = 'affine:embed-github';
|
||||
|
||||
override createBlock(githubEmbed: BlockSnapshot): string | null {
|
||||
if (!this.surface) return null;
|
||||
|
||||
const {
|
||||
xywh,
|
||||
style,
|
||||
owner,
|
||||
repo,
|
||||
githubType,
|
||||
githubId,
|
||||
url,
|
||||
caption,
|
||||
image,
|
||||
status,
|
||||
statusReason,
|
||||
title,
|
||||
description,
|
||||
createdAt,
|
||||
assignees,
|
||||
} = githubEmbed.props;
|
||||
|
||||
const embedGithubId = this.crud.addBlock(
|
||||
'affine:embed-github',
|
||||
{
|
||||
xywh,
|
||||
style,
|
||||
owner,
|
||||
repo,
|
||||
githubType,
|
||||
githubId,
|
||||
url,
|
||||
caption,
|
||||
image,
|
||||
status,
|
||||
statusReason,
|
||||
title,
|
||||
description,
|
||||
createdAt,
|
||||
assignees,
|
||||
},
|
||||
this.surface.model.id
|
||||
);
|
||||
return embedGithubId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { EmbedGithubBlockSchema } from '@blocksuite/affine-model';
|
||||
|
||||
import { createEmbedEdgelessBlockInteraction } from '../common/embed-block-element.js';
|
||||
import { toEdgelessEmbedBlock } from '../common/to-edgeless-embed-block.js';
|
||||
import { EmbedGithubBlockComponent } from './embed-github-block.js';
|
||||
|
||||
export class EmbedEdgelessGithubBlockComponent extends toEdgelessEmbedBlock(
|
||||
EmbedGithubBlockComponent
|
||||
) {}
|
||||
|
||||
export const EmbedGithubBlockInteraction = createEmbedEdgelessBlockInteraction(
|
||||
EmbedGithubBlockSchema.model.flavour
|
||||
);
|
||||
@@ -0,0 +1,279 @@
|
||||
import { LoadingIcon, OpenIcon } from '@blocksuite/affine-components/icons';
|
||||
import type {
|
||||
EmbedGithubModel,
|
||||
EmbedGithubStyles,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { ImageProxyService } from '@blocksuite/affine-shared/adapters';
|
||||
import { ThemeProvider } from '@blocksuite/affine-shared/services';
|
||||
import { BlockSelection, isGfxBlockComponent } from '@blocksuite/std';
|
||||
import { html, nothing } from 'lit';
|
||||
import { property } 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.props.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(BlockSelection, {
|
||||
blockId: this.blockId,
|
||||
});
|
||||
selectionManager.setGroup('note', [blockSelection]);
|
||||
}
|
||||
|
||||
protected _handleClick(event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
this._selectBlock();
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._cardStyle = this.model.props.style;
|
||||
|
||||
if (
|
||||
!this.model.props.owner ||
|
||||
!this.model.props.repo ||
|
||||
!this.model.props.githubId
|
||||
) {
|
||||
this.store.withoutTransact(() => {
|
||||
const url = this.model.props.url;
|
||||
const urlMatch = url.match(githubUrlRegex);
|
||||
if (urlMatch) {
|
||||
const [, owner, repo, githubType, githubId] = urlMatch;
|
||||
this.store.updateBlock(this.model, {
|
||||
owner,
|
||||
repo,
|
||||
githubType: githubType === 'issue' ? 'issue' : 'pr',
|
||||
githubId,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.store.withoutTransact(() => {
|
||||
if (!this.model.props.description && !this.model.props.title) {
|
||||
this.refreshData();
|
||||
} else {
|
||||
this.refreshStatus();
|
||||
}
|
||||
});
|
||||
|
||||
this.disposables.add(
|
||||
this.model.propsUpdated.subscribe(({ key }) => {
|
||||
if (key === 'url') {
|
||||
this.refreshData();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override renderBlock() {
|
||||
const {
|
||||
title,
|
||||
githubType,
|
||||
status,
|
||||
statusReason,
|
||||
owner,
|
||||
repo,
|
||||
createdAt,
|
||||
assignees,
|
||||
description,
|
||||
image,
|
||||
style,
|
||||
} = this.model.props;
|
||||
|
||||
const loading = this.loading;
|
||||
const theme = this.std.get(ThemeProvider).theme;
|
||||
const imageProxyService = this.store.get(ImageProxyService);
|
||||
const { 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 || 'GitHub';
|
||||
const descriptionText = loading ? '' : description;
|
||||
const bannerImage =
|
||||
!loading && image
|
||||
? html`<img src=${imageProxyService.buildUrl(image)} alt="banner" />`
|
||||
: 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,
|
||||
edgeless: isGfxBlockComponent(this),
|
||||
selected: this.selected$.value,
|
||||
})}
|
||||
style=${styleMap({
|
||||
// transform: `scale(${this._scale})`,
|
||||
transformOrigin: '0 0 ',
|
||||
})}
|
||||
@click=${this._handleClick}
|
||||
@dblclick=${this._handleDoubleClick}
|
||||
>
|
||||
<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 class="affine-embed-github-banner">${bannerImage}</div>
|
||||
</div>
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
@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,36 @@
|
||||
import {
|
||||
EmbedGithubBlockSchema,
|
||||
type EmbedGithubModel,
|
||||
EmbedGithubStyles,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
EmbedOptionConfig,
|
||||
LinkPreviewServiceIdentifier,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { BlockService } from '@blocksuite/std';
|
||||
|
||||
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;
|
||||
|
||||
queryApiData = (embedGithubModel: EmbedGithubModel, signal?: AbortSignal) => {
|
||||
return queryEmbedGithubApiData(embedGithubModel, signal);
|
||||
};
|
||||
|
||||
queryUrlData = (embedGithubModel: EmbedGithubModel, signal?: AbortSignal) => {
|
||||
return queryEmbedGithubData(
|
||||
embedGithubModel,
|
||||
this.std.get(LinkPreviewServiceIdentifier),
|
||||
signal
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export const EmbedGithubBlockOptionConfig = EmbedOptionConfig({
|
||||
flavour: EmbedGithubBlockSchema.model.flavour,
|
||||
urlRegex: githubUrlRegex,
|
||||
styles: EmbedGithubStyles,
|
||||
viewType: 'card',
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { EmbedGithubBlockSchema } from '@blocksuite/affine-model';
|
||||
import { SlashMenuConfigExtension } from '@blocksuite/affine-widget-slash-menu';
|
||||
import { BlockViewExtension, FlavourExtension } from '@blocksuite/std';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
import { literal } from 'lit/static-html.js';
|
||||
|
||||
import { createBuiltinToolbarConfigExtension } from '../configs/toolbar';
|
||||
import { embedGithubSlashMenuConfig } from './configs/slash-menu';
|
||||
import { EmbedGithubBlockInteraction } from './embed-edgeless-github-block';
|
||||
import { EmbedGithubBlockComponent } from './embed-github-block';
|
||||
import {
|
||||
EmbedGithubBlockOptionConfig,
|
||||
EmbedGithubBlockService,
|
||||
} from './embed-github-service';
|
||||
|
||||
const flavour = EmbedGithubBlockSchema.model.flavour;
|
||||
|
||||
export const EmbedGithubViewExtensions: ExtensionType[] = [
|
||||
FlavourExtension(flavour),
|
||||
EmbedGithubBlockService,
|
||||
BlockViewExtension(flavour, model => {
|
||||
return model.parent?.flavour === 'affine:surface'
|
||||
? literal`affine-embed-edgeless-github-block`
|
||||
: literal`affine-embed-github-block`;
|
||||
}),
|
||||
EmbedGithubBlockOptionConfig,
|
||||
EmbedGithubBlockInteraction,
|
||||
createBuiltinToolbarConfigExtension(flavour, EmbedGithubBlockComponent),
|
||||
SlashMenuConfigExtension(flavour, embedGithubSlashMenuConfig),
|
||||
].flat();
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './adapters/index.js';
|
||||
export * from './edgeless-clipboard-config';
|
||||
export * from './embed-github-block.js';
|
||||
export * from './embed-github-service.js';
|
||||
export * from './embed-github-spec.js';
|
||||
export { GithubIcon } from './styles.js';
|
||||
502
blocksuite/affine/blocks/embed/src/embed-github-block/styles.ts
Normal file
502
blocksuite/affine/blocks/embed/src/embed-github-block/styles.ts
Normal file
@@ -0,0 +1,502 @@
|
||||
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
import { css, html } from 'lit';
|
||||
|
||||
export const styles = css`
|
||||
.affine-embed-github-block {
|
||||
container: affine-embed-github-block / inline-size;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
border-radius: 8px;
|
||||
border: 1px solid ${unsafeCSSVarV2('layer/background/tertiary')};
|
||||
|
||||
background: ${unsafeCSSVarV2('layer/background/primary')};
|
||||
user-select: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.affine-embed-github-content {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
flex-direction: column;
|
||||
align-self: stretch;
|
||||
gap: 4px;
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.affine-embed-github-content-title {
|
||||
display: flex;
|
||||
min-height: 22px;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
.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.vertical {
|
||||
flex-direction: column-reverse;
|
||||
|
||||
.affine-embed-github-content {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.affine-embed-github-content-description {
|
||||
-webkit-line-clamp: 6;
|
||||
}
|
||||
|
||||
.affine-embed-github-content-assignees {
|
||||
display: flex;
|
||||
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 {
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
@container affine-embed-github-block (width < 375px) {
|
||||
.affine-embed-github-content {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.affine-embed-github-block:not(.edgeless) .affine-embed-github-banner {
|
||||
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>`;
|
||||
182
blocksuite/affine/blocks/embed/src/embed-github-block/utils.ts
Normal file
182
blocksuite/affine/blocks/embed/src/embed-github-block/utils.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import type {
|
||||
EmbedGithubBlockUrlData,
|
||||
EmbedGithubModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import type { LinkPreviewProvider } from '@blocksuite/affine-shared/services';
|
||||
import { isAbortError } from '@blocksuite/affine-shared/utils';
|
||||
import { nothing } from 'lit';
|
||||
|
||||
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: LinkPreviewProvider,
|
||||
signal?: AbortSignal
|
||||
): Promise<Partial<EmbedGithubBlockUrlData>> {
|
||||
const [githubApiData, openGraphData] = await Promise.all([
|
||||
queryEmbedGithubApiData(embedGithubModel, signal),
|
||||
linkPreviewer.query(embedGithubModel.props.url, signal),
|
||||
]);
|
||||
return { ...githubApiData, ...openGraphData };
|
||||
}
|
||||
|
||||
export async function queryEmbedGithubApiData(
|
||||
embedGithubModel: EmbedGithubModel,
|
||||
signal?: AbortSignal
|
||||
): Promise<Partial<EmbedGithubBlockUrlData>> {
|
||||
const { owner, repo, githubType, githubId } = embedGithubModel.props;
|
||||
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;
|
||||
|
||||
// TODO(@mirone): remove service
|
||||
const queryUrlData = embedGithubElement.service?.queryUrlData;
|
||||
if (!queryUrlData) {
|
||||
console.error(
|
||||
`Trying to refresh github url data, but the queryUrlData is not found.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
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.store.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
|
||||
) {
|
||||
// TODO(@mirone): remove service
|
||||
const queryApiData = embedGithubElement.service?.queryApiData;
|
||||
if (!queryApiData) {
|
||||
console.error(
|
||||
`Trying to refresh github status, but the queryApiData is not found.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const githubApiData = await queryApiData(embedGithubElement.model, signal);
|
||||
|
||||
if (!githubApiData.status || signal?.aborted) return;
|
||||
|
||||
embedGithubElement.store.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 readonly _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.props.html ?? '',
|
||||
}))
|
||||
.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,193 @@
|
||||
import { toast } from '@blocksuite/affine-components/toast';
|
||||
import { EmbedHtmlModel } from '@blocksuite/affine-model';
|
||||
import {
|
||||
ActionPlacement,
|
||||
type ToolbarAction,
|
||||
type ToolbarActionGroup,
|
||||
type ToolbarModuleConfig,
|
||||
ToolbarModuleExtension,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { getBlockProps } from '@blocksuite/affine-shared/utils';
|
||||
import {
|
||||
CaptionIcon,
|
||||
CopyIcon,
|
||||
DeleteIcon,
|
||||
DuplicateIcon,
|
||||
ExpandFullIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import { BlockFlavourIdentifier } from '@blocksuite/std';
|
||||
import { type ExtensionType, Slice } from '@blocksuite/store';
|
||||
import { html } from 'lit';
|
||||
import { keyed } from 'lit/directives/keyed.js';
|
||||
|
||||
import { EmbedHtmlBlockComponent } from '../embed-html-block';
|
||||
|
||||
const trackBaseProps = {
|
||||
category: 'html',
|
||||
type: 'card view',
|
||||
};
|
||||
|
||||
const openDocAction = {
|
||||
id: 'a.open-doc',
|
||||
icon: ExpandFullIcon(),
|
||||
tooltip: 'Open this doc',
|
||||
run(ctx) {
|
||||
const block = ctx.getCurrentBlockByType(EmbedHtmlBlockComponent);
|
||||
block?.open();
|
||||
},
|
||||
} as const satisfies ToolbarAction;
|
||||
|
||||
const captionAction = {
|
||||
id: 'c.caption',
|
||||
tooltip: 'Caption',
|
||||
icon: CaptionIcon(),
|
||||
run(ctx) {
|
||||
const block = ctx.getCurrentBlockByType(EmbedHtmlBlockComponent);
|
||||
block?.captionEditor?.show();
|
||||
|
||||
ctx.track('OpenedCaptionEditor', {
|
||||
...trackBaseProps,
|
||||
control: 'add caption',
|
||||
});
|
||||
},
|
||||
} as const satisfies ToolbarAction;
|
||||
|
||||
const builtinToolbarConfig = {
|
||||
actions: [
|
||||
openDocAction,
|
||||
{
|
||||
id: 'b.style',
|
||||
actions: [
|
||||
{
|
||||
id: 'horizontal',
|
||||
label: 'Large horizontal style',
|
||||
},
|
||||
{
|
||||
id: 'list',
|
||||
label: 'Small horizontal style',
|
||||
},
|
||||
],
|
||||
content(ctx) {
|
||||
const model = ctx.getCurrentModelByType(EmbedHtmlModel);
|
||||
if (!model) return null;
|
||||
|
||||
const actions = this.actions.map<ToolbarAction>(action => ({
|
||||
...action,
|
||||
run: ({ store }) => {
|
||||
store.updateBlock(model, { style: action.id });
|
||||
|
||||
ctx.track('SelectedCardStyle', {
|
||||
...trackBaseProps,
|
||||
control: 'select card style',
|
||||
type: action.id,
|
||||
});
|
||||
},
|
||||
}));
|
||||
const onToggle = (e: CustomEvent<boolean>) => {
|
||||
e.stopPropagation();
|
||||
const opened = e.detail;
|
||||
if (!opened) return;
|
||||
|
||||
ctx.track('OpenedCardStyleSelector', {
|
||||
...trackBaseProps,
|
||||
control: 'switch card style',
|
||||
});
|
||||
};
|
||||
|
||||
return html`${keyed(
|
||||
model,
|
||||
html`<affine-card-style-dropdown-menu
|
||||
@toggle=${onToggle}
|
||||
.actions=${actions}
|
||||
.context=${ctx}
|
||||
.style=${model.props.style$}
|
||||
></affine-card-style-dropdown-menu>`
|
||||
)}`;
|
||||
},
|
||||
} satisfies ToolbarActionGroup<ToolbarAction>,
|
||||
captionAction,
|
||||
{
|
||||
placement: ActionPlacement.More,
|
||||
id: 'a.clipboard',
|
||||
actions: [
|
||||
{
|
||||
id: 'copy',
|
||||
label: 'Copy',
|
||||
icon: CopyIcon(),
|
||||
run(ctx) {
|
||||
const model = ctx.getCurrentModelByType(EmbedHtmlModel);
|
||||
if (!model) return;
|
||||
|
||||
const slice = Slice.fromModels(ctx.store, [model]);
|
||||
ctx.clipboard
|
||||
.copySlice(slice)
|
||||
.then(() => toast(ctx.host, 'Copied to clipboard'))
|
||||
.catch(console.error);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'duplicate',
|
||||
label: 'Duplicate',
|
||||
icon: DuplicateIcon(),
|
||||
run(ctx) {
|
||||
const model = ctx.getCurrentModelByType(EmbedHtmlModel);
|
||||
if (!model) return;
|
||||
|
||||
const { flavour, parent } = model;
|
||||
const props = getBlockProps(model);
|
||||
const index = parent?.children.indexOf(model);
|
||||
|
||||
ctx.store.addBlock(flavour, props, parent, index);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
placement: ActionPlacement.More,
|
||||
id: 'c.delete',
|
||||
label: 'Delete',
|
||||
icon: DeleteIcon(),
|
||||
variant: 'destructive',
|
||||
run(ctx) {
|
||||
const model = ctx.getCurrentModelByType(EmbedHtmlModel);
|
||||
if (!model) return;
|
||||
|
||||
ctx.store.deleteBlock(model);
|
||||
|
||||
// Clears
|
||||
ctx.select('note');
|
||||
ctx.reset();
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
when: ctx => ctx.getSurfaceModelsByType(EmbedHtmlModel).length === 1,
|
||||
} as const satisfies ToolbarModuleConfig;
|
||||
|
||||
const builtinSurfaceToolbarConfig = {
|
||||
actions: [
|
||||
openDocAction,
|
||||
{
|
||||
...captionAction,
|
||||
id: 'b.caption',
|
||||
},
|
||||
],
|
||||
} as const satisfies ToolbarModuleConfig;
|
||||
|
||||
export const createBuiltinToolbarConfigExtension = (
|
||||
flavour: string
|
||||
): ExtensionType[] => {
|
||||
const name = flavour.split(':').pop();
|
||||
|
||||
return [
|
||||
ToolbarModuleExtension({
|
||||
id: BlockFlavourIdentifier(flavour),
|
||||
config: builtinToolbarConfig,
|
||||
}),
|
||||
|
||||
ToolbarModuleExtension({
|
||||
id: BlockFlavourIdentifier(`affine:surface:${name}`),
|
||||
config: builtinSurfaceToolbarConfig,
|
||||
}),
|
||||
];
|
||||
};
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,24 @@
|
||||
import { EdgelessClipboardConfig } from '@blocksuite/affine-block-surface';
|
||||
import { type BlockSnapshot } from '@blocksuite/store';
|
||||
|
||||
export class EdgelessClipboardEmbedHtmlConfig extends EdgelessClipboardConfig {
|
||||
static override readonly key = 'affine:embed-html';
|
||||
|
||||
override createBlock(htmlEmbed: BlockSnapshot): string | null {
|
||||
if (!this.surface) return null;
|
||||
const { xywh, style, caption, html, design } = htmlEmbed.props;
|
||||
|
||||
const embedHtmlId = this.crud.addBlock(
|
||||
'affine:embed-html',
|
||||
{
|
||||
xywh,
|
||||
style,
|
||||
caption,
|
||||
html,
|
||||
design,
|
||||
},
|
||||
this.surface.model.id
|
||||
);
|
||||
return embedHtmlId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { EmbedHtmlBlockSchema } from '@blocksuite/affine-model';
|
||||
|
||||
import { createEmbedEdgelessBlockInteraction } from '../common/embed-block-element.js';
|
||||
import { toEdgelessEmbedBlock } from '../common/to-edgeless-embed-block.js';
|
||||
import { EmbedHtmlBlockComponent } from './embed-html-block.js';
|
||||
import { EMBED_HTML_MIN_HEIGHT, EMBED_HTML_MIN_WIDTH } from './styles.js';
|
||||
|
||||
export class EmbedEdgelessHtmlBlockComponent extends toEdgelessEmbedBlock(
|
||||
EmbedHtmlBlockComponent
|
||||
) {}
|
||||
|
||||
export const EmbedEdgelessHtmlBlockInteraction =
|
||||
createEmbedEdgelessBlockInteraction(EmbedHtmlBlockSchema.model.flavour, {
|
||||
resizeConstraint: {
|
||||
minWidth: EMBED_HTML_MIN_WIDTH,
|
||||
minHeight: EMBED_HTML_MIN_HEIGHT,
|
||||
lockRatio: false,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { EmbedHtmlModel, EmbedHtmlStyles } from '@blocksuite/affine-model';
|
||||
import { BlockSelection } from '@blocksuite/std';
|
||||
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';
|
||||
|
||||
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';
|
||||
|
||||
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(BlockSelection, {
|
||||
blockId: this.blockId,
|
||||
});
|
||||
selectionManager.setGroup('note', [blockSelection]);
|
||||
}
|
||||
|
||||
protected _handleClick(event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
this._selectBlock();
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._cardStyle = this.model.props.style;
|
||||
}
|
||||
|
||||
override renderBlock(): unknown {
|
||||
const titleText = 'Basic HTML Page Structure';
|
||||
|
||||
const htmlSrc = `
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
${this.model.props.html}
|
||||
`;
|
||||
|
||||
return this.renderEmbed(() => {
|
||||
if (!this.model.props.html) {
|
||||
return html` <div class="affine-html-empty">Empty</div>`;
|
||||
}
|
||||
return html`
|
||||
<div
|
||||
class=${classMap({
|
||||
'affine-embed-html-block': true,
|
||||
selected: this.selected$.value,
|
||||
})}
|
||||
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>
|
||||
|
||||
<!-- overlay to prevent the iframe from capturing pointer events -->
|
||||
<div
|
||||
class=${classMap({
|
||||
'affine-embed-html-iframe-overlay': true,
|
||||
hide: !this.showOverlay$.value,
|
||||
})}
|
||||
></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>
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
@query('.embed-html-block-iframe-wrapper')
|
||||
accessor iframeWrapper!: HTMLDivElement;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { EmbedHtmlBlockSchema } from '@blocksuite/affine-model';
|
||||
import { BlockViewExtension } from '@blocksuite/std';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
import { literal } from 'lit/static-html.js';
|
||||
|
||||
import { createBuiltinToolbarConfigExtension } from './configs/toolbar';
|
||||
import { EmbedEdgelessHtmlBlockInteraction } from './embed-edgeless-html-block';
|
||||
|
||||
const flavour = EmbedHtmlBlockSchema.model.flavour;
|
||||
|
||||
export const EmbedHtmlViewExtensions: ExtensionType[] = [
|
||||
BlockViewExtension(flavour, model => {
|
||||
return model.parent?.flavour === 'affine:surface'
|
||||
? literal`affine-embed-edgeless-html-block`
|
||||
: literal`affine-embed-html-block`;
|
||||
}),
|
||||
createBuiltinToolbarConfigExtension(flavour),
|
||||
EmbedEdgelessHtmlBlockInteraction,
|
||||
].flat();
|
||||
@@ -0,0 +1,8 @@
|
||||
export * from './edgeless-clipboard-config';
|
||||
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';
|
||||
145
blocksuite/affine/blocks/embed/src/embed-html-block/styles.ts
Normal file
145
blocksuite/affine/blocks/embed/src/embed-html-block/styles.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
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 ${unsafeCSSVarV2('layer/background/tertiary')};
|
||||
|
||||
background: ${unsafeCSSVarV2('layer/background/primary')};
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.affine-embed-html {
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.affine-embed-html img,
|
||||
.affine-embed-html object,
|
||||
.affine-embed-html svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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,55 @@
|
||||
import { EmbedIframeBlockSchema } from '@blocksuite/affine-model';
|
||||
import { BlockHtmlAdapterExtension } from '@blocksuite/affine-shared/adapters';
|
||||
|
||||
import { createEmbedBlockHtmlAdapterMatcher } from '../../common/adapters/html';
|
||||
|
||||
export const embedIframeBlockHtmlAdapterMatcher =
|
||||
createEmbedBlockHtmlAdapterMatcher(EmbedIframeBlockSchema.model.flavour, {
|
||||
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();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const EmbedIframeBlockHtmlAdapterExtension = BlockHtmlAdapterExtension(
|
||||
embedIframeBlockHtmlAdapterMatcher
|
||||
);
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
|
||||
import { EmbedIframeBlockHtmlAdapterExtension } from './html';
|
||||
import { EmbedIframeBlockMarkdownAdapterExtension } from './markdown';
|
||||
import { EmbedIframeBlockPlainTextAdapterExtension } from './plain-text';
|
||||
|
||||
export * from './html';
|
||||
export * from './markdown';
|
||||
export * from './plain-text';
|
||||
|
||||
export const EmbedIframeBlockAdapterExtensions: ExtensionType[] = [
|
||||
EmbedIframeBlockHtmlAdapterExtension,
|
||||
EmbedIframeBlockMarkdownAdapterExtension,
|
||||
EmbedIframeBlockPlainTextAdapterExtension,
|
||||
];
|
||||
@@ -0,0 +1,46 @@
|
||||
import { EmbedIframeBlockSchema } from '@blocksuite/affine-model';
|
||||
import { BlockMarkdownAdapterExtension } from '@blocksuite/affine-shared/adapters';
|
||||
|
||||
import { createEmbedBlockMarkdownAdapterMatcher } from '../../common/adapters/markdown.js';
|
||||
|
||||
export const embedIframeBlockMarkdownAdapterMatcher =
|
||||
createEmbedBlockMarkdownAdapterMatcher(EmbedIframeBlockSchema.model.flavour, {
|
||||
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();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const EmbedIframeBlockMarkdownAdapterExtension =
|
||||
BlockMarkdownAdapterExtension(embedIframeBlockMarkdownAdapterMatcher);
|
||||
@@ -0,0 +1,31 @@
|
||||
import { EmbedIframeBlockSchema } from '@blocksuite/affine-model';
|
||||
import { BlockPlainTextAdapterExtension } from '@blocksuite/affine-shared/adapters';
|
||||
|
||||
import { createEmbedBlockPlainTextAdapterMatcher } from '../../common/adapters/plain-text';
|
||||
|
||||
export const embedIframeBlockPlainTextAdapterMatcher =
|
||||
createEmbedBlockPlainTextAdapterMatcher(
|
||||
EmbedIframeBlockSchema.model.flavour,
|
||||
{
|
||||
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';
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export const EmbedIframeBlockPlainTextAdapterExtension =
|
||||
BlockPlainTextAdapterExtension(embedIframeBlockPlainTextAdapterMatcher);
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './insert-embed-iframe-with-url';
|
||||
export * from './insert-empty-embed-iframe';
|
||||
@@ -0,0 +1,107 @@
|
||||
import {
|
||||
DefaultTool,
|
||||
EdgelessCRUDIdentifier,
|
||||
SurfaceBlockComponent,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import { EmbedIframeService } from '@blocksuite/affine-shared/services';
|
||||
import { Bound, Vec } from '@blocksuite/global/gfx';
|
||||
import {
|
||||
BlockSelection,
|
||||
type Command,
|
||||
SurfaceSelection,
|
||||
TextSelection,
|
||||
} from '@blocksuite/std';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
|
||||
|
||||
import {
|
||||
EMBED_IFRAME_DEFAULT_HEIGHT_IN_SURFACE,
|
||||
EMBED_IFRAME_DEFAULT_WIDTH_IN_SURFACE,
|
||||
} from '../consts';
|
||||
|
||||
export const insertEmbedIframeWithUrlCommand: Command<
|
||||
{ url: string },
|
||||
{ blockId: string; flavour: string }
|
||||
> = (ctx, next) => {
|
||||
const { url, std } = ctx;
|
||||
const embedIframeService = std.get(EmbedIframeService);
|
||||
if (!embedIframeService || !embedIframeService.canEmbed(url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = embedIframeService.getConfig(url);
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { host } = std;
|
||||
const selectionManager = host.selection;
|
||||
|
||||
let selectedBlockId: string | undefined;
|
||||
const textSelection = selectionManager.find(TextSelection);
|
||||
const blockSelection = selectionManager.find(BlockSelection);
|
||||
const surfaceSelection = selectionManager.find(SurfaceSelection);
|
||||
if (textSelection) {
|
||||
selectedBlockId = textSelection.blockId;
|
||||
} else if (blockSelection) {
|
||||
selectedBlockId = blockSelection.blockId;
|
||||
} else if (surfaceSelection && surfaceSelection.editing) {
|
||||
selectedBlockId = surfaceSelection.blockId;
|
||||
}
|
||||
|
||||
const flavour = 'affine:embed-iframe';
|
||||
const props: Record<string, unknown> = { url };
|
||||
// When there is a selected block, it means that the selection is in note or edgeless text
|
||||
// we should insert the embed iframe block after the selected block and only need the url prop
|
||||
let newBlockId: string | undefined;
|
||||
if (selectedBlockId) {
|
||||
const block = host.view.getBlock(selectedBlockId);
|
||||
if (!block) return;
|
||||
const parent = host.store.getParent(block.model);
|
||||
if (!parent) return;
|
||||
const index = parent.children.indexOf(block.model);
|
||||
newBlockId = host.store.addBlock(flavour, props, parent, index + 1);
|
||||
} else {
|
||||
// When there is no selected block and in edgeless mode
|
||||
// We should insert the embed iframe block to surface
|
||||
// It means that not only the url prop but also the xywh prop is needed
|
||||
const rootId = std.store.root?.id;
|
||||
if (!rootId) return;
|
||||
const edgelessRoot = std.view.getBlock(rootId);
|
||||
if (!edgelessRoot) return;
|
||||
|
||||
const gfx = std.get(GfxControllerIdentifier);
|
||||
const crud = std.get(EdgelessCRUDIdentifier);
|
||||
|
||||
gfx.viewport.smoothZoom(1);
|
||||
const surfaceBlock = gfx.surfaceComponent;
|
||||
if (!(surfaceBlock instanceof SurfaceBlockComponent)) return;
|
||||
|
||||
const options = config.options;
|
||||
const { widthInSurface, heightInSurface } = options ?? {};
|
||||
const width = widthInSurface ?? EMBED_IFRAME_DEFAULT_WIDTH_IN_SURFACE;
|
||||
const height = heightInSurface ?? EMBED_IFRAME_DEFAULT_HEIGHT_IN_SURFACE;
|
||||
const center = Vec.toVec(surfaceBlock.renderer.viewport.center);
|
||||
const xywh = Bound.fromCenter(center, width, height).serialize();
|
||||
newBlockId = crud.addBlock(
|
||||
flavour,
|
||||
{
|
||||
...props,
|
||||
xywh,
|
||||
},
|
||||
surfaceBlock.model
|
||||
);
|
||||
|
||||
gfx.tool.setTool(DefaultTool);
|
||||
|
||||
gfx.selection.set({
|
||||
elements: [newBlockId],
|
||||
editing: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (!newBlockId) {
|
||||
return;
|
||||
}
|
||||
|
||||
next({ blockId: newBlockId, flavour });
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { EmbedIframeBlockProps } from '@blocksuite/affine-model';
|
||||
import type { Command } from '@blocksuite/std';
|
||||
import type { BlockModel } from '@blocksuite/store';
|
||||
|
||||
import type { EmbedLinkInputPopupOptions } from '../components/embed-iframe-link-input-popup';
|
||||
import { EmbedIframeBlockComponent } from '../embed-iframe-block';
|
||||
|
||||
export const insertEmptyEmbedIframeCommand: Command<
|
||||
{
|
||||
place?: 'after' | 'before';
|
||||
removeEmptyLine?: boolean;
|
||||
selectedModels?: BlockModel[];
|
||||
linkInputPopupOptions?: EmbedLinkInputPopupOptions;
|
||||
},
|
||||
{
|
||||
insertedEmbedIframeBlockId: Promise<string>;
|
||||
}
|
||||
> = (ctx, next) => {
|
||||
const { selectedModels, place, removeEmptyLine, std, linkInputPopupOptions } =
|
||||
ctx;
|
||||
if (!selectedModels?.length) return;
|
||||
|
||||
const targetModel =
|
||||
place === 'before'
|
||||
? selectedModels[0]
|
||||
: selectedModels[selectedModels.length - 1];
|
||||
|
||||
const embedIframeBlockProps: Partial<EmbedIframeBlockProps> & {
|
||||
flavour: 'affine:embed-iframe';
|
||||
} = {
|
||||
flavour: 'affine:embed-iframe',
|
||||
};
|
||||
|
||||
const result = std.store.addSiblingBlocks(
|
||||
targetModel,
|
||||
[embedIframeBlockProps],
|
||||
place
|
||||
);
|
||||
if (result.length === 0) return;
|
||||
|
||||
if (removeEmptyLine && targetModel.text?.length === 0) {
|
||||
std.store.deleteBlock(targetModel);
|
||||
}
|
||||
|
||||
next({
|
||||
insertedEmbedIframeBlockId: std.host.updateComplete.then(async () => {
|
||||
const blockComponent = std.view.getBlock(result[0]);
|
||||
if (blockComponent instanceof EmbedIframeBlockComponent) {
|
||||
await blockComponent.updateComplete;
|
||||
blockComponent.toggleLinkInputPopup(linkInputPopupOptions);
|
||||
}
|
||||
return result[0];
|
||||
}),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,345 @@
|
||||
import { createLitPortal } from '@blocksuite/affine-components/portal';
|
||||
import type { EmbedIframeBlockModel } from '@blocksuite/affine-model';
|
||||
import {
|
||||
DocModeProvider,
|
||||
TelemetryProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import { EditIcon, InformationIcon, ResetIcon } from '@blocksuite/icons/lit';
|
||||
import type { BlockStdScope } from '@blocksuite/std';
|
||||
import { flip, offset } from '@floating-ui/dom';
|
||||
import { baseTheme } from '@toeverything/theme';
|
||||
import { css, html, LitElement, nothing, unsafeCSS } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { ERROR_CARD_DEFAULT_HEIGHT } from '../consts';
|
||||
import type { EmbedIframeStatusCardOptions } from '../types';
|
||||
|
||||
const LINK_EDIT_POPUP_OFFSET = 12;
|
||||
|
||||
export class EmbedIframeErrorCard extends WithDisposable(LitElement) {
|
||||
static override styles = css`
|
||||
:host {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.affine-embed-iframe-error-card {
|
||||
container: affine-embed-iframe-error-card / size;
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
user-select: none;
|
||||
padding: 12px;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
border: 1px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
|
||||
background: ${unsafeCSSVarV2('layer/background/secondary')};
|
||||
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
|
||||
user-select: none;
|
||||
|
||||
.error-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.error-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
.error-icon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: ${unsafeCSSVarV2('status/error')};
|
||||
}
|
||||
|
||||
.error-title-text {
|
||||
color: ${unsafeCSSVarV2('text/primary')};
|
||||
text-align: justify;
|
||||
/* Client/smBold */
|
||||
font-size: var(--affine-font-sm);
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
line-height: 22px; /* 157.143% */
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.error-message {
|
||||
display: flex;
|
||||
align-self: stretch;
|
||||
color: ${unsafeCSSVarV2('text/secondary')};
|
||||
overflow: hidden;
|
||||
font-feature-settings:
|
||||
'liga' off,
|
||||
'clig' off;
|
||||
text-overflow: ellipsis;
|
||||
/* Client/xs */
|
||||
font-size: var(--affine-font-xs);
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 166.667% */
|
||||
}
|
||||
|
||||
.error-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
.button {
|
||||
display: flex;
|
||||
padding: 0px 4px;
|
||||
align-items: center;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
|
||||
.icon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.text {
|
||||
padding: 0px 4px;
|
||||
font-size: var(--affine-font-xs);
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 20px; /* 166.667% */
|
||||
}
|
||||
}
|
||||
|
||||
.button.edit {
|
||||
color: ${unsafeCSSVarV2('text/secondary')};
|
||||
}
|
||||
|
||||
.button.retry {
|
||||
color: ${unsafeCSSVarV2('text/emphasis')};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.affine-embed-iframe-error-card.horizontal {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
|
||||
.error-content {
|
||||
align-items: flex-start;
|
||||
flex: 1 0 0;
|
||||
|
||||
.error-message {
|
||||
height: 40px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@container affine-embed-iframe-error-card (width < 480px) {
|
||||
.error-banner {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.affine-embed-iframe-error-card.vertical {
|
||||
flex-direction: column-reverse;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.error-content {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
.error-message {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-box {
|
||||
svg {
|
||||
transform: scale(1.6) translateY(-14px);
|
||||
}
|
||||
}
|
||||
|
||||
@container affine-embed-iframe-error-card (height < 300px) or (width < 300px) {
|
||||
.error-banner {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
private _editAbortController: AbortController | null = null;
|
||||
private readonly _toggleEdit = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!this._editButton || this.readonly) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._editAbortController) {
|
||||
this._editAbortController.abort();
|
||||
}
|
||||
|
||||
this._editAbortController = new AbortController();
|
||||
|
||||
createLitPortal({
|
||||
template: html`<embed-iframe-link-edit-popup
|
||||
.model=${this.model}
|
||||
.abortController=${this._editAbortController}
|
||||
.std=${this.std}
|
||||
.inSurface=${this.inSurface}
|
||||
></embed-iframe-link-edit-popup>`,
|
||||
container: document.body,
|
||||
computePosition: {
|
||||
referenceElement: this._editButton,
|
||||
placement: 'bottom-start',
|
||||
middleware: [flip(), offset(LINK_EDIT_POPUP_OFFSET)],
|
||||
autoUpdate: { animationFrame: true },
|
||||
},
|
||||
abortController: this._editAbortController,
|
||||
closeOnClickAway: true,
|
||||
});
|
||||
};
|
||||
|
||||
private readonly _handleRetry = async (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const success = await this.onRetry();
|
||||
|
||||
// track retry event
|
||||
this.telemetryService?.track('ReloadLink', {
|
||||
type: 'embed iframe block',
|
||||
page: this.editorMode === 'page' ? 'doc editor' : 'whiteboard editor',
|
||||
segment: 'editor',
|
||||
module: 'embed block',
|
||||
control: 'reload button',
|
||||
result: success ? 'success' : 'failure',
|
||||
});
|
||||
};
|
||||
|
||||
override render() {
|
||||
const { layout, width, height } = this.options;
|
||||
const cardClasses = classMap({
|
||||
'affine-embed-iframe-error-card': true,
|
||||
horizontal: layout === 'horizontal',
|
||||
vertical: layout === 'vertical',
|
||||
});
|
||||
|
||||
const cardWidth = width ? `${width}px` : '100%';
|
||||
const cardHeight = height ? `${height}px` : '100%';
|
||||
const cardStyle = styleMap({
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
});
|
||||
|
||||
return html`
|
||||
<div class=${cardClasses} style=${cardStyle}>
|
||||
<div class="error-content">
|
||||
<div class="error-title">
|
||||
<span class="error-icon">
|
||||
${InformationIcon({ width: '16px', height: '16px' })}
|
||||
</span>
|
||||
<span class="error-title-text">This link couldn’t be loaded.</span>
|
||||
</div>
|
||||
<div class="error-message">
|
||||
${this.error?.message || 'Failed to load embedded content'}
|
||||
</div>
|
||||
<div class="error-info">
|
||||
${
|
||||
this.readonly
|
||||
? nothing
|
||||
: html`
|
||||
<div class="button edit" @click=${this._toggleEdit}>
|
||||
<span class="icon"
|
||||
>${EditIcon({ width: '16px', height: '16px' })}</span
|
||||
>
|
||||
<span class="text">Edit</span>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
<div class="button retry" @click=${this._handleRetry}>
|
||||
<span class="icon"
|
||||
>${ResetIcon({ width: '16px', height: '16px' })}</span
|
||||
>
|
||||
<span class="text">Reload</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="error-banner">
|
||||
<div class="icon-box">${EmbedIframeErrorIcon}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
get host() {
|
||||
return this.std.host;
|
||||
}
|
||||
|
||||
get readonly() {
|
||||
return this.model.store.readonly;
|
||||
}
|
||||
|
||||
get telemetryService() {
|
||||
return this.std.getOptional(TelemetryProvider);
|
||||
}
|
||||
|
||||
get editorMode() {
|
||||
const docModeService = this.std.get(DocModeProvider);
|
||||
const mode = docModeService.getEditorMode();
|
||||
return mode ?? 'page';
|
||||
}
|
||||
|
||||
@query('.button.edit')
|
||||
accessor _editButton: HTMLElement | null = null;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor error: Error | null = null;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onRetry!: () => Promise<boolean>;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor model!: EmbedIframeBlockModel;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor std!: BlockStdScope;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor inSurface = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor options: EmbedIframeStatusCardOptions = {
|
||||
layout: 'horizontal',
|
||||
height: ERROR_CARD_DEFAULT_HEIGHT,
|
||||
};
|
||||
}
|
||||
|
||||
export const EmbedIframeErrorIcon = html`<svg
|
||||
width="204"
|
||||
height="102"
|
||||
viewBox="0 0 204 102"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g clip-path="url(#clip0_2676_106795)">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M94.6838 8.45092L106.173 31.9276L84.6593 57.0514L90.5888 64.9202C88.6083 64.6092 86.5089 65.0701 84.7813 66.3719L78.4802 71.1202C75.0967 73.6698 74.4207 78.4796 76.9704 81.8631C79.5201 85.2467 84.3299 85.9227 87.7134 83.373L89.4487 82.0654C90.3714 81.37 90.5558 80.0582 89.8604 79.1354C89.1651 78.2127 87.8533 78.0283 86.9305 78.7237L85.1952 80.0313C83.6573 81.1902 81.471 80.883 80.3121 79.345C79.1531 77.807 79.4604 75.6208 80.9984 74.4618L87.2995 69.7136C88.8375 68.5547 91.0237 68.8619 92.1827 70.3999C92.8645 71.3047 94.1389 71.4996 95.0582 70.8513L95.8982 71.966L94.6469 72.9089C93.109 74.0679 90.9227 73.7606 89.7638 72.2227C89.0684 71.2999 87.7566 71.1155 86.8339 71.8109C85.9111 72.5062 85.7267 73.818 86.4221 74.7408C88.9718 78.1243 93.7816 78.8003 97.1651 76.2506L98.4164 75.3077L99.8156 77.1646L86.8434 102.707L89.291 114.735L42.1397 108.108L56.3354 7.10072C56.6429 4.91308 58.6655 3.38889 60.8532 3.69634L94.6838 8.45092ZM122.987 12.4287L119.974 33.8672L95.4607 58.4925C98.7006 56.8928 102.722 57.7678 104.976 60.7594C107.526 64.1429 106.85 68.9527 103.466 71.5024L102.718 72.0665L105.949 78.0266L92.2105 103.461L92.9872 115.254L147.108 122.86L161.304 21.8531C161.611 19.6654 160.087 17.6428 157.899 17.3353L122.987 12.4287ZM100.701 68.3471L100.948 68.1607C102.486 67.0018 102.793 64.8155 101.634 63.2775C100.625 61.9381 98.8364 61.5321 97.3755 62.2152L100.701 68.3471ZM88.8231 36.502C84.6277 35.9124 80.7486 38.8354 80.159 43.0308L79.1885 49.9367C79.0277 51.0809 79.8249 52.1388 80.9691 52.2996C82.1133 52.4604 83.1712 51.6632 83.332 50.519L84.3025 43.6132C84.5705 41.7062 86.3337 40.3775 88.2407 40.6455L95.1466 41.6161C96.2908 41.7769 97.3487 40.9797 97.5095 39.8355C97.6703 38.6913 96.8731 37.6334 95.7289 37.4726L88.8231 36.502ZM115.065 40.1901C113.921 40.0293 112.863 40.8265 112.702 41.9707C112.542 43.1149 113.339 44.1728 114.483 44.3336L121.389 45.3042C123.296 45.5722 124.625 47.3354 124.357 49.2424L123.386 56.1483C123.225 57.2925 124.022 58.3504 125.167 58.5112C126.311 58.672 127.369 57.8748 127.529 56.7306L128.5 49.8247C129.09 45.6293 126.167 41.7503 121.971 41.1607L115.065 40.1901ZM123.031 73.7041C124.176 73.8649 124.973 74.9228 124.812 76.067L123.841 82.9728C123.252 87.1682 119.373 90.0913 115.177 89.5017L106.89 88.337C105.746 88.1762 104.949 87.1183 105.11 85.9741C105.27 84.8299 106.328 84.0327 107.473 84.1935L115.76 85.3582C117.667 85.6262 119.43 84.2975 119.698 82.3905L120.668 75.4847C120.829 74.3405 121.887 73.5433 123.031 73.7041Z"
|
||||
fill="#E6E6E6"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2676_106795">
|
||||
<rect width="204" height="102" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>`;
|
||||
@@ -0,0 +1,132 @@
|
||||
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import { EmbedIcon } from '@blocksuite/icons/lit';
|
||||
import { baseTheme } from '@toeverything/theme';
|
||||
import { css, html, LitElement, unsafeCSS } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { IDLE_CARD_DEFAULT_HEIGHT } from '../consts';
|
||||
import type { EmbedIframeStatusCardOptions } from '../types';
|
||||
|
||||
export class EmbedIframeIdleCard extends WithDisposable(LitElement) {
|
||||
static override styles = css`
|
||||
:host {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.affine-embed-iframe-idle-card {
|
||||
container: affine-embed-iframe-idle-card / size;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
gap: 8px;
|
||||
border-radius: 8px;
|
||||
background-color: ${unsafeCSSVarV2('layer/background/secondary')};
|
||||
|
||||
.icon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: ${unsafeCSSVarV2('icon/secondary')};
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.text {
|
||||
/* Client/base */
|
||||
font-size: 15px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 24px; /* 160% */
|
||||
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
|
||||
color: ${unsafeCSSVarV2('text/secondary')};
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.affine-embed-iframe-idle-card:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.affine-embed-iframe-idle-card.horizontal {
|
||||
flex-direction: row;
|
||||
|
||||
.icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
|
||||
svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.affine-embed-iframe-idle-card.vertical {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
gap: 12px;
|
||||
|
||||
.icon {
|
||||
width: 176px;
|
||||
height: 112px;
|
||||
overflow-y: hidden;
|
||||
|
||||
svg {
|
||||
width: 112px;
|
||||
height: 112px;
|
||||
transform: rotate(12deg) translateY(18%);
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
text-align: center;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@container affine-embed-iframe-idle-card (height < 180px) {
|
||||
.icon {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
override render() {
|
||||
const { layout, width, height } = this.options;
|
||||
const cardClasses = classMap({
|
||||
'affine-embed-iframe-idle-card': true,
|
||||
horizontal: layout === 'horizontal',
|
||||
vertical: layout === 'vertical',
|
||||
});
|
||||
|
||||
const cardWidth = width ? `${width}px` : '100%';
|
||||
const cardHeight = height ? `${height}px` : '100%';
|
||||
const cardStyle = styleMap({
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
});
|
||||
|
||||
return html`
|
||||
<div class=${cardClasses} style=${cardStyle}>
|
||||
<span class="icon"> ${EmbedIcon()} </span>
|
||||
<span class="text">
|
||||
Embed anything (Google Drive, Google Docs, Spotify, Miro…)
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor options: EmbedIframeStatusCardOptions = {
|
||||
layout: 'horizontal',
|
||||
height: IDLE_CARD_DEFAULT_HEIGHT,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import {
|
||||
DocModeProvider,
|
||||
TelemetryProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
import { SignalWatcher } from '@blocksuite/global/lit';
|
||||
import { DoneIcon } from '@blocksuite/icons/lit';
|
||||
import { css, html } from 'lit';
|
||||
|
||||
import { EmbedIframeLinkInputBase } from './embed-iframe-link-input-base';
|
||||
|
||||
export class EmbedIframeLinkEditPopup extends SignalWatcher(
|
||||
EmbedIframeLinkInputBase
|
||||
) {
|
||||
static override styles = css`
|
||||
.embed-iframe-link-edit-popup {
|
||||
display: flex;
|
||||
padding: 12px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: var(--affine-text-primary-color);
|
||||
border-radius: 8px;
|
||||
border: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
|
||||
background: ${unsafeCSSVarV2('layer/background/primary')};
|
||||
box-shadow: ${unsafeCSSVar('overlayPanelShadow')};
|
||||
|
||||
.input-container {
|
||||
display: flex;
|
||||
width: 280px;
|
||||
align-items: center;
|
||||
border: 1px solid var(--affine-border-color);
|
||||
border-radius: 4px;
|
||||
padding: 0 8px;
|
||||
background-color: var(--affine-background-color);
|
||||
gap: 8px;
|
||||
|
||||
.input-label {
|
||||
color: var(--affine-text-secondary-color);
|
||||
font-size: 14px;
|
||||
margin-right: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.link-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
padding: 8px 0;
|
||||
background: transparent;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.input-container:focus-within {
|
||||
border-color: var(--affine-blue-700);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.confirm-button {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
padding: 2px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: ${unsafeCSSVarV2('icon/activated')};
|
||||
}
|
||||
|
||||
.confirm-button[disabled] {
|
||||
color: ${unsafeCSSVarV2('icon/primary')};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
protected override track(status: 'success' | 'failure') {
|
||||
this.telemetryService?.track('EditLink', {
|
||||
type: 'embed iframe block',
|
||||
page: this.editorMode === 'page' ? 'doc editor' : 'whiteboard editor',
|
||||
segment: 'editor',
|
||||
module: 'embed block',
|
||||
control: 'edit button',
|
||||
result: status,
|
||||
});
|
||||
}
|
||||
|
||||
override render() {
|
||||
const isInputEmpty = this.isInputEmpty();
|
||||
const { url$ } = this.model.props;
|
||||
|
||||
return html`
|
||||
<div class="embed-iframe-link-edit-popup">
|
||||
<div class="input-container">
|
||||
<span class="input-label">Link</span>
|
||||
<input
|
||||
class="link-input"
|
||||
type="text"
|
||||
spellcheck="false"
|
||||
placeholder=${url$.value}
|
||||
@input=${this.handleInput}
|
||||
@keydown=${this.handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="confirm-button"
|
||||
?disabled=${isInputEmpty}
|
||||
@click=${this.onConfirm}
|
||||
>
|
||||
${DoneIcon({ width: '24px', height: '24px' })}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
get telemetryService() {
|
||||
return this.std.getOptional(TelemetryProvider);
|
||||
}
|
||||
|
||||
get editorMode() {
|
||||
const docModeService = this.std.get(DocModeProvider);
|
||||
const mode = docModeService.getEditorMode();
|
||||
return mode ?? 'page';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import type { EmbedIframeBlockModel } from '@blocksuite/affine-model';
|
||||
import {
|
||||
EmbedIframeService,
|
||||
NotificationProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { isValidUrl, stopPropagation } from '@blocksuite/affine-shared/utils';
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import { noop } from '@blocksuite/global/utils';
|
||||
import {
|
||||
BlockSelection,
|
||||
type BlockStdScope,
|
||||
SurfaceSelection,
|
||||
} from '@blocksuite/std';
|
||||
import { LitElement } from 'lit';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
|
||||
export class EmbedIframeLinkInputBase extends WithDisposable(LitElement) {
|
||||
// this method is used to track the event when the user inputs the link
|
||||
// it should be overridden by the subclass
|
||||
protected track(status: 'success' | 'failure') {
|
||||
noop(status);
|
||||
}
|
||||
|
||||
protected isInputEmpty() {
|
||||
return this._linkInputValue.trim() === '';
|
||||
}
|
||||
|
||||
protected tryToAddBookmark(url: string) {
|
||||
if (!isValidUrl(url)) {
|
||||
this.notificationService?.notify({
|
||||
title: 'Invalid URL',
|
||||
message: 'Please enter a valid URL',
|
||||
accent: 'error',
|
||||
onClose: function (): void {},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { model } = this;
|
||||
const { parent } = model;
|
||||
const index = parent?.children.indexOf(model);
|
||||
const flavour = 'affine:bookmark';
|
||||
|
||||
this.store.transact(() => {
|
||||
const blockId = this.store.addBlock(flavour, { url }, parent, index);
|
||||
this.store.deleteBlock(model);
|
||||
if (this.inSurface) {
|
||||
this.std.selection.setGroup('gfx', [
|
||||
this.std.selection.create(
|
||||
SurfaceSelection,
|
||||
blockId,
|
||||
[blockId],
|
||||
false
|
||||
),
|
||||
]);
|
||||
} else {
|
||||
this.std.selection.setGroup('note', [
|
||||
this.std.selection.create(BlockSelection, { blockId }),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
this.abortController?.abort();
|
||||
}
|
||||
|
||||
protected async onConfirm() {
|
||||
if (this.isInputEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const embedIframeService = this.std.get(EmbedIframeService);
|
||||
if (!embedIframeService) {
|
||||
console.error('iframe EmbedIframeService not found');
|
||||
this.track('failure');
|
||||
return;
|
||||
}
|
||||
|
||||
const url = this._linkInputValue;
|
||||
const canEmbed = embedIframeService.canEmbed(url);
|
||||
|
||||
if (!canEmbed) {
|
||||
console.log('iframe can not be embedded, add as a bookmark', url);
|
||||
this.tryToAddBookmark(url);
|
||||
return;
|
||||
}
|
||||
|
||||
this.store.updateBlock(this.model, {
|
||||
url: this._linkInputValue,
|
||||
iframeUrl: '',
|
||||
title: '',
|
||||
description: '',
|
||||
});
|
||||
this.track('success');
|
||||
} catch (error) {
|
||||
this.track('failure');
|
||||
this.notificationService?.notify({
|
||||
title: 'Error in embed iframe creation',
|
||||
message: error instanceof Error ? error.message : 'Please try again',
|
||||
accent: 'error',
|
||||
onClose: function (): void {},
|
||||
});
|
||||
} finally {
|
||||
this.abortController?.abort();
|
||||
}
|
||||
}
|
||||
|
||||
protected handleInput = (e: InputEvent) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
this._linkInputValue = target.value;
|
||||
};
|
||||
|
||||
protected handleKeyDown = async (e: KeyboardEvent) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === 'Enter' && !e.isComposing) {
|
||||
await this.onConfirm();
|
||||
}
|
||||
};
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.updateComplete
|
||||
.then(() => {
|
||||
requestAnimationFrame(() => {
|
||||
this.input.focus();
|
||||
});
|
||||
})
|
||||
.catch(console.error);
|
||||
this.disposables.addFromEvent(this, 'cut', stopPropagation);
|
||||
this.disposables.addFromEvent(this, 'copy', stopPropagation);
|
||||
this.disposables.addFromEvent(this, 'paste', stopPropagation);
|
||||
this.disposables.addFromEvent(this, 'pointerdown', stopPropagation);
|
||||
}
|
||||
|
||||
get store() {
|
||||
return this.model.store;
|
||||
}
|
||||
|
||||
get notificationService() {
|
||||
return this.std.getOptional(NotificationProvider);
|
||||
}
|
||||
|
||||
@state()
|
||||
protected accessor _linkInputValue = '';
|
||||
|
||||
@query('input')
|
||||
accessor input!: HTMLInputElement;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor model!: EmbedIframeBlockModel;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor std!: BlockStdScope;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor abortController: AbortController | undefined = undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor inSurface = false;
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import {
|
||||
DocModeProvider,
|
||||
TelemetryProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
import { CloseIcon } from '@blocksuite/icons/lit';
|
||||
import { baseTheme } from '@toeverything/theme';
|
||||
import { css, html, nothing, unsafeCSS } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
|
||||
import { EmbedIframeLinkInputBase } from './embed-iframe-link-input-base';
|
||||
|
||||
type EmbedLinkInputPopupVariant = 'default' | 'mobile';
|
||||
|
||||
export type EmbedLinkInputPopupOptions = {
|
||||
showCloseButton?: boolean;
|
||||
variant?: EmbedLinkInputPopupVariant;
|
||||
title?: string;
|
||||
description?: string;
|
||||
placeholder?: string;
|
||||
telemetrySegment?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_OPTIONS: EmbedLinkInputPopupOptions = {
|
||||
showCloseButton: false,
|
||||
variant: 'default',
|
||||
title: 'Embed Link',
|
||||
description: 'Works with links of Google Drive, Spotify…',
|
||||
placeholder: 'Paste the Embed link...',
|
||||
telemetrySegment: 'editor',
|
||||
};
|
||||
|
||||
export class EmbedIframeLinkInputPopup extends EmbedIframeLinkInputBase {
|
||||
static override styles = css`
|
||||
.link-input-popup-main-wrapper {
|
||||
box-sizing: border-box;
|
||||
width: 340px;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: ${unsafeCSSVarV2('layer/background/overlayPanel')};
|
||||
box-shadow: ${unsafeCSSVar('overlayPanelShadow')};
|
||||
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
|
||||
}
|
||||
|
||||
.link-input-popup-content-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.popup-close-button {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
color: var(--affine-icon-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.popup-close-button:hover {
|
||||
background-color: var(--affine-hover-color);
|
||||
}
|
||||
|
||||
.title {
|
||||
/* Client/h6 */
|
||||
font-size: var(--affine-font-base);
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
color: ${unsafeCSSVarV2('text/primary')};
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-top: 4px;
|
||||
font-feature-settings:
|
||||
'liga' off,
|
||||
'clig' off;
|
||||
font-size: var(--affine-font-sm);
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 22px;
|
||||
color: ${unsafeCSSVarV2('text/secondary')};
|
||||
}
|
||||
|
||||
.input-container {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
box-sizing: border-box;
|
||||
|
||||
.link-input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
padding: 4px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
|
||||
background: ${unsafeCSSVarV2('input/background')};
|
||||
}
|
||||
|
||||
.link-input:focus {
|
||||
border-color: var(--affine-blue-700);
|
||||
box-shadow: var(--affine-active-shadow);
|
||||
outline: none;
|
||||
}
|
||||
.link-input::placeholder {
|
||||
color: ${unsafeCSSVarV2('text/placeholder')};
|
||||
}
|
||||
}
|
||||
|
||||
.button-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 12px;
|
||||
|
||||
.confirm-button {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 8px;
|
||||
background: ${unsafeCSSVarV2('button/primary')};
|
||||
border: 1px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
|
||||
|
||||
color: ${unsafeCSSVarV2('button/pureWhiteText')};
|
||||
/* Client/xsMedium */
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.confirm-button[disabled] {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.link-input-popup-main-wrapper.mobile {
|
||||
width: 360px;
|
||||
border-radius: 22px;
|
||||
padding: 12px 0;
|
||||
|
||||
.popup-close-button {
|
||||
top: 20px;
|
||||
right: 16px;
|
||||
}
|
||||
|
||||
.link-input-popup-content-wrapper {
|
||||
gap: 0;
|
||||
|
||||
.title {
|
||||
padding: 10px 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.input-container {
|
||||
padding: 4px 12px;
|
||||
}
|
||||
|
||||
.link-input {
|
||||
padding: 11px 10px;
|
||||
font-size: 17px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.43px;
|
||||
}
|
||||
|
||||
.title,
|
||||
.description {
|
||||
font-size: 17px;
|
||||
font-style: normal;
|
||||
line-height: 22px; /* 129.412% */
|
||||
letter-spacing: -0.43px;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-weight: 400;
|
||||
text-align: left;
|
||||
order: 2;
|
||||
padding: 11px 16px;
|
||||
color: ${unsafeCSSVarV2('text/secondary')};
|
||||
}
|
||||
|
||||
.input-container {
|
||||
order: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.description,
|
||||
.input-container,
|
||||
.button-container {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.button-container {
|
||||
padding: 4px 16px;
|
||||
|
||||
.confirm-button {
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
font-size: 17px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.43px;
|
||||
}
|
||||
|
||||
.confirm-button[disabled] {
|
||||
opacity: 1;
|
||||
background: ${unsafeCSSVarV2('button/disable')};
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
private readonly _onClose = () => {
|
||||
this.abortController?.abort();
|
||||
};
|
||||
|
||||
protected override track(status: 'success' | 'failure') {
|
||||
this.telemetryService?.track('CreateEmbedBlock', {
|
||||
type: 'embed iframe block',
|
||||
page: this.editorMode === 'page' ? 'doc editor' : 'whiteboard editor',
|
||||
segment: this.options?.telemetrySegment ?? 'editor',
|
||||
module: 'embed block',
|
||||
control: 'confirm embed link',
|
||||
result: status,
|
||||
});
|
||||
}
|
||||
|
||||
override render() {
|
||||
const options = { ...DEFAULT_OPTIONS, ...this.options };
|
||||
const { showCloseButton, variant, title, description, placeholder } =
|
||||
options;
|
||||
|
||||
const modalMainWrapperClass = classMap({
|
||||
'link-input-popup-main-wrapper': true,
|
||||
mobile: variant === 'mobile',
|
||||
});
|
||||
|
||||
return html`
|
||||
<div class=${modalMainWrapperClass}>
|
||||
${
|
||||
showCloseButton
|
||||
? html`
|
||||
<div class="popup-close-button" @click=${this._onClose}>
|
||||
${CloseIcon({ width: '20', height: '20' })}
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<div class="link-input-popup-content-wrapper">
|
||||
<div class="title">${title}</div>
|
||||
<div class="description">${description}</div>
|
||||
<div class="input-container">
|
||||
<input
|
||||
class="link-input"
|
||||
type="text"
|
||||
placeholder=${ifDefined(placeholder)}
|
||||
@input=${this.handleInput}
|
||||
@keydown=${this.handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="button-container">
|
||||
<div
|
||||
class="confirm-button"
|
||||
@click=${this.onConfirm}
|
||||
?disabled=${this.isInputEmpty()}
|
||||
>
|
||||
Confirm
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
get telemetryService() {
|
||||
return this.std.getOptional(TelemetryProvider);
|
||||
}
|
||||
|
||||
get editorMode() {
|
||||
const docModeService = this.std.get(DocModeProvider);
|
||||
const mode = docModeService.getEditorMode();
|
||||
return mode ?? 'page';
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor options: EmbedLinkInputPopupOptions | undefined = undefined;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { LoadingIcon } from '@blocksuite/affine-components/icons';
|
||||
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
import { EmbedIcon } from '@blocksuite/icons/lit';
|
||||
import { type BlockStdScope } from '@blocksuite/std';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { LOADING_CARD_DEFAULT_HEIGHT } from '../consts';
|
||||
import type { EmbedIframeStatusCardOptions } from '../types';
|
||||
|
||||
export class EmbedIframeLoadingCard extends LitElement {
|
||||
static override styles = css`
|
||||
:host {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.affine-embed-iframe-loading-card {
|
||||
container: affine-embed-iframe-loading-card / size;
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
border-radius: 8px;
|
||||
user-select: none;
|
||||
padding: 12px;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
|
||||
background: ${unsafeCSSVarV2('layer/white')};
|
||||
|
||||
.loading-content {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-self: stretch;
|
||||
|
||||
.loading-spinner {
|
||||
display: flex;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 1;
|
||||
overflow: hidden;
|
||||
color: ${unsafeCSSVarV2('text/primary')};
|
||||
text-overflow: ellipsis;
|
||||
/* Client/smMedium */
|
||||
font-family: Inter;
|
||||
font-size: var(--affine-font-sm);
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 22px; /* 157.143% */
|
||||
}
|
||||
}
|
||||
|
||||
.loading-banner {
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
.icon-box {
|
||||
display: flex;
|
||||
transform: rotate(8deg);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
border-radius: 4px 4px 0px 0px;
|
||||
background: ${unsafeCSSVarV2('slashMenu/background')};
|
||||
box-shadow: 0px 0px 5px 0px rgba(66, 65, 73, 0.17);
|
||||
|
||||
svg {
|
||||
fill: black;
|
||||
fill-opacity: 0.07;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@container affine-embed-iframe-loading-card (width < 360px) {
|
||||
.loading-banner {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.affine-embed-iframe-loading-card.horizontal {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
|
||||
.loading-content {
|
||||
flex: 1 0 0;
|
||||
align-items: flex-start;
|
||||
|
||||
.loading-text {
|
||||
flex: 1 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-banner {
|
||||
width: 204px;
|
||||
padding: 3.139px 42.14px 0px 42.14px;
|
||||
|
||||
.icon-box {
|
||||
width: 106px;
|
||||
height: 106px;
|
||||
|
||||
svg {
|
||||
width: 66px;
|
||||
height: 66px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.affine-embed-iframe-loading-card.vertical {
|
||||
flex-direction: column-reverse;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.loading-content {
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
transform: translateX(-2%);
|
||||
}
|
||||
|
||||
.loading-banner {
|
||||
width: 340px;
|
||||
padding: 5.23px 70.234px 0px 70.232px;
|
||||
overflow-y: hidden;
|
||||
|
||||
.icon-box {
|
||||
width: 176px;
|
||||
height: 176px;
|
||||
transform: rotate(8deg) translateY(15%);
|
||||
|
||||
svg {
|
||||
width: 112px;
|
||||
height: 112px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@container affine-embed-iframe-loading-card (height < 240px) {
|
||||
.loading-banner {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
override render() {
|
||||
const { layout, width, height } = this.options;
|
||||
const cardClasses = classMap({
|
||||
'affine-embed-iframe-loading-card': true,
|
||||
horizontal: layout === 'horizontal',
|
||||
vertical: layout === 'vertical',
|
||||
});
|
||||
|
||||
const cardWidth = width ? `${width}px` : '100%';
|
||||
const cardHeight = height ? `${height}px` : '100%';
|
||||
const cardStyle = styleMap({
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
});
|
||||
|
||||
return html`
|
||||
<div class=${cardClasses} style=${cardStyle}>
|
||||
<div class="loading-content">
|
||||
<div class="loading-spinner">${LoadingIcon()}</div>
|
||||
<div class="loading-text">Loading...</div>
|
||||
</div>
|
||||
<div class="loading-banner">
|
||||
<div class="icon-box">${EmbedIcon()}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor std!: BlockStdScope;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor options: EmbedIframeStatusCardOptions = {
|
||||
layout: 'horizontal',
|
||||
height: LOADING_CARD_DEFAULT_HEIGHT,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './providers';
|
||||
export * from './toolbar';
|
||||
@@ -0,0 +1,97 @@
|
||||
import { EmbedIframeConfigExtension } from '@blocksuite/affine-shared/services';
|
||||
|
||||
import {
|
||||
type EmbedIframeUrlValidationOptions,
|
||||
validateEmbedIframeUrl,
|
||||
} from '../../utils';
|
||||
|
||||
const BILIBILI_DEFAULT_WIDTH_IN_SURFACE = 800;
|
||||
const BILIBILI_DEFAULT_HEIGHT_IN_SURFACE = 450;
|
||||
const BILIBILI_DEFAULT_HEIGHT_IN_NOTE = 450;
|
||||
const BILIBILI_DEFAULT_WIDTH_PERCENT = 100;
|
||||
|
||||
const bilibiliValidationOptions: EmbedIframeUrlValidationOptions = {
|
||||
protocols: ['https:'],
|
||||
hostnames: ['player.bilibili.com', 'www.bilibili.com', 'bilibili.com'],
|
||||
};
|
||||
|
||||
const biliPlayerValidationOptions: EmbedIframeUrlValidationOptions = {
|
||||
protocols: ['https:'],
|
||||
hostnames: ['player.bilibili.com'],
|
||||
};
|
||||
|
||||
const AV_REGEX = /av([0-9]+)/i;
|
||||
const BV_REGEX = /(BV[0-9A-Za-z]{10})/;
|
||||
|
||||
const extractAvid = (url: string) => {
|
||||
const match = url.match(AV_REGEX);
|
||||
return match ? match[1] : undefined;
|
||||
};
|
||||
|
||||
const extractBvid = (url: string) => {
|
||||
const match = url.match(BV_REGEX);
|
||||
return match ? match[1] : undefined;
|
||||
};
|
||||
|
||||
const buildBiliPlayerEmbedUrl = (url: string) => {
|
||||
// If the user pasted the embed URL directly, keep it
|
||||
if (isValidBiliPlayerUrl(url)) {
|
||||
return url;
|
||||
}
|
||||
const avid = extractAvid(url);
|
||||
if (avid) {
|
||||
const params = new URLSearchParams({
|
||||
aid: avid,
|
||||
autoplay: '0',
|
||||
});
|
||||
return `https://player.bilibili.com/player.html?${params.toString()}`;
|
||||
}
|
||||
const bvid = extractBvid(url);
|
||||
if (bvid) {
|
||||
const params = new URLSearchParams({
|
||||
bvid,
|
||||
autoplay: '0',
|
||||
});
|
||||
return `https://player.bilibili.com/player.html?${params.toString()}`;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
function isValidBiliPlayerUrl(url: string) {
|
||||
try {
|
||||
if (!validateEmbedIframeUrl(url, biliPlayerValidationOptions)) {
|
||||
return false;
|
||||
}
|
||||
const parsedUrl = new URL(url);
|
||||
return (
|
||||
parsedUrl.pathname === '/player.html' &&
|
||||
(!!parsedUrl.searchParams.get('aid') ||
|
||||
!!parsedUrl.searchParams.get('bvid'))
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const bilibiliConfig = {
|
||||
name: 'bilibili',
|
||||
match: (url: string) =>
|
||||
isValidBiliPlayerUrl(url) ||
|
||||
(validateEmbedIframeUrl(url, bilibiliValidationOptions) &&
|
||||
(!!extractAvid(url) || !!extractBvid(url))),
|
||||
buildOEmbedUrl: buildBiliPlayerEmbedUrl,
|
||||
useOEmbedUrlDirectly: true,
|
||||
validateIframeUrl: (iframeUrl: string) => isValidBiliPlayerUrl(iframeUrl),
|
||||
options: {
|
||||
widthInSurface: BILIBILI_DEFAULT_WIDTH_IN_SURFACE,
|
||||
heightInSurface: BILIBILI_DEFAULT_HEIGHT_IN_SURFACE,
|
||||
heightInNote: BILIBILI_DEFAULT_HEIGHT_IN_NOTE,
|
||||
widthPercent: BILIBILI_DEFAULT_WIDTH_PERCENT,
|
||||
allow: 'clipboard-write; encrypted-media; picture-in-picture',
|
||||
sandbox: 'allow-same-origin allow-scripts',
|
||||
style: 'border: none; border-radius: 8px;',
|
||||
allowFullscreen: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const BilibiliEmbedConfig = EmbedIframeConfigExtension(bilibiliConfig);
|
||||
@@ -0,0 +1,44 @@
|
||||
import { EmbedIframeConfigExtension } from '@blocksuite/affine-shared/services';
|
||||
|
||||
import {
|
||||
type EmbedIframeUrlValidationOptions,
|
||||
validateEmbedIframeUrl,
|
||||
} from '../../utils';
|
||||
|
||||
const EXCALIDRAW_DEFAULT_WIDTH_IN_SURFACE = 640;
|
||||
const EXCALIDRAW_DEFAULT_HEIGHT_IN_SURFACE = 480;
|
||||
const EXCALIDRAW_DEFAULT_HEIGHT_IN_NOTE = 480;
|
||||
const EXCALIDRAW_DEFAULT_WIDTH_PERCENT = 100;
|
||||
|
||||
const excalidrawUrlValidationOptions: EmbedIframeUrlValidationOptions = {
|
||||
protocols: ['https:'],
|
||||
hostnames: ['excalidraw.com'],
|
||||
};
|
||||
|
||||
export const excalidrawConfig = {
|
||||
name: 'excalidraw',
|
||||
match: (url: string) =>
|
||||
validateEmbedIframeUrl(url, excalidrawUrlValidationOptions),
|
||||
buildOEmbedUrl: (url: string) => {
|
||||
const match = validateEmbedIframeUrl(url, excalidrawUrlValidationOptions);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
return url;
|
||||
},
|
||||
useOEmbedUrlDirectly: true,
|
||||
validateIframeUrl: (iframeUrl: string) =>
|
||||
validateEmbedIframeUrl(iframeUrl, excalidrawUrlValidationOptions),
|
||||
options: {
|
||||
widthInSurface: EXCALIDRAW_DEFAULT_WIDTH_IN_SURFACE,
|
||||
heightInSurface: EXCALIDRAW_DEFAULT_HEIGHT_IN_SURFACE,
|
||||
heightInNote: EXCALIDRAW_DEFAULT_HEIGHT_IN_NOTE,
|
||||
widthPercent: EXCALIDRAW_DEFAULT_WIDTH_PERCENT,
|
||||
allow: 'clipboard-read; clipboard-write',
|
||||
style: 'border: none; border-radius: 8px;',
|
||||
allowFullscreen: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const ExcalidrawEmbedConfig =
|
||||
EmbedIframeConfigExtension(excalidrawConfig);
|
||||
@@ -0,0 +1,90 @@
|
||||
import { EmbedIframeConfigExtension } from '@blocksuite/affine-shared/services';
|
||||
|
||||
import {
|
||||
type EmbedIframeUrlValidationOptions,
|
||||
validateEmbedIframeUrl,
|
||||
} from '../../utils';
|
||||
|
||||
const GENERIC_DEFAULT_WIDTH_IN_SURFACE = 800;
|
||||
const GENERIC_DEFAULT_HEIGHT_IN_SURFACE = 600;
|
||||
const GENERIC_DEFAULT_WIDTH_PERCENT = 100;
|
||||
const GENERIC_DEFAULT_HEIGHT_IN_NOTE = 400;
|
||||
|
||||
/**
|
||||
* AFFiNE domains that should be excluded from generic embedding
|
||||
* These are based on the centralized cloud constants and known AFFiNE domains
|
||||
*/
|
||||
const AFFINE_DOMAINS = [
|
||||
'app.affine.pro', // Stable cloud domain
|
||||
'insider.affine.pro', // Beta/internal cloud domain
|
||||
'affine.fail', // Canary cloud domain
|
||||
'toeverything.app', // Safety measure for potential future use
|
||||
'apple.getaffineapp.com', // Cloud domain for Apple app
|
||||
];
|
||||
|
||||
const genericUrlValidationOptions: EmbedIframeUrlValidationOptions = {
|
||||
protocols: ['https:'],
|
||||
hostnames: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates if a URL is suitable for generic iframe embedding
|
||||
* Allows HTTPS URLs but excludes AFFiNE domains
|
||||
* @param url The URL to validate
|
||||
* @returns Boolean indicating if the URL can be generically embedded
|
||||
*/
|
||||
function isValidGenericEmbedUrl(url: string): boolean {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
|
||||
if (
|
||||
!validateEmbedIframeUrl(url, {
|
||||
...genericUrlValidationOptions,
|
||||
hostnames: [parsedUrl.hostname],
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exclude AFFiNE domains
|
||||
const hostname = parsedUrl.hostname.toLowerCase();
|
||||
if (
|
||||
AFFINE_DOMAINS.some(
|
||||
domain => hostname === domain || hostname.endsWith(`.${domain}`)
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
// Invalid URL
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const genericConfig = {
|
||||
name: 'generic',
|
||||
match: (url: string) => isValidGenericEmbedUrl(url),
|
||||
buildOEmbedUrl: (url: string) => {
|
||||
if (!isValidGenericEmbedUrl(url)) {
|
||||
return undefined;
|
||||
}
|
||||
return url;
|
||||
},
|
||||
useOEmbedUrlDirectly: true,
|
||||
validateIframeUrl: (iframeUrl: string) => isValidGenericEmbedUrl(iframeUrl),
|
||||
options: {
|
||||
widthInSurface: GENERIC_DEFAULT_WIDTH_IN_SURFACE,
|
||||
heightInSurface: GENERIC_DEFAULT_HEIGHT_IN_SURFACE,
|
||||
widthPercent: GENERIC_DEFAULT_WIDTH_PERCENT,
|
||||
heightInNote: GENERIC_DEFAULT_HEIGHT_IN_NOTE,
|
||||
allowFullscreen: true,
|
||||
style: 'border: none; border-radius: 8px;',
|
||||
allow: '',
|
||||
referrerpolicy: 'no-referrer-when-downgrade',
|
||||
sandbox: 'allow-scripts',
|
||||
},
|
||||
};
|
||||
|
||||
export const GenericEmbedConfig = EmbedIframeConfigExtension(genericConfig);
|
||||
@@ -0,0 +1,82 @@
|
||||
import { EmbedIframeConfigExtension } from '@blocksuite/affine-shared/services';
|
||||
|
||||
import {
|
||||
type EmbedIframeUrlValidationOptions,
|
||||
validateEmbedIframeUrl,
|
||||
} from '../../utils';
|
||||
|
||||
const GOOGLE_DOCS_DEFAULT_WIDTH_IN_SURFACE = 800;
|
||||
const GOOGLE_DOCS_DEFAULT_HEIGHT_IN_SURFACE = 600;
|
||||
const GOOGLE_DOCS_DEFAULT_WIDTH_PERCENT = 100;
|
||||
const GOOGLE_DOCS_DEFAULT_HEIGHT_IN_NOTE = 600;
|
||||
|
||||
const googleDocsUrlValidationOptions: EmbedIframeUrlValidationOptions = {
|
||||
protocols: ['https:'],
|
||||
hostnames: ['docs.google.com'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the URL has a valid sharing parameter
|
||||
* @param parsedUrl Parsed URL object
|
||||
* @returns Boolean indicating if the URL has a valid sharing parameter
|
||||
*/
|
||||
function hasValidSharingParam(parsedUrl: URL): boolean {
|
||||
const usp = parsedUrl.searchParams.get('usp');
|
||||
return usp === 'sharing';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if a URL is a valid Google Docs URL
|
||||
* Valid format: https://docs.google.com/document/d/doc-id/edit?usp=sharing
|
||||
* @param url The URL to validate
|
||||
* @param strictMode Whether to strictly validate sharing parameters
|
||||
* @returns Boolean indicating if the URL is a valid Google Docs URL
|
||||
*/
|
||||
function isValidGoogleDocsUrl(url: string, strictMode = true): boolean {
|
||||
try {
|
||||
if (!validateEmbedIframeUrl(url, googleDocsUrlValidationOptions)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parsedUrl = new URL(url);
|
||||
|
||||
if (strictMode && !hasValidSharingParam(parsedUrl)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pathSegments = parsedUrl.pathname.split('/').filter(Boolean);
|
||||
return (
|
||||
pathSegments[0] === 'document' &&
|
||||
pathSegments[1] === 'd' &&
|
||||
pathSegments.length >= 3 &&
|
||||
!!pathSegments[2]
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('Invalid Google Docs URL:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const googleDocsConfig = {
|
||||
name: 'google-docs',
|
||||
match: (url: string) => isValidGoogleDocsUrl(url),
|
||||
buildOEmbedUrl: (url: string) => {
|
||||
if (!isValidGoogleDocsUrl(url)) {
|
||||
return undefined;
|
||||
}
|
||||
return url;
|
||||
},
|
||||
useOEmbedUrlDirectly: true,
|
||||
validateIframeUrl: (iframeUrl: string) => isValidGoogleDocsUrl(iframeUrl),
|
||||
options: {
|
||||
widthInSurface: GOOGLE_DOCS_DEFAULT_WIDTH_IN_SURFACE,
|
||||
heightInSurface: GOOGLE_DOCS_DEFAULT_HEIGHT_IN_SURFACE,
|
||||
widthPercent: GOOGLE_DOCS_DEFAULT_WIDTH_PERCENT,
|
||||
heightInNote: GOOGLE_DOCS_DEFAULT_HEIGHT_IN_NOTE,
|
||||
allowFullscreen: true,
|
||||
style: 'border: none; border-radius: 8px;',
|
||||
},
|
||||
};
|
||||
|
||||
export const GoogleDocsEmbedConfig =
|
||||
EmbedIframeConfigExtension(googleDocsConfig);
|
||||
@@ -0,0 +1,222 @@
|
||||
import { EmbedIframeConfigExtension } from '@blocksuite/affine-shared/services';
|
||||
|
||||
import {
|
||||
type EmbedIframeUrlValidationOptions,
|
||||
validateEmbedIframeUrl,
|
||||
} from '../../utils';
|
||||
|
||||
const GOOGLE_DRIVE_DEFAULT_WIDTH_IN_SURFACE = 640;
|
||||
const GOOGLE_DRIVE_DEFAULT_HEIGHT_IN_SURFACE = 480;
|
||||
const GOOGLE_DRIVE_DEFAULT_WIDTH_PERCENT = 100;
|
||||
const GOOGLE_DRIVE_DEFAULT_HEIGHT_IN_NOTE = 480;
|
||||
const GOOGLE_DRIVE_EMBED_FOLDER_URL =
|
||||
'https://drive.google.com/embeddedfolderview';
|
||||
const GOOGLE_DRIVE_EMBED_FILE_URL = 'https://drive.google.com/file/d/';
|
||||
|
||||
const googleDriveUrlValidationOptions: EmbedIframeUrlValidationOptions = {
|
||||
protocols: ['https:'],
|
||||
hostnames: ['drive.google.com'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the URL has a valid sharing parameter
|
||||
* @param parsedUrl Parsed URL object
|
||||
* @returns Boolean indicating if the URL has a valid sharing parameter
|
||||
*/
|
||||
function hasValidSharingParam(parsedUrl: URL): boolean {
|
||||
const usp = parsedUrl.searchParams.get('usp');
|
||||
return usp === 'sharing';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the url is a valid google drive file url
|
||||
* @param parsedUrl Parsed URL object
|
||||
* @returns Boolean indicating if the URL is a valid Google Drive file URL
|
||||
*/
|
||||
function isValidGoogleDriveFileUrl(parsedUrl: URL): boolean {
|
||||
const pathSegments = parsedUrl.pathname.split('/').filter(Boolean);
|
||||
return (
|
||||
pathSegments[0] === 'file' &&
|
||||
pathSegments[1] === 'd' &&
|
||||
pathSegments.length >= 3 &&
|
||||
!!pathSegments[2]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the url is a valid google drive folder url
|
||||
* @param parsedUrl Parsed URL object
|
||||
* @returns Boolean indicating if the URL is a valid Google Drive folder URL
|
||||
*/
|
||||
function isValidGoogleDriveFolderUrl(parsedUrl: URL): boolean {
|
||||
const pathSegments = parsedUrl.pathname.split('/').filter(Boolean);
|
||||
return (
|
||||
pathSegments[0] === 'drive' &&
|
||||
pathSegments[1] === 'folders' &&
|
||||
pathSegments.length >= 3 &&
|
||||
!!pathSegments[2]
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Validates if a URL is a valid Google Drive path URL
|
||||
* @param parsedUrl Parsed URL object
|
||||
* @returns Boolean indicating if the URL is valid
|
||||
*/
|
||||
function isValidGoogleDrivePathUrl(parsedUrl: URL): boolean {
|
||||
const pathSegments = parsedUrl.pathname.split('/').filter(Boolean);
|
||||
|
||||
// Should have at least 2 segments
|
||||
if (pathSegments.length < 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for file pattern: /file/d/file-id/view
|
||||
if (isValidGoogleDriveFileUrl(parsedUrl)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for folder pattern: /drive/folders/folder-id
|
||||
if (isValidGoogleDriveFolderUrl(parsedUrl)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely validates if a URL is a valid Google Drive URL
|
||||
* https://drive.google.com/file/d/your-file-id/view?usp=sharing
|
||||
* https://drive.google.com/drive/folders/your-folder-id?usp=sharing
|
||||
* @param url The URL to validate
|
||||
* @param strictMode Whether to strictly validate sharing parameters
|
||||
* @returns Boolean indicating if the URL is a valid Google Drive URL
|
||||
*/
|
||||
function isValidGoogleDriveUrl(url: string, strictMode = true): boolean {
|
||||
try {
|
||||
if (!validateEmbedIframeUrl(url, googleDriveUrlValidationOptions)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parsedUrl = new URL(url);
|
||||
|
||||
// Check sharing parameter if in strict mode
|
||||
if (strictMode && !hasValidSharingParam(parsedUrl)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check hostname and path structure
|
||||
return isValidGoogleDrivePathUrl(parsedUrl);
|
||||
} catch (e) {
|
||||
// URL parsing failed
|
||||
console.warn('Invalid Google Drive URL:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isValidGoogleDriveIframeUrl(url: string): boolean {
|
||||
try {
|
||||
if (!validateEmbedIframeUrl(url, googleDriveUrlValidationOptions)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parsedUrl = new URL(url);
|
||||
const pathSegments = parsedUrl.pathname.split('/').filter(Boolean);
|
||||
|
||||
if (isValidGoogleDriveFileUrl(parsedUrl)) {
|
||||
return pathSegments[3] === 'preview';
|
||||
}
|
||||
|
||||
return (
|
||||
parsedUrl.pathname === '/embeddedfolderview' &&
|
||||
!!parsedUrl.searchParams.get('id')
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('Invalid Google Drive iframe URL:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build embed URL for Google Drive files
|
||||
* @param fileId File ID
|
||||
* @returns Embed URL
|
||||
*/
|
||||
function buildGoogleDriveFileEmbedUrl(fileId: string): string | undefined {
|
||||
const embedUrl = new URL(
|
||||
'preview',
|
||||
`${GOOGLE_DRIVE_EMBED_FILE_URL}${fileId}/`
|
||||
);
|
||||
embedUrl.searchParams.set('usp', 'embed_googleplus');
|
||||
return embedUrl.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build embed URL for Google Drive folders
|
||||
* @param folderId Folder ID
|
||||
* @returns Embed URL
|
||||
*/
|
||||
function buildGoogleDriveFolderEmbedUrl(folderId: string): string | undefined {
|
||||
const embedUrl = new URL(GOOGLE_DRIVE_EMBED_FOLDER_URL);
|
||||
embedUrl.searchParams.set('id', folderId);
|
||||
embedUrl.hash = 'list';
|
||||
return embedUrl.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build embed URL for Google Drive paths
|
||||
* @param url The URL to embed
|
||||
* @returns The embed URL
|
||||
*/
|
||||
function buildGoogleDriveEmbedUrl(url: string): string | undefined {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
const pathSegments = parsedUrl.pathname.split('/').filter(Boolean);
|
||||
|
||||
// Should have at least 2 segments
|
||||
if (pathSegments.length < 2) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Handle file URL: /file/d/file-id/view
|
||||
if (isValidGoogleDriveFileUrl(parsedUrl)) {
|
||||
return buildGoogleDriveFileEmbedUrl(pathSegments[2]);
|
||||
}
|
||||
|
||||
// Handle folder URL: /drive/folders/folder-id
|
||||
if (isValidGoogleDriveFolderUrl(parsedUrl)) {
|
||||
return buildGoogleDriveFolderEmbedUrl(pathSegments[2]);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse Google Drive path URL:', e);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export const googleDriveConfig = {
|
||||
name: 'google-drive',
|
||||
match: (url: string) => isValidGoogleDriveUrl(url),
|
||||
buildOEmbedUrl: (url: string) => {
|
||||
if (!isValidGoogleDriveUrl(url)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// If is a valid google drive url, build the embed url
|
||||
return buildGoogleDriveEmbedUrl(url);
|
||||
},
|
||||
useOEmbedUrlDirectly: true,
|
||||
validateIframeUrl: (iframeUrl: string) =>
|
||||
isValidGoogleDriveIframeUrl(iframeUrl),
|
||||
options: {
|
||||
widthInSurface: GOOGLE_DRIVE_DEFAULT_WIDTH_IN_SURFACE,
|
||||
heightInSurface: GOOGLE_DRIVE_DEFAULT_HEIGHT_IN_SURFACE,
|
||||
widthPercent: GOOGLE_DRIVE_DEFAULT_WIDTH_PERCENT,
|
||||
heightInNote: GOOGLE_DRIVE_DEFAULT_HEIGHT_IN_NOTE,
|
||||
allowFullscreen: true,
|
||||
style: 'border: none; border-radius: 8px;',
|
||||
},
|
||||
};
|
||||
|
||||
export const GoogleDriveEmbedConfig =
|
||||
EmbedIframeConfigExtension(googleDriveConfig);
|
||||
@@ -0,0 +1,17 @@
|
||||
import { BilibiliEmbedConfig } from './bilibili';
|
||||
import { ExcalidrawEmbedConfig } from './excalidraw';
|
||||
import { GenericEmbedConfig } from './generic';
|
||||
import { GoogleDocsEmbedConfig } from './google-docs';
|
||||
import { GoogleDriveEmbedConfig } from './google-drive';
|
||||
import { MiroEmbedConfig } from './miro';
|
||||
import { SpotifyEmbedConfig } from './spotify';
|
||||
|
||||
export const EmbedIframeConfigExtensions = [
|
||||
SpotifyEmbedConfig,
|
||||
GoogleDriveEmbedConfig,
|
||||
MiroEmbedConfig,
|
||||
ExcalidrawEmbedConfig,
|
||||
GoogleDocsEmbedConfig,
|
||||
BilibiliEmbedConfig,
|
||||
GenericEmbedConfig,
|
||||
];
|
||||
@@ -0,0 +1,52 @@
|
||||
import { EmbedIframeConfigExtension } from '@blocksuite/affine-shared/services';
|
||||
|
||||
import {
|
||||
type EmbedIframeUrlValidationOptions,
|
||||
validateEmbedIframeUrl,
|
||||
} from '../../utils';
|
||||
|
||||
const MIRO_DEFAULT_WIDTH_IN_SURFACE = 640;
|
||||
const MIRO_DEFAULT_HEIGHT_IN_SURFACE = 480;
|
||||
const MIRO_DEFAULT_HEIGHT_IN_NOTE = 480;
|
||||
const MIRO_DEFAULT_WIDTH_PERCENT = 100;
|
||||
|
||||
// https://developers.miro.com/reference/getembeddata
|
||||
const miroEndpoint = 'https://miro.com/api/v1/oembed';
|
||||
|
||||
const miroUrlValidationOptions: EmbedIframeUrlValidationOptions = {
|
||||
protocols: ['https:'],
|
||||
hostnames: ['miro.com'],
|
||||
};
|
||||
|
||||
export const miroConfig = {
|
||||
name: 'miro',
|
||||
match: (url: string) => validateEmbedIframeUrl(url, miroUrlValidationOptions),
|
||||
buildOEmbedUrl: (url: string) => {
|
||||
const match = validateEmbedIframeUrl(url, miroUrlValidationOptions);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
const encodedUrl = encodeURIComponent(url);
|
||||
const oEmbedUrl = `${miroEndpoint}?url=${encodedUrl}`;
|
||||
return oEmbedUrl;
|
||||
},
|
||||
useOEmbedUrlDirectly: false,
|
||||
validateIframeUrl: (iframeUrl: string) => {
|
||||
if (!validateEmbedIframeUrl(iframeUrl, miroUrlValidationOptions)) {
|
||||
return false;
|
||||
}
|
||||
return new URL(iframeUrl).pathname.startsWith('/app/live-embed/');
|
||||
},
|
||||
options: {
|
||||
widthInSurface: MIRO_DEFAULT_WIDTH_IN_SURFACE,
|
||||
heightInSurface: MIRO_DEFAULT_HEIGHT_IN_SURFACE,
|
||||
heightInNote: MIRO_DEFAULT_HEIGHT_IN_NOTE,
|
||||
widthPercent: MIRO_DEFAULT_WIDTH_PERCENT,
|
||||
allow: 'clipboard-read; clipboard-write',
|
||||
style: 'border: none;',
|
||||
allowFullscreen: true,
|
||||
containerBorderRadius: 0,
|
||||
},
|
||||
};
|
||||
|
||||
export const MiroEmbedConfig = EmbedIframeConfigExtension(miroConfig);
|
||||
@@ -0,0 +1,59 @@
|
||||
import { EmbedIframeConfigExtension } from '@blocksuite/affine-shared/services';
|
||||
|
||||
import {
|
||||
type EmbedIframeUrlValidationOptions,
|
||||
validateEmbedIframeUrl,
|
||||
} from '../../utils';
|
||||
|
||||
const SPOTIFY_DEFAULT_WIDTH_IN_SURFACE = 640;
|
||||
const SPOTIFY_DEFAULT_HEIGHT_IN_SURFACE = 152;
|
||||
const SPOTIFY_DEFAULT_HEIGHT_IN_NOTE = 152;
|
||||
const SPOTIFY_DEFAULT_WIDTH_PERCENT = 100;
|
||||
|
||||
// https://developer.spotify.com/documentation/embeds/reference/oembed
|
||||
const spotifyEndpoint = 'https://open.spotify.com/oembed';
|
||||
|
||||
const spotifyUrlValidationOptions: EmbedIframeUrlValidationOptions = {
|
||||
protocols: ['https:'],
|
||||
hostnames: ['open.spotify.com', 'spotify.link'],
|
||||
};
|
||||
|
||||
const spotifyIframeUrlValidationOptions: EmbedIframeUrlValidationOptions = {
|
||||
protocols: ['https:'],
|
||||
hostnames: ['open.spotify.com'],
|
||||
};
|
||||
|
||||
export const spotifyConfig = {
|
||||
name: 'spotify',
|
||||
match: (url: string) =>
|
||||
validateEmbedIframeUrl(url, spotifyUrlValidationOptions),
|
||||
buildOEmbedUrl: (url: string) => {
|
||||
const match = validateEmbedIframeUrl(url, spotifyUrlValidationOptions);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
const encodedUrl = encodeURIComponent(url);
|
||||
const oEmbedUrl = `${spotifyEndpoint}?url=${encodedUrl}`;
|
||||
return oEmbedUrl;
|
||||
},
|
||||
useOEmbedUrlDirectly: false,
|
||||
validateIframeUrl: (iframeUrl: string) => {
|
||||
if (!validateEmbedIframeUrl(iframeUrl, spotifyIframeUrlValidationOptions)) {
|
||||
return false;
|
||||
}
|
||||
const parsedUrl = new URL(iframeUrl);
|
||||
return parsedUrl.pathname.split('/').find(Boolean) === 'embed';
|
||||
},
|
||||
options: {
|
||||
widthInSurface: SPOTIFY_DEFAULT_WIDTH_IN_SURFACE,
|
||||
heightInSurface: SPOTIFY_DEFAULT_HEIGHT_IN_SURFACE,
|
||||
heightInNote: SPOTIFY_DEFAULT_HEIGHT_IN_NOTE,
|
||||
widthPercent: SPOTIFY_DEFAULT_WIDTH_PERCENT,
|
||||
allow: 'autoplay; clipboard-write; encrypted-media; picture-in-picture',
|
||||
style: 'border-radius: 8px;',
|
||||
allowFullscreen: true,
|
||||
containerBorderRadius: 12,
|
||||
},
|
||||
};
|
||||
|
||||
export const SpotifyEmbedConfig = EmbedIframeConfigExtension(spotifyConfig);
|
||||
@@ -0,0 +1,37 @@
|
||||
import { getSelectedModelsCommand } from '@blocksuite/affine-shared/commands';
|
||||
import type { SlashMenuConfig } from '@blocksuite/affine-widget-slash-menu';
|
||||
import { EmbedIcon } from '@blocksuite/icons/lit';
|
||||
|
||||
import { insertEmptyEmbedIframeCommand } from '../../commands/insert-empty-embed-iframe';
|
||||
import { EmbedIframeTooltip } from './tooltip';
|
||||
|
||||
export const embedIframeSlashMenuConfig: SlashMenuConfig = {
|
||||
items: [
|
||||
{
|
||||
name: 'Embed',
|
||||
description: 'For Google Drive, and more.',
|
||||
icon: EmbedIcon(),
|
||||
tooltip: {
|
||||
figure: EmbedIframeTooltip,
|
||||
caption: 'Embed',
|
||||
},
|
||||
group: '4_Content & Media@5',
|
||||
when: ({ model }) => {
|
||||
return model.store.schema.flavourSchemaMap.has('affine:embed-iframe');
|
||||
},
|
||||
action: ({ std }) => {
|
||||
std.command
|
||||
.chain()
|
||||
.pipe(getSelectedModelsCommand)
|
||||
.pipe(insertEmptyEmbedIframeCommand, {
|
||||
place: 'after',
|
||||
removeEmptyLine: true,
|
||||
linkInputPopupOptions: {
|
||||
telemetrySegment: 'slash menu',
|
||||
},
|
||||
})
|
||||
.run();
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,492 @@
|
||||
import { reassociateConnectorsCommand } from '@blocksuite/affine-block-surface';
|
||||
import { toast } from '@blocksuite/affine-components/toast';
|
||||
import {
|
||||
BookmarkStyles,
|
||||
EmbedIframeBlockModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
EMBED_CARD_HEIGHT,
|
||||
EMBED_CARD_WIDTH,
|
||||
} from '@blocksuite/affine-shared/consts';
|
||||
import {
|
||||
ActionPlacement,
|
||||
type ToolbarAction,
|
||||
type ToolbarActionGroup,
|
||||
type ToolbarContext,
|
||||
type ToolbarModuleConfig,
|
||||
ToolbarModuleExtension,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { getBlockProps } from '@blocksuite/affine-shared/utils';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
import {
|
||||
CaptionIcon,
|
||||
CopyIcon,
|
||||
DeleteIcon,
|
||||
DuplicateIcon,
|
||||
LinkedPageIcon,
|
||||
OpenInNewIcon,
|
||||
ResetIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import { BlockFlavourIdentifier, BlockSelection } from '@blocksuite/std';
|
||||
import {
|
||||
type ExtensionType,
|
||||
Slice,
|
||||
Text,
|
||||
toDraftModel,
|
||||
} from '@blocksuite/store';
|
||||
import { computed, signal } from '@preact/signals-core';
|
||||
import { html } from 'lit';
|
||||
import { keyed } from 'lit/directives/keyed.js';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import {
|
||||
convertSelectedBlocksToLinkedDoc,
|
||||
getTitleFromSelectedModels,
|
||||
notifyDocCreated,
|
||||
promptDocTitle,
|
||||
} from '../../common/render-linked-doc';
|
||||
import { EmbedIframeBlockComponent } from '../embed-iframe-block';
|
||||
|
||||
const trackBaseProps = {
|
||||
category: 'embed iframe block',
|
||||
};
|
||||
|
||||
const showWhenUrlExists = (ctx: ToolbarContext) => {
|
||||
const model = ctx.getCurrentModelByType(EmbedIframeBlockModel);
|
||||
if (!model) return false;
|
||||
|
||||
return !!model.props.url;
|
||||
};
|
||||
|
||||
const openLinkAction = (id: string): ToolbarAction => {
|
||||
return {
|
||||
id,
|
||||
when: showWhenUrlExists,
|
||||
tooltip: 'Original',
|
||||
icon: OpenInNewIcon(),
|
||||
run(ctx) {
|
||||
const component = ctx.getCurrentBlockByType(EmbedIframeBlockComponent);
|
||||
component?.open();
|
||||
|
||||
ctx.track('OpenLink', {
|
||||
...trackBaseProps,
|
||||
control: 'open original link',
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const captionAction = (id: string): ToolbarAction => {
|
||||
return {
|
||||
id,
|
||||
when: showWhenUrlExists,
|
||||
tooltip: 'Caption',
|
||||
icon: CaptionIcon(),
|
||||
run(ctx) {
|
||||
const component = ctx.getCurrentBlockByType(EmbedIframeBlockComponent);
|
||||
component?.captionEditor?.show();
|
||||
|
||||
ctx.track('OpenedCaptionEditor', {
|
||||
...trackBaseProps,
|
||||
control: 'add caption',
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const builtinToolbarConfig = {
|
||||
actions: [
|
||||
openLinkAction('a.open-link'),
|
||||
{
|
||||
id: 'c.conversions',
|
||||
when: showWhenUrlExists,
|
||||
actions: [
|
||||
{
|
||||
id: 'inline',
|
||||
label: 'Inline view',
|
||||
run(ctx) {
|
||||
const model = ctx.getCurrentModelByType(EmbedIframeBlockModel);
|
||||
if (!model) return;
|
||||
|
||||
const { title, caption, url } = model.props;
|
||||
if (!url) return;
|
||||
|
||||
const { parent } = model;
|
||||
const index = parent?.children.indexOf(model);
|
||||
|
||||
const yText = new Y.Text();
|
||||
const insert = title || caption || url;
|
||||
yText.insert(0, insert);
|
||||
yText.format(0, insert.length, { link: url });
|
||||
|
||||
const text = new Text(yText);
|
||||
|
||||
ctx.store.addBlock('affine:paragraph', { text }, parent, index);
|
||||
|
||||
ctx.store.deleteBlock(model);
|
||||
|
||||
// Clears
|
||||
ctx.reset();
|
||||
ctx.select('note');
|
||||
|
||||
ctx.track('SelectedView', {
|
||||
...trackBaseProps,
|
||||
control: 'select view',
|
||||
type: 'inline view',
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'card',
|
||||
label: 'Card view',
|
||||
run(ctx) {
|
||||
const model = ctx.getCurrentModelByType(EmbedIframeBlockModel);
|
||||
if (!model) return;
|
||||
|
||||
const { url, caption } = model.props;
|
||||
if (!url) return;
|
||||
|
||||
const { parent } = model;
|
||||
const index = parent?.children.indexOf(model);
|
||||
|
||||
const flavour = 'affine:bookmark';
|
||||
const style =
|
||||
BookmarkStyles.find(s => s !== 'vertical' && s !== 'cube') ??
|
||||
BookmarkStyles[1];
|
||||
|
||||
const blockId = ctx.store.addBlock(
|
||||
flavour,
|
||||
{ url, caption, style },
|
||||
parent,
|
||||
index
|
||||
);
|
||||
|
||||
ctx.store.deleteBlock(model);
|
||||
|
||||
// Selects new block
|
||||
ctx.select('note', [
|
||||
ctx.selection.create(BlockSelection, { blockId }),
|
||||
]);
|
||||
|
||||
ctx.track('SelectedView', {
|
||||
...trackBaseProps,
|
||||
control: 'select view',
|
||||
type: 'card view',
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'embed',
|
||||
label: 'Embed view',
|
||||
disabled: true,
|
||||
},
|
||||
],
|
||||
content(ctx) {
|
||||
const model = ctx.getCurrentModelByType(EmbedIframeBlockModel);
|
||||
if (!model) return null;
|
||||
|
||||
const actions = this.actions.map(action => ({ ...action }));
|
||||
const toggle = (e: CustomEvent<boolean>) => {
|
||||
const opened = e.detail;
|
||||
if (!opened) return;
|
||||
|
||||
ctx.track('OpenedViewSelector', {
|
||||
...trackBaseProps,
|
||||
control: 'switch view',
|
||||
});
|
||||
};
|
||||
|
||||
return html`${keyed(
|
||||
model,
|
||||
html`<affine-view-dropdown-menu
|
||||
.actions=${actions}
|
||||
.context=${ctx}
|
||||
.toggle=${toggle}
|
||||
.viewTypeSignal=${signal(actions[2].label)}
|
||||
></affine-view-dropdown-menu>`
|
||||
)}`;
|
||||
},
|
||||
} satisfies ToolbarActionGroup<ToolbarAction>,
|
||||
captionAction('d.caption'),
|
||||
{
|
||||
id: 'e.convert-to-linked-doc',
|
||||
tooltip: 'Create Linked Doc',
|
||||
icon: LinkedPageIcon(),
|
||||
run(ctx) {
|
||||
const model = ctx.getCurrentModelByType(EmbedIframeBlockModel);
|
||||
if (!model) return;
|
||||
|
||||
const { store, std, selection, track } = ctx;
|
||||
selection.clear();
|
||||
|
||||
const draftedModels = [model].map(toDraftModel);
|
||||
const autofill = getTitleFromSelectedModels(draftedModels);
|
||||
promptDocTitle(std, autofill)
|
||||
.then(async title => {
|
||||
if (title === null) return;
|
||||
await convertSelectedBlocksToLinkedDoc(
|
||||
std,
|
||||
store,
|
||||
draftedModels,
|
||||
title
|
||||
);
|
||||
notifyDocCreated(std);
|
||||
|
||||
track('DocCreated', {
|
||||
segment: 'doc',
|
||||
page: 'doc editor',
|
||||
module: 'toolbar',
|
||||
control: 'create linked doc',
|
||||
type: 'embed-linked-doc',
|
||||
});
|
||||
|
||||
track('LinkedDocCreated', {
|
||||
segment: 'doc',
|
||||
page: 'doc editor',
|
||||
module: 'toolbar',
|
||||
control: 'create linked doc',
|
||||
type: 'embed-linked-doc',
|
||||
});
|
||||
})
|
||||
.catch(console.error);
|
||||
},
|
||||
},
|
||||
{
|
||||
placement: ActionPlacement.More,
|
||||
id: 'a.clipboard',
|
||||
actions: [
|
||||
{
|
||||
id: 'copy',
|
||||
label: 'Copy',
|
||||
icon: CopyIcon(),
|
||||
run(ctx) {
|
||||
const model = ctx.getCurrentModelByType(EmbedIframeBlockModel);
|
||||
if (!model) return;
|
||||
|
||||
const slice = Slice.fromModels(ctx.store, [model]);
|
||||
ctx.clipboard
|
||||
.copySlice(slice)
|
||||
.then(() => toast(ctx.host, 'Copied to clipboard'))
|
||||
.catch(console.error);
|
||||
|
||||
ctx.track('CopiedLink', {
|
||||
...trackBaseProps,
|
||||
control: 'copy link',
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'duplicate',
|
||||
label: 'Duplicate',
|
||||
icon: DuplicateIcon(),
|
||||
run(ctx) {
|
||||
const model = ctx.getCurrentModelByType(EmbedIframeBlockModel);
|
||||
if (!model) return;
|
||||
|
||||
const { flavour, parent } = model;
|
||||
const props = getBlockProps(model);
|
||||
const index = parent?.children.indexOf(model);
|
||||
|
||||
ctx.store.addBlock(flavour, props, parent, index);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
placement: ActionPlacement.More,
|
||||
id: 'b.reload',
|
||||
label: 'Reload',
|
||||
icon: ResetIcon(),
|
||||
run(ctx) {
|
||||
const component = ctx.getCurrentBlockByType(EmbedIframeBlockComponent);
|
||||
component
|
||||
?.refreshData()
|
||||
.then(success => {
|
||||
ctx.track('ReloadLink', {
|
||||
type: 'embed iframe block',
|
||||
page: 'doc editor',
|
||||
segment: 'doc',
|
||||
module: 'toolbar',
|
||||
control: 'reload link',
|
||||
result: success ? 'success' : 'failure',
|
||||
});
|
||||
})
|
||||
.catch(console.error);
|
||||
},
|
||||
},
|
||||
{
|
||||
placement: ActionPlacement.More,
|
||||
id: 'c.delete',
|
||||
label: 'Delete',
|
||||
icon: DeleteIcon(),
|
||||
variant: 'destructive',
|
||||
run(ctx) {
|
||||
const model = ctx.getCurrentModelByType(EmbedIframeBlockModel);
|
||||
if (!model) return;
|
||||
|
||||
ctx.store.deleteBlock(model);
|
||||
|
||||
// Clears
|
||||
ctx.select('note');
|
||||
ctx.reset();
|
||||
},
|
||||
},
|
||||
],
|
||||
} as const satisfies ToolbarModuleConfig;
|
||||
|
||||
export const builtinSurfaceToolbarConfig = {
|
||||
actions: [
|
||||
openLinkAction('a.open-link'),
|
||||
{
|
||||
id: 'c.conversions',
|
||||
when: showWhenUrlExists,
|
||||
actions: [
|
||||
{
|
||||
id: 'card',
|
||||
label: 'Card view',
|
||||
run(ctx) {
|
||||
const model = ctx.getCurrentModelByType(EmbedIframeBlockModel);
|
||||
if (!model) return;
|
||||
|
||||
const { id: oldId, xywh, parent } = model;
|
||||
const { url, caption } = model.props;
|
||||
|
||||
if (!url) return;
|
||||
|
||||
const style =
|
||||
BookmarkStyles.find(s => s !== 'vertical' && s !== 'cube') ??
|
||||
BookmarkStyles[1];
|
||||
let flavour = 'affine:bookmark';
|
||||
|
||||
const bounds = Bound.deserialize(xywh);
|
||||
bounds.w = EMBED_CARD_WIDTH[style];
|
||||
bounds.h = EMBED_CARD_HEIGHT[style];
|
||||
|
||||
const newId = ctx.store.addBlock(
|
||||
flavour,
|
||||
{ url, caption, style, xywh: bounds.serialize() },
|
||||
parent
|
||||
);
|
||||
|
||||
ctx.command.exec(reassociateConnectorsCommand, { oldId, newId });
|
||||
|
||||
ctx.store.deleteBlock(model);
|
||||
|
||||
// Selects new block
|
||||
ctx.gfx.selection.set({ editing: false, elements: [newId] });
|
||||
|
||||
ctx.track('SelectedView', {
|
||||
...trackBaseProps,
|
||||
control: 'select view',
|
||||
type: 'card view',
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'embed',
|
||||
label: 'Embed view',
|
||||
disabled: true,
|
||||
},
|
||||
],
|
||||
content(ctx) {
|
||||
const model = ctx.getCurrentModelByType(EmbedIframeBlockModel);
|
||||
if (!model) return null;
|
||||
|
||||
const actions = this.actions.map(action => ({ ...action }));
|
||||
const onToggle = (e: CustomEvent<boolean>) => {
|
||||
if (!e.detail) return;
|
||||
|
||||
ctx.track('OpenedViewSelector', {
|
||||
...trackBaseProps,
|
||||
control: 'switch view',
|
||||
});
|
||||
};
|
||||
|
||||
return html`${keyed(
|
||||
model,
|
||||
html`<affine-view-dropdown-menu
|
||||
@toggle=${onToggle}
|
||||
.actions=${actions}
|
||||
.context=${ctx}
|
||||
.viewTypeSignal=${signal(actions[1].label)}
|
||||
></affine-view-dropdown-menu>`
|
||||
)}`;
|
||||
},
|
||||
} satisfies ToolbarActionGroup<ToolbarAction>,
|
||||
captionAction('d.caption'),
|
||||
{
|
||||
id: 'e.scale',
|
||||
content(ctx) {
|
||||
const model = ctx.getCurrentModelByType(EmbedIframeBlockModel);
|
||||
if (!model) return null;
|
||||
|
||||
const scale$ = computed(() => {
|
||||
const scale = model.props.scale$.value ?? 1;
|
||||
return Math.round(100 * scale);
|
||||
});
|
||||
const onSelect = (e: CustomEvent<number>) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const scale = e.detail / 100;
|
||||
|
||||
const bounds = Bound.deserialize(model.xywh);
|
||||
const oldScale = model.props.scale ?? 1;
|
||||
const ratio = scale / oldScale;
|
||||
bounds.w *= ratio;
|
||||
bounds.h *= ratio;
|
||||
const xywh = bounds.serialize();
|
||||
|
||||
ctx.store.updateBlock(model, () => {
|
||||
model.xywh = xywh;
|
||||
model.props.scale = scale;
|
||||
});
|
||||
|
||||
ctx.track('SelectedCardScale', {
|
||||
...trackBaseProps,
|
||||
control: 'select card scale',
|
||||
});
|
||||
};
|
||||
const onToggle = (e: CustomEvent<boolean>) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const opened = e.detail;
|
||||
if (!opened) return;
|
||||
|
||||
ctx.track('OpenedCardScaleSelector', {
|
||||
...trackBaseProps,
|
||||
control: 'switch card scale',
|
||||
});
|
||||
};
|
||||
const format = (value: number) => `${value}%`;
|
||||
|
||||
return html`${keyed(
|
||||
model,
|
||||
html`<affine-size-dropdown-menu
|
||||
@select=${onSelect}
|
||||
@toggle=${onToggle}
|
||||
.format=${format}
|
||||
.sizeSignal=${scale$}
|
||||
></affine-size-dropdown-menu>`
|
||||
)}`;
|
||||
},
|
||||
},
|
||||
],
|
||||
when: ctx => ctx.getSurfaceModelsByType(EmbedIframeBlockModel).length > 0,
|
||||
} as const satisfies ToolbarModuleConfig;
|
||||
|
||||
export const createBuiltinToolbarConfigExtension = (
|
||||
flavour: string
|
||||
): ExtensionType[] => {
|
||||
const name = flavour.split(':').pop();
|
||||
|
||||
return [
|
||||
ToolbarModuleExtension({
|
||||
id: BlockFlavourIdentifier(flavour),
|
||||
config: builtinToolbarConfig,
|
||||
}),
|
||||
ToolbarModuleExtension({
|
||||
id: BlockFlavourIdentifier(`affine:surface:${name}`),
|
||||
config: builtinSurfaceToolbarConfig,
|
||||
}),
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
export const EMBED_IFRAME_DEFAULT_WIDTH_IN_SURFACE = 752;
|
||||
export const EMBED_IFRAME_DEFAULT_HEIGHT_IN_SURFACE = 116;
|
||||
export const EMBED_IFRAME_DEFAULT_CONTAINER_BORDER_RADIUS = 8;
|
||||
|
||||
export const DEFAULT_IFRAME_HEIGHT = 152;
|
||||
export const DEFAULT_IFRAME_WIDTH = '100%';
|
||||
|
||||
export const LINK_CREATE_POPUP_OFFSET = 4;
|
||||
|
||||
export const IDLE_CARD_DEFAULT_HEIGHT = 48;
|
||||
export const LOADING_CARD_DEFAULT_HEIGHT = 114;
|
||||
export const ERROR_CARD_DEFAULT_HEIGHT = 114;
|
||||
@@ -0,0 +1,37 @@
|
||||
import { EdgelessClipboardConfig } from '@blocksuite/affine-block-surface';
|
||||
import { type BlockSnapshot } from '@blocksuite/store';
|
||||
|
||||
export class EdgelessClipboardEmbedIframeConfig extends EdgelessClipboardConfig {
|
||||
static override readonly key = 'affine:embed-iframe';
|
||||
|
||||
override createBlock(embedIframe: BlockSnapshot): string | null {
|
||||
if (!this.surface) return null;
|
||||
const {
|
||||
xywh,
|
||||
caption,
|
||||
url,
|
||||
title,
|
||||
description,
|
||||
iframeUrl,
|
||||
scale,
|
||||
width,
|
||||
height,
|
||||
} = embedIframe.props;
|
||||
|
||||
return this.crud.addBlock(
|
||||
'affine:embed-iframe',
|
||||
{
|
||||
url,
|
||||
iframeUrl,
|
||||
xywh,
|
||||
caption,
|
||||
title,
|
||||
description,
|
||||
scale,
|
||||
width,
|
||||
height,
|
||||
},
|
||||
this.surface.model.id
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { EdgelessLegacySlotIdentifier } from '@blocksuite/affine-block-surface';
|
||||
import { EmbedIframeBlockSchema } from '@blocksuite/affine-model';
|
||||
import { Bound, clamp } from '@blocksuite/global/gfx';
|
||||
import { toGfxBlockComponent } from '@blocksuite/std';
|
||||
import { GfxViewInteractionExtension } from '@blocksuite/std/gfx';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
import { html } from 'lit/static-html.js';
|
||||
|
||||
import { EmbedIframeBlockComponent } from './embed-iframe-block';
|
||||
|
||||
export class EmbedEdgelessIframeBlockComponent extends toGfxBlockComponent(
|
||||
EmbedIframeBlockComponent
|
||||
) {
|
||||
override selectedStyle$ = null;
|
||||
|
||||
override blockDraggable = false;
|
||||
|
||||
override accessor blockContainerStyles = {
|
||||
margin: '0',
|
||||
backgroundColor: 'transparent',
|
||||
};
|
||||
|
||||
get edgelessSlots() {
|
||||
return this.std.get(EdgelessLegacySlotIdentifier);
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
this.edgelessSlots.elementResizeStart.subscribe(() => {
|
||||
this.isResizing$.value = true;
|
||||
});
|
||||
|
||||
this.edgelessSlots.elementResizeEnd.subscribe(() => {
|
||||
this.isResizing$.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
override renderGfxBlock() {
|
||||
const bound = Bound.deserialize(this.model.props.xywh$.value);
|
||||
const scale = this.model.props.scale$.value;
|
||||
const width = bound.w / scale;
|
||||
const height = bound.h / scale;
|
||||
const style = {
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
transformOrigin: '0 0',
|
||||
transform: `scale(${scale})`,
|
||||
};
|
||||
|
||||
return html`
|
||||
<div class="edgeless-embed-iframe-block" style=${styleMap(style)}>
|
||||
${this.renderPageContent()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export const EmbedIframeInteraction =
|
||||
GfxViewInteractionExtension<EmbedEdgelessIframeBlockComponent>(
|
||||
EmbedIframeBlockSchema.model.flavour,
|
||||
{
|
||||
resizeConstraint: {
|
||||
minWidth: 218,
|
||||
minHeight: 44,
|
||||
maxWidth: 3400,
|
||||
maxHeight: 2200,
|
||||
},
|
||||
|
||||
handleResize: context => {
|
||||
const { model } = context;
|
||||
const initialScale = model.props.scale$.peek();
|
||||
|
||||
return {
|
||||
onResizeStart(context) {
|
||||
context.default(context);
|
||||
model.stash('scale');
|
||||
},
|
||||
onResizeMove(context) {
|
||||
const { newBound, originalBound, lockRatio, constraint } = context;
|
||||
const { minWidth, maxWidth, minHeight, maxHeight } = constraint;
|
||||
|
||||
let scale = initialScale;
|
||||
const originalRealWidth = originalBound.w / scale;
|
||||
|
||||
// update scale if resize is proportional
|
||||
if (lockRatio) {
|
||||
scale = newBound.w / originalRealWidth;
|
||||
}
|
||||
|
||||
let newRealWidth = clamp(newBound.w / scale, minWidth, maxWidth);
|
||||
let newRealHeight = clamp(newBound.h / scale, minHeight, maxHeight);
|
||||
|
||||
newBound.w = newRealWidth * scale;
|
||||
newBound.h = newRealHeight * scale;
|
||||
|
||||
model.props.xywh = newBound.serialize();
|
||||
if (scale !== initialScale) {
|
||||
model.props.scale = scale;
|
||||
}
|
||||
},
|
||||
onResizeEnd(context) {
|
||||
context.default(context);
|
||||
model.pop('scale');
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
handleRotate: () => {
|
||||
return {
|
||||
beforeRotate(context) {
|
||||
context.set({
|
||||
rotatable: false,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,568 @@
|
||||
import { SurfaceBlockModel } from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
CaptionedBlockComponent,
|
||||
SelectedStyle,
|
||||
} from '@blocksuite/affine-components/caption';
|
||||
import { createLitPortal } from '@blocksuite/affine-components/portal';
|
||||
import type { EmbedIframeBlockModel } from '@blocksuite/affine-model';
|
||||
import {
|
||||
type EmbedIframeData,
|
||||
EmbedIframeService,
|
||||
type IframeOptions,
|
||||
LinkPreviewServiceIdentifier,
|
||||
NotificationProvider,
|
||||
VirtualKeyboardProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { matchModels } from '@blocksuite/affine-shared/utils';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import { BlockSelection } from '@blocksuite/std';
|
||||
import { flip, offset, shift } from '@floating-ui/dom';
|
||||
import {
|
||||
computed,
|
||||
effect,
|
||||
type ReadonlySignal,
|
||||
signal,
|
||||
} from '@preact/signals-core';
|
||||
import { html, nothing } from 'lit';
|
||||
import { query } from 'lit/decorators.js';
|
||||
import { type ClassInfo, classMap } from 'lit/directives/class-map.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import type { EmbedLinkInputPopupOptions } from './components/embed-iframe-link-input-popup.js';
|
||||
import {
|
||||
DEFAULT_IFRAME_HEIGHT,
|
||||
DEFAULT_IFRAME_WIDTH,
|
||||
EMBED_IFRAME_DEFAULT_CONTAINER_BORDER_RADIUS,
|
||||
ERROR_CARD_DEFAULT_HEIGHT,
|
||||
IDLE_CARD_DEFAULT_HEIGHT,
|
||||
LINK_CREATE_POPUP_OFFSET,
|
||||
LOADING_CARD_DEFAULT_HEIGHT,
|
||||
} from './consts.js';
|
||||
import { embedIframeBlockStyles } from './style.js';
|
||||
import type { EmbedIframeStatusCardOptions } from './types.js';
|
||||
import { safeGetIframeSrc } from './utils.js';
|
||||
|
||||
export type EmbedIframeStatus = 'idle' | 'loading' | 'success' | 'error';
|
||||
|
||||
const TRUSTED_SANDBOX =
|
||||
'allow-same-origin allow-scripts allow-forms allow-presentation';
|
||||
const UNTRUSTED_SANDBOX = 'allow-scripts';
|
||||
|
||||
export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIframeBlockModel> {
|
||||
selectedStyle$: ReadonlySignal<ClassInfo> | null = computed<ClassInfo>(
|
||||
() => ({
|
||||
'selected-style': this.selected$.value,
|
||||
})
|
||||
);
|
||||
|
||||
blockDraggable = true;
|
||||
|
||||
static override styles = embedIframeBlockStyles;
|
||||
|
||||
readonly status$ = signal<EmbedIframeStatus>('idle');
|
||||
readonly error$ = signal<Error | null>(null);
|
||||
|
||||
readonly isIdle$ = computed(() => this.status$.value === 'idle');
|
||||
readonly isLoading$ = computed(() => this.status$.value === 'loading');
|
||||
readonly hasError$ = computed(() => this.status$.value === 'error');
|
||||
readonly isSuccess$ = computed(() => this.status$.value === 'success');
|
||||
|
||||
readonly isDraggingOnHost$ = signal(false);
|
||||
readonly isResizing$ = signal(false);
|
||||
// show overlay to prevent the iframe from capturing pointer events
|
||||
// when the block is dragging, resizing, or not selected
|
||||
readonly showOverlay$ = computed(
|
||||
() =>
|
||||
this.isSuccess$.value &&
|
||||
(this.isDraggingOnHost$.value ||
|
||||
this.isResizing$.value ||
|
||||
!this.selected$.value)
|
||||
);
|
||||
|
||||
// since different providers have different border radius
|
||||
// we need to update the selected border radius when the iframe is loaded
|
||||
readonly selectedBorderRadius$ = computed(() => {
|
||||
if (
|
||||
this.status$.value === 'success' &&
|
||||
typeof this.iframeOptions?.containerBorderRadius === 'number'
|
||||
) {
|
||||
return this.iframeOptions.containerBorderRadius;
|
||||
}
|
||||
return EMBED_IFRAME_DEFAULT_CONTAINER_BORDER_RADIUS;
|
||||
});
|
||||
|
||||
protected iframeOptions: IframeOptions | undefined = undefined;
|
||||
private currentConfigName: string | undefined;
|
||||
|
||||
get embedIframeService() {
|
||||
return this.std.get(EmbedIframeService);
|
||||
}
|
||||
|
||||
get linkPreviewService() {
|
||||
return this.std.get(LinkPreviewServiceIdentifier);
|
||||
}
|
||||
|
||||
get notificationService() {
|
||||
return this.std.getOptional(NotificationProvider);
|
||||
}
|
||||
|
||||
get inSurface() {
|
||||
return matchModels(this.model.parent, [SurfaceBlockModel]);
|
||||
}
|
||||
|
||||
get _horizontalCardHeight(): number {
|
||||
switch (this.status$.value) {
|
||||
case 'idle':
|
||||
return IDLE_CARD_DEFAULT_HEIGHT;
|
||||
case 'loading':
|
||||
return LOADING_CARD_DEFAULT_HEIGHT;
|
||||
case 'error':
|
||||
return ERROR_CARD_DEFAULT_HEIGHT;
|
||||
default:
|
||||
return LOADING_CARD_DEFAULT_HEIGHT;
|
||||
}
|
||||
}
|
||||
|
||||
get _statusCardOptions(): EmbedIframeStatusCardOptions {
|
||||
return this.inSurface
|
||||
? { layout: 'vertical' }
|
||||
: { layout: 'horizontal', height: this._horizontalCardHeight };
|
||||
}
|
||||
|
||||
open = () => {
|
||||
const link = this.model.props.url;
|
||||
if (!link) {
|
||||
this.notificationService?.notify({
|
||||
title: 'No link found',
|
||||
message: 'Please set a link to the block',
|
||||
accent: 'warning',
|
||||
onClose: function (): void {},
|
||||
});
|
||||
return;
|
||||
}
|
||||
window.open(link, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
refreshData = async () => {
|
||||
try {
|
||||
const { url } = this.model.props;
|
||||
if (!url) {
|
||||
this.status$.value = 'idle';
|
||||
return false;
|
||||
}
|
||||
|
||||
// set loading status
|
||||
this.status$.value = 'loading';
|
||||
this.error$.value = null;
|
||||
|
||||
// get embed data
|
||||
const embedIframeService = this.embedIframeService;
|
||||
const linkPreviewService = this.linkPreviewService;
|
||||
if (!embedIframeService || !linkPreviewService) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.ValueNotExists,
|
||||
'EmbedIframeService or LinkPreviewService not found'
|
||||
);
|
||||
}
|
||||
|
||||
// get embed data and preview data in a promise
|
||||
const [embedData, previewData] = await Promise.all([
|
||||
embedIframeService.getEmbedIframeData(url),
|
||||
linkPreviewService.query(url),
|
||||
]);
|
||||
|
||||
// if the embed data is not found, and the iframeUrl is not set, throw an error
|
||||
const currentIframeUrl = this.model.props.iframeUrl;
|
||||
if (!embedData && !currentIframeUrl) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.ValueNotExists,
|
||||
'Failed to get embed data'
|
||||
);
|
||||
}
|
||||
|
||||
// update model
|
||||
const iframeUrl = this._getIframeUrl(embedData) ?? currentIframeUrl;
|
||||
if (!this._validateIframeUrl(url, iframeUrl)) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.ValueNotExists,
|
||||
'Invalid embed iframe url'
|
||||
);
|
||||
}
|
||||
this.store.updateBlock(this.model, {
|
||||
iframeUrl,
|
||||
title: embedData?.title || previewData?.title,
|
||||
description: embedData?.description || previewData?.description,
|
||||
});
|
||||
|
||||
// update iframe options, to ensure the iframe is rendered with the correct options
|
||||
this._updateIframeOptions(url);
|
||||
|
||||
// set success status
|
||||
this.status$.value = 'success';
|
||||
return true;
|
||||
} catch (err) {
|
||||
// set error status
|
||||
this.status$.value = 'error';
|
||||
this.error$.value = err instanceof Error ? err : new Error(String(err));
|
||||
console.error('Failed to refresh iframe data:', err);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
private _linkInputAbortController: AbortController | null = null;
|
||||
toggleLinkInputPopup = (options?: EmbedLinkInputPopupOptions) => {
|
||||
if (this.readonly) {
|
||||
return;
|
||||
}
|
||||
|
||||
// toggle create popup when ths block is in idle status and the url is not set
|
||||
if (!this._blockContainer || !this.isIdle$.value || this.model.props.url) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._linkInputAbortController) {
|
||||
this._linkInputAbortController.abort();
|
||||
}
|
||||
|
||||
const keyboard = this.host.std.getOptional(VirtualKeyboardProvider);
|
||||
const computePosition = keyboard
|
||||
? {
|
||||
referenceElement: document.body,
|
||||
placement: 'top' as const,
|
||||
middleware: [
|
||||
offset(({ rects }) => ({
|
||||
mainAxis:
|
||||
-rects.floating.height -
|
||||
(window.innerHeight -
|
||||
rects.floating.height -
|
||||
keyboard.height$.value) /
|
||||
2,
|
||||
})),
|
||||
],
|
||||
autoUpdate: { animationFrame: true },
|
||||
}
|
||||
: {
|
||||
referenceElement: this._blockContainer,
|
||||
placement: 'bottom' as const,
|
||||
middleware: [flip(), offset(LINK_CREATE_POPUP_OFFSET), shift()],
|
||||
autoUpdate: { animationFrame: true },
|
||||
};
|
||||
|
||||
this._linkInputAbortController = new AbortController();
|
||||
|
||||
const { update } = createLitPortal({
|
||||
template: html`<embed-iframe-link-input-popup
|
||||
.model=${this.model}
|
||||
.abortController=${this._linkInputAbortController}
|
||||
.std=${this.std}
|
||||
.inSurface=${this.inSurface}
|
||||
.options=${options}
|
||||
></embed-iframe-link-input-popup>`,
|
||||
container: document.body,
|
||||
computePosition,
|
||||
abortController: this._linkInputAbortController,
|
||||
closeOnClickAway: true,
|
||||
});
|
||||
|
||||
if (keyboard) {
|
||||
this._linkInputAbortController.signal.addEventListener(
|
||||
'abort',
|
||||
keyboard.height$.subscribe(() => {
|
||||
update();
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the iframe url from the embed data, first check if iframe_url is set,
|
||||
* if not, check if html is set and get the iframe src from html
|
||||
* @param embedData - The embed data
|
||||
* @returns The iframe url
|
||||
*/
|
||||
private readonly _getIframeUrl = (embedData: EmbedIframeData | null) => {
|
||||
const { iframe_url, html } = embedData ?? {};
|
||||
return iframe_url ?? (html && safeGetIframeSrc(html));
|
||||
};
|
||||
|
||||
private readonly _updateIframeOptions = (url: string) => {
|
||||
const config = this.embedIframeService?.getConfig(url);
|
||||
if (config) {
|
||||
this.iframeOptions = config.options;
|
||||
this.currentConfigName = config.name;
|
||||
} else {
|
||||
this.iframeOptions = undefined;
|
||||
this.currentConfigName = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
private readonly _validateIframeUrl = (url: string, iframeUrl?: string) => {
|
||||
if (!iframeUrl) {
|
||||
return false;
|
||||
}
|
||||
const config = this.embedIframeService?.getConfig(url);
|
||||
if (!config) {
|
||||
return false;
|
||||
}
|
||||
return config.validateIframeUrl
|
||||
? config.validateIframeUrl(iframeUrl, url)
|
||||
: config.match(iframeUrl);
|
||||
};
|
||||
|
||||
private readonly _handleDoubleClick = () => {
|
||||
this.open();
|
||||
};
|
||||
|
||||
private readonly _selectBlock = () => {
|
||||
const { selectionManager } = this;
|
||||
const blockSelection = selectionManager.create(BlockSelection, {
|
||||
blockId: this.blockId,
|
||||
});
|
||||
selectionManager.setGroup('note', [blockSelection]);
|
||||
};
|
||||
|
||||
protected _handleClick = () => {
|
||||
// when the block is in idle status and the url is not set, clear the selection
|
||||
// and show the link input popup
|
||||
if (this.isIdle$.value && !this.model.props.url) {
|
||||
// when the block is in the surface, clear the surface selection
|
||||
// otherwise, clear the block selection
|
||||
this.selectionManager.clear([this.inSurface ? 'surface' : 'block']);
|
||||
this.toggleLinkInputPopup();
|
||||
return;
|
||||
}
|
||||
|
||||
// We don't need to select the block when the block is in the surface
|
||||
if (this.inSurface) {
|
||||
return;
|
||||
}
|
||||
|
||||
// otherwise, select the block
|
||||
this._selectBlock();
|
||||
};
|
||||
|
||||
private readonly _handleRetry = async () => {
|
||||
return await this.refreshData();
|
||||
};
|
||||
|
||||
private readonly _renderIframe = () => {
|
||||
const { iframeUrl } = this.model.props;
|
||||
if (!iframeUrl || !this._isIframeUrlAllowed(iframeUrl)) {
|
||||
return html`<embed-iframe-error-card
|
||||
.error=${new Error('Invalid iframe URL')}
|
||||
.model=${this.model}
|
||||
.onRetry=${this._handleRetry}
|
||||
.std=${this.std}
|
||||
.inSurface=${this.inSurface}
|
||||
.options=${this._statusCardOptions}
|
||||
></embed-iframe-error-card>`;
|
||||
}
|
||||
const {
|
||||
widthPercent,
|
||||
heightInNote,
|
||||
style,
|
||||
allow,
|
||||
referrerpolicy,
|
||||
scrolling,
|
||||
allowFullscreen,
|
||||
sandbox,
|
||||
} = this.iframeOptions ?? {};
|
||||
const width = `${widthPercent}%`;
|
||||
// if the block is in the surface, use 100% as the height
|
||||
// otherwise, use the heightInNote
|
||||
const height = this.inSurface ? '100%' : heightInNote;
|
||||
const sandboxValue =
|
||||
sandbox ??
|
||||
(this.currentConfigName === 'generic'
|
||||
? UNTRUSTED_SANDBOX
|
||||
: TRUSTED_SANDBOX);
|
||||
const sourceHost = this._getSourceHost();
|
||||
return html`<iframe
|
||||
width=${width ?? DEFAULT_IFRAME_WIDTH}
|
||||
height=${height ?? DEFAULT_IFRAME_HEIGHT}
|
||||
?allowfullscreen=${allowFullscreen}
|
||||
loading="lazy"
|
||||
frameborder="0"
|
||||
credentialless
|
||||
sandbox=${sandboxValue}
|
||||
src=${ifDefined(iframeUrl)}
|
||||
allow=${ifDefined(allow)}
|
||||
referrerpolicy=${ifDefined(referrerpolicy)}
|
||||
scrolling=${ifDefined(scrolling)}
|
||||
style=${ifDefined(style)}
|
||||
></iframe>
|
||||
${
|
||||
sourceHost
|
||||
? html`<div class="affine-embed-iframe-source">${sourceHost}</div>`
|
||||
: nothing
|
||||
}`;
|
||||
};
|
||||
|
||||
private readonly _isIframeUrlAllowed = (iframeUrl: string) => {
|
||||
return this._validateIframeUrl(this.model.props.url, iframeUrl);
|
||||
};
|
||||
|
||||
private readonly _getSourceHost = () => {
|
||||
const url = this.model.props.url ?? this.model.props.iframeUrl;
|
||||
if (!url) return null;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.hostname;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
private readonly _renderContent = () => {
|
||||
if (this.isIdle$.value) {
|
||||
return html`<embed-iframe-idle-card
|
||||
.options=${this._statusCardOptions}
|
||||
></embed-iframe-idle-card>`;
|
||||
}
|
||||
|
||||
if (this.isLoading$.value) {
|
||||
return html`<embed-iframe-loading-card
|
||||
.std=${this.std}
|
||||
.options=${this._statusCardOptions}
|
||||
></embed-iframe-loading-card>`;
|
||||
}
|
||||
|
||||
if (this.hasError$.value) {
|
||||
return html`<embed-iframe-error-card
|
||||
.error=${this.error$.value}
|
||||
.model=${this.model}
|
||||
.onRetry=${this._handleRetry}
|
||||
.std=${this.std}
|
||||
.inSurface=${this.inSurface}
|
||||
.options=${this._statusCardOptions}
|
||||
></embed-iframe-error-card>`;
|
||||
}
|
||||
|
||||
return this._renderIframe();
|
||||
};
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
this.contentEditable = 'false';
|
||||
|
||||
// update the selected style when the block is in the note
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
if (this.inSurface) {
|
||||
return;
|
||||
}
|
||||
|
||||
// when the block is in idle status, use the background style
|
||||
// otherwise, use the border style
|
||||
if (this.status$.value === 'idle') {
|
||||
this.selectedStyle = SelectedStyle.Background;
|
||||
} else {
|
||||
this.selectedStyle = SelectedStyle.Border;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// if the iframe url is not set, refresh the data to get the iframe url
|
||||
if (!this.model.props.iframeUrl) {
|
||||
this.store.withoutTransact(() => {
|
||||
this.refreshData().catch(console.error);
|
||||
});
|
||||
} else {
|
||||
// update iframe options, to ensure the iframe is rendered with the correct options
|
||||
this._updateIframeOptions(this.model.props.url);
|
||||
this.status$.value = this._validateIframeUrl(
|
||||
this.model.props.url,
|
||||
this.model.props.iframeUrl
|
||||
)
|
||||
? 'success'
|
||||
: 'error';
|
||||
}
|
||||
|
||||
// refresh data when original url changes
|
||||
this.disposables.add(
|
||||
this.model.propsUpdated.subscribe(({ key }) => {
|
||||
if (key === 'url') {
|
||||
this.refreshData().catch(console.error);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// subscribe the editor host global dragging event
|
||||
// to show the overlay for the dragging area or other pointer events
|
||||
this.handleEvent(
|
||||
'dragStart',
|
||||
() => {
|
||||
this.isDraggingOnHost$.value = true;
|
||||
},
|
||||
{ global: true }
|
||||
);
|
||||
this.handleEvent(
|
||||
'dragEnd',
|
||||
() => {
|
||||
this.isDraggingOnHost$.value = false;
|
||||
},
|
||||
{ global: true }
|
||||
);
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._linkInputAbortController?.abort();
|
||||
this._linkInputAbortController = null;
|
||||
}
|
||||
|
||||
override renderBlock() {
|
||||
const containerClasses = classMap({
|
||||
'affine-embed-iframe-block-container': true,
|
||||
...this.selectedStyle$?.value,
|
||||
'in-surface': this.inSurface,
|
||||
});
|
||||
const containerStyles = styleMap({
|
||||
borderRadius: `${this.selectedBorderRadius$.value}px`,
|
||||
});
|
||||
|
||||
const overlayClasses = classMap({
|
||||
'affine-embed-iframe-block-overlay': true,
|
||||
show: this.showOverlay$.value,
|
||||
});
|
||||
|
||||
return html`
|
||||
<div
|
||||
draggable=${this.blockDraggable ? 'true' : 'false'}
|
||||
class=${containerClasses}
|
||||
style=${containerStyles}
|
||||
@click=${this._handleClick}
|
||||
@dblclick=${this._handleDoubleClick}
|
||||
>
|
||||
${this._renderContent()}
|
||||
|
||||
<!-- overlay to prevent the iframe from capturing pointer events -->
|
||||
<div class=${overlayClasses}></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
override accessor blockContainerStyles = {
|
||||
margin: '18px 0',
|
||||
backgroundColor: 'transparent',
|
||||
};
|
||||
|
||||
get readonly() {
|
||||
return this.store.readonly;
|
||||
}
|
||||
|
||||
get selectionManager() {
|
||||
return this.host.selection;
|
||||
}
|
||||
|
||||
override accessor useCaptionEditor = true;
|
||||
|
||||
override accessor useZeroWidth = true;
|
||||
|
||||
override accessor selectedStyle = SelectedStyle.Border;
|
||||
|
||||
@query('.affine-embed-iframe-block-container')
|
||||
accessor _blockContainer: HTMLElement | null = null;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { EmbedIframeBlockSchema } from '@blocksuite/affine-model';
|
||||
import { SlashMenuConfigExtension } from '@blocksuite/affine-widget-slash-menu';
|
||||
import { BlockViewExtension, FlavourExtension } from '@blocksuite/std';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
import { literal } from 'lit/static-html.js';
|
||||
|
||||
import { embedIframeSlashMenuConfig } from './configs/slash-menu/slash-menu';
|
||||
import { createBuiltinToolbarConfigExtension } from './configs/toolbar';
|
||||
import { EmbedIframeInteraction } from './embed-edgeless-iframe-block';
|
||||
|
||||
const flavour = EmbedIframeBlockSchema.model.flavour;
|
||||
|
||||
export const EmbedIframeViewExtensions: ExtensionType[] = [
|
||||
FlavourExtension(flavour),
|
||||
BlockViewExtension(flavour, model => {
|
||||
return model.parent?.flavour === 'affine:surface'
|
||||
? literal`affine-embed-edgeless-iframe-block`
|
||||
: literal`affine-embed-iframe-block`;
|
||||
}),
|
||||
createBuiltinToolbarConfigExtension(flavour),
|
||||
SlashMenuConfigExtension(flavour, embedIframeSlashMenuConfig),
|
||||
EmbedIframeInteraction,
|
||||
].flat();
|
||||
@@ -0,0 +1,11 @@
|
||||
export * from './adapters';
|
||||
export * from './commands';
|
||||
export * from './configs';
|
||||
export {
|
||||
EMBED_IFRAME_DEFAULT_HEIGHT_IN_SURFACE,
|
||||
EMBED_IFRAME_DEFAULT_WIDTH_IN_SURFACE,
|
||||
} from './consts';
|
||||
export * from './edgeless-clipboard-config';
|
||||
export * from './embed-iframe-block';
|
||||
export * from './embed-iframe-spec';
|
||||
export { canEmbedAsIframe } from './utils';
|
||||
@@ -0,0 +1,42 @@
|
||||
import { css } from 'lit';
|
||||
|
||||
export const embedIframeBlockStyles = css`
|
||||
.affine-embed-iframe-block-container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
user-select: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.affine-embed-iframe-block-container.in-surface {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.affine-embed-iframe-block-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.affine-embed-iframe-source {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
bottom: 8px;
|
||||
padding: 2px 6px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.affine-embed-iframe-block-overlay.show {
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* The options for the embed iframe status card
|
||||
* layout: the layout of the card, horizontal or vertical
|
||||
* width: the width of the card, if not set, the card width will be 100%
|
||||
* height: the height of the card, if not set, the card height will be 100%
|
||||
* @example
|
||||
* {
|
||||
* layout: 'horizontal',
|
||||
* height: 114,
|
||||
* }
|
||||
*/
|
||||
export type EmbedIframeStatusCardOptions = {
|
||||
layout: 'horizontal' | 'vertical';
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
103
blocksuite/affine/blocks/embed/src/embed-iframe-block/utils.ts
Normal file
103
blocksuite/affine/blocks/embed/src/embed-iframe-block/utils.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { EmbedIframeService } from '@blocksuite/affine-shared/services';
|
||||
import type { BlockStdScope } from '@blocksuite/std';
|
||||
|
||||
/**
|
||||
* The options for the embed iframe url validation
|
||||
*/
|
||||
export interface EmbedIframeUrlValidationOptions {
|
||||
protocols: string[]; // Allowed protocols, e.g. ['https']
|
||||
hostnames: string[]; // Allowed hostnames, e.g. ['docs.google.com']
|
||||
}
|
||||
|
||||
function isLocalOrIpHostname(hostname: string): boolean {
|
||||
const lower = hostname.toLowerCase();
|
||||
if (
|
||||
lower === 'localhost' ||
|
||||
lower.endsWith('.localhost') ||
|
||||
lower === '0.0.0.0' ||
|
||||
lower === '::' ||
|
||||
lower === '::1'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(lower)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return lower.startsWith('[') && lower.endsWith(']');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the url is allowed to embed in the iframe
|
||||
* @param url URL to validate
|
||||
* @param options Validation options
|
||||
* @returns Whether the url is valid
|
||||
*/
|
||||
export function validateEmbedIframeUrl(
|
||||
url: string,
|
||||
options: EmbedIframeUrlValidationOptions
|
||||
): boolean {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
|
||||
const { protocols, hostnames } = options;
|
||||
if (
|
||||
parsedUrl.username ||
|
||||
parsedUrl.password ||
|
||||
parsedUrl.port ||
|
||||
isLocalOrIpHostname(parsedUrl.hostname)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
protocols.includes(parsedUrl.protocol) &&
|
||||
hostnames.includes(parsedUrl.hostname)
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn(`Invalid embed iframe url: ${url}`, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely extracts the src URL from an iframe HTML string
|
||||
* @param htmlString The iframe HTML string to parse
|
||||
* @param options Optional validation configuration
|
||||
* @returns The validated src URL or undefined if validation fails
|
||||
*/
|
||||
export function safeGetIframeSrc(htmlString: string): string | undefined {
|
||||
try {
|
||||
// Create a DOMParser instance
|
||||
const parser = new DOMParser();
|
||||
// Parse the HTML string
|
||||
const doc = parser.parseFromString(htmlString, 'text/html');
|
||||
// Get the iframe element
|
||||
const iframe = doc.querySelector('iframe');
|
||||
if (!iframe) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Get the src attribute
|
||||
const src = iframe.getAttribute('src');
|
||||
if (!src) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return src;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the url can be embedded as an iframe
|
||||
* @param std The block std scope
|
||||
* @param url The url to check
|
||||
* @returns Whether the url can be embedded as an iframe
|
||||
*/
|
||||
export function canEmbedAsIframe(std: BlockStdScope, url: string) {
|
||||
const embedIframeService = std.get(EmbedIframeService);
|
||||
return embedIframeService.canEmbed(url);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
|
||||
import { EmbedLoomBlockHtmlAdapterExtension } from './html.js';
|
||||
import { EmbedLoomMarkdownAdapterExtension } from './markdown.js';
|
||||
import { EmbedLoomBlockNotionHtmlAdapterExtension } from './notion-html.js';
|
||||
import { EmbedLoomBlockPlainTextAdapterExtension } from './plain-text.js';
|
||||
|
||||
export const EmbedLoomBlockAdapterExtensions: ExtensionType[] = [
|
||||
EmbedLoomBlockHtmlAdapterExtension,
|
||||
EmbedLoomMarkdownAdapterExtension,
|
||||
EmbedLoomBlockPlainTextAdapterExtension,
|
||||
EmbedLoomBlockNotionHtmlAdapterExtension,
|
||||
];
|
||||
@@ -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,4 @@
|
||||
export * from './html.js';
|
||||
export * from './markdown.js';
|
||||
export * from './notion-html.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,14 @@
|
||||
import { EmbedLoomBlockSchema } from '@blocksuite/affine-model';
|
||||
import { BlockNotionHtmlAdapterExtension } from '@blocksuite/affine-shared/adapters';
|
||||
|
||||
import { createEmbedBlockNotionHtmlAdapterMatcher } from '../../common/adapters/notion-html.js';
|
||||
import { loomUrlRegex } from '../embed-loom-model.js';
|
||||
|
||||
export const embedLoomBlockNotionHtmlAdapterMatcher =
|
||||
createEmbedBlockNotionHtmlAdapterMatcher(
|
||||
EmbedLoomBlockSchema.model.flavour,
|
||||
loomUrlRegex
|
||||
);
|
||||
|
||||
export const EmbedLoomBlockNotionHtmlAdapterExtension =
|
||||
BlockNotionHtmlAdapterExtension(embedLoomBlockNotionHtmlAdapterMatcher);
|
||||
@@ -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,47 @@
|
||||
import { DefaultTool } from '@blocksuite/affine-block-surface';
|
||||
import { toggleEmbedCardCreateModal } from '@blocksuite/affine-components/embed-card-modal';
|
||||
import type { SlashMenuConfig } from '@blocksuite/affine-widget-slash-menu';
|
||||
import { LoomLogoDuotoneIcon } from '@blocksuite/icons/lit';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
|
||||
|
||||
import { LoomTooltip } from './tooltips';
|
||||
|
||||
export const embedLoomSlashMenuConfig: SlashMenuConfig = {
|
||||
items: [
|
||||
{
|
||||
name: 'Loom',
|
||||
icon: LoomLogoDuotoneIcon(),
|
||||
description: 'Embed a Loom video.',
|
||||
tooltip: {
|
||||
figure: LoomTooltip,
|
||||
caption: 'loom',
|
||||
},
|
||||
group: '4_Content & Media@9',
|
||||
when: ({ model }) =>
|
||||
model.store.schema.flavourSchemaMap.has('affine:embed-loom'),
|
||||
action: ({ std, model }) => {
|
||||
(async () => {
|
||||
const { host } = std;
|
||||
const parentModel = host.store.getParent(model);
|
||||
if (!parentModel) {
|
||||
return;
|
||||
}
|
||||
const index = parentModel.children.indexOf(model) + 1;
|
||||
await toggleEmbedCardCreateModal(
|
||||
host,
|
||||
'Loom',
|
||||
'The added Loom video link will be displayed as an embed view.',
|
||||
{ mode: 'page', parentModel, index },
|
||||
({ mode }) => {
|
||||
if (mode === 'edgeless') {
|
||||
const gfx = std.get(GfxControllerIdentifier);
|
||||
gfx.tool.setTool(DefaultTool);
|
||||
}
|
||||
}
|
||||
);
|
||||
if (model.text?.length === 0) std.store.deleteBlock(model);
|
||||
})().catch(console.error);
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,28 @@
|
||||
import { EdgelessClipboardConfig } from '@blocksuite/affine-block-surface';
|
||||
import { type BlockSnapshot } from '@blocksuite/store';
|
||||
|
||||
export class EdgelessClipboardEmbedLoomConfig extends EdgelessClipboardConfig {
|
||||
static override readonly key = 'affine:embed-loom';
|
||||
|
||||
override createBlock(loomEmbed: BlockSnapshot): string | null {
|
||||
if (!this.surface) return null;
|
||||
const { xywh, style, url, caption, videoId, image, title, description } =
|
||||
loomEmbed.props;
|
||||
|
||||
const embedLoomId = this.crud.addBlock(
|
||||
'affine:embed-loom',
|
||||
{
|
||||
xywh,
|
||||
style,
|
||||
url,
|
||||
caption,
|
||||
videoId,
|
||||
image,
|
||||
title,
|
||||
description,
|
||||
},
|
||||
this.surface.model.id
|
||||
);
|
||||
return embedLoomId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { EmbedLoomBlockSchema } from '@blocksuite/affine-model';
|
||||
|
||||
import { createEmbedEdgelessBlockInteraction } from '../common/embed-block-element.js';
|
||||
import { toEdgelessEmbedBlock } from '../common/to-edgeless-embed-block.js';
|
||||
import { EmbedLoomBlockComponent } from './embed-loom-block.js';
|
||||
|
||||
export class EmbedEdgelessLoomBlockComponent extends toEdgelessEmbedBlock(
|
||||
EmbedLoomBlockComponent
|
||||
) {}
|
||||
|
||||
export const EmbedLoomBlockInteraction = createEmbedEdgelessBlockInteraction(
|
||||
EmbedLoomBlockSchema.model.flavour
|
||||
);
|
||||
@@ -0,0 +1,174 @@
|
||||
import { LoadingIcon, OpenIcon } from '@blocksuite/affine-components/icons';
|
||||
import type { EmbedLoomModel, EmbedLoomStyles } from '@blocksuite/affine-model';
|
||||
import { ImageProxyService } from '@blocksuite/affine-shared/adapters';
|
||||
import { ThemeProvider } from '@blocksuite/affine-shared/services';
|
||||
import { BlockSelection } from '@blocksuite/std';
|
||||
import { html } from 'lit';
|
||||
import { property } 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';
|
||||
|
||||
open = () => {
|
||||
let link = this.model.props.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(BlockSelection, {
|
||||
blockId: this.blockId,
|
||||
});
|
||||
selectionManager.setGroup('note', [blockSelection]);
|
||||
}
|
||||
|
||||
protected _handleClick(event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
this._selectBlock();
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._cardStyle = this.model.props.style;
|
||||
|
||||
if (!this.model.props.videoId) {
|
||||
this.store.withoutTransact(() => {
|
||||
const url = this.model.props.url;
|
||||
const urlMatch = url.match(loomUrlRegex);
|
||||
if (urlMatch) {
|
||||
const [, videoId] = urlMatch;
|
||||
this.store.updateBlock(this.model, {
|
||||
videoId,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.model.props.description && !this.model.props.title) {
|
||||
this.store.withoutTransact(() => {
|
||||
this.refreshData();
|
||||
});
|
||||
}
|
||||
|
||||
this.disposables.add(
|
||||
this.model.propsUpdated.subscribe(({ key }) => {
|
||||
this.requestUpdate();
|
||||
if (key === 'url') {
|
||||
this.refreshData();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override renderBlock() {
|
||||
const { image, title, description, videoId } = this.model.props;
|
||||
|
||||
const loading = this.loading;
|
||||
const theme = this.std.get(ThemeProvider).theme;
|
||||
const imageProxyService = this.store.get(ImageProxyService);
|
||||
const { EmbedCardBannerIcon } = getEmbedCardIcons(theme);
|
||||
const titleIcon = loading ? LoadingIcon() : LoomIcon;
|
||||
const titleText = loading ? 'Loading...' : title || 'Loom';
|
||||
const descriptionText = loading ? '' : description;
|
||||
const bannerImage =
|
||||
!loading && image
|
||||
? html`<img src=${imageProxyService.buildUrl(image)} alt="banner" />`
|
||||
: EmbedCardBannerIcon;
|
||||
|
||||
return this.renderEmbed(
|
||||
() => html`
|
||||
<div
|
||||
class=${classMap({
|
||||
'affine-embed-loom-block': true,
|
||||
loading,
|
||||
selected: this.selected$.value,
|
||||
})}
|
||||
style=${styleMap({
|
||||
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; autoplay; clipboard-write; encrypted-media; picture-in-picture; web-share"
|
||||
sandbox="allow-scripts allow-same-origin allow-presentation"
|
||||
loading="lazy"
|
||||
credentialless
|
||||
></iframe>
|
||||
|
||||
<!-- overlay to prevent the iframe from capturing pointer events -->
|
||||
<div
|
||||
class=${classMap({
|
||||
'affine-embed-loom-video-iframe-overlay': true,
|
||||
hide: !this.showOverlay$.value,
|
||||
})}
|
||||
></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>
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor loading = false;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user