mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-17 18:16:15 +08:00
b9f2650369
### What's Changed! * adds theme type: `ThemeSchema` * adds default theme: `DefaultTheme` * stores real color values
532 lines
15 KiB
TypeScript
532 lines
15 KiB
TypeScript
import { EdgelessLegacySlotIdentifier } from '@blocksuite/affine-block-surface';
|
|
import { MoreIndicatorIcon } from '@blocksuite/affine-components/icons';
|
|
import type { NoteBlockModel } from '@blocksuite/affine-model';
|
|
import {
|
|
DefaultTheme,
|
|
NoteDisplayMode,
|
|
StrokeStyle,
|
|
} from '@blocksuite/affine-model';
|
|
import { EDGELESS_BLOCK_CHILD_PADDING } from '@blocksuite/affine-shared/consts';
|
|
import { ThemeProvider } from '@blocksuite/affine-shared/services';
|
|
import {
|
|
getClosestBlockComponentByPoint,
|
|
handleNativeRangeAtPoint,
|
|
matchFlavours,
|
|
stopPropagation,
|
|
} from '@blocksuite/affine-shared/utils';
|
|
import type { BlockComponent, EditorHost } from '@blocksuite/block-std';
|
|
import { ShadowlessElement, toGfxBlockComponent } from '@blocksuite/block-std';
|
|
import {
|
|
almostEqual,
|
|
Bound,
|
|
clamp,
|
|
Point,
|
|
WithDisposable,
|
|
} from '@blocksuite/global/utils';
|
|
import type { BlockModel } from '@blocksuite/store';
|
|
import { computed } from '@preact/signals-core';
|
|
import { css, html, nothing } from 'lit';
|
|
import { property, query, state } from 'lit/decorators.js';
|
|
import { classMap } from 'lit/directives/class-map.js';
|
|
import { styleMap } from 'lit/directives/style-map.js';
|
|
|
|
import { NoteBlockComponent } from './note-block.js';
|
|
|
|
export class EdgelessNoteMask extends WithDisposable(ShadowlessElement) {
|
|
protected override firstUpdated() {
|
|
const maskDOM = this.renderRoot!.querySelector('.affine-note-mask');
|
|
const observer = new ResizeObserver(entries => {
|
|
for (const entry of entries) {
|
|
if (!this.model.edgeless.collapse) {
|
|
const bound = Bound.deserialize(this.model.xywh);
|
|
const scale = this.model.edgeless.scale ?? 1;
|
|
const height = entry.contentRect.height * scale;
|
|
|
|
if (!height || almostEqual(bound.h, height)) {
|
|
return;
|
|
}
|
|
|
|
bound.h = height;
|
|
this.model.stash('xywh');
|
|
this.model.xywh = bound.serialize();
|
|
this.model.pop('xywh');
|
|
}
|
|
}
|
|
});
|
|
|
|
observer.observe(maskDOM!);
|
|
|
|
this._disposables.add(() => {
|
|
observer.disconnect();
|
|
});
|
|
}
|
|
|
|
override render() {
|
|
const extra = this.editing ? ACTIVE_NOTE_EXTRA_PADDING : 0;
|
|
return html`
|
|
<div
|
|
class="affine-note-mask"
|
|
style=${styleMap({
|
|
position: 'absolute',
|
|
top: `${-extra}px`,
|
|
left: `${-extra}px`,
|
|
bottom: `${-extra}px`,
|
|
right: `${-extra}px`,
|
|
zIndex: '1',
|
|
pointerEvents: this.display ? 'auto' : 'none',
|
|
borderRadius: `${
|
|
this.model.edgeless.style.borderRadius * this.zoom
|
|
}px`,
|
|
})}
|
|
></div>
|
|
`;
|
|
}
|
|
|
|
@property({ attribute: false })
|
|
accessor display!: boolean;
|
|
|
|
@property({ attribute: false })
|
|
accessor editing!: boolean;
|
|
|
|
@property({ attribute: false })
|
|
accessor host!: EditorHost;
|
|
|
|
@property({ attribute: false })
|
|
accessor model!: NoteBlockModel;
|
|
|
|
@property({ attribute: false })
|
|
accessor zoom!: number;
|
|
}
|
|
|
|
const ACTIVE_NOTE_EXTRA_PADDING = 20;
|
|
|
|
export class EdgelessNoteBlockComponent extends toGfxBlockComponent(
|
|
NoteBlockComponent
|
|
) {
|
|
static override styles = css`
|
|
.edgeless-note-collapse-button {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 28px;
|
|
height: 28px;
|
|
z-index: 2;
|
|
position: absolute;
|
|
bottom: 0;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
opacity: 0.2;
|
|
transition: opacity 0.3s;
|
|
}
|
|
.edgeless-note-collapse-button:hover {
|
|
opacity: 1;
|
|
}
|
|
.edgeless-note-collapse-button.flip {
|
|
transform: translateX(-50%) rotate(180deg);
|
|
}
|
|
.edgeless-note-collapse-button.hide {
|
|
display: none;
|
|
}
|
|
|
|
.edgeless-note-container:has(.affine-embed-synced-doc-container.editing)
|
|
> .note-background {
|
|
left: ${-ACTIVE_NOTE_EXTRA_PADDING}px !important;
|
|
top: ${-ACTIVE_NOTE_EXTRA_PADDING}px !important;
|
|
width: calc(100% + ${ACTIVE_NOTE_EXTRA_PADDING * 2}px) !important;
|
|
height: calc(100% + ${ACTIVE_NOTE_EXTRA_PADDING * 2}px) !important;
|
|
}
|
|
|
|
.edgeless-note-container:has(.affine-embed-synced-doc-container.editing)
|
|
> edgeless-note-mask {
|
|
display: none;
|
|
}
|
|
`;
|
|
|
|
private readonly _backgroundColor$ = computed(() => {
|
|
const themeProvider = this.std.get(ThemeProvider);
|
|
const theme = themeProvider.theme$.value;
|
|
return themeProvider.generateColorProperty(
|
|
this.model.background$.value,
|
|
DefaultTheme.noteBackgrounColor,
|
|
theme
|
|
);
|
|
});
|
|
|
|
private get _isShowCollapsedContent() {
|
|
return this.model.edgeless.collapse && (this._isResizing || this._isHover);
|
|
}
|
|
|
|
get _zoom() {
|
|
return this.gfx.viewport.zoom;
|
|
}
|
|
|
|
get rootService() {
|
|
return this.std.getService('affine:page');
|
|
}
|
|
|
|
private _collapsedContent() {
|
|
if (!this._isShowCollapsedContent) {
|
|
return nothing;
|
|
}
|
|
|
|
const { xywh, edgeless } = this.model;
|
|
const { borderSize } = edgeless.style;
|
|
|
|
const extraPadding = this._editing ? ACTIVE_NOTE_EXTRA_PADDING : 0;
|
|
const extraBorder = this._editing ? borderSize : 0;
|
|
const bound = Bound.deserialize(xywh);
|
|
const scale = edgeless.scale ?? 1;
|
|
const width = bound.w / scale + extraPadding * 2 + extraBorder;
|
|
const height = bound.h / scale;
|
|
|
|
const rect = this._notePageContent?.getBoundingClientRect();
|
|
if (!rect) return nothing;
|
|
|
|
const zoom = this.gfx.viewport.zoom;
|
|
this._noteFullHeight =
|
|
rect.height / scale / zoom + 2 * EDGELESS_BLOCK_CHILD_PADDING;
|
|
|
|
if (height >= this._noteFullHeight) {
|
|
return nothing;
|
|
}
|
|
|
|
return html`
|
|
<div
|
|
style=${styleMap({
|
|
width: `${width}px`,
|
|
height: `${this._noteFullHeight - height}px`,
|
|
position: 'absolute',
|
|
left: `${-(extraPadding + extraBorder / 2)}px`,
|
|
top: `${height + extraPadding + extraBorder / 2}px`,
|
|
background: 'var(--affine-white)',
|
|
opacity: 0.5,
|
|
pointerEvents: 'none',
|
|
borderLeft: '2px var(--affine-blue) solid',
|
|
borderBottom: '2px var(--affine-blue) solid',
|
|
borderRight: '2px var(--affine-blue) solid',
|
|
borderRadius: '0 0 8px 8px',
|
|
})}
|
|
></div>
|
|
`;
|
|
}
|
|
|
|
private _handleClickAtBackground(e: MouseEvent) {
|
|
e.stopPropagation();
|
|
if (!this._editing) return;
|
|
|
|
const rect = this.getBoundingClientRect();
|
|
const offsetY = 16 * this._zoom;
|
|
const offsetX = 2 * this._zoom;
|
|
const x = clamp(e.x, rect.left + offsetX, rect.right - offsetX);
|
|
const y = clamp(e.y, rect.top + offsetY, rect.bottom - offsetY);
|
|
handleNativeRangeAtPoint(x, y);
|
|
|
|
if (this.doc.readonly) return;
|
|
|
|
this._tryAddParagraph(x, y);
|
|
}
|
|
|
|
private _hovered() {
|
|
if (
|
|
this.selection.value.some(
|
|
sel => sel.type === 'surface' && sel.blockId === this.model.id
|
|
)
|
|
) {
|
|
this._isHover = true;
|
|
}
|
|
}
|
|
|
|
private _leaved() {
|
|
if (this._isHover) {
|
|
this._isHover = false;
|
|
}
|
|
}
|
|
|
|
private _setCollapse(event: MouseEvent) {
|
|
event.stopImmediatePropagation();
|
|
|
|
const { collapse, collapsedHeight } = this.model.edgeless;
|
|
|
|
if (collapse) {
|
|
this.model.doc.updateBlock(this.model, () => {
|
|
this.model.edgeless.collapse = false;
|
|
});
|
|
} else if (collapsedHeight) {
|
|
const { xywh, edgeless } = this.model;
|
|
const bound = Bound.deserialize(xywh);
|
|
bound.h = collapsedHeight * (edgeless.scale ?? 1);
|
|
this.model.doc.updateBlock(this.model, () => {
|
|
this.model.edgeless.collapse = true;
|
|
this.model.xywh = bound.serialize();
|
|
});
|
|
}
|
|
|
|
this.selection.clear();
|
|
}
|
|
|
|
private _tryAddParagraph(x: number, y: number) {
|
|
const nearest = getClosestBlockComponentByPoint(
|
|
new Point(x, y)
|
|
) as BlockComponent | null;
|
|
if (!nearest) return;
|
|
|
|
const nearestBBox = nearest.getBoundingClientRect();
|
|
const yRel = y - nearestBBox.top;
|
|
|
|
const insertPos: 'before' | 'after' =
|
|
yRel < nearestBBox.height / 2 ? 'before' : 'after';
|
|
|
|
const nearestModel = nearest.model as BlockModel;
|
|
const nearestModelIdx = this.model.children.indexOf(nearestModel);
|
|
|
|
const children = this.model.children;
|
|
const siblingModel =
|
|
children[
|
|
clamp(
|
|
nearestModelIdx + (insertPos === 'before' ? -1 : 1),
|
|
0,
|
|
children.length
|
|
)
|
|
];
|
|
|
|
if (
|
|
(!nearestModel.text ||
|
|
!matchFlavours(nearestModel, ['affine:paragraph', 'affine:list'])) &&
|
|
(!siblingModel ||
|
|
!siblingModel.text ||
|
|
!matchFlavours(siblingModel, ['affine:paragraph', 'affine:list']))
|
|
) {
|
|
const [pId] = this.doc.addSiblingBlocks(
|
|
nearestModel,
|
|
[{ flavour: 'affine:paragraph' }],
|
|
insertPos
|
|
);
|
|
|
|
this.updateComplete
|
|
.then(() => {
|
|
this.std.selection.setGroup('note', [
|
|
this.std.selection.create('text', {
|
|
from: {
|
|
blockId: pId,
|
|
index: 0,
|
|
length: 0,
|
|
},
|
|
to: null,
|
|
}),
|
|
]);
|
|
})
|
|
.catch(console.error);
|
|
}
|
|
}
|
|
|
|
override connectedCallback(): void {
|
|
super.connectedCallback();
|
|
|
|
const selection = this.gfx.selection;
|
|
|
|
this._editing = selection.has(this.model.id) && selection.editing;
|
|
this._disposables.add(
|
|
selection.slots.updated.on(() => {
|
|
if (selection.has(this.model.id) && selection.editing) {
|
|
this._editing = true;
|
|
} else {
|
|
this._editing = false;
|
|
}
|
|
})
|
|
);
|
|
}
|
|
|
|
get edgelessSlots() {
|
|
return this.std.get(EdgelessLegacySlotIdentifier);
|
|
}
|
|
|
|
override firstUpdated() {
|
|
const { _disposables } = this;
|
|
const selection = this.gfx.selection;
|
|
|
|
_disposables.add(
|
|
this.edgelessSlots.elementResizeStart.on(() => {
|
|
if (selection.selectedElements.includes(this.model)) {
|
|
this._isResizing = true;
|
|
}
|
|
})
|
|
);
|
|
|
|
_disposables.add(
|
|
this.edgelessSlots.elementResizeEnd.on(() => {
|
|
this._isResizing = false;
|
|
})
|
|
);
|
|
|
|
const observer = new MutationObserver(() => {
|
|
const rect = this._notePageContent?.getBoundingClientRect();
|
|
if (!rect) return;
|
|
const zoom = this.gfx.viewport.zoom;
|
|
const scale = this.model.edgeless.scale ?? 1;
|
|
this._noteFullHeight =
|
|
rect.height / scale / zoom + 2 * EDGELESS_BLOCK_CHILD_PADDING;
|
|
});
|
|
if (this._notePageContent) {
|
|
observer.observe(this, { childList: true, subtree: true });
|
|
_disposables.add(() => observer.disconnect());
|
|
}
|
|
}
|
|
|
|
override getRenderingRect() {
|
|
const { xywh, edgeless } = this.model;
|
|
const { collapse, scale = 1 } = edgeless;
|
|
|
|
const bound = Bound.deserialize(xywh);
|
|
const width = bound.w / scale;
|
|
const height = bound.h / scale;
|
|
|
|
return {
|
|
x: bound.x,
|
|
y: bound.y,
|
|
w: width,
|
|
h: collapse ? height : 'inherit',
|
|
zIndex: this.toZIndex(),
|
|
};
|
|
}
|
|
|
|
override renderGfxBlock() {
|
|
const { model } = this;
|
|
const { displayMode } = model;
|
|
if (!!displayMode && displayMode === NoteDisplayMode.DocOnly)
|
|
return nothing;
|
|
|
|
const { xywh, edgeless } = model;
|
|
const { borderRadius, borderSize, borderStyle, shadowType } =
|
|
edgeless.style;
|
|
const { collapse, collapsedHeight, scale = 1 } = edgeless;
|
|
|
|
const bound = Bound.deserialize(xywh);
|
|
const width = bound.w / scale;
|
|
const height = bound.h / scale;
|
|
|
|
const style = {
|
|
height: '100%',
|
|
padding: `${EDGELESS_BLOCK_CHILD_PADDING}px`,
|
|
boxSizing: 'border-box',
|
|
borderRadius: borderRadius + 'px',
|
|
pointerEvents: 'all',
|
|
transformOrigin: '0 0',
|
|
transform: `scale(${scale})`,
|
|
fontWeight: '400',
|
|
lineHeight: 'var(--affine-line-height)',
|
|
};
|
|
|
|
const extra = this._editing ? ACTIVE_NOTE_EXTRA_PADDING : 0;
|
|
|
|
const backgroundStyle = {
|
|
position: 'absolute',
|
|
left: `${-extra}px`,
|
|
top: `${-extra}px`,
|
|
width: `${width + extra * 2}px`,
|
|
height: `calc(100% + ${extra * 2}px)`,
|
|
borderRadius: borderRadius + 'px',
|
|
transition: this._editing
|
|
? 'left 0.3s, top 0.3s, width 0.3s, height 0.3s'
|
|
: 'none',
|
|
backgroundColor: this._backgroundColor$.value,
|
|
border: `${borderSize}px ${
|
|
borderStyle === StrokeStyle.Dash ? 'dashed' : borderStyle
|
|
} var(--affine-black-10)`,
|
|
boxShadow: this._editing
|
|
? 'var(--affine-active-shadow)'
|
|
: !shadowType
|
|
? 'none'
|
|
: `var(${shadowType})`,
|
|
};
|
|
|
|
const isCollapsable =
|
|
collapse != null &&
|
|
collapsedHeight != null &&
|
|
collapsedHeight !== this._noteFullHeight;
|
|
|
|
const isCollapseArrowUp = collapse
|
|
? this._noteFullHeight < height
|
|
: !!collapsedHeight && collapsedHeight < height;
|
|
|
|
return html`
|
|
<div
|
|
class="edgeless-note-container"
|
|
style=${styleMap(style)}
|
|
data-model-height="${bound.h}"
|
|
@mouseleave=${this._leaved}
|
|
@mousemove=${this._hovered}
|
|
data-scale="${scale}"
|
|
>
|
|
<div
|
|
class="note-background"
|
|
style=${styleMap(backgroundStyle)}
|
|
@pointerdown=${stopPropagation}
|
|
@click=${this._handleClickAtBackground}
|
|
></div>
|
|
|
|
<div
|
|
class="edgeless-note-page-content"
|
|
style=${styleMap({
|
|
width: '100%',
|
|
height: '100%',
|
|
'overflow-y': this._isShowCollapsedContent ? 'initial' : 'clip',
|
|
})}
|
|
>
|
|
${this.renderPageContent()}
|
|
</div>
|
|
|
|
${isCollapsable
|
|
? html`<div
|
|
class="${classMap({
|
|
'edgeless-note-collapse-button': true,
|
|
flip: isCollapseArrowUp,
|
|
hide: this._isSelected,
|
|
})}"
|
|
style=${styleMap({
|
|
bottom: this._editing ? `${-extra}px` : '0',
|
|
})}
|
|
@mousedown=${stopPropagation}
|
|
@mouseup=${stopPropagation}
|
|
@click=${this._setCollapse}
|
|
>
|
|
${MoreIndicatorIcon}
|
|
</div>`
|
|
: nothing}
|
|
${this._collapsedContent()}
|
|
|
|
<edgeless-note-mask
|
|
.model=${this.model}
|
|
.display=${!this._editing}
|
|
.host=${this.host}
|
|
.zoom=${this.gfx.viewport.zoom ?? 1}
|
|
.editing=${this._editing}
|
|
></edgeless-note-mask>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
@state()
|
|
private accessor _editing = false;
|
|
|
|
@state()
|
|
private accessor _isHover = false;
|
|
|
|
@state()
|
|
private accessor _isResizing = false;
|
|
|
|
@state()
|
|
private accessor _isSelected = false;
|
|
|
|
@state()
|
|
private accessor _noteFullHeight = 0;
|
|
|
|
@query('.edgeless-note-page-content .affine-note-block-container')
|
|
private accessor _notePageContent: HTMLElement | null = null;
|
|
}
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
'affine-edgeless-note': EdgelessNoteBlockComponent;
|
|
}
|
|
}
|