feat: enhance element detail

This commit is contained in:
chenshenhai 2024-06-02 12:10:26 +08:00
parent 39478d072f
commit a2979cd5ba
18 changed files with 224 additions and 48 deletions

View file

@ -1,4 +1,4 @@
import type { ActiveStore, StoreSharer, ViewScaleInfo, ViewSizeInfo } from '@idraw/types';
import type { ActiveStore, Element, ElementDetailMap, RecursivePartial, StoreSharer, ViewScaleInfo, ViewSizeInfo } from '@idraw/types';
import { Store } from '@idraw/util';
const defaultActiveStorage: ActiveStore = {
@ -12,10 +12,11 @@ const defaultActiveStorage: ActiveStore = {
offsetLeft: 0,
offsetRight: 0,
offsetTop: 0,
offsetBottom: 0
offsetBottom: 0,
overrideElementMap: null
};
export class Sharer implements StoreSharer<Record<string | number | symbol, any>> {
export class Sharer implements StoreSharer<Record<string | number | symbol | any, any>> {
#activeStore: Store<ActiveStore>;
#sharedStore: Store<{
[string: string | number | symbol]: any;
@ -96,4 +97,12 @@ export class Sharer implements StoreSharer<Record<string | number | symbol, any>
};
return sizeInfo;
}
getActiveOverrideElemenentMap(): Record<string, RecursivePartial<Element<keyof ElementDetailMap, Record<string, any>>>> | null {
return this.#activeStore.get('overrideElementMap');
}
setActiveOverrideElemenentMap(map: Record<string, RecursivePartial<Element<keyof ElementDetailMap, Record<string, any>>>> | null): void {
this.#activeStore.set('overrideElementMap', map);
}
}

View file

@ -705,7 +705,7 @@ export const MiddlewareSelector: BoardMiddleware<DeepSelectorSharedStorage, Core
viewer.drawFrame();
return;
}
} else if (target.elements.length === 1 && target.elements[0]?.type === 'text') {
} else if (target.elements.length === 1 && target.elements[0]?.type === 'text' && !target.elements[0]?.operations?.invisible) {
eventHub.trigger(middlewareEventTextEdit, {
element: target.elements[0],
groupQueue: sharer.getSharedStorage(keyGroupQueue) || [],

View file

@ -1,5 +1,5 @@
import type { BoardMiddleware, CoreEventMap, Element, ElementSize, ViewScaleInfo, ElementPosition } from '@idraw/types';
import { limitAngle, getDefaultElementDetailConfig } from '@idraw/util';
import { limitAngle, getDefaultElementDetailConfig, enhanceFontFamliy } from '@idraw/util';
export const middlewareEventTextEdit = '@middleware/text-edit';
export const middlewareEventTextChange = '@middleware/text-change';
@ -25,7 +25,7 @@ type ExtendEventMap = Record<typeof middlewareEventTextEdit, TextEditEvent> & Re
const defaultElementDetail = getDefaultElementDetailConfig();
export const MiddlewareTextEditor: BoardMiddleware<ExtendEventMap, CoreEventMap & ExtendEventMap> = (opts) => {
const { eventHub, boardContent, viewer } = opts;
const { eventHub, boardContent, viewer, sharer } = opts;
const canvas = boardContent.boardContext.canvas;
// const textarea = document.createElement('textarea');
const textarea = document.createElement('div');
@ -53,9 +53,26 @@ export const MiddlewareTextEditor: BoardMiddleware<ExtendEventMap, CoreEventMap
resetCanvasWrapper();
resetTextArea(e);
mask.style.display = 'block';
if (activeElem?.uuid) {
sharer.setActiveOverrideElemenentMap({
[activeElem.uuid]: {
operations: { invisible: true }
}
});
viewer.drawFrame();
}
};
const hideTextArea = () => {
if (activeElem?.uuid) {
const map = sharer.getActiveOverrideElemenentMap();
if (map) {
delete map[activeElem.uuid];
}
sharer.setActiveOverrideElemenentMap(map);
viewer.drawFrame();
}
mask.style.display = 'none';
activeElem = null;
activePosition = [];
@ -157,11 +174,13 @@ export const MiddlewareTextEditor: BoardMiddleware<ExtendEventMap, CoreEventMap
textarea.style.resize = 'none';
textarea.style.overflow = 'hidden';
textarea.style.wordBreak = 'break-all';
textarea.style.background = '#FFFFFF';
textarea.style.color = '#333333';
// textarea.style.background = '#FFFFFF';
textarea.style.background = 'transparent';
// textarea.style.color = '#333333';
textarea.style.color = `${detail.color || '#333333'}`;
textarea.style.fontSize = `${detail.fontSize * scale}px`;
textarea.style.lineHeight = `${detail.lineHeight * scale}px`;
textarea.style.fontFamily = detail.fontFamily;
textarea.style.lineHeight = `${(detail.lineHeight || detail.fontSize) * scale}px`;
textarea.style.fontFamily = enhanceFontFamliy(detail.fontFamily);
textarea.style.fontWeight = `${detail.fontWeight}`;
textarea.style.padding = '0';
textarea.style.margin = '0';

View file

@ -29,22 +29,40 @@ export function drawBox(
const { parentOpacity } = opts;
const opacity = getOpacity(originElem) * parentOpacity;
drawClipPath(ctx, viewElem, {
originElem,
calcElemSize,
viewScaleInfo,
viewSizeInfo,
renderContent: () => {
ctx.globalAlpha = opacity;
drawBoxBackground(ctx, viewElem, { pattern, viewScaleInfo, viewSizeInfo });
renderContent?.();
drawBoxBorder(ctx, viewElem, { viewScaleInfo, viewSizeInfo });
ctx.globalAlpha = parentOpacity;
const { clipPath, clipPathStrokeColor, clipPathStrokeWidth } = originElem.detail;
const mainRender = () => {
ctx.globalAlpha = opacity;
drawBoxBackground(ctx, viewElem, { pattern, viewScaleInfo, viewSizeInfo });
renderContent?.();
drawBoxBorder(ctx, viewElem, { viewScaleInfo, viewSizeInfo });
ctx.globalAlpha = parentOpacity;
};
if (clipPath) {
drawClipPath(ctx, viewElem, {
originElem,
calcElemSize,
viewScaleInfo,
viewSizeInfo,
renderContent: () => {
mainRender();
}
});
if (typeof clipPathStrokeWidth === 'number' && clipPathStrokeWidth > 0 && clipPathStrokeColor) {
drawClipPathStroke(ctx, viewElem, {
originElem,
calcElemSize,
viewScaleInfo,
viewSizeInfo,
parentOpacity
});
}
});
} else {
mainRender();
}
}
// TODO
function drawClipPath(
ctx: ViewContext2D,
viewElem: Element<ElementType>,
@ -75,6 +93,56 @@ function drawClipPath(
const pathStr = generateSVGPath(clipPath.commands || []);
const path2d = new Path2D(pathStr);
ctx.clip(path2d);
ctx.translate(0 - (internalX as number), 0 - (internalY as number));
ctx.setTransform(1, 0, 0, 1, 0, 0);
rotateElement(ctx, { ...viewElem }, () => {
renderContent?.();
});
ctx.restore();
} else {
renderContent?.();
}
}
function drawClipPathStroke(
ctx: ViewContext2D,
viewElem: Element<ElementType>,
opts: {
originElem?: Element<ElementType>;
calcElemSize?: ElementSize;
renderContent?: () => void;
viewScaleInfo: ViewScaleInfo;
viewSizeInfo: ViewSizeInfo;
parentOpacity: number;
}
) {
const { renderContent, originElem, calcElemSize, viewSizeInfo, parentOpacity } = opts;
const totalScale = viewSizeInfo.devicePixelRatio;
const { clipPath, clipPathStrokeColor, clipPathStrokeWidth } = originElem?.detail || {};
if (clipPath && calcElemSize && clipPath.commands && typeof clipPathStrokeWidth === 'number' && clipPathStrokeWidth > 0 && clipPathStrokeColor) {
const { x, y, w, h } = calcElemSize;
const { originW, originH, originX, originY } = clipPath;
const scaleW = w / originW;
const scaleH = h / originH;
const viewOriginX = originX * scaleW;
const viewOriginY = originY * scaleH;
const internalX = x - viewOriginX;
const internalY = y - viewOriginY;
ctx.save();
ctx.globalAlpha = parentOpacity;
ctx.translate(internalX as number, internalY as number);
ctx.scale(totalScale * scaleW, totalScale * scaleH);
const pathStr = generateSVGPath(clipPath.commands || []);
const path2d = new Path2D(pathStr);
ctx.strokeStyle = clipPathStrokeColor;
ctx.lineWidth = clipPathStrokeWidth;
ctx.stroke(path2d);
ctx.translate(0 - (internalX as number), 0 - (internalY as number));
ctx.setTransform(1, 0, 0, 1, 0, 0);

View file

@ -0,0 +1,11 @@
import type { RendererDrawElementOptions, ViewContext2D, ElementGlobalDetail } from '@idraw/types';
export function drawGlobalBackground(ctx: ViewContext2D, global: ElementGlobalDetail | undefined, opts: RendererDrawElementOptions) {
if (typeof global?.background === 'string') {
const { viewSizeInfo } = opts;
const { width, height } = viewSizeInfo;
ctx.globalAlpha = 1;
ctx.fillStyle = global.background;
ctx.fillRect(0, 0, width, height);
}
}

View file

@ -19,6 +19,11 @@ export function drawElement(ctx: ViewContext2D, elem: Element<ElementType>, opts
return;
}
const { overrideElementMap } = opts;
if (overrideElementMap?.[elem.uuid]?.operations?.invisible) {
return;
}
try {
switch (elem.type) {
case 'rect': {
@ -71,7 +76,7 @@ export function drawElement(ctx: ViewContext2D, elem: Element<ElementType>, opts
export function drawGroup(ctx: ViewContext2D, elem: Element<'group'>, opts: RendererDrawElementOptions) {
const { viewScaleInfo, viewSizeInfo, parentOpacity } = opts;
const { x, y, w, h, angle } = calcViewElementSize({ x: elem.x, y: elem.y, w: elem.w, h: elem.h, angle: elem.angle }, { viewScaleInfo, viewSizeInfo }) || elem;
const { x, y, w, h, angle } = calcViewElementSize({ x: elem.x, y: elem.y, w: elem.w, h: elem.h, angle: elem.angle }, { viewScaleInfo }) || elem;
const viewElem = { ...elem, ...{ x, y, w, h, angle } };
rotateElement(ctx, { x, y, w, h, angle }, () => {
ctx.globalAlpha = getOpacity(elem) * parentOpacity;

View file

@ -6,3 +6,4 @@ export { drawHTML } from './html';
export { drawText } from './text';
export { drawElementList } from './elements';
export { drawLayout } from './layout';
export { drawGlobalBackground } from './global';

View file

@ -4,8 +4,8 @@ import { drawBoxShadow, drawBoxBackground, drawBoxBorder } from './box';
export function drawLayout(ctx: ViewContext2D, layout: DataLayout, opts: RendererDrawElementOptions, renderContent: (ctx: ViewContext2D) => void) {
const { viewScaleInfo, viewSizeInfo, parentOpacity } = opts;
const elem: Element = { uuid: 'layout', type: 'group', ...layout };
const { x, y, w, h } = calcViewElementSize(elem, { viewScaleInfo, viewSizeInfo }) || elem;
const elem: Element = { uuid: 'layout', type: 'group', ...layout } as Element;
const { x, y, w, h } = calcViewElementSize(elem, { viewScaleInfo }) || elem;
const angle = 0;
const viewElem: Element = { ...elem, ...{ x, y, w, h, angle } } as Element;
ctx.globalAlpha = 1;
@ -20,7 +20,7 @@ export function drawLayout(ctx: ViewContext2D, layout: DataLayout, opts: Rendere
if (layout.detail.overflow === 'hidden') {
const { viewScaleInfo, viewSizeInfo } = opts;
const elem: Element<'group'> = { uuid: 'layout', type: 'group', ...layout } as Element<'group'>;
const viewElemSize = calcViewElementSize(elem, { viewScaleInfo, viewSizeInfo }) || elem;
const viewElemSize = calcViewElementSize(elem, { viewScaleInfo }) || elem;
const viewElem = { ...elem, ...viewElemSize };
const { x, y, w, h, radiusList } = calcViewBoxSize(viewElem, {
viewScaleInfo,

View file

@ -1,10 +1,20 @@
import type { Element, RendererDrawElementOptions, ViewContext2D } from '@idraw/types';
import { rotateElement, calcViewElementSize } from '@idraw/util';
import { rotateElement, calcViewElementSize, enhanceFontFamliy } from '@idraw/util';
import { is, isColorStr, getDefaultElementDetailConfig } from '@idraw/util';
import { drawBox } from './box';
const detailConfig = getDefaultElementDetailConfig();
// TODO
function isTextWidthWithinErrorRange(w0: number, w1: number, scale: number): boolean {
if (scale < 0.5) {
if (w0 < w1 && (w0 - w1) / w0 > -0.15) {
return true;
}
}
return w0 >= w1;
}
export function drawText(ctx: ViewContext2D, elem: Element<'text'>, opts: RendererDrawElementOptions) {
const { viewScaleInfo, viewSizeInfo, parentOpacity } = opts;
const { x, y, w, h, angle } = calcViewElementSize(elem, { viewScaleInfo }) || elem;
@ -24,6 +34,10 @@ export function drawText(ctx: ViewContext2D, elem: Element<'text'>, opts: Render
const originFontSize = detail.fontSize || detailConfig.fontSize;
const fontSize = originFontSize * viewScaleInfo.scale;
if (fontSize < 2) {
return;
}
const originLineHeight = detail.lineHeight || originFontSize;
const lineHeight = originLineHeight * viewScaleInfo.scale;
@ -32,7 +46,7 @@ export function drawText(ctx: ViewContext2D, elem: Element<'text'>, opts: Render
ctx.$setFont({
fontWeight: detail.fontWeight,
fontSize: fontSize,
fontFamily: detail.fontFamily
fontFamily: enhanceFontFamliy(detail.fontFamily)
});
let detailText = detail.text.replace(/\r\n/gi, '\n');
if (detail.textTransform === 'lowercase') {
@ -46,30 +60,49 @@ export function drawText(ctx: ViewContext2D, elem: Element<'text'>, opts: Render
const lines: { text: string; width: number }[] = [];
let lineNum = 0;
detailTextList.forEach((tempText: string, idx: number) => {
detailTextList.forEach((itemText: string, idx: number) => {
if (detail.minInlineSize === 'maxContent') {
lines.push({
text: tempText,
width: ctx.$undoPixelRatio(ctx.measureText(tempText).width)
text: itemText,
width: ctx.$undoPixelRatio(ctx.measureText(itemText).width)
});
} else {
let lineText = '';
if (tempText.length > 0) {
for (let i = 0; i < tempText.length; i++) {
if (ctx.measureText(lineText + (tempText[i] || '')).width <= ctx.$doPixelRatio(w)) {
lineText += tempText[i] || '';
let splitStr = '';
let tempStrList: string[] = itemText.split(splitStr);
if (detail.wordBreak === 'normal') {
const splitStr = ' ';
const wordList = itemText.split(splitStr);
tempStrList = [];
wordList.forEach((word: string, idx: number) => {
tempStrList.push(word);
if (idx < wordList.length - 1) {
tempStrList.push(splitStr);
}
});
}
if (tempStrList.length === 1 && detail.overflow === 'visible') {
lines.push({
text: tempStrList[0],
width: ctx.$undoPixelRatio(ctx.measureText(tempStrList[0]).width)
});
} else if (tempStrList.length > 0) {
for (let i = 0; i < tempStrList.length; i++) {
if (isTextWidthWithinErrorRange(ctx.$doPixelRatio(w), ctx.measureText(lineText + tempStrList[i]).width, viewScaleInfo.scale)) {
lineText += tempStrList[i] || '';
} else {
lines.push({
text: lineText,
width: ctx.$undoPixelRatio(ctx.measureText(lineText).width)
});
lineText = tempText[i] || '';
lineText = tempStrList[i] || '';
lineNum++;
}
if ((lineNum + 1) * fontHeight > h) {
if ((lineNum + 1) * fontHeight > h && detail.overflow === 'hidden') {
break;
}
if (tempText.length - 1 === i) {
if (tempStrList.length - 1 === i) {
if ((lineNum + 1) * fontHeight <= h) {
lines.push({
text: lineText,
@ -92,6 +125,10 @@ export function drawText(ctx: ViewContext2D, elem: Element<'text'>, opts: Render
});
let startY = 0;
let eachLineStartY = 0;
if (fontHeight > fontSize) {
eachLineStartY = (fontHeight - fontSize) / 2;
}
if (lines.length * fontHeight < h) {
if (elem.detail.verticalAlign === 'top') {
startY = 0;
@ -125,7 +162,7 @@ export function drawText(ctx: ViewContext2D, elem: Element<'text'>, opts: Render
} else if (detail.textAlign === 'right') {
_x = x + (w - line.width);
}
ctx.fillText(line.text, _x, _y + fontHeight * i);
ctx.fillText(line.text, _x, _y + fontHeight * i + eachLineStartY);
});
}

View file

@ -1,6 +1,6 @@
import { EventEmitter } from '@idraw/util';
import type { DataLayout, LoadItemMap } from '@idraw/types';
import { drawElementList, drawLayout } from './draw/index';
import { drawElementList, drawLayout, drawGlobalBackground } from './draw/index';
import { Loader } from './loader';
import type { Data, BoardRenderer, RendererOptions, RendererEventMap, RendererDrawOptions } from '@idraw/types';
@ -45,7 +45,7 @@ export class Renderer extends EventEmitter<RendererEventMap> implements BoardRen
drawData(data: Data, opts: RendererDrawOptions) {
const loader = this.#loader;
const { calculator } = this.#opts;
const { calculator, sharer } = this.#opts;
const viewContext = this.#opts.viewContext;
viewContext.clearRect(0, 0, viewContext.canvas.width, viewContext.canvas.height);
const parentElementSize = {
@ -69,8 +69,10 @@ export class Renderer extends EventEmitter<RendererEventMap> implements BoardRen
parentElementSize,
elementAssets: data.assets,
parentOpacity: 1,
overrideElementMap: sharer?.getActiveOverrideElemenentMap(),
...opts
};
drawGlobalBackground(viewContext, data.global, drawOpts);
if (data.layout) {
drawLayout(viewContext, data.layout as DataLayout, drawOpts, () => {
drawElementList(viewContext, data, drawOpts);

View file

@ -1,5 +1,5 @@
import type { ElementBaseDetail, ElementTextDetail, ElementGroupDetail } from './element';
export type DefaultElementDetailConfig = Required<Omit<ElementBaseDetail, 'clipPath' | 'background'>> &
Required<Pick<ElementTextDetail, 'color' | 'textAlign' | 'verticalAlign' | 'fontSize' | 'fontFamily' | 'fontWeight'>> &
Required<Pick<ElementTextDetail, 'color' | 'textAlign' | 'verticalAlign' | 'fontSize' | 'fontFamily' | 'fontWeight' | 'minInlineSize' | 'wordBreak'>> &
Required<Pick<ElementGroupDetail, 'overflow'>>;

View file

@ -1,4 +1,4 @@
import type { Element, ElementType, ElementAssets, ElementSize, ElementGroupDetail } from './element';
import type { Element, ElementType, ElementAssets, ElementSize, ElementGroupDetail, ElementGlobalDetail } from './element';
export type DataLayout = Pick<ElementSize, 'x' | 'y' | 'w' | 'h'> & {
detail: Pick<
@ -16,11 +16,13 @@ export type DataLayout = Pick<ElementSize, 'x' | 'y' | 'w' | 'h'> & {
disabledBottomRight?: boolean;
};
};
export interface Data<E extends Record<string, any> = Record<string, any>> {
export type Data<E extends Record<string, any> = Record<string, any>> = {
elements: Element<ElementType, E>[];
assets?: ElementAssets;
layout?: DataLayout;
}
global?: ElementGlobalDetail;
};
export type Matrix = [
number,

View file

@ -82,6 +82,8 @@ export interface ElementBaseDetail {
background?: string | LinearGradientColor | RadialGradientColor;
opacity?: number;
clipPath?: ElementClipPath;
clipPathStrokeWidth?: number;
clipPathStrokeColor?: string;
}
// interface ElementRectDetail extends ElementBaseDetail {
@ -106,6 +108,8 @@ export interface ElementTextDetail extends ElementBaseDetail {
textShadowBlur?: number;
minInlineSize?: 'maxContent' | 'auto';
textTransform?: 'none' | 'uppercase' | 'lowercase';
wordBreak?: 'break-all' | 'normal'; // default: 'normal'
overflow?: 'hidden' | 'visible'; // default: 'hidden'
}
export interface ElementCircleDetail extends ElementBaseDetail {
@ -174,6 +178,10 @@ export interface ElementOperations {
deepResize?: boolean;
}
export interface ElementGlobalDetail {
background?: string;
}
export interface Element<T extends ElementType = ElementType, E extends Record<string, any> = Record<string, any>> extends ElementSize {
uuid: string;
name?: string;
@ -181,6 +189,7 @@ export interface Element<T extends ElementType = ElementType, E extends Record<s
detail: ElementDetailMap[T];
operations?: ElementOperations;
extends?: E;
global?: ElementGlobalDetail;
}
export type Elements = Element<ElementType>[];

View file

@ -1,7 +1,7 @@
import type { ViewScaleInfo, ViewCalculator, ViewSizeInfo } from './view';
import type { Element, ElementSize, ElementAssets } from './element';
import type { LoaderEventMap, LoadElementType, LoadContent, LoadItemMap } from './loader';
import type { UtilEventEmitter } from './util';
import type { UtilEventEmitter, RecursivePartial } from './util';
import type { StoreSharer } from './store';
import { ViewContext2D } from '@idraw/types';
@ -43,4 +43,5 @@ export interface RendererDrawElementOptions extends RendererDrawOptions {
parentElementSize: ElementSize;
elementAssets?: ElementAssets;
parentOpacity: number;
overrideElementMap?: Record<string, RecursivePartial<Element>> | null;
}

View file

@ -4,11 +4,13 @@ import {
ViewScaleInfo,
ViewSizeInfo
} from './view';
import { Element } from './element';
import { RecursivePartial } from './util';
export type ActiveStore = ViewSizeInfo &
ViewScaleInfo & {
data: Data | null;
// selectedViewRectVertexes: ViewRectVertexes | null;
overrideElementMap: Record<string, RecursivePartial<Element>> | null;
};
export interface StoreSharer<S extends Record<any, any> = any> {
@ -23,4 +25,6 @@ export interface StoreSharer<S extends Record<any, any> = any> {
setActiveViewScaleInfo(viewScaleInfo: ViewScaleInfo): void;
setActiveViewSizeInfo(size: ViewSizeInfo): void;
getActiveViewSizeInfo(): ViewSizeInfo;
setActiveOverrideElemenentMap(map: Record<string, RecursivePartial<Element>> | null): void;
getActiveOverrideElemenentMap(): Record<string, RecursivePartial<Element>> | null;
}

View file

@ -87,3 +87,4 @@ export { deepResizeGroupElement } from './lib/resize-element';
export { calcViewCenterContent, calcViewCenter } from './lib/view-content';
export { modifyElement, getModifiedElement } from './lib/modify';
// export { ModifyRecorder } from './lib/modify-recorder';
export { enhanceFontFamliy } from './lib/text';

View file

@ -31,6 +31,8 @@ export function getDefaultElementDetailConfig(): DefaultElementDetailConfig {
// lineHeight: 20,
fontFamily: 'sans-serif',
fontWeight: 400,
minInlineSize: 'auto',
wordBreak: 'break-all',
overflow: 'hidden'
};
return config;

View file

@ -0,0 +1,5 @@
const baseFontFamilyList = ['-apple-system', '"system-ui"', ' "Segoe UI"', ' Roboto', '"Helvetica Neue"', 'Arial', '"Noto Sans"', ' sans-serif'];
export function enhanceFontFamliy(fontFamily?: string): string {
return [fontFamily, ...baseFontFamilyList].join(', ');
}