mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-23 09:17:06 +08:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user