refactor(editor): unify directories naming (#11516)
**Directory Structure Changes** - Renamed multiple block-related directories by removing the "block-" prefix: - `block-attachment` → `attachment` - `block-bookmark` → `bookmark` - `block-callout` → `callout` - `block-code` → `code` - `block-data-view` → `data-view` - `block-database` → `database` - `block-divider` → `divider` - `block-edgeless-text` → `edgeless-text` - `block-embed` → `embed`
This commit is contained in:
893 files changed
+439
-460
No files matched your search
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "@blocksuite/affine-block-embed",
|
||||
"description": "Embed blocks for BlockSuite.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"keywords": [],
|
||||
"author": "toeverything",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@blocksuite/affine-block-surface": "workspace:*",
|
||||
"@blocksuite/affine-components": "workspace:*",
|
||||
"@blocksuite/affine-inline-reference": "workspace:*",
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
"@blocksuite/affine-rich-text": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/affine-widget-slash-menu": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.10",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.12",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"lit": "^3.2.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"minimatch": "^10.0.1",
|
||||
"rxjs": "^7.8.1",
|
||||
"yjs": "^13.6.21",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "3.1.1"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./effects": "./src/effects.ts"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.21.0"
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import {
|
||||
CaptionedBlockComponent,
|
||||
SelectedStyle,
|
||||
} from '@blocksuite/affine-components/caption';
|
||||
import type { EmbedCardStyle } from '@blocksuite/affine-model';
|
||||
import {
|
||||
EMBED_CARD_HEIGHT,
|
||||
EMBED_CARD_MIN_WIDTH,
|
||||
EMBED_CARD_WIDTH,
|
||||
} from '@blocksuite/affine-shared/consts';
|
||||
import { DocModeProvider } from '@blocksuite/affine-shared/services';
|
||||
import { findAncestorModel } from '@blocksuite/affine-shared/utils';
|
||||
import type { BlockService } from '@blocksuite/std';
|
||||
import type { GfxCompatibleProps } 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<GfxCompatibleProps> = BlockModel<GfxCompatibleProps>,
|
||||
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';
|
||||
|
||||
/**
|
||||
* The actual rendered scale of the embed card.
|
||||
* By default, it is set to 1.
|
||||
*/
|
||||
protected _scale = 1;
|
||||
|
||||
blockDraggable = true;
|
||||
|
||||
/**
|
||||
* The style of the embed card.
|
||||
* You can use this to change the height and width of the card.
|
||||
* By default, the height and width are set to `_cardHeight` and `_cardWidth` respectively.
|
||||
*/
|
||||
protected embedContainerStyle: StyleInfo = {};
|
||||
|
||||
renderEmbed = (content: () => TemplateResult) => {
|
||||
if (
|
||||
this._cardStyle === 'horizontal' ||
|
||||
this._cardStyle === 'horizontalThin' ||
|
||||
this._cardStyle === 'list'
|
||||
) {
|
||||
this.style.display = 'block';
|
||||
|
||||
const 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.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;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { css } from 'lit';
|
||||
|
||||
export const embedNoteContentStyles = css`
|
||||
.affine-embed-doc-content-note-blocks affine-divider,
|
||||
.affine-embed-doc-content-note-blocks affine-divider > * {
|
||||
margin-top: 0px !important;
|
||||
margin-bottom: 0px !important;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph,
|
||||
.affine-embed-doc-content-note-blocks affine-list {
|
||||
margin-top: 4px !important;
|
||||
margin-bottom: 4px !important;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph *,
|
||||
.affine-embed-doc-content-note-blocks affine-list * {
|
||||
margin-top: 0px !important;
|
||||
margin-bottom: 0px !important;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
line-height: 20px;
|
||||
font-size: var(--affine-font-xs);
|
||||
font-weight: 400;
|
||||
}
|
||||
.affine-embed-doc-content-note-blocks affine-list .affine-list-block__prefix {
|
||||
height: 20px;
|
||||
}
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph .quote {
|
||||
padding-left: 15px;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph:has(.h1),
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph:has(.h2),
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph:has(.h3),
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph:has(.h4),
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph:has(.h5),
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph:has(.h6) {
|
||||
margin-top: 6px !important;
|
||||
margin-bottom: 4px !important;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph:has(.h1) *,
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph:has(.h2) *,
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph:has(.h3) *,
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph:has(.h4) *,
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph:has(.h5) *,
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph:has(.h6) * {
|
||||
margin-top: 0px !important;
|
||||
margin-bottom: 0px !important;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
line-height: 20px;
|
||||
font-size: var(--affine-font-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.affine-embed-linked-doc-block.horizontal {
|
||||
affine-paragraph,
|
||||
affine-list {
|
||||
margin-top: 0 !important;
|
||||
margin-bottom: 0 !important;
|
||||
max-height: 40px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
}
|
||||
affine-paragraph .quote {
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
height: 28px;
|
||||
}
|
||||
affine-paragraph .quote::after {
|
||||
height: 20px;
|
||||
margin-top: 4px !important;
|
||||
margin-bottom: 4px !important;
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
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.doc.getParent(block.model);
|
||||
if (!parent) return;
|
||||
const index = parent.children.indexOf(block.model);
|
||||
const cardId = host.doc.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(
|
||||
// @ts-expect-error FIXME: resolve after gfx tool refactor
|
||||
'default'
|
||||
);
|
||||
gfx.selection.set({
|
||||
elements: [cardId],
|
||||
editing: false,
|
||||
});
|
||||
|
||||
return cardId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
import { getSurfaceBlock } from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
type DocMode,
|
||||
ImageBlockModel,
|
||||
ListBlockModel,
|
||||
NoteBlockModel,
|
||||
NoteDisplayMode,
|
||||
ParagraphBlockModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { EMBED_CARD_HEIGHT } from '@blocksuite/affine-shared/consts';
|
||||
import { NotificationProvider } from '@blocksuite/affine-shared/services';
|
||||
import { matchModels, SpecProvider } from '@blocksuite/affine-shared/utils';
|
||||
import { BlockStdScope, EditorLifeCycleExtension } from '@blocksuite/std';
|
||||
import {
|
||||
type BlockModel,
|
||||
type BlockSnapshot,
|
||||
type DraftModel,
|
||||
type Query,
|
||||
Slice,
|
||||
type Store,
|
||||
Text,
|
||||
} from '@blocksuite/store';
|
||||
import { render, type TemplateResult } from 'lit';
|
||||
|
||||
import type { EmbedLinkedDocBlockComponent } from '../embed-linked-doc-block/index.js';
|
||||
import type { EmbedSyncedDocCard } from '../embed-synced-doc-block/components/embed-synced-doc-card.js';
|
||||
|
||||
// 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;
|
||||
|
||||
export function renderLinkedDocInCard(
|
||||
card: EmbedLinkedDocBlockComponent | EmbedSyncedDocCard
|
||||
) {
|
||||
const linkedDoc = card.linkedDoc;
|
||||
if (!linkedDoc) {
|
||||
console.error(
|
||||
`Trying to load page ${card.model.props.pageId} in linked page block, but the page is not found.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/no-collapsible-if
|
||||
if ('bannerContainer' in card) {
|
||||
if (card.editorMode === 'page') {
|
||||
renderPageAsBanner(card).catch(e => {
|
||||
console.error(e);
|
||||
card.isError = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
renderNoteContent(card).catch(e => {
|
||||
console.error(e);
|
||||
card.isError = true;
|
||||
});
|
||||
}
|
||||
|
||||
async function renderPageAsBanner(card: EmbedSyncedDocCard) {
|
||||
const linkedDoc = card.linkedDoc;
|
||||
if (!linkedDoc) {
|
||||
console.error(
|
||||
`Trying to load page ${card.model.props.pageId} in linked page block, but the page is not found.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const notes = getNotesFromDoc(linkedDoc);
|
||||
if (!notes) {
|
||||
card.isBannerEmpty = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const target = notes.flatMap(note =>
|
||||
note.children.filter(child => matchModels(child, [ImageBlockModel]))
|
||||
)[0];
|
||||
|
||||
if (target) {
|
||||
await renderImageAsBanner(card, target);
|
||||
return;
|
||||
}
|
||||
|
||||
card.isBannerEmpty = true;
|
||||
}
|
||||
|
||||
async function renderImageAsBanner(
|
||||
card: EmbedSyncedDocCard,
|
||||
image: BlockModel
|
||||
) {
|
||||
const sourceId = (image as ImageBlockModel).props.sourceId;
|
||||
if (!sourceId) return;
|
||||
|
||||
const storage = card.linkedDoc?.blobSync;
|
||||
if (!storage) return;
|
||||
|
||||
const blob = await storage.get(sourceId);
|
||||
if (!blob) return;
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const $img = document.createElement('img');
|
||||
$img.src = url;
|
||||
await addCover(card, $img);
|
||||
|
||||
card.isBannerEmpty = false;
|
||||
}
|
||||
|
||||
async function addCover(
|
||||
card: EmbedSyncedDocCard,
|
||||
cover: HTMLElement | TemplateResult<1>
|
||||
) {
|
||||
const coverContainer = await card.bannerContainer;
|
||||
if (!coverContainer) return;
|
||||
while (coverContainer.firstChild) {
|
||||
coverContainer.firstChild.remove();
|
||||
}
|
||||
|
||||
if (cover instanceof HTMLElement) {
|
||||
coverContainer.append(cover);
|
||||
} else {
|
||||
render(cover, coverContainer);
|
||||
}
|
||||
}
|
||||
|
||||
async function renderNoteContent(
|
||||
card: EmbedLinkedDocBlockComponent | EmbedSyncedDocCard
|
||||
) {
|
||||
card.isNoteContentEmpty = true;
|
||||
|
||||
const doc = card.linkedDoc;
|
||||
if (!doc) {
|
||||
console.error(
|
||||
`Trying to load page ${card.model.props.pageId} in linked page block, but the page is not found.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const notes = getNotesFromDoc(doc);
|
||||
if (!notes) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cardStyle = card.model.props.style;
|
||||
const isHorizontal = cardStyle === 'horizontal';
|
||||
const allowFlavours = isHorizontal ? [] : [ImageBlockModel];
|
||||
|
||||
const noteChildren = notes.flatMap(note =>
|
||||
note.children.filter(model => {
|
||||
if (matchModels(model, allowFlavours)) {
|
||||
return true;
|
||||
}
|
||||
return filterTextModel(model);
|
||||
})
|
||||
);
|
||||
|
||||
if (!noteChildren.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
card.isNoteContentEmpty = false;
|
||||
|
||||
const noteContainer = await card.noteContainer;
|
||||
|
||||
if (!noteContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
while (noteContainer.firstChild) {
|
||||
noteContainer.firstChild.remove();
|
||||
}
|
||||
|
||||
const noteBlocksContainer = document.createElement('div');
|
||||
noteBlocksContainer.classList.add('affine-embed-doc-content-note-blocks');
|
||||
noteBlocksContainer.contentEditable = 'false';
|
||||
noteContainer.append(noteBlocksContainer);
|
||||
|
||||
if (isHorizontal) {
|
||||
// When the card is horizontal, we only render the first block
|
||||
noteChildren.splice(1);
|
||||
} else {
|
||||
// Before rendering, we can not know the height of each block
|
||||
// But we can limit the number of blocks to render simply by the height of the card
|
||||
const cardHeight = EMBED_CARD_HEIGHT[cardStyle];
|
||||
const minSingleBlockHeight = 20;
|
||||
const maxBlockCount = Math.floor(cardHeight / minSingleBlockHeight);
|
||||
if (noteChildren.length > maxBlockCount) {
|
||||
noteChildren.splice(maxBlockCount);
|
||||
}
|
||||
}
|
||||
const childIds = noteChildren.map(child => child.id);
|
||||
const ids: string[] = [];
|
||||
childIds.forEach(block => {
|
||||
let parent: string | null = block;
|
||||
while (parent && !ids.includes(parent)) {
|
||||
ids.push(parent);
|
||||
parent = doc.getParent(parent)?.id ?? null;
|
||||
}
|
||||
});
|
||||
const query: Query = {
|
||||
mode: 'strict',
|
||||
match: ids.map(id => ({ id, viewType: 'display' })),
|
||||
};
|
||||
const previewDoc = doc.doc.getStore({ query });
|
||||
const previewSpec = SpecProvider._.getSpec('preview:page');
|
||||
const previewStd = new BlockStdScope({
|
||||
store: previewDoc,
|
||||
extensions: previewSpec.value,
|
||||
});
|
||||
const previewTemplate = previewStd.render();
|
||||
const fragment = document.createDocumentFragment();
|
||||
render(previewTemplate, fragment);
|
||||
noteBlocksContainer.append(fragment);
|
||||
const contentEditableElements = noteBlocksContainer.querySelectorAll(
|
||||
'[contenteditable="true"]'
|
||||
);
|
||||
contentEditableElements.forEach(element => {
|
||||
(element as HTMLElement).contentEditable = 'false';
|
||||
});
|
||||
}
|
||||
|
||||
function filterTextModel(model: BlockModel) {
|
||||
if (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, doc: Store) {
|
||||
const notification = std.getOptional(NotificationProvider);
|
||||
if (!notification) return;
|
||||
|
||||
const abortController = new AbortController();
|
||||
const clear = () => {
|
||||
doc.history.off('stack-item-added', addHandler);
|
||||
doc.history.off('stack-item-popped', popHandler);
|
||||
disposable.unsubscribe();
|
||||
};
|
||||
const closeNotify = () => {
|
||||
abortController.abort();
|
||||
clear();
|
||||
};
|
||||
|
||||
// edit or undo or switch doc, close notify toast
|
||||
const addHandler = doc.history.on('stack-item-added', closeNotify);
|
||||
const popHandler = doc.history.on('stack-item-popped', closeNotify);
|
||||
const disposable = std
|
||||
.get(EditorLifeCycleExtension)
|
||||
.slots.unmounted.subscribe(closeNotify);
|
||||
|
||||
notification.notify({
|
||||
title: 'Linked doc created',
|
||||
message: 'You can click undo to recovery block content',
|
||||
accent: 'info',
|
||||
duration: 10 * 1000,
|
||||
action: {
|
||||
label: 'Undo',
|
||||
onClick: () => {
|
||||
doc.undo();
|
||||
clear();
|
||||
},
|
||||
},
|
||||
abort: abortController.signal,
|
||||
onClose: clear,
|
||||
});
|
||||
}
|
||||
|
||||
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,81 @@
|
||||
import { EdgelessLegacySlotIdentifier } from '@blocksuite/affine-block-surface';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
import {
|
||||
blockComponentSymbol,
|
||||
type BlockService,
|
||||
type GfxBlockComponent,
|
||||
GfxElementSymbol,
|
||||
toGfxBlockComponent,
|
||||
} from '@blocksuite/std';
|
||||
import type {
|
||||
GfxBlockElementModel,
|
||||
GfxCompatibleProps,
|
||||
} 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<GfxCompatibleProps>,
|
||||
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`,
|
||||
};
|
||||
this._scale = bound.w / this._cardWidth;
|
||||
|
||||
return this.renderPageContent();
|
||||
}
|
||||
|
||||
protected override accessor blockContainerStyles: StyleInfo | undefined =
|
||||
undefined;
|
||||
} as B & {
|
||||
new (...args: any[]): GfxBlockComponent;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
DarkLoadingIcon,
|
||||
EmbedCardDarkBannerIcon,
|
||||
EmbedCardDarkCubeIcon,
|
||||
EmbedCardDarkHorizontalIcon,
|
||||
EmbedCardDarkListIcon,
|
||||
EmbedCardDarkVerticalIcon,
|
||||
EmbedCardLightBannerIcon,
|
||||
EmbedCardLightCubeIcon,
|
||||
EmbedCardLightHorizontalIcon,
|
||||
EmbedCardLightListIcon,
|
||||
EmbedCardLightVerticalIcon,
|
||||
LightLoadingIcon,
|
||||
} from '@blocksuite/affine-components/icons';
|
||||
import { ColorScheme } from '@blocksuite/affine-model';
|
||||
import type { TemplateResult } from 'lit';
|
||||
|
||||
type EmbedCardIcons = {
|
||||
LoadingIcon: TemplateResult<1>;
|
||||
EmbedCardBannerIcon: TemplateResult<1>;
|
||||
EmbedCardHorizontalIcon: TemplateResult<1>;
|
||||
EmbedCardListIcon: TemplateResult<1>;
|
||||
EmbedCardVerticalIcon: TemplateResult<1>;
|
||||
EmbedCardCubeIcon: TemplateResult<1>;
|
||||
};
|
||||
|
||||
export function getEmbedCardIcons(theme: ColorScheme): EmbedCardIcons {
|
||||
if (theme === ColorScheme.Light) {
|
||||
return {
|
||||
LoadingIcon: LightLoadingIcon,
|
||||
EmbedCardBannerIcon: EmbedCardLightBannerIcon,
|
||||
EmbedCardHorizontalIcon: EmbedCardLightHorizontalIcon,
|
||||
EmbedCardListIcon: EmbedCardLightListIcon,
|
||||
EmbedCardVerticalIcon: EmbedCardLightVerticalIcon,
|
||||
EmbedCardCubeIcon: EmbedCardLightCubeIcon,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
LoadingIcon: DarkLoadingIcon,
|
||||
EmbedCardBannerIcon: EmbedCardDarkBannerIcon,
|
||||
EmbedCardHorizontalIcon: EmbedCardDarkHorizontalIcon,
|
||||
EmbedCardListIcon: EmbedCardDarkListIcon,
|
||||
EmbedCardVerticalIcon: EmbedCardDarkVerticalIcon,
|
||||
EmbedCardCubeIcon: EmbedCardDarkCubeIcon,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,677 @@
|
||||
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 } = model.props;
|
||||
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 } = model.props;
|
||||
|
||||
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}
|
||||
.viewType$=${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}
|
||||
.style$=${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)) {
|
||||
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}
|
||||
.viewType$=${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',
|
||||
},
|
||||
].filter(action =>
|
||||
EmbedGithubStyles.includes(action.id as EmbedCardStyle)
|
||||
),
|
||||
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}
|
||||
.style$=${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}
|
||||
.size$=${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),
|
||||
}),
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
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 { EmbedLinkedDocBlockComponent } from './embed-linked-doc-block';
|
||||
import { EmbedEdgelessLinkedDocBlockComponent } from './embed-linked-doc-block/embed-edgeless-linked-doc-block';
|
||||
import { EmbedLoomBlockComponent } from './embed-loom-block';
|
||||
import { EmbedEdgelessLoomBlockComponent } from './embed-loom-block/embed-edgeless-loom-bock';
|
||||
import { EmbedSyncedDocBlockComponent } from './embed-synced-doc-block';
|
||||
import { EmbedSyncedDocCard } from './embed-synced-doc-block/components/embed-synced-doc-card';
|
||||
import { EmbedEdgelessSyncedDocBlockComponent } from './embed-synced-doc-block/embed-edgeless-synced-doc-block';
|
||||
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-synced-doc-card', EmbedSyncedDocCard);
|
||||
|
||||
customElements.define(
|
||||
'affine-embed-edgeless-linked-doc-block',
|
||||
EmbedEdgelessLinkedDocBlockComponent
|
||||
);
|
||||
customElements.define(
|
||||
'affine-embed-linked-doc-block',
|
||||
EmbedLinkedDocBlockComponent
|
||||
);
|
||||
|
||||
customElements.define(
|
||||
'affine-embed-edgeless-synced-doc-block',
|
||||
EmbedEdgelessSyncedDocBlockComponent
|
||||
);
|
||||
customElements.define(
|
||||
'affine-embed-synced-doc-block',
|
||||
EmbedSyncedDocBlockComponent
|
||||
);
|
||||
|
||||
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-synced-doc-card': EmbedSyncedDocCard;
|
||||
'affine-embed-synced-doc-block': EmbedSyncedDocBlockComponent;
|
||||
'affine-embed-edgeless-synced-doc-block': EmbedEdgelessSyncedDocBlockComponent;
|
||||
'affine-embed-linked-doc-block': EmbedLinkedDocBlockComponent;
|
||||
'affine-embed-edgeless-linked-doc-block': EmbedEdgelessLinkedDocBlockComponent;
|
||||
'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,39 @@
|
||||
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 { 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.doc.schema.flavourSchemaMap.has('affine:embed-figma'),
|
||||
action: ({ std, model }) => {
|
||||
(async () => {
|
||||
const { host } = std;
|
||||
const parentModel = host.doc.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 }
|
||||
);
|
||||
if (model.text?.length === 0) std.store.deleteBlock(model);
|
||||
})().catch(console.error);
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { html } from 'lit';
|
||||
|
||||
// prettier-ignore
|
||||
export const FigmaTooltip = html`<svg width="170" height="106" viewBox="0 0 170 106" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<rect width="170" height="106" rx="2" fill="white"/>
|
||||
<mask id="mask0_16460_1083" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="106">
|
||||
<rect width="170" height="106" rx="2" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_16460_1083)">
|
||||
<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">Embed a Figma document.</tspan></text>
|
||||
<rect x="8.5" y="28.5" width="169" height="121" rx="3.5" fill="white" stroke="#E3E2E4"/>
|
||||
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="9" font-weight="500" letter-spacing="0em"><tspan x="18" y="44.7727">AFFiNE Design System - Jul 2077</tspan></text>
|
||||
<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="9" letter-spacing="0em"><tspan x="18" y="57.7727">Edited just now</tspan></text>
|
||||
<rect x="16" y="66" width="154" height="58" rx="2" fill="url(#pattern0_16460_1083)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<pattern id="pattern0_16460_1083" patternContentUnits="objectBoundingBox" width="1" height="1">
|
||||
<use xlink:href="#image0_16460_1083" transform="scale(0.00324675 0.00862069)"/>
|
||||
</pattern>
|
||||
<image id="image0_16460_1083" width="308" height="116" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAATQAAAB0CAYAAAALrs5HAAAACXBIWXMAABYlAAAWJQFJUiTwAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAABDPSURBVHgB7Z11kBxFG8Z7Q3C7w53gDgnu7u7uUIUWEKBwl0IKCFC4BSgKLSDBCw3uJLhDcM8drsl99+tL3/fuu7138Nft9T2/qq3czs7MzvR0P/1abyqhnZaWlgFtbW1D2/8c2P5qCkII0XsYVqlUBjc3N4+ujBezkUFCJoTovbS2i9qgfu1iNiRIzIQQvZsmvMzKmDFj2oIQQvR+WvsFIYQogyYJmhCiGCRoQohikKAJIYpBgiaEKAYJmhCiGCRoQohikKAJIYpBgiaEKAYJmhCiGCRoQohikKAJIYpBgiaEKAYJmhCiGCRoQohikKAJIYpBgiaEKAYJmhCiGCRoQohikKAJIYpBgiaEKAYJmhCiGCRoQohikKAJIYpBgiaEKAYJmhCiGCRoQohikKAJIYpBgiaEKAYJmhCiGCRoQohikKAJIYpBgiaEKAYJmhCiGCRoQohikKAJIYpBgiaEKAYJmhCiGCRoQohi6B9EEbz99tvhwQcfDNNPP33YZJNNwpRTThmE6GtI0AyvvfZa+PPPP7OfTTjhhGGyySYLs88+e5h00klDI3HvvfeG8847r/M94nbqqaeGRue7774Lb7zxRhg9enT466+/Qv/+/cMcc8wRFlhggfivEP8VCdp4/vjjj3DooYeGtra2LvebYoopwsCBA8Nee+3VMIPuzjvvrHr/zTffhEaGieOaa64J7733XnYC6devX1hxxRXDQQcdFKaddtrQkyC0vCyTTz55qFQqQTQeErTxYCV0J2bwyy+/hKeeeiqMGjUq7LnnnmGzzTYLPc1UU01V9X611VYLjcqNN94YbrjhhrqWMIwbNy62Mc9k6NChUeB6irPPPju8+OKLVdsuuuiiaKmLxkNJgfF8/fXX/2l/hO3SSy8Njz76aOhpDj/88LD88stHq+bAAw8MO+20U2hEbr/99nD11Vd3KWaWzz//PApaT8I18KzTC0t+1llnDaIxkYU2nvfff79m23TTTRfjZlgMuHF///131ee8v/7668Maa6zRoy7ILLPMEk4//fTQyLS2tkY307PEEktEF562xCr79NNPqz5/9dVXQ08xduzY8PHHH1dto0/0pMUoukaCNh7fceG0004L8803X/wbd/S5556LLshPP/3Uuc9nn30WY0IMTMsPP/wQhg0bFl555ZXwxRdfxDjMxBNPHBZddNGw7rrrhlVWWaXm+37//ff4HU888UR45513okXwzz//xCTEbLPNFlZdddUonjauhKB6K/HYY4/tvG54/vnnw/Dhw+M5sY4GDBgQ9t5777D44ovHf62rPeecc4aTTz45fPjhhzWJBfZFfG6++ebwzDPPRJEnWYJ1uNtuu4WZZ5451AO3DevGwnFWiLfeeuuwzTbbRCFJ/Pbbb/H+uE/LPvvsE1ZYYYXO96+//no499xzq/aZeuqp4zaSDQjl3XffHd59991oddEOCBP7LLLIImG99dYLSy65ZOexRx55ZLTaaX9LS0tL2H333aOwnXHGGfH+4ddffw1PPvlkePzxx2Pb8X6CCSaIrinPbf3114/flfjggw9i/7LsvPPOsU24V85FP+N7Ntpoo9guTJrJaiWZQv+YZpppwhZbbBG23HLLICRonfhAOoNgrrnm6nxPZ2IAbbvttuGqq66q2pfOaQWNgcM+dDgLgwgh4LX55ptH9zBZdnw/Hfytt96quTbE8McffwxvvvlmTACcddZZUeCA/RFVS4qpIQyXXXZZPMaKFsJ2yimnhGOOOabGIkpCSMDenxcOPvjgGNtKIFIPPfRQHGDElpqamkKOnKXFfXFdqQ0Y8GRrraCxDWHx18LAttxzzz01+yAEPMdbbrklXHvttTXBfUAwv/rqq/Dwww+HTTfdNN4fkxGTVG5/niHfQ1lMErOPPvooHH/88dmwBW3Niz7Bvc0wwwxxe+6eaEsSIZ988knVfV5++eXx+yaZZJIwZMiQKJaJL7/8Mlx88cVRPBshntvTyHYOHa6j71zMjAwGTy4Y/P3333f+zex5wQUX1IiZB+uNWRgYwAyInJh5GAh06gQd2oKLnAYNg/iOO+7IJjuY/S+55JKa7en+rGglsPJy2wFRYNDWI3cNWK+I+pVXXhktUwYqFiyTQ3phTS600EI1xyLwiTFjxoSXX3656nOs2NVXXz26sVdccUVWnDx33XVXvCbKSbrbP2W4EbPBgwd3G4OlfawFiRXn4VqtmFnuv//+cOGFF1aJmeW2226LoZG+jiy0dr799tsqqwCohcrBTOhJFsazzz4bM3iWeeedN6yzzjrx/HQ6XJYEQXLcEdwl38GxwHbZZZfoUtx0001xoCWY8RnElA/gzloIWHM9DAxcQw9uIedGPL11BnPPPXf811tAwDXgNnN+7z5CvcEIiy22WHjggQdqticLhmvlvAsuuGB0PcnUWsuNlxUx+zd1eLZdgcQIRcYnnnhizXfi2uFm0nYkKXD1E1iayy67bHwuZLJteAFWWmml2Af4l7gg57eTFxPKVlttFWaaaaZoNVITmEB0scRnnHHGrADiljc3N8fz+Xgt1jkw0XKvvr9yDNYmZUV9GQla6BhUnnrxIGZvD0F5wBKwYO1gTdHJgc6Ku5hgdgdcVg8xkbXXXjv+TQzNZtYY6GzzYgYIKDCY/IxNzIwYIK7SCy+8EI4++uia4+eZZ56610SsZt99940DhwwvKxMsXSVGiFE9/fTT0d2uB1ZcKgpGPPfbb7/OImba2IpYElSOIW5lSdYZn/mJgs923HHHKMyAEOBi2vtHVBEqYoZW0Gg34ovpPpmgvIWMpY0gAnFSxDlZe1wPEwmChkvv2X///aMYMpkQp/NWLfFGYmyPPfZYTfwNd7TRCr57Agla6HAHPEkYPC+99FLNNsSGmd1bPHR8Bn7CWxEIA+RcW9wLrL35558/Wk2DBg2KLpiNUeUGBQOf2TsnHBQOp7jP0ksvHa/PDhqEFyFHLLAALSQLcA+BGB3i6AUtubo5+C6ECrcO94mscld1f1hdtOt2220X3xPPtNZOsqp4Hj6hs/HGG3cG4BEuG9hHvIg1MdkgXLi0JAMWXnjhKkHmGG9xckzah899QTNtSxghhRJyMMnw8kJIuyNmgAXNs7DuJdeImAFuOX3G3hcimfMe+hoStJCPZ+AyeBAK3EMLnYiB4Ts3IHA5ty7BrAokGxAvL3iICrElXkAnJ9u16667xvc5IcbCwPLzLg0DprtiUEQLON5bdwx8i7WW6u2Tg8A7a01pF+JxDH4yj36AAzGlJGgpCZJIkwHBfAtiSxslSAzceuutVfvgznGPyUIGXOLDDjuss424Ht8GyR0HBNknkjjvfffdF7oihQl8jM5a4Nybj5XZBBUxW599VaFvBxK00BFDs0w00URxxvMQy/IxFdwLTP1cHVt3M2ZyVfmuk046KZx//vnZ8pEEHf26666LnZfyjdx34lLllj75Ds+g8hZSGjTeOgNiTpZcvCxXcIolRXmDhTbDikJAiZUxUSDovjTDuvf++4lfYW2NGDGiajvWl3W9cBuZOHAPbazMw0RF5pewAVZYzuW295cLPVAG0l09InG9XPzMLqPLtW2abCB3vP28L9PnBY00vA+AIzQE4y24QIiJhzIO8EFcXJ3uZmsLbgSlHgga1gFBYCxH3EovoiQfEDQ/qAgII1w5ofPV+WQsPbYUxOPXrfr4He5WLu7ItRM7szDoEbQEwu/r+MAKCEka62YhujwTa6kgFn6VBOemRg6rDZeVZ414YSF6yxyLjcmNCaar+GT6fs+ZZ54ZllpqqdAdubAFLm8i9/xs++fEVov5O+jzgka5Rq7EIokXrhXxsZxbusEGG8RYEuBKWBA4XMI0yLGaKJOwVhG1aAx4XxhLMJwsGuBSeQsHi4Nr9pYYQoyQ5mJyCCMDAZcUV46kgSe5VDkLwbrgiIi3Eoif5WrQclYq8T1qq1ZeeeXoRuN6ssbTY8UBwSSpkkQclwxBs+DKphghAn7OOedUCTkW6B577NFZr0UW2bu6ybrLPW9b0OzXzwLtZq8Za89OlvQFni0utsfGH33b0obWY6gXahAStOxsyACrV2+VoGKeavUELpGN5xB/oUgTlwpXkayindXpwLgJWFvegmEQEPvBrfIxIkDs6NQ+zpLEMzdb425xvQx4b03aawI/YHDBbQyL6/PxPmrFcu4WIsBgtOJL2xDX8rEtC9bWWmutVbWNot8kaJzD1v9hUXurj+JYuw9tTZvRTlihXsw4fxIqe1yCNbNYwYQeiJvSLjYWRoErViDCS8mHF0UyoOCz6r6I2/dJ2s+KqT+eCc57FH2VPi9oOdeiKxi0CApLY1I5BlCWQC2VHbjEeChs9WDNHXLIIbGTEk/ivRUnZvpc0StQCrDccstlM2nJbcE9Iytma9cSiBnC5QWBrCADHevLFxmn2rZELmmQ4oEejsMSwlr6t9AeZFR9HLOrpVW4lHZpESKx4YYbVsXluOZc8gYQKgQrkSuBYGJK5SLcL5MVqyQs9X6sgPINSkloXx8qQMxsX/KfI+7J0kVA/edY1lpf2kGfbwWb6eoOSiiOO+64+LIdEHD1qF3KJRMsxLhIACBKgLhQX8Ss3hV0aAYt6zTBljAkrNuBdWhn/QRW4RFHHFGzHbHA4shl97w7k1sSZdeOenDNEaiU1e0KxJjyDtzR3LXnYMCnmj0LIpLbnjsv9YE2RlZvGRFtk0TtgAMOCGuuuWboCoSSe6d+D3KTgXU3mWS8hWzbnxirP17W2f/p8xYaA7He+kNmPVwQOjwuVXeBVwYjy3jIvO
Line truncated
|
||||
</defs>
|
||||
</svg>
|
||||
`;
|
||||
@@ -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,6 @@
|
||||
import { toEdgelessEmbedBlock } from '../common/to-edgeless-embed-block.js';
|
||||
import { EmbedFigmaBlockComponent } from './embed-figma-block.js';
|
||||
|
||||
export class EmbedEdgelessBlockComponent extends toEdgelessEmbedBlock(
|
||||
EmbedFigmaBlockComponent
|
||||
) {}
|
||||
@@ -0,0 +1,130 @@
|
||||
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 { styleMap } from 'lit/directives/style-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.doc.withoutTransact(() => {
|
||||
this.doc.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,
|
||||
})}
|
||||
style=${styleMap({
|
||||
transform: `scale(${this._scale})`,
|
||||
transformOrigin: '0 0',
|
||||
})}
|
||||
@click=${this._handleClick}
|
||||
@dblclick=${this._handleDoubleClick}
|
||||
>
|
||||
<div class="affine-embed-figma">
|
||||
<div class="affine-embed-figma-iframe-container">
|
||||
<iframe
|
||||
src=${`https://www.figma.com/embed?embed_host=blocksuite&url=${url}`}
|
||||
allowfullscreen
|
||||
loading="lazy"
|
||||
></iframe>
|
||||
|
||||
<!-- 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 { EmbedFigmaBlockAdapterExtensions } from './adapters/extension';
|
||||
import { embedFigmaSlashMenuConfig } from './configs/slash-menu';
|
||||
import { EmbedFigmaBlockComponent } from './embed-figma-block';
|
||||
import { EmbedFigmaBlockOptionConfig } from './embed-figma-service';
|
||||
|
||||
const flavour = EmbedFigmaBlockSchema.model.flavour;
|
||||
|
||||
export const EmbedFigmaBlockSpec: ExtensionType[] = [
|
||||
FlavourExtension(flavour),
|
||||
BlockViewExtension(flavour, model => {
|
||||
return model.parent?.flavour === 'affine:surface'
|
||||
? literal`affine-embed-edgeless-figma-block`
|
||||
: literal`affine-embed-figma-block`;
|
||||
}),
|
||||
EmbedFigmaBlockAdapterExtensions,
|
||||
EmbedFigmaBlockOptionConfig,
|
||||
createBuiltinToolbarConfigExtension(flavour, EmbedFigmaBlockComponent),
|
||||
SlashMenuConfigExtension(flavour, embedFigmaSlashMenuConfig),
|
||||
].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';
|
||||
@@ -0,0 +1,225 @@
|
||||
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 var(--affine-background-tertiary-color);
|
||||
|
||||
opacity: var(--add, 1);
|
||||
background: var(--affine-background-primary-color);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.affine-embed-figma {
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
opacity: var(--add, 1);
|
||||
}
|
||||
|
||||
.affine-embed-figma img,
|
||||
.affine-embed-figma object,
|
||||
.affine-embed-figma svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 4px 4px var(--1, 0px) var(--1, 0px);
|
||||
}
|
||||
|
||||
.affine-embed-figma-iframe-container {
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.affine-embed-figma-iframe-container > iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 4px 4px var(--1, 0px) var(--1, 0px);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.affine-embed-figma-iframe-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.affine-embed-figma-iframe-overlay.hide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.affine-embed-figma-content {
|
||||
display: block;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: fit-content;
|
||||
border-radius: var(--1, 0px);
|
||||
opacity: var(--add, 1);
|
||||
}
|
||||
|
||||
.affine-embed-figma-content-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
align-self: stretch;
|
||||
padding: var(--1, 0px);
|
||||
border-radius: var(--1, 0px);
|
||||
opacity: var(--add, 1);
|
||||
}
|
||||
|
||||
.affine-embed-figma-content-title-icon {
|
||||
display: flex;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.affine-embed-figma-content-title-icon img,
|
||||
.affine-embed-figma-content-title-icon object,
|
||||
.affine-embed-figma-content-title-icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
fill: var(--affine-background-primary-color);
|
||||
}
|
||||
|
||||
.affine-embed-figma-content-title-text {
|
||||
flex: 1 0 0;
|
||||
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
|
||||
word-break: break-word;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--affine-text-primary-color);
|
||||
|
||||
font-family: var(--affine-font-family);
|
||||
font-size: var(--affine-font-sm);
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.affine-embed-figma-content-description {
|
||||
height: 40px;
|
||||
|
||||
position: relative;
|
||||
|
||||
word-break: break-word;
|
||||
white-space: normal;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--affine-text-primary-color);
|
||||
|
||||
font-family: var(--affine-font-family);
|
||||
font-size: var(--affine-font-xs);
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.affine-embed-figma-content-description::after {
|
||||
content: '...';
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--affine-background-primary-color);
|
||||
}
|
||||
|
||||
.affine-embed-figma-content-url {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 4px;
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
.affine-embed-figma-content-url > span {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
|
||||
word-break: break-all;
|
||||
white-space: normal;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: ${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,39 @@
|
||||
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 { 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.doc.schema.flavourSchemaMap.has('affine:embed-github'),
|
||||
action: ({ std, model }) => {
|
||||
(async () => {
|
||||
const { host } = std;
|
||||
const parentModel = host.doc.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 }
|
||||
);
|
||||
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,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"/>
|
||||
</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,6 @@
|
||||
import { toEdgelessEmbedBlock } from '../common/to-edgeless-embed-block.js';
|
||||
import { EmbedGithubBlockComponent } from './embed-github-block.js';
|
||||
|
||||
export class EmbedEdgelessGithubBlockComponent extends toEdgelessEmbedBlock(
|
||||
EmbedGithubBlockComponent
|
||||
) {}
|
||||
@@ -0,0 +1,270 @@
|
||||
import { OpenIcon } from '@blocksuite/affine-components/icons';
|
||||
import type {
|
||||
EmbedGithubModel,
|
||||
EmbedGithubStyles,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { ThemeProvider } from '@blocksuite/affine-shared/services';
|
||||
import { BlockSelection } 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.doc.withoutTransact(() => {
|
||||
const url = this.model.props.url;
|
||||
const urlMatch = url.match(githubUrlRegex);
|
||||
if (urlMatch) {
|
||||
const [, owner, repo, githubType, githubId] = urlMatch;
|
||||
this.doc.updateBlock(this.model, {
|
||||
owner,
|
||||
repo,
|
||||
githubType: githubType === 'issue' ? 'issue' : 'pr',
|
||||
githubId,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.doc.withoutTransact(() => {
|
||||
if (!this.model.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 = 'GitHub',
|
||||
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 { LoadingIcon, EmbedCardBannerIcon } = getEmbedCardIcons(theme);
|
||||
const titleIcon = loading ? LoadingIcon : GithubIcon;
|
||||
const statusIcon = status
|
||||
? getGithubStatusIcon(githubType, status, statusReason)
|
||||
: nothing;
|
||||
const statusText = loading ? '' : status;
|
||||
const titleText = loading ? 'Loading...' : title;
|
||||
const descriptionText = loading ? '' : description;
|
||||
const bannerImage =
|
||||
!loading && image
|
||||
? html`<object type="image/webp" data=${image} draggable="false">
|
||||
${EmbedCardBannerIcon}
|
||||
</object>`
|
||||
: EmbedCardBannerIcon;
|
||||
|
||||
let dateText = '';
|
||||
if (createdAt) {
|
||||
const date = new Date(createdAt);
|
||||
dateText = date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
const day = date.getDate();
|
||||
const suffix =
|
||||
['th', 'st', 'nd', 'rd'][((day / 10) | 0) !== 1 ? day % 10 : 4] || 'th';
|
||||
dateText = dateText.replace(/\d+/, `${day}${suffix}`);
|
||||
}
|
||||
|
||||
return this.renderEmbed(
|
||||
() => html`
|
||||
<div
|
||||
class=${classMap({
|
||||
'affine-embed-github-block': true,
|
||||
loading,
|
||||
[style]: true,
|
||||
selected: this.selected$.value,
|
||||
})}
|
||||
style=${styleMap({
|
||||
transform: `scale(${this._scale})`,
|
||||
transformOrigin: '0 0 ',
|
||||
})}
|
||||
@click=${this._handleClick}
|
||||
@dblclick=${this._handleDoubleClick}
|
||||
>
|
||||
<div class="affine-embed-github-banner">${bannerImage}</div>
|
||||
<div class="affine-embed-github-content">
|
||||
<div class="affine-embed-github-content-title">
|
||||
<div class="affine-embed-github-content-title-icons">
|
||||
<div class="affine-embed-github-content-title-site-icon">
|
||||
${titleIcon}
|
||||
</div>
|
||||
|
||||
${status && statusText
|
||||
? html`<div
|
||||
class=${classMap({
|
||||
'affine-embed-github-content-title-status-icon': true,
|
||||
[githubType]: true,
|
||||
[status]: true,
|
||||
success: statusReason === 'completed',
|
||||
failure: statusReason === 'not_planned',
|
||||
})}
|
||||
>
|
||||
${statusIcon}
|
||||
|
||||
<span>${statusText}</span>
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
|
||||
<div class="affine-embed-github-content-title-text">
|
||||
${titleText}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="affine-embed-github-content-description">
|
||||
${descriptionText}
|
||||
</div>
|
||||
|
||||
${githubType === 'issue' && assignees
|
||||
? html`
|
||||
<div class="affine-embed-github-content-assignees">
|
||||
<div
|
||||
class="affine-embed-github-content-assignees-text label"
|
||||
>
|
||||
Assignees
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="affine-embed-github-content-assignees-text users"
|
||||
>
|
||||
${assignees.length === 0
|
||||
? html`<span
|
||||
class="affine-embed-github-content-assignees-text-users placeholder"
|
||||
>No one</span
|
||||
>`
|
||||
: repeat(
|
||||
assignees,
|
||||
assignee => assignee,
|
||||
(assignee, index) =>
|
||||
html`<span
|
||||
class="affine-embed-github-content-assignees-text-users user"
|
||||
@click=${() =>
|
||||
this._handleAssigneeClick(assignee)}
|
||||
>${`@${assignee}`}</span
|
||||
>
|
||||
${index === assignees.length - 1 ? '' : `, `}`
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
|
||||
<div class="affine-embed-github-content-url" @click=${this.open}>
|
||||
<span class="affine-embed-github-content-repo"
|
||||
>${`${owner}/${repo} |`}</span
|
||||
>
|
||||
|
||||
${createdAt
|
||||
? html`<span class="affine-embed-github-content-date"
|
||||
>${dateText} |</span
|
||||
>`
|
||||
: nothing}
|
||||
<span>github.com</span>
|
||||
|
||||
<div class="affine-embed-github-content-url-icon">
|
||||
${OpenIcon}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
@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,
|
||||
LinkPreviewerService,
|
||||
} 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.doc.get(LinkPreviewerService),
|
||||
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 { EmbedGithubBlockAdapterExtensions } from './adapters/extension';
|
||||
import { embedGithubSlashMenuConfig } from './configs/slash-menu';
|
||||
import { EmbedGithubBlockComponent } from './embed-github-block';
|
||||
import {
|
||||
EmbedGithubBlockOptionConfig,
|
||||
EmbedGithubBlockService,
|
||||
} from './embed-github-service';
|
||||
|
||||
const flavour = EmbedGithubBlockSchema.model.flavour;
|
||||
|
||||
export const EmbedGithubBlockSpec: ExtensionType[] = [
|
||||
FlavourExtension(flavour),
|
||||
EmbedGithubBlockService,
|
||||
BlockViewExtension(flavour, model => {
|
||||
return model.parent?.flavour === 'affine:surface'
|
||||
? literal`affine-embed-edgeless-github-block`
|
||||
: literal`affine-embed-github-block`;
|
||||
}),
|
||||
EmbedGithubBlockAdapterExtensions,
|
||||
EmbedGithubBlockOptionConfig,
|
||||
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';
|
||||
@@ -0,0 +1,507 @@
|
||||
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 var(--affine-background-tertiary-color);
|
||||
|
||||
opacity: var(--add, 1);
|
||||
background: var(--affine-background-primary-color);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.affine-embed-github-content {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
flex-direction: column;
|
||||
align-self: stretch;
|
||||
gap: 4px;
|
||||
padding: 12px;
|
||||
border-radius: var(--1, 0px);
|
||||
opacity: var(--add, 1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.affine-embed-github-content-title {
|
||||
display: flex;
|
||||
min-height: 22px;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
align-self: stretch;
|
||||
padding: var(--1, 0px);
|
||||
border-radius: var(--1, 0px);
|
||||
opacity: var(--add, 1);
|
||||
}
|
||||
|
||||
.affine-embed-github-content-title-icons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.affine-embed-github-content-title-icons img,
|
||||
.affine-embed-github-content-title-icons object,
|
||||
.affine-embed-github-content-title-icons svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--affine-pure-white);
|
||||
}
|
||||
|
||||
.affine-embed-github-content-title-site-icon {
|
||||
display: flex;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
.github-icon {
|
||||
fill: var(--affine-black);
|
||||
color: var(--affine-black);
|
||||
}
|
||||
}
|
||||
|
||||
.affine-embed-github-content-title-status-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 6px;
|
||||
border-radius: 20px;
|
||||
|
||||
color: var(--affine-pure-white);
|
||||
leading-trim: both;
|
||||
|
||||
text-edge: cap;
|
||||
font-feature-settings:
|
||||
'clig' off,
|
||||
'liga' off;
|
||||
text-transform: capitalize;
|
||||
font-family: var(--affine-font-family);
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 16px;
|
||||
}
|
||||
.affine-embed-github-content-title-status-icon.issue.open {
|
||||
background: #238636;
|
||||
}
|
||||
.affine-embed-github-content-title-status-icon.issue.closed.success {
|
||||
background: #8957e5;
|
||||
}
|
||||
.affine-embed-github-content-title-status-icon.issue.closed.failure {
|
||||
background: #6e7681;
|
||||
}
|
||||
.affine-embed-github-content-title-status-icon.pr.open {
|
||||
background: #238636;
|
||||
}
|
||||
.affine-embed-github-content-title-status-icon.pr.draft {
|
||||
background: #6e7681;
|
||||
}
|
||||
.affine-embed-github-content-title-status-icon.pr.merged {
|
||||
background: #8957e5;
|
||||
}
|
||||
.affine-embed-github-content-title-status-icon.pr.closed {
|
||||
background: #c03737;
|
||||
}
|
||||
|
||||
.affine-embed-github-content-title-status-icon > svg {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.affine-embed-github-content-title-status-icon > span {
|
||||
padding: 0px 1.5px;
|
||||
}
|
||||
|
||||
.affine-embed-github-content-title-text {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
|
||||
word-break: break-word;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--affine-text-primary-color);
|
||||
|
||||
font-family: var(--affine-font-family);
|
||||
font-size: var(--affine-font-sm);
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.affine-embed-github-content-description {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
|
||||
flex-grow: 1;
|
||||
|
||||
word-break: break-word;
|
||||
white-space: normal;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--affine-text-primary-color);
|
||||
|
||||
font-family: var(--affine-font-family);
|
||||
font-size: var(--affine-font-xs);
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.affine-embed-github-content-assignees {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.affine-embed-github-content-url {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 4px;
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
.affine-embed-github-content-url > span {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
|
||||
word-break: break-all;
|
||||
white-space: normal;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--affine-text-secondary-color);
|
||||
|
||||
font-family: var(--affine-font-family);
|
||||
font-size: var(--affine-font-xs);
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
.affine-embed-github-content-url:hover > span {
|
||||
color: var(--affine-link-color);
|
||||
}
|
||||
.affine-embed-github-content-url:hover .open-icon {
|
||||
fill: var(--affine-link-color);
|
||||
}
|
||||
|
||||
.affine-embed-github-content-url-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
.affine-embed-github-content-url-icon .open-icon {
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
fill: var(--affine-text-secondary-color);
|
||||
}
|
||||
|
||||
.affine-embed-github-banner {
|
||||
margin: 12px 0px 0px 12px;
|
||||
width: 204px;
|
||||
height: 102px;
|
||||
opacity: var(--add, 1);
|
||||
}
|
||||
|
||||
.affine-embed-github-banner img,
|
||||
.affine-embed-github-banner object,
|
||||
.affine-embed-github-banner svg {
|
||||
width: 204px;
|
||||
height: 102px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px 4px var(--1, 0px) var(--1, 0px);
|
||||
}
|
||||
|
||||
.affine-embed-github-block.loading {
|
||||
.affine-embed-github-content-title-text {
|
||||
color: var(--affine-placeholder-color);
|
||||
}
|
||||
}
|
||||
|
||||
.affine-embed-github-block.selected {
|
||||
.affine-embed-github-content-url > span {
|
||||
color: var(--affine-link-color);
|
||||
}
|
||||
.affine-embed-github-content-url .open-icon {
|
||||
fill: var(--affine-link-color);
|
||||
}
|
||||
}
|
||||
|
||||
.affine-embed-github-block.list {
|
||||
.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;
|
||||
|
||||
.affine-embed-github-content {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.affine-embed-github-content-description {
|
||||
-webkit-line-clamp: 6;
|
||||
}
|
||||
|
||||
.affine-embed-github-content-assignees {
|
||||
display: flex;
|
||||
padding: var(--1, 0px);
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 2px;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.affine-embed-github-content-assignees-text {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
|
||||
font-family: var(--affine-font-family);
|
||||
font-size: var(--affine-font-xs);
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.affine-embed-github-content-assignees-text.label {
|
||||
width: 72px;
|
||||
color: var(--affine-text-primary-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.affine-embed-github-content-assignees-text.users {
|
||||
width: calc(100% - 72px);
|
||||
word-break: break-all;
|
||||
white-space: normal;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.affine-embed-github-content-assignees-text-users.user {
|
||||
color: var(--affine-link-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.affine-embed-github-content-assignees-text-users.placeholder {
|
||||
color: var(--affine-placeholder-color);
|
||||
}
|
||||
|
||||
.affine-embed-github-banner {
|
||||
width: 340px;
|
||||
height: 170px;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.affine-embed-github-banner img,
|
||||
.affine-embed-github-banner object,
|
||||
.affine-embed-github-banner svg {
|
||||
width: 340px;
|
||||
height: 170px;
|
||||
}
|
||||
}
|
||||
|
||||
.affine-embed-github-block.cube {
|
||||
.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-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>`;
|
||||
@@ -0,0 +1,182 @@
|
||||
import type {
|
||||
EmbedGithubBlockUrlData,
|
||||
EmbedGithubModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import type { LinkPreviewerService } 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: LinkPreviewerService,
|
||||
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.doc.updateBlock(embedGithubElement.model, {
|
||||
image,
|
||||
status,
|
||||
statusReason,
|
||||
title,
|
||||
description,
|
||||
createdAt,
|
||||
assignees,
|
||||
});
|
||||
} catch (error) {
|
||||
if (signal?.aborted || isAbortError(error)) return;
|
||||
throw Error;
|
||||
} finally {
|
||||
embedGithubElement.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshEmbedGithubStatus(
|
||||
embedGithubElement: EmbedGithubBlockComponent,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
// 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.doc.updateBlock(embedGithubElement.model, {
|
||||
status: githubApiData.status,
|
||||
statusReason: githubApiData.statusReason,
|
||||
createdAt: githubApiData.createdAt,
|
||||
assignees: githubApiData.assignees,
|
||||
});
|
||||
}
|
||||
|
||||
export function getGithubStatusIcon(
|
||||
type: 'issue' | 'pr',
|
||||
status: string,
|
||||
statusReason: string | null
|
||||
) {
|
||||
if (type === 'issue') {
|
||||
if (status === 'open') {
|
||||
return GithubIssueOpenIcon;
|
||||
} else if (status === 'closed' && statusReason === 'completed') {
|
||||
return GithubIssueClosedSuccessIcon;
|
||||
} else if (status === 'closed' && statusReason === 'not_planned') {
|
||||
return GithubIssueClosedFailureIcon;
|
||||
} else {
|
||||
return nothing;
|
||||
}
|
||||
} else if (type === 'pr') {
|
||||
if (status === 'open') {
|
||||
return GithubPROpenIcon;
|
||||
} else if (status === 'draft') {
|
||||
return GithubPRDraftIcon;
|
||||
} else if (status === 'merged') {
|
||||
return GithubPRMergedIcon;
|
||||
} else if (status === 'closed') {
|
||||
return GithubPRClosedIcon;
|
||||
}
|
||||
}
|
||||
return nothing;
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import {
|
||||
menu,
|
||||
popMenu,
|
||||
popupTargetFromElement,
|
||||
} from '@blocksuite/affine-components/context-menu';
|
||||
import { EditPropsStore } from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
CopyIcon,
|
||||
DoneIcon,
|
||||
ExpandCloseIcon,
|
||||
SettingsIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import { autoPlacement, flip, offset } from '@floating-ui/dom';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
|
||||
import type { EmbedEdgelessHtmlBlockComponent } from '../embed-edgeless-html-block.js';
|
||||
|
||||
export class EmbedHtmlFullscreenToolbar extends LitElement {
|
||||
static override styles = css`
|
||||
:host {
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
bottom: 0;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.toolbar-toggle-control {
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.toolbar-toggle-control[data-auto-hide='true'] {
|
||||
transition: 0.27s ease;
|
||||
padding-top: 100px;
|
||||
transform: translateY(100px);
|
||||
}
|
||||
|
||||
.toolbar-toggle-control[data-auto-hide='true']:hover {
|
||||
padding-top: 0;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.fullscreen-toolbar-container {
|
||||
background: var(--affine-background-overlay-panel-color);
|
||||
box-shadow: var(--affine-menu-shadow);
|
||||
border: 1px solid var(--affine-border-color);
|
||||
border-radius: 40px;
|
||||
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
padding: 0 20px;
|
||||
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.short-v-divider {
|
||||
display: inline-block;
|
||||
background-color: var(--affine-border-color);
|
||||
width: 1px;
|
||||
height: 36px;
|
||||
}
|
||||
`;
|
||||
|
||||
private 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 ?? '';
|
||||
return items;
|
||||
})
|
||||
.then(() => {
|
||||
this._copied = true;
|
||||
setTimeout(() => (this._copied = false), 1500);
|
||||
})
|
||||
.catch(console.error);
|
||||
};
|
||||
|
||||
private get autoHideToolbar() {
|
||||
return (
|
||||
this.embedHtml.std
|
||||
.get(EditPropsStore)
|
||||
.getStorage('autoHideEmbedHTMLFullScreenToolbar') ?? false
|
||||
);
|
||||
}
|
||||
|
||||
private set autoHideToolbar(val: boolean) {
|
||||
this.embedHtml.std
|
||||
.get(EditPropsStore)
|
||||
.setStorage('autoHideEmbedHTMLFullScreenToolbar', val);
|
||||
}
|
||||
|
||||
override render() {
|
||||
const hideToolbar = !this._popperVisible && this.autoHideToolbar;
|
||||
|
||||
return html`
|
||||
<div data-auto-hide="${hideToolbar}" class="toolbar-toggle-control">
|
||||
<div class="fullscreen-toolbar-container">
|
||||
<icon-button @click="${this.embedHtml.close}"
|
||||
>${ExpandCloseIcon()}
|
||||
</icon-button>
|
||||
<icon-button
|
||||
@click="${this._popSettings}"
|
||||
hover="${this._popperVisible}"
|
||||
>${SettingsIcon()}
|
||||
</icon-button>
|
||||
|
||||
<div class="short-v-divider"></div>
|
||||
|
||||
<icon-button class="copy-button" @click="${this.copyCode}"
|
||||
>${this._copied ? DoneIcon() : CopyIcon()}
|
||||
</icon-button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@state()
|
||||
private accessor _copied = false;
|
||||
|
||||
@query('.fullscreen-toolbar-container')
|
||||
private accessor _fullScreenToolbarContainer!: HTMLElement;
|
||||
|
||||
@state()
|
||||
private accessor _popperVisible = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor embedHtml!: EmbedEdgelessHtmlBlockComponent;
|
||||
}
|
||||
@@ -0,0 +1,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,
|
||||
}),
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import { html } from 'lit';
|
||||
|
||||
// prettier-ignore
|
||||
export const TweetTooltip = html`<svg width="170" height="106" viewBox="0 0 170 106" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<g clip-path="url(#clip0_24732_2246)">
|
||||
<path d="M168 0H2C0.89543 0 0 0.89543 0 2V104C0 105.105 0.89543 106 2 106H168C169.105 106 170 105.105 170 104V2C170 0.89543 169.105 0 168 0Z" fill="white"/>
|
||||
<mask id="mask0_24732_2246" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="106">
|
||||
<path d="M168 0H2C0.89543 0 0 0.89543 0 2V104C0 105.105 0.89543 106 2 106H168C169.105 106 170 105.105 170 104V2C170 0.89543 169.105 0 168 0Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_24732_2246)">
|
||||
<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0em"><tspan x="10" y="18.2727">Embed a X post (tweet).</tspan></text>
|
||||
<path d="M174 28.5H12C10.067 28.5 8.5 30.067 8.5 32V209C8.5 210.933 10.067 212.5 12 212.5H174C175.933 212.5 177.5 210.933 177.5 209V32C177.5 30.067 175.933 28.5 174 28.5Z" fill="white" stroke="#E3E2E4"/>
|
||||
<path d="M168.244 71H17.7557C16.7861 71 16 71.2054 16 71.4588V105.541C16 105.795 16.7861 106 17.7557 106H168.244C169.214 106 170 105.795 170 105.541V71.4588C170 71.2054 169.214 71 168.244 71Z" fill="url(#pattern0_24732_2246)" stroke="#E3E2E4" stroke-width="0.5"/>
|
||||
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="9" letter-spacing="0em"><tspan x="16" y="62.5454">To Shape, Not to Adapt.</tspan></text>
|
||||
<path d="M32 42C32 37.5817 28.4183 34 24 34C19.5817 34 16 37.5817 16 42C16 46.4183 19.5817 50 24 50C28.4183 50 32 46.4183 32 42Z" fill="url(#pattern1_24732_2246)" stroke="#E3E2E4" stroke-width="0.5"/>
|
||||
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="9" letter-spacing="0em"><tspan x="38" y="45.0454">AFFiNE</tspan></text>
|
||||
<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="9" letter-spacing="0em"><tspan x="72" y="45.0454">@AFFiNEOfficial</tspan></text>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<pattern id="pattern0_24732_2246" patternContentUnits="objectBoundingBox" width="1" height="1">
|
||||
<use xlink:href="#image0_24732_2246" transform="scale(0.00324675 0.0142857)"/>
|
||||
</pattern>
|
||||
<pattern id="pattern1_24732_2246" patternContentUnits="objectBoundingBox" width="1" height="1">
|
||||
<use xlink:href="#image1_24732_2246" transform="scale(0.0208333)"/>
|
||||
</pattern>
|
||||
<clipPath id="clip0_24732_2246">
|
||||
<rect width="170" height="106" fill="white"/>
|
||||
</clipPath>
|
||||
<image id="image0_24732_2246" width="308" height="70" xlink:href="data:image/png;base64,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
Line truncated
|
||||
<image id="image1_24732_2246" width="48" height="48" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAMAAABg3Am1AAAA9lBMVEUAAAD///////////////////////////////////////////////////////////8BAQH9/f0UFBQGBgawsLD7+/vf39+RkZEwMDAYGBgJCQlsbGwtLS0kJCQPDw/29vaMjIx+fn5eXl5AQEAhISEMDAzu7u7j4+Ofn59jY2NYWFhJSUk0NDQqKiofHx/r6+vb29u9vb24uLitra2FhYV4eHhwcHBOTk5FRUU7OzsbGxsRERGBgYF5eXlnZ2fy8vLR0dHCwsKqqqqlpaWTk5OOjo6IiIh0dHTo6OjW1tbLy8vIyMizs7Ojo6OWlpZTU1NDQ0N2dnYEsD/KAAAAD3RSTlMAgO8gYN8Qz7+Qn1CvX7Cz4vbnAAAC20lEQVRIx41V53riMBAkgAFDch4hbNzovfde0nu993+Zk00IUkzOmR98YM/szFrLOuBBJCyfSkFFUaSYHA4FfBA5iSkCooLGSw8qXsR/lIh0cvh6cpQeinLkYUd/4RTSEZMzvvxDGTB63IVgwhNH4XEPhglnIcTy8vsUhQ3MC+VHRUK4RRbIqKqOEhEun3H9ik+zDtTdz5GgCB46lwS+mgFuiOOj58RDPN4AuQVDmihPFHfkWBshRUDNgF12FMTC6kERQkVcQVw02AKb2gdTqD0DRUW0OGIwAHCl5LaOIg8MvBZhseMZgG6dkAJTnHfR8nYRFQJdw0XrUs1TpFPfH23seyJW04nUAprawEC6hWVVIETERCyIgxvtsgTjtl/GtIKCYBEOyPxPrYIKpQD0+8YcmYKNJLKPPEMOCH/JBgy7baL1ul2YyDJdB0CRt/gjjMUjkF8VKahu2zbFHk8cRQrwHdjoDPFmAtn1eLxe4QMu2ip3EpyAvAEXU1yaFCv2aHqG9RfFMgCa5qpygtoSRbVNc+aSZseKku9uMDm3wJCscoLgl8EUpqahTWgDzWRN687NyUsHRiEDbA59H5rWTEzIKxqEWvp7SW9XKpYFTKukn0FFOzR9ut9CTXSrpIk1ocX77DWAUgelZ6LU1mXgnXzNhvwpSAFs5JYYEdoaoEEBal+rF3cLCgfDT4W8H41cGTNVeQb6hOq5LIDVnTZugyFjjdip5/ajEdkZ1N0ieSQJoZkJgHmhRIHsLN0nhAyB251FKLCbjVoZDSZrYk40N0OywsjjgVvXvbHsuXtgvwLSgJVKjQwsWF0XnelV6oAtkCduIrbjnZPIwxc3u0SfFtVi0gczJ1Kc0XcWxBfq3sC18Ie4j6O/40v8MvaDuIyd4/ZHwvNC8W1AVPjzRST+20cwfOS1K/3Mj3L9/iJWkI8jmsSP0iMiS5SEoyI95qV7NXJMcipLp3LYy/4HwhGNw7uiTMQAAAAASUVORK5CYII="/>
|
||||
</defs>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
// prettier-ignore
|
||||
export const LinearTooltip = html`<svg width="170" height="106" viewBox="0 0 170 106" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<rect width="170" height="106" rx="2" fill="white"/>
|
||||
<mask id="mask0_16460_1097" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="106">
|
||||
<rect width="170" height="106" rx="2" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_16460_1097)">
|
||||
<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">Insert a Linear issue.</tspan></text>
|
||||
<rect x="8.5" y="28.5" width="169" height="84" rx="3.5" fill="white" stroke="#E3E2E4"/>
|
||||
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="9" font-weight="500" letter-spacing="0em"><tspan x="18" y="44.7727">Change theme following phone case color</tspan></text>
|
||||
<g clip-path="url(#clip0_16460_1097)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.5474 50.5324C20.6694 50.6545 20.6694 50.8523 20.5474 50.9744L19.7835 51.7383C19.6614 51.8603 19.4636 51.8603 19.3415 51.7383C19.2195 51.6162 19.2195 51.4184 19.3415 51.2963L20.1054 50.5324C20.2275 50.4104 20.4253 50.4104 20.5474 50.5324ZM25.4526 50.5324C25.5747 50.4104 25.7725 50.4104 25.8946 50.5324L26.6585 51.2963C26.7805 51.4184 26.7805 51.6162 26.6585 51.7383C26.5364 51.8603 26.3386 51.8603 26.2165 51.7383L25.4526 50.9744C25.3306 50.8523 25.3306 50.6545 25.4526 50.5324ZM23 51.4479C21.4851 51.4479 20.2569 52.676 20.2569 54.1909C20.2569 55.7059 21.4851 56.934 23 56.934C24.5149 56.934 25.7431 55.7059 25.7431 54.1909C25.7431 52.676 24.5149 51.4479 23 51.4479ZM19.6319 54.1909C19.6319 52.3308 21.1399 50.8229 23 50.8229C24.8601 50.8229 26.3681 52.3308 26.3681 54.1909C26.3681 56.051 24.8601 57.559 23 57.559C21.1399 57.559 19.6319 56.051 19.6319 54.1909Z" fill="#8E8D91"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M24.3321 53.316C24.4541 53.4381 24.4541 53.6359 24.3321 53.758L22.8506 55.2394C22.7286 55.3615 22.5307 55.3615 22.4087 55.2394L21.6679 54.4987C21.5459 54.3767 21.5459 54.1788 21.6679 54.0568C21.79 53.9347 21.9878 53.9347 22.1099 54.0568L22.6296 54.5765L23.8901 53.316C24.0122 53.194 24.21 53.194 24.3321 53.316Z" fill="#8E8D91"/>
|
||||
</g>
|
||||
<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="7" letter-spacing="0em"><tspan x="30" y="56.5455">Work in Progress · </tspan></text>
|
||||
<g clip-path="url(#clip1_16460_1097)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M96.6591 50.3635C96.8266 50.4054 96.9284 50.5751 96.8865 50.7425L96.4627 52.4375H98.3185L98.7802 50.5909C98.822 50.4235 98.9917 50.3217 99.1591 50.3635C99.3266 50.4054 99.4284 50.5751 99.3865 50.7425L98.9627 52.4375H100.333C100.506 52.4375 100.646 52.5774 100.646 52.75C100.646 52.9226 100.506 53.0625 100.333 53.0625H98.8065L98.3377 54.9375H99.5C99.6726 54.9375 99.8125 55.0774 99.8125 55.25C99.8125 55.4226 99.6726 55.5625 99.5 55.5625H98.1815L97.7198 57.4092C97.678 57.5766 97.5083 57.6784 97.3409 57.6365C97.1734 57.5947 97.0716 57.425 97.1135 57.2576L97.5373 55.5625H95.6815L95.2198 57.4092C95.178 57.5766 95.0083 57.6784 94.8409 57.6365C94.6734 57.5947 94.5716 57.425 94.6135 57.2576L95.0373 55.5625H93.6667C93.4941 55.5625 93.3542 55.4226 93.3542 55.25C93.3542 55.0774 93.4941 54.9375 93.6667 54.9375H95.1935L95.6623 53.0625H94.5C94.3274 53.0625 94.1875 52.9226 94.1875 52.75C94.1875 52.5774 94.3274 52.4375 94.5 52.4375H95.8185L96.2802 50.5909C96.322 50.4235 96.4917 50.3217 96.6591 50.3635ZM96.3065 53.0625L95.8377 54.9375H97.6935L98.1623 53.0625H96.3065Z" fill="#8E8D91"/>
|
||||
</g>
|
||||
<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="7" letter-spacing="0em"><tspan x="104" y="56.5455">High</tspan></text>
|
||||
<rect x="16" y="65" width="12" height="12" rx="6" fill="url(#pattern0_16460_1097)"/>
|
||||
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="7" font-weight="500" letter-spacing="0em"><tspan x="32" y="71.5455">qpomelo</tspan></text>
|
||||
<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="7" letter-spacing="0em"><tspan x="32" y="79.5455">re-assigned to </tspan></text>
|
||||
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="7" font-weight="500" letter-spacing="0em"><tspan x="82.1006" y="79.5455">tsiheng</tspan></text>
|
||||
<rect x="16" y="87" width="12" height="12" rx="6" fill="url(#pattern1_16460_1097)"/>
|
||||
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="7" font-weight="500" letter-spacing="0em"><tspan x="32" y="93.5455">tsiheng</tspan></text>
|
||||
<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="7" letter-spacing="0em"><tspan x="32" y="101.545">re-assigned to </tspan></text>
|
||||
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="7" font-weight="500" letter-spacing="0em"><tspan x="82.1006" y="101.545">qpomelo</tspan></text>
|
||||
<path d="M22 79V85" stroke="#E3E2E4" stroke-width="0.5" stroke-linecap="round" stroke-dasharray="5 5"/>
|
||||
<path d="M22 101V107" stroke="#E3E2E4" stroke-width="0.5" stroke-linecap="round" stroke-dasharray="5 5"/>
|
||||
</g>
|
||||
<defs>
|
||||
<pattern id="pattern0_16460_1097" patternContentUnits="objectBoundingBox" width="1" height="1">
|
||||
<use xlink:href="#image0_16460_1097" transform="scale(0.0416667)"/>
|
||||
</pattern>
|
||||
<pattern id="pattern1_16460_1097" patternContentUnits="objectBoundingBox" width="1" height="1">
|
||||
<use xlink:href="#image1_16460_1097" transform="scale(0.0416667)"/>
|
||||
</pattern>
|
||||
<clipPath id="clip0_16460_1097">
|
||||
<rect width="10" height="10" fill="white" transform="translate(18 49)"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip1_16460_1097">
|
||||
<rect width="10" height="10" fill="white" transform="translate(92 49)"/>
|
||||
</clipPath>
|
||||
<image id="image0_16460_1097" width="24" height="24" xlink:href="data:image/png;base64,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"/>
|
||||
<image id="image1_16460_1097" width="24" height="24" xlink:href="data:image/png;base64,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"/>
|
||||
</defs>
|
||||
</svg>
|
||||
`;
|
||||
@@ -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,6 @@
|
||||
import { toEdgelessEmbedBlock } from '../common/to-edgeless-embed-block.js';
|
||||
import { EmbedHtmlBlockComponent } from './embed-html-block.js';
|
||||
|
||||
export class EmbedEdgelessHtmlBlockComponent extends toEdgelessEmbedBlock(
|
||||
EmbedHtmlBlockComponent
|
||||
) {}
|
||||
@@ -0,0 +1,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,17 @@
|
||||
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';
|
||||
|
||||
const flavour = EmbedHtmlBlockSchema.model.flavour;
|
||||
|
||||
export const EmbedHtmlBlockSpec: ExtensionType[] = [
|
||||
BlockViewExtension(flavour, model => {
|
||||
return model.parent?.flavour === 'affine:surface'
|
||||
? literal`affine-embed-edgeless-html-block`
|
||||
: literal`affine-embed-html-block`;
|
||||
}),
|
||||
createBuiltinToolbarConfigExtension(flavour),
|
||||
].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';
|
||||
@@ -0,0 +1,150 @@
|
||||
import { css, html } from 'lit';
|
||||
|
||||
export const EMBED_HTML_MIN_WIDTH = 370;
|
||||
export const EMBED_HTML_MIN_HEIGHT = 80;
|
||||
|
||||
export const styles = css`
|
||||
.affine-embed-html-block {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
padding: 12px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--affine-background-tertiary-color);
|
||||
|
||||
opacity: var(--add, 1);
|
||||
background: var(--affine-background-primary-color);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.affine-embed-html {
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
opacity: var(--add, 1);
|
||||
}
|
||||
|
||||
.affine-embed-html img,
|
||||
.affine-embed-html object,
|
||||
.affine-embed-html svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 4px 4px var(--1, 0px) var(--1, 0px);
|
||||
}
|
||||
|
||||
.affine-embed-html-iframe-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 4px 4px 0px 0px;
|
||||
box-shadow: var(--affine-shadow-1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.embed-html-block-iframe-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.embed-html-block-iframe-wrapper > iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.embed-html-block-iframe-wrapper affine-menu {
|
||||
min-width: 296px;
|
||||
}
|
||||
|
||||
.embed-html-block-iframe-wrapper affine-menu .settings-header {
|
||||
padding: 7px 12px;
|
||||
font-weight: 500;
|
||||
font-size: var(--affine-font-xs);
|
||||
color: var(--affine-text-secondary-color);
|
||||
}
|
||||
|
||||
.embed-html-block-iframe-wrapper > embed-html-fullscreen-toolbar {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.embed-html-block-iframe-wrapper:fullscreen > embed-html-fullscreen-toolbar {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.affine-embed-html-iframe-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.affine-embed-html-iframe-overlay.hide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.affine-embed-html-title {
|
||||
height: fit-content;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
padding: var(--1, 0px);
|
||||
border-radius: var(--1, 0px);
|
||||
opacity: var(--add, 1);
|
||||
}
|
||||
|
||||
.affine-embed-html-title-icon {
|
||||
display: flex;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.affine-embed-html-title-icon img,
|
||||
.affine-embed-html-title-icon object,
|
||||
.affine-embed-html-title-icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
fill: var(--affine-background-primary-color);
|
||||
}
|
||||
|
||||
.affine-embed-html-title-text {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
|
||||
word-break: break-word;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--affine-text-primary-color);
|
||||
|
||||
font-family: var(--affine-font-family);
|
||||
font-size: var(--affine-font-sm);
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
line-height: 22px;
|
||||
}
|
||||
`;
|
||||
|
||||
export const HtmlIcon = html`<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M6.66667 1.875C5.40101 1.875 4.375 2.90101 4.375 4.16667V6.66667C4.375 7.01184 4.65482 7.29167 5 7.29167C5.34518 7.29167 5.625 7.01184 5.625 6.66667V4.16667C5.625 3.59137 6.09137 3.125 6.66667 3.125H12.9349C13.2563 3.125 13.5598 3.27341 13.7571 3.52714L15.8222 6.18232C15.9645 6.36517 16.0417 6.5902 16.0417 6.82185V15C16.0417 15.5753 15.5753 16.0417 15 16.0417H6.66667C6.09137 16.0417 5.625 15.5753 5.625 15V13.75C5.625 13.4048 5.34518 13.125 5 13.125C4.65482 13.125 4.375 13.4048 4.375 13.75V15C4.375 16.2657 5.40101 17.2917 6.66667 17.2917H15C16.2657 17.2917 17.2917 16.2657 17.2917 15V6.82185C17.2917 6.31223 17.1218 5.81716 16.8089 5.4149L14.7438 2.75972C14.3096 2.2015 13.642 1.875 12.9349 1.875H6.66667ZM2.30713 11.4758C2.30713 11.7936 2.47945 11.9727 2.78158 11.9727C3.0837 11.9727 3.25602 11.7936 3.25602 11.4758V10.6679H4.3929V11.4758C4.3929 11.7936 4.56523 11.9727 4.86735 11.9727C5.16947 11.9727 5.3418 11.7936 5.3418 11.4758V9.12821C5.3418 8.81043 5.16947 8.63139 4.86735 8.63139C4.56523 8.63139 4.3929 8.81043 4.3929 9.12821V9.91374H3.25602V9.12821C3.25602 8.81043 3.0837 8.63139 2.78158 8.63139C2.47945 8.63139 2.30713 8.81043 2.30713 9.12821V11.4758ZM6.51672 11.4758C6.51672 11.7936 6.68905 11.9727 6.99117 11.9727C7.29329 11.9727 7.46562 11.7936 7.46562 11.4758V9.44377H7.9423C8.19295 9.44377 8.3608 9.30725 8.3608 9.06555C8.3608 8.82385 8.19743 8.68734 7.9423 8.68734H6.04004C5.78491 8.68734 5.62154 8.82385 5.62154 9.06555C5.62154 9.30725 5.78939 9.44377 6.04004 9.44377H6.51672V11.4758ZM9.05457 11.9727C8.79049 11.9727 8.64054 11.8138 8.64054 11.534V9.25354C8.64054 8.85518 8.85986 8.63139 9.25598 8.63139C9.58944 8.63139 9.76624 8.76343 9.90051 9.11479L10.46 10.5717H10.4779L11.0352 9.11479C11.1694 8.76343 11.3462 8.63139 11.6797 8.63139C12.0758 8.63139 12.2951 8.85518 12.2951 9.25354V11.534C12.2951 11.8138 12.1452 11.9727 11.8811 11.9727C11.617 11.9727 11.4671 11.8138 11.4671 11.534V10.0458H11.4492L10.8069 11.6638C10.742 11.8272 10.639 11.901 10.4712 11.901C10.3011 11.901 10.1914 11.825 10.1288 11.6638L9.48649 10.0458H9.46859V11.534C9.46859 11.8138 9.31864 11.9727 9.05457 11.9727ZM12.745 11.4199C12.745 11.7377 12.9173 11.9167 13.2194 11.9167H14.5868C14.8419 11.9167 15.0053 11.7802 15.0053 11.5385C15.0053 11.2968 14.8374 11.1603 14.5868 11.1603H13.6938V9.12821C13.6938 8.81043 13.5215 8.63139 13.2194 8.63139C12.9173 8.63139 12.745 8.81043 12.745 9.12821V11.4199Z"
|
||||
fill="#77757D"
|
||||
/>
|
||||
</svg> `;
|
||||
@@ -0,0 +1,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';
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
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.doc.getParent(block.model);
|
||||
if (!parent) return;
|
||||
const index = parent.children.indexOf(block.model);
|
||||
newBlockId = host.doc.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(
|
||||
// @ts-expect-error FIXME: resolve after gfx tool refactor
|
||||
'default'
|
||||
);
|
||||
|
||||
gfx.selection.set({
|
||||
elements: [newBlockId],
|
||||
editing: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (!newBlockId) {
|
||||
return;
|
||||
}
|
||||
|
||||
next({ blockId: newBlockId, flavour });
|
||||
};
|
||||
+55
@@ -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];
|
||||
}),
|
||||
});
|
||||
};
|
||||
+342
@@ -0,0 +1,342 @@
|
||||
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 = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
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',
|
||||
});
|
||||
};
|
||||
|
||||
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.doc.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!: () => void;
|
||||
|
||||
@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>`;
|
||||
+132
@@ -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,
|
||||
};
|
||||
}
|
||||
+122
@@ -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',
|
||||
other: 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';
|
||||
}
|
||||
}
|
||||
+160
@@ -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.doc;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
+292
@@ -0,0 +1,292 @@
|
||||
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',
|
||||
other: 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;
|
||||
}
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
import { ThemeProvider } from '@blocksuite/affine-shared/services';
|
||||
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 { getEmbedCardIcons } from '../../common/utils';
|
||||
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 theme = this.std.get(ThemeProvider).theme;
|
||||
const { LoadingIcon } = getEmbedCardIcons(theme);
|
||||
|
||||
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,42 @@
|
||||
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'],
|
||||
};
|
||||
|
||||
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,
|
||||
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);
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
const googleDocsConfig = {
|
||||
name: 'google-docs',
|
||||
match: (url: string) => isValidGoogleDocsUrl(url),
|
||||
buildOEmbedUrl: (url: string) => {
|
||||
if (!isValidGoogleDocsUrl(url)) {
|
||||
return undefined;
|
||||
}
|
||||
return url;
|
||||
},
|
||||
useOEmbedUrlDirectly: true,
|
||||
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);
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
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,13 @@
|
||||
import { ExcalidrawEmbedConfig } from './excalidraw';
|
||||
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,
|
||||
];
|
||||
@@ -0,0 +1,46 @@
|
||||
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'],
|
||||
};
|
||||
|
||||
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,
|
||||
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,47 @@
|
||||
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 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,
|
||||
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);
|
||||
+37
@@ -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.doc.schema.flavourSchemaMap.has('affine:embed-iframe');
|
||||
},
|
||||
action: ({ std }) => {
|
||||
std.command
|
||||
.chain()
|
||||
.pipe(getSelectedModelsCommand)
|
||||
.pipe(insertEmptyEmbedIframeCommand, {
|
||||
place: 'after',
|
||||
removeEmptyLine: true,
|
||||
linkInputPopupOptions: {
|
||||
telemetrySegment: 'slash menu',
|
||||
},
|
||||
})
|
||||
.run();
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import { html } from 'lit';
|
||||
|
||||
export const EmbedIframeTooltip = html`
|
||||
<svg
|
||||
width="170"
|
||||
height="106"
|
||||
viewBox="0 0 170 106"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
>
|
||||
<rect width="170" height="106" rx="2" fill="white" />
|
||||
<mask
|
||||
id="mask0_888_46686"
|
||||
style="mask-type:alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="170"
|
||||
height="106"
|
||||
>
|
||||
<rect width="170" height="106" rx="2" fill="white" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_888_46686)">
|
||||
<path
|
||||
d="M10.8807 19V11.7273H15.2415V12.5085H11.7614V14.9659H14.9148V15.7472H11.7614V19H10.8807ZM18.4517 19.1136C17.9593 19.1136 17.5272 18.9964 17.1555 18.7621C16.7862 18.5277 16.4974 18.1998 16.2891 17.7784C16.0831 17.357 15.9801 16.8646 15.9801 16.3011C15.9801 15.733 16.0831 15.237 16.2891 14.8132C16.4974 14.3894 16.7862 14.0604 17.1555 13.826C17.5272 13.5916 17.9593 13.4744 18.4517 13.4744C18.9441 13.4744 19.375 13.5916 19.7443 13.826C20.116 14.0604 20.4048 14.3894 20.6108 14.8132C20.8191 15.237 20.9233 15.733 20.9233 16.3011C20.9233 16.8646 20.8191 17.357 20.6108 17.7784C20.4048 18.1998 20.116 18.5277 19.7443 18.7621C19.375 18.9964 18.9441 19.1136 18.4517 19.1136ZM18.4517 18.3608C18.8258 18.3608 19.1335 18.2649 19.375 18.0732C19.6165 17.8814 19.7952 17.6293 19.9112 17.3168C20.0272 17.0043 20.0852 16.6657 20.0852 16.3011C20.0852 15.9366 20.0272 15.5968 19.9112 15.282C19.7952 14.9671 19.6165 14.7126 19.375 14.5185C19.1335 14.3243 18.8258 14.2273 18.4517 14.2273C18.0777 14.2273 17.7699 14.3243 17.5284 14.5185C17.2869 14.7126 17.1082 14.9671 16.9922 15.282C16.8762 15.5968 16.8182 15.9366 16.8182 16.3011C16.8182 16.6657 16.8762 17.0043 16.9922 17.3168C17.1082 17.6293 17.2869 17.8814 17.5284 18.0732C17.7699 18.2649 18.0777 18.3608 18.4517 18.3608ZM22.2026 19V13.5455H23.0123V14.3693H23.0691C23.1685 14.0994 23.3484 13.8804 23.6088 13.7124C23.8693 13.5443 24.1628 13.4602 24.4895 13.4602C24.5511 13.4602 24.628 13.4614 24.7203 13.4638C24.8127 13.4661 24.8825 13.4697 24.9299 13.4744V14.3267C24.9015 14.3196 24.8364 14.3089 24.7346 14.2947C24.6351 14.2782 24.5298 14.2699 24.4185 14.2699C24.1534 14.2699 23.9166 14.3255 23.7083 14.4368C23.5023 14.5457 23.339 14.6972 23.2182 14.8913C23.0998 15.0831 23.0407 15.3021 23.0407 15.5483V19H22.2026ZM33.821 14C33.7429 13.7609 33.6399 13.5466 33.5121 13.3572C33.3866 13.1655 33.2363 13.0021 33.0611 12.8672C32.8883 12.7322 32.6918 12.6293 32.4716 12.5582C32.2514 12.4872 32.0099 12.4517 31.7472 12.4517C31.3163 12.4517 30.9245 12.563 30.5717 12.7855C30.219 13.008 29.9384 13.3359 29.7301 13.7692C29.5218 14.2024 29.4176 14.7339 29.4176 15.3636C29.4176 15.9934 29.523 16.5249 29.7337 16.9581C29.9444 17.3913 30.2296 17.7192 30.5895 17.9418C30.9493 18.1643 31.3542 18.2756 31.804 18.2756C32.2206 18.2756 32.5876 18.1868 32.9048 18.0092C33.2244 17.8293 33.473 17.576 33.6506 17.2493C33.8305 16.9202 33.9205 16.5331 33.9205 16.0881L34.1903 16.1449H32.0028V15.3636H34.7727V16.1449C34.7727 16.7438 34.6449 17.2647 34.3892 17.7074C34.1359 18.1501 33.7855 18.4934 33.3381 18.7372C32.893 18.9787 32.3816 19.0994 31.804 19.0994C31.16 19.0994 30.5942 18.9479 30.1065 18.6449C29.6212 18.3419 29.2424 17.911 28.9702 17.3523C28.7003 16.7936 28.5653 16.1307 28.5653 15.3636C28.5653 14.7884 28.6423 14.2711 28.7962 13.8118C28.9524 13.3501 29.1726 12.9571 29.4567 12.6328C29.7408 12.3085 30.0769 12.0599 30.4652 11.8871C30.8535 11.7143 31.2808 11.6278 31.7472 11.6278C32.1307 11.6278 32.4882 11.6858 32.8196 11.8018C33.1534 11.9155 33.4505 12.0777 33.7109 12.2884C33.9737 12.4967 34.1927 12.7464 34.3679 13.0376C34.5431 13.3265 34.6638 13.6473 34.7301 14H33.821ZM38.3833 19.1136C37.8909 19.1136 37.4589 18.9964 37.0872 18.7621C36.7179 18.5277 36.429 18.1998 36.2207 17.7784C36.0147 17.357 35.9118 16.8646 35.9118 16.3011C35.9118 15.733 36.0147 15.237 36.2207 14.8132C36.429 14.3894 36.7179 14.0604 37.0872 13.826C37.4589 13.5916 37.8909 13.4744 38.3833 13.4744C38.8758 13.4744 39.3066 13.5916 39.676 13.826C40.0476 14.0604 40.3365 14.3894 40.5424 14.8132C40.7508 15.237 40.8549 15.733 40.8549 16.3011C40.8549 16.8646 40.7508 17.357 40.5424 17.7784C40.3365 18.1998 40.0476 18.5277 39.676 18.7621C39.3066 18.9964 38.8758 19.1136 38.3833 19.1136ZM38.3833 18.3608C38.7574 18.3608 39.0652 18.2649 39.3066 18.0732C39.5481 17.8814 39.7269 17.6293 39.8429 17.3168C39.9589 17.0043 40.0169 16.6657 40.0169 16.3011C40.0169 15.9366 39.9589 15.5968 39.8429 15.282C39.7269 14.9671 39.5481 14.7126 39.3066 14.5185C39.0652 14.3243 38.7574 14.2273 38.3833 14.2273C38.0093 14.2273 37.7015 14.3243 37.46 14.5185C37.2186 14.7126 37.0398 14.9671 36.9238 15.282C36.8078 15.5968 36.7498 15.9366 36.7498 16.3011C36.7498 16.6657 36.8078 17.0043 36.9238 17.3168C37.0398 17.6293 37.2186 17.8814 37.46 18.0732C37.7015 18.2649 38.0093 18.3608 38.3833 18.3608ZM44.3501 19.1136C43.8577 19.1136 43.4257 18.9964 43.054 18.7621C42.6847 18.5277 42.3958 18.1998 42.1875 17.7784C41.9815 17.357 41.8786 16.8646 41.8786 16.3011C41.8786 15.733 41.9815 15.237 42.1875 14.8132C42.3958 14.3894 42.6847 14.0604 43.054 13.826C43.4257 13.5916 43.8577 13.4744 44.3501 13.4744C44.8426 13.4744 45.2734 13.5916 45.6428 13.826C46.0144 14.0604 46.3033 14.3894 46.5092 14.8132C46.7176 15.237 46.8217 15.733 46.8217 16.3011C46.8217 16.8646 46.7176 17.357 46.5092 17.7784C46.3033 18.1998 46.0144 18.5277 45.6428 18.7621C45.2734 18.9964 44.8426 19.1136 44.3501 19.1136ZM44.3501 18.3608C44.7242 18.3608 45.032 18.2649 45.2734 18.0732C45.5149 17.8814 45.6937 17.6293 45.8097 17.3168C45.9257 17.0043 45.9837 16.665Line truncated
|
||||
fill="#7A7A7A"
|
||||
/>
|
||||
<rect
|
||||
x="8.5"
|
||||
y="28.5"
|
||||
width="169"
|
||||
height="121"
|
||||
rx="3.5"
|
||||
fill="white"
|
||||
stroke="#E6E6E6"
|
||||
/>
|
||||
<path
|
||||
d="M19.598 42.0852V45H18.6424V38.4545H19.5852V40.8899H19.646C19.761 40.6257 19.9368 40.4158 20.1733 40.2603C20.4098 40.1048 20.7188 40.027 21.1001 40.027C21.4368 40.027 21.7308 40.0962 21.9822 40.2347C22.2358 40.3732 22.4318 40.5799 22.5703 40.8548C22.7109 41.1275 22.7812 41.4684 22.7812 41.8775V45H21.8256V41.9925C21.8256 41.6325 21.733 41.3533 21.5476 41.1552C21.3622 40.9549 21.1044 40.8548 20.7741 40.8548C20.5483 40.8548 20.3459 40.9027 20.1669 40.9986C19.9901 41.0945 19.8505 41.2351 19.7482 41.4205C19.6481 41.6037 19.598 41.8253 19.598 42.0852ZM26.348 40.0909V40.858H23.6665V40.0909H26.348ZM24.3857 38.9148H25.3413V43.5586C25.3413 43.744 25.369 43.8835 25.4244 43.9773C25.4798 44.0689 25.5511 44.1317 25.6385 44.1658C25.728 44.1978 25.8249 44.2138 25.9293 44.2138C26.006 44.2138 26.0732 44.2085 26.1307 44.1978C26.1882 44.1871 26.233 44.1786 26.2649 44.1722L26.4375 44.9616C26.3821 44.983 26.3033 45.0043 26.201 45.0256C26.0987 45.049 25.9709 45.0618 25.8175 45.0639C25.5661 45.0682 25.3317 45.0234 25.1143 44.9297C24.897 44.8359 24.7212 44.6911 24.587 44.495C24.4528 44.299 24.3857 44.0529 24.3857 43.7567V38.9148ZM29.6966 40.0909V40.858H27.0152V40.0909H29.6966ZM27.7343 38.9148H28.6899V43.5586C28.6899 43.744 28.7176 43.8835 28.773 43.9773C28.8284 44.0689 28.8998 44.1317 28.9871 44.1658C29.0766 44.1978 29.1736 44.2138 29.278 44.2138C29.3547 44.2138 29.4218 44.2085 29.4793 44.1978C29.5368 44.1871 29.5816 44.1786 29.6135 44.1722L29.7861 44.9616C29.7307 44.983 29.6519 45.0043 29.5496 45.0256C29.4474 45.049 29.3195 45.0618 29.1661 45.0639C28.9147 45.0682 28.6803 45.0234 28.463 44.9297C28.2456 44.8359 28.0699 44.6911 27.9356 44.495C27.8014 44.299 27.7343 44.0529 27.7343 43.7567V38.9148ZM30.7537 46.8409V40.0909H31.687V40.8867H31.7669C31.8223 40.7844 31.9022 40.6662 32.0066 40.532C32.111 40.3977 32.2559 40.2805 32.4412 40.1804C32.6266 40.0781 32.8716 40.027 33.1763 40.027C33.5726 40.027 33.9263 40.1271 34.2374 40.3274C34.5485 40.5277 34.7924 40.8164 34.9693 41.1935C35.1483 41.5707 35.2377 42.0245 35.2377 42.555C35.2377 43.0856 35.1493 43.5405 34.9725 43.9197C34.7956 44.2969 34.5527 44.5877 34.2438 44.7923C33.9348 44.9947 33.5822 45.0959 33.1859 45.0959C32.8876 45.0959 32.6436 45.0458 32.454 44.9457C32.2665 44.8455 32.1195 44.7283 32.013 44.5941C31.9064 44.4599 31.8244 44.3406 31.7669 44.2362H31.7093V46.8409H30.7537ZM31.6902 42.5455C31.6902 42.8906 31.7402 43.1932 31.8404 43.4531C31.9405 43.7131 32.0854 43.9165 32.275 44.0636C32.4647 44.2085 32.6969 44.2809 32.9718 44.2809C33.2573 44.2809 33.4959 44.2053 33.6877 44.054C33.8794 43.9006 34.0243 43.6928 34.1223 43.4308C34.2225 43.1687 34.2725 42.8736 34.2725 42.5455C34.2725 42.2216 34.2235 41.9308 34.1255 41.6729C34.0297 41.4151 33.8848 41.2116 33.6909 41.0625C33.4991 40.9134 33.2594 40.8388 32.9718 40.8388C32.6948 40.8388 32.4604 40.9102 32.2686 41.0529C32.079 41.1957 31.9352 41.3949 31.8372 41.6506C31.7392 41.9062 31.6902 42.2045 31.6902 42.5455ZM39.987 41.2894L39.1209 41.4428C39.0847 41.332 39.0272 41.2266 38.9483 41.1264C38.8716 41.0263 38.7672 40.9442 38.6351 40.8803C38.503 40.8164 38.3379 40.7844 38.1397 40.7844C37.8691 40.7844 37.6433 40.8452 37.4622 40.9666C37.2811 41.0859 37.1905 41.2404 37.1905 41.43C37.1905 41.5941 37.2512 41.7262 37.3727 41.8263C37.4941 41.9265 37.6902 42.0085 37.9608 42.0724L38.7406 42.2514C39.1923 42.3558 39.5289 42.5167 39.7505 42.734C39.9721 42.9513 40.0829 43.2337 40.0829 43.581C40.0829 43.875 39.9977 44.1371 39.8272 44.3672C39.6589 44.5952 39.4235 44.7741 39.1209 44.9041C38.8205 45.0341 38.4721 45.0991 38.0758 45.0991C37.5261 45.0991 37.0776 44.9819 36.7303 44.7475C36.383 44.511 36.1699 44.1754 36.0911 43.7408L37.0147 43.6001C37.0723 43.8409 37.1905 44.0231 37.3695 44.1467C37.5485 44.2681 37.7818 44.3288 38.0694 44.3288C38.3826 44.3288 38.633 44.2638 38.8205 44.1339C39.008 44.0018 39.1017 43.8409 39.1017 43.6513C39.1017 43.4979 39.0442 43.369 38.9292 43.2646C38.8162 43.1602 38.6426 43.0813 38.4082 43.0281L37.5772 42.8459C37.1191 42.7415 36.7804 42.5753 36.5609 42.3473C36.3436 42.1193 36.2349 41.8306 36.2349 41.4812C36.2349 41.1914 36.3159 40.9379 36.4778 40.7205C36.6397 40.5032 36.8635 40.3338 37.149 40.2124C37.4345 40.0888 37.7615 40.027 38.1301 40.027C38.6607 40.027 39.0783 40.142 39.383 40.3722C39.6877 40.6001 39.889 40.9059 39.987 41.2894ZM41.7632 45.0607C41.5885 45.0607 41.4383 44.9989 41.3126 44.8754C41.1869 44.7496 41.124 44.5984 41.124 44.4215C41.124 44.2468 41.1869 44.0977 41.3126 43.9741C41.4383 43.8484 41.5885 43.7855 41.7632 43.7855C41.9379 43.7855 42.0882 43.8484 42.2139 43.9741C42.3396 44.0977 42.4024 44.2468 42.4024 44.4215C42.4024 44.5387 42.3726 44.6463 42.3129 44.7443C42.2554 44.8402 42.1787 44.9169 42.0828 44.9744C41.9869 45.032 41.8804 45.0607 41.7632 45.0607ZM41.7632 41.4876C41.5885 41.4876 41.4383 41.4247 41.3126 41.299C41.1869 41.1733 41.124 41.0231 41.124 40.8484C41.124 40.6737 41.1869 40.5245 41.3126 40.4009C41.4383 40.2752 41.5885 40.2124 41.7632 40.2124C41.9379 40.2124 42.0882 40.2752 42.2139 40.4009C42.339Line truncated
|
||||
fill="#141414"
|
||||
/>
|
||||
<rect
|
||||
x="16.25"
|
||||
y="53.25"
|
||||
width="153.5"
|
||||
height="133.5"
|
||||
rx="1.75"
|
||||
fill="url(#pattern0_888_46686)"
|
||||
stroke="#E6E6E6"
|
||||
stroke-width="0.5"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<pattern
|
||||
id="pattern0_888_46686"
|
||||
patternContentUnits="objectBoundingBox"
|
||||
width="1"
|
||||
height="1"
|
||||
>
|
||||
<use
|
||||
xlink:href="#image0_888_46686"
|
||||
transform="scale(0.00324675 0.00373134)"
|
||||
/>
|
||||
</pattern>
|
||||
<image
|
||||
id="image0_888_46686"
|
||||
width="308"
|
||||
height="268"
|
||||
preserveAspectRatio="none"
|
||||
xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAATQAAAEMCAYAAABKqEpfAAAACXBIWXMAABYlAAAWJQFJUiTwAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAImFSURBVHgB7X0HmBxHmfbXszkHSSutVmmVk63obDkHDLbB5ANzwJHzwRGOO45whANMuIOfnI4jGIONA9gG56hgyco5h9Uq7mpzmJ3w1/v11HR1T/XuzM7ObFC9elYz09NTXV3d9faXyyKBQ4dOzMjNCf9KvF0q/irJwMDAYNTAeiA3L/Dx2traw1aMzDaVlBRVFhUXkWVZZGBgYDAaEI1Gqae7lzo6Olty83KW5ebmRL5bWlrMZGZgYGAwmgABrKi4kCLRSGVXZ/evAoLjXlNYVEAGBgYGoxXFtkC2NID/LStABgYGBqMVMVNZpWEyAwODMQNDaAYGBmMGhtAMDAzGDAyhGRgYjBkYQjMwMBgzMIRmYGAwZmAIzcDAYMzAEJqBgcGYgSE0AwODMQNDaAYGBmMGhtAMDAzGDAyhGRgYjBkYQjMwMBgzMIRmYGAwZmAIzcDAYMzAEJqBgcGYgSE0A4pEImRgMBZgCO08x9q162ju3Hl0ySWX0j/8w1vor3/9K4VCITIwGI0whJYC7t0Toot/10mZxqOPPkpPPvkUZQPLli2l5cuX0ZYtW+j++++n17/+DTR79hz62c9+Ti0tLWRgMJpgCC0FnOiKUEtvlN785n+gefPm0//+7/8m7HPuXDMtWLCQv7/nnnsSvt+7dy9/t3DhIuro6Ej4ft++fXTHHa+lV73qVbRx40bKNAoKCuiPf/wjXXvttfx++vTpdPLkSfrIRz5Ct9/+atqxYycZGIwW5JJByli6dCk98MAD4u9Besc73uH67oknnqJDhw7x+9Wr19Cb3vQm1/eQvI4cOUKLFi2i0tLShLbLy8tp4sSJvN7glClTeNtrXnMHE91DDz1Es2bNpEzg+PHjbEt74onHWVr7+Mc/QS+99BLddtttgrh/RVdddRWNZBxpj9DJjuiA++UFLFo0PkAFOc42nPeDDz6Y8IApLi6mOXPm0OLFiykQsJ/9wWCQH1T5+fkJ19Zg+GEIbRC46KKL+HXt2rUJ36nbQED//d/fdS3e/OKLL/AriEIHkNmBA/uZ0PLy8ngbCPLAgQNaiW4oAIIFYY4bN47Gjx9Pt956K1133XX0vve9j/70p3vpda97vSC6x2jJkqW8P6TQqqrq+O9BCA0NDTR58mTKzbVvKfQf/Z44cRKVlBTztm3btol9asVxxruOHw6HmUSXL1+e0LcXXniBLr/88jih6PClNT30u13J2/1qSyz6w61F4tUhqX/+54/TqVOntPvPnDmTvv/979H1118vzv0cvec976XKykpDaCMQRuUcBC655GKevLAx7dq1O74dE3v16tX8HpLWiRMneKJLYJI//7xNaDfddCP96lf/S3fffTd//vvfH6O77voWPf744/Sb3/yWfvvb3zHJYB9MIuDee//En48dOxZvs6HhOP3yl7+kb33r2/TTn/40Lh2mAvQZfZszZzYVFdkLTkM6+fWvf80SaHt7O7397e9kZ8Hp06eFWlovzuN53u+RRx6l+vqZ7Fj4yU9+ytueeOIJWrFiJavVkERfeOFF3v62t/2j+Ht7wvE/+9l/E6R1BX3ve99zbd+wYQPdcIM9Tn442BpJicyAE51R+smWPu13H/jA++lzn/sc/33qU58UNsZldPDgQbrzzreJa23U75EOI6ENAlAVL7nkEjaiP/roI8JmNp+3Q9KBpDFlSh0/vb/97e/E1M6p/D3IDVIAyGL+/PlCCrqen/S7du1iQgIhvuc97xEG+Z/x/t///vfZliUBwgN+85v/o6lTp9KPfvRj+tKXvuQy3oOQPvnJT9K///u/UbJYt+4lfl21yq1WQir62te+yiS7e/duQb5/oKuvvoqJDe+hpr7rXe9mSfLHP/6R8JL+A/3857/g4/f09NCkSZOEVNlO//iP/0hr1qxhSeypp55iGx2+A0DWv/3tb/j9L37xS5YKYcsDHn74EX7Nzc3x7fuZroHVTB16wvrfvf/9HxA2zrnxzxjfN77xjcL7+7CQtr9H//mfXyKDkQsjoQ0Sl19+Gb9u2bI1vg32MZDSbbfdHldLYZuReOyxx/j15ptvppwce5JC+vnv//4f+tjHPiqI7Kfit7fG97/hhuuF2vog1dXV8ecf/ehH/PnKK68QquCfhJ3r46zO/uAHPxD2rnVCUvsFE+SXv/xlse2HlAwkyQCve90dCd9XV1fTRz/6kXj/QV5QK/fv30//+q+fpRkzZghVcitLcpDMQGYgvLvuukvss4+++tWvUmNjo5DkHokT1ZkzZ+Pto+/NzefYJnXmzBlW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
Line truncated
|
||||
/>
|
||||
</defs>
|
||||
</svg>
|
||||
`;
|
||||
@@ -0,0 +1,485 @@
|
||||
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}
|
||||
.viewType$=${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, store);
|
||||
|
||||
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().catch(console.error);
|
||||
|
||||
ctx.track('ReloadLink', {
|
||||
...trackBaseProps,
|
||||
control: 'reload link',
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
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}
|
||||
.viewType$=${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}
|
||||
.size$=${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,55 @@
|
||||
import { EdgelessLegacySlotIdentifier } from '@blocksuite/affine-block-surface';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
import { toGfxBlockComponent } from '@blocksuite/std';
|
||||
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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
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,
|
||||
LinkPreviewerService,
|
||||
NotificationProvider,
|
||||
} 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 } 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';
|
||||
|
||||
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;
|
||||
|
||||
get embedIframeService() {
|
||||
return this.std.get(EmbedIframeService);
|
||||
}
|
||||
|
||||
get linkPreviewService() {
|
||||
return this.std.get(LinkPreviewerService);
|
||||
}
|
||||
|
||||
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');
|
||||
};
|
||||
|
||||
refreshData = async () => {
|
||||
try {
|
||||
const { url } = this.model.props;
|
||||
if (!url) {
|
||||
this.status$.value = 'idle';
|
||||
return;
|
||||
}
|
||||
|
||||
// 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 LinkPreviewerService 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;
|
||||
this.doc.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';
|
||||
} 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);
|
||||
}
|
||||
};
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
this._linkInputAbortController = new AbortController();
|
||||
|
||||
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: {
|
||||
referenceElement: this._blockContainer,
|
||||
placement: 'bottom',
|
||||
middleware: [flip(), offset(LINK_CREATE_POPUP_OFFSET), shift()],
|
||||
autoUpdate: { animationFrame: true },
|
||||
},
|
||||
abortController: this._linkInputAbortController,
|
||||
closeOnClickAway: true,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
};
|
||||
|
||||
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 () => {
|
||||
await this.refreshData();
|
||||
};
|
||||
|
||||
private readonly _renderIframe = () => {
|
||||
const { iframeUrl } = this.model.props;
|
||||
const {
|
||||
widthPercent,
|
||||
heightInNote,
|
||||
style,
|
||||
allow,
|
||||
referrerpolicy,
|
||||
scrolling,
|
||||
allowFullscreen,
|
||||
} = 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;
|
||||
return html`
|
||||
<iframe
|
||||
width=${width ?? DEFAULT_IFRAME_WIDTH}
|
||||
height=${height ?? DEFAULT_IFRAME_HEIGHT}
|
||||
?allowfullscreen=${allowFullscreen}
|
||||
loading="lazy"
|
||||
frameborder="0"
|
||||
src=${ifDefined(iframeUrl)}
|
||||
allow=${ifDefined(allow)}
|
||||
referrerpolicy=${ifDefined(referrerpolicy)}
|
||||
scrolling=${ifDefined(scrolling)}
|
||||
style=${ifDefined(style)}
|
||||
></iframe>
|
||||
`;
|
||||
};
|
||||
|
||||
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.doc.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 = 'success';
|
||||
}
|
||||
|
||||
// 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.doc.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 { EmbedIframeBlockAdapterExtensions } from './adapters';
|
||||
import { embedIframeSlashMenuConfig } from './configs/slash-menu/slash-menu';
|
||||
import { createBuiltinToolbarConfigExtension } from './configs/toolbar';
|
||||
|
||||
const flavour = EmbedIframeBlockSchema.model.flavour;
|
||||
|
||||
export const EmbedIframeBlockSpec: ExtensionType[] = [
|
||||
FlavourExtension(flavour),
|
||||
BlockViewExtension(flavour, model => {
|
||||
return model.parent?.flavour === 'affine:surface'
|
||||
? literal`affine-embed-edgeless-iframe-block`
|
||||
: literal`affine-embed-iframe-block`;
|
||||
}),
|
||||
EmbedIframeBlockAdapterExtensions,
|
||||
createBuiltinToolbarConfigExtension(flavour),
|
||||
SlashMenuConfigExtension(flavour, embedIframeSlashMenuConfig),
|
||||
].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,29 @@
|
||||
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-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;
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
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']
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
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,11 @@
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
|
||||
import { EmbedLinkedDocHtmlAdapterExtension } from './html.js';
|
||||
import { EmbedLinkedDocMarkdownAdapterExtension } from './markdown.js';
|
||||
import { EmbedLinkedDocBlockPlainTextAdapterExtension } from './plain-text.js';
|
||||
|
||||
export const EmbedLinkedDocBlockAdapterExtensions: ExtensionType[] = [
|
||||
EmbedLinkedDocHtmlAdapterExtension,
|
||||
EmbedLinkedDocMarkdownAdapterExtension,
|
||||
EmbedLinkedDocBlockPlainTextAdapterExtension,
|
||||
];
|
||||
@@ -0,0 +1,63 @@
|
||||
import { EmbedLinkedDocBlockSchema } from '@blocksuite/affine-model';
|
||||
import {
|
||||
AdapterTextUtils,
|
||||
BlockHtmlAdapterExtension,
|
||||
type BlockHtmlAdapterMatcher,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
|
||||
export const embedLinkedDocBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = {
|
||||
flavour: EmbedLinkedDocBlockSchema.model.flavour,
|
||||
toMatch: () => false,
|
||||
fromMatch: o => o.node.flavour === EmbedLinkedDocBlockSchema.model.flavour,
|
||||
toBlockSnapshot: {},
|
||||
fromBlockSnapshot: {
|
||||
enter: (o, context) => {
|
||||
const { configs, walkerContext } = context;
|
||||
// Parse as link
|
||||
if (!o.node.props.pageId) {
|
||||
return;
|
||||
}
|
||||
const title = configs.get('title:' + o.node.props.pageId) ?? 'untitled';
|
||||
const url = AdapterTextUtils.generateDocUrl(
|
||||
configs.get('docLinkBaseUrl') ?? '',
|
||||
String(o.node.props.pageId),
|
||||
o.node.props.params ?? Object.create(null)
|
||||
);
|
||||
|
||||
walkerContext
|
||||
.openNode(
|
||||
{
|
||||
type: 'element',
|
||||
tagName: 'div',
|
||||
properties: {
|
||||
className: ['affine-paragraph-block-container'],
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
'children'
|
||||
)
|
||||
.openNode(
|
||||
{
|
||||
type: 'element',
|
||||
tagName: 'a',
|
||||
properties: {
|
||||
href: url,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
value: title,
|
||||
},
|
||||
],
|
||||
},
|
||||
'children'
|
||||
)
|
||||
.closeNode()
|
||||
.closeNode();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const EmbedLinkedDocHtmlAdapterExtension = BlockHtmlAdapterExtension(
|
||||
embedLinkedDocBlockHtmlAdapterMatcher
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './html.js';
|
||||
export * from './markdown.js';
|
||||
export * from './plain-text.js';
|
||||
@@ -0,0 +1,56 @@
|
||||
import { EmbedLinkedDocBlockSchema } from '@blocksuite/affine-model';
|
||||
import {
|
||||
AdapterTextUtils,
|
||||
BlockMarkdownAdapterExtension,
|
||||
type BlockMarkdownAdapterMatcher,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
|
||||
export const embedLinkedDocBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher =
|
||||
{
|
||||
flavour: EmbedLinkedDocBlockSchema.model.flavour,
|
||||
toMatch: () => false,
|
||||
fromMatch: o => o.node.flavour === EmbedLinkedDocBlockSchema.model.flavour,
|
||||
toBlockSnapshot: {},
|
||||
fromBlockSnapshot: {
|
||||
enter: (o, context) => {
|
||||
const { configs, walkerContext } = context;
|
||||
// Parse as link
|
||||
if (!o.node.props.pageId) {
|
||||
return;
|
||||
}
|
||||
const title = configs.get('title:' + o.node.props.pageId) ?? 'untitled';
|
||||
const url = AdapterTextUtils.generateDocUrl(
|
||||
configs.get('docLinkBaseUrl') ?? '',
|
||||
String(o.node.props.pageId),
|
||||
o.node.props.params ?? Object.create(null)
|
||||
);
|
||||
walkerContext
|
||||
.openNode(
|
||||
{
|
||||
type: 'paragraph',
|
||||
children: [],
|
||||
},
|
||||
'children'
|
||||
)
|
||||
.openNode(
|
||||
{
|
||||
type: 'link',
|
||||
url,
|
||||
title: o.node.props.caption as string | null,
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
value: title,
|
||||
},
|
||||
],
|
||||
},
|
||||
'children'
|
||||
)
|
||||
.closeNode()
|
||||
.closeNode();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const EmbedLinkedDocMarkdownAdapterExtension =
|
||||
BlockMarkdownAdapterExtension(embedLinkedDocBlockMarkdownAdapterMatcher);
|
||||
@@ -0,0 +1,33 @@
|
||||
import { EmbedLinkedDocBlockSchema } from '@blocksuite/affine-model';
|
||||
import {
|
||||
AdapterTextUtils,
|
||||
BlockPlainTextAdapterExtension,
|
||||
type BlockPlainTextAdapterMatcher,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
|
||||
export const embedLinkedDocBlockPlainTextAdapterMatcher: BlockPlainTextAdapterMatcher =
|
||||
{
|
||||
flavour: EmbedLinkedDocBlockSchema.model.flavour,
|
||||
toMatch: () => false,
|
||||
fromMatch: o => o.node.flavour === EmbedLinkedDocBlockSchema.model.flavour,
|
||||
toBlockSnapshot: {},
|
||||
fromBlockSnapshot: {
|
||||
enter: (o, context) => {
|
||||
const { configs, textBuffer } = context;
|
||||
// Parse as link
|
||||
if (!o.node.props.pageId) {
|
||||
return;
|
||||
}
|
||||
const title = configs.get('title:' + o.node.props.pageId) ?? 'untitled';
|
||||
const url = AdapterTextUtils.generateDocUrl(
|
||||
configs.get('docLinkBaseUrl') ?? '',
|
||||
String(o.node.props.pageId),
|
||||
o.node.props.params ?? Object.create(null)
|
||||
);
|
||||
textBuffer.content += `${title}: ${url}\n`;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const EmbedLinkedDocBlockPlainTextAdapterExtension =
|
||||
BlockPlainTextAdapterExtension(embedLinkedDocBlockPlainTextAdapterMatcher);
|
||||
@@ -0,0 +1 @@
|
||||
export * from './insert-embed-linked-doc';
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import type { EmbedCardStyle, ReferenceParams } from '@blocksuite/affine-model';
|
||||
import type { Command } from '@blocksuite/std';
|
||||
|
||||
import { insertEmbedCard } from '../../common/insert-embed-card.js';
|
||||
|
||||
export type LinkableFlavour =
|
||||
| 'affine:bookmark'
|
||||
| 'affine:embed-linked-doc'
|
||||
| 'affine:embed-iframe'
|
||||
| 'affine:embed-figma'
|
||||
| 'affine:embed-github'
|
||||
| 'affine:embed-loom'
|
||||
| 'affine:embed-youtube';
|
||||
|
||||
export type InsertedLinkType = {
|
||||
flavour: LinkableFlavour;
|
||||
} | null;
|
||||
|
||||
export const insertEmbedLinkedDocCommand: Command<
|
||||
{
|
||||
docId: string;
|
||||
params?: ReferenceParams;
|
||||
},
|
||||
{ blockId: string }
|
||||
> = (ctx, next) => {
|
||||
const { docId, params, std } = ctx;
|
||||
const flavour = 'affine:embed-linked-doc';
|
||||
const targetStyle: EmbedCardStyle = 'vertical';
|
||||
const props: Record<string, unknown> = { pageId: docId };
|
||||
if (params) props.params = params;
|
||||
const blockId = insertEmbedCard(std, { flavour, targetStyle, props });
|
||||
if (!blockId) return;
|
||||
next({ blockId });
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
import { EmbedLinkedDocBlockSchema } from '@blocksuite/affine-model';
|
||||
import {
|
||||
getInlineEditorByModel,
|
||||
insertContent,
|
||||
} from '@blocksuite/affine-rich-text';
|
||||
import { REFERENCE_NODE } from '@blocksuite/affine-shared/consts';
|
||||
import { createDefaultDoc } from '@blocksuite/affine-shared/utils';
|
||||
import {
|
||||
type SlashMenuConfig,
|
||||
SlashMenuConfigIdentifier,
|
||||
} from '@blocksuite/affine-widget-slash-menu';
|
||||
import { LinkedPageIcon, PlusIcon } from '@blocksuite/icons/lit';
|
||||
import { type ExtensionType } from '@blocksuite/store';
|
||||
|
||||
import { LinkDocTooltip, NewDocTooltip } from './tooltips';
|
||||
|
||||
const linkedDocSlashMenuConfig: SlashMenuConfig = {
|
||||
items: [
|
||||
{
|
||||
name: 'New Doc',
|
||||
description: 'Start a new document.',
|
||||
icon: PlusIcon(),
|
||||
tooltip: {
|
||||
figure: NewDocTooltip,
|
||||
caption: 'New Doc',
|
||||
},
|
||||
group: '3_Page@0',
|
||||
when: ({ model }) =>
|
||||
model.doc.schema.flavourSchemaMap.has('affine:embed-linked-doc'),
|
||||
action: ({ std, model }) => {
|
||||
const newDoc = createDefaultDoc(std.host.doc.workspace);
|
||||
insertContent(std, model, REFERENCE_NODE, {
|
||||
reference: {
|
||||
type: 'LinkedPage',
|
||||
pageId: newDoc.id,
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Linked Doc',
|
||||
description: 'Link to another document.',
|
||||
icon: LinkedPageIcon(),
|
||||
tooltip: {
|
||||
figure: LinkDocTooltip,
|
||||
caption: 'Link Doc',
|
||||
},
|
||||
searchAlias: ['dual link'],
|
||||
group: '3_Page@1',
|
||||
when: ({ std, model }) => {
|
||||
const root = model.doc.root;
|
||||
if (!root) return false;
|
||||
const linkedDocWidget = std.view.getWidget(
|
||||
'affine-linked-doc-widget',
|
||||
root.id
|
||||
);
|
||||
if (!linkedDocWidget) return false;
|
||||
|
||||
return model.doc.schema.flavourSchemaMap.has('affine:embed-linked-doc');
|
||||
},
|
||||
action: ({ model, std }) => {
|
||||
const root = model.doc.root;
|
||||
if (!root) return;
|
||||
const linkedDocWidget = std.view.getWidget(
|
||||
'affine-linked-doc-widget',
|
||||
root.id
|
||||
);
|
||||
if (!linkedDocWidget) return;
|
||||
// TODO(@L-Sun): make linked-doc-widget as extension
|
||||
// @ts-expect-error same as above
|
||||
const triggerKey = linkedDocWidget.config.triggerKeys[0];
|
||||
|
||||
insertContent(std, model, triggerKey);
|
||||
|
||||
const inlineEditor = getInlineEditorByModel(std, model);
|
||||
if (inlineEditor) {
|
||||
// Wait for range to be updated
|
||||
const subscription = inlineEditor.slots.inlineRangeSync.subscribe(
|
||||
() => {
|
||||
// TODO(@L-Sun): make linked-doc-widget as extension
|
||||
subscription.unsubscribe();
|
||||
// @ts-expect-error same as above
|
||||
linkedDocWidget.show({ addTriggerKey: true });
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const LinkedDocSlashMenuConfigIdentifier = SlashMenuConfigIdentifier(
|
||||
EmbedLinkedDocBlockSchema.model.flavour
|
||||
);
|
||||
|
||||
export const LinkedDocSlashMenuConfigExtension: ExtensionType = {
|
||||
setup: di => {
|
||||
di.addImpl(LinkedDocSlashMenuConfigIdentifier, linkedDocSlashMenuConfig);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,498 @@
|
||||
import { toast } from '@blocksuite/affine-components/toast';
|
||||
import { EditorChevronDown } from '@blocksuite/affine-components/toolbar';
|
||||
import {
|
||||
type EmbedCardStyle,
|
||||
EmbedLinkedDocModel,
|
||||
EmbedLinkedDocStyles,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
EMBED_CARD_HEIGHT,
|
||||
EMBED_CARD_WIDTH,
|
||||
} from '@blocksuite/affine-shared/consts';
|
||||
import {
|
||||
ActionPlacement,
|
||||
DocDisplayMetaProvider,
|
||||
type LinkEventType,
|
||||
type OpenDocMode,
|
||||
type ToolbarAction,
|
||||
type ToolbarActionGroup,
|
||||
type ToolbarContext,
|
||||
type ToolbarModuleConfig,
|
||||
ToolbarModuleExtension,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
getBlockProps,
|
||||
referenceToNode,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
import {
|
||||
CaptionIcon,
|
||||
CopyIcon,
|
||||
DeleteIcon,
|
||||
DuplicateIcon,
|
||||
ExpandFullIcon,
|
||||
OpenInNewIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import { BlockFlavourIdentifier } from '@blocksuite/std';
|
||||
import { type ExtensionType, Slice } from '@blocksuite/store';
|
||||
import { computed, signal } from '@preact/signals-core';
|
||||
import { html } from 'lit';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { keyed } from 'lit/directives/keyed.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
|
||||
import { EmbedLinkedDocBlockComponent } from '../embed-linked-doc-block';
|
||||
|
||||
const trackBaseProps = {
|
||||
category: 'linked doc',
|
||||
type: 'card view',
|
||||
};
|
||||
|
||||
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 });
|
||||
};
|
||||
|
||||
const docTitleAction = {
|
||||
id: 'a.doc-title',
|
||||
content(ctx) {
|
||||
const block = ctx.getCurrentBlockByType(EmbedLinkedDocBlockComponent);
|
||||
if (!block) return null;
|
||||
|
||||
const model = block.model;
|
||||
if (!model.props.title) return null;
|
||||
|
||||
const originalTitle =
|
||||
ctx.std.get(DocDisplayMetaProvider).title(model.props.pageId).value ||
|
||||
'Untitled';
|
||||
const open = (event: MouseEvent) => block.open({ event });
|
||||
|
||||
return html`<affine-linked-doc-title
|
||||
.title=${originalTitle}
|
||||
.open=${open}
|
||||
></affine-linked-doc-title>`;
|
||||
},
|
||||
} as const satisfies ToolbarAction;
|
||||
|
||||
const captionAction = {
|
||||
id: 'd.caption',
|
||||
tooltip: 'Caption',
|
||||
icon: CaptionIcon(),
|
||||
run(ctx) {
|
||||
const block = ctx.getCurrentBlockByType(EmbedLinkedDocBlockComponent);
|
||||
block?.captionEditor?.show();
|
||||
|
||||
ctx.track('OpenedCaptionEditor', {
|
||||
...trackBaseProps,
|
||||
control: 'add caption',
|
||||
});
|
||||
},
|
||||
} as const satisfies ToolbarAction;
|
||||
|
||||
const openDocActions = [
|
||||
{
|
||||
mode: 'open-in-active-view',
|
||||
id: 'a.open-in-active-view',
|
||||
label: 'Open this doc',
|
||||
icon: ExpandFullIcon(),
|
||||
},
|
||||
] as const satisfies (Pick<ToolbarAction, 'id' | 'label' | 'icon'> & {
|
||||
mode: OpenDocMode;
|
||||
})[];
|
||||
|
||||
const openDocActionGroup = {
|
||||
placement: ActionPlacement.Start,
|
||||
id: 'A.open-doc',
|
||||
content(ctx) {
|
||||
const block = ctx.getCurrentBlockByType(EmbedLinkedDocBlockComponent);
|
||||
if (!block) return null;
|
||||
|
||||
const actions = openDocActions.map<ToolbarAction>(action => {
|
||||
const openMode = action.mode;
|
||||
const shouldOpenInActiveView = openMode === 'open-in-active-view';
|
||||
return {
|
||||
...action,
|
||||
disabled: shouldOpenInActiveView
|
||||
? block.model.props.pageId === ctx.store.id
|
||||
: false,
|
||||
when: true,
|
||||
run: (_ctx: ToolbarContext) => block.open({ openMode }),
|
||||
};
|
||||
});
|
||||
|
||||
return html`
|
||||
<editor-menu-button
|
||||
.contentPadding="${'8px'}"
|
||||
.button=${html`
|
||||
<editor-icon-button aria-label="Open doc" .tooltip=${'Open doc'}>
|
||||
${OpenInNewIcon()} ${EditorChevronDown}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<div data-size="small" data-orientation="vertical">
|
||||
${repeat(
|
||||
actions,
|
||||
action => action.id,
|
||||
({ label, icon, run, disabled }) => html`
|
||||
<editor-menu-action
|
||||
aria-label=${ifDefined(label)}
|
||||
?disabled=${ifDefined(
|
||||
typeof disabled === 'function' ? disabled(ctx) : disabled
|
||||
)}
|
||||
@click=${() => run?.(ctx)}
|
||||
>
|
||||
${icon}<span class="label">${label}</span>
|
||||
</editor-menu-action>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</editor-menu-button>
|
||||
`;
|
||||
},
|
||||
} as const satisfies ToolbarAction;
|
||||
|
||||
const conversionsActionGroup = {
|
||||
id: 'b.conversions',
|
||||
actions: [
|
||||
{
|
||||
id: 'inline',
|
||||
label: 'Inline view',
|
||||
run(ctx) {
|
||||
const block = ctx.getCurrentBlockByType(EmbedLinkedDocBlockComponent);
|
||||
block?.convertToInline();
|
||||
|
||||
// Clears
|
||||
ctx.select('note');
|
||||
ctx.reset();
|
||||
|
||||
ctx.track('SelectedView', {
|
||||
...trackBaseProps,
|
||||
control: 'select view',
|
||||
type: 'inline view',
|
||||
});
|
||||
},
|
||||
when: ctx => !ctx.hasSelectedSurfaceModels,
|
||||
},
|
||||
{
|
||||
id: 'card',
|
||||
label: 'Card view',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
id: 'embed',
|
||||
label: 'Embed view',
|
||||
disabled(ctx) {
|
||||
const block = ctx.getCurrentBlockByType(EmbedLinkedDocBlockComponent);
|
||||
if (!block) return true;
|
||||
|
||||
if (block.closest('affine-embed-synced-doc-block')) return true;
|
||||
|
||||
const model = block.model;
|
||||
|
||||
// same doc
|
||||
if (model.props.pageId === ctx.store.id) return true;
|
||||
|
||||
// linking to block
|
||||
if (referenceToNode(model.props)) return true;
|
||||
|
||||
return false;
|
||||
},
|
||||
run(ctx) {
|
||||
const block = ctx.getCurrentBlockByType(EmbedLinkedDocBlockComponent);
|
||||
block?.convertToEmbed();
|
||||
|
||||
ctx.track('SelectedView', {
|
||||
...trackBaseProps,
|
||||
control: 'select view',
|
||||
type: 'embed view',
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
content(ctx) {
|
||||
const model = ctx.getCurrentModelByType(EmbedLinkedDocModel);
|
||||
if (!model) return null;
|
||||
|
||||
const actions = this.actions.map(action => ({ ...action }));
|
||||
const viewType$ = signal('Card view');
|
||||
const onToggle = createOnToggleFn(ctx, 'OpenedViewSelector', 'switch view');
|
||||
|
||||
return html`${keyed(
|
||||
model,
|
||||
html`<affine-view-dropdown-menu
|
||||
@toggle=${onToggle}
|
||||
.actions=${actions}
|
||||
.context=${ctx}
|
||||
.viewType$=${viewType$}
|
||||
></affine-view-dropdown-menu>`
|
||||
)}`;
|
||||
},
|
||||
} as const satisfies ToolbarActionGroup<ToolbarAction>;
|
||||
|
||||
const builtinToolbarConfig = {
|
||||
actions: [
|
||||
docTitleAction,
|
||||
conversionsActionGroup,
|
||||
{
|
||||
id: 'c.style',
|
||||
actions: [
|
||||
{
|
||||
id: 'horizontal',
|
||||
label: 'Large horizontal style',
|
||||
},
|
||||
{
|
||||
id: 'list',
|
||||
label: 'Small horizontal style',
|
||||
},
|
||||
].filter(action =>
|
||||
EmbedLinkedDocStyles.includes(action.id as EmbedCardStyle)
|
||||
),
|
||||
content(ctx) {
|
||||
const model = ctx.getCurrentModelByType(EmbedLinkedDocModel);
|
||||
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}
|
||||
.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(EmbedLinkedDocModel);
|
||||
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(EmbedLinkedDocModel);
|
||||
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(EmbedLinkedDocModel);
|
||||
if (!model) return;
|
||||
|
||||
ctx.store.deleteBlock(model);
|
||||
|
||||
// Clears
|
||||
ctx.select('note');
|
||||
ctx.reset();
|
||||
},
|
||||
},
|
||||
],
|
||||
} as const satisfies ToolbarModuleConfig;
|
||||
|
||||
const builtinSurfaceToolbarConfig = {
|
||||
actions: [
|
||||
openDocActionGroup,
|
||||
docTitleAction,
|
||||
conversionsActionGroup,
|
||||
{
|
||||
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',
|
||||
},
|
||||
].filter(action =>
|
||||
EmbedLinkedDocStyles.includes(action.id as EmbedCardStyle)
|
||||
),
|
||||
content(ctx) {
|
||||
const model = ctx.getCurrentModelByType(EmbedLinkedDocModel);
|
||||
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}
|
||||
.style$=${style$}
|
||||
></affine-card-style-dropdown-menu>`
|
||||
)}`;
|
||||
},
|
||||
} satisfies ToolbarActionGroup<ToolbarAction>,
|
||||
captionAction,
|
||||
{
|
||||
id: 'e.scale',
|
||||
content(ctx) {
|
||||
const model = ctx.getCurrentBlockByType(
|
||||
EmbedLinkedDocBlockComponent
|
||||
)?.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}
|
||||
.size$=${scale$}
|
||||
></affine-size-dropdown-menu>`
|
||||
)}`;
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
when: ctx => ctx.getSurfaceModelsByType(EmbedLinkedDocModel).length === 1,
|
||||
} 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,31 @@
|
||||
import { html } from 'lit';
|
||||
// prettier-ignore
|
||||
export const NewDocTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="170" height="68" rx="2" fill="white"/>
|
||||
<mask id="mask0_16460_991" 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_991)">
|
||||
<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="22" font-weight="600" letter-spacing="0px"><tspan x="8" y="27.5">Title</tspan></text>
|
||||
<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="44.6364">Type '/' for commands</tspan></text>
|
||||
</g>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
// prettier-ignore
|
||||
export const LinkDocTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="170" height="68" rx="2" fill="white"/>
|
||||
<mask id="mask0_16460_998" 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_998)">
|
||||
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="15.6364">In a decentralized system, we can have a kaleidoscopic </tspan><tspan x="8" y="27.6364">complexity to our data. </tspan><tspan x="8" y="63.6364">Any user may have a different perspective on what data they </tspan><tspan x="8" y="75.6364">either have, choose to share, or accept. </tspan><tspan x="8" y="111.636">For example, one user’s edits to a document might be on </tspan><tspan x="8" y="123.636">their laptop on an airplane; when the plane lands and the </tspan><tspan x="8" y="135.636">computer reconnects, those changes are distributed to </tspan><tspan x="8" y="147.636">other users. </tspan><tspan x="8" y="183.636">Other users might choose to accept all, some, or none of </tspan><tspan x="8" y="195.636">those changes to their version of the document.</tspan></text>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.125 39C10.125 38.2406 10.7406 37.625 11.5 37.625H16.5C17.2594 37.625 17.875 38.2406 17.875 39V42C17.875 42.2071 17.7071 42.375 17.5 42.375C17.2929 42.375 17.125 42.2071 17.125 42V39C17.125 38.6548 16.8452 38.375 16.5 38.375H11.5C11.1548 38.375 10.875 38.6548 10.875 39V45C10.875 45.3452 11.1548 45.625 11.5 45.625H14C14.2071 45.625 14.375 45.7929 14.375 46C14.375 46.2071 14.2071 46.375 14 46.375H11.5C10.7406 46.375 10.125 45.7594 10.125 45V39ZM12.125 40C12.125 39.7929 12.2929 39.625 12.5 39.625H14C14.2071 39.625 14.375 39.7929 14.375 40C14.375 40.2071 14.2071 40.375 14 40.375H12.5C12.2929 40.375 12.125 40.2071 12.125 40ZM12.5 41.375C12.2929 41.375 12.125 41.5429 12.125 41.75C12.125 41.9571 12.2929 42.125 12.5 42.125H15.5C15.7071 42.125 15.875 41.9571 15.875 41.75C15.875 41.5429 15.7071 41.375 15.5 41.375H12.5ZM12.125 43.5C12.125 43.2929 12.2929 43.125 12.5 43.125H13.75C13.9571 43.125 14.125 43.2929 14.125 43.5C14.125 43.7071 13.9571 43.875 13.75 43.875H12.5C12.2929 43.875 12.125 43.7071 12.125 43.5ZM15.75 43.125C15.5429 43.125 15.375 43.2929 15.375 43.5C15.375 43.7071 15.5429 43.875 15.75 43.875H17.0947L15.2348 45.7348C15.0884 45.8813 15.0884 46.1187 15.2348 46.2652C15.3813 46.4116 15.6187 46.4116 15.7652 46.2652L17.625 44.4053V45.75C17.625 45.9571 17.7929 46.125 18 46.125C18.2071 46.125 18.375 45.9571 18.375 45.75V43.5C18.375 43.4005 18.3355 43.3052 18.2652 43.2348C18.1948 43.1645 18.0995 43.125 18 43.125H15.75Z" fill="#77757D"/>
|
||||
<mask id="path-5-inside-1_16460_998" fill="white">
|
||||
<path d="M24 35H98V49H24V35Z"/>
|
||||
</mask>
|
||||
<path d="M98 48.5H24V49.5H98V48.5Z" fill="#E3E2E4" mask="url(#path-5-inside-1_16460_998)"/>
|
||||
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="24" y="45.6364">What’s AFFiNE?</tspan></text>
|
||||
</g>
|
||||
</svg>
|
||||
`;
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { EdgelessClipboardConfig } from '@blocksuite/affine-block-surface';
|
||||
import { ReferenceInfoSchema } from '@blocksuite/affine-model';
|
||||
import { type BlockSnapshot } from '@blocksuite/store';
|
||||
|
||||
export class EdgelessClipboardEmbedLinkedDocConfig extends EdgelessClipboardConfig {
|
||||
static override readonly key = 'affine:embed-linked-doc';
|
||||
|
||||
override createBlock(linkedDocEmbed: BlockSnapshot): string | null {
|
||||
if (!this.surface) return null;
|
||||
|
||||
const { xywh, style, caption, pageId, params, title, description } =
|
||||
linkedDocEmbed.props;
|
||||
const referenceInfo = ReferenceInfoSchema.parse({
|
||||
pageId,
|
||||
params,
|
||||
title,
|
||||
description,
|
||||
});
|
||||
|
||||
return this.crud.addBlock(
|
||||
'affine:embed-linked-doc',
|
||||
{
|
||||
xywh,
|
||||
style,
|
||||
caption,
|
||||
...referenceInfo,
|
||||
},
|
||||
this.surface.model.id
|
||||
);
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
EdgelessCRUDIdentifier,
|
||||
reassociateConnectorsCommand,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
EMBED_CARD_HEIGHT,
|
||||
EMBED_CARD_WIDTH,
|
||||
} from '@blocksuite/affine-shared/consts';
|
||||
import {
|
||||
cloneReferenceInfoWithoutAliases,
|
||||
isNewTabTrigger,
|
||||
isNewViewTrigger,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
|
||||
import { toEdgelessEmbedBlock } from '../common/to-edgeless-embed-block.js';
|
||||
import { EmbedLinkedDocBlockComponent } from './embed-linked-doc-block.js';
|
||||
|
||||
export class EmbedEdgelessLinkedDocBlockComponent extends toEdgelessEmbedBlock(
|
||||
EmbedLinkedDocBlockComponent
|
||||
) {
|
||||
override convertToEmbed = () => {
|
||||
const { caption, xywh } = this.model.props;
|
||||
const { doc, id } = this.model;
|
||||
|
||||
const style = 'syncedDoc';
|
||||
const bound = Bound.deserialize(xywh);
|
||||
bound.w = EMBED_CARD_WIDTH[style];
|
||||
bound.h = EMBED_CARD_HEIGHT[style];
|
||||
|
||||
const { addBlock } = this.std.get(EdgelessCRUDIdentifier);
|
||||
const surface = this.gfx.surface ?? undefined;
|
||||
const newId = addBlock(
|
||||
'affine:embed-synced-doc',
|
||||
{
|
||||
xywh: bound.serialize(),
|
||||
caption,
|
||||
...cloneReferenceInfoWithoutAliases(this.referenceInfo$.peek()),
|
||||
},
|
||||
surface
|
||||
);
|
||||
|
||||
this.std.command.exec(reassociateConnectorsCommand, {
|
||||
oldId: id,
|
||||
newId,
|
||||
});
|
||||
|
||||
this.gfx.selection.set({
|
||||
editing: false,
|
||||
elements: [newId],
|
||||
});
|
||||
|
||||
doc.deleteBlock(this.model);
|
||||
};
|
||||
|
||||
protected override _handleClick(evt: MouseEvent): void {
|
||||
if (isNewTabTrigger(evt)) {
|
||||
this.open({ openMode: 'open-in-new-tab', event: evt });
|
||||
} else if (isNewViewTrigger(evt)) {
|
||||
this.open({ openMode: 'open-in-new-view', event: evt });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,557 @@
|
||||
import { SurfaceBlockModel } from '@blocksuite/affine-block-surface';
|
||||
import { isPeekable, Peekable } from '@blocksuite/affine-components/peek';
|
||||
import { RefNodeSlotsProvider } from '@blocksuite/affine-inline-reference';
|
||||
import type {
|
||||
DocMode,
|
||||
EmbedLinkedDocModel,
|
||||
EmbedLinkedDocStyles,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
EMBED_CARD_HEIGHT,
|
||||
EMBED_CARD_WIDTH,
|
||||
REFERENCE_NODE,
|
||||
} from '@blocksuite/affine-shared/consts';
|
||||
import {
|
||||
DocDisplayMetaProvider,
|
||||
DocModeProvider,
|
||||
OpenDocExtensionIdentifier,
|
||||
type OpenDocMode,
|
||||
ThemeProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
cloneReferenceInfo,
|
||||
cloneReferenceInfoWithoutAliases,
|
||||
isNewTabTrigger,
|
||||
isNewViewTrigger,
|
||||
matchModels,
|
||||
referenceToNode,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
import { BlockSelection } from '@blocksuite/std';
|
||||
import { Text } from '@blocksuite/store';
|
||||
import { computed } from '@preact/signals-core';
|
||||
import { html, nothing } from 'lit';
|
||||
import { property, queryAsync, state } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
import { when } from 'lit/directives/when.js';
|
||||
import throttle from 'lodash-es/throttle';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { EmbedBlockComponent } from '../common/embed-block-element.js';
|
||||
import {
|
||||
RENDER_CARD_THROTTLE_MS,
|
||||
renderLinkedDocInCard,
|
||||
} from '../common/render-linked-doc.js';
|
||||
import { SyncedDocErrorIcon } from '../embed-synced-doc-block/styles.js';
|
||||
import { styles } from './styles.js';
|
||||
import { getEmbedLinkedDocIcons } from './utils.js';
|
||||
|
||||
@Peekable({
|
||||
enableOn: ({ doc }: EmbedLinkedDocBlockComponent) => !doc.readonly,
|
||||
})
|
||||
export class EmbedLinkedDocBlockComponent extends EmbedBlockComponent<EmbedLinkedDocModel> {
|
||||
static override styles = styles;
|
||||
|
||||
private readonly _load = async () => {
|
||||
const {
|
||||
loading = true,
|
||||
isError = false,
|
||||
isBannerEmpty = true,
|
||||
isNoteContentEmpty = true,
|
||||
} = this.getInitialState();
|
||||
|
||||
this._loading = loading;
|
||||
this.isError = isError;
|
||||
this.isBannerEmpty = isBannerEmpty;
|
||||
this.isNoteContentEmpty = isNoteContentEmpty;
|
||||
|
||||
if (!this._loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
const linkedDoc = this.linkedDoc;
|
||||
if (!linkedDoc) {
|
||||
this._loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!linkedDoc.loaded) {
|
||||
try {
|
||||
linkedDoc.load();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
this.isError = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.isError && !linkedDoc.root) {
|
||||
await new Promise<void>(resolve => {
|
||||
const subscription = linkedDoc.slots.rootAdded.subscribe(() => {
|
||||
subscription.unsubscribe();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
this._loading = false;
|
||||
|
||||
// If it is a link to a block or element, the content will not be rendered.
|
||||
if (this._referenceToNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isError) {
|
||||
const cardStyle = this.model.props.style;
|
||||
if (cardStyle === 'horizontal' || cardStyle === 'vertical') {
|
||||
renderLinkedDocInCard(this);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private readonly _selectBlock = () => {
|
||||
const selectionManager = this.std.selection;
|
||||
const blockSelection = selectionManager.create(BlockSelection, {
|
||||
blockId: this.blockId,
|
||||
});
|
||||
selectionManager.setGroup('note', [blockSelection]);
|
||||
};
|
||||
|
||||
private readonly _setDocUpdatedAt = () => {
|
||||
const meta = this.doc.workspace.meta.getDocMeta(this.model.props.pageId);
|
||||
if (meta) {
|
||||
const date = meta.updatedDate || meta.createDate;
|
||||
this._docUpdatedAt = new Date(date);
|
||||
}
|
||||
};
|
||||
|
||||
override _cardStyle: (typeof EmbedLinkedDocStyles)[number] = 'horizontal';
|
||||
|
||||
convertToEmbed = () => {
|
||||
if (this._referenceToNode) return;
|
||||
|
||||
const { caption } = this.model.props;
|
||||
const { parent, doc } = this.model;
|
||||
const index = parent?.children.indexOf(this.model);
|
||||
|
||||
const blockId = doc.addBlock(
|
||||
'affine:embed-synced-doc',
|
||||
{
|
||||
caption,
|
||||
...cloneReferenceInfoWithoutAliases(this.referenceInfo$.peek()),
|
||||
},
|
||||
parent,
|
||||
index
|
||||
);
|
||||
|
||||
doc.deleteBlock(this.model);
|
||||
|
||||
this.std.selection.setGroup('note', [
|
||||
this.std.selection.create(BlockSelection, { blockId }),
|
||||
]);
|
||||
};
|
||||
|
||||
convertToInline = () => {
|
||||
const { doc } = this.model;
|
||||
const parent = doc.getParent(this.model);
|
||||
if (!parent) {
|
||||
return;
|
||||
}
|
||||
const index = parent.children.indexOf(this.model);
|
||||
|
||||
const yText = new Y.Text();
|
||||
yText.insert(0, REFERENCE_NODE);
|
||||
yText.format(0, REFERENCE_NODE.length, {
|
||||
reference: {
|
||||
type: 'LinkedPage',
|
||||
...this.referenceInfo$.peek(),
|
||||
},
|
||||
});
|
||||
const text = new Text(yText);
|
||||
|
||||
doc.addBlock(
|
||||
'affine:paragraph',
|
||||
{
|
||||
text,
|
||||
},
|
||||
parent,
|
||||
index
|
||||
);
|
||||
|
||||
doc.deleteBlock(this.model);
|
||||
};
|
||||
|
||||
referenceInfo$ = computed(() => {
|
||||
const { pageId, params, title$, description$ } = this.model.props;
|
||||
return cloneReferenceInfo({
|
||||
pageId,
|
||||
params,
|
||||
title: title$.value,
|
||||
description: description$.value,
|
||||
});
|
||||
});
|
||||
|
||||
icon$ = computed(() => {
|
||||
const { pageId, params, title } = this.referenceInfo$.value;
|
||||
return this.std
|
||||
.get(DocDisplayMetaProvider)
|
||||
.icon(pageId, { params, title, referenced: true }).value;
|
||||
});
|
||||
|
||||
open = ({
|
||||
openMode,
|
||||
event,
|
||||
}: {
|
||||
openMode?: OpenDocMode;
|
||||
event?: MouseEvent;
|
||||
} = {}) => {
|
||||
this.std.getOptional(RefNodeSlotsProvider)?.docLinkClicked.next({
|
||||
...this.referenceInfo$.peek(),
|
||||
openMode,
|
||||
event,
|
||||
host: this.host,
|
||||
});
|
||||
};
|
||||
|
||||
refreshData = () => {
|
||||
this._load().catch(e => {
|
||||
console.error(e);
|
||||
this.isError = true;
|
||||
});
|
||||
};
|
||||
|
||||
title$ = computed(() => {
|
||||
const { pageId, params, title } = this.referenceInfo$.value;
|
||||
return (
|
||||
this.std
|
||||
.get(DocDisplayMetaProvider)
|
||||
.title(pageId, { params, title, referenced: true }) || title
|
||||
);
|
||||
});
|
||||
|
||||
get docTitle() {
|
||||
return this.model.props.title || this.linkedDoc?.meta?.title || 'Untitled';
|
||||
}
|
||||
|
||||
get editorMode() {
|
||||
return this._linkedDocMode;
|
||||
}
|
||||
|
||||
get linkedDoc() {
|
||||
const doc = this.std.workspace.getDoc(this.model.props.pageId);
|
||||
return doc?.getStore({ id: this.model.props.pageId });
|
||||
}
|
||||
|
||||
private _handleDoubleClick(event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
const openDocService = this.std.get(OpenDocExtensionIdentifier);
|
||||
const shouldOpenInPeek =
|
||||
openDocService.isAllowed('open-in-center-peek') && isPeekable(this);
|
||||
this.open({
|
||||
openMode: shouldOpenInPeek
|
||||
? 'open-in-center-peek'
|
||||
: 'open-in-active-view',
|
||||
event,
|
||||
});
|
||||
}
|
||||
|
||||
private _isDocEmpty() {
|
||||
const linkedDoc = this.linkedDoc;
|
||||
if (!linkedDoc) {
|
||||
return false;
|
||||
}
|
||||
return !!linkedDoc && this.isNoteContentEmpty && this.isBannerEmpty;
|
||||
}
|
||||
|
||||
protected _handleClick(event: MouseEvent) {
|
||||
if (isNewTabTrigger(event)) {
|
||||
this.open({ openMode: 'open-in-new-tab', event });
|
||||
} else if (isNewViewTrigger(event)) {
|
||||
this.open({ openMode: 'open-in-new-view', event });
|
||||
}
|
||||
this._selectBlock();
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
this._cardStyle = this.model.props.style;
|
||||
this._referenceToNode = referenceToNode(this.model.props);
|
||||
|
||||
this._load().catch(e => {
|
||||
console.error(e);
|
||||
this.isError = true;
|
||||
});
|
||||
|
||||
const linkedDoc = this.linkedDoc;
|
||||
if (linkedDoc) {
|
||||
this.disposables.add(
|
||||
linkedDoc.workspace.slots.docListUpdated.subscribe(() => {
|
||||
this._load().catch(e => {
|
||||
console.error(e);
|
||||
this.isError = true;
|
||||
});
|
||||
})
|
||||
);
|
||||
// Should throttle the blockUpdated event to avoid too many re-renders
|
||||
// Because the blockUpdated event is triggered too frequently at some cases
|
||||
this.disposables.add(
|
||||
linkedDoc.slots.blockUpdated.subscribe(
|
||||
throttle(payload => {
|
||||
if (
|
||||
payload.type === 'update' &&
|
||||
['', 'caption', 'xywh'].includes(payload.props.key)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.type === 'add' && payload.init) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._load().catch(e => {
|
||||
console.error(e);
|
||||
this.isError = true;
|
||||
});
|
||||
}, RENDER_CARD_THROTTLE_MS)
|
||||
)
|
||||
);
|
||||
|
||||
this._setDocUpdatedAt();
|
||||
this.disposables.add(
|
||||
this.doc.workspace.slots.docListUpdated.subscribe(() => {
|
||||
this._setDocUpdatedAt();
|
||||
})
|
||||
);
|
||||
|
||||
if (this._referenceToNode) {
|
||||
this._linkedDocMode = this.model.props.params?.mode ?? 'page';
|
||||
} else {
|
||||
const docMode = this.std.get(DocModeProvider);
|
||||
this._linkedDocMode = docMode.getPrimaryMode(this.model.props.pageId);
|
||||
this.disposables.add(
|
||||
docMode.onPrimaryModeChange(mode => {
|
||||
this._linkedDocMode = mode;
|
||||
}, this.model.props.pageId)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.disposables.add(
|
||||
this.model.propsUpdated.subscribe(({ key }) => {
|
||||
if (key === 'style') {
|
||||
this._cardStyle = this.model.props.style;
|
||||
}
|
||||
if (key === 'pageId' || key === 'style') {
|
||||
this._load().catch(e => {
|
||||
console.error(e);
|
||||
this.isError = true;
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
getInitialState(): {
|
||||
loading?: boolean;
|
||||
isError?: boolean;
|
||||
isNoteContentEmpty?: boolean;
|
||||
isBannerEmpty?: boolean;
|
||||
} {
|
||||
return {};
|
||||
}
|
||||
|
||||
override renderBlock() {
|
||||
const linkedDoc = this.linkedDoc;
|
||||
const isDeleted = !linkedDoc;
|
||||
const isLoading = this._loading;
|
||||
const isError = this.isError;
|
||||
const isEmpty = this._isDocEmpty() && this.isBannerEmpty;
|
||||
const inCanvas = matchModels(this.model.parent, [SurfaceBlockModel]);
|
||||
|
||||
const cardClassMap = classMap({
|
||||
loading: isLoading,
|
||||
error: isError,
|
||||
deleted: isDeleted,
|
||||
empty: isEmpty,
|
||||
'banner-empty': this.isBannerEmpty,
|
||||
'note-empty': this.isNoteContentEmpty,
|
||||
'in-canvas': inCanvas,
|
||||
[this._cardStyle]: true,
|
||||
});
|
||||
|
||||
const theme = this.std.get(ThemeProvider).theme;
|
||||
const {
|
||||
LoadingIcon,
|
||||
ReloadIcon,
|
||||
LinkedDocDeletedBanner,
|
||||
LinkedDocEmptyBanner,
|
||||
SyncedDocErrorBanner,
|
||||
} = getEmbedLinkedDocIcons(theme, this._linkedDocMode, this._cardStyle);
|
||||
|
||||
const icon = isError
|
||||
? SyncedDocErrorIcon
|
||||
: isLoading
|
||||
? LoadingIcon
|
||||
: this.icon$.value;
|
||||
const title = isLoading ? 'Loading...' : this.title$;
|
||||
const description = this.model.props.description$;
|
||||
|
||||
const showDefaultNoteContent = isError || isLoading || isDeleted || isEmpty;
|
||||
const defaultNoteContent = isError
|
||||
? 'This linked doc failed to load.'
|
||||
: isLoading
|
||||
? ''
|
||||
: isDeleted
|
||||
? 'This linked doc is deleted.'
|
||||
: isEmpty
|
||||
? 'Preview of the doc will be displayed here.'
|
||||
: '';
|
||||
|
||||
const dateText =
|
||||
this._cardStyle === 'cube'
|
||||
? this._docUpdatedAt.toLocaleTimeString()
|
||||
: this._docUpdatedAt.toLocaleString();
|
||||
|
||||
const showDefaultBanner = isError || isLoading || isDeleted || isEmpty;
|
||||
|
||||
const defaultBanner = isError
|
||||
? SyncedDocErrorBanner
|
||||
: isLoading
|
||||
? LinkedDocEmptyBanner
|
||||
: isDeleted
|
||||
? LinkedDocDeletedBanner
|
||||
: LinkedDocEmptyBanner;
|
||||
|
||||
const hasDescriptionAlias = Boolean(description.value);
|
||||
|
||||
return this.renderEmbed(
|
||||
() => html`
|
||||
<div
|
||||
class="affine-embed-linked-doc-block ${cardClassMap}"
|
||||
style=${styleMap({
|
||||
transform: `scale(${this._scale})`,
|
||||
transformOrigin: '0 0',
|
||||
})}
|
||||
@click=${this._handleClick}
|
||||
@dblclick=${this._handleDoubleClick}
|
||||
>
|
||||
<div class="affine-embed-linked-doc-content">
|
||||
<div class="affine-embed-linked-doc-content-title">
|
||||
<div class="affine-embed-linked-doc-content-title-icon">
|
||||
${icon}
|
||||
</div>
|
||||
|
||||
<div class="affine-embed-linked-doc-content-title-text">
|
||||
${title}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${when(
|
||||
hasDescriptionAlias,
|
||||
() =>
|
||||
html`<div class="affine-embed-linked-doc-content-note alias">
|
||||
${repeat(
|
||||
(description.value ?? '').split('\n'),
|
||||
text => html`<p>${text}</p>`
|
||||
)}
|
||||
</div>`,
|
||||
() =>
|
||||
when(
|
||||
showDefaultNoteContent,
|
||||
() => html`
|
||||
<div class="affine-embed-linked-doc-content-note default">
|
||||
${defaultNoteContent}
|
||||
</div>
|
||||
`,
|
||||
() => html`
|
||||
<div
|
||||
class="affine-embed-linked-doc-content-note render"
|
||||
></div>
|
||||
`
|
||||
)
|
||||
)}
|
||||
${isError
|
||||
? html`
|
||||
<div class="affine-embed-linked-doc-card-content-reload">
|
||||
<div
|
||||
class="affine-embed-linked-doc-card-content-reload-button"
|
||||
@click=${this.refreshData}
|
||||
>
|
||||
${ReloadIcon} <span>Reload</span>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div class="affine-embed-linked-doc-content-date">
|
||||
<span>Updated</span>
|
||||
|
||||
<span>${dateText}</span>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
|
||||
${showDefaultBanner
|
||||
? html`
|
||||
<div class="affine-embed-linked-doc-banner default">
|
||||
${defaultBanner}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
override updated() {
|
||||
// update card style when linked doc deleted
|
||||
const linkedDoc = this.linkedDoc;
|
||||
const { xywh, style } = this.model.props;
|
||||
const bound = Bound.deserialize(xywh);
|
||||
if (linkedDoc && style === 'horizontalThin') {
|
||||
bound.w = EMBED_CARD_WIDTH.horizontal;
|
||||
bound.h = EMBED_CARD_HEIGHT.horizontal;
|
||||
this.doc.withoutTransact(() => {
|
||||
this.doc.updateBlock(this.model, {
|
||||
xywh: bound.serialize(),
|
||||
style: 'horizontal',
|
||||
});
|
||||
});
|
||||
} else if (!linkedDoc && style === 'horizontal') {
|
||||
bound.w = EMBED_CARD_WIDTH.horizontalThin;
|
||||
bound.h = EMBED_CARD_HEIGHT.horizontalThin;
|
||||
this.doc.withoutTransact(() => {
|
||||
this.doc.updateBlock(this.model, {
|
||||
xywh: bound.serialize(),
|
||||
style: 'horizontalThin',
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@state()
|
||||
private accessor _docUpdatedAt: Date = new Date();
|
||||
|
||||
@state()
|
||||
private accessor _linkedDocMode: DocMode = 'page';
|
||||
|
||||
@state()
|
||||
private accessor _loading = false;
|
||||
|
||||
// reference to block/element
|
||||
@state()
|
||||
private accessor _referenceToNode = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor isBannerEmpty = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor isError = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor isNoteContentEmpty = false;
|
||||
|
||||
@queryAsync('.affine-embed-linked-doc-content-note.render')
|
||||
accessor noteContainer!: Promise<HTMLDivElement | null>;
|
||||
}
|
||||
Loaded 100 of 893 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user