refactor(editor): gfx text package (#10738)

This commit is contained in:
Saul-Mirone
2025-03-10 10:25:21 +00:00
parent 0cdec6957b
commit cac05e720a
21 changed files with 260 additions and 119 deletions

View File

@@ -30,6 +30,7 @@
"@blocksuite/affine-block-table": "workspace:*",
"@blocksuite/affine-components": "workspace:*",
"@blocksuite/affine-fragment-doc-title": "workspace:*",
"@blocksuite/affine-gfx-text": "workspace:*",
"@blocksuite/affine-model": "workspace:*",
"@blocksuite/affine-rich-text": "workspace:*",
"@blocksuite/affine-shared": "workspace:*",

View File

@@ -4,6 +4,7 @@ import {
EdgelessCRUDIdentifier,
} from '@blocksuite/affine-block-surface';
import { FontFamilyIcon } from '@blocksuite/affine-components/icons';
import { mountTextElementEditor } from '@blocksuite/affine-gfx-text';
import type {
Connection,
ConnectorElementModel,
@@ -56,10 +57,7 @@ import {
SHAPE_OVERLAY_HEIGHT,
SHAPE_OVERLAY_WIDTH,
} from '../../utils/consts.js';
import {
mountShapeTextEditor,
mountTextElementEditor,
} from '../../utils/text.js';
import { mountShapeTextEditor } from '../../utils/text.js';
import { ShapeComponentConfig } from '../toolbar/shape/shape-menu-config.js';
import {
type AUTO_COMPLETE_TARGET_TYPE,

View File

@@ -1,415 +0,0 @@
import {
EdgelessCRUDIdentifier,
TextUtils,
} from '@blocksuite/affine-block-surface';
import type { TextElementModel } from '@blocksuite/affine-model';
import type { RichText } from '@blocksuite/affine-rich-text';
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 { Bound, toRadian, Vec } from '@blocksuite/global/gfx';
import { WithDisposable } from '@blocksuite/global/lit';
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() {
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;
this.updateComplete
.then(() => {
if (!this.inlineEditor) return;
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;
}

View File

@@ -2,6 +2,7 @@ import { addAttachments } from '@blocksuite/affine-block-attachment';
import { insertEdgelessTextCommand } from '@blocksuite/affine-block-edgeless-text';
import { addImages } from '@blocksuite/affine-block-image';
import { CanvasElementType } from '@blocksuite/affine-block-surface';
import { mountTextElementEditor } from '@blocksuite/affine-gfx-text';
import {
MAX_IMAGE_WIDTH,
type MindmapStyle,
@@ -18,7 +19,6 @@ import * as Y from 'yjs';
import type { EdgelessRootBlockComponent } from '../../../edgeless-root-block.js';
import type { EdgelessRootService } from '../../../edgeless-root-service.js';
import { mountTextElementEditor } from '../../../utils/text.js';
export type ConfigProperty = 'x' | 'y' | 'r' | 's' | 'z' | 'o';
export type ConfigState = 'default' | 'active' | 'hover' | 'next';

View File

@@ -4,6 +4,7 @@ import {
PresentTool,
} from '@blocksuite/affine-block-frame';
import { ConnectionOverlay } from '@blocksuite/affine-block-surface';
import { TextTool } from '@blocksuite/affine-gfx-text';
import type { ExtensionType } from '@blocksuite/store';
import { EdgelessRootBlockSpec } from './edgeless-root-spec.js';
@@ -19,7 +20,6 @@ import { NoteTool } from './gfx-tool/note-tool.js';
import { PanTool } from './gfx-tool/pan-tool.js';
import { ShapeTool } from './gfx-tool/shape-tool.js';
import { TemplateTool } from './gfx-tool/template-tool.js';
import { TextTool } from './gfx-tool/text-tool.js';
import { EditPropsMiddlewareBuilder } from './middlewares/base.js';
import { SnapManager } from './utils/snap-manager.js';

View File

@@ -9,6 +9,7 @@ import {
isNoteBlock,
OverlayIdentifier,
} from '@blocksuite/affine-block-surface';
import { addText, mountTextElementEditor } from '@blocksuite/affine-gfx-text';
import type {
EdgelessTextBlockModel,
FrameBlockModel,
@@ -55,12 +56,10 @@ import { calPanDelta } from '../utils/panning-utils.js';
import { isCanvasElement, isEdgelessTextBlock } from '../utils/query.js';
import type { SnapManager } from '../utils/snap-manager.js';
import {
addText,
mountConnectorLabelEditor,
mountFrameTitleEditor,
mountGroupTitleEditor,
mountShapeTextEditor,
mountTextElementEditor,
} from '../utils/text.js';
import { CanvasElementEventExt } from './default-tool-ext/event-ext.js';
import type { DefaultToolExt } from './default-tool-ext/ext.js';

View File

@@ -9,4 +9,3 @@ export { NoteTool, type NoteToolOption } from './note-tool.js';
export { PanTool, type PanToolOption } from './pan-tool.js';
export { ShapeTool, type ShapeToolOption } from './shape-tool.js';
export { TemplateTool } from './template-tool.js';
export { TextTool } from './text-tool.js';

View File

@@ -1,71 +0,0 @@
import { insertEdgelessTextCommand } from '@blocksuite/affine-block-edgeless-text';
import type { TextElementModel } from '@blocksuite/affine-model';
import {
FeatureFlagService,
TelemetryProvider,
} from '@blocksuite/affine-shared/services';
import type { PointerEventState } from '@blocksuite/block-std';
import { BaseTool, type GfxController } from '@blocksuite/block-std/gfx';
import { Bound } from '@blocksuite/global/gfx';
import * as Y from 'yjs';
import type { EdgelessRootBlockComponent } from '../edgeless-root-block.js';
import { mountTextElementEditor } from '../utils/text.js';
export function addText(gfx: GfxController, event: PointerEventState) {
const [x, y] = gfx.viewport.toModelCoord(event.x, event.y);
const selected = gfx.getElementByPoint(x, y);
if (!selected) {
const [modelX, modelY] = gfx.viewport.toModelCoord(event.x, event.y);
if (!gfx.surface) {
return;
}
const id = gfx.surface.addElement({
type: 'text',
xywh: new Bound(modelX, modelY, 32, 32).serialize(),
text: new Y.Text(),
});
gfx.doc.captureSync();
const textElement = gfx.getElementById(id) as TextElementModel;
const edgelessView = gfx.std.view.getBlock(gfx.std.store.root!.id);
mountTextElementEditor(
textElement,
edgelessView as EdgelessRootBlockComponent
);
}
}
export class TextTool extends BaseTool {
static override toolName: string = 'text';
override click(e: PointerEventState): void {
const textFlag = this.gfx.doc
.get(FeatureFlagService)
.getFlag('enable_edgeless_text');
if (textFlag) {
const [x, y] = this.gfx.viewport.toModelCoord(e.x, e.y);
this.gfx.std.command.exec(insertEdgelessTextCommand, { x, y });
this.gfx.tool.setTool('default');
} else {
addText(this.gfx, e);
}
this.gfx.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', {
control: 'canvas:draw',
page: 'whiteboard editor',
module: 'toolbar',
segment: 'toolbar',
type: 'text',
});
}
}
declare module '@blocksuite/block-std/gfx' {
interface GfxToolsMap {
text: TextTool;
}
}

View File

@@ -1,16 +1,10 @@
import {
CanvasElementType,
EdgelessCRUDIdentifier,
type IModelCoord,
TextUtils,
} from '@blocksuite/affine-block-surface';
import { EdgelessCRUDIdentifier } from '@blocksuite/affine-block-surface';
import type {
ConnectorElementModel,
FrameBlockModel,
GroupElementModel,
} from '@blocksuite/affine-model';
import { ShapeElementModel, TextElementModel } from '@blocksuite/affine-model';
import type { PointerEventState } from '@blocksuite/block-std';
import { ShapeElementModel } from '@blocksuite/affine-model';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import type { IVec } from '@blocksuite/global/gfx';
import { Bound } from '@blocksuite/global/gfx';
@@ -20,47 +14,8 @@ import { EdgelessConnectorLabelEditor } from '../components/text/edgeless-connec
import { EdgelessFrameTitleEditor } from '../components/text/edgeless-frame-title-editor.js';
import { EdgelessGroupTitleEditor } from '../components/text/edgeless-group-title-editor.js';
import { EdgelessShapeTextEditor } from '../components/text/edgeless-shape-text-editor.js';
import { EdgelessTextEditor } from '../components/text/edgeless-text-editor.js';
import type { EdgelessRootBlockComponent } from '../edgeless-root-block.js';
export function mountTextElementEditor(
textElement: TextElementModel,
edgeless: EdgelessRootBlockComponent,
focusCoord?: IModelCoord
) {
if (!edgeless.mountElm) {
throw new BlockSuiteError(
ErrorCode.ValueNotExists,
"edgeless block's mount point does not exist"
);
}
let cursorIndex = textElement.text.length;
if (focusCoord) {
cursorIndex = Math.min(
TextUtils.getCursorByCoord(textElement, focusCoord),
cursorIndex
);
}
const textEditor = new EdgelessTextEditor();
textEditor.edgeless = edgeless;
textEditor.element = textElement;
edgeless.append(textEditor);
textEditor.updateComplete
.then(() => {
textEditor.inlineEditor?.focusIndex(cursorIndex);
})
.catch(console.error);
edgeless.gfx.tool.setTool('default');
edgeless.gfx.selection.set({
elements: [textElement.id],
editing: true,
});
}
export function mountShapeTextEditor(
shapeElement: ShapeElementModel,
edgeless: EdgelessRootBlockComponent
@@ -145,39 +100,6 @@ export function mountGroupTitleEditor(
});
}
/**
* @deprecated
*
* Canvas Text has been deprecated
*/
export function addText(
edgeless: EdgelessRootBlockComponent,
event: PointerEventState
) {
const [x, y] = edgeless.service.viewport.toModelCoord(event.x, event.y);
const selected = edgeless.service.gfx.getElementByPoint(x, y);
if (!selected) {
const [modelX, modelY] = edgeless.service.viewport.toModelCoord(
event.x,
event.y
);
const id = edgeless.std
.get(EdgelessCRUDIdentifier)
.addElement(CanvasElementType.TEXT, {
xywh: new Bound(modelX, modelY, 32, 32).serialize(),
text: new Y.Text(),
});
if (!id) return;
edgeless.doc.captureSync();
const textElement = edgeless.service.crud.getElementById(id);
if (!textElement) return;
if (textElement instanceof TextElementModel) {
mountTextElementEditor(textElement, edgeless);
}
}
}
export function mountConnectorLabelEditor(
connector: ConnectorElementModel,
edgeless: EdgelessRootBlockComponent,

View File

@@ -1,3 +1,5 @@
import { effects as gfxCanvasTextEffects } from '@blocksuite/affine-gfx-text/effects';
import { EdgelessAutoCompletePanel } from './edgeless/components/auto-complete/auto-complete-panel.js';
import { EdgelessAutoComplete } from './edgeless/components/auto-complete/edgeless-auto-complete.js';
import { EdgelessToolIconButton } from './edgeless/components/buttons/tool-icon-button.js';
@@ -36,7 +38,6 @@ import { EdgelessConnectorLabelEditor } from './edgeless/components/text/edgeles
import { EdgelessFrameTitleEditor } from './edgeless/components/text/edgeless-frame-title-editor.js';
import { EdgelessGroupTitleEditor } from './edgeless/components/text/edgeless-group-title-editor.js';
import { EdgelessShapeTextEditor } from './edgeless/components/text/edgeless-shape-text-editor.js';
import { EdgelessTextEditor } from './edgeless/components/text/edgeless-text-editor.js';
import { EdgelessBrushMenu } from './edgeless/components/toolbar/brush/brush-menu.js';
import { EdgelessBrushToolButton } from './edgeless/components/toolbar/brush/brush-tool-button.js';
import { EdgelessSlideMenu } from './edgeless/components/toolbar/common/slide-menu.js';
@@ -122,6 +123,7 @@ export function effects() {
// Register components by category
registerRootComponents();
registerGfxEffects();
registerWidgets();
registerEdgelessToolbarComponents();
registerEdgelessPanelComponents();
@@ -139,6 +141,10 @@ function registerRootComponents() {
);
}
function registerGfxEffects() {
gfxCanvasTextEffects();
}
function registerWidgets() {
customElements.define(AFFINE_INNER_MODAL_WIDGET, AffineInnerModalWidget);
customElements.define(AFFINE_MODAL_WIDGET, AffineModalWidget);
@@ -258,7 +264,6 @@ function registerEdgelessEditorComponents() {
'edgeless-frame-title-editor',
EdgelessFrameTitleEditor
);
customElements.define('edgeless-text-editor', EdgelessTextEditor);
}
function registerMiscComponents() {
@@ -348,7 +353,6 @@ declare global {
'edgeless-frame-title-editor': EdgelessFrameTitleEditor;
'edgeless-group-title-editor': EdgelessGroupTitleEditor;
'edgeless-shape-text-editor': EdgelessShapeTextEditor;
'edgeless-text-editor': EdgelessTextEditor;
'edgeless-toolbar-widget': EdgelessToolbarWidget;
'presentation-toolbar': PresentationToolbar;
'edgeless-brush-menu': EdgelessBrushMenu;

View File

@@ -24,6 +24,7 @@
{ "path": "../block-table" },
{ "path": "../../components" },
{ "path": "../../fragments/fragment-doc-title" },
{ "path": "../../gfx/text" },
{ "path": "../../model" },
{ "path": "../../rich-text" },
{ "path": "../../shared" },