chore(editor): reorg packages (#10702)

This commit is contained in:
Saul-Mirone
2025-03-08 03:57:04 +00:00
parent 334912e85b
commit 8aedef0a36
961 changed files with 837 additions and 927 deletions
@@ -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,136 @@
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/block-std';
import type { GfxCompatibleProps } from '@blocksuite/block-std/gfx';
import type { BlockModel } from '@blocksuite/store';
import { computed, type ReadonlySignal } 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,
})
);
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';
}
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,89 @@
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 {
BlockSelection,
type BlockStdScope,
SurfaceSelection,
TextSelection,
} from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import { Bound, Vec } from '@blocksuite/global/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);
host.doc.addBlock(flavour as never, props, parent, index + 1);
} 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.selection.set({
elements: [cardId],
editing: false,
});
gfx.tool.setTool(
// @ts-expect-error FIXME: resolve after gfx tool refactor
'default'
);
}
}
@@ -0,0 +1,429 @@
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/block-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.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.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).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.pageId} in linked page block, but the page is not found.`
);
return;
}
const notes = getNotesFromDoc(doc);
if (!notes) {
return;
}
const cardStyle = card.model.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.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.type.startsWith('h')) {
return firstBlock.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.dispose();
};
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.on(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 linkedDoc = doc.workspace.createDoc({});
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,90 @@
import { EdgelessLegacySlotIdentifier } from '@blocksuite/affine-block-surface';
import {
blockComponentSymbol,
type BlockService,
type GfxBlockComponent,
GfxElementSymbol,
toGfxBlockComponent,
} from '@blocksuite/block-std';
import type {
GfxBlockElementModel,
GfxCompatibleProps,
} from '@blocksuite/block-std/gfx';
import { Bound } from '@blocksuite/global/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;
_isDragging = false;
_isResizing = false;
_showOverlay = false;
override [blockComponentSymbol] = true;
override blockDraggable = false;
protected override embedContainerStyle: StyleInfo = {};
override [GfxElementSymbol] = true;
get bound(): Bound {
return Bound.deserialize(this.model.xywh);
}
_handleClick(_: MouseEvent): void {
return;
}
get edgelessSlots() {
return this.std.get(EdgelessLegacySlotIdentifier);
}
override connectedCallback(): void {
super.connectedCallback();
this._disposables.add(
this.edgelessSlots.elementResizeStart.on(() => {
this._isResizing = true;
this._showOverlay = true;
})
);
this._disposables.add(
this.edgelessSlots.elementResizeEnd.on(() => {
this._isResizing = false;
this._showOverlay =
this._isResizing || this._isDragging || !this.selected$.peek();
})
);
}
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,404 @@
import { toast } from '@blocksuite/affine-components/toast';
import {
BookmarkStyles,
EmbedGithubModel,
isExternalEmbedModel,
} from '@blocksuite/affine-model';
import {
ActionPlacement,
EmbedOptionProvider,
type ToolbarAction,
type ToolbarActionGroup,
type ToolbarModuleConfig,
} from '@blocksuite/affine-shared/services';
import { getBlockProps } from '@blocksuite/affine-shared/utils';
import { BlockSelection } from '@blocksuite/block-std';
import {
CaptionIcon,
CopyIcon,
DeleteIcon,
DuplicateIcon,
ResetIcon,
} from '@blocksuite/icons/lit';
import { Slice, Text } from '@blocksuite/store';
import { 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 = {
segment: 'doc',
page: 'doc editor',
module: 'toolbar',
category: 'link',
type: 'card view',
};
// External embed blocks
export function createBuiltinToolbarConfigForExternal(
klass:
| typeof EmbedGithubBlockComponent
| typeof EmbedFigmaBlockComponent
| typeof EmbedLoomBlockComponent
| typeof EmbedYoutubeBlockComponent
) {
return {
actions: [
{
id: 'a.preview',
content(ctx) {
const model = ctx.getCurrentBlockBy(BlockSelection)?.model;
if (!model || !isExternalEmbedModel(model)) return null;
const { url } = model;
const options = ctx.std
.get(EmbedOptionProvider)
.getEmbedBlockOptions(url);
if (options?.viewType !== 'card') return null;
return html`<affine-link-preview .url=${url}></affine-link-preview>`;
},
},
{
id: 'b.conversions',
actions: [
{
id: 'inline',
label: 'Inline view',
run(ctx) {
const model = ctx.getCurrentBlockBy(BlockSelection)?.model;
if (!model || !isExternalEmbedModel(model)) return;
const { title, caption, url: link, 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.getCurrentBlockBy(BlockSelection)?.model;
if (!model || !isExternalEmbedModel(model)) return true;
const { url } = model;
const options = ctx.std
.get(EmbedOptionProvider)
.getEmbedBlockOptions(url);
return options?.viewType === 'card';
},
run(ctx) {
const model = ctx.getCurrentBlockBy(BlockSelection)?.model;
if (!model || !isExternalEmbedModel(model)) return;
const { url, caption, parent } = model;
const index = parent?.children.indexOf(model);
const options = ctx.std
.get(EmbedOptionProvider)
.getEmbedBlockOptions(url);
let { style } = model;
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.getCurrentBlockBy(BlockSelection)?.model;
if (!model || !isExternalEmbedModel(model)) return false;
const { url } = model;
const options = ctx.std
.get(EmbedOptionProvider)
.getEmbedBlockOptions(url);
return options?.viewType === 'embed';
},
when(ctx) {
const model = ctx.getCurrentBlockBy(BlockSelection)?.model;
if (!model || !isExternalEmbedModel(model)) return false;
const { url } = model;
const options = ctx.std
.get(EmbedOptionProvider)
.getEmbedBlockOptions(url);
return options?.viewType === 'embed';
},
run(ctx) {
const model = ctx.getCurrentBlockBy(BlockSelection)?.model;
if (!model || !isExternalEmbedModel(model)) return;
const { url, caption, 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;
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.getCurrentBlockBy(BlockSelection)?.model;
if (!model || !isExternalEmbedModel(model)) return null;
const { url } = model;
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 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$=${viewType$}
></affine-view-dropdown-menu>`
)}`;
},
} satisfies ToolbarActionGroup<ToolbarAction>,
{
id: 'c.style',
actions: [
{
id: 'horizontal',
label: 'Large horizontal style',
},
{
id: 'list',
label: 'Small horizontal style',
},
],
content(ctx) {
const model = ctx.getCurrentModelByType(
BlockSelection,
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 toggle = (e: CustomEvent<boolean>) => {
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
.actions=${actions}
.context=${ctx}
.toggle=${toggle}
.style$=${model.style$}
></affine-card-style-dropdown-menu>`
)}`;
},
} satisfies ToolbarActionGroup<ToolbarAction>,
{
id: 'd.caption',
tooltip: 'Caption',
icon: CaptionIcon(),
run(ctx) {
const component = ctx.getCurrentBlockComponentBy(
BlockSelection,
klass
);
if (!component) return;
component.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.getCurrentModelBy(BlockSelection);
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.getCurrentModelBy(BlockSelection);
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 component = ctx.getCurrentBlockComponentBy(
BlockSelection,
klass
);
component?.refreshData();
},
},
{
placement: ActionPlacement.More,
id: 'c.delete',
label: 'Delete',
icon: DeleteIcon(),
variant: 'destructive',
run(ctx) {
const model = ctx.getCurrentModelBy(BlockSelection);
if (!model || !isExternalEmbedModel(model)) return;
ctx.store.deleteBlock(model);
// Clears
ctx.select('note');
ctx.reset();
},
},
],
} as const satisfies ToolbarModuleConfig;
}
@@ -0,0 +1,96 @@
import { EmbedFigmaBlockComponent } from './embed-figma-block';
import { EmbedEdgelessBlockComponent } from './embed-figma-block/embed-edgeless-figma-block';
import { EmbedGithubBlockComponent } from './embed-github-block';
import { EmbedEdgelessGithubBlockComponent } from './embed-github-block/embed-edgeless-github-block';
import { EmbedHtmlBlockComponent } from './embed-html-block';
import { EmbedHtmlFullscreenToolbar } from './embed-html-block/components/fullscreen-toolbar';
import { EmbedEdgelessHtmlBlockComponent } from './embed-html-block/embed-edgeless-html-block';
import { 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
);
}
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;
}
}
@@ -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@7',
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);
},
},
],
};
File diff suppressed because one or more lines are too long
@@ -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,156 @@
import { OpenIcon } from '@blocksuite/affine-components/icons';
import type {
EmbedFigmaModel,
EmbedFigmaStyles,
} from '@blocksuite/affine-model';
import { BlockSelection } from '@blocksuite/block-std';
import { html, nothing } from 'lit';
import { state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { styleMap } from 'lit/directives/style-map.js';
import { EmbedBlockComponent } from '../common/embed-block-element.js';
import { FigmaIcon, styles } from './styles.js';
export class EmbedFigmaBlockComponent extends EmbedBlockComponent<EmbedFigmaModel> {
static override styles = styles;
override _cardStyle: (typeof EmbedFigmaStyles)[number] = 'figma';
protected _isDragging = false;
protected _isResizing = false;
open = () => {
let link = this.model.url;
if (!link.match(/^[a-zA-Z]+:\/\//)) {
link = 'https://' + link;
}
window.open(link, '_blank');
};
refreshData = () => {};
private _handleDoubleClick(event: MouseEvent) {
event.stopPropagation();
this.open();
}
private _selectBlock() {
const selectionManager = this.host.selection;
const blockSelection = selectionManager.create(BlockSelection, {
blockId: this.blockId,
});
selectionManager.setGroup('note', [blockSelection]);
}
protected _handleClick(event: MouseEvent) {
event.stopPropagation();
this._selectBlock();
}
override connectedCallback() {
super.connectedCallback();
this._cardStyle = this.model.style;
if (!this.model.title) {
this.doc.withoutTransact(() => {
this.doc.updateBlock(this.model, {
title: 'Figma',
});
});
}
this.disposables.add(
this.model.propsUpdated.on(({ key }) => {
if (key === 'url') {
this.refreshData();
}
})
);
// this is required to prevent iframe from capturing pointer events
this.disposables.add(
this.selected$.subscribe(selected => {
this._showOverlay = this._isResizing || this._isDragging || !selected;
})
);
// this is required to prevent iframe from capturing pointer events
this.handleEvent('dragStart', () => {
this._isDragging = true;
this._showOverlay =
this._isResizing || this._isDragging || !this.selected$.peek();
});
this.handleEvent('dragEnd', () => {
this._isDragging = false;
this._showOverlay =
this._isResizing || this._isDragging || !this.selected$.peek();
});
}
override renderBlock() {
const { title, description, url } = this.model;
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>
<div
class=${classMap({
'affine-embed-figma-iframe-overlay': true,
hide: !this._showOverlay,
})}
></div>
</div>
</div>
<div class="affine-embed-figma-content">
<div class="affine-embed-figma-content-header">
<div class="affine-embed-figma-content-title-icon">
${FigmaIcon}
</div>
<div class="affine-embed-figma-content-title-text">
${titleText}
</div>
</div>
${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>
`
);
}
@state()
protected accessor _showOverlay = true;
}
@@ -0,0 +1,2 @@
export const figmaUrlRegex: RegExp =
/https:\/\/[\w.-]+\.?figma.com\/([\w-]+)\/([0-9a-zA-Z]{22,128})(?:\/.*)?$/;
@@ -0,0 +1,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,34 @@
import { EmbedFigmaBlockSchema } from '@blocksuite/affine-model';
import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services';
import { SlashMenuConfigExtension } from '@blocksuite/affine-widget-slash-menu';
import {
BlockServiceIdentifier,
BlockViewExtension,
FlavourExtension,
} from '@blocksuite/block-std';
import type { ExtensionType } from '@blocksuite/store';
import { literal } from 'lit/static-html.js';
import { createBuiltinToolbarConfigForExternal } 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,
ToolbarModuleExtension({
id: BlockServiceIdentifier(flavour),
config: createBuiltinToolbarConfigForExternal(EmbedFigmaBlockComponent),
}),
SlashMenuConfigExtension(flavour, embedFigmaSlashMenuConfig),
].flat();
@@ -0,0 +1,5 @@
export * from './adapters/index.js';
export * from './embed-figma-block.js';
export * from './embed-figma-model.js';
export * from './embed-figma-spec.js';
export { FigmaIcon } from './styles.js';
@@ -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@6',
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,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAACXBIWXMAABYlAAAWJQFJUiTwAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAgNSURBVHgB7VldbBzVFf7unZmd/ct61xjHxEkU4jhRbYeQxAlp2tBC+4Cg/1LpHy8VqipVVaU+9K3vfe5zJaSqrdq0Qg1UoQoEKlBjKMQtYBKbn/zbBsf2Ouv17s7uzJ3bc+54nYBc8M8saaQcabOzk5k75zvnO985dyw0GW5hk7jF7TaAm223AazEWqkTLQHADgdBsPTb9/2WgYgfADmq4WNuvkrHMJ9i2YsOWmAtAhDgwmQNIR+HwNiVKh2HaIXFDICcFAJ1ZePVkRloqRAKjZGxWVR9gVZYzAC0Icr5yQr+9UYFQUi50AJvjfl470oZrbBYAXCdhuTw0JsfoLRg48J4BdMzFbw/F+DU6SmEYWCStPhPLGYjRtM6gBdIDJ0uQ8kkhkdLyCUtNCwHw2drKFZCtGcbkDoBLUKIGOIXbwZouaefH0dxJgdt2Xhp+BqGRkoU8BSulhw89fwk6ZNvBEkgQBwWK4DJGYWn/1mkVQVspXHxisK/x6pIEHU0FfOzLxcxMcneK7raQhy2LgCh+TSgUEXD1/jTM1dQKgtytkFOajQ8D1XuAUQXdrdaTeDo8XF4LKlU4EZjORNcPGtsE+sCIMh5QZIZKhfH/vE+hl6vUfBFpP/cDeoe/HqdjgiUtkhWBV45M4+jz0wRCCo/TY8PGVozK586AAVFzv7thXH84fgU6jrSeiGi74+OD4oyEYgcjp2Yxx+fHUMtrJtipoUok2tzZX0UouI8evIDPPH3OTBJpP7wcgxEyuY5DYsAylDAtzz89USVKDeFaoP/h6m0tkYnPn5LyUuraHGOLlNVsHqQxk9U8eRz4zj1nwCBjBwzkddMmNBQolKu0bEHJ9cJh2kvdLQWUQ9hBglae3d/HY8+vA27tibNGpLWjuLAMiuicUoroiYrvlwlAG5MHBwaCRhKSCvPzZPSvDCB46/M0MSZgVb6hsXogbJh6CB0Bk6CHK3V4DkuuVWjxZJYijQXsGTQQEp4+PJn8/jWQ9tQyFAmEQE1TOQPAYCwsFyWlgXAp8xpVpJQYbasce5yCS9Thx15dwHX5vImGoJnHVKTJuclj9EyNJKZoPNffTiLnJPF756aIFBtpuhvlJuQC54pRg5aJAZpp4E9A8Dn9nZSRrK4I2/DlrR+6NAz5NJzbrRlOzFfyPN8qdzAeRoHRi+W8M54A+cuaZS9LJQVwA4jFZEfUg9FD6PIYwFfOuDiO/dvQ9JRmJpdwIlTNUPDxTI3QBiwFSy6QdSs+Qpvvk014s1h4lIDvdvS2Lo5jXwuhGsLiJVm4IZURNngpNK9vvIxRmBOUod98dVrKE1X4Nc8KEWkITo4dgbZDhuPfbMb375/E/2OJDKoA8dOX8bvnyyiOF0y0hpSZoW0kHDS2JCz8MAXuvHFwTb0390GV3J+OeJRNsPo17JlvoJZiMSSFlmoK7x7oYQ3Rucx+lYZtp9A2tXwKLUqYGcEkokEOZ3G6DtzuLqvgI2FDD24hko9gbOvF6FI8x3XNXSIasCC66YhEzTBvldCwaZgEdgdPVlsSArSNSpqipylRNS4l0HwPzPA28BiuYqLVys0DhD/X7uG8kKa+qZNFHKpoCskixxgZ2ldCYqsZqcVejYF+OVPeimDPn71xHmcvUyOk9NEafPh+tJoqhJpHTlIzRwu3duVBQ7fm0P/LgebujbgjpxLVHSJritUoaUiNuuHRiYrVY3hM7N48bVpjLxNsz42mCLki1h9WPRsViHBcw/XhcKePgsdKRcnTzMJA7peGndZTYXp2KHZvVFekKRo9G6X+PzBDtzXl0Fb2jEqxMIquVvLVWZgOWM9JrLgzKUKfvOXc9QLQjMaMxNDETYXRFNpAt8zD7WcFN3LsqiXmpY2A0akQD1dHh772k7s39UGy2JwCtc9/vgGtyoAHNXQTJYW5omrLw1N4c/PTWK6noUTNGBRhK+PBFQ3pSIC6hP59o5FIaA80QzEIDm6jljA976+BQ8eaEMhTYwXjrnvulp+cndeXQZgGBVNj0RkgoNhKr5f/3YU5fmNNBcp0xuiR2vMXZ0ys9KdnXeR80Qi8swKudd6yCTn8bPHB7B3Z4o6shUNdaaoVjfdrOpqTr82VSiMQzx57usp4BeP34PshlnTsaMldTR6YHGsWKJMJIVpt4Kf/mgPBnfl4ZjdGY0P1trm6VUBWFJj0WSnpmhKDNzt4uc/7ENKVnlko3pQ0TU8v1ihOcezEQcg5Xr48Q96cXBHJlJGKRbXkljLbLmuaVQvvrmylMTe7Ql8/xubCdAC9Q3b9A7XzSCVyi5CZSlXeOSBAg7tcY2+x2Hr3A/YphaEpJmFCvvBAwVsvdM3VGb3cnmJVJofoYzD3XkPjxzZSFpPxSrjeTOxvj1xM4rmy6E3EAKPfmUHFSXRhjS+7zNpHN5fiIgWNvDdh7bTWwkijnYRl60PgGguERWFFAkMDuTRszVhSHNodwFH7i2A971buoAj+9tN4Zus6f+DTf1HjXmesBQO7U/SOO1jb28e/T15OlfCfYMFWHZommGcFu+rRVIai/R8cKATWzZpdLU7yGds9HSncHh3pxkfRHNnFdOr0pjfjUaC2N2ewsF7kkZ0uUz29aWwpTOJlYwGq7VYAQij98KoUP/OTjO1cS3s7uuAJW+Ft9Nm1oga0+aNWQOA5fOujiRa4/6qh7lPMLOPFubLD+hFC8k9N7lAKPPKRVrxw4gXwNIGJcJi1HVx84IW5SBmAJ++3f478c222wButv0X5BSXyMVazj4AAAAASUVORK5CYII="/>
</defs>
</svg>
`;
@@ -0,0 +1,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,266 @@
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/block-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.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.style;
if (!this.model.owner || !this.model.repo || !this.model.githubId) {
this.doc.withoutTransact(() => {
const url = this.model.url;
const urlMatch = url.match(githubUrlRegex);
if (urlMatch) {
const [, owner, repo, githubType, githubId] = urlMatch;
this.doc.updateBlock(this.model, {
owner,
repo,
githubType: githubType === 'issue' ? 'issue' : 'pr',
githubId,
});
}
});
}
this.doc.withoutTransact(() => {
if (!this.model.description && !this.model.title) {
this.refreshData();
} else {
this.refreshStatus();
}
});
this.disposables.add(
this.model.propsUpdated.on(({ key }) => {
if (key === 'url') {
this.refreshData();
}
})
);
}
override renderBlock() {
const {
title = 'GitHub',
githubType,
status,
statusReason,
owner,
repo,
createdAt,
assignees,
description,
image,
style,
} = this.model;
const loading = this.loading;
const theme = this.std.get(ThemeProvider).theme;
const { LoadingIcon, EmbedCardBannerIcon } = getEmbedCardIcons(theme);
const titleIcon = loading ? LoadingIcon : GithubIcon;
const statusIcon = status
? getGithubStatusIcon(githubType, status, statusReason)
: nothing;
const statusText = loading ? '' : status;
const titleText = loading ? 'Loading...' : title;
const descriptionText = loading ? '' : description;
const bannerImage =
!loading && image
? html`<object type="image/webp" data=${image} draggable="false">
${EmbedCardBannerIcon}
</object>`
: EmbedCardBannerIcon;
let dateText = '';
if (createdAt) {
const date = new Date(createdAt);
dateText = date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
});
const day = date.getDate();
const suffix =
['th', 'st', 'nd', 'rd'][((day / 10) | 0) !== 1 ? day % 10 : 4] || 'th';
dateText = dateText.replace(/\d+/, `${day}${suffix}`);
}
return this.renderEmbed(
() => html`
<div
class=${classMap({
'affine-embed-github-block': true,
loading,
[style]: true,
selected: this.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/block-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,38 @@
import { EmbedGithubBlockSchema } from '@blocksuite/affine-model';
import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services';
import { SlashMenuConfigExtension } from '@blocksuite/affine-widget-slash-menu';
import {
BlockServiceIdentifier,
BlockViewExtension,
FlavourExtension,
} from '@blocksuite/block-std';
import type { ExtensionType } from '@blocksuite/store';
import { literal } from 'lit/static-html.js';
import { createBuiltinToolbarConfigForExternal } 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,
ToolbarModuleExtension({
id: BlockServiceIdentifier(flavour),
config: createBuiltinToolbarConfigForExternal(EmbedGithubBlockComponent),
}),
SlashMenuConfigExtension(flavour, embedGithubSlashMenuConfig),
].flat();
@@ -0,0 +1,5 @@
export * from './adapters/index.js';
export * from './embed-github-block.js';
export * from './embed-github-service.js';
export * from './embed-github-spec.js';
export { GithubIcon } from './styles.js';
@@ -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.url, signal),
]);
return { ...githubApiData, ...openGraphData };
}
export async function queryEmbedGithubApiData(
embedGithubModel: EmbedGithubModel,
signal?: AbortSignal
): Promise<Partial<EmbedGithubBlockUrlData>> {
const { owner, repo, githubType, githubId } = embedGithubModel;
let githubApiData: Partial<EmbedGithubBlockUrlData> = {};
// github's public api has a rate limit of 60 requests per hour
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/${
githubType === 'issue' ? 'issues' : 'pulls'
}/${githubId}`;
const githubApiResponse = await fetch(apiUrl, {
cache: 'no-cache',
signal,
}).catch(() => null);
if (githubApiResponse && githubApiResponse.ok) {
const githubApiJson = await githubApiResponse.json();
const { state, state_reason, draft, merged, created_at, assignees } =
githubApiJson;
const assigneeLogins = assignees.map(
(assignee: { login: string }) => assignee.login
);
let status = state;
if (merged) {
status = 'merged';
} else if (state === 'open' && draft) {
status = 'draft';
}
githubApiData = {
status,
statusReason: state_reason,
createdAt: created_at,
assignees: assigneeLogins,
};
}
return githubApiData;
}
export async function refreshEmbedGithubUrlData(
embedGithubElement: EmbedGithubBlockComponent,
signal?: AbortSignal
): Promise<void> {
let image = null,
status = null,
statusReason = null,
title = null,
description = null,
createdAt = null,
assignees = null;
try {
embedGithubElement.loading = true;
// 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.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,167 @@
import { toast } from '@blocksuite/affine-components/toast';
import { EmbedHtmlModel } from '@blocksuite/affine-model';
import {
ActionPlacement,
type ToolbarAction,
type ToolbarActionGroup,
type ToolbarModuleConfig,
} from '@blocksuite/affine-shared/services';
import { getBlockProps } from '@blocksuite/affine-shared/utils';
import { BlockSelection } from '@blocksuite/block-std';
import {
CaptionIcon,
CopyIcon,
DeleteIcon,
DuplicateIcon,
ExpandFullIcon,
} from '@blocksuite/icons/lit';
import { Slice } from '@blocksuite/store';
import { html } from 'lit';
import { keyed } from 'lit/directives/keyed.js';
import { EmbedHtmlBlockComponent } from '../embed-html-block';
const trackBaseProps = {
segment: 'doc',
page: 'doc editor',
module: 'toolbar',
category: 'html',
type: 'card view',
};
export const builtinToolbarConfig = {
actions: [
{
id: 'a.open-doc',
icon: ExpandFullIcon(),
tooltip: 'Open this doc',
run(ctx) {
const component = ctx.getCurrentBlockComponentBy(
BlockSelection,
EmbedHtmlBlockComponent
);
component?.open();
},
},
{
id: 'b.style',
actions: [
{
id: 'horizontal',
label: 'Large horizontal style',
},
{
id: 'list',
label: 'Small horizontal style',
},
],
content(ctx) {
const model = ctx.getCurrentModelByType(BlockSelection, 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 toggle = (e: CustomEvent<boolean>) => {
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
.actions=${actions}
.context=${ctx}
.toggle=${toggle}
.style$=${model.style$}
></affine-card-style-dropdown-menu>`
)}`;
},
} satisfies ToolbarActionGroup<ToolbarAction>,
{
id: 'c.caption',
tooltip: 'Caption',
icon: CaptionIcon(),
run(ctx) {
const component = ctx.getCurrentBlockComponentBy(
BlockSelection,
EmbedHtmlBlockComponent
);
component?.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.getCurrentModelBy(BlockSelection);
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.getCurrentModelBy(BlockSelection);
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.getCurrentModelBy(BlockSelection);
if (!model) return;
ctx.store.deleteBlock(model);
// Clears
ctx.select('note');
ctx.reset();
},
},
],
} as const satisfies ToolbarModuleConfig;
File diff suppressed because one or more lines are too long
@@ -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,139 @@
import type { EmbedHtmlModel, EmbedHtmlStyles } from '@blocksuite/affine-model';
import { BlockSelection } from '@blocksuite/block-std';
import { html } from 'lit';
import { query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { type StyleInfo, styleMap } from 'lit/directives/style-map.js';
import { EmbedBlockComponent } from '../common/embed-block-element.js';
import { HtmlIcon, styles } from './styles.js';
export class EmbedHtmlBlockComponent extends EmbedBlockComponent<EmbedHtmlModel> {
static override styles = styles;
override _cardStyle: (typeof EmbedHtmlStyles)[number] = 'html';
protected _isDragging = false;
protected _isResizing = false;
close = () => {
document.exitFullscreen().catch(console.error);
};
protected embedHtmlStyle: StyleInfo = {};
open = () => {
this.iframeWrapper?.requestFullscreen().catch(console.error);
};
refreshData = () => {};
private _handleDoubleClick(event: MouseEvent) {
event.stopPropagation();
this.open();
}
private _selectBlock() {
const selectionManager = this.host.selection;
const blockSelection = selectionManager.create(BlockSelection, {
blockId: this.blockId,
});
selectionManager.setGroup('note', [blockSelection]);
}
protected _handleClick(event: MouseEvent) {
event.stopPropagation();
this._selectBlock();
}
override connectedCallback() {
super.connectedCallback();
this._cardStyle = this.model.style;
// this is required to prevent iframe from capturing pointer events
this.disposables.add(
this.selected$.subscribe(selected => {
this._showOverlay = this._isResizing || this._isDragging || !selected;
})
);
// this is required to prevent iframe from capturing pointer events
this.handleEvent('dragStart', () => {
this._isDragging = true;
this._showOverlay =
this._isResizing || this._isDragging || !this.selected$.peek();
});
this.handleEvent('dragEnd', () => {
this._isDragging = false;
this._showOverlay =
this._isResizing || this._isDragging || !this.selected$.peek();
});
}
override renderBlock(): unknown {
const titleText = 'Basic HTML Page Structure';
const htmlSrc = `
<style>
body {
margin: 0;
}
</style>
${this.model.html}
`;
return this.renderEmbed(() => {
if (!this.model.html) {
return html` <div class="affine-html-empty">Empty</div>`;
}
return html`
<div
class=${classMap({
'affine-embed-html-block': true,
selected: this.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>
<div
class=${classMap({
'affine-embed-html-iframe-overlay': true,
hide: !this._showOverlay,
})}
></div>
</div>
</div>
<div class="affine-embed-html-title">
<div class="affine-embed-html-title-icon">${HtmlIcon}</div>
<div class="affine-embed-html-title-text">${titleText}</div>
</div>
</div>
`;
});
}
@state()
protected accessor _showOverlay = true;
@query('.embed-html-block-iframe-wrapper')
accessor iframeWrapper!: HTMLDivElement;
}
@@ -0,0 +1,24 @@
import { EmbedHtmlBlockSchema } from '@blocksuite/affine-model';
import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services';
import {
BlockFlavourIdentifier,
BlockViewExtension,
} from '@blocksuite/block-std';
import type { ExtensionType } from '@blocksuite/store';
import { literal } from 'lit/static-html.js';
import { builtinToolbarConfig } 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`;
}),
ToolbarModuleExtension({
id: BlockFlavourIdentifier(flavour),
config: builtinToolbarConfig,
}),
];
@@ -0,0 +1,7 @@
export * from './embed-html-block.js';
export * from './embed-html-spec.js';
export {
EMBED_HTML_MIN_HEIGHT,
EMBED_HTML_MIN_WIDTH,
HtmlIcon,
} from './styles.js';
@@ -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,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';
@@ -0,0 +1,21 @@
import type { EmbedCardStyle, ReferenceParams } from '@blocksuite/affine-model';
import type { Command } from '@blocksuite/block-std';
import { insertEmbedCard } from '../../common/insert-embed-card.js';
export type InsertedLinkType = {
flavour?: 'affine:bookmark' | 'affine:embed-linked-doc';
} | null;
export const insertEmbedLinkedDocCommand: Command<{
docId: string;
params?: ReferenceParams;
}> = (ctx, next) => {
const { docId, params, std } = ctx;
const flavour = 'affine:embed-linked-doc';
const targetStyle: EmbedCardStyle = 'vertical';
const props: Record<string, unknown> = { pageId: docId };
if (params) props.params = params;
insertEmbedCard(std, { flavour, targetStyle, props });
next();
};
@@ -0,0 +1,95 @@
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.host, 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.host, model, triggerKey);
const inlineEditor = getInlineEditorByModel(std.host, model);
// Wait for range to be updated
inlineEditor?.slots.inlineRangeSync.once(() => {
// TODO(@L-Sun): make linked-doc-widget as extension
// @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,276 @@
import { toast } from '@blocksuite/affine-components/toast';
import { EmbedLinkedDocModel } from '@blocksuite/affine-model';
import {
ActionPlacement,
type ToolbarAction,
type ToolbarActionGroup,
type ToolbarModuleConfig,
} from '@blocksuite/affine-shared/services';
import {
getBlockProps,
referenceToNode,
} from '@blocksuite/affine-shared/utils';
import { BlockSelection } from '@blocksuite/block-std';
import {
CaptionIcon,
CopyIcon,
DeleteIcon,
DuplicateIcon,
} from '@blocksuite/icons/lit';
import { Slice } from '@blocksuite/store';
import { signal } from '@preact/signals-core';
import { html } from 'lit';
import { keyed } from 'lit/directives/keyed.js';
import { EmbedLinkedDocBlockComponent } from '../embed-linked-doc-block';
const trackBaseProps = {
segment: 'doc',
page: 'doc editor',
module: 'toolbar',
category: 'linked doc',
type: 'card view',
};
export const builtinToolbarConfig = {
actions: [
{
id: 'a.doc-title',
content(ctx) {
const component = ctx.getCurrentBlockComponentBy(
BlockSelection,
EmbedLinkedDocBlockComponent
);
if (!component) return null;
const model = component.model;
if (!model.title) return null;
const originalTitle =
ctx.workspace.getDoc(model.pageId)?.meta?.title || 'Untitled';
return html`<affine-linked-doc-title
.title=${originalTitle}
.open=${(event: MouseEvent) => component.open({ event })}
></affine-linked-doc-title>`;
},
},
{
id: 'b.conversions',
actions: [
{
id: 'inline',
label: 'Inline view',
run(ctx) {
const component = ctx.getCurrentBlockComponentBy(
BlockSelection,
EmbedLinkedDocBlockComponent
);
component?.covertToInline();
// Clears
ctx.select('note');
ctx.reset();
ctx.track('SelectedView', {
...trackBaseProps,
control: 'select view',
type: 'inline view',
});
},
},
{
id: 'card',
label: 'Card view',
disabled: true,
},
{
id: 'embed',
label: 'Embed view',
disabled(ctx) {
const component = ctx.getCurrentBlockComponentBy(
BlockSelection,
EmbedLinkedDocBlockComponent
);
if (!component) return true;
if (component.closest('affine-embed-synced-doc-block')) return true;
const model = component.model;
// same doc
if (model.pageId === ctx.store.id) return true;
// linking to block
if (referenceToNode(model)) return true;
return false;
},
run(ctx) {
const component = ctx.getCurrentBlockComponentBy(
BlockSelection,
EmbedLinkedDocBlockComponent
);
component?.convertToEmbed();
ctx.track('SelectedView', {
...trackBaseProps,
control: 'select view',
type: 'embed view',
});
},
},
],
content(ctx) {
const model = ctx.getCurrentModelByType(
BlockSelection,
EmbedLinkedDocModel
);
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[1].label)}
></affine-view-dropdown-menu>`
)}`;
},
} satisfies ToolbarActionGroup<ToolbarAction>,
{
id: 'c.style',
actions: [
{
id: 'horizontal',
label: 'Large horizontal style',
},
{
id: 'list',
label: 'Small horizontal style',
},
],
content(ctx) {
const model = ctx.getCurrentModelByType(
BlockSelection,
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 toggle = (e: CustomEvent<boolean>) => {
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
.actions=${actions}
.context=${ctx}
.toggle=${toggle}
.style$=${model.style$}
></affine-card-style-dropdown-menu>`
)}`;
},
} satisfies ToolbarActionGroup<ToolbarAction>,
{
id: 'd.caption',
tooltip: 'Caption',
icon: CaptionIcon(),
run(ctx) {
const component = ctx.getCurrentBlockComponentBy(
BlockSelection,
EmbedLinkedDocBlockComponent
);
component?.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.getCurrentModelBy(BlockSelection);
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.getCurrentModelBy(BlockSelection);
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.getCurrentModelBy(BlockSelection);
if (!model) return;
ctx.store.deleteBlock(model);
// Clears
ctx.select('note');
ctx.reset();
},
},
],
} as const satisfies ToolbarModuleConfig;
@@ -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 &#39;/&#39; 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.&#10;</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.&#10;</tspan><tspan x="8" y="111.636">For example, one user&#x2019;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.&#10;</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&#x2019;s AFFiNE?</tspan></text>
</g>
</svg>
`;
@@ -0,0 +1,62 @@
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 { id, doc, caption, xywh } = 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,552 @@
import { SurfaceBlockModel } from '@blocksuite/affine-block-surface';
import { isPeekable, Peekable } from '@blocksuite/affine-components/peek';
import type {
DocMode,
EmbedLinkedDocModel,
EmbedLinkedDocStyles,
} from '@blocksuite/affine-model';
import { RefNodeSlotsProvider } from '@blocksuite/affine-rich-text';
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 { BlockSelection } from '@blocksuite/block-std';
import { Bound } from '@blocksuite/global/gfx';
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 { 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 => {
linkedDoc.slots.rootAdded.once(() => {
resolve();
});
});
}
this._loading = false;
// If it is a link to a block or element, the content will not be rendered.
if (this._referenceToNode) {
return;
}
if (!this.isError) {
const cardStyle = this.model.style;
if (cardStyle === 'horizontal' || cardStyle === 'vertical') {
renderLinkedDocInCard(this);
}
}
};
private 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.pageId);
if (meta) {
const date = meta.updatedDate || meta.createDate;
this._docUpdatedAt = new Date(date);
}
};
override _cardStyle: (typeof EmbedLinkedDocStyles)[number] = 'horizontal';
convertToEmbed = () => {
if (this._referenceToNode) return;
const { doc, caption, parent } = 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 }),
]);
};
covertToInline = () => {
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;
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.emit({
...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.title || this.linkedDoc?.meta?.title || 'Untitled';
}
get editorMode() {
return this._linkedDocMode;
}
get linkedDoc() {
return this.std.workspace.getDoc(this.model.pageId, {
id: this.model.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.style;
this._referenceToNode = referenceToNode(this.model);
this._load().catch(e => {
console.error(e);
this.isError = true;
});
const linkedDoc = this.linkedDoc;
if (linkedDoc) {
this.disposables.add(
linkedDoc.workspace.slots.docListUpdated.on(() => {
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.on(
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.on(() => {
this._setDocUpdatedAt();
})
);
if (this._referenceToNode) {
this._linkedDocMode = this.model.params?.mode ?? 'page';
} else {
const docMode = this.std.get(DocModeProvider);
this._linkedDocMode = docMode.getPrimaryMode(this.model.pageId);
this.disposables.add(
docMode.onPrimaryModeChange(mode => {
this._linkedDocMode = mode;
}, this.model.pageId)
);
}
}
this.disposables.add(
this.model.propsUpdated.on(({ key }) => {
if (key === 'style') {
this._cardStyle = this.model.style;
}
if (key === 'pageId' || key === 'style') {
this._load().catch(e => {
console.error(e);
this.isError = true;
});
}
})
);
}
getInitialState(): {
loading?: boolean;
isError?: boolean;
isNoteContentEmpty?: boolean;
isBannerEmpty?: boolean;
} {
return {};
}
override renderBlock() {
const linkedDoc = this.linkedDoc;
const isDeleted = !linkedDoc;
const isLoading = this._loading;
const isError = this.isError;
const isEmpty = this._isDocEmpty() && this.isBannerEmpty;
const inCanvas = 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.description$;
const showDefaultNoteContent = isError || isLoading || isDeleted || isEmpty;
const defaultNoteContent = isError
? 'This linked doc failed to load.'
: isLoading
? ''
: isDeleted
? 'This linked doc is deleted.'
: isEmpty
? 'Preview of the doc will be displayed here.'
: '';
const dateText =
this._cardStyle === 'cube'
? this._docUpdatedAt.toLocaleTimeString()
: this._docUpdatedAt.toLocaleString();
const showDefaultBanner = isError || isLoading || isDeleted || isEmpty;
const defaultBanner = isError
? SyncedDocErrorBanner
: isLoading
? LinkedDocEmptyBanner
: isDeleted
? LinkedDocDeletedBanner
: LinkedDocEmptyBanner;
const hasDescriptionAlias = Boolean(description.value);
return this.renderEmbed(
() => html`
<div
class="affine-embed-linked-doc-block ${cardClassMap}"
style=${styleMap({
transform: `scale(${this._scale})`,
transformOrigin: '0 0',
})}
@click=${this._handleClick}
@dblclick=${this._handleDoubleClick}
>
<div class="affine-embed-linked-doc-content">
<div class="affine-embed-linked-doc-content-title">
<div class="affine-embed-linked-doc-content-title-icon">
${icon}
</div>
<div class="affine-embed-linked-doc-content-title-text">
${title}
</div>
</div>
${when(
hasDescriptionAlias,
() =>
html`<div class="affine-embed-linked-doc-content-note alias">
${description}
</div>`,
() =>
when(
showDefaultNoteContent,
() => html`
<div class="affine-embed-linked-doc-content-note default">
${defaultNoteContent}
</div>
`,
() => html`
<div
class="affine-embed-linked-doc-content-note render"
></div>
`
)
)}
${isError
? html`
<div class="affine-embed-linked-doc-card-content-reload">
<div
class="affine-embed-linked-doc-card-content-reload-button"
@click=${this.refreshData}
>
${ReloadIcon} <span>Reload</span>
</div>
</div>
`
: html`
<div class="affine-embed-linked-doc-content-date">
<span>Updated</span>
<span>${dateText}</span>
</div>
`}
</div>
${showDefaultBanner
? html`
<div class="affine-embed-linked-doc-banner default">
${defaultBanner}
</div>
`
: nothing}
</div>
`
);
}
override updated() {
// update card style when linked doc deleted
const linkedDoc = this.linkedDoc;
const { xywh, style } = this.model;
const bound = Bound.deserialize(xywh);
if (linkedDoc && style === 'horizontalThin') {
bound.w = EMBED_CARD_WIDTH.horizontal;
bound.h = EMBED_CARD_HEIGHT.horizontal;
this.doc.withoutTransact(() => {
this.doc.updateBlock(this.model, {
xywh: bound.serialize(),
style: 'horizontal',
});
});
} else if (!linkedDoc && style === 'horizontal') {
bound.w = EMBED_CARD_WIDTH.horizontalThin;
bound.h = EMBED_CARD_HEIGHT.horizontalThin;
this.doc.withoutTransact(() => {
this.doc.updateBlock(this.model, {
xywh: bound.serialize(),
style: 'horizontalThin',
});
});
}
}
@state()
private accessor _docUpdatedAt: Date = new Date();
@state()
private accessor _linkedDocMode: DocMode = 'page';
@state()
private accessor _loading = false;
// reference to block/element
@state()
private accessor _referenceToNode = false;
@property({ attribute: false })
accessor isBannerEmpty = false;
@property({ attribute: false })
accessor isError = false;
@property({ attribute: false })
accessor isNoteContentEmpty = false;
@queryAsync('.affine-embed-linked-doc-content-note.render')
accessor noteContainer!: Promise<HTMLDivElement | null>;
}
@@ -0,0 +1,28 @@
import { EmbedLinkedDocBlockSchema } from '@blocksuite/affine-model';
import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services';
import {
BlockServiceIdentifier,
BlockViewExtension,
} from '@blocksuite/block-std';
import type { ExtensionType } from '@blocksuite/store';
import { literal } from 'lit/static-html.js';
import { EmbedLinkedDocBlockAdapterExtensions } from './adapters/extension';
import { LinkedDocSlashMenuConfigExtension } from './configs/slash-menu';
import { builtinToolbarConfig } from './configs/toolbar';
const flavour = EmbedLinkedDocBlockSchema.model.flavour;
export const EmbedLinkedDocBlockSpec: ExtensionType[] = [
BlockViewExtension(flavour, model => {
return model.parent?.flavour === 'affine:surface'
? literal`affine-embed-edgeless-linked-doc-block`
: literal`affine-embed-linked-doc-block`;
}),
EmbedLinkedDocBlockAdapterExtensions,
ToolbarModuleExtension({
id: BlockServiceIdentifier(flavour),
config: builtinToolbarConfig,
}),
LinkedDocSlashMenuConfigExtension,
].flat();
@@ -0,0 +1,5 @@
export * from './adapters';
export * from './commands';
export { LinkedDocSlashMenuConfigIdentifier } from './configs/slash-menu';
export * from './embed-linked-doc-block';
export * from './embed-linked-doc-spec';
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,115 @@
import {
DarkLoadingIcon,
EmbedEdgelessIcon,
EmbedPageIcon,
LightLoadingIcon,
ReloadIcon,
} from '@blocksuite/affine-components/icons';
import {
ColorScheme,
type EmbedLinkedDocStyles,
} from '@blocksuite/affine-model';
import type { TemplateResult } from 'lit';
import {
DarkSyncedDocErrorBanner,
LightSyncedDocErrorBanner,
} from '../embed-synced-doc-block/styles.js';
import {
DarkLinkedEdgelessDeletedLargeBanner,
DarkLinkedEdgelessDeletedSmallBanner,
DarkLinkedEdgelessEmptyLargeBanner,
DarkLinkedEdgelessEmptySmallBanner,
DarkLinkedPageDeletedLargeBanner,
DarkLinkedPageDeletedSmallBanner,
DarkLinkedPageEmptyLargeBanner,
DarkLinkedPageEmptySmallBanner,
LightLinkedEdgelessDeletedLargeBanner,
LightLinkedEdgelessDeletedSmallBanner,
LightLinkedEdgelessEmptyLargeBanner,
LightLinkedEdgelessEmptySmallBanner,
LightLinkedPageDeletedLargeBanner,
LightLinkedPageDeletedSmallBanner,
LightLinkedPageEmptyLargeBanner,
LightLinkedPageEmptySmallBanner,
LinkedDocDeletedIcon,
} from './styles.js';
type EmbedCardImages = {
LoadingIcon: TemplateResult<1>;
ReloadIcon: TemplateResult<1>;
LinkedDocIcon: TemplateResult<1>;
LinkedDocDeletedIcon: TemplateResult<1>;
LinkedDocEmptyBanner: TemplateResult<1>;
LinkedDocDeletedBanner: TemplateResult<1>;
SyncedDocErrorBanner: TemplateResult<1>;
};
export function getEmbedLinkedDocIcons(
theme: ColorScheme,
editorMode: 'page' | 'edgeless',
style: (typeof EmbedLinkedDocStyles)[number]
): EmbedCardImages {
const small = style !== 'vertical';
if (editorMode === 'page') {
if (theme === ColorScheme.Light) {
return {
LoadingIcon: LightLoadingIcon,
ReloadIcon,
LinkedDocIcon: EmbedPageIcon,
LinkedDocDeletedIcon,
LinkedDocEmptyBanner: small
? LightLinkedPageEmptySmallBanner
: LightLinkedPageEmptyLargeBanner,
LinkedDocDeletedBanner: small
? LightLinkedPageDeletedSmallBanner
: LightLinkedPageDeletedLargeBanner,
SyncedDocErrorBanner: LightSyncedDocErrorBanner,
};
} else {
return {
ReloadIcon,
LoadingIcon: DarkLoadingIcon,
LinkedDocIcon: EmbedPageIcon,
LinkedDocDeletedIcon,
LinkedDocEmptyBanner: small
? DarkLinkedPageEmptySmallBanner
: DarkLinkedPageEmptyLargeBanner,
LinkedDocDeletedBanner: small
? DarkLinkedPageDeletedSmallBanner
: DarkLinkedPageDeletedLargeBanner,
SyncedDocErrorBanner: DarkSyncedDocErrorBanner,
};
}
} else {
if (theme === ColorScheme.Light) {
return {
ReloadIcon,
LoadingIcon: LightLoadingIcon,
LinkedDocIcon: EmbedEdgelessIcon,
LinkedDocDeletedIcon,
LinkedDocEmptyBanner: small
? LightLinkedEdgelessEmptySmallBanner
: LightLinkedEdgelessEmptyLargeBanner,
LinkedDocDeletedBanner: small
? LightLinkedEdgelessDeletedSmallBanner
: LightLinkedEdgelessDeletedLargeBanner,
SyncedDocErrorBanner: LightSyncedDocErrorBanner,
};
} else {
return {
ReloadIcon,
LoadingIcon: DarkLoadingIcon,
LinkedDocIcon: EmbedEdgelessIcon,
LinkedDocDeletedIcon,
LinkedDocEmptyBanner: small
? DarkLinkedEdgelessEmptySmallBanner
: DarkLinkedEdgelessEmptyLargeBanner,
LinkedDocDeletedBanner: small
? DarkLinkedEdgelessDeletedSmallBanner
: DarkLinkedEdgelessDeletedLargeBanner,
SyncedDocErrorBanner: DarkSyncedDocErrorBanner,
};
}
}
}
@@ -0,0 +1,13 @@
import type { ExtensionType } from '@blocksuite/store';
import { EmbedLoomBlockHtmlAdapterExtension } from './html.js';
import { EmbedLoomMarkdownAdapterExtension } from './markdown.js';
import { EmbedLoomBlockNotionHtmlAdapterExtension } from './notion-html.js';
import { EmbedLoomBlockPlainTextAdapterExtension } from './plain-text.js';
export const EmbedLoomBlockAdapterExtensions: ExtensionType[] = [
EmbedLoomBlockHtmlAdapterExtension,
EmbedLoomMarkdownAdapterExtension,
EmbedLoomBlockPlainTextAdapterExtension,
EmbedLoomBlockNotionHtmlAdapterExtension,
];
@@ -0,0 +1,11 @@
import { EmbedLoomBlockSchema } from '@blocksuite/affine-model';
import { BlockHtmlAdapterExtension } from '@blocksuite/affine-shared/adapters';
import { createEmbedBlockHtmlAdapterMatcher } from '../../common/adapters/html.js';
export const embedLoomBlockHtmlAdapterMatcher =
createEmbedBlockHtmlAdapterMatcher(EmbedLoomBlockSchema.model.flavour);
export const EmbedLoomBlockHtmlAdapterExtension = BlockHtmlAdapterExtension(
embedLoomBlockHtmlAdapterMatcher
);
@@ -0,0 +1,4 @@
export * from './html.js';
export * from './markdown.js';
export * from './notion-html.js';
export * from './plain-text.js';
@@ -0,0 +1,11 @@
import { EmbedLoomBlockSchema } from '@blocksuite/affine-model';
import { BlockMarkdownAdapterExtension } from '@blocksuite/affine-shared/adapters';
import { createEmbedBlockMarkdownAdapterMatcher } from '../../common/adapters/markdown.js';
export const embedLoomBlockMarkdownAdapterMatcher =
createEmbedBlockMarkdownAdapterMatcher(EmbedLoomBlockSchema.model.flavour);
export const EmbedLoomMarkdownAdapterExtension = BlockMarkdownAdapterExtension(
embedLoomBlockMarkdownAdapterMatcher
);
@@ -0,0 +1,14 @@
import { EmbedLoomBlockSchema } from '@blocksuite/affine-model';
import { BlockNotionHtmlAdapterExtension } from '@blocksuite/affine-shared/adapters';
import { createEmbedBlockNotionHtmlAdapterMatcher } from '../../common/adapters/notion-html.js';
import { loomUrlRegex } from '../embed-loom-model.js';
export const embedLoomBlockNotionHtmlAdapterMatcher =
createEmbedBlockNotionHtmlAdapterMatcher(
EmbedLoomBlockSchema.model.flavour,
loomUrlRegex
);
export const EmbedLoomBlockNotionHtmlAdapterExtension =
BlockNotionHtmlAdapterExtension(embedLoomBlockNotionHtmlAdapterMatcher);
@@ -0,0 +1,10 @@
import { EmbedLoomBlockSchema } from '@blocksuite/affine-model';
import { BlockPlainTextAdapterExtension } from '@blocksuite/affine-shared/adapters';
import { createEmbedBlockPlainTextAdapterMatcher } from '../../common/adapters/plain-text.js';
export const embedLoomBlockPlainTextAdapterMatcher =
createEmbedBlockPlainTextAdapterMatcher(EmbedLoomBlockSchema.model.flavour);
export const EmbedLoomBlockPlainTextAdapterExtension =
BlockPlainTextAdapterExtension(embedLoomBlockPlainTextAdapterMatcher);
@@ -0,0 +1,32 @@
import { toggleEmbedCardCreateModal } from '@blocksuite/affine-components/embed-card-modal';
import type { SlashMenuConfig } from '@blocksuite/affine-widget-slash-menu';
import { LoomLogoDuotoneIcon } from '@blocksuite/icons/lit';
export const embedLoomSlashMenuConfig: SlashMenuConfig = {
items: [
{
name: 'Loom',
icon: LoomLogoDuotoneIcon(),
group: '4_Content & Media@8',
when: ({ model }) =>
model.doc.schema.flavourSchemaMap.has('affine:embed-loom'),
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,
'Loom',
'The added Loom video 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,6 @@
import { toEdgelessEmbedBlock } from '../common/to-edgeless-embed-block.js';
import { EmbedLoomBlockComponent } from './embed-loom-block.js';
export class EmbedEdgelessLoomBlockComponent extends toEdgelessEmbedBlock(
EmbedLoomBlockComponent
) {}
@@ -0,0 +1,196 @@
import { OpenIcon } from '@blocksuite/affine-components/icons';
import type { EmbedLoomModel, EmbedLoomStyles } from '@blocksuite/affine-model';
import { ThemeProvider } from '@blocksuite/affine-shared/services';
import { BlockSelection } from '@blocksuite/block-std';
import { html } from 'lit';
import { property, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { styleMap } from 'lit/directives/style-map.js';
import { EmbedBlockComponent } from '../common/embed-block-element.js';
import { getEmbedCardIcons } from '../common/utils.js';
import { loomUrlRegex } from './embed-loom-model.js';
import type { EmbedLoomBlockService } from './embed-loom-service.js';
import { LoomIcon, styles } from './styles.js';
import { refreshEmbedLoomUrlData } from './utils.js';
export class EmbedLoomBlockComponent extends EmbedBlockComponent<
EmbedLoomModel,
EmbedLoomBlockService
> {
static override styles = styles;
override _cardStyle: (typeof EmbedLoomStyles)[number] = 'video';
protected _isDragging = false;
protected _isResizing = false;
open = () => {
let link = this.model.url;
if (!link.match(/^[a-zA-Z]+:\/\//)) {
link = 'https://' + link;
}
window.open(link, '_blank');
};
refreshData = () => {
refreshEmbedLoomUrlData(this, this.fetchAbortController.signal).catch(
console.error
);
};
private _handleDoubleClick(event: MouseEvent) {
event.stopPropagation();
this.open();
}
private _selectBlock() {
const selectionManager = this.host.selection;
const blockSelection = selectionManager.create(BlockSelection, {
blockId: this.blockId,
});
selectionManager.setGroup('note', [blockSelection]);
}
protected _handleClick(event: MouseEvent) {
event.stopPropagation();
this._selectBlock();
}
override connectedCallback() {
super.connectedCallback();
this._cardStyle = this.model.style;
if (!this.model.videoId) {
this.doc.withoutTransact(() => {
const url = this.model.url;
const urlMatch = url.match(loomUrlRegex);
if (urlMatch) {
const [, videoId] = urlMatch;
this.doc.updateBlock(this.model, {
videoId,
});
}
});
}
if (!this.model.description && !this.model.title) {
this.doc.withoutTransact(() => {
this.refreshData();
});
}
this.disposables.add(
this.model.propsUpdated.on(({ key }) => {
this.requestUpdate();
if (key === 'url') {
this.refreshData();
}
})
);
// this is required to prevent iframe from capturing pointer events
this.disposables.add(
this.selected$.subscribe(selected => {
this._showOverlay = this._isResizing || this._isDragging || !selected;
})
);
// this is required to prevent iframe from capturing pointer events
this.handleEvent('dragStart', () => {
this._isDragging = true;
this._showOverlay =
this._isResizing || this._isDragging || !this.selected$.peek();
});
this.handleEvent('dragEnd', () => {
this._isDragging = false;
this._showOverlay =
this._isResizing || this._isDragging || !this.selected$.peek();
});
}
override renderBlock() {
const { image, title = 'Loom', description, videoId } = this.model;
const loading = this.loading;
const theme = this.std.get(ThemeProvider).theme;
const { LoadingIcon, EmbedCardBannerIcon } = getEmbedCardIcons(theme);
const titleIcon = loading ? LoadingIcon : LoomIcon;
const titleText = loading ? 'Loading...' : title;
const descriptionText = loading ? '' : description;
const bannerImage =
!loading && image
? html`<object type="image/webp" data=${image} draggable="false">
${EmbedCardBannerIcon}
</object>`
: EmbedCardBannerIcon;
return this.renderEmbed(
() => html`
<div
class=${classMap({
'affine-embed-loom-block': true,
loading,
selected: this.selected$.value,
})}
style=${styleMap({
transform: `scale(${this._scale})`,
transformOrigin: '0 0',
})}
@click=${this._handleClick}
@dblclick=${this._handleDoubleClick}
>
<div class="affine-embed-loom-video">
${videoId
? html`
<div class="affine-embed-loom-video-iframe-container">
<iframe
src=${`https://www.loom.com/embed/${videoId}?hide_title=true`}
frameborder="0"
allow="fullscreen; accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
loading="lazy"
></iframe>
<div
class=${classMap({
'affine-embed-loom-video-iframe-overlay': true,
hide: !this._showOverlay,
})}
></div>
</div>
`
: bannerImage}
</div>
<div class="affine-embed-loom-content">
<div class="affine-embed-loom-content-header">
<div class="affine-embed-loom-content-title-icon">
${titleIcon}
</div>
<div class="affine-embed-loom-content-title-text">
${titleText}
</div>
</div>
<div class="affine-embed-loom-content-description">
${descriptionText}
</div>
<div class="affine-embed-loom-content-url" @click=${this.open}>
<span>loom.com</span>
<div class="affine-embed-loom-content-url-icon">${OpenIcon}</div>
</div>
</div>
</div>
`
);
}
@state()
protected accessor _showOverlay = true;
@property({ attribute: false })
accessor loading = false;
}
@@ -0,0 +1,2 @@
export const loomUrlRegex: RegExp =
/(?:https?:\/\/)??(?:www\.)?loom\.com\/share\/([a-zA-Z0-9]+)/;
@@ -0,0 +1,25 @@
import {
EmbedLoomBlockSchema,
type EmbedLoomModel,
EmbedLoomStyles,
} from '@blocksuite/affine-model';
import { EmbedOptionConfig } from '@blocksuite/affine-shared/services';
import { BlockService } from '@blocksuite/block-std';
import { loomUrlRegex } from './embed-loom-model.js';
import { queryEmbedLoomData } from './utils.js';
export class EmbedLoomBlockService extends BlockService {
static override readonly flavour = EmbedLoomBlockSchema.model.flavour;
queryUrlData = (embedLoomModel: EmbedLoomModel, signal?: AbortSignal) => {
return queryEmbedLoomData(embedLoomModel, signal);
};
}
export const EmbedLoomBlockOptionConfig = EmbedOptionConfig({
flavour: EmbedLoomBlockSchema.model.flavour,
urlRegex: loomUrlRegex,
styles: EmbedLoomStyles,
viewType: 'embed',
});
@@ -0,0 +1,38 @@
import { EmbedLoomBlockSchema } from '@blocksuite/affine-model';
import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services';
import { SlashMenuConfigExtension } from '@blocksuite/affine-widget-slash-menu';
import {
BlockServiceIdentifier,
BlockViewExtension,
FlavourExtension,
} from '@blocksuite/block-std';
import type { ExtensionType } from '@blocksuite/store';
import { literal } from 'lit/static-html.js';
import { createBuiltinToolbarConfigForExternal } from '../configs/toolbar';
import { EmbedLoomBlockAdapterExtensions } from './adapters/extension';
import { embedLoomSlashMenuConfig } from './configs/slash-menu';
import { EmbedLoomBlockComponent } from './embed-loom-block';
import {
EmbedLoomBlockOptionConfig,
EmbedLoomBlockService,
} from './embed-loom-service';
const flavour = EmbedLoomBlockSchema.model.flavour;
export const EmbedLoomBlockSpec: ExtensionType[] = [
FlavourExtension(flavour),
EmbedLoomBlockService,
BlockViewExtension(flavour, model => {
return model.parent?.flavour === 'affine:surface'
? literal`affine-embed-edgeless-loom-block`
: literal`affine-embed-loom-block`;
}),
EmbedLoomBlockAdapterExtensions,
EmbedLoomBlockOptionConfig,
ToolbarModuleExtension({
id: BlockServiceIdentifier(flavour),
config: createBuiltinToolbarConfigForExternal(EmbedLoomBlockComponent),
}),
SlashMenuConfigExtension(flavour, embedLoomSlashMenuConfig),
].flat();
@@ -0,0 +1,6 @@
export * from './adapters/index.js';
export * from './embed-loom-block.js';
export * from './embed-loom-model.js';
export * from './embed-loom-service.js';
export * from './embed-loom-spec.js';
export { LoomIcon } from './styles.js';
@@ -0,0 +1,217 @@
import { css, html } from 'lit';
export const styles = css`
.affine-embed-loom-block {
box-sizing: border-box;
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-loom-video {
flex-grow: 1;
width: 100%;
opacity: var(--add, 1);
}
.affine-embed-loom-video img,
.affine-embed-loom-video object,
.affine-embed-loom-video svg {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 4px 4px var(--1, 0px) var(--1, 0px);
}
.affine-embed-loom-video-iframe-container {
position: relative;
height: 100%;
}
.affine-embed-loom-video-iframe-container > iframe {
width: 100%;
height: 100%;
border-radius: 4px 4px var(--1, 0px) var(--1, 0px);
}
.affine-embed-loom-video-iframe-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.affine-embed-loom-video-iframe-overlay.hide {
display: none;
}
.affine-embed-loom-content {
display: flex;
flex-direction: column;
width: 100%;
height: fit-content;
border-radius: var(--1, 0px);
opacity: var(--add, 1);
}
.affine-embed-loom-content-header {
display: flex;
flex-direction: row;
gap: 8px;
align-items: center;
align-self: stretch;
padding: var(--1, 0px);
border-radius: var(--1, 0px);
opacity: var(--add, 1);
}
.affine-embed-loom-content-title-icon {
display: flex;
width: 20px;
height: 20px;
justify-content: center;
align-items: center;
}
.affine-embed-loom-content-title-icon img,
.affine-embed-loom-content-title-icon object,
.affine-embed-loom-content-title-icon svg {
width: 20px;
height: 20px;
fill: var(--affine-background-primary-color);
}
.affine-embed-loom-content-title-text {
flex: 1 0 0;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
word-break: break-word;
overflow: hidden;
text-overflow: ellipsis;
color: var(--affine-text-primary-color);
font-family: var(--affine-font-family);
font-size: var(--affine-font-sm);
font-style: normal;
font-weight: 600;
line-height: 22px;
}
.affine-embed-loom-content-description {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
flex: 1 0 0;
align-self: stretch;
word-break: break-word;
white-space: normal;
overflow: hidden;
text-overflow: ellipsis;
color: var(--affine-text-primary-color);
font-family: var(--affine-font-family);
font-size: var(--affine-font-xs);
font-style: normal;
font-weight: 400;
line-height: 20px;
}
.affine-embed-loom-content-url {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 4px;
width: max-content;
max-width: 100%;
cursor: pointer;
}
.affine-embed-loom-content-url > span {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
word-break: break-all;
white-space: normal;
overflow: hidden;
text-overflow: ellipsis;
color: var(--affine-text-secondary-color);
font-family: var(--affine-font-family);
font-size: var(--affine-font-xs);
font-style: normal;
font-weight: 400;
line-height: 20px;
}
.affine-embed-loom-content-url:hover > span {
color: var(--affine-link-color);
}
.affine-embed-loom-content-url:hover .open-icon {
fill: var(--affine-link-color);
}
.affine-embed-loom-content-url-icon {
display: flex;
align-items: center;
justify-content: center;
width: 12px;
height: 12px;
}
.affine-embed-loom-content-url-icon .open-icon {
height: 12px;
width: 12px;
fill: var(--affine-text-secondary-color);
}
.affine-embed-loom-block.loading {
.affine-embed-loom-content-title-text {
color: var(--affine-placeholder-color);
}
}
.affine-embed-loom-block.selected {
.affine-embed-loom-content-url > span {
color: var(--affine-link-color);
}
.affine-embed-loom-content-url .open-icon {
fill: var(--affine-link-color);
}
}
`;
export const LoomIcon = html`<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clip0_1780_25276)">
<path
d="M18.3333 9.07327H13.4597L17.6805 6.63642L16.7536 5.03052L12.5328 7.46736L14.9691 3.24695L13.3632 2.3195L10.9269 6.5399V1.66669H9.073V6.54037L6.63577 2.3195L5.03036 3.24648L7.46713 7.4669L3.24638 5.03052L2.31942 6.63596L6.54017 9.07281H1.66663V10.9268H6.53971L2.31942 13.3636L3.24638 14.9695L7.46667 12.5331L5.0299 16.7535L6.63577 17.6805L9.07254 13.4597V18.3334H10.9265V13.4601L13.3628 17.6805L14.9686 16.7535L12.5319 12.5327L16.7526 14.9695L17.6796 13.3636L13.4593 10.9272H18.3323V9.07327H18.3333ZM9.99996 12.5215C8.60206 12.5215 7.469 11.3884 7.469 9.99047C7.469 8.59253 8.60206 7.45943 9.99996 7.45943C11.3979 7.45943 12.5309 8.59253 12.5309 9.99047C12.5309 11.3884 11.3979 12.5215 9.99996 12.5215Z"
fill="#625DF5"
/>
</g>
<defs>
<clipPath id="clip0_1780_25276">
<rect width="20" height="20" fill="white" />
</clipPath>
</defs>
</svg>`;
@@ -0,0 +1,75 @@
import type {
EmbedLoomBlockUrlData,
EmbedLoomModel,
} from '@blocksuite/affine-model';
import { isAbortError } from '@blocksuite/affine-shared/utils';
import type { EmbedLoomBlockComponent } from './embed-loom-block.js';
const LoomOEmbedEndpoint = 'https://www.loom.com/v1/oembed';
export async function queryEmbedLoomData(
embedLoomModel: EmbedLoomModel,
signal?: AbortSignal
): Promise<Partial<EmbedLoomBlockUrlData>> {
const url = embedLoomModel.url;
const loomEmbedData: Partial<EmbedLoomBlockUrlData> =
await queryLoomOEmbedData(url, signal);
return loomEmbedData;
}
export async function queryLoomOEmbedData(
url: string,
signal?: AbortSignal
): Promise<Partial<EmbedLoomBlockUrlData>> {
let loomOEmbedData: Partial<EmbedLoomBlockUrlData> = {};
const oEmbedUrl = `${LoomOEmbedEndpoint}?url=${url}`;
const oEmbedResponse = await fetch(oEmbedUrl, { signal }).catch(() => null);
if (oEmbedResponse && oEmbedResponse.ok) {
const oEmbedJson = await oEmbedResponse.json();
const { title, description, thumbnail_url: image } = oEmbedJson;
loomOEmbedData = {
title,
description,
image,
};
}
return loomOEmbedData;
}
export async function refreshEmbedLoomUrlData(
embedLoomElement: EmbedLoomBlockComponent,
signal?: AbortSignal
): Promise<void> {
let title = null,
description = null,
image = null;
try {
embedLoomElement.loading = true;
const queryUrlData = embedLoomElement.service?.queryUrlData;
if (!queryUrlData) return;
const loomUrlData = await queryUrlData(embedLoomElement.model);
({ title = null, description = null, image = null } = loomUrlData);
if (signal?.aborted) return;
embedLoomElement.doc.updateBlock(embedLoomElement.model, {
title,
description,
image,
});
} catch (error) {
if (signal?.aborted || isAbortError(error)) return;
} finally {
embedLoomElement.loading = false;
}
}
@@ -0,0 +1,11 @@
import type { ExtensionType } from '@blocksuite/store';
import { EmbedSyncedDocBlockHtmlAdapterExtension } from './html.js';
import { EmbedSyncedDocMarkdownAdapterExtension } from './markdown.js';
import { EmbedSyncedDocBlockPlainTextAdapterExtension } from './plain-text.js';
export const EmbedSyncedDocBlockAdapterExtensions: ExtensionType[] = [
EmbedSyncedDocBlockHtmlAdapterExtension,
EmbedSyncedDocMarkdownAdapterExtension,
EmbedSyncedDocBlockPlainTextAdapterExtension,
];
@@ -0,0 +1,88 @@
import { EmbedSyncedDocBlockSchema } from '@blocksuite/affine-model';
import {
BlockHtmlAdapterExtension,
type BlockHtmlAdapterMatcher,
} from '@blocksuite/affine-shared/adapters';
export const embedSyncedDocBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = {
flavour: EmbedSyncedDocBlockSchema.model.flavour,
toMatch: () => false,
fromMatch: o => o.node.flavour === EmbedSyncedDocBlockSchema.model.flavour,
toBlockSnapshot: {},
fromBlockSnapshot: {
enter: async (o, context) => {
const { configs, walker, walkerContext, job } = context;
const type = configs.get('embedSyncedDocExportType');
// this context is used for nested sync block
if (
walkerContext.getGlobalContext('embed-synced-doc-counter') === undefined
) {
walkerContext.setGlobalContext('embed-synced-doc-counter', 0);
}
let counter = walkerContext.getGlobalContext(
'embed-synced-doc-counter'
) as number;
walkerContext.setGlobalContext('embed-synced-doc-counter', ++counter);
if (type === 'content') {
const syncedDocId = o.node.props.pageId as string;
const syncedDoc = job.docCRUD.get(syncedDocId);
walkerContext.setGlobalContext('hast:html-root-doc', false);
if (!syncedDoc) return;
if (counter === 1) {
const syncedSnapshot = job.docToSnapshot(syncedDoc);
if (syncedSnapshot) {
await walker.walkONode(syncedSnapshot.blocks);
}
} else {
walkerContext
.openNode(
{
type: 'element',
tagName: 'div',
properties: {
className: ['affine-paragraph-block-container'],
},
children: [],
},
'children'
)
.openNode(
{
type: 'element',
tagName: 'p',
properties: {},
children: [
{ type: 'text', value: syncedDoc.meta?.title ?? '' },
],
},
'children'
)
.closeNode()
.closeNode();
}
}
},
leave: (_, context) => {
const { walkerContext } = context;
const counter = walkerContext.getGlobalContext(
'embed-synced-doc-counter'
) as number;
const currentCounter = counter - 1;
walkerContext.setGlobalContext(
'embed-synced-doc-counter',
currentCounter
);
// When leave the last embed synced doc block, we need to set the html root doc context to true
walkerContext.setGlobalContext(
'hast:html-root-doc',
currentCounter === 0
);
},
},
};
export const EmbedSyncedDocBlockHtmlAdapterExtension =
BlockHtmlAdapterExtension(embedSyncedDocBlockHtmlAdapterMatcher);
@@ -0,0 +1,3 @@
export * from './html.js';
export * from './markdown.js';
export * from './plain-text.js';
@@ -0,0 +1,64 @@
import { EmbedSyncedDocBlockSchema } from '@blocksuite/affine-model';
import {
BlockMarkdownAdapterExtension,
type BlockMarkdownAdapterMatcher,
} from '@blocksuite/affine-shared/adapters';
export const embedSyncedDocBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher =
{
flavour: EmbedSyncedDocBlockSchema.model.flavour,
toMatch: () => false,
fromMatch: o => o.node.flavour === EmbedSyncedDocBlockSchema.model.flavour,
toBlockSnapshot: {},
fromBlockSnapshot: {
enter: async (o, context) => {
const { configs, walker, walkerContext, job } = context;
const type = configs.get('embedSyncedDocExportType');
// this context is used for nested sync block
if (
walkerContext.getGlobalContext('embed-synced-doc-counter') ===
undefined
) {
walkerContext.setGlobalContext('embed-synced-doc-counter', 0);
}
let counter = walkerContext.getGlobalContext(
'embed-synced-doc-counter'
) as number;
walkerContext.setGlobalContext('embed-synced-doc-counter', ++counter);
if (type === 'content') {
const syncedDocId = o.node.props.pageId as string;
const syncedDoc = job.docCRUD.get(syncedDocId);
if (!syncedDoc) return;
if (counter === 1) {
const syncedSnapshot = job.docToSnapshot(syncedDoc);
if (syncedSnapshot) {
await walker.walkONode(syncedSnapshot.blocks);
}
} else {
// TODO(@L-Sun) may be use the nested content
walkerContext
.openNode({
type: 'paragraph',
children: [
{ type: 'text', value: syncedDoc.meta?.title ?? '' },
],
})
.closeNode();
}
}
},
leave: (_, context) => {
const { walkerContext } = context;
const counter = walkerContext.getGlobalContext(
'embed-synced-doc-counter'
) as number;
walkerContext.setGlobalContext('embed-synced-doc-counter', counter - 1);
},
},
};
export const EmbedSyncedDocMarkdownAdapterExtension =
BlockMarkdownAdapterExtension(embedSyncedDocBlockMarkdownAdapterMatcher);
@@ -0,0 +1,62 @@
import { EmbedSyncedDocBlockSchema } from '@blocksuite/affine-model';
import {
BlockPlainTextAdapterExtension,
type BlockPlainTextAdapterMatcher,
} from '@blocksuite/affine-shared/adapters';
export const embedSyncedDocBlockPlainTextAdapterMatcher: BlockPlainTextAdapterMatcher =
{
flavour: EmbedSyncedDocBlockSchema.model.flavour,
toMatch: () => false,
fromMatch: o => o.node.flavour === EmbedSyncedDocBlockSchema.model.flavour,
toBlockSnapshot: {},
fromBlockSnapshot: {
enter: async (o, context) => {
const { configs, walker, walkerContext, job, textBuffer } = context;
const type = configs.get('embedSyncedDocExportType');
// this context is used for nested sync block
if (
walkerContext.getGlobalContext('embed-synced-doc-counter') ===
undefined
) {
walkerContext.setGlobalContext('embed-synced-doc-counter', 0);
}
let counter = walkerContext.getGlobalContext(
'embed-synced-doc-counter'
) as number;
walkerContext.setGlobalContext('embed-synced-doc-counter', ++counter);
let buffer = '';
if (type === 'content') {
const syncedDocId = o.node.props.pageId as string;
const syncedDoc = job.docCRUD.get(syncedDocId);
if (!syncedDoc) return;
if (counter === 1) {
const syncedSnapshot = job.docToSnapshot(syncedDoc);
if (syncedSnapshot) {
await walker.walkONode(syncedSnapshot.blocks);
}
} else {
buffer = syncedDoc.meta?.title ?? '';
if (buffer) {
buffer += '\n';
}
}
}
textBuffer.content += buffer;
},
leave: (_, context) => {
const { walkerContext } = context;
const counter = walkerContext.getGlobalContext(
'embed-synced-doc-counter'
) as number;
walkerContext.setGlobalContext('embed-synced-doc-counter', counter - 1);
},
},
};
export const EmbedSyncedDocBlockPlainTextAdapterExtension =
BlockPlainTextAdapterExtension(embedSyncedDocBlockPlainTextAdapterMatcher);
@@ -0,0 +1,263 @@
import { ThemeProvider } from '@blocksuite/affine-shared/services';
import {
BlockSelection,
isGfxBlockComponent,
ShadowlessElement,
} from '@blocksuite/block-std';
import { WithDisposable } from '@blocksuite/global/lit';
import { html, nothing } from 'lit';
import { property, queryAsync } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import throttle from 'lodash-es/throttle';
import {
RENDER_CARD_THROTTLE_MS,
renderLinkedDocInCard,
} from '../../common/render-linked-doc.js';
import type { EmbedSyncedDocBlockComponent } from '../embed-synced-doc-block.js';
import { cardStyles } from '../styles.js';
import { getSyncedDocIcons } from '../utils.js';
export class EmbedSyncedDocCard extends WithDisposable(ShadowlessElement) {
static override styles = cardStyles;
private _dragging = false;
get blockState() {
return this.block.blockState;
}
get editorMode() {
return this.block.editorMode;
}
get host() {
return this.block.host;
}
get linkedDoc() {
return this.block.syncedDoc;
}
get model() {
return this.block.model;
}
get std() {
return this.block.std;
}
private _handleClick(event: MouseEvent) {
event.stopPropagation();
if (!isGfxBlockComponent(this.block)) {
this._selectBlock();
}
}
private _isDocEmpty() {
const syncedDoc = this.block.syncedDoc;
if (!syncedDoc) {
return false;
}
return (
!!syncedDoc &&
!syncedDoc.meta?.title.length &&
this.isNoteContentEmpty &&
this.isBannerEmpty
);
}
private _selectBlock() {
const selectionManager = this.host.selection;
const blockSelection = selectionManager.create(BlockSelection, {
blockId: this.block.blockId,
});
selectionManager.setGroup('note', [blockSelection]);
}
override connectedCallback() {
super.connectedCallback();
this.block.handleEvent(
'dragStart',
() => {
this._dragging = true;
},
{ global: true }
);
this.block.handleEvent(
'dragEnd',
() => {
this._dragging = false;
},
{ global: true }
);
const { isCycle } = this.block.blockState;
const syncedDoc = this.block.syncedDoc;
if (isCycle && syncedDoc) {
if (syncedDoc.root) {
renderLinkedDocInCard(this);
} else {
syncedDoc.slots.rootAdded.once(() => {
renderLinkedDocInCard(this);
});
}
this.disposables.add(
syncedDoc.workspace.slots.docListUpdated.on(() => {
renderLinkedDocInCard(this);
})
);
// 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(
syncedDoc.slots.blockUpdated.on(
throttle(payload => {
if (this._dragging) {
return;
}
if (
payload.type === 'update' &&
['', 'caption', 'xywh'].includes(payload.props.key)
) {
return;
}
renderLinkedDocInCard(this);
}, RENDER_CARD_THROTTLE_MS)
)
);
}
}
override render() {
const { isLoading, isDeleted, isError, isCycle } = this.blockState;
const error = this.isError || isError;
const isEmpty = this._isDocEmpty() && this.isBannerEmpty;
const cardClassMap = classMap({
loading: isLoading,
error,
deleted: isDeleted,
cycle: isCycle,
surface: isGfxBlockComponent(this.block),
empty: isEmpty,
'banner-empty': this.isBannerEmpty,
'note-empty': this.isNoteContentEmpty,
});
const theme = this.std.get(ThemeProvider).theme;
const {
LoadingIcon,
SyncedDocErrorIcon,
ReloadIcon,
SyncedDocEmptyBanner,
SyncedDocErrorBanner,
SyncedDocDeletedBanner,
} = getSyncedDocIcons(theme, this.editorMode);
const icon = error
? SyncedDocErrorIcon
: isLoading
? LoadingIcon
: this.block.icon$.value;
const title = isLoading ? 'Loading...' : this.block.title$;
const showDefaultNoteContent = isLoading || error || isDeleted || isEmpty;
const defaultNoteContent = error
? 'This linked doc failed to load.'
: isLoading
? ''
: isDeleted
? 'This linked doc is deleted.'
: isEmpty
? 'Preview of the page will be displayed here.'
: '';
const dateText = this.block.docUpdatedAt.toLocaleString();
const showDefaultBanner = isLoading || error || isDeleted || isEmpty;
const defaultBanner = isLoading
? SyncedDocEmptyBanner
: error
? SyncedDocErrorBanner
: isDeleted
? SyncedDocDeletedBanner
: SyncedDocEmptyBanner;
return html`
<div
class="affine-embed-synced-doc-card ${cardClassMap}"
@click=${this._handleClick}
>
<div class="affine-embed-synced-doc-card-content">
<div class="affine-embed-synced-doc-card-content-title">
<div class="affine-embed-synced-doc-card-content-title-icon">
${icon}
</div>
<div class="affine-embed-synced-doc-card-content-title-text">
${title}
</div>
</div>
${showDefaultNoteContent
? html`<div class="affine-embed-synced-doc-content-note default">
${defaultNoteContent}
</div>`
: nothing}
<div class="affine-embed-synced-doc-content-note render"></div>
${error
? html`
<div class="affine-embed-synced-doc-card-content-reload">
<div
class="affine-embed-synced-doc-card-content-reload-button"
@click=${() => this.block.refreshData()}
>
${ReloadIcon} <span>Reload</span>
</div>
</div>
`
: html`
<div class="affine-embed-synced-doc-card-content-date">
<span>Updated</span>
<span>${dateText}</span>
</div>
`}
</div>
<div class="affine-embed-synced-doc-card-banner render"></div>
${showDefaultBanner
? html`
<div class="affine-embed-synced-doc-card-banner default">
${defaultBanner}
</div>
`
: nothing}
</div>
`;
}
@queryAsync('.affine-embed-synced-doc-card-banner.render')
accessor bannerContainer!: Promise<HTMLDivElement>;
@property({ attribute: false })
accessor block!: EmbedSyncedDocBlockComponent;
@property({ attribute: false })
accessor isBannerEmpty = false;
@property({ attribute: false })
accessor isError = false;
@property({ attribute: false })
accessor isNoteContentEmpty = false;
@queryAsync('.affine-embed-synced-doc-content-note.render')
accessor noteContainer!: Promise<HTMLDivElement>;
}
@@ -0,0 +1,259 @@
import { toast } from '@blocksuite/affine-components/toast';
import { EmbedSyncedDocModel } from '@blocksuite/affine-model';
import {
ActionPlacement,
type OpenDocMode,
type ToolbarAction,
type ToolbarActionGroup,
type ToolbarContext,
type ToolbarModuleConfig,
} from '@blocksuite/affine-shared/services';
import { getBlockProps } from '@blocksuite/affine-shared/utils';
import { BlockSelection } from '@blocksuite/block-std';
import {
ArrowDownSmallIcon,
CaptionIcon,
CopyIcon,
DeleteIcon,
DuplicateIcon,
ExpandFullIcon,
OpenInNewIcon,
} from '@blocksuite/icons/lit';
import { Slice } from '@blocksuite/store';
import { 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 { EmbedSyncedDocBlockComponent } from '../embed-synced-doc-block';
const trackBaseProps = {
segment: 'doc',
page: 'doc editor',
module: 'toolbar',
category: 'linked doc',
type: 'embed view',
};
export const builtinToolbarConfig = {
actions: [
{
placement: ActionPlacement.Start,
id: 'A.open-doc',
actions: [
{
id: 'open-in-active-view',
label: 'Open this doc',
icon: ExpandFullIcon(),
},
],
content(ctx) {
const component = ctx.getCurrentBlockComponentBy(
BlockSelection,
EmbedSyncedDocBlockComponent
);
if (!component) return null;
const actions = this.actions
.map<ToolbarAction>(action => {
const shouldOpenInActiveView = action.id === 'open-in-active-view';
const allowed =
typeof action.when === 'function'
? action.when(ctx)
: (action.when ?? true);
return {
...action,
disabled: shouldOpenInActiveView
? component.model.pageId === ctx.store.id
: false,
when: allowed,
run: (_ctx: ToolbarContext) =>
component.open({
openMode: action.id as OpenDocMode,
}),
};
})
.filter(action => {
if (typeof action.when === 'function') return action.when(ctx);
return action.when ?? true;
});
return html`
<editor-menu-button
.contentPadding="${'8px'}"
.button=${html`
<editor-icon-button aria-label="Open doc" .tooltip=${'Open doc'}>
${OpenInNewIcon()} ${ArrowDownSmallIcon()}
</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>
`;
},
} satisfies ToolbarActionGroup<ToolbarAction>,
{
id: 'a.conversions',
actions: [
{
id: 'inline',
label: 'Inline view',
run(ctx) {
const component = ctx.getCurrentBlockComponentBy(
BlockSelection,
EmbedSyncedDocBlockComponent
);
component?.covertToInline();
// Clears
ctx.reset();
ctx.select('note');
ctx.track('SelectedView', {
...trackBaseProps,
control: 'select view',
type: 'inline view',
});
},
},
{
id: 'card',
label: 'Card view',
run(ctx) {
const component = ctx.getCurrentBlockComponentBy(
BlockSelection,
EmbedSyncedDocBlockComponent
);
component?.convertToCard();
ctx.track('SelectedView', {
...trackBaseProps,
control: 'select view',
type: 'card view',
});
},
},
{
id: 'embed',
label: 'Embed view',
disabled: true,
},
],
content(ctx) {
const model = ctx.getCurrentModelByType(
BlockSelection,
EmbedSyncedDocModel
);
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>,
{
id: 'b.caption',
tooltip: 'Caption',
icon: CaptionIcon(),
run(ctx) {
const component = ctx.getCurrentBlockComponentBy(
BlockSelection,
EmbedSyncedDocBlockComponent
);
component?.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.getCurrentModelBy(BlockSelection);
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.getCurrentModelBy(BlockSelection);
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.getCurrentModelBy(BlockSelection);
if (!model) return;
ctx.store.deleteBlock(model);
// Clears
ctx.select('note');
ctx.reset();
},
},
],
} as const satisfies ToolbarModuleConfig;
@@ -0,0 +1,176 @@
import {
EdgelessCRUDIdentifier,
reassociateConnectorsCommand,
} from '@blocksuite/affine-block-surface';
import type { AliasInfo } from '@blocksuite/affine-model';
import {
EMBED_CARD_HEIGHT,
EMBED_CARD_WIDTH,
} from '@blocksuite/affine-shared/consts';
import {
ThemeExtensionIdentifier,
ThemeProvider,
} from '@blocksuite/affine-shared/services';
import { BlockStdScope } from '@blocksuite/block-std';
import { Bound } from '@blocksuite/global/gfx';
import { html, nothing } from 'lit';
import { choose } from 'lit/directives/choose.js';
import { classMap } from 'lit/directives/class-map.js';
import { guard } from 'lit/directives/guard.js';
import { styleMap } from 'lit/directives/style-map.js';
import { toEdgelessEmbedBlock } from '../common/to-edgeless-embed-block.js';
import { EmbedSyncedDocBlockComponent } from './embed-synced-doc-block.js';
export class EmbedEdgelessSyncedDocBlockComponent extends toEdgelessEmbedBlock(
EmbedSyncedDocBlockComponent
) {
protected override _renderSyncedView = () => {
const { syncedDoc, editorMode } = this;
if (!syncedDoc) {
console.error('Synced doc is not found');
return html`${nothing}`;
}
let containerStyleMap = styleMap({
position: 'relative',
width: '100%',
});
const modelScale = this.model.scale ?? 1;
const bound = Bound.deserialize(this.model.xywh);
const width = bound.w / modelScale;
const height = bound.h / modelScale;
containerStyleMap = styleMap({
width: `${width}px`,
height: `${height}px`,
minHeight: `${height}px`,
transform: `scale(${modelScale})`,
transformOrigin: '0 0',
});
const themeService = this.std.get(ThemeProvider);
const themeExtension = this.std.getOptional(ThemeExtensionIdentifier);
const appTheme = themeService.app$.value;
let edgelessTheme = themeService.edgeless$.value;
if (themeExtension?.getEdgelessTheme && this.syncedDoc?.id) {
edgelessTheme = themeExtension.getEdgelessTheme(this.syncedDoc.id).value;
}
const theme = this.isPageMode ? appTheme : edgelessTheme;
const scale = this.model.scale ?? 1;
this.dataset.nestedEditor = '';
const renderEditor = () => {
return choose(editorMode, [
[
'page',
() => html`
<div class="affine-page-viewport" data-theme=${appTheme}>
${new BlockStdScope({
store: syncedDoc,
extensions: this._buildPreviewSpec('preview:page'),
}).render()}
</div>
`,
],
[
'edgeless',
() => html`
<div class="affine-edgeless-viewport" data-theme=${edgelessTheme}>
${new BlockStdScope({
store: syncedDoc,
extensions: this._buildPreviewSpec('preview:edgeless'),
}).render()}
</div>
`,
],
]);
};
return this.renderEmbed(
() => html`
<div
class=${classMap({
'affine-embed-synced-doc-container': true,
[editorMode]: true,
[theme]: true,
surface: true,
selected: this.selected$.value,
})}
@click=${this._handleClick}
style=${containerStyleMap}
?data-scale=${scale}
>
<div class="affine-embed-synced-doc-editor">
${this.isPageMode && this._isEmptySyncedDoc
? html`
<div class="affine-embed-synced-doc-editor-empty">
<span>
This is a linked doc, you can add content here.
</span>
</div>
`
: guard([editorMode, syncedDoc], renderEditor)}
</div>
<div class="affine-embed-synced-doc-editor-overlay"></div>
</div>
`
);
};
override convertToCard = (aliasInfo?: AliasInfo) => {
const { id, doc, caption, xywh } = this.model;
const style = 'vertical';
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-linked-doc',
{
xywh: bound.serialize(),
style,
caption,
...this.referenceInfo,
...aliasInfo,
},
surface
);
this.std.command.exec(reassociateConnectorsCommand, {
oldId: id,
newId,
});
this.gfx.selection.set({
editing: false,
elements: [newId],
});
doc.deleteBlock(this.model);
};
override renderGfxBlock() {
const { style, xywh } = this.model;
const bound = Bound.deserialize(xywh);
this.embedContainerStyle.width = `${bound.w}px`;
this.embedContainerStyle.height = `${bound.h}px`;
this.cardStyleMap = {
display: 'block',
width: `${EMBED_CARD_WIDTH[style]}px`,
height: `${EMBED_CARD_HEIGHT[style]}px`,
transform: `scale(${bound.w / EMBED_CARD_WIDTH[style]}, ${bound.h / EMBED_CARD_HEIGHT[style]})`,
transformOrigin: '0 0',
};
return this.renderPageContent();
}
override accessor useCaptionEditor = true;
}
@@ -0,0 +1,624 @@
import { Peekable } from '@blocksuite/affine-components/peek';
import {
type AliasInfo,
type DocMode,
type EmbedSyncedDocModel,
NoteDisplayMode,
type ReferenceInfo,
} from '@blocksuite/affine-model';
import {
type DocLinkClickedEvent,
RefNodeSlotsProvider,
} from '@blocksuite/affine-rich-text';
import { REFERENCE_NODE } from '@blocksuite/affine-shared/consts';
import {
DocDisplayMetaProvider,
DocModeProvider,
EditorSettingExtension,
EditorSettingProvider,
GeneralSettingSchema,
ThemeExtensionIdentifier,
ThemeProvider,
} from '@blocksuite/affine-shared/services';
import {
cloneReferenceInfo,
SpecProvider,
} from '@blocksuite/affine-shared/utils';
import {
BlockSelection,
BlockStdScope,
type EditorHost,
LifeCycleWatcher,
} from '@blocksuite/block-std';
import {
GfxControllerIdentifier,
GfxExtension,
} from '@blocksuite/block-std/gfx';
import { Bound, getCommonBound } from '@blocksuite/global/gfx';
import { type GetBlocksOptions, type Query, Text } from '@blocksuite/store';
import { computed, signal } from '@preact/signals-core';
import { html, nothing, type PropertyValues } from 'lit';
import { query, state } from 'lit/decorators.js';
import { choose } from 'lit/directives/choose.js';
import { classMap } from 'lit/directives/class-map.js';
import { guard } from 'lit/directives/guard.js';
import { type StyleInfo, styleMap } from 'lit/directives/style-map.js';
import * as Y from 'yjs';
import { EmbedBlockComponent } from '../common/embed-block-element.js';
import { isEmptyDoc } from '../common/render-linked-doc.js';
import type { EmbedSyncedDocCard } from './components/embed-synced-doc-card.js';
import { blockStyles } from './styles.js';
@Peekable({
enableOn: ({ doc }: EmbedSyncedDocBlockComponent) => !doc.readonly,
})
export class EmbedSyncedDocBlockComponent extends EmbedBlockComponent<EmbedSyncedDocModel> {
static override styles = blockStyles;
// Caches total bounds, includes all blocks and elements.
private _cachedBounds: Bound | null = null;
private readonly _initEdgelessFitEffect = () => {
const fitToContent = () => {
if (this.isPageMode) return;
const controller = this.syncedDocEditorHost?.std.getOptional(
GfxControllerIdentifier
);
if (!controller) return;
const viewport = controller.viewport;
if (!viewport) return;
if (!this._cachedBounds) {
this._cachedBounds = getCommonBound([
...controller.layer.blocks.map(block =>
Bound.deserialize(block.xywh)
),
...controller.layer.canvasElements,
]);
}
viewport.onResize();
const { centerX, centerY, zoom } = viewport.getFitToScreenData(
this._cachedBounds
);
viewport.setCenter(centerX, centerY);
viewport.setZoom(zoom);
};
const observer = new ResizeObserver(fitToContent);
const block = this.embedBlock;
observer.observe(block);
this._disposables.add(() => {
observer.disconnect();
});
this.syncedDocEditorHost?.updateComplete
.then(() => fitToContent())
.catch(() => {});
};
private readonly _pageFilter: Query = {
mode: 'loose',
match: [
{
flavour: 'affine:note',
props: {
displayMode: NoteDisplayMode.EdgelessOnly,
},
viewType: 'hidden',
},
],
};
protected _buildPreviewSpec = (name: 'preview:page' | 'preview:edgeless') => {
const nextDepth = this.depth + 1;
const previewSpecBuilder = SpecProvider._.getSpec(name);
const currentDisposables = this.disposables;
const editorSetting =
this.std.getOptional(EditorSettingProvider) ??
signal(GeneralSettingSchema.parse({}));
class EmbedSyncedDocWatcher extends LifeCycleWatcher {
static override key = 'embed-synced-doc-watcher';
override mounted(): void {
const { view } = this.std;
view.viewUpdated.on(payload => {
if (
payload.type !== 'block' ||
payload.view.model.flavour !== 'affine:embed-synced-doc'
) {
return;
}
const nextComponent = payload.view as EmbedSyncedDocBlockComponent;
if (payload.method === 'add') {
nextComponent.depth = nextDepth;
currentDisposables.add(() => {
nextComponent.depth = 0;
});
return;
}
if (payload.method === 'delete') {
nextComponent.depth = 0;
return;
}
});
}
}
class GfxViewportInitializer extends GfxExtension {
static override key = 'gfx-viewport-initializer';
override mounted(): void {
this.gfx.fitToScreen();
}
}
previewSpecBuilder.extend([
EmbedSyncedDocWatcher,
GfxViewportInitializer,
EditorSettingExtension(editorSetting),
]);
return previewSpecBuilder.value;
};
protected _renderSyncedView = () => {
const syncedDoc = this.syncedDoc;
const editorMode = this.editorMode;
const isPageMode = this.isPageMode;
if (!syncedDoc) {
console.error('Synced doc is not found');
return html`${nothing}`;
}
if (isPageMode) {
this.dataset.pageMode = '';
}
const containerStyleMap = styleMap({
position: 'relative',
width: '100%',
});
const themeService = this.std.get(ThemeProvider);
const themeExtension = this.std.getOptional(ThemeExtensionIdentifier);
const appTheme = themeService.app$.value;
let edgelessTheme = themeService.edgeless$.value;
if (themeExtension?.getEdgelessTheme && this.syncedDoc?.id) {
edgelessTheme = themeExtension.getEdgelessTheme(this.syncedDoc.id).value;
}
const theme = isPageMode ? appTheme : edgelessTheme;
this.dataset.nestedEditor = '';
const renderEditor = () => {
return choose(editorMode, [
[
'page',
() => html`
<div class="affine-page-viewport" data-theme=${appTheme}>
${new BlockStdScope({
store: syncedDoc,
extensions: this._buildPreviewSpec('preview:page'),
}).render()}
</div>
`,
],
[
'edgeless',
() => html`
<div class="affine-edgeless-viewport" data-theme=${edgelessTheme}>
${new BlockStdScope({
store: syncedDoc,
extensions: this._buildPreviewSpec('preview:edgeless'),
}).render()}
</div>
`,
],
]);
};
return this.renderEmbed(
() => html`
<div
class=${classMap({
'affine-embed-synced-doc-container': true,
[editorMode]: true,
[theme]: true,
surface: false,
selected: this.selected$.value,
'show-hover-border': true,
})}
@click=${this._handleClick}
style=${containerStyleMap}
?data-scale=${undefined}
>
<div class="affine-embed-synced-doc-editor">
${isPageMode && this._isEmptySyncedDoc
? html`
<div class="affine-embed-synced-doc-editor-empty">
<span>
This is a linked doc, you can add content here.
</span>
</div>
`
: guard(
[editorMode, syncedDoc, appTheme, edgelessTheme],
renderEditor
)}
</div>
<div
class=${classMap({
'affine-embed-synced-doc-header-wrapper': true,
selected: this.selected$.value,
})}
>
<div class="affine-embed-synced-doc-header">
<span class="affine-embed-synced-doc-icon"
>${this.icon$.value}</span
>
<span class="affine-embed-synced-doc-title">${this.title$}</span>
</div>
</div>
</div>
`
);
};
protected cardStyleMap = styleMap({
position: 'relative',
display: 'block',
width: '100%',
});
convertToCard = (aliasInfo?: AliasInfo) => {
const { doc, caption } = this.model;
const parent = doc.getParent(this.model);
if (!parent) {
console.error(
`Trying to convert synced doc to card, but the parent is not found.`
);
return;
}
const index = parent.children.indexOf(this.model);
const blockId = doc.addBlock(
'affine:embed-linked-doc',
{ caption, ...this.referenceInfo, ...aliasInfo },
parent,
index
);
doc.deleteBlock(this.model);
this.std.selection.setGroup('note', [
this.std.selection.create(BlockSelection, { blockId }),
]);
};
covertToInline = () => {
const { doc } = this.model;
const parent = doc.getParent(this.model);
if (!parent) {
console.error(
`Trying to convert synced doc to inline, but the parent is not found.`
);
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,
},
});
const text = new Text(yText);
doc.addBlock(
'affine:paragraph',
{
text,
},
parent,
index
);
doc.deleteBlock(this.model);
};
protected override embedContainerStyle: StyleInfo = {
height: 'unset',
};
icon$ = computed(() => {
const { pageId, params } = this.model;
return this.std
.get(DocDisplayMetaProvider)
.icon(pageId, { params, referenced: true }).value;
});
open = (event?: Partial<DocLinkClickedEvent>) => {
const pageId = this.model.pageId;
if (pageId === this.doc.id) return;
this.std
.getOptional(RefNodeSlotsProvider)
?.docLinkClicked.emit({ ...event, pageId, host: this.host });
};
refreshData = () => {
this._load().catch(e => {
console.error(e);
this._error = true;
});
};
title$ = computed(() => {
const { pageId, params } = this.model;
return this.std
.get(DocDisplayMetaProvider)
.title(pageId, { params, referenced: true });
});
get blockState() {
return {
isLoading: this._loading,
isError: this._error,
isDeleted: this._deleted,
isCycle: this._cycle,
};
}
get docTitle() {
return this.syncedDoc?.meta?.title || 'Untitled';
}
get docUpdatedAt() {
return this._docUpdatedAt;
}
get editorMode() {
return this.linkedMode ?? this.syncedDocMode;
}
protected get isPageMode() {
return this.editorMode === 'page';
}
get linkedMode() {
return this.referenceInfo.params?.mode;
}
get referenceInfo(): ReferenceInfo {
return cloneReferenceInfo(this.model);
}
get syncedDoc() {
const options: GetBlocksOptions = { readonly: true };
if (this.isPageMode) options.query = this._pageFilter;
return this.std.workspace.getDoc(this.model.pageId, options);
}
private _checkCycle() {
let editorHost: EditorHost | null = this.host;
while (editorHost && !this._cycle) {
this._cycle = !!editorHost && editorHost.doc.id === this.model.pageId;
editorHost = editorHost.parentElement?.closest('editor-host') ?? null;
}
}
private _isClickAtBorder(
event: MouseEvent,
element: HTMLElement,
tolerance = 8
): boolean {
const { x, y } = event;
const rect = element.getBoundingClientRect();
if (!rect) {
return false;
}
return (
Math.abs(x - rect.left) < tolerance ||
Math.abs(x - rect.right) < tolerance ||
Math.abs(y - rect.top) < tolerance ||
Math.abs(y - rect.bottom) < tolerance
);
}
private async _load() {
this._loading = true;
this._error = false;
this._deleted = false;
this._cycle = false;
const syncedDoc = this.syncedDoc;
if (!syncedDoc) {
this._deleted = true;
this._loading = false;
return;
}
this._checkCycle();
if (!syncedDoc.loaded) {
try {
syncedDoc.load();
} catch (e) {
console.error(e);
this._error = true;
}
}
if (!this._error && !syncedDoc.root) {
await new Promise<void>(resolve => {
syncedDoc.slots.rootAdded.once(() => resolve());
});
}
this._loading = false;
}
private _selectBlock() {
const selectionManager = this.std.selection;
const blockSelection = selectionManager.create(BlockSelection, {
blockId: this.blockId,
});
selectionManager.setGroup('note', [blockSelection]);
}
private _setDocUpdatedAt() {
const meta = this.doc.workspace.meta.getDocMeta(this.model.pageId);
if (meta) {
const date = meta.updatedDate || meta.createDate;
this._docUpdatedAt = new Date(date);
}
}
protected _handleClick(_event: MouseEvent) {
this._selectBlock();
}
override connectedCallback() {
super.connectedCallback();
this._cardStyle = this.model.style;
this.style.display = 'block';
this._load().catch(e => {
console.error(e);
this._error = true;
});
this.contentEditable = 'false';
this.disposables.add(
this.model.propsUpdated.on(({ key }) => {
if (key === 'pageId' || key === 'style') {
this._load().catch(e => {
console.error(e);
this._error = true;
});
}
})
);
this._setDocUpdatedAt();
this.disposables.add(
this.doc.workspace.slots.docListUpdated.on(() => {
this._setDocUpdatedAt();
})
);
if (!this.linkedMode) {
const docMode = this.std.get(DocModeProvider);
this.syncedDocMode = docMode.getPrimaryMode(this.model.pageId);
this._isEmptySyncedDoc = isEmptyDoc(this.syncedDoc, this.editorMode);
this.disposables.add(
docMode.onPrimaryModeChange(mode => {
this.syncedDocMode = mode;
this._isEmptySyncedDoc = isEmptyDoc(this.syncedDoc, this.editorMode);
}, this.model.pageId)
);
}
this.syncedDoc &&
this.disposables.add(
this.syncedDoc.slots.blockUpdated.on(() => {
this._isEmptySyncedDoc = isEmptyDoc(this.syncedDoc, this.editorMode);
})
);
}
override firstUpdated() {
this.disposables.addFromEvent(this, 'click', e => {
e.stopPropagation();
if (this._isClickAtBorder(e, this)) {
e.preventDefault();
this._selectBlock();
}
});
this._initEdgelessFitEffect();
}
override renderBlock() {
delete this.dataset.nestedEditor;
const syncedDoc = this.syncedDoc;
const { isLoading, isError, isDeleted, isCycle } = this.blockState;
const isCardOnly = this.depth >= 1;
if (
isLoading ||
isError ||
isDeleted ||
isCardOnly ||
isCycle ||
!syncedDoc
) {
return this.renderEmbed(
() => html`
<affine-embed-synced-doc-card
style=${this.cardStyleMap}
.block=${this}
></affine-embed-synced-doc-card>
`
);
}
return this._renderSyncedView();
}
override updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
this.syncedDocCard?.requestUpdate();
}
@state()
private accessor _cycle = false;
@state()
private accessor _deleted = false;
@state()
private accessor _docUpdatedAt: Date = new Date();
@state()
private accessor _error = false;
@state()
protected accessor _isEmptySyncedDoc: boolean = true;
@state()
private accessor _loading = false;
@state()
accessor depth = 0;
@query(
':scope > .affine-block-component > .embed-block-container > affine-embed-synced-doc-card'
)
accessor syncedDocCard: EmbedSyncedDocCard | null = null;
@query(
':scope > .affine-block-component > .embed-block-container > .affine-embed-synced-doc-container > .affine-embed-synced-doc-editor > div > editor-host'
)
accessor syncedDocEditorHost: EditorHost | null = null;
@state()
accessor syncedDocMode: DocMode = 'page';
override accessor useCaptionEditor = true;
}
@@ -0,0 +1,6 @@
import { EmbedSyncedDocBlockSchema } from '@blocksuite/affine-model';
import { BlockService } from '@blocksuite/block-std';
export class EmbedSyncedDocBlockService extends BlockService {
static override readonly flavour = EmbedSyncedDocBlockSchema.model.flavour;
}
@@ -0,0 +1,30 @@
import { EmbedSyncedDocBlockSchema } from '@blocksuite/affine-model';
import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services';
import {
BlockServiceIdentifier,
BlockViewExtension,
FlavourExtension,
} from '@blocksuite/block-std';
import type { ExtensionType } from '@blocksuite/store';
import { literal } from 'lit/static-html.js';
import { EmbedSyncedDocBlockAdapterExtensions } from './adapters/extension';
import { builtinToolbarConfig } from './configs/toolbar';
import { EmbedSyncedDocBlockService } from './embed-synced-doc-service';
const flavour = EmbedSyncedDocBlockSchema.model.flavour;
export const EmbedSyncedDocBlockSpec: ExtensionType[] = [
FlavourExtension(flavour),
EmbedSyncedDocBlockService,
BlockViewExtension(flavour, model => {
return model.parent?.flavour === 'affine:surface'
? literal`affine-embed-edgeless-synced-doc-block`
: literal`affine-embed-synced-doc-block`;
}),
EmbedSyncedDocBlockAdapterExtensions,
ToolbarModuleExtension({
id: BlockServiceIdentifier(flavour),
config: builtinToolbarConfig,
}),
].flat();
@@ -0,0 +1,4 @@
export * from './adapters/index.js';
export * from './embed-synced-doc-block.js';
export * from './embed-synced-doc-spec.js';
export { SYNCED_MIN_HEIGHT, SYNCED_MIN_WIDTH } from './styles.js';
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,60 @@
import {
DarkLoadingIcon,
EmbedEdgelessIcon,
EmbedPageIcon,
LightLoadingIcon,
ReloadIcon,
} from '@blocksuite/affine-components/icons';
import { ColorScheme } from '@blocksuite/affine-model';
import type { TemplateResult } from 'lit';
import {
DarkSyncedDocDeletedBanner,
DarkSyncedDocEmptyBanner,
DarkSyncedDocErrorBanner,
LightSyncedDocDeletedBanner,
LightSyncedDocEmptyBanner,
LightSyncedDocErrorBanner,
SyncedDocDeletedIcon,
SyncedDocErrorIcon,
} from './styles.js';
type SyncedCardImages = {
LoadingIcon: TemplateResult<1>;
SyncedDocIcon: TemplateResult<1>;
SyncedDocErrorIcon: TemplateResult<1>;
SyncedDocDeletedIcon: TemplateResult<1>;
ReloadIcon: TemplateResult<1>;
SyncedDocEmptyBanner: TemplateResult<1>;
SyncedDocErrorBanner: TemplateResult<1>;
SyncedDocDeletedBanner: TemplateResult<1>;
};
export function getSyncedDocIcons(
theme: ColorScheme,
editorMode: 'page' | 'edgeless'
): SyncedCardImages {
if (theme === ColorScheme.Light) {
return {
LoadingIcon: LightLoadingIcon,
SyncedDocIcon: editorMode === 'page' ? EmbedPageIcon : EmbedEdgelessIcon,
SyncedDocErrorIcon,
SyncedDocDeletedIcon,
ReloadIcon,
SyncedDocEmptyBanner: LightSyncedDocEmptyBanner,
SyncedDocErrorBanner: LightSyncedDocErrorBanner,
SyncedDocDeletedBanner: LightSyncedDocDeletedBanner,
};
} else {
return {
LoadingIcon: DarkLoadingIcon,
SyncedDocIcon: editorMode === 'page' ? EmbedPageIcon : EmbedEdgelessIcon,
SyncedDocErrorIcon,
SyncedDocDeletedIcon,
ReloadIcon,
SyncedDocEmptyBanner: DarkSyncedDocEmptyBanner,
SyncedDocErrorBanner: DarkSyncedDocErrorBanner,
SyncedDocDeletedBanner: DarkSyncedDocDeletedBanner,
};
}
}
@@ -0,0 +1,13 @@
import type { ExtensionType } from '@blocksuite/store';
import { EmbedYoutubeBlockHtmlAdapterExtension } from './html.js';
import { EmbedYoutubeMarkdownAdapterExtension } from './markdown.js';
import { EmbedYoutubeBlockNotionHtmlAdapterExtension } from './notion-html.js';
import { EmbedYoutubeBlockPlainTextAdapterExtension } from './plain-text.js';
export const EmbedYoutubeBlockAdapterExtensions: ExtensionType[] = [
EmbedYoutubeBlockHtmlAdapterExtension,
EmbedYoutubeMarkdownAdapterExtension,
EmbedYoutubeBlockPlainTextAdapterExtension,
EmbedYoutubeBlockNotionHtmlAdapterExtension,
];
@@ -0,0 +1,51 @@
import { EmbedYoutubeBlockSchema } from '@blocksuite/affine-model';
import {
BlockHtmlAdapterExtension,
HastUtils,
} from '@blocksuite/affine-shared/adapters';
import { nanoid } from '@blocksuite/store';
import { createEmbedBlockHtmlAdapterMatcher } from '../../common/adapters/html.js';
export const embedYoutubeBlockHtmlAdapterMatcher =
createEmbedBlockHtmlAdapterMatcher(EmbedYoutubeBlockSchema.model.flavour, {
toMatch: o => HastUtils.isElement(o.node) && o.node.tagName === 'iframe',
toBlockSnapshot: {
enter: (o, context) => {
if (!HastUtils.isElement(o.node)) {
return;
}
const src = o.node.properties?.src;
if (typeof src !== 'string') {
return;
}
const { walkerContext } = context;
if (src.startsWith('https://www.youtube.com/embed/')) {
const videoId = src.substring(
'https://www.youtube.com/embed/'.length,
src.indexOf('?') !== -1 ? src.indexOf('?') : undefined
);
walkerContext
.openNode(
{
type: 'block',
id: nanoid(),
flavour: 'affine:embed-youtube',
props: {
url: `https://www.youtube.com/watch?v=${videoId}`,
},
children: [],
},
'children'
)
.closeNode();
}
},
},
});
export const EmbedYoutubeBlockHtmlAdapterExtension = BlockHtmlAdapterExtension(
embedYoutubeBlockHtmlAdapterMatcher
);
@@ -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 { EmbedYoutubeBlockSchema } from '@blocksuite/affine-model';
import { BlockMarkdownAdapterExtension } from '@blocksuite/affine-shared/adapters';
import { createEmbedBlockMarkdownAdapterMatcher } from '../../common/adapters/markdown.js';
export const embedYoutubeBlockMarkdownAdapterMatcher =
createEmbedBlockMarkdownAdapterMatcher(EmbedYoutubeBlockSchema.model.flavour);
export const EmbedYoutubeMarkdownAdapterExtension =
BlockMarkdownAdapterExtension(embedYoutubeBlockMarkdownAdapterMatcher);

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