Files
AFFiNE-Mirror/blocksuite/affine/blocks/surface-ref/src/surface-ref-block.ts
T
L-Sun 1d865f16fe feat(editor): comment for edgeless element (#13098)
#### PR Dependency Tree


* **PR #13098** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added support for comments on graphical elements, allowing users to
comment on both blocks and graphical elements within surfaces.
* Enhanced comment previews to include graphical elements in selection
summaries.
* Improved editor navigation to focus on commented graphical elements in
addition to blocks and inline texts.

* **Bug Fixes**
* Updated comment highlighting and management to consistently use the
new comment manager across all block and element types.

* **Refactor**
* Renamed and extended the comment manager to handle both block and
element comments.
* Streamlined toolbar configurations by removing outdated comment button
entries and adding a consolidated comment button in the root toolbar.

* **Tests**
* Disabled the mock comment provider integration in the test editor
environment to refine testing setup.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-08 10:33:09 +00:00

512 lines
14 KiB
TypeScript

import { FrameBlockComponent } from '@blocksuite/affine-block-frame';
import {
EdgelessCRUDIdentifier,
getSurfaceBlock,
} from '@blocksuite/affine-block-surface';
import type { BlockCaptionEditor } from '@blocksuite/affine-components/caption';
import { whenHover } from '@blocksuite/affine-components/hover';
import { Peekable } from '@blocksuite/affine-components/peek';
import { ViewExtensionManagerIdentifier } from '@blocksuite/affine-ext-loader';
import { RefNodeSlotsProvider } from '@blocksuite/affine-inline-reference';
import {
FrameBlockModel,
type SurfaceRefBlockModel,
} from '@blocksuite/affine-model';
import {
BlockElementCommentManager,
DocModeProvider,
EditPropsStore,
type OpenDocMode,
ThemeProvider,
ToolbarRegistryIdentifier,
ViewportElementExtension,
} from '@blocksuite/affine-shared/services';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { requestConnectedFrame } from '@blocksuite/affine-shared/utils';
import { DisposableGroup } from '@blocksuite/global/disposable';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { Bound, type SerializedXYWH } from '@blocksuite/global/gfx';
import {
BlockComponent,
BlockSelection,
BlockStdScope,
type EditorHost,
LifeCycleWatcher,
TextSelection,
} from '@blocksuite/std';
import {
GfxBlockElementModel,
GfxControllerIdentifier,
type GfxModel,
GfxPrimitiveElementModel,
} from '@blocksuite/std/gfx';
import type { BaseSelection, ExtensionType, Store } from '@blocksuite/store';
import { effect, signal } from '@preact/signals-core';
import { css, html, nothing } from 'lit';
import { query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { guard } from 'lit/directives/guard.js';
import { styleMap } from 'lit/directives/style-map.js';
@Peekable({
enableOn: (block: SurfaceRefBlockComponent) => !!block.referenceModel,
})
export class SurfaceRefBlockComponent extends BlockComponent<SurfaceRefBlockModel> {
static override styles = css`
affine-surface-ref {
position: relative;
}
affine-surface-ref:not(:hover)
affine-surface-ref-toolbar:not([data-open-menu-display='show']) {
display: none;
}
.affine-surface-ref {
position: relative;
user-select: none;
margin: 10px 0;
break-inside: avoid;
border-radius: 8px;
border: 1px solid ${unsafeCSSVarV2('edgeless/frame/border/default')};
background-color: ${unsafeCSSVarV2('layer/background/primary')};
overflow: hidden;
}
.affine-surface-ref.focused {
border-color: ${unsafeCSSVarV2('edgeless/frame/border/active')};
}
.affine-surface-ref.comment-highlighted {
outline: 2px solid ${unsafeCSSVarV2('block/comment/highlightUnderline')};
}
@media print {
.affine-surface-ref {
outline: none !important;
}
}
.ref-content {
position: relative;
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;
user-select: none;
}
.ref-viewport-event-mask {
position: absolute;
inset: 0;
}
`;
private _previewDoc: Store | null = null;
private _runtimePreviewExt: ExtensionType[] = [];
private get _viewExtensionManager() {
return this.std.get(ViewExtensionManagerIdentifier);
}
private get _previewSpec() {
return [
...this._viewExtensionManager.get('preview-edgeless'),
ViewportElementExtension('.ref-viewport'),
];
}
private _referencedModel: GfxModel | null = null;
// since the xywh of edgeless element is not a signal, we need to use a signal to store the xywh
private readonly _referenceXYWH$ = signal<SerializedXYWH | 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;
}
get isCommentHighlighted() {
return (
this.std
.getOptional(BlockElementCommentManager)
?.isBlockCommentHighlighted(this.model) ?? false
);
}
private readonly _handleClick = () => {
this.selection.update(() => {
return [this.selection.create(BlockSelection, { blockId: this.blockId })];
});
};
private _initHotkey() {
const selection = this.host.selection;
const addParagraph = () => {
if (!this.store.getParent(this.model)) return;
const [paragraphId] = this.store.addSiblingBlocks(this.model, [
{
flavour: 'affine:paragraph',
},
]);
const model = this.store.getModelById(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.selected$.value) return;
addParagraph();
return true;
},
});
}
private _initReferencedModel() {
const findReferencedModel = (): [GfxModel | null, string] => {
if (!this.model.props.reference) return [null, this.store.id];
const referenceId = this.model.props.reference;
const find = (doc: Store): [GfxModel | null, string] => {
const block = doc.getBlock(referenceId)?.model;
if (block instanceof GfxBlockElementModel) {
return [block, doc.id];
}
const surfaceBlock = getSurfaceBlock(doc);
if (!surfaceBlock) return [null, doc.id];
const element = surfaceBlock.getElementById(referenceId);
if (element) return [element, doc.id];
return [null, doc.id];
};
// find current doc first
let result = find(this.store);
if (result[0]) return result;
for (const doc of this.std.workspace.docs.values()) {
result = find(doc.getStore());
if (result[0]) return result;
}
return [null, this.store.id];
};
const init = () => {
const [referencedModel, docId] = findReferencedModel();
this._referencedModel =
referencedModel && referencedModel.xywh ? referencedModel : null;
// TODO(@L-Sun): clear query cache
const doc = this.store.workspace.getDoc(docId);
this._previewDoc = doc?.getStore({ readonly: true }) ?? null;
};
init();
this._disposables.add(
this.model.propsUpdated.subscribe(payload => {
if (
payload.key === 'reference' &&
this.model.props.reference !== this._referencedModel?.id
) {
init();
}
})
);
if (this._referencedModel instanceof GfxPrimitiveElementModel) {
this._disposables.add(
this._referencedModel.surface.elementRemoved.subscribe(({ id }) => {
if (this.model.props.reference === id) {
init();
}
})
);
}
if (this._referencedModel instanceof GfxBlockElementModel) {
this._disposables.add(
this.store.slots.blockUpdated.subscribe(({ type, id }) => {
if (type === 'delete' && id === this.model.props.reference) {
init();
}
})
);
}
}
private _initViewport() {
this._referenceXYWH$.value = this.referenceModel?.xywh ?? null;
const refreshViewport = () => {
if (!this._referenceXYWH$.value) return;
const previewEditorHost = this.previewEditor;
if (!previewEditorHost) return;
const gfx = previewEditorHost.std.get(GfxControllerIdentifier);
const viewport = gfx.viewport;
viewport.setViewportByBound(
Bound.deserialize(this._referenceXYWH$.value),
this.referenceModel instanceof FrameBlockModel
? undefined
: [20, 20, 20, 20]
);
};
this.disposables.add(effect(refreshViewport));
const referenceId = this.model.props.reference;
const referenceXYWH$ = this._referenceXYWH$;
class SurfaceRefViewportWatcher extends LifeCycleWatcher {
static override readonly key = 'surface-ref-viewport-watcher';
private readonly _disposable = new DisposableGroup();
override mounted() {
const crud = this.std.get(EdgelessCRUDIdentifier);
const gfx = this.std.get(GfxControllerIdentifier);
const { surface, viewport } = gfx;
if (!surface) return;
const referenceElement = crud.getElementById(referenceId);
if (!referenceElement) {
throw new BlockSuiteError(
ErrorCode.MissingViewModelError,
`can not find element(id:${referenceElement})`
);
}
referenceXYWH$.value = referenceElement.xywh;
const { _disposable } = this;
refreshViewport();
_disposable.add(viewport.sizeUpdated.subscribe(refreshViewport));
if (referenceElement instanceof GfxBlockElementModel) {
_disposable.add(
referenceElement.xywh$.subscribe(xywh => {
referenceXYWH$.value = xywh;
})
);
} else if (referenceElement instanceof GfxPrimitiveElementModel) {
_disposable.add(
surface.elementUpdated.subscribe(({ id, oldValues }) => {
if (
id === referenceId &&
oldValues.xywh !== referenceElement.xywh
) {
referenceXYWH$.value = referenceElement.xywh;
}
})
);
}
const subscription = this.std.view.viewUpdated.subscribe(
({ id, type, method, view }) => {
if (
id === referenceElement.id &&
type === 'block' &&
method === 'add' &&
view instanceof FrameBlockComponent
) {
view.showBorder = false;
subscription.unsubscribe();
}
}
);
_disposable.add(subscription);
}
override unmounted() {
this._disposable.dispose();
}
}
this._runtimePreviewExt = [SurfaceRefViewportWatcher];
}
private _initHover() {
const { setReference, setFloating, dispose } = whenHover(
hovered => {
const message$ = this.std.get(ToolbarRegistryIdentifier).message$;
if (hovered) {
message$.value = {
flavour: this.model.flavour,
element: this,
setFloating,
};
return;
}
// Clears previous bindings
message$.value = null;
setFloating();
},
{ enterDelay: 500 }
);
setReference(this.hoverableContainer);
this._disposables.add(dispose);
}
private _renderRefContent() {
if (!this._referenceXYWH$.value) return nothing;
const { w, h } = Bound.deserialize(this._referenceXYWH$.value);
const aspectRatio = h !== 0 ? w / h : 1;
const _previewSpec = this._previewSpec.concat(this._runtimePreviewExt);
const edgelessTheme = this.std.get(ThemeProvider).edgeless$.value;
return html`<div class="ref-content">
<div
class="ref-viewport"
style=${styleMap({
aspectRatio: `${aspectRatio}`,
})}
data-theme=${edgelessTheme}
>
${guard(this._previewDoc, () => {
return this._previewDoc
? new BlockStdScope({
store: this._previewDoc,
extensions: _previewSpec,
}).render()
: nothing;
})}
<div class="ref-viewport-event-mask"></div>
</div>
</div>`;
}
readonly open = ({
openMode,
event,
}: {
openMode?: OpenDocMode;
event?: MouseEvent;
} = {}) => {
const pageId = this.referenceModel?.surface?.store.id;
if (!pageId) return;
this.std.getOptional(RefNodeSlotsProvider)?.docLinkClicked.next({
pageId: pageId,
params: {
mode: 'edgeless',
elementIds: [this.model.props.reference],
},
openMode,
event,
host: this.host,
});
};
override connectedCallback() {
super.connectedCallback();
this.contentEditable = 'false';
if (!this._shouldRender) return;
this._initReferencedModel();
this._initHotkey();
this._initViewport();
}
override firstUpdated() {
if (!this._shouldRender) return;
this._initHover();
}
override render() {
if (!this._shouldRender) return nothing;
const { _referencedModel, model } = this;
const isEmpty = !_referencedModel || !_referencedModel.xywh;
const theme = this.std.get(ThemeProvider).theme$.value;
const content = isEmpty
? html`<surface-ref-placeholder
.referenceModel=${_referencedModel}
.refFlavour=${model.props.refFlavour$.value}
.theme=${theme}
></surface-ref-placeholder>`
: this._renderRefContent();
return html`
<div
class=${classMap({
'affine-surface-ref': true,
focused: this.selected$.value,
'comment-highlighted': this.isCommentHighlighted,
})}
@click=${this._handleClick}
>
${content}
</div>
<block-caption-editor></block-caption-editor>
${Object.values(this.widgets)}
`;
}
viewInEdgeless() {
if (!this._referenceXYWH$.value) return;
const viewport = {
xywh: this._referenceXYWH$.value,
padding: [20, 20, 20, 20] as [number, number, number, number],
};
this.std.get(EditPropsStore).setStorage('viewport', viewport);
this.std.get(DocModeProvider).setEditorMode('edgeless');
}
@query('.affine-surface-ref')
accessor hoverableContainer!: HTMLDivElement;
@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;
}
}