mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-22 12:36:24 +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:
@@ -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);
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
import { ThemeProvider } from '@blocksuite/affine-shared/services';
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import {
|
||||
BlockSelection,
|
||||
isGfxBlockComponent,
|
||||
ShadowlessElement,
|
||||
} from '@blocksuite/std';
|
||||
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 {
|
||||
const subscription = syncedDoc.slots.rootAdded.subscribe(() => {
|
||||
subscription.unsubscribe();
|
||||
renderLinkedDocInCard(this);
|
||||
});
|
||||
}
|
||||
|
||||
this.disposables.add(
|
||||
syncedDoc.workspace.slots.docListUpdated.subscribe(() => {
|
||||
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.subscribe(
|
||||
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,330 @@
|
||||
import { toast } from '@blocksuite/affine-components/toast';
|
||||
import { EditorChevronDown } from '@blocksuite/affine-components/toolbar';
|
||||
import { EmbedSyncedDocModel } from '@blocksuite/affine-model';
|
||||
import {
|
||||
ActionPlacement,
|
||||
type LinkEventType,
|
||||
type OpenDocMode,
|
||||
type ToolbarAction,
|
||||
type ToolbarActionGroup,
|
||||
type ToolbarContext,
|
||||
type ToolbarModuleConfig,
|
||||
ToolbarModuleExtension,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { getBlockProps } from '@blocksuite/affine-shared/utils';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
import {
|
||||
CaptionIcon,
|
||||
CopyIcon,
|
||||
DeleteIcon,
|
||||
DuplicateIcon,
|
||||
ExpandFullIcon,
|
||||
OpenInNewIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import { BlockFlavourIdentifier } from '@blocksuite/std';
|
||||
import { type ExtensionType, Slice } from '@blocksuite/store';
|
||||
import { computed, signal } from '@preact/signals-core';
|
||||
import { html } from 'lit';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { keyed } from 'lit/directives/keyed.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
|
||||
import { EmbedSyncedDocBlockComponent } from '../embed-synced-doc-block';
|
||||
|
||||
const trackBaseProps = {
|
||||
category: 'linked doc',
|
||||
type: 'embed view',
|
||||
};
|
||||
|
||||
const createOnToggleFn =
|
||||
(
|
||||
ctx: ToolbarContext,
|
||||
name: Extract<
|
||||
LinkEventType,
|
||||
'OpenedViewSelector' | 'OpenedCardScaleSelector'
|
||||
>,
|
||||
control: 'switch view' | 'switch card scale'
|
||||
) =>
|
||||
(e: CustomEvent<boolean>) => {
|
||||
e.stopPropagation();
|
||||
const opened = e.detail;
|
||||
if (!opened) return;
|
||||
|
||||
ctx.track(name, { ...trackBaseProps, control });
|
||||
};
|
||||
|
||||
const openDocActions = [
|
||||
{
|
||||
mode: 'open-in-active-view',
|
||||
id: 'a.open-in-active-view',
|
||||
label: 'Open this doc',
|
||||
icon: ExpandFullIcon(),
|
||||
},
|
||||
] as const satisfies (Pick<ToolbarAction, 'id' | 'label' | 'icon'> & {
|
||||
mode: OpenDocMode;
|
||||
})[];
|
||||
|
||||
const openDocActionGroup = {
|
||||
placement: ActionPlacement.Start,
|
||||
id: 'A.open-doc',
|
||||
content(ctx) {
|
||||
const block = ctx.getCurrentBlockByType(EmbedSyncedDocBlockComponent);
|
||||
if (!block) return null;
|
||||
|
||||
const actions = openDocActions.map<ToolbarAction>(action => {
|
||||
const openMode = action.mode;
|
||||
const shouldOpenInActiveView = openMode === 'open-in-active-view';
|
||||
return {
|
||||
...action,
|
||||
disabled: shouldOpenInActiveView
|
||||
? block.model.props.pageId === ctx.store.id
|
||||
: false,
|
||||
when: true,
|
||||
run: (_ctx: ToolbarContext) => block.open({ openMode }),
|
||||
};
|
||||
});
|
||||
|
||||
return html`
|
||||
<editor-menu-button
|
||||
.contentPadding="${'8px'}"
|
||||
.button=${html`
|
||||
<editor-icon-button aria-label="Open doc" .tooltip=${'Open doc'}>
|
||||
${OpenInNewIcon()} ${EditorChevronDown}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<div data-size="small" data-orientation="vertical">
|
||||
${repeat(
|
||||
actions,
|
||||
action => action.id,
|
||||
({ label, icon, run, disabled }) => html`
|
||||
<editor-menu-action
|
||||
aria-label=${ifDefined(label)}
|
||||
?disabled=${ifDefined(
|
||||
typeof disabled === 'function' ? disabled(ctx) : disabled
|
||||
)}
|
||||
@click=${() => run?.(ctx)}
|
||||
>
|
||||
${icon}<span class="label">${label}</span>
|
||||
</editor-menu-action>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</editor-menu-button>
|
||||
`;
|
||||
},
|
||||
} as const satisfies ToolbarAction;
|
||||
|
||||
const conversionsActionGroup = {
|
||||
id: 'a.conversions',
|
||||
actions: [
|
||||
{
|
||||
id: 'inline',
|
||||
label: 'Inline view',
|
||||
run(ctx) {
|
||||
const block = ctx.getCurrentBlockByType(EmbedSyncedDocBlockComponent);
|
||||
block?.convertToInline();
|
||||
|
||||
// Clears
|
||||
ctx.select('note');
|
||||
ctx.reset();
|
||||
|
||||
ctx.track('SelectedView', {
|
||||
...trackBaseProps,
|
||||
control: 'select view',
|
||||
type: 'inline view',
|
||||
});
|
||||
},
|
||||
when: ctx => !ctx.hasSelectedSurfaceModels,
|
||||
},
|
||||
{
|
||||
id: 'card',
|
||||
label: 'Card view',
|
||||
run(ctx) {
|
||||
const block = ctx.getCurrentBlockByType(EmbedSyncedDocBlockComponent);
|
||||
block?.convertToCard();
|
||||
|
||||
ctx.track('SelectedView', {
|
||||
...trackBaseProps,
|
||||
control: 'select view',
|
||||
type: 'card view',
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'embed',
|
||||
label: 'Embed view',
|
||||
disabled: true,
|
||||
},
|
||||
],
|
||||
content(ctx) {
|
||||
const model = ctx.getCurrentModelByType(EmbedSyncedDocModel);
|
||||
if (!model) return null;
|
||||
|
||||
const actions = this.actions.map(action => ({ ...action }));
|
||||
const viewType$ = signal('Embed view');
|
||||
const onToggle = createOnToggleFn(ctx, 'OpenedViewSelector', 'switch view');
|
||||
|
||||
return html`${keyed(
|
||||
model,
|
||||
html`<affine-view-dropdown-menu
|
||||
@toggle=${onToggle}
|
||||
.actions=${actions}
|
||||
.context=${ctx}
|
||||
.viewType$=${viewType$}
|
||||
></affine-view-dropdown-menu>`
|
||||
)}`;
|
||||
},
|
||||
} as const satisfies ToolbarActionGroup<ToolbarAction>;
|
||||
|
||||
const captionAction = {
|
||||
id: 'c.caption',
|
||||
tooltip: 'Caption',
|
||||
icon: CaptionIcon(),
|
||||
run(ctx) {
|
||||
const block = ctx.getCurrentBlockByType(EmbedSyncedDocBlockComponent);
|
||||
block?.captionEditor?.show();
|
||||
|
||||
ctx.track('OpenedCaptionEditor', {
|
||||
...trackBaseProps,
|
||||
control: 'add caption',
|
||||
});
|
||||
},
|
||||
} as const satisfies ToolbarAction;
|
||||
|
||||
const builtinToolbarConfig = {
|
||||
actions: [
|
||||
openDocActionGroup,
|
||||
conversionsActionGroup,
|
||||
captionAction,
|
||||
{
|
||||
placement: ActionPlacement.More,
|
||||
id: 'a.clipboard',
|
||||
actions: [
|
||||
{
|
||||
id: 'copy',
|
||||
label: 'Copy',
|
||||
icon: CopyIcon(),
|
||||
run(ctx) {
|
||||
const model = ctx.getCurrentModelByType(EmbedSyncedDocModel);
|
||||
if (!model) return;
|
||||
|
||||
const slice = Slice.fromModels(ctx.store, [model]);
|
||||
ctx.clipboard
|
||||
.copySlice(slice)
|
||||
.then(() => toast(ctx.host, 'Copied to clipboard'))
|
||||
.catch(console.error);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'duplicate',
|
||||
label: 'Duplicate',
|
||||
icon: DuplicateIcon(),
|
||||
run(ctx) {
|
||||
const model = ctx.getCurrentModelByType(EmbedSyncedDocModel);
|
||||
if (!model) return;
|
||||
|
||||
const { flavour, parent } = model;
|
||||
const props = getBlockProps(model);
|
||||
const index = parent?.children.indexOf(model);
|
||||
|
||||
ctx.store.addBlock(flavour, props, parent, index);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
placement: ActionPlacement.More,
|
||||
id: 'c.delete',
|
||||
label: 'Delete',
|
||||
icon: DeleteIcon(),
|
||||
variant: 'destructive',
|
||||
run(ctx) {
|
||||
const model = ctx.getCurrentModelByType(EmbedSyncedDocModel);
|
||||
if (!model) return;
|
||||
|
||||
ctx.store.deleteBlock(model);
|
||||
|
||||
// Clears
|
||||
ctx.select('note');
|
||||
ctx.reset();
|
||||
},
|
||||
},
|
||||
],
|
||||
} as const satisfies ToolbarModuleConfig;
|
||||
|
||||
const builtinSurfaceToolbarConfig = {
|
||||
actions: [
|
||||
openDocActionGroup,
|
||||
conversionsActionGroup,
|
||||
captionAction,
|
||||
{
|
||||
id: 'd.scale',
|
||||
content(ctx) {
|
||||
const model = ctx.getCurrentBlockByType(
|
||||
EmbedSyncedDocBlockComponent
|
||||
)?.model;
|
||||
if (!model) return null;
|
||||
|
||||
const scale$ = computed(() =>
|
||||
Math.round(100 * (model.props.scale$.value ?? 1))
|
||||
);
|
||||
const onSelect = (e: CustomEvent<number>) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const scale = e.detail / 100;
|
||||
|
||||
const oldScale = model.props.scale ?? 1;
|
||||
const ratio = scale / oldScale;
|
||||
const bounds = Bound.deserialize(model.xywh);
|
||||
bounds.h *= ratio;
|
||||
bounds.w *= ratio;
|
||||
const xywh = bounds.serialize();
|
||||
|
||||
ctx.store.updateBlock(model, { scale, xywh });
|
||||
|
||||
ctx.track('SelectedCardScale', {
|
||||
...trackBaseProps,
|
||||
control: 'select card scale',
|
||||
});
|
||||
};
|
||||
const onToggle = createOnToggleFn(
|
||||
ctx,
|
||||
'OpenedCardScaleSelector',
|
||||
'switch card scale'
|
||||
);
|
||||
const format = (value: number) => `${value}%`;
|
||||
|
||||
return html`${keyed(
|
||||
model,
|
||||
html`<affine-size-dropdown-menu
|
||||
@select=${onSelect}
|
||||
@toggle=${onToggle}
|
||||
.format=${format}
|
||||
.size$=${scale$}
|
||||
></affine-size-dropdown-menu>`
|
||||
)}`;
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
when: ctx => ctx.getSurfaceModelsByType(EmbedSyncedDocModel).length === 1,
|
||||
} as const satisfies ToolbarModuleConfig;
|
||||
|
||||
export const createBuiltinToolbarConfigExtension = (
|
||||
flavour: string
|
||||
): ExtensionType[] => {
|
||||
const name = flavour.split(':').pop();
|
||||
|
||||
return [
|
||||
ToolbarModuleExtension({
|
||||
id: BlockFlavourIdentifier(flavour),
|
||||
config: builtinToolbarConfig,
|
||||
}),
|
||||
|
||||
ToolbarModuleExtension({
|
||||
id: BlockFlavourIdentifier(`affine:surface:${name}`),
|
||||
config: builtinSurfaceToolbarConfig,
|
||||
}),
|
||||
];
|
||||
};
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import { EdgelessClipboardConfig } from '@blocksuite/affine-block-surface';
|
||||
import { ReferenceInfoSchema } from '@blocksuite/affine-model';
|
||||
import { type BlockSnapshot } from '@blocksuite/store';
|
||||
|
||||
export class EdgelessClipboardEmbedSyncedDocConfig extends EdgelessClipboardConfig {
|
||||
static override readonly key = 'affine:embed-synced-doc';
|
||||
|
||||
override createBlock(syncedDocEmbed: BlockSnapshot): string | null {
|
||||
if (!this.surface) return null;
|
||||
|
||||
const { xywh, style, caption, scale, pageId, params } =
|
||||
syncedDocEmbed.props;
|
||||
const referenceInfo = ReferenceInfoSchema.parse({ pageId, params });
|
||||
|
||||
return this.crud.addBlock(
|
||||
'affine:embed-synced-doc',
|
||||
{
|
||||
xywh,
|
||||
style,
|
||||
caption,
|
||||
scale,
|
||||
...referenceInfo,
|
||||
},
|
||||
this.surface.model.id
|
||||
);
|
||||
}
|
||||
}
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
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 { Bound } from '@blocksuite/global/gfx';
|
||||
import { BlockStdScope } from '@blocksuite/std';
|
||||
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.props.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.props.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, xywh } = this.model;
|
||||
const { caption } = this.model.props;
|
||||
|
||||
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.props;
|
||||
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,627 @@
|
||||
import { Peekable } from '@blocksuite/affine-components/peek';
|
||||
import {
|
||||
type DocLinkClickedEvent,
|
||||
RefNodeSlotsProvider,
|
||||
} from '@blocksuite/affine-inline-reference';
|
||||
import {
|
||||
type AliasInfo,
|
||||
type DocMode,
|
||||
type EmbedSyncedDocModel,
|
||||
NoteDisplayMode,
|
||||
type ReferenceInfo,
|
||||
} from '@blocksuite/affine-model';
|
||||
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 { Bound, getCommonBound } from '@blocksuite/global/gfx';
|
||||
import {
|
||||
BlockSelection,
|
||||
BlockStdScope,
|
||||
type EditorHost,
|
||||
LifeCycleWatcher,
|
||||
} from '@blocksuite/std';
|
||||
import { GfxControllerIdentifier, GfxExtension } from '@blocksuite/std/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.subscribe(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 } = this.model;
|
||||
const { caption } = this.model.props;
|
||||
|
||||
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 }),
|
||||
]);
|
||||
};
|
||||
|
||||
convertToInline = () => {
|
||||
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.props;
|
||||
return this.std
|
||||
.get(DocDisplayMetaProvider)
|
||||
.icon(pageId, { params, referenced: true }).value;
|
||||
});
|
||||
|
||||
open = (event?: Partial<DocLinkClickedEvent>) => {
|
||||
const pageId = this.model.props.pageId;
|
||||
if (pageId === this.doc.id) return;
|
||||
|
||||
this.std
|
||||
.getOptional(RefNodeSlotsProvider)
|
||||
?.docLinkClicked.next({ ...event, pageId, host: this.host });
|
||||
};
|
||||
|
||||
refreshData = () => {
|
||||
this._load().catch(e => {
|
||||
console.error(e);
|
||||
this._error = true;
|
||||
});
|
||||
};
|
||||
|
||||
title$ = computed(() => {
|
||||
const { pageId, params } = this.model.props;
|
||||
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.props);
|
||||
}
|
||||
|
||||
get syncedDoc() {
|
||||
const options: GetBlocksOptions = { readonly: true };
|
||||
if (this.isPageMode) options.query = this._pageFilter;
|
||||
const doc = this.std.workspace.getDoc(this.model.props.pageId);
|
||||
return doc?.getStore(options) ?? null;
|
||||
}
|
||||
|
||||
private _checkCycle() {
|
||||
let editorHost: EditorHost | null = this.host;
|
||||
while (editorHost && !this._cycle) {
|
||||
this._cycle =
|
||||
!!editorHost && editorHost.doc.id === this.model.props.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 => {
|
||||
const subscription = syncedDoc.slots.rootAdded.subscribe(() => {
|
||||
subscription.unsubscribe();
|
||||
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.props.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.props.style;
|
||||
|
||||
this.style.display = 'block';
|
||||
this._load().catch(e => {
|
||||
console.error(e);
|
||||
this._error = true;
|
||||
});
|
||||
|
||||
this.contentEditable = 'false';
|
||||
|
||||
this.disposables.add(
|
||||
this.model.propsUpdated.subscribe(({ 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.subscribe(() => {
|
||||
this._setDocUpdatedAt();
|
||||
})
|
||||
);
|
||||
|
||||
if (!this.linkedMode) {
|
||||
const docMode = this.std.get(DocModeProvider);
|
||||
this.syncedDocMode = docMode.getPrimaryMode(this.model.props.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.props.pageId)
|
||||
);
|
||||
}
|
||||
|
||||
this.syncedDoc &&
|
||||
this.disposables.add(
|
||||
this.syncedDoc.slots.blockUpdated.subscribe(() => {
|
||||
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,20 @@
|
||||
import { EmbedSyncedDocBlockSchema } from '@blocksuite/affine-model';
|
||||
import { BlockViewExtension, FlavourExtension } from '@blocksuite/std';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
import { literal } from 'lit/static-html.js';
|
||||
|
||||
import { EmbedSyncedDocBlockAdapterExtensions } from './adapters/extension';
|
||||
import { createBuiltinToolbarConfigExtension } from './configs/toolbar';
|
||||
|
||||
const flavour = EmbedSyncedDocBlockSchema.model.flavour;
|
||||
|
||||
export const EmbedSyncedDocBlockSpec: ExtensionType[] = [
|
||||
FlavourExtension(flavour),
|
||||
BlockViewExtension(flavour, model => {
|
||||
return model.parent?.flavour === 'affine:surface'
|
||||
? literal`affine-embed-edgeless-synced-doc-block`
|
||||
: literal`affine-embed-synced-doc-block`;
|
||||
}),
|
||||
EmbedSyncedDocBlockAdapterExtensions,
|
||||
createBuiltinToolbarConfigExtension(flavour),
|
||||
].flat();
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './adapters/index.js';
|
||||
export * from './edgeless-clipboard-config';
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user