mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 13:25:12 +00:00
660 lines
17 KiB
TypeScript
660 lines
17 KiB
TypeScript
import {
|
|
EdgelessCRUDExtension,
|
|
getSurfaceBlock,
|
|
type SurfaceBlockModel,
|
|
SurfaceElementModel,
|
|
} from '@blocksuite/affine-block-surface';
|
|
import type { BlockCaptionEditor } from '@blocksuite/affine-components/caption';
|
|
import { Peekable } from '@blocksuite/affine-components/peek';
|
|
import {
|
|
FrameBlockModel,
|
|
RootBlockModel,
|
|
type SurfaceRefBlockModel,
|
|
} from '@blocksuite/affine-model';
|
|
import {
|
|
DocModeProvider,
|
|
EditPropsStore,
|
|
ThemeProvider,
|
|
} from '@blocksuite/affine-shared/services';
|
|
import {
|
|
matchModels,
|
|
requestConnectedFrame,
|
|
SpecProvider,
|
|
} from '@blocksuite/affine-shared/utils';
|
|
import {
|
|
BlockComponent,
|
|
BlockSelection,
|
|
BlockStdScope,
|
|
type EditorHost,
|
|
LifeCycleWatcher,
|
|
TextSelection,
|
|
} from '@blocksuite/block-std';
|
|
import {
|
|
GfxBlockElementModel,
|
|
GfxControllerIdentifier,
|
|
type GfxModel,
|
|
GfxPrimitiveElementModel,
|
|
} from '@blocksuite/block-std/gfx';
|
|
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
|
import {
|
|
Bound,
|
|
deserializeXYWH,
|
|
type SerializedXYWH,
|
|
} from '@blocksuite/global/gfx';
|
|
import { DisposableGroup } from '@blocksuite/global/slot';
|
|
import { DeleteIcon, EdgelessIcon, FrameIcon } from '@blocksuite/icons/lit';
|
|
import type { BaseSelection, Store } from '@blocksuite/store';
|
|
import { css, html, nothing, type TemplateResult } from 'lit';
|
|
import { query, state } from 'lit/decorators.js';
|
|
import { styleMap } from 'lit/directives/style-map.js';
|
|
|
|
import { noContentPlaceholder } from './utils.js';
|
|
|
|
const iconSize = { width: '20px', height: '20px' };
|
|
const REF_LABEL_ICON = {
|
|
'affine:frame': FrameIcon(iconSize),
|
|
DEFAULT_NOTE_HEIGHT: EdgelessIcon(iconSize),
|
|
} as Record<string, TemplateResult>;
|
|
|
|
const NO_CONTENT_TITLE = {
|
|
'affine:frame': 'Frame',
|
|
group: 'Group',
|
|
DEFAULT: 'Content',
|
|
} as Record<string, string>;
|
|
|
|
const NO_CONTENT_REASON = {
|
|
group: 'This content was ungrouped or deleted on edgeless mode',
|
|
DEFAULT: 'This content was deleted on edgeless mode',
|
|
} as Record<string, string>;
|
|
|
|
@Peekable()
|
|
export class SurfaceRefBlockComponent extends BlockComponent<SurfaceRefBlockModel> {
|
|
static override styles = css`
|
|
.affine-surface-ref {
|
|
position: relative;
|
|
user-select: none;
|
|
margin: 10px 0;
|
|
break-inside: avoid;
|
|
}
|
|
|
|
@media print {
|
|
.affine-surface-ref {
|
|
outline: none !important;
|
|
}
|
|
}
|
|
|
|
.ref-placeholder {
|
|
padding: 26px 0px 0px;
|
|
}
|
|
|
|
.placeholder-image {
|
|
margin: 0 auto;
|
|
text-align: center;
|
|
}
|
|
|
|
.placeholder-text {
|
|
margin: 12px auto 0;
|
|
text-align: center;
|
|
font-size: 28px;
|
|
font-weight: 600;
|
|
line-height: 36px;
|
|
font-family: var(--affine-font-family);
|
|
}
|
|
|
|
.placeholder-action {
|
|
margin: 32px auto 0;
|
|
text-align: center;
|
|
}
|
|
|
|
.delete-button {
|
|
width: 204px;
|
|
padding: 4px 18px;
|
|
|
|
display: inline-flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
gap: 4px;
|
|
|
|
border-radius: 8px;
|
|
border: 1px solid var(--affine-border-color);
|
|
|
|
font-family: var(--affine-font-family);
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
line-height: 20px;
|
|
|
|
background-color: transparent;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.delete-button > .icon > svg {
|
|
color: var(--affine-icon-color);
|
|
width: 16px;
|
|
height: 16px;
|
|
display: block;
|
|
}
|
|
|
|
.placeholder-reason {
|
|
margin: 72px auto 0;
|
|
padding: 10px;
|
|
|
|
text-align: center;
|
|
font-size: 12px;
|
|
font-family: var(--affine-font-family);
|
|
line-height: 20px;
|
|
|
|
color: var(--affine-warning-color);
|
|
background-color: var(--affine-background-error-color);
|
|
}
|
|
|
|
.ref-content {
|
|
position: relative;
|
|
padding: 20px;
|
|
background-color: var(--affine-background-primary-color);
|
|
background: radial-gradient(
|
|
var(--affine-edgeless-grid-color) 1px,
|
|
var(--affine-background-primary-color) 1px
|
|
);
|
|
}
|
|
|
|
.ref-viewport {
|
|
max-width: 100%;
|
|
margin: 0 auto;
|
|
position: relative;
|
|
overflow: hidden;
|
|
pointer-events: none;
|
|
user-select: none;
|
|
}
|
|
|
|
.ref-viewport.frame {
|
|
border-radius: 2px;
|
|
border: 1px solid var(--affine-black-30);
|
|
}
|
|
|
|
.surface-ref-mask {
|
|
position: absolute;
|
|
left: 0;
|
|
top: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
break-inside: avoid;
|
|
}
|
|
|
|
.surface-ref-mask:hover {
|
|
background-color: rgba(211, 211, 211, 0.1);
|
|
}
|
|
|
|
.surface-ref-mask:hover .ref-label {
|
|
display: block;
|
|
}
|
|
|
|
.ref-label {
|
|
display: none;
|
|
user-select: none;
|
|
}
|
|
|
|
.ref-label {
|
|
position: absolute;
|
|
left: 0;
|
|
bottom: 0;
|
|
|
|
width: 100%;
|
|
padding: 8px 16px;
|
|
border: 1px solid var(--affine-border-color);
|
|
gap: 14px;
|
|
|
|
background: var(--affine-background-primary-color);
|
|
|
|
font-size: 12px;
|
|
|
|
user-select: none;
|
|
}
|
|
|
|
.ref-label .title {
|
|
display: inline-block;
|
|
font-weight: 600;
|
|
font-family: var(--affine-font-family);
|
|
line-height: 20px;
|
|
|
|
color: var(--affine-text-secondary-color);
|
|
}
|
|
|
|
.ref-label .title > svg {
|
|
color: var(--affine-icon-secondary);
|
|
display: inline-block;
|
|
vertical-align: baseline;
|
|
width: 20px;
|
|
height: 20px;
|
|
vertical-align: bottom;
|
|
}
|
|
|
|
.ref-label .suffix {
|
|
display: inline-block;
|
|
font-weight: 400;
|
|
color: var(--affine-text-disable-color);
|
|
line-height: 20px;
|
|
}
|
|
`;
|
|
|
|
private _previewDoc: Store | null = null;
|
|
|
|
private readonly _previewSpec = SpecProvider._.getSpec('preview:edgeless');
|
|
|
|
private _referencedModel: GfxModel | null = null;
|
|
|
|
private _referenceXYWH: SerializedXYWH | null = null;
|
|
|
|
private _viewportEditor: EditorHost | null = null;
|
|
|
|
private get _shouldRender() {
|
|
return (
|
|
this.isConnected &&
|
|
// prevent surface-ref from render itself in loop
|
|
!this.parentComponent?.closest('affine-surface-ref')
|
|
);
|
|
}
|
|
|
|
get referenceModel() {
|
|
return this._referencedModel;
|
|
}
|
|
|
|
private _deleteThis() {
|
|
this.doc.deleteBlock(this.model);
|
|
}
|
|
|
|
private _focusBlock() {
|
|
this.selection.update(() => {
|
|
return [this.selection.create(BlockSelection, { blockId: this.blockId })];
|
|
});
|
|
}
|
|
|
|
private _initHotkey() {
|
|
const selection = this.host.selection;
|
|
const addParagraph = () => {
|
|
if (!this.doc.getParent(this.model)) return;
|
|
|
|
const [paragraphId] = this.doc.addSiblingBlocks(this.model, [
|
|
{
|
|
flavour: 'affine:paragraph',
|
|
},
|
|
]);
|
|
const model = this.doc.getBlockById(paragraphId);
|
|
if (!model) return;
|
|
|
|
requestConnectedFrame(() => {
|
|
selection.update(selList => {
|
|
return selList
|
|
.filter<BaseSelection>(sel => !sel.is(BlockSelection))
|
|
.concat(
|
|
selection.create(TextSelection, {
|
|
from: {
|
|
blockId: model.id,
|
|
index: 0,
|
|
length: 0,
|
|
},
|
|
to: null,
|
|
})
|
|
);
|
|
});
|
|
}, this);
|
|
};
|
|
|
|
this.bindHotKey({
|
|
Enter: () => {
|
|
if (!this._focused) return;
|
|
addParagraph();
|
|
return true;
|
|
},
|
|
});
|
|
}
|
|
|
|
private _initReferencedModel() {
|
|
const surfaceModel = getSurfaceBlock(this.doc);
|
|
this._surfaceModel = surfaceModel;
|
|
|
|
const findReferencedModel = (): [GfxModel | null, string] => {
|
|
if (!this.model.reference) return [null, this.doc.id];
|
|
|
|
if (this.doc.getBlock(this.model.reference)) {
|
|
return [
|
|
this.doc.getBlock(this.model.reference)
|
|
?.model as GfxBlockElementModel,
|
|
this.doc.id,
|
|
];
|
|
}
|
|
|
|
if (this._surfaceModel?.getElementById(this.model.reference)) {
|
|
return [
|
|
this._surfaceModel.getElementById(this.model.reference),
|
|
this.doc.id,
|
|
];
|
|
}
|
|
|
|
const doc = [...this.std.workspace.docs.values()]
|
|
.map(doc => doc.getStore())
|
|
.find(
|
|
doc =>
|
|
doc.getBlock(this.model.reference) ||
|
|
getSurfaceBlock(doc)?.getElementById(this.model.reference)
|
|
);
|
|
|
|
if (doc) {
|
|
this._surfaceModel = getSurfaceBlock(doc);
|
|
}
|
|
|
|
if (doc && doc.getBlock(this.model.reference)) {
|
|
return [
|
|
doc.getBlock(this.model.reference)?.model as GfxBlockElementModel,
|
|
doc.id,
|
|
];
|
|
}
|
|
|
|
if (doc) {
|
|
const surfaceBlock = getSurfaceBlock(doc);
|
|
if (surfaceBlock) {
|
|
return [surfaceBlock.getElementById(this.model.reference), doc.id];
|
|
}
|
|
}
|
|
|
|
return [null, this.doc.id];
|
|
};
|
|
|
|
const init = () => {
|
|
const [referencedModel, docId] = findReferencedModel();
|
|
|
|
this._referencedModel =
|
|
referencedModel && referencedModel.xywh ? referencedModel : null;
|
|
this._previewDoc = this.doc.workspace.getDoc(docId, {
|
|
readonly: true,
|
|
});
|
|
this._referenceXYWH = this._referencedModel?.xywh ?? null;
|
|
};
|
|
|
|
init();
|
|
|
|
this._disposables.add(
|
|
this.model.propsUpdated.on(payload => {
|
|
if (
|
|
payload.key === 'reference' &&
|
|
this.model.reference !== this._referencedModel?.id
|
|
) {
|
|
init();
|
|
}
|
|
})
|
|
);
|
|
|
|
if (surfaceModel && this._referencedModel instanceof SurfaceElementModel) {
|
|
this._disposables.add(
|
|
surfaceModel.elementRemoved.on(({ id }) => {
|
|
if (this.model.reference === id) {
|
|
init();
|
|
}
|
|
})
|
|
);
|
|
}
|
|
|
|
if (this._referencedModel instanceof GfxBlockElementModel) {
|
|
this._disposables.add(
|
|
this.doc.slots.blockUpdated.on(({ type, id }) => {
|
|
if (type === 'delete' && id === this.model.reference) {
|
|
init();
|
|
}
|
|
})
|
|
);
|
|
}
|
|
}
|
|
|
|
private _initSelection() {
|
|
const selection = this.host.selection;
|
|
this._disposables.add(
|
|
selection.slots.changed.on(selList => {
|
|
this._focused = selList.some(
|
|
sel => sel.blockId === this.blockId && sel.is(BlockSelection)
|
|
);
|
|
})
|
|
);
|
|
}
|
|
|
|
private _initSpec() {
|
|
const refreshViewport = this._refreshViewport.bind(this);
|
|
class SurfaceRefViewportInitializer extends LifeCycleWatcher {
|
|
static override readonly key = 'surfaceRefViewportInitializer';
|
|
|
|
override mounted() {
|
|
const disposable = this.std.view.viewUpdated.on(payload => {
|
|
if (payload.type !== 'block') return;
|
|
if (
|
|
payload.method === 'add' &&
|
|
matchModels(payload.view.model, [RootBlockModel])
|
|
) {
|
|
disposable.dispose();
|
|
queueMicrotask(() => refreshViewport());
|
|
const gfx = this.std.get(GfxControllerIdentifier);
|
|
gfx.viewport.sizeUpdated.on(() => {
|
|
refreshViewport();
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
this._previewSpec.extend([SurfaceRefViewportInitializer]);
|
|
|
|
const referenceId = this.model.reference;
|
|
const setReferenceXYWH = (xywh: typeof this._referenceXYWH) => {
|
|
this._referenceXYWH = xywh;
|
|
};
|
|
|
|
class FrameGroupViewWatcher extends LifeCycleWatcher {
|
|
static override readonly key = 'surface-ref-group-view-watcher';
|
|
|
|
private readonly _disposable = new DisposableGroup();
|
|
|
|
override mounted() {
|
|
const crud = this.std.get(EdgelessCRUDExtension);
|
|
const { _disposable } = this;
|
|
const surfaceModel = getSurfaceBlock(this.std.store);
|
|
if (!surfaceModel) return;
|
|
|
|
const referenceElement = crud.getElementById(referenceId);
|
|
if (!referenceElement) {
|
|
throw new BlockSuiteError(
|
|
ErrorCode.MissingViewModelError,
|
|
`can not find element(id:${referenceElement})`
|
|
);
|
|
}
|
|
|
|
if (referenceElement instanceof FrameBlockModel) {
|
|
_disposable.add(
|
|
referenceElement.xywh$.subscribe(xywh => {
|
|
setReferenceXYWH(xywh);
|
|
refreshViewport();
|
|
})
|
|
);
|
|
} else if (referenceElement instanceof GfxPrimitiveElementModel) {
|
|
_disposable.add(
|
|
surfaceModel.elementUpdated.on(({ id, oldValues }) => {
|
|
if (
|
|
id === referenceId &&
|
|
oldValues.xywh !== referenceElement.xywh
|
|
) {
|
|
setReferenceXYWH(referenceElement.xywh);
|
|
refreshViewport();
|
|
}
|
|
})
|
|
);
|
|
}
|
|
}
|
|
|
|
override unmounted() {
|
|
this._disposable.dispose();
|
|
}
|
|
}
|
|
|
|
this._previewSpec.extend([FrameGroupViewWatcher]);
|
|
}
|
|
|
|
private _refreshViewport() {
|
|
if (!this._referenceXYWH) return;
|
|
|
|
const previewEditorHost = this.previewEditor;
|
|
|
|
if (!previewEditorHost) return;
|
|
|
|
const gfx = previewEditorHost.std.get(GfxControllerIdentifier);
|
|
const viewport = gfx.viewport;
|
|
|
|
viewport.setViewportByBound(Bound.deserialize(this._referenceXYWH));
|
|
}
|
|
|
|
private _renderMask(referencedModel: GfxModel, flavourOrType: string) {
|
|
const title = 'title' in referencedModel ? referencedModel.title : '';
|
|
|
|
return html`
|
|
<div class="surface-ref-mask">
|
|
<div class="ref-label">
|
|
<div class="title">
|
|
${REF_LABEL_ICON[flavourOrType ?? 'DEFAULT'] ??
|
|
REF_LABEL_ICON.DEFAULT}
|
|
<span>${title}</span>
|
|
</div>
|
|
<div class="suffix">from edgeless mode</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private _renderRefContent(referencedModel: GfxModel) {
|
|
const [, , w, h] = deserializeXYWH(referencedModel.xywh);
|
|
const flavourOrType =
|
|
'flavour' in referencedModel
|
|
? referencedModel.flavour
|
|
: referencedModel.type;
|
|
const _previewSpec = this._previewSpec.value;
|
|
|
|
if (!this._viewportEditor) {
|
|
if (this._previewDoc) {
|
|
this._viewportEditor = new BlockStdScope({
|
|
store: this._previewDoc,
|
|
extensions: _previewSpec,
|
|
}).render();
|
|
} else {
|
|
console.error('Preview doc is not found');
|
|
}
|
|
}
|
|
|
|
return html`<div class="ref-content">
|
|
<div
|
|
class="ref-viewport ${flavourOrType === 'affine:frame' ? 'frame' : ''}"
|
|
style=${styleMap({
|
|
width: `${w}px`,
|
|
aspectRatio: `${w} / ${h}`,
|
|
})}
|
|
>
|
|
${this._viewportEditor}
|
|
</div>
|
|
${this._renderMask(referencedModel, flavourOrType)}
|
|
</div>`;
|
|
}
|
|
|
|
private _renderRefPlaceholder(model: SurfaceRefBlockModel) {
|
|
return html`<div class="ref-placeholder">
|
|
<div class="placeholder-image">${noContentPlaceholder}</div>
|
|
<div class="placeholder-text">
|
|
No Such
|
|
${NO_CONTENT_TITLE[model.refFlavour ?? 'DEFAULT'] ??
|
|
NO_CONTENT_TITLE.DEFAULT}
|
|
</div>
|
|
<div class="placeholder-action">
|
|
<button class="delete-button" type="button" @click=${this._deleteThis}>
|
|
<span class="icon">${DeleteIcon()}</span
|
|
><span>Delete this block</span>
|
|
</button>
|
|
</div>
|
|
<div class="placeholder-reason">
|
|
${NO_CONTENT_REASON[model.refFlavour ?? 'DEFAULT'] ??
|
|
NO_CONTENT_REASON.DEFAULT}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
override connectedCallback() {
|
|
super.connectedCallback();
|
|
|
|
this.contentEditable = 'false';
|
|
|
|
if (!this._shouldRender) return;
|
|
|
|
this._initHotkey();
|
|
this._initSpec();
|
|
this._initReferencedModel();
|
|
this._initSelection();
|
|
}
|
|
|
|
override render() {
|
|
if (!this._shouldRender) return nothing;
|
|
|
|
const { _surfaceModel, _referencedModel, model } = this;
|
|
const isEmpty =
|
|
!_surfaceModel || !_referencedModel || !_referencedModel.xywh;
|
|
const content = isEmpty
|
|
? this._renderRefPlaceholder(model)
|
|
: this._renderRefContent(_referencedModel);
|
|
const edgelessTheme = this.std.get(ThemeProvider).edgeless$.value;
|
|
|
|
return html`
|
|
<div
|
|
class="affine-surface-ref"
|
|
data-theme=${edgelessTheme}
|
|
@click=${this._focusBlock}
|
|
style=${styleMap({
|
|
outline: this._focused
|
|
? '2px solid var(--affine-primary-color)'
|
|
: undefined,
|
|
})}
|
|
>
|
|
${content}
|
|
</div>
|
|
|
|
<block-caption-editor></block-caption-editor>
|
|
|
|
${Object.values(this.widgets)}
|
|
`;
|
|
}
|
|
|
|
viewInEdgeless() {
|
|
if (!this._referenceXYWH) return;
|
|
|
|
const viewport = {
|
|
xywh: this._referenceXYWH,
|
|
padding: [60, 20, 20, 20] as [number, number, number, number],
|
|
};
|
|
|
|
this.std.get(EditPropsStore).setStorage('viewport', viewport);
|
|
this.std.get(DocModeProvider).setEditorMode('edgeless');
|
|
}
|
|
|
|
override willUpdate(_changedProperties: Map<PropertyKey, unknown>): void {
|
|
if (_changedProperties.has('_referencedModel')) {
|
|
this._refreshViewport();
|
|
}
|
|
}
|
|
|
|
@state()
|
|
private accessor _focused: boolean = false;
|
|
|
|
@state()
|
|
private accessor _surfaceModel: SurfaceBlockModel | null = null;
|
|
|
|
@query('affine-surface-ref > block-caption-editor')
|
|
accessor captionElement!: BlockCaptionEditor;
|
|
|
|
@query('editor-host')
|
|
accessor previewEditor!: EditorHost | null;
|
|
}
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
'affine-surface-ref': SurfaceRefBlockComponent;
|
|
}
|
|
}
|