Files
AFFiNE-Mirror/libs/components/board-shapes/src/editor-util/EditorUtil.tsx
T

265 lines
8.0 KiB
TypeScript

/* eslint-disable no-restricted-syntax */
import { useRef, useCallback, useEffect, memo } from 'react';
import type { SyntheticEvent } from 'react';
import { Utils, HTMLContainer, TLBounds } from '@tldraw/core';
import {
EditorShape,
TDMeta,
TDShapeType,
TransformInfo,
} from '@toeverything/components/board-types';
import {
defaultTextStyle,
getBoundsRectangle,
getTextSvgElement,
} from '../shared';
import { TDShapeUtil } from '../TDShapeUtil';
import { getShapeStyle } from '../shared/shape-styles';
import { styled } from '@toeverything/components/ui';
import { Vec } from '@tldraw/vec';
import { AffineEditor } from '@toeverything/components/affine-editor';
import { MIN_PAGE_WIDTH } from '@toeverything/components/editor-core';
const MemoAffineEditor = memo(AffineEditor, (prev, next) => {
return (
prev.workspace === next.workspace &&
prev.rootBlockId === next.rootBlockId
);
});
type T = EditorShape;
type E = HTMLDivElement;
export class EditorUtil extends TDShapeUtil<T, E> {
type = TDShapeType.Editor as const;
override canBind = true;
override canEdit = true;
override canClone = true;
override showCloneHandles = true;
/**
* Prevent editor from being destroyed when moving out of viewport
*/
override isStateful = true;
getShape = (props: Partial<T>): T => {
return Utils.deepMerge<T>(
{
id: props.id,
type: TDShapeType.Editor,
name: 'Editor',
parentId: 'page',
childIndex: 1,
point: [0, 0],
size: [MIN_PAGE_WIDTH, 200],
rotation: 0,
style: defaultTextStyle,
rootBlockId: props.rootBlockId,
workspace: props.workspace,
},
props
);
};
Component = TDShapeUtil.Component<T, E, TDMeta>(
({ shape, meta: { app }, events, isEditing, onShapeChange }, ref) => {
const containerRef = useRef<HTMLDivElement>();
const {
workspace,
rootBlockId,
size: [width, height],
} = shape;
const state = app.useStore();
const { currentPageId } = state.appState;
const { editingId } = state.document.pageStates[currentPageId];
const { shapes } = state.document.pages[currentPageId];
const editingText =
editingId != null &&
shapes[editingId].type === TDShapeType.Editor;
const zoomLevel =
state.document.pageStates[state.appState.currentPageId].camera
.zoom;
// TODO: useEvent
const onResize = useRef((_: ResizeObserverEntry[]) => {});
useEffect(() => {
onResize.current = e => {
const first = e[0];
const bounds = first.contentRect;
const realHeight = bounds.height;
if (
bounds.height !== 0 &&
Math.abs(realHeight - height) > 1
) {
onShapeChange({
id: shape.id,
size: [width, realHeight],
});
}
};
}, [height, onShapeChange, shape.id, width, zoomLevel]);
useEffect(() => {
if (containerRef.current) {
const obv = new ResizeObserver(e => onResize.current(e));
obv.observe(containerRef.current);
return () => obv.disconnect();
}
}, [onShapeChange]);
const stopPropagation = useCallback(
(e: SyntheticEvent) => {
if (isEditing) {
e.stopPropagation();
}
},
[isEditing]
);
const activateIfEditing = useCallback(() => {
if (editingText && editingId !== shape.id) {
app.setEditingText(shape.id);
}
}, [app, shape.id, editingText, editingId]);
return (
<HTMLContainer ref={ref} {...events}>
<Container
ref={containerRef}
onPointerDown={stopPropagation}
onMouseEnter={activateIfEditing}
onDragEnter={activateIfEditing}
>
<MemoAffineEditor
workspace={workspace}
rootBlockId={rootBlockId}
scrollBlank={false}
isWhiteboard
/>
{editingText ? null : <Mask />}
</Container>
</HTMLContainer>
);
}
);
Indicator = TDShapeUtil.Indicator<T>(({ shape }) => {
const {
size: [width, height],
} = shape;
return (
<rect
x={0}
y={0}
rx={3}
ry={3}
width={Math.max(1, width)}
height={Math.max(1, height)}
/>
);
});
getBounds = (shape: T) => {
return getBoundsRectangle(shape, this.boundsCache);
};
override shouldRender = (prev: T, next: T) => {
return (
next.size !== prev.size ||
next.style !== prev.style ||
next.rootBlockId !== prev.rootBlockId ||
next.workspace !== prev.workspace
);
};
override transform = (
shape: T,
bounds: TLBounds,
{ scaleX, scaleY, transformOrigin }: TransformInfo<T>
): Partial<T> => {
const point = Vec.toFixed([
bounds.minX +
(bounds.width - shape.size[0]) *
(scaleX < 0 ? 1 - transformOrigin[0] : transformOrigin[0]),
bounds.minY +
(bounds.height - shape.size[1]) *
(scaleY < 0 ? 1 - transformOrigin[1] : transformOrigin[1]),
]);
return {
point,
};
};
override transformSingle = (shape: T, bounds: TLBounds): Partial<T> => {
return {
size: Vec.toFixed([
bounds.width > MIN_PAGE_WIDTH ? MIN_PAGE_WIDTH : bounds.width,
shape.size[1],
]),
};
};
override getSvgElement = (
shape: T,
isDarkMode: boolean
): SVGElement | void => {
const bounds = this.getBounds(shape);
const textBounds = Utils.expandBounds(bounds, -PADDING);
const textElm = getTextSvgElement(
'This feature is currently not supported',
shape.style,
textBounds
);
const style = getShapeStyle(shape.style, isDarkMode);
textElm.setAttribute('fill', style.fill);
textElm.setAttribute('transform', `translate(${PADDING}, ${PADDING})`);
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
const rect = document.createElementNS(
'http://www.w3.org/2000/svg',
'rect'
);
rect.setAttribute('width', bounds.width + '');
rect.setAttribute('height', bounds.height + '');
rect.setAttribute('fill', style.fill);
rect.setAttribute('rx', '3');
rect.setAttribute('ry', '3');
g.appendChild(rect);
g.appendChild(textElm);
return g;
};
}
/* -------------------------------------------------- */
/* Helpers */
/* -------------------------------------------------- */
const PADDING = 16;
// const MIN_CONTAINER_HEIGHT = 200;
const Container = styled('div')({
pointerEvents: 'all',
position: 'relative',
width: '100%',
});
const Mask = styled('div')({
position: 'absolute',
userSelect: 'none',
top: 0,
left: 0,
bottom: 0,
right: 0,
});