refactor(editor): extract root block (#10356)

Closes: [BS-2207](https://linear.app/affine-design/issue/BS-2207/move-root-block-to-affineblock-root)
This commit is contained in:
Saul-Mirone
2025-02-21 12:38:26 +00:00
parent 4a66ec7400
commit 55651503df
336 changed files with 626 additions and 423 deletions

View File

@@ -0,0 +1,334 @@
import {
EdgelessCRUDIdentifier,
TextUtils,
} from '@blocksuite/affine-block-surface';
import type { RichText } from '@blocksuite/affine-components/rich-text';
import type { ConnectorElementModel } from '@blocksuite/affine-model';
import { ThemeProvider } from '@blocksuite/affine-shared/services';
import { almostEqual } from '@blocksuite/affine-shared/utils';
import {
RANGE_SYNC_EXCLUDE_ATTR,
ShadowlessElement,
} from '@blocksuite/block-std';
import {
assertExists,
Bound,
Vec,
WithDisposable,
} from '@blocksuite/global/utils';
import { css, html, nothing } from 'lit';
import { property, query } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import * as Y from 'yjs';
import type { EdgelessRootBlockComponent } from '../../edgeless-root-block.js';
const HORIZONTAL_PADDING = 2;
const VERTICAL_PADDING = 2;
const BORDER_WIDTH = 1;
export class EdgelessConnectorLabelEditor extends WithDisposable(
ShadowlessElement
) {
static override styles = css`
.edgeless-connector-label-editor {
position: absolute;
left: 0;
top: 0;
transform-origin: center;
z-index: 10;
padding: ${VERTICAL_PADDING}px ${HORIZONTAL_PADDING}px;
border: ${BORDER_WIDTH}px solid var(--affine-primary-color, #1e96eb);
background: var(--affine-background-primary-color, #fff);
border-radius: 2px;
box-shadow: 0px 0px 0px 2px rgba(30, 150, 235, 0.3);
box-sizing: border-box;
overflow: visible;
.inline-editor {
white-space: pre-wrap !important;
outline: none;
}
.inline-editor span {
word-break: normal !important;
overflow-wrap: anywhere !important;
}
.edgeless-connector-label-editor-placeholder {
pointer-events: none;
color: var(--affine-text-disable-color);
white-space: nowrap;
}
}
`;
get crud() {
return this.edgeless.std.get(EdgelessCRUDIdentifier);
}
private _isComposition = false;
private _keeping = false;
private _resizeObserver: ResizeObserver | null = null;
private readonly _updateLabelRect = () => {
const { connector, edgeless } = this;
if (!connector || !edgeless) return;
if (!this.inlineEditorContainer) return;
const newWidth = this.inlineEditorContainer.scrollWidth;
const newHeight = this.inlineEditorContainer.scrollHeight;
const center = connector.getPointByOffsetDistance(
connector.labelOffset.distance
);
const bounds = Bound.fromCenter(center, newWidth, newHeight);
const labelXYWH = bounds.toXYWH();
if (
!connector.labelXYWH ||
labelXYWH.some((p, i) => !almostEqual(p, connector.labelXYWH![i]))
) {
this.crud.updateElement(connector.id, {
labelXYWH,
});
}
};
get inlineEditor() {
assertExists(this.richText.inlineEditor);
return this.richText.inlineEditor;
}
get inlineEditorContainer() {
return this.inlineEditor.rootElement;
}
override connectedCallback() {
super.connectedCallback();
this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true');
}
override disconnectedCallback() {
super.disconnectedCallback();
this._resizeObserver?.disconnect();
this._resizeObserver = null;
}
override firstUpdated() {
const { edgeless, connector } = this;
const { dispatcher } = edgeless;
assertExists(dispatcher);
this._resizeObserver = new ResizeObserver(() => {
this._updateLabelRect();
this.requestUpdate();
});
this._resizeObserver.observe(this.richText);
this.updateComplete
.then(() => {
this.inlineEditor.selectAll();
this.inlineEditor.slots.renderComplete.on(() => {
this.requestUpdate();
});
this.disposables.add(
dispatcher.add('keyDown', ctx => {
const state = ctx.get('keyboardState');
const { key, ctrlKey, metaKey, altKey, shiftKey, isComposing } =
state.raw;
const onlyCmd = (ctrlKey || metaKey) && !altKey && !shiftKey;
const isModEnter = onlyCmd && key === 'Enter';
const isEscape = key === 'Escape';
if (!isComposing && (isModEnter || isEscape)) {
this.inlineEditorContainer?.blur();
edgeless.service.selection.set({
elements: [connector.id],
editing: false,
});
return true;
}
return false;
})
);
this.disposables.add(
edgeless.service.surface.elementUpdated.on(({ id }) => {
if (id === connector.id) this.requestUpdate();
})
);
this.disposables.add(
edgeless.service.viewport.viewportUpdated.on(() => {
this.requestUpdate();
})
);
this.disposables.add(dispatcher.add('click', () => true));
this.disposables.add(dispatcher.add('doubleClick', () => true));
this.disposables.add(() => {
if (connector.text) {
const text = connector.text.toString();
const trimed = text.trim();
const len = trimed.length;
if (len === 0) {
// reset
this.crud.updateElement(connector.id, {
text: undefined,
labelXYWH: undefined,
labelOffset: undefined,
});
} else if (len < text.length) {
this.crud.updateElement(connector.id, {
// @TODO: trim in Y.Text?
text: new Y.Text(trimed),
});
}
}
connector.lableEditing = false;
edgeless.service.selection.set({
elements: [],
editing: false,
});
});
if (!this.inlineEditorContainer) return;
this.disposables.addFromEvent(
this.inlineEditorContainer,
'blur',
() => {
if (this._keeping) return;
this.remove();
}
);
this.disposables.addFromEvent(
this.inlineEditorContainer,
'compositionstart',
() => {
this._isComposition = true;
this.requestUpdate();
}
);
this.disposables.addFromEvent(
this.inlineEditorContainer,
'compositionend',
() => {
this._isComposition = false;
this.requestUpdate();
}
);
connector.lableEditing = true;
})
.catch(console.error);
}
override async getUpdateComplete(): Promise<boolean> {
const result = await super.getUpdateComplete();
await this.richText?.updateComplete;
return result;
}
override render() {
const { connector } = this;
const {
labelOffset: { distance },
labelStyle: {
fontFamily,
fontSize,
fontStyle,
fontWeight,
textAlign,
color: labelColor,
},
labelConstraints: { hasMaxWidth, maxWidth },
} = connector;
const lineHeight = TextUtils.getLineHeight(
fontFamily,
fontSize,
fontWeight
);
const { translateX, translateY, zoom } = this.edgeless.service.viewport;
const [x, y] = Vec.mul(connector.getPointByOffsetDistance(distance), zoom);
const transformOperation = [
'translate(-50%, -50%)',
`translate(${translateX}px, ${translateY}px)`,
`translate(${x}px, ${y}px)`,
`scale(${zoom})`,
];
const isEmpty = !connector.text?.length && !this._isComposition;
const color = this.edgeless.std
.get(ThemeProvider)
.generateColorProperty(labelColor, '#000000');
return html`
<div
class="edgeless-connector-label-editor"
style=${styleMap({
fontFamily: `"${fontFamily}"`,
fontSize: `${fontSize}px`,
fontStyle,
fontWeight,
textAlign,
lineHeight: `${lineHeight}px`,
maxWidth: hasMaxWidth
? `${maxWidth + BORDER_WIDTH * 2 + HORIZONTAL_PADDING * 2}px`
: 'initial',
color,
transform: transformOperation.join(' '),
})}
>
<rich-text
.yText=${connector.text}
.enableFormat=${false}
style=${isEmpty
? styleMap({
position: 'absolute',
left: 0,
top: 0,
padding: `${VERTICAL_PADDING}px ${HORIZONTAL_PADDING}px`,
})
: nothing}
></rich-text>
${isEmpty
? html`
<span class="edgeless-connector-label-editor-placeholder">
Add text
</span>
`
: nothing}
</div>
`;
}
setKeeping(keeping: boolean) {
this._keeping = keeping;
}
@property({ attribute: false })
accessor connector!: ConnectorElementModel;
@property({ attribute: false })
accessor edgeless!: EdgelessRootBlockComponent;
@query('rich-text')
accessor richText!: RichText;
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-connector-label-editor': EdgelessConnectorLabelEditor;
}
}

View File

@@ -0,0 +1,183 @@
import type { RichText } from '@blocksuite/affine-components/rich-text';
import { FrameBlockModel } from '@blocksuite/affine-model';
import {
AFFINE_FRAME_TITLE_WIDGET,
type AffineFrameTitleWidget,
frameTitleStyleVars,
} from '@blocksuite/affine-widget-frame-title';
import {
RANGE_SYNC_EXCLUDE_ATTR,
ShadowlessElement,
} from '@blocksuite/block-std';
import { assertExists, Bound, WithDisposable } from '@blocksuite/global/utils';
import { cssVarV2 } from '@toeverything/theme/v2';
import { css, html, nothing } from 'lit';
import { property, query } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import type { EdgelessRootBlockComponent } from '../../edgeless-root-block.js';
export class EdgelessFrameTitleEditor extends WithDisposable(
ShadowlessElement
) {
static override styles = css`
.frame-title-editor {
display: flex;
align-items: center;
transform-origin: top left;
border-radius: 4px;
width: fit-content;
padding: 0 4px;
outline: none;
z-index: 1;
border: 1px solid var(--affine-primary-color);
box-shadow: 0px 0px 0px 2px rgba(30, 150, 235, 0.3);
overflow: hidden;
font-family: var(--affine-font-family);
}
`;
get editorHost() {
return this.edgeless.host;
}
get inlineEditor() {
return this.richText?.inlineEditor;
}
private _unmount() {
// dispose in advance to avoid execute `this.remove()` twice
this.disposables.dispose();
this.edgeless.service.selection.set({
elements: [],
editing: false,
});
this.remove();
}
override connectedCallback() {
super.connectedCallback();
this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true');
}
override firstUpdated(): void {
const dispatcher = this.edgeless.dispatcher;
assertExists(dispatcher);
this.updateComplete
.then(() => {
if (!this.inlineEditor) return;
this.inlineEditor.selectAll();
this.inlineEditor.slots.renderComplete.on(() => {
this.requestUpdate();
});
this.disposables.add(
dispatcher.add('keyDown', ctx => {
const state = ctx.get('keyboardState');
if (state.raw.key === 'Enter' && !state.raw.isComposing) {
this._unmount();
return true;
}
requestAnimationFrame(() => {
this.requestUpdate();
});
return false;
})
);
this.disposables.add(
this.edgeless.service.viewport.viewportUpdated.on(() => {
this.requestUpdate();
})
);
this.disposables.add(dispatcher.add('click', () => true));
this.disposables.add(dispatcher.add('doubleClick', () => true));
if (!this.inlineEditor.rootElement) return;
this.disposables.addFromEvent(
this.inlineEditor.rootElement,
'blur',
() => {
this._unmount();
}
);
})
.catch(console.error);
}
override async getUpdateComplete(): Promise<boolean> {
const result = await super.getUpdateComplete();
await this.richText?.updateComplete;
return result;
}
override render() {
const rootBlockId = this.editorHost.doc.root?.id;
if (!rootBlockId) return nothing;
const viewport = this.edgeless.service.viewport;
const bound = Bound.deserialize(this.frameModel.xywh);
const [x, y] = viewport.toViewCoord(bound.x, bound.y);
const isInner = this.edgeless.service.gfx.grid.has(
this.frameModel.elementBound,
true,
true,
model => model !== this.frameModel && model instanceof FrameBlockModel
);
const frameTitleWidget = this.edgeless.std.view.getWidget(
AFFINE_FRAME_TITLE_WIDGET,
rootBlockId
) as AffineFrameTitleWidget | null;
if (!frameTitleWidget) return nothing;
const frameTitle = frameTitleWidget.getFrameTitle(this.frameModel);
const colors = frameTitle?.colors ?? {
background: cssVarV2('edgeless/frame/background/white'),
text: 'var(--affine-text-primary-color)',
};
const inlineEditorStyle = styleMap({
fontSize: frameTitleStyleVars.fontSize + 'px',
position: 'absolute',
left: (isInner ? x + 4 : x) + 'px',
top: (isInner ? y + 4 : y - (frameTitleStyleVars.height + 8 / 2)) + 'px',
minWidth: '8px',
height: frameTitleStyleVars.height + 'px',
background: colors.background,
color: colors.text,
});
const richTextStyle = styleMap({
height: 'fit-content',
});
return html`<div class="frame-title-editor" style=${inlineEditorStyle}>
<rich-text
.yText=${this.frameModel.title.yText}
.enableFormat=${false}
.enableAutoScrollHorizontally=${false}
style=${richTextStyle}
></rich-text>
</div>`;
}
@property({ attribute: false })
accessor edgeless!: EdgelessRootBlockComponent;
@property({ attribute: false })
accessor frameModel!: FrameBlockModel;
@query('rich-text')
accessor richText: RichText | null = null;
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-frame-title-editor': EdgelessFrameTitleEditor;
}
}

View File

@@ -0,0 +1,153 @@
import {
GROUP_TITLE_FONT_SIZE,
GROUP_TITLE_OFFSET,
GROUP_TITLE_PADDING,
} from '@blocksuite/affine-block-surface';
import type { RichText } from '@blocksuite/affine-components/rich-text';
import type { GroupElementModel } from '@blocksuite/affine-model';
import {
RANGE_SYNC_EXCLUDE_ATTR,
ShadowlessElement,
} from '@blocksuite/block-std';
import { assertExists, Bound, WithDisposable } from '@blocksuite/global/utils';
import { html, nothing } from 'lit';
import { property, query } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import type { EdgelessRootBlockComponent } from '../../edgeless-root-block.js';
export class EdgelessGroupTitleEditor extends WithDisposable(
ShadowlessElement
) {
get inlineEditor() {
assertExists(this.richText.inlineEditor);
return this.richText.inlineEditor;
}
get inlineEditorContainer() {
return this.inlineEditor.rootElement;
}
private _unmount() {
// dispose in advance to avoid execute `this.remove()` twice
this.disposables.dispose();
this.group.showTitle = true;
this.edgeless.service.selection.set({
elements: [this.group.id],
editing: false,
});
this.remove();
}
override connectedCallback() {
super.connectedCallback();
this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true');
}
override firstUpdated(): void {
const dispatcher = this.edgeless.dispatcher;
assertExists(dispatcher);
this.updateComplete
.then(() => {
this.inlineEditor.selectAll();
this.group.showTitle = false;
this.inlineEditor.slots.renderComplete.on(() => {
this.requestUpdate();
});
this.disposables.add(
dispatcher.add('keyDown', ctx => {
const state = ctx.get('keyboardState');
if (state.raw.key === 'Enter' && !state.raw.isComposing) {
this._unmount();
return true;
}
requestAnimationFrame(() => {
this.requestUpdate();
});
return false;
})
);
this.disposables.add(
this.edgeless.service.viewport.viewportUpdated.on(() => {
this.requestUpdate();
})
);
this.disposables.add(dispatcher.add('click', () => true));
this.disposables.add(dispatcher.add('doubleClick', () => true));
if (!this.inlineEditorContainer) return;
this.disposables.addFromEvent(
this.inlineEditorContainer,
'blur',
() => {
this._unmount();
}
);
})
.catch(console.error);
}
override async getUpdateComplete(): Promise<boolean> {
const result = await super.getUpdateComplete();
await this.richText?.updateComplete;
return result;
}
override render() {
if (!this.group.externalXYWH) {
console.error('group.externalXYWH is not set');
return nothing;
}
const viewport = this.edgeless.service.viewport;
const bound = Bound.deserialize(this.group.externalXYWH);
const [x, y] = viewport.toViewCoord(bound.x, bound.y);
const inlineEditorStyle = styleMap({
transformOrigin: 'top left',
borderRadius: '2px',
width: 'fit-content',
maxHeight: '30px',
height: 'fit-content',
padding: `${GROUP_TITLE_PADDING[1]}px ${GROUP_TITLE_PADDING[0]}px`,
fontSize: GROUP_TITLE_FONT_SIZE + 'px',
position: 'absolute',
left: x + 'px',
top: `${y - GROUP_TITLE_OFFSET + 2}px`,
minWidth: '8px',
fontFamily: 'var(--affine-font-family)',
color: 'var(--affine-text-primary-color)',
background: 'var(--affine-white-10)',
outline: 'none',
zIndex: '1',
border: `1px solid
var(--affine-primary-color)`,
boxShadow: 'var(--affine-active-shadow)',
});
return html`<rich-text
.yText=${this.group.title}
.enableFormat=${false}
.enableAutoScrollHorizontally=${false}
style=${inlineEditorStyle}
></rich-text>`;
}
@property({ attribute: false })
accessor edgeless!: EdgelessRootBlockComponent;
@property({ attribute: false })
accessor group!: GroupElementModel;
@query('rich-text')
accessor richText!: RichText;
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-group-title-editor': EdgelessGroupTitleEditor;
}
}

View File

@@ -0,0 +1,374 @@
import {
EdgelessCRUDIdentifier,
TextUtils,
} from '@blocksuite/affine-block-surface';
import type { RichText } from '@blocksuite/affine-components/rich-text';
import type { ShapeElementModel } from '@blocksuite/affine-model';
import { MindmapElementModel, TextResizing } from '@blocksuite/affine-model';
import { ThemeProvider } from '@blocksuite/affine-shared/services';
import { getSelectedRect } from '@blocksuite/affine-shared/utils';
import {
RANGE_SYNC_EXCLUDE_ATTR,
ShadowlessElement,
} from '@blocksuite/block-std';
import {
assertExists,
Bound,
toRadian,
Vec,
WithDisposable,
} from '@blocksuite/global/utils';
import { html, nothing } from 'lit';
import { property, query } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import * as Y from 'yjs';
import type { EdgelessRootBlockComponent } from '../../edgeless-root-block.js';
export class EdgelessShapeTextEditor extends WithDisposable(ShadowlessElement) {
get crud() {
return this.edgeless.std.get(EdgelessCRUDIdentifier);
}
private _keeping = false;
private _lastXYWH = '';
private _resizeObserver: ResizeObserver | null = null;
get inlineEditor() {
assertExists(this.richText.inlineEditor);
return this.richText.inlineEditor;
}
get inlineEditorContainer() {
return this.inlineEditor.rootElement;
}
get isMindMapNode() {
return this.element.group instanceof MindmapElementModel;
}
private _initMindmapKeyBindings() {
if (!this.isMindMapNode) {
return;
}
const service = this.edgeless.service;
this._disposables.addFromEvent(this, 'keydown', evt => {
switch (evt.key) {
case 'Enter': {
evt.stopPropagation();
if (evt.shiftKey || evt.isComposing) return;
(this.ownerDocument.activeElement as HTMLElement).blur();
service.selection.set({
elements: [this.element.id],
editing: false,
});
break;
}
case 'Esc':
case 'Tab': {
evt.stopPropagation();
(this.ownerDocument.activeElement as HTMLElement).blur();
service.selection.set({
elements: [this.element.id],
editing: false,
});
break;
}
}
});
}
private _stashMindMapTree() {
if (!this.isMindMapNode) {
return;
}
const mindmap = this.element.group as MindmapElementModel;
const pop = mindmap.stashTree(mindmap.tree);
this._disposables.add(() => {
mindmap.layout();
pop?.();
});
}
private _unmount() {
this._resizeObserver?.disconnect();
this._resizeObserver = null;
if (this.element.text) {
const text = this.element.text.toString();
const trimed = text.trim();
const len = trimed.length;
if (len === 0) {
this.element.text = undefined;
} else if (len < text.length) {
this.element.text = new Y.Text(trimed);
}
}
this.element.textDisplay = true;
this.remove();
this.edgeless.service.selection.set({
elements: [],
editing: false,
});
}
private _updateElementWH() {
const bcr = this.richText.getBoundingClientRect();
const containerHeight = this.richText.offsetHeight;
const containerWidth = this.richText.offsetWidth;
const textResizing = this.element.textResizing;
if (
(containerHeight !== this.element.h &&
textResizing === TextResizing.AUTO_HEIGHT) ||
(textResizing === TextResizing.AUTO_WIDTH_AND_HEIGHT &&
(containerWidth !== this.element.w ||
containerHeight !== this.element.h))
) {
const [leftTopX, leftTopY] = Vec.rotWith(
[this.richText.offsetLeft, this.richText.offsetTop],
[bcr.left + bcr.width / 2, bcr.top + bcr.height / 2],
toRadian(-this.element.rotate)
);
const [modelLeftTopX, modelLeftTopY] =
this.edgeless.service.viewport.toModelCoord(leftTopX, leftTopY);
this.crud.updateElement(this.element.id, {
xywh: new Bound(
modelLeftTopX,
modelLeftTopY,
textResizing === TextResizing.AUTO_WIDTH_AND_HEIGHT
? containerWidth
: this.element.w,
containerHeight
).serialize(),
});
if (this._lastXYWH !== this.element.xywh) {
this.requestUpdate();
}
if (this.isMindMapNode) {
const mindmap = this.element.group as MindmapElementModel;
mindmap.layout();
}
this.richText.style.minHeight = `${containerHeight}px`;
}
this.edgeless.service.selection.set({
elements: [this.element.id],
editing: true,
});
}
override connectedCallback() {
super.connectedCallback();
this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true');
}
override firstUpdated(): void {
const dispatcher = this.edgeless.dispatcher;
assertExists(dispatcher);
this.element.textDisplay = false;
this.disposables.add(
this.edgeless.service.viewport.viewportUpdated.on(() => {
this.requestUpdate();
this.updateComplete
.then(() => {
this._updateElementWH();
})
.catch(console.error);
})
);
this.disposables.add(
dispatcher.add('click', () => {
return true;
})
);
this.disposables.add(
dispatcher.add('doubleClick', () => {
return true;
})
);
this.updateComplete
.then(() => {
if (this.element.group instanceof MindmapElementModel) {
this.inlineEditor.selectAll();
} else {
this.inlineEditor.focusEnd();
}
this.disposables.add(
this.inlineEditor.slots.renderComplete.on(() => {
this._updateElementWH();
})
);
if (!this.inlineEditorContainer) return;
this.disposables.addFromEvent(
this.inlineEditorContainer,
'blur',
() => {
if (this._keeping) return;
this._unmount();
}
);
})
.catch(console.error);
this.disposables.addFromEvent(this, 'keydown', evt => {
if (evt.key === 'Escape') {
requestAnimationFrame(() => {
this.edgeless.service.selection.set({
elements: [this.element.id],
editing: false,
});
});
(this.ownerDocument.activeElement as HTMLElement).blur();
}
});
this._initMindmapKeyBindings();
this._stashMindMapTree();
}
override async getUpdateComplete(): Promise<boolean> {
const result = await super.getUpdateComplete();
await this.richText?.updateComplete;
return result;
}
override render() {
if (!this.element.text) {
console.error('Failed to mount shape editor because of no text.');
return nothing;
}
const [verticalPadding, horiPadding] = this.element.padding;
const textResizing = this.element.textResizing;
const viewport = this.edgeless.service.viewport;
const zoom = viewport.zoom;
const rect = getSelectedRect([this.element]);
const rotate = this.element.rotate;
const [leftTopX, leftTopY] = Vec.rotWith(
[rect.left, rect.top],
[rect.left + rect.width / 2, rect.top + rect.height / 2],
toRadian(rotate)
);
const [x, y] = this.edgeless.service.viewport.toViewCoord(
leftTopX,
leftTopY
);
const autoWidth = textResizing === TextResizing.AUTO_WIDTH_AND_HEIGHT;
const color = this.edgeless.std
.get(ThemeProvider)
.generateColorProperty(this.element.color, '#000000');
const inlineEditorStyle = styleMap({
position: 'absolute',
left: x + 'px',
top: y + 'px',
width:
textResizing === TextResizing.AUTO_HEIGHT
? rect.width + 'px'
: 'fit-content',
// override rich-text style (height: 100%)
height: 'initial',
minHeight:
textResizing === TextResizing.AUTO_WIDTH_AND_HEIGHT
? '1em'
: `${rect.height}px`,
maxWidth:
textResizing === TextResizing.AUTO_WIDTH_AND_HEIGHT
? this.element.maxWidth
? `${this.element.maxWidth}px`
: undefined
: undefined,
boxSizing: 'border-box',
fontSize: this.element.fontSize + 'px',
fontFamily: TextUtils.wrapFontFamily(this.element.fontFamily),
fontWeight: this.element.fontWeight,
lineHeight: 'normal',
outline: 'none',
transform: `scale(${zoom}, ${zoom}) rotate(${rotate}deg)`,
transformOrigin: 'top left',
color,
padding: `${verticalPadding}px ${horiPadding}px`,
textAlign: this.element.textAlign,
display: 'grid',
gridTemplateColumns: '100%',
alignItems:
this.element.textVerticalAlign === 'center'
? 'center'
: this.element.textVerticalAlign === 'bottom'
? 'end'
: 'start',
alignContent: 'center',
gap: '0',
zIndex: '1',
});
this._lastXYWH = this.element.xywh;
return html` <style>
edgeless-shape-text-editor v-text [data-v-text] {
overflow-wrap: ${autoWidth ? 'normal' : 'anywhere'};
word-break: ${autoWidth ? 'normal' : 'break-word'} !important;
white-space: ${autoWidth ? 'pre' : 'pre-wrap'} !important;
}
edgeless-shape-text-editor .inline-editor {
min-width: 1px;
}
</style>
<rich-text
.yText=${this.element.text}
.enableFormat=${false}
.enableAutoScrollHorizontally=${false}
style=${inlineEditorStyle}
></rich-text>`;
}
setKeeping(keeping: boolean) {
this._keeping = keeping;
}
@property({ attribute: false })
accessor edgeless!: EdgelessRootBlockComponent;
@property({ attribute: false })
accessor element!: ShapeElementModel;
@property({ attribute: false })
accessor mountEditor:
| ((
element: ShapeElementModel,
edgeless: EdgelessRootBlockComponent
) => void)
| undefined = undefined;
@query('rich-text')
accessor richText!: RichText;
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-shape-text-editor': EdgelessShapeTextEditor;
}
}

View File

@@ -0,0 +1,427 @@
import {
EdgelessCRUDIdentifier,
TextUtils,
} from '@blocksuite/affine-block-surface';
import type { RichText } from '@blocksuite/affine-components/rich-text';
import type { TextElementModel } from '@blocksuite/affine-model';
import { ThemeProvider } from '@blocksuite/affine-shared/services';
import { getSelectedRect } from '@blocksuite/affine-shared/utils';
import {
RANGE_SYNC_EXCLUDE_ATTR,
ShadowlessElement,
} from '@blocksuite/block-std';
import {
assertExists,
Bound,
toRadian,
Vec,
WithDisposable,
} from '@blocksuite/global/utils';
import { css, html, nothing } from 'lit';
import { property, query } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import type { EdgelessRootBlockComponent } from '../../edgeless-root-block.js';
import { deleteElements } from '../../utils/crud.js';
export class EdgelessTextEditor extends WithDisposable(ShadowlessElement) {
get crud() {
return this.edgeless.std.get(EdgelessCRUDIdentifier);
}
static BORDER_WIDTH = 1;
static PADDING_HORIZONTAL = 10;
static PADDING_VERTICAL = 6;
static PLACEHOLDER_TEXT = 'Type from here';
static override styles = css`
.edgeless-text-editor {
position: absolute;
left: 0;
top: 0;
z-index: 10;
transform-origin: left top;
font-kerning: none;
border: ${EdgelessTextEditor.BORDER_WIDTH}px solid
var(--affine-primary-color, #1e96eb);
border-radius: 4px;
box-shadow: 0px 0px 0px 2px rgba(30, 150, 235, 0.3);
padding: ${EdgelessTextEditor.PADDING_VERTICAL}px
${EdgelessTextEditor.PADDING_HORIZONTAL}px;
overflow: visible;
}
.edgeless-text-editor .inline-editor {
white-space: pre-wrap !important;
outline: none;
}
.edgeless-text-editor .inline-editor span {
word-break: normal !important;
overflow-wrap: anywhere !important;
}
.edgeless-text-editor-placeholder {
pointer-events: none;
color: var(--affine-text-disable-color);
white-space: nowrap;
}
`;
private _isComposition = false;
private _keeping = false;
private readonly _updateRect = () => {
const edgeless = this.edgeless;
const element = this.element;
if (!edgeless || !element || !this.inlineEditorContainer) return;
const newWidth = this.inlineEditorContainer.scrollWidth;
const newHeight = this.inlineEditorContainer.scrollHeight;
const bound = new Bound(element.x, element.y, newWidth, newHeight);
const { x, y, w, h, rotate } = element;
switch (element.textAlign) {
case 'left':
{
const newPos = this.getCoordsOnLeftAlign(
{
x,
y,
w,
h,
r: toRadian(rotate),
},
newWidth,
newHeight
);
bound.x = newPos.x;
bound.y = newPos.y;
}
break;
case 'center':
{
const newPos = this.getCoordsOnCenterAlign(
{
x,
y,
w,
h,
r: toRadian(rotate),
},
newWidth,
newHeight
);
bound.x = newPos.x;
bound.y = newPos.y;
}
break;
case 'right':
{
const newPos = this.getCoordsOnRightAlign(
{
x,
y,
w,
h,
r: toRadian(rotate),
},
newWidth,
newHeight
);
bound.x = newPos.x;
bound.y = newPos.y;
}
break;
}
this.crud.updateElement(element.id, {
xywh: bound.serialize(),
});
};
get inlineEditor() {
assertExists(this.richText.inlineEditor);
return this.richText.inlineEditor;
}
get inlineEditorContainer() {
return this.inlineEditor.rootElement;
}
override connectedCallback(): void {
super.connectedCallback();
if (!this.edgeless) {
console.error('edgeless is not set.');
return;
}
if (!this.element) {
console.error('text element is not set.');
return;
}
this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true');
}
override firstUpdated(): void {
const edgeless = this.edgeless;
const element = this.element;
const { dispatcher } = this.edgeless;
assertExists(dispatcher);
this.updateComplete
.then(() => {
this.inlineEditor.slots.renderComplete.on(() => {
this._updateRect();
this.requestUpdate();
});
this.disposables.add(
edgeless.service.surface.elementUpdated.on(({ id }) => {
if (id === element.id) this.requestUpdate();
})
);
this.disposables.add(
edgeless.service.viewport.viewportUpdated.on(() => {
this.requestUpdate();
})
);
this.disposables.add(dispatcher.add('click', () => true));
this.disposables.add(dispatcher.add('doubleClick', () => true));
this.disposables.add(() => {
element.display = true;
if (element.text.length === 0) {
deleteElements(edgeless, [element]);
}
edgeless.service.selection.set({
elements: [],
editing: false,
});
});
if (!this.inlineEditorContainer) return;
this.disposables.addFromEvent(
this.inlineEditorContainer,
'blur',
() => !this._keeping && this.remove()
);
this.disposables.addFromEvent(
this.inlineEditorContainer,
'compositionstart',
() => {
this._isComposition = true;
this.requestUpdate();
}
);
this.disposables.addFromEvent(
this.inlineEditorContainer,
'compositionend',
() => {
this._isComposition = false;
this.requestUpdate();
}
);
element.display = false;
})
.catch(console.error);
}
getContainerOffset() {
const { PADDING_VERTICAL, PADDING_HORIZONTAL, BORDER_WIDTH } =
EdgelessTextEditor;
return `-${PADDING_HORIZONTAL + BORDER_WIDTH}px, -${
PADDING_VERTICAL + BORDER_WIDTH
}px`;
}
getCoordsOnCenterAlign(
rect: { w: number; h: number; r: number; x: number; y: number },
w1: number,
h1: number
): { x: number; y: number } {
const centerX = rect.x + rect.w / 2;
const centerY = rect.y + rect.h / 2;
let deltaXPrime = 0;
let deltaYPrime = (-rect.h / 2) * Math.cos(rect.r);
const vX = centerX + deltaXPrime;
const vY = centerY + deltaYPrime;
deltaXPrime = 0;
deltaYPrime = (-h1 / 2) * Math.cos(rect.r);
const newCenterX = vX - deltaXPrime;
const newCenterY = vY - deltaYPrime;
return { x: newCenterX - w1 / 2, y: newCenterY - h1 / 2 };
}
getCoordsOnLeftAlign(
rect: { w: number; h: number; r: number; x: number; y: number },
w1: number,
h1: number
): { x: number; y: number } {
const cX = rect.x + rect.w / 2;
const cY = rect.y + rect.h / 2;
let deltaXPrime =
(-rect.w / 2) * Math.cos(rect.r) + (rect.h / 2) * Math.sin(rect.r);
let deltaYPrime =
(-rect.w / 2) * Math.sin(rect.r) - (rect.h / 2) * Math.cos(rect.r);
const vX = cX + deltaXPrime;
const vY = cY + deltaYPrime;
deltaXPrime = (-w1 / 2) * Math.cos(rect.r) + (h1 / 2) * Math.sin(rect.r);
deltaYPrime = (-w1 / 2) * Math.sin(rect.r) - (h1 / 2) * Math.cos(rect.r);
const newCenterX = vX - deltaXPrime;
const newCenterY = vY - deltaYPrime;
return { x: newCenterX - w1 / 2, y: newCenterY - h1 / 2 };
}
getCoordsOnRightAlign(
rect: { w: number; h: number; r: number; x: number; y: number },
w1: number,
h1: number
): { x: number; y: number } {
const centerX = rect.x + rect.w / 2;
const centerY = rect.y + rect.h / 2;
let deltaXPrime =
(rect.w / 2) * Math.cos(rect.r) - (-rect.h / 2) * Math.sin(rect.r);
let deltaYPrime =
(rect.w / 2) * Math.sin(rect.r) + (-rect.h / 2) * Math.cos(rect.r);
const vX = centerX + deltaXPrime;
const vY = centerY + deltaYPrime;
deltaXPrime = (w1 / 2) * Math.cos(rect.r) - (-h1 / 2) * Math.sin(rect.r);
deltaYPrime = (w1 / 2) * Math.sin(rect.r) + (-h1 / 2) * Math.cos(rect.r);
const newCenterX = vX - deltaXPrime;
const newCenterY = vY - deltaYPrime;
return { x: newCenterX - w1 / 2, y: newCenterY - h1 / 2 };
}
override async getUpdateComplete(): Promise<boolean> {
const result = await super.getUpdateComplete();
await this.richText?.updateComplete;
return result;
}
getVisualPosition(element: TextElementModel) {
const { x, y, w, h, rotate } = element;
return Vec.rotWith([x, y], [x + w / 2, y + h / 2], toRadian(rotate));
}
override render() {
const {
text,
fontFamily,
fontSize,
fontWeight,
fontStyle,
textAlign,
rotate,
hasMaxWidth,
w,
} = this.element;
const lineHeight = TextUtils.getLineHeight(
fontFamily,
fontSize,
fontWeight
);
const rect = getSelectedRect([this.element]);
const { translateX, translateY, zoom } = this.edgeless.service.viewport;
const [visualX, visualY] = this.getVisualPosition(this.element);
const containerOffset = this.getContainerOffset();
const transformOperation = [
`translate(${translateX}px, ${translateY}px)`,
`translate(${visualX * zoom}px, ${visualY * zoom}px)`,
`scale(${zoom})`,
`rotate(${rotate}deg)`,
`translate(${containerOffset})`,
];
const isEmpty = !text.length && !this._isComposition;
const color = this.edgeless.std
.get(ThemeProvider)
.generateColorProperty(this.element.color, '#000000');
return html`<div
style=${styleMap({
transform: transformOperation.join(' '),
minWidth: hasMaxWidth ? `${rect.width}px` : 'none',
maxWidth: hasMaxWidth ? `${w}px` : 'none',
fontFamily: TextUtils.wrapFontFamily(fontFamily),
fontSize: `${fontSize}px`,
fontWeight,
fontStyle,
color,
textAlign,
lineHeight: `${lineHeight}px`,
boxSizing: 'content-box',
})}
class="edgeless-text-editor"
>
<rich-text
.yText=${text}
.enableFormat=${false}
.enableAutoScrollHorizontally=${false}
style=${isEmpty
? styleMap({
position: 'absolute',
left: 0,
top: 0,
padding: `${EdgelessTextEditor.PADDING_VERTICAL}px
${EdgelessTextEditor.PADDING_HORIZONTAL}px`,
})
: nothing}
></rich-text>
${isEmpty
? html`<span class="edgeless-text-editor-placeholder">
Type from here
</span>`
: nothing}
</div>`;
}
setKeeping(keeping: boolean) {
this._keeping = keeping;
}
@property({ attribute: false })
accessor edgeless!: EdgelessRootBlockComponent;
@property({ attribute: false })
accessor element!: TextElementModel;
@query('rich-text')
accessor richText!: RichText;
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-text-editor': EdgelessTextEditor;
}
}