refactor(editor): extract edgeless text (#9375)

This commit is contained in:
Saul-Mirone
2024-12-27 10:48:12 +00:00
parent 5c4058cd73
commit 80dc0e8271
20 changed files with 165 additions and 36 deletions

View File

@@ -1,8 +1,8 @@
import { EdgelessTextBlockSpec } from '@blocksuite/affine-block-edgeless-text';
import { FrameBlockSpec } from '@blocksuite/affine-block-frame';
import { LatexBlockSpec } from '@blocksuite/affine-block-latex';
import { EdgelessSurfaceBlockSpec } from '@blocksuite/affine-block-surface';
import { EdgelessTextBlockSpec } from '../../edgeless-text-block/index.js';
import { EdgelessRootBlockSpec } from '../../root-block/edgeless/edgeless-root-spec.js';
import { EdgelessSurfaceRefBlockSpec } from '../../surface-ref-block/surface-ref-spec.js';

View File

@@ -1,3 +1,4 @@
import { EdgelessTextBlockSpec } from '@blocksuite/affine-block-edgeless-text';
import { FrameBlockSpec } from '@blocksuite/affine-block-frame';
import { LatexBlockSpec } from '@blocksuite/affine-block-latex';
import {
@@ -7,7 +8,6 @@ import {
import { FontLoaderService } from '@blocksuite/affine-shared/services';
import type { ExtensionType } from '@blocksuite/block-std';
import { EdgelessTextBlockSpec } from '../../edgeless-text-block/edgeless-text-spec.js';
import { EdgelessRootBlockSpec } from '../../root-block/edgeless/edgeless-root-spec.js';
import {
EdgelessFrameManager,

View File

@@ -1,3 +1,4 @@
import { EdgelessTextBlockSpec } from '@blocksuite/affine-block-edgeless-text';
import { FrameBlockSpec } from '@blocksuite/affine-block-frame';
import { LatexBlockSpec } from '@blocksuite/affine-block-latex';
import {
@@ -19,7 +20,6 @@ import {
} from '@blocksuite/block-std';
import { literal } from 'lit/static-html.js';
import { EdgelessTextBlockSpec } from '../../edgeless-text-block/index.js';
import { PreviewEdgelessRootBlockSpec } from '../../root-block/edgeless/edgeless-root-spec.js';
import { PageRootService } from '../../root-block/page/page-root-service.js';
import {

View File

@@ -1,7 +0,0 @@
import type { BlockCommands } from '@blocksuite/block-std';
import { insertEdgelessTextCommand } from './insert-edgeless-text.js';
export const commands: BlockCommands = {
insertEdgelessText: insertEdgelessTextCommand,
};

View File

@@ -1,111 +0,0 @@
import {
EdgelessCRUDIdentifier,
getSurfaceBlock,
} from '@blocksuite/affine-block-surface';
import { focusTextModel } from '@blocksuite/affine-components/rich-text';
import type { Command } from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import { Bound } from '@blocksuite/global/utils';
import {
EDGELESS_TEXT_BLOCK_MIN_HEIGHT,
EDGELESS_TEXT_BLOCK_MIN_WIDTH,
EdgelessTextBlockComponent,
} from '../edgeless-text-block.js';
export const insertEdgelessTextCommand: Command<
never,
'textId',
{
x: number;
y: number;
}
> = (ctx, next) => {
const { std, x, y } = ctx;
const host = std.host;
const doc = host.doc;
const surface = getSurfaceBlock(doc);
if (!surface) {
next();
return;
}
const gfx = std.get(GfxControllerIdentifier);
const zoom = gfx.viewport.zoom;
const selection = gfx.selection;
const textId = std.get(EdgelessCRUDIdentifier).addBlock(
'affine:edgeless-text',
{
xywh: new Bound(
x - (EDGELESS_TEXT_BLOCK_MIN_WIDTH * zoom) / 2,
y - (EDGELESS_TEXT_BLOCK_MIN_HEIGHT * zoom) / 2,
EDGELESS_TEXT_BLOCK_MIN_WIDTH * zoom,
EDGELESS_TEXT_BLOCK_MIN_HEIGHT * zoom
).serialize(),
},
surface.id
);
const blockId = doc.addBlock('affine:paragraph', { type: 'text' }, textId);
host.updateComplete
.then(() => {
selection.set({
elements: [textId],
editing: true,
});
const disposable = selection.slots.updated.on(() => {
const editing = selection.editing;
const id = selection.selectedIds[0];
if (!editing || id !== textId) {
const textBlock = host.view.getBlock(textId);
if (textBlock instanceof EdgelessTextBlockComponent) {
textBlock.model.hasMaxWidth = true;
}
disposable.dispose();
}
});
focusTextModel(std, blockId);
host.updateComplete
.then(() => {
const edgelessText = host.view.getBlock(textId);
const paragraph = host.view.getBlock(blockId);
if (!edgelessText || !paragraph) return;
const abortController = new AbortController();
edgelessText.addEventListener(
'focusout',
e => {
if (edgelessText.model.children.length > 1) return;
if (
!paragraph.model.text ||
(paragraph.model.text.length === 0 && e.relatedTarget !== null)
) {
doc.deleteBlock(edgelessText.model);
}
},
{
once: true,
signal: abortController.signal,
}
);
paragraph.model.deleted.once(() => {
abortController.abort();
});
edgelessText.addEventListener(
'beforeinput',
() => {
abortController.abort();
},
{
once: true,
}
);
})
.catch(console.error);
})
.catch(console.error);
next({ textId });
};

View File

@@ -1,347 +0,0 @@
import { TextUtils } from '@blocksuite/affine-block-surface';
import type { EdgelessTextBlockModel } from '@blocksuite/affine-model';
import { ThemeProvider } from '@blocksuite/affine-shared/services';
import { matchFlavours } from '@blocksuite/affine-shared/utils';
import type { BlockComponent } from '@blocksuite/block-std';
import { GfxBlockComponent } from '@blocksuite/block-std';
import { Bound } from '@blocksuite/global/utils';
import { css, html } from 'lit';
import { query, state } from 'lit/decorators.js';
import { type StyleInfo, styleMap } from 'lit/directives/style-map.js';
import type { EdgelessRootService } from '../root-block/index.js';
export const EDGELESS_TEXT_BLOCK_MIN_WIDTH = 50;
export const EDGELESS_TEXT_BLOCK_MIN_HEIGHT = 50;
export class EdgelessTextBlockComponent extends GfxBlockComponent<EdgelessTextBlockModel> {
static override styles = css`
.edgeless-text-block-container[data-max-width='false'] .inline-editor span {
word-break: keep-all !important;
text-wrap: nowrap !important;
}
.edgeless-text-block-container affine-paragraph,
affine-list {
color: var(--edgeless-text-color);
font-family: var(--edgeless-text-font-family);
font-style: var(--edgeless-text-font-style);
font-weight: var(--edgeless-text-font-weight);
text-align: var(--edgeless-text-text-align);
}
`;
private readonly _resizeObserver = new ResizeObserver(() => {
if (this.doc.readonly) {
return;
}
if (!this.model.hasMaxWidth) {
this._updateW();
}
this._updateH();
});
get rootService() {
return this.std.getService('affine:page') as EdgelessRootService;
}
private _updateH() {
const bound = Bound.deserialize(this.model.xywh);
const rect = this._textContainer.getBoundingClientRect();
bound.h = rect.height / this.gfx.viewport.zoom;
this.doc.updateBlock(this.model, {
xywh: bound.serialize(),
});
}
private _updateW() {
const bound = Bound.deserialize(this.model.xywh);
const rect = this._textContainer.getBoundingClientRect();
bound.w = Math.max(
rect.width / this.gfx.viewport.zoom,
EDGELESS_TEXT_BLOCK_MIN_WIDTH * this.gfx.viewport.zoom
);
this.doc.updateBlock(this.model, {
xywh: bound.serialize(),
});
}
checkWidthOverflow(width: number) {
let wValid = true;
const oldWidthStr = this._textContainer.style.width;
this._textContainer.style.width = `${width}px`;
if (
this.childrenContainer.scrollWidth > this.childrenContainer.offsetWidth
) {
wValid = false;
}
this._textContainer.style.width = oldWidthStr;
return wValid;
}
override connectedCallback() {
super.connectedCallback();
this.disposables.add(
this.model.propsUpdated.on(({ key }) => {
this.updateComplete
.then(() => {
if (!this.host) return;
const command = this.host.command;
const blockSelections = this.model.children.map(child =>
this.host.selection.create('block', {
blockId: child.id,
})
);
if (key === 'fontStyle') {
command.exec('formatBlock', {
blockSelections,
styles: {
italic: null,
},
});
} else if (key === 'color') {
command.exec('formatBlock', {
blockSelections,
styles: {
color: null,
},
});
} else if (key === 'fontWeight') {
command.exec('formatBlock', {
blockSelections,
styles: {
bold: null,
},
});
}
})
.catch(console.error);
})
);
}
override firstUpdated(props: Map<string, unknown>) {
super.firstUpdated(props);
const { disposables, rootService } = this;
const edgelessSelection = rootService.selection;
disposables.add(
edgelessSelection.slots.updated.on(() => {
if (edgelessSelection.has(this.model.id) && edgelessSelection.editing) {
this._editing = true;
} else {
this._editing = false;
}
})
);
this._resizeObserver.observe(this._textContainer);
disposables.add(() => {
this._resizeObserver.disconnect();
});
disposables.addFromEvent(this._textContainer, 'click', e => {
if (!this._editing) return;
const containerRect = this._textContainer.getBoundingClientRect();
const isTop = e.clientY < containerRect.top + containerRect.height / 2;
let newParagraphId: string | null = null;
if (isTop) {
const firstChild = this.model.firstChild();
if (
!firstChild ||
!matchFlavours(firstChild, ['affine:list', 'affine:paragraph'])
) {
newParagraphId = this.doc.addBlock(
'affine:paragraph',
{},
this.model.id,
0
);
}
} else {
const lastChild = this.model.lastChild();
if (
!lastChild ||
!matchFlavours(lastChild, ['affine:list', 'affine:paragraph'])
) {
newParagraphId = this.doc.addBlock(
'affine:paragraph',
{},
this.model.id
);
}
}
if (newParagraphId) {
this.rootService.selectionManager.setGroup('note', [
this.rootService.selectionManager.create('text', {
from: {
blockId: newParagraphId,
index: 0,
length: 0,
},
to: null,
}),
]);
}
});
disposables.addFromEvent(this._textContainer, 'focusout', () => {
if (!this._editing) return;
this.rootService.selectionManager.clear();
});
let composingWidth = EDGELESS_TEXT_BLOCK_MIN_WIDTH;
disposables.addFromEvent(this, 'compositionupdate', () => {
composingWidth = Math.max(
this._textContainer.offsetWidth,
EDGELESS_TEXT_BLOCK_MIN_HEIGHT
);
});
disposables.addFromEvent(this, 'compositionend', () => {
if (this.model.hasMaxWidth) {
composingWidth = EDGELESS_TEXT_BLOCK_MIN_WIDTH;
return;
}
// when IME finish container will crash to a small width, so
// we set a max width to prevent this
this._textContainer.style.width = `${composingWidth}px`;
this.model.hasMaxWidth = true;
requestAnimationFrame(() => {
this._textContainer.style.width = '';
});
});
}
override getCSSTransform(): string {
const viewport = this.gfx.viewport;
const { translateX, translateY, zoom } = viewport;
const bound = Bound.deserialize(this.model.xywh);
const scaledX = bound.x * zoom;
const scaledY = bound.y * zoom;
const deltaX = scaledX - bound.x;
const deltaY = scaledY - bound.y;
return `translate(${translateX + deltaX}px, ${translateY + deltaY}px) scale(${zoom * this.model.scale})`;
}
override getRenderingRect() {
const { xywh, scale, rotate, hasMaxWidth } = this.model;
const bound = Bound.deserialize(xywh);
const w = hasMaxWidth ? bound.w / scale : undefined;
return {
x: bound.x,
y: bound.y,
w,
h: bound.h / scale,
rotate,
zIndex: this.toZIndex(),
};
}
override renderGfxBlock() {
const { model } = this;
const { rotate, hasMaxWidth } = model;
const editing = this._editing;
const containerStyle: StyleInfo = {
transform: `rotate(${rotate}deg)`,
transformOrigin: 'center',
border: `1px solid ${editing ? 'var(--affine—primary—color, #1e96eb)' : 'transparent'}`,
borderRadius: '4px',
boxSizing: 'border-box',
boxShadow: editing ? '0px 0px 0px 2px rgba(30, 150, 235, 0.3)' : 'none',
fontWeight: '400',
lineHeight: 'var(--affine-line-height)',
};
return html`
<div
class="edgeless-text-block-container"
data-max-width="${hasMaxWidth}"
style=${styleMap(containerStyle)}
>
<div
style=${styleMap({
pointerEvents: editing ? 'auto' : 'none',
userSelect: editing ? 'auto' : 'none',
})}
contenteditable=${editing}
>
${this.renderPageContent()}
</div>
</div>
`;
}
override renderPageContent() {
const { fontFamily, fontStyle, fontWeight, textAlign } = this.model;
const color = this.std
.get(ThemeProvider)
.generateColorProperty(this.model.color, '#000000');
const style = styleMap({
'--edgeless-text-color': color,
'--edgeless-text-font-family': TextUtils.wrapFontFamily(fontFamily),
'--edgeless-text-font-style': fontStyle,
'--edgeless-text-font-weight': fontWeight,
'--edgeless-text-text-align': textAlign,
'--affine-list-margin': '0',
'--affine-paragraph-margin': '0',
});
return html`
<div style=${style} class="affine-block-children-container">
${this.renderChildren(this.model)}
</div>
`;
}
tryFocusEnd() {
const paragraphOrLists = Array.from(
this.querySelectorAll<BlockComponent>('affine-paragraph, affine-list')
);
const last = paragraphOrLists.at(-1);
if (last) {
this.host.selection.setGroup('note', [
this.host.selection.create('text', {
from: {
blockId: last.blockId,
index: last.model.text?.length ?? 0,
length: 0,
},
to: null,
}),
]);
}
}
@state()
private accessor _editing = false;
@query('.edgeless-text-block-container')
private accessor _textContainer!: HTMLDivElement;
@query('.affine-block-children-container')
accessor childrenContainer!: HTMLDivElement;
}
declare global {
interface HTMLElementTagNameMap {
'affine-edgeless-text': EdgelessTextBlockComponent;
}
}

View File

@@ -1,13 +0,0 @@
import {
BlockViewExtension,
CommandExtension,
type ExtensionType,
} from '@blocksuite/block-std';
import { literal } from 'lit/static-html.js';
import { commands } from './commands/index.js';
export const EdgelessTextBlockSpec: ExtensionType[] = [
CommandExtension(commands),
BlockViewExtension('affine:edgeless-text', literal`affine-edgeless-text`),
];

View File

@@ -1,2 +0,0 @@
export * from './edgeless-text-block.js';
export * from './edgeless-text-spec.js';

View File

@@ -1,5 +1,6 @@
import { effects as blockAttachmentEffects } from '@blocksuite/affine-block-attachment/effects';
import { effects as blockBookmarkEffects } from '@blocksuite/affine-block-bookmark/effects';
import { effects as blockEdgelessTextEffects } from '@blocksuite/affine-block-edgeless-text/effects';
import { effects as blockEmbedEffects } from '@blocksuite/affine-block-embed/effects';
import { effects as blockFrameEffects } from '@blocksuite/affine-block-frame/effects';
import { effects as blockImageEffects } from '@blocksuite/affine-block-image/effects';
@@ -64,8 +65,6 @@ import {
HeaderAreaTextCellEditing,
} from './database-block/properties/title/text.js';
import { DividerBlockComponent } from './divider-block/index.js';
import type { insertEdgelessTextCommand } from './edgeless-text-block/commands/insert-edgeless-text.js';
import { EdgelessTextBlockComponent } from './edgeless-text-block/index.js';
import { EdgelessAutoCompletePanel } from './root-block/edgeless/components/auto-complete/auto-complete-panel.js';
import { EdgelessAutoComplete } from './root-block/edgeless/components/auto-complete/edgeless-auto-complete.js';
import { EdgelessToolIconButton } from './root-block/edgeless/components/buttons/tool-icon-button.js';
@@ -260,6 +259,7 @@ export function effects() {
blockDatabaseEffects();
blockSurfaceRefEffects();
blockLatexEffects();
blockEdgelessTextEffects();
componentCaptionEffects();
componentContextMenuEffects();
@@ -293,7 +293,6 @@ export function effects() {
'affine-database-rich-text-cell-editing',
RichTextCellEditing
);
customElements.define('affine-edgeless-text', EdgelessTextBlockComponent);
customElements.define('center-peek', CenterPeek);
customElements.define('database-datasource-note-renderer', NoteRenderer);
customElements.define('database-datasource-block-renderer', BlockRenderer);
@@ -528,14 +527,10 @@ export function effects() {
declare global {
namespace BlockSuite {
interface Commands {
insertEdgelessText: typeof insertEdgelessTextCommand;
}
interface CommandContext {
focusBlock?: BlockComponent | null;
anchorBlock?: BlockComponent | null;
updatedBlocks?: BlockModel[];
textId?: string;
}
interface BlockConfigs {
'affine:code': CodeBlockConfig;

View File

@@ -20,7 +20,6 @@ export * from './code-block/index.js';
export * from './data-view-block/index.js';
export * from './database-block/index.js';
export * from './divider-block/index.js';
export * from './edgeless-text-block/index.js';
export { EdgelessTemplatePanel } from './root-block/edgeless/components/toolbar/template/template-panel.js';
export type {
Template,
@@ -46,6 +45,7 @@ export {
export * from './surface-ref-block/index.js';
export * from '@blocksuite/affine-block-attachment';
export * from '@blocksuite/affine-block-bookmark';
export * from '@blocksuite/affine-block-edgeless-text';
export * from '@blocksuite/affine-block-embed';
export * from '@blocksuite/affine-block-frame';
export * from '@blocksuite/affine-block-image';

View File

@@ -1,3 +1,5 @@
import type { EdgelessTextBlockComponent } from '@blocksuite/affine-block-edgeless-text';
import { EDGELESS_TEXT_BLOCK_MIN_WIDTH } from '@blocksuite/affine-block-edgeless-text';
import {
EMBED_HTML_MIN_HEIGHT,
EMBED_HTML_MIN_WIDTH,
@@ -59,8 +61,6 @@ import { ifDefined } from 'lit/directives/if-defined.js';
import { styleMap } from 'lit/directives/style-map.js';
import { isMindmapNode } from '../../../../_common/edgeless/mindmap/index.js';
import type { EdgelessTextBlockComponent } from '../../../../edgeless-text-block/edgeless-text-block.js';
import { EDGELESS_TEXT_BLOCK_MIN_WIDTH } from '../../../../edgeless-text-block/edgeless-text-block.js';
import type { EdgelessRootBlockComponent } from '../../edgeless-root-block.js';
import type {
EdgelessFrameManager,

View File

@@ -1,3 +1,4 @@
import { EdgelessTextBlockComponent } from '@blocksuite/affine-block-edgeless-text';
import {
ConnectorElementModel,
ConnectorMode,
@@ -27,7 +28,6 @@ import {
isSingleMindMapNode,
} from '../../_common/edgeless/mindmap/index.js';
import { LassoMode } from '../../_common/types.js';
import { EdgelessTextBlockComponent } from '../../edgeless-text-block/edgeless-text-block.js';
import { PageKeyboardManager } from '../keyboard/keyboard-manager.js';
import { GfxBlockModel } from './block-model.js';
import type { EdgelessRootBlockComponent } from './edgeless-root-block.js';