mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-25 02:13:00 +08:00
refactor(editor): unify directories naming (#11516)
**Directory Structure Changes** - Renamed multiple block-related directories by removing the "block-" prefix: - `block-attachment` → `attachment` - `block-bookmark` → `bookmark` - `block-callout` → `callout` - `block-code` → `code` - `block-data-view` → `data-view` - `block-database` → `database` - `block-divider` → `divider` - `block-edgeless-text` → `edgeless-text` - `block-embed` → `embed`
This commit is contained in:
66
blocksuite/affine/blocks/embed/src/common/adapters/html.ts
Normal file
66
blocksuite/affine/blocks/embed/src/common/adapters/html.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { BlockHtmlAdapterMatcher } from '@blocksuite/affine-shared/adapters';
|
||||
|
||||
export function createEmbedBlockHtmlAdapterMatcher(
|
||||
flavour: string,
|
||||
{
|
||||
toMatch = () => false,
|
||||
fromMatch = o => o.node.flavour === flavour,
|
||||
toBlockSnapshot = {},
|
||||
fromBlockSnapshot = {
|
||||
enter: (o, context) => {
|
||||
const { walkerContext } = context;
|
||||
// Parse as link
|
||||
if (
|
||||
typeof o.node.props.title !== 'string' ||
|
||||
typeof o.node.props.url !== 'string'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
walkerContext
|
||||
.openNode(
|
||||
{
|
||||
type: 'element',
|
||||
tagName: 'div',
|
||||
properties: {
|
||||
className: ['affine-paragraph-block-container'],
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
'children'
|
||||
)
|
||||
.openNode(
|
||||
{
|
||||
type: 'element',
|
||||
tagName: 'a',
|
||||
properties: {
|
||||
href: o.node.props.url,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
value: o.node.props.title,
|
||||
},
|
||||
],
|
||||
},
|
||||
'children'
|
||||
)
|
||||
.closeNode()
|
||||
.closeNode();
|
||||
},
|
||||
},
|
||||
}: {
|
||||
toMatch?: BlockHtmlAdapterMatcher['toMatch'];
|
||||
fromMatch?: BlockHtmlAdapterMatcher['fromMatch'];
|
||||
toBlockSnapshot?: BlockHtmlAdapterMatcher['toBlockSnapshot'];
|
||||
fromBlockSnapshot?: BlockHtmlAdapterMatcher['fromBlockSnapshot'];
|
||||
} = Object.create(null)
|
||||
): BlockHtmlAdapterMatcher {
|
||||
return {
|
||||
flavour,
|
||||
toMatch,
|
||||
fromMatch,
|
||||
toBlockSnapshot,
|
||||
fromBlockSnapshot,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { BlockMarkdownAdapterMatcher } from '@blocksuite/affine-shared/adapters';
|
||||
|
||||
export function createEmbedBlockMarkdownAdapterMatcher(
|
||||
flavour: string,
|
||||
{
|
||||
toMatch = () => false,
|
||||
fromMatch = o => o.node.flavour === flavour,
|
||||
toBlockSnapshot = {},
|
||||
fromBlockSnapshot = {
|
||||
enter: (o, context) => {
|
||||
const { walkerContext } = context;
|
||||
// Parse as link
|
||||
if (
|
||||
typeof o.node.props.title !== 'string' ||
|
||||
typeof o.node.props.url !== 'string'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
walkerContext
|
||||
.openNode(
|
||||
{
|
||||
type: 'paragraph',
|
||||
children: [],
|
||||
},
|
||||
'children'
|
||||
)
|
||||
.openNode(
|
||||
{
|
||||
type: 'link',
|
||||
url: o.node.props.url,
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
value: o.node.props.title,
|
||||
},
|
||||
],
|
||||
},
|
||||
'children'
|
||||
)
|
||||
.closeNode()
|
||||
.closeNode();
|
||||
},
|
||||
},
|
||||
}: {
|
||||
toMatch?: BlockMarkdownAdapterMatcher['toMatch'];
|
||||
fromMatch?: BlockMarkdownAdapterMatcher['fromMatch'];
|
||||
toBlockSnapshot?: BlockMarkdownAdapterMatcher['toBlockSnapshot'];
|
||||
fromBlockSnapshot?: BlockMarkdownAdapterMatcher['fromBlockSnapshot'];
|
||||
} = {}
|
||||
): BlockMarkdownAdapterMatcher {
|
||||
return {
|
||||
flavour,
|
||||
toMatch,
|
||||
fromMatch,
|
||||
toBlockSnapshot,
|
||||
fromBlockSnapshot,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
type BlockNotionHtmlAdapterMatcher,
|
||||
HastUtils,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import { nanoid } from '@blocksuite/store';
|
||||
|
||||
export function createEmbedBlockNotionHtmlAdapterMatcher(
|
||||
flavour: string,
|
||||
urlRegex: RegExp,
|
||||
{
|
||||
toMatch = o => {
|
||||
const isFigure =
|
||||
HastUtils.isElement(o.node) && o.node.tagName === 'figure';
|
||||
const embededFigureWrapper = HastUtils.querySelector(o.node, '.source');
|
||||
if (!isFigure || !embededFigureWrapper) {
|
||||
return false;
|
||||
}
|
||||
const embededURL = HastUtils.querySelector(embededFigureWrapper, 'a')
|
||||
?.properties.href;
|
||||
|
||||
if (!embededURL || typeof embededURL !== 'string') {
|
||||
return false;
|
||||
}
|
||||
// To avoid polynomial regular expression used on uncontrolled data
|
||||
// https://codeql.github.com/codeql-query-help/javascript/js-polynomial-redos/
|
||||
if (embededURL.length > 1000) {
|
||||
return false;
|
||||
}
|
||||
return urlRegex.test(embededURL);
|
||||
},
|
||||
fromMatch = o => o.node.flavour === flavour,
|
||||
toBlockSnapshot = {
|
||||
enter: (o, context) => {
|
||||
if (!HastUtils.isElement(o.node)) {
|
||||
return;
|
||||
}
|
||||
const { assets, walkerContext } = context;
|
||||
if (!assets) {
|
||||
return;
|
||||
}
|
||||
|
||||
const embededFigureWrapper = HastUtils.querySelector(o.node, '.source');
|
||||
if (!embededFigureWrapper) {
|
||||
return;
|
||||
}
|
||||
|
||||
let embededURL = '';
|
||||
const embedA = HastUtils.querySelector(embededFigureWrapper, 'a');
|
||||
embededURL =
|
||||
typeof embedA?.properties.href === 'string'
|
||||
? embedA.properties.href
|
||||
: '';
|
||||
if (!embededURL) {
|
||||
return;
|
||||
}
|
||||
|
||||
walkerContext
|
||||
.openNode(
|
||||
{
|
||||
type: 'block',
|
||||
id: nanoid(),
|
||||
flavour,
|
||||
props: {
|
||||
url: embededURL,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
'children'
|
||||
)
|
||||
.closeNode();
|
||||
walkerContext.skipAllChildren();
|
||||
},
|
||||
},
|
||||
fromBlockSnapshot = {},
|
||||
}: {
|
||||
toMatch?: BlockNotionHtmlAdapterMatcher['toMatch'];
|
||||
fromMatch?: BlockNotionHtmlAdapterMatcher['fromMatch'];
|
||||
toBlockSnapshot?: BlockNotionHtmlAdapterMatcher['toBlockSnapshot'];
|
||||
fromBlockSnapshot?: BlockNotionHtmlAdapterMatcher['fromBlockSnapshot'];
|
||||
} = Object.create(null)
|
||||
): BlockNotionHtmlAdapterMatcher {
|
||||
return {
|
||||
flavour,
|
||||
toMatch,
|
||||
fromMatch,
|
||||
toBlockSnapshot,
|
||||
fromBlockSnapshot,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { BlockPlainTextAdapterMatcher } from '@blocksuite/affine-shared/adapters';
|
||||
|
||||
export function createEmbedBlockPlainTextAdapterMatcher(
|
||||
flavour: string,
|
||||
{
|
||||
toMatch = () => false,
|
||||
fromMatch = o => o.node.flavour === flavour,
|
||||
toBlockSnapshot = {},
|
||||
fromBlockSnapshot = {
|
||||
enter: (o, context) => {
|
||||
const { textBuffer } = context;
|
||||
// Parse as link
|
||||
if (
|
||||
typeof o.node.props.title !== 'string' ||
|
||||
typeof o.node.props.url !== 'string'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const buffer = `[${o.node.props.title}](${o.node.props.url})`;
|
||||
if (buffer.length > 0) {
|
||||
textBuffer.content += buffer;
|
||||
textBuffer.content += '\n';
|
||||
}
|
||||
},
|
||||
},
|
||||
}: {
|
||||
toMatch?: BlockPlainTextAdapterMatcher['toMatch'];
|
||||
fromMatch?: BlockPlainTextAdapterMatcher['fromMatch'];
|
||||
toBlockSnapshot?: BlockPlainTextAdapterMatcher['toBlockSnapshot'];
|
||||
fromBlockSnapshot?: BlockPlainTextAdapterMatcher['fromBlockSnapshot'];
|
||||
} = {}
|
||||
): BlockPlainTextAdapterMatcher {
|
||||
return {
|
||||
flavour,
|
||||
toMatch,
|
||||
fromMatch,
|
||||
toBlockSnapshot,
|
||||
fromBlockSnapshot,
|
||||
};
|
||||
}
|
||||
165
blocksuite/affine/blocks/embed/src/common/embed-block-element.ts
Normal file
165
blocksuite/affine/blocks/embed/src/common/embed-block-element.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import {
|
||||
CaptionedBlockComponent,
|
||||
SelectedStyle,
|
||||
} from '@blocksuite/affine-components/caption';
|
||||
import type { EmbedCardStyle } from '@blocksuite/affine-model';
|
||||
import {
|
||||
EMBED_CARD_HEIGHT,
|
||||
EMBED_CARD_MIN_WIDTH,
|
||||
EMBED_CARD_WIDTH,
|
||||
} from '@blocksuite/affine-shared/consts';
|
||||
import { DocModeProvider } from '@blocksuite/affine-shared/services';
|
||||
import { findAncestorModel } from '@blocksuite/affine-shared/utils';
|
||||
import type { BlockService } from '@blocksuite/std';
|
||||
import type { GfxCompatibleProps } from '@blocksuite/std/gfx';
|
||||
import type { BlockModel } from '@blocksuite/store';
|
||||
import { computed, type ReadonlySignal, signal } from '@preact/signals-core';
|
||||
import type { TemplateResult } from 'lit';
|
||||
import { html } from 'lit';
|
||||
import { query } from 'lit/decorators.js';
|
||||
import { type ClassInfo, classMap } from 'lit/directives/class-map.js';
|
||||
import { type StyleInfo, styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
export class EmbedBlockComponent<
|
||||
Model extends BlockModel<GfxCompatibleProps> = BlockModel<GfxCompatibleProps>,
|
||||
Service extends BlockService = BlockService,
|
||||
WidgetName extends string = string,
|
||||
> extends CaptionedBlockComponent<Model, Service, WidgetName> {
|
||||
selectedStyle$: ReadonlySignal<ClassInfo> | null = computed<ClassInfo>(
|
||||
() => ({
|
||||
'selected-style': this.selected$.value,
|
||||
})
|
||||
);
|
||||
|
||||
readonly isDraggingOnHost$ = signal(false);
|
||||
readonly isResizing$ = signal(false);
|
||||
// show overlay to prevent the iframe from capturing pointer events
|
||||
// when the block is dragging, resizing, or not selected
|
||||
readonly showOverlay$ = computed(
|
||||
() =>
|
||||
this.isDraggingOnHost$.value ||
|
||||
this.isResizing$.value ||
|
||||
!this.selected$.value
|
||||
);
|
||||
|
||||
private _fetchAbortController = new AbortController();
|
||||
|
||||
_cardStyle: EmbedCardStyle = 'horizontal';
|
||||
|
||||
/**
|
||||
* The actual rendered scale of the embed card.
|
||||
* By default, it is set to 1.
|
||||
*/
|
||||
protected _scale = 1;
|
||||
|
||||
blockDraggable = true;
|
||||
|
||||
/**
|
||||
* The style of the embed card.
|
||||
* You can use this to change the height and width of the card.
|
||||
* By default, the height and width are set to `_cardHeight` and `_cardWidth` respectively.
|
||||
*/
|
||||
protected embedContainerStyle: StyleInfo = {};
|
||||
|
||||
renderEmbed = (content: () => TemplateResult) => {
|
||||
if (
|
||||
this._cardStyle === 'horizontal' ||
|
||||
this._cardStyle === 'horizontalThin' ||
|
||||
this._cardStyle === 'list'
|
||||
) {
|
||||
this.style.display = 'block';
|
||||
|
||||
const insideNote = findAncestorModel(
|
||||
this.model,
|
||||
m => m.flavour === 'affine:note'
|
||||
);
|
||||
|
||||
if (
|
||||
!insideNote &&
|
||||
this.std.get(DocModeProvider).getEditorMode() === 'edgeless'
|
||||
) {
|
||||
this.style.minWidth = `${EMBED_CARD_MIN_WIDTH}px`;
|
||||
}
|
||||
}
|
||||
|
||||
return html`
|
||||
<div
|
||||
draggable="${this.blockDraggable ? 'true' : 'false'}"
|
||||
class=${classMap({
|
||||
'embed-block-container': true,
|
||||
...this.selectedStyle$?.value,
|
||||
})}
|
||||
style=${styleMap({
|
||||
height: `${this._cardHeight}px`,
|
||||
width: '100%',
|
||||
...this.embedContainerStyle,
|
||||
})}
|
||||
>
|
||||
${content()}
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
/**
|
||||
* The height of the current embed card. Changes based on the card style.
|
||||
*/
|
||||
get _cardHeight() {
|
||||
return EMBED_CARD_HEIGHT[this._cardStyle];
|
||||
}
|
||||
|
||||
/**
|
||||
* The width of the current embed card. Changes based on the card style.
|
||||
*/
|
||||
get _cardWidth() {
|
||||
return EMBED_CARD_WIDTH[this._cardStyle];
|
||||
}
|
||||
|
||||
get fetchAbortController() {
|
||||
return this._fetchAbortController;
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
if (this._fetchAbortController.signal.aborted)
|
||||
this._fetchAbortController = new AbortController();
|
||||
|
||||
this.contentEditable = 'false';
|
||||
|
||||
// subscribe the editor host global dragging event
|
||||
// to show the overlay for the dragging area or other pointer events
|
||||
this.handleEvent(
|
||||
'dragStart',
|
||||
() => {
|
||||
this.isDraggingOnHost$.value = true;
|
||||
},
|
||||
{ global: true }
|
||||
);
|
||||
|
||||
this.handleEvent(
|
||||
'dragEnd',
|
||||
() => {
|
||||
this.isDraggingOnHost$.value = false;
|
||||
},
|
||||
{ global: true }
|
||||
);
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this._fetchAbortController.abort();
|
||||
}
|
||||
|
||||
protected override accessor blockContainerStyles: StyleInfo | undefined = {
|
||||
margin: '18px 0',
|
||||
};
|
||||
|
||||
@query('.embed-block-container')
|
||||
protected accessor embedBlock!: HTMLDivElement;
|
||||
|
||||
override accessor selectedStyle = SelectedStyle.Border;
|
||||
|
||||
override accessor useCaptionEditor = true;
|
||||
|
||||
override accessor useZeroWidth = true;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { css } from 'lit';
|
||||
|
||||
export const embedNoteContentStyles = css`
|
||||
.affine-embed-doc-content-note-blocks affine-divider,
|
||||
.affine-embed-doc-content-note-blocks affine-divider > * {
|
||||
margin-top: 0px !important;
|
||||
margin-bottom: 0px !important;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph,
|
||||
.affine-embed-doc-content-note-blocks affine-list {
|
||||
margin-top: 4px !important;
|
||||
margin-bottom: 4px !important;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph *,
|
||||
.affine-embed-doc-content-note-blocks affine-list * {
|
||||
margin-top: 0px !important;
|
||||
margin-bottom: 0px !important;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
line-height: 20px;
|
||||
font-size: var(--affine-font-xs);
|
||||
font-weight: 400;
|
||||
}
|
||||
.affine-embed-doc-content-note-blocks affine-list .affine-list-block__prefix {
|
||||
height: 20px;
|
||||
}
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph .quote {
|
||||
padding-left: 15px;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph:has(.h1),
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph:has(.h2),
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph:has(.h3),
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph:has(.h4),
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph:has(.h5),
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph:has(.h6) {
|
||||
margin-top: 6px !important;
|
||||
margin-bottom: 4px !important;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph:has(.h1) *,
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph:has(.h2) *,
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph:has(.h3) *,
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph:has(.h4) *,
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph:has(.h5) *,
|
||||
.affine-embed-doc-content-note-blocks affine-paragraph:has(.h6) * {
|
||||
margin-top: 0px !important;
|
||||
margin-bottom: 0px !important;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
line-height: 20px;
|
||||
font-size: var(--affine-font-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.affine-embed-linked-doc-block.horizontal {
|
||||
affine-paragraph,
|
||||
affine-list {
|
||||
margin-top: 0 !important;
|
||||
margin-bottom: 0 !important;
|
||||
max-height: 40px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
}
|
||||
affine-paragraph .quote {
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
height: 28px;
|
||||
}
|
||||
affine-paragraph .quote::after {
|
||||
height: 20px;
|
||||
margin-top: 4px !important;
|
||||
margin-bottom: 4px !important;
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
EdgelessCRUDIdentifier,
|
||||
SurfaceBlockComponent,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import type { EmbedCardStyle } from '@blocksuite/affine-model';
|
||||
import {
|
||||
EMBED_CARD_HEIGHT,
|
||||
EMBED_CARD_WIDTH,
|
||||
} from '@blocksuite/affine-shared/consts';
|
||||
import { Bound, Vec } from '@blocksuite/global/gfx';
|
||||
import {
|
||||
BlockSelection,
|
||||
type BlockStdScope,
|
||||
SurfaceSelection,
|
||||
TextSelection,
|
||||
} from '@blocksuite/std';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
|
||||
|
||||
interface EmbedCardProperties {
|
||||
flavour: string;
|
||||
targetStyle: EmbedCardStyle;
|
||||
props: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function insertEmbedCard(
|
||||
std: BlockStdScope,
|
||||
properties: EmbedCardProperties
|
||||
) {
|
||||
const { host } = std;
|
||||
const { flavour, targetStyle, props } = properties;
|
||||
const selectionManager = host.selection;
|
||||
|
||||
let blockId: string | undefined;
|
||||
const textSelection = selectionManager.find(TextSelection);
|
||||
const blockSelection = selectionManager.find(BlockSelection);
|
||||
const surfaceSelection = selectionManager.find(SurfaceSelection);
|
||||
if (textSelection) {
|
||||
blockId = textSelection.blockId;
|
||||
} else if (blockSelection) {
|
||||
blockId = blockSelection.blockId;
|
||||
} else if (surfaceSelection && surfaceSelection.editing) {
|
||||
blockId = surfaceSelection.blockId;
|
||||
}
|
||||
|
||||
if (blockId) {
|
||||
const block = host.view.getBlock(blockId);
|
||||
if (!block) return;
|
||||
const parent = host.doc.getParent(block.model);
|
||||
if (!parent) return;
|
||||
const index = parent.children.indexOf(block.model);
|
||||
const cardId = host.doc.addBlock(
|
||||
flavour as never,
|
||||
props,
|
||||
parent,
|
||||
index + 1
|
||||
);
|
||||
return cardId;
|
||||
} else {
|
||||
const rootId = std.store.root?.id;
|
||||
if (!rootId) return;
|
||||
const edgelessRoot = std.view.getBlock(rootId);
|
||||
if (!edgelessRoot) return;
|
||||
|
||||
const gfx = std.get(GfxControllerIdentifier);
|
||||
const crud = std.get(EdgelessCRUDIdentifier);
|
||||
|
||||
gfx.viewport.smoothZoom(1);
|
||||
const surfaceBlock = gfx.surfaceComponent;
|
||||
if (!(surfaceBlock instanceof SurfaceBlockComponent)) return;
|
||||
const center = Vec.toVec(surfaceBlock.renderer.viewport.center);
|
||||
const cardId = crud.addBlock(
|
||||
flavour,
|
||||
{
|
||||
...props,
|
||||
xywh: Bound.fromCenter(
|
||||
center,
|
||||
EMBED_CARD_WIDTH[targetStyle],
|
||||
EMBED_CARD_HEIGHT[targetStyle]
|
||||
).serialize(),
|
||||
style: targetStyle,
|
||||
},
|
||||
surfaceBlock.model
|
||||
);
|
||||
|
||||
gfx.tool.setTool(
|
||||
// @ts-expect-error FIXME: resolve after gfx tool refactor
|
||||
'default'
|
||||
);
|
||||
gfx.selection.set({
|
||||
elements: [cardId],
|
||||
editing: false,
|
||||
});
|
||||
|
||||
return cardId;
|
||||
}
|
||||
}
|
||||
430
blocksuite/affine/blocks/embed/src/common/render-linked-doc.ts
Normal file
430
blocksuite/affine/blocks/embed/src/common/render-linked-doc.ts
Normal file
@@ -0,0 +1,430 @@
|
||||
import { getSurfaceBlock } from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
type DocMode,
|
||||
ImageBlockModel,
|
||||
ListBlockModel,
|
||||
NoteBlockModel,
|
||||
NoteDisplayMode,
|
||||
ParagraphBlockModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { EMBED_CARD_HEIGHT } from '@blocksuite/affine-shared/consts';
|
||||
import { NotificationProvider } from '@blocksuite/affine-shared/services';
|
||||
import { matchModels, SpecProvider } from '@blocksuite/affine-shared/utils';
|
||||
import { BlockStdScope, EditorLifeCycleExtension } from '@blocksuite/std';
|
||||
import {
|
||||
type BlockModel,
|
||||
type BlockSnapshot,
|
||||
type DraftModel,
|
||||
type Query,
|
||||
Slice,
|
||||
type Store,
|
||||
Text,
|
||||
} from '@blocksuite/store';
|
||||
import { render, type TemplateResult } from 'lit';
|
||||
|
||||
import type { EmbedLinkedDocBlockComponent } from '../embed-linked-doc-block/index.js';
|
||||
import type { EmbedSyncedDocCard } from '../embed-synced-doc-block/components/embed-synced-doc-card.js';
|
||||
|
||||
// Throttle delay for block updates to reduce unnecessary re-renders
|
||||
// - Prevents rapid-fire updates when multiple blocks are updated in quick succession
|
||||
// - Ensures UI remains responsive while maintaining performance
|
||||
// - Small enough to feel instant to users, large enough to batch updates effectively
|
||||
export const RENDER_CARD_THROTTLE_MS = 60;
|
||||
|
||||
export function renderLinkedDocInCard(
|
||||
card: EmbedLinkedDocBlockComponent | EmbedSyncedDocCard
|
||||
) {
|
||||
const linkedDoc = card.linkedDoc;
|
||||
if (!linkedDoc) {
|
||||
console.error(
|
||||
`Trying to load page ${card.model.props.pageId} in linked page block, but the page is not found.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/no-collapsible-if
|
||||
if ('bannerContainer' in card) {
|
||||
if (card.editorMode === 'page') {
|
||||
renderPageAsBanner(card).catch(e => {
|
||||
console.error(e);
|
||||
card.isError = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
renderNoteContent(card).catch(e => {
|
||||
console.error(e);
|
||||
card.isError = true;
|
||||
});
|
||||
}
|
||||
|
||||
async function renderPageAsBanner(card: EmbedSyncedDocCard) {
|
||||
const linkedDoc = card.linkedDoc;
|
||||
if (!linkedDoc) {
|
||||
console.error(
|
||||
`Trying to load page ${card.model.props.pageId} in linked page block, but the page is not found.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const notes = getNotesFromDoc(linkedDoc);
|
||||
if (!notes) {
|
||||
card.isBannerEmpty = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const target = notes.flatMap(note =>
|
||||
note.children.filter(child => matchModels(child, [ImageBlockModel]))
|
||||
)[0];
|
||||
|
||||
if (target) {
|
||||
await renderImageAsBanner(card, target);
|
||||
return;
|
||||
}
|
||||
|
||||
card.isBannerEmpty = true;
|
||||
}
|
||||
|
||||
async function renderImageAsBanner(
|
||||
card: EmbedSyncedDocCard,
|
||||
image: BlockModel
|
||||
) {
|
||||
const sourceId = (image as ImageBlockModel).props.sourceId;
|
||||
if (!sourceId) return;
|
||||
|
||||
const storage = card.linkedDoc?.blobSync;
|
||||
if (!storage) return;
|
||||
|
||||
const blob = await storage.get(sourceId);
|
||||
if (!blob) return;
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const $img = document.createElement('img');
|
||||
$img.src = url;
|
||||
await addCover(card, $img);
|
||||
|
||||
card.isBannerEmpty = false;
|
||||
}
|
||||
|
||||
async function addCover(
|
||||
card: EmbedSyncedDocCard,
|
||||
cover: HTMLElement | TemplateResult<1>
|
||||
) {
|
||||
const coverContainer = await card.bannerContainer;
|
||||
if (!coverContainer) return;
|
||||
while (coverContainer.firstChild) {
|
||||
coverContainer.firstChild.remove();
|
||||
}
|
||||
|
||||
if (cover instanceof HTMLElement) {
|
||||
coverContainer.append(cover);
|
||||
} else {
|
||||
render(cover, coverContainer);
|
||||
}
|
||||
}
|
||||
|
||||
async function renderNoteContent(
|
||||
card: EmbedLinkedDocBlockComponent | EmbedSyncedDocCard
|
||||
) {
|
||||
card.isNoteContentEmpty = true;
|
||||
|
||||
const doc = card.linkedDoc;
|
||||
if (!doc) {
|
||||
console.error(
|
||||
`Trying to load page ${card.model.props.pageId} in linked page block, but the page is not found.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const notes = getNotesFromDoc(doc);
|
||||
if (!notes) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cardStyle = card.model.props.style;
|
||||
const isHorizontal = cardStyle === 'horizontal';
|
||||
const allowFlavours = isHorizontal ? [] : [ImageBlockModel];
|
||||
|
||||
const noteChildren = notes.flatMap(note =>
|
||||
note.children.filter(model => {
|
||||
if (matchModels(model, allowFlavours)) {
|
||||
return true;
|
||||
}
|
||||
return filterTextModel(model);
|
||||
})
|
||||
);
|
||||
|
||||
if (!noteChildren.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
card.isNoteContentEmpty = false;
|
||||
|
||||
const noteContainer = await card.noteContainer;
|
||||
|
||||
if (!noteContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
while (noteContainer.firstChild) {
|
||||
noteContainer.firstChild.remove();
|
||||
}
|
||||
|
||||
const noteBlocksContainer = document.createElement('div');
|
||||
noteBlocksContainer.classList.add('affine-embed-doc-content-note-blocks');
|
||||
noteBlocksContainer.contentEditable = 'false';
|
||||
noteContainer.append(noteBlocksContainer);
|
||||
|
||||
if (isHorizontal) {
|
||||
// When the card is horizontal, we only render the first block
|
||||
noteChildren.splice(1);
|
||||
} else {
|
||||
// Before rendering, we can not know the height of each block
|
||||
// But we can limit the number of blocks to render simply by the height of the card
|
||||
const cardHeight = EMBED_CARD_HEIGHT[cardStyle];
|
||||
const minSingleBlockHeight = 20;
|
||||
const maxBlockCount = Math.floor(cardHeight / minSingleBlockHeight);
|
||||
if (noteChildren.length > maxBlockCount) {
|
||||
noteChildren.splice(maxBlockCount);
|
||||
}
|
||||
}
|
||||
const childIds = noteChildren.map(child => child.id);
|
||||
const ids: string[] = [];
|
||||
childIds.forEach(block => {
|
||||
let parent: string | null = block;
|
||||
while (parent && !ids.includes(parent)) {
|
||||
ids.push(parent);
|
||||
parent = doc.getParent(parent)?.id ?? null;
|
||||
}
|
||||
});
|
||||
const query: Query = {
|
||||
mode: 'strict',
|
||||
match: ids.map(id => ({ id, viewType: 'display' })),
|
||||
};
|
||||
const previewDoc = doc.doc.getStore({ query });
|
||||
const previewSpec = SpecProvider._.getSpec('preview:page');
|
||||
const previewStd = new BlockStdScope({
|
||||
store: previewDoc,
|
||||
extensions: previewSpec.value,
|
||||
});
|
||||
const previewTemplate = previewStd.render();
|
||||
const fragment = document.createDocumentFragment();
|
||||
render(previewTemplate, fragment);
|
||||
noteBlocksContainer.append(fragment);
|
||||
const contentEditableElements = noteBlocksContainer.querySelectorAll(
|
||||
'[contenteditable="true"]'
|
||||
);
|
||||
contentEditableElements.forEach(element => {
|
||||
(element as HTMLElement).contentEditable = 'false';
|
||||
});
|
||||
}
|
||||
|
||||
function filterTextModel(model: BlockModel) {
|
||||
if (matchModels(model, [ParagraphBlockModel, ListBlockModel])) {
|
||||
return !!model.text?.toString().length;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getNotesFromDoc(doc: Store) {
|
||||
const notes = doc.root?.children.filter(
|
||||
child =>
|
||||
matchModels(child, [NoteBlockModel]) &&
|
||||
child.props.displayMode !== NoteDisplayMode.EdgelessOnly
|
||||
);
|
||||
|
||||
if (!notes || !notes.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return notes;
|
||||
}
|
||||
|
||||
export function isEmptyDoc(doc: Store | null, mode: DocMode) {
|
||||
if (!doc) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (mode === 'page') {
|
||||
const notes = getNotesFromDoc(doc);
|
||||
if (!notes || !notes.length) {
|
||||
return true;
|
||||
}
|
||||
return notes.every(note => isEmptyNote(note));
|
||||
} else {
|
||||
const surface = getSurfaceBlock(doc);
|
||||
if (surface?.elementModels.length || doc.blockSize > 2) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function isEmptyNote(note: BlockModel) {
|
||||
return note.children.every(block => {
|
||||
return (
|
||||
block.flavour === 'affine:paragraph' &&
|
||||
(!block.text || block.text.length === 0)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the document content with a max length.
|
||||
*/
|
||||
export function getDocContentWithMaxLength(doc: Store, maxlength = 500) {
|
||||
const notes = getNotesFromDoc(doc);
|
||||
if (!notes) return;
|
||||
|
||||
const noteChildren = notes.flatMap(note =>
|
||||
note.children.filter(model => filterTextModel(model))
|
||||
);
|
||||
if (!noteChildren.length) return;
|
||||
|
||||
let count = 0;
|
||||
let reached = false;
|
||||
const texts = [];
|
||||
|
||||
for (const model of noteChildren) {
|
||||
let t = model.text?.toString();
|
||||
if (t?.length) {
|
||||
const c: number = count + Math.max(0, texts.length - 1);
|
||||
|
||||
if (t.length + c > maxlength) {
|
||||
t = t.substring(0, maxlength - c);
|
||||
reached = true;
|
||||
}
|
||||
|
||||
texts.push(t);
|
||||
count += t.length;
|
||||
|
||||
if (reached) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return texts.join('\n');
|
||||
}
|
||||
|
||||
export function getTitleFromSelectedModels(selectedModels: DraftModel[]) {
|
||||
const firstBlock = selectedModels[0];
|
||||
const isParagraph = (
|
||||
model: DraftModel
|
||||
): model is DraftModel<ParagraphBlockModel> =>
|
||||
model.flavour === 'affine:paragraph';
|
||||
if (isParagraph(firstBlock) && firstBlock.props.type.startsWith('h')) {
|
||||
return firstBlock.props.text.toString();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function promptDocTitle(std: BlockStdScope, autofill?: string) {
|
||||
const notification = std.getOptional(NotificationProvider);
|
||||
if (!notification) return Promise.resolve(undefined);
|
||||
|
||||
return notification.prompt({
|
||||
title: 'Create linked doc',
|
||||
message: 'Enter a title for the new doc.',
|
||||
placeholder: 'Untitled',
|
||||
autofill,
|
||||
confirmText: 'Confirm',
|
||||
cancelText: 'Cancel',
|
||||
});
|
||||
}
|
||||
|
||||
export function notifyDocCreated(std: BlockStdScope, doc: Store) {
|
||||
const notification = std.getOptional(NotificationProvider);
|
||||
if (!notification) return;
|
||||
|
||||
const abortController = new AbortController();
|
||||
const clear = () => {
|
||||
doc.history.off('stack-item-added', addHandler);
|
||||
doc.history.off('stack-item-popped', popHandler);
|
||||
disposable.unsubscribe();
|
||||
};
|
||||
const closeNotify = () => {
|
||||
abortController.abort();
|
||||
clear();
|
||||
};
|
||||
|
||||
// edit or undo or switch doc, close notify toast
|
||||
const addHandler = doc.history.on('stack-item-added', closeNotify);
|
||||
const popHandler = doc.history.on('stack-item-popped', closeNotify);
|
||||
const disposable = std
|
||||
.get(EditorLifeCycleExtension)
|
||||
.slots.unmounted.subscribe(closeNotify);
|
||||
|
||||
notification.notify({
|
||||
title: 'Linked doc created',
|
||||
message: 'You can click undo to recovery block content',
|
||||
accent: 'info',
|
||||
duration: 10 * 1000,
|
||||
action: {
|
||||
label: 'Undo',
|
||||
onClick: () => {
|
||||
doc.undo();
|
||||
clear();
|
||||
},
|
||||
},
|
||||
abort: abortController.signal,
|
||||
onClose: clear,
|
||||
});
|
||||
}
|
||||
|
||||
export async function convertSelectedBlocksToLinkedDoc(
|
||||
std: BlockStdScope,
|
||||
doc: Store,
|
||||
selectedModels: DraftModel[] | Promise<DraftModel[]>,
|
||||
docTitle?: string
|
||||
) {
|
||||
const models = await selectedModels;
|
||||
const slice = std.clipboard.sliceToSnapshot(Slice.fromModels(doc, models));
|
||||
if (!slice) {
|
||||
return;
|
||||
}
|
||||
const firstBlock = models[0];
|
||||
if (!firstBlock) {
|
||||
return;
|
||||
}
|
||||
// if title undefined, use the first heading block content as doc title
|
||||
const title = docTitle || getTitleFromSelectedModels(models);
|
||||
const linkedDoc = createLinkedDocFromSlice(std, doc, slice.content, title);
|
||||
// insert linked doc card
|
||||
doc.addSiblingBlocks(
|
||||
doc.getBlock(firstBlock.id)!.model,
|
||||
[
|
||||
{
|
||||
flavour: 'affine:embed-linked-doc',
|
||||
pageId: linkedDoc.id,
|
||||
},
|
||||
],
|
||||
'before'
|
||||
);
|
||||
// delete selected elements
|
||||
models.forEach(model => doc.deleteBlock(model.id));
|
||||
return linkedDoc;
|
||||
}
|
||||
|
||||
export function createLinkedDocFromSlice(
|
||||
std: BlockStdScope,
|
||||
doc: Store,
|
||||
snapshots: BlockSnapshot[],
|
||||
docTitle?: string
|
||||
) {
|
||||
const _doc = doc.workspace.createDoc();
|
||||
const linkedDoc = _doc.getStore();
|
||||
linkedDoc.load(() => {
|
||||
const rootId = linkedDoc.addBlock('affine:page', {
|
||||
title: new Text(docTitle),
|
||||
});
|
||||
linkedDoc.addBlock('affine:surface', {}, rootId);
|
||||
const noteId = linkedDoc.addBlock('affine:note', {}, rootId);
|
||||
snapshots.forEach(snapshot => {
|
||||
std.clipboard
|
||||
.pasteBlockSnapshot(snapshot, linkedDoc, noteId)
|
||||
.catch(console.error);
|
||||
});
|
||||
});
|
||||
return linkedDoc;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { EdgelessLegacySlotIdentifier } from '@blocksuite/affine-block-surface';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
import {
|
||||
blockComponentSymbol,
|
||||
type BlockService,
|
||||
type GfxBlockComponent,
|
||||
GfxElementSymbol,
|
||||
toGfxBlockComponent,
|
||||
} from '@blocksuite/std';
|
||||
import type {
|
||||
GfxBlockElementModel,
|
||||
GfxCompatibleProps,
|
||||
} from '@blocksuite/std/gfx';
|
||||
import type { StyleInfo } from 'lit/directives/style-map.js';
|
||||
|
||||
import type { EmbedBlockComponent } from './embed-block-element.js';
|
||||
|
||||
export function toEdgelessEmbedBlock<
|
||||
Model extends GfxBlockElementModel<GfxCompatibleProps>,
|
||||
Service extends BlockService,
|
||||
WidgetName extends string,
|
||||
B extends typeof EmbedBlockComponent<Model, Service, WidgetName>,
|
||||
>(block: B) {
|
||||
return class extends toGfxBlockComponent(block) {
|
||||
override selectedStyle$ = null;
|
||||
|
||||
override [blockComponentSymbol] = true;
|
||||
|
||||
override blockDraggable = false;
|
||||
|
||||
protected override embedContainerStyle: StyleInfo = {};
|
||||
|
||||
override [GfxElementSymbol] = true;
|
||||
|
||||
get bound(): Bound {
|
||||
return Bound.deserialize(this.model.xywh);
|
||||
}
|
||||
|
||||
_handleClick(_: MouseEvent): void {
|
||||
return;
|
||||
}
|
||||
|
||||
get edgelessSlots() {
|
||||
return this.std.get(EdgelessLegacySlotIdentifier);
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
this._disposables.add(
|
||||
this.edgelessSlots.elementResizeStart.subscribe(() => {
|
||||
this.isResizing$.value = true;
|
||||
})
|
||||
);
|
||||
|
||||
this._disposables.add(
|
||||
this.edgelessSlots.elementResizeEnd.subscribe(() => {
|
||||
this.isResizing$.value = false;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override renderGfxBlock() {
|
||||
const bound = Bound.deserialize(this.model.xywh);
|
||||
|
||||
this.embedContainerStyle.width = `${bound.w}px`;
|
||||
this.embedContainerStyle.height = `${bound.h}px`;
|
||||
this.blockContainerStyles = {
|
||||
width: `${bound.w}px`,
|
||||
};
|
||||
this._scale = bound.w / this._cardWidth;
|
||||
|
||||
return this.renderPageContent();
|
||||
}
|
||||
|
||||
protected override accessor blockContainerStyles: StyleInfo | undefined =
|
||||
undefined;
|
||||
} as B & {
|
||||
new (...args: any[]): GfxBlockComponent;
|
||||
};
|
||||
}
|
||||
47
blocksuite/affine/blocks/embed/src/common/utils.ts
Normal file
47
blocksuite/affine/blocks/embed/src/common/utils.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
DarkLoadingIcon,
|
||||
EmbedCardDarkBannerIcon,
|
||||
EmbedCardDarkCubeIcon,
|
||||
EmbedCardDarkHorizontalIcon,
|
||||
EmbedCardDarkListIcon,
|
||||
EmbedCardDarkVerticalIcon,
|
||||
EmbedCardLightBannerIcon,
|
||||
EmbedCardLightCubeIcon,
|
||||
EmbedCardLightHorizontalIcon,
|
||||
EmbedCardLightListIcon,
|
||||
EmbedCardLightVerticalIcon,
|
||||
LightLoadingIcon,
|
||||
} from '@blocksuite/affine-components/icons';
|
||||
import { ColorScheme } from '@blocksuite/affine-model';
|
||||
import type { TemplateResult } from 'lit';
|
||||
|
||||
type EmbedCardIcons = {
|
||||
LoadingIcon: TemplateResult<1>;
|
||||
EmbedCardBannerIcon: TemplateResult<1>;
|
||||
EmbedCardHorizontalIcon: TemplateResult<1>;
|
||||
EmbedCardListIcon: TemplateResult<1>;
|
||||
EmbedCardVerticalIcon: TemplateResult<1>;
|
||||
EmbedCardCubeIcon: TemplateResult<1>;
|
||||
};
|
||||
|
||||
export function getEmbedCardIcons(theme: ColorScheme): EmbedCardIcons {
|
||||
if (theme === ColorScheme.Light) {
|
||||
return {
|
||||
LoadingIcon: LightLoadingIcon,
|
||||
EmbedCardBannerIcon: EmbedCardLightBannerIcon,
|
||||
EmbedCardHorizontalIcon: EmbedCardLightHorizontalIcon,
|
||||
EmbedCardListIcon: EmbedCardLightListIcon,
|
||||
EmbedCardVerticalIcon: EmbedCardLightVerticalIcon,
|
||||
EmbedCardCubeIcon: EmbedCardLightCubeIcon,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
LoadingIcon: DarkLoadingIcon,
|
||||
EmbedCardBannerIcon: EmbedCardDarkBannerIcon,
|
||||
EmbedCardHorizontalIcon: EmbedCardDarkHorizontalIcon,
|
||||
EmbedCardListIcon: EmbedCardDarkListIcon,
|
||||
EmbedCardVerticalIcon: EmbedCardDarkVerticalIcon,
|
||||
EmbedCardCubeIcon: EmbedCardDarkCubeIcon,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user