/** * Base class of all displayable graphic objects */ import Element, {ElementProps, ElementStatePropNames, ElementAnimateConfig, ElementCommonState} from '../Element'; import BoundingRect from '../core/BoundingRect'; import { PropType, Dictionary, MapToType } from '../core/types'; import Path from './Path'; import { keys, extend, createObject } from '../core/util'; import Animator from '../animation/Animator'; import { REDRAW_BIT, STYLE_CHANGED_BIT } from './constants'; // type CalculateTextPositionResult = ReturnType const STYLE_MAGIC_KEY = '__zr_style_' + Math.round((Math.random() * 10)); export interface CommonStyleProps { shadowBlur?: number shadowOffsetX?: number shadowOffsetY?: number shadowColor?: string opacity?: number /** * https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/globalCompositeOperation */ blend?: string } export const DEFAULT_COMMON_STYLE: CommonStyleProps = { shadowBlur: 0, shadowOffsetX: 0, shadowOffsetY: 0, shadowColor: '#000', opacity: 1, blend: 'source-over' }; export const DEFAULT_COMMON_ANIMATION_PROPS: MapToType = { style: { shadowBlur: true, shadowOffsetX: true, shadowOffsetY: true, shadowColor: true, opacity: true } }; (DEFAULT_COMMON_STYLE as any)[STYLE_MAGIC_KEY] = true; export interface DisplayableProps extends ElementProps { style?: Dictionary zlevel?: number z?: number z2?: number culling?: boolean // TODO list all cursors cursor?: string rectHover?: boolean progressive?: boolean incremental?: boolean ignoreCoarsePointer?: boolean batch?: boolean invisible?: boolean } type DisplayableKey = keyof DisplayableProps type DisplayablePropertyType = PropType export type DisplayableStatePropNames = ElementStatePropNames | 'style' | 'z' | 'z2' | 'invisible'; export type DisplayableState = Pick & ElementCommonState; const PRIMARY_STATES_KEYS = ['z', 'z2', 'invisible'] as const; const PRIMARY_STATES_KEYS_IN_HOVER_LAYER = ['invisible'] as const; // eslint-disable-next-line @typescript-eslint/no-unused-vars interface Displayable { animate(key?: '', loop?: boolean): Animator animate(key: 'style', loop?: boolean): Animator getState(stateName: string): DisplayableState ensureState(stateName: string): DisplayableState states: Dictionary stateProxy: (stateName: string) => DisplayableState } class Displayable extends Element { /** * Whether the displayable object is visible. when it is true, the displayable object * is not drawn, but the mouse event can still trigger the object. */ invisible: boolean z: number z2: number /** * The z level determines the displayable object can be drawn in which layer canvas. */ zlevel: number /** * If enable culling */ culling: boolean /** * Mouse cursor when hovered */ cursor: string /** * If hover area is bounding rect */ rectHover: boolean /** * For increamental rendering */ incremental: boolean /** * Never increase to target size */ ignoreCoarsePointer?: boolean style: Dictionary protected _normalState: DisplayableState protected _rect: BoundingRect protected _paintRect: BoundingRect protected _prevPaintRect: BoundingRect dirtyRectTolerance: number /************* Properties will be inejected in other modules. *******************/ // @deprecated. useHoverLayer?: boolean __hoverStyle?: CommonStyleProps // TODO use WeakMap? // Shapes for cascade clipping. // Can only be `null`/`undefined` or an non-empty array, MUST NOT be an empty array. // because it is easy to only using null to check whether clipPaths changed. __clipPaths?: Path[] // FOR CANVAS PAINTER __canvasFillGradient: CanvasGradient __canvasStrokeGradient: CanvasGradient __canvasFillPattern: CanvasPattern __canvasStrokePattern: CanvasPattern // FOR SVG PAINTER __svgEl: SVGElement constructor(props?: Props) { super(props); } protected _init(props?: Props) { // Init default properties const keysArr = keys(props); for (let i = 0; i < keysArr.length; i++) { const key = keysArr[i]; if (key === 'style') { this.useStyle(props[key] as Props['style']); } else { super.attrKV(key as any, props[key]); } } // Give a empty style if (!this.style) { this.useStyle({}); } } // Hook provided to developers. beforeBrush() {} afterBrush() {} // Hook provided to inherited classes. // Executed between beforeBrush / afterBrush innerBeforeBrush() {} innerAfterBrush() {} shouldBePainted( viewWidth: number, viewHeight: number, considerClipPath: boolean, considerAncestors: boolean ) { const m = this.transform; if ( this.ignore // Ignore invisible element || this.invisible // Ignore transparent element || this.style.opacity === 0 // Ignore culled element || (this.culling && isDisplayableCulled(this, viewWidth, viewHeight) ) // Ignore scale 0 element, in some environment like node-canvas // Draw a scale 0 element can cause all following draw wrong // And setTransform with scale 0 will cause set back transform failed. || (m && !m[0] && !m[3]) ) { return false; } if (considerClipPath && this.__clipPaths) { for (let i = 0; i < this.__clipPaths.length; ++i) { if (this.__clipPaths[i].isZeroArea()) { return false; } } } if (considerAncestors && this.parent) { let parent = this.parent; while (parent) { if (parent.ignore) { return false; } parent = parent.parent; } } return true; } /** * If displayable element contain coord x, y */ contain(x: number, y: number) { return this.rectContain(x, y); } traverse( cb: (this: Context, el: this) => void, context?: Context ) { cb.call(context, this); } /** * If bounding rect of element contain coord x, y */ rectContain(x: number, y: number) { const coord = this.transformCoordToLocal(x, y); const rect = this.getBoundingRect(); return rect.contain(coord[0], coord[1]); } getPaintRect(): BoundingRect { let rect = this._paintRect; if (!this._paintRect || this.__dirty) { const transform = this.transform; const elRect = this.getBoundingRect(); const style = this.style; const shadowSize = style.shadowBlur || 0; const shadowOffsetX = style.shadowOffsetX || 0; const shadowOffsetY = style.shadowOffsetY || 0; rect = this._paintRect || (this._paintRect = new BoundingRect(0, 0, 0, 0)); if (transform) { BoundingRect.applyTransform(rect, elRect, transform); } else { rect.copy(elRect); } if (shadowSize || shadowOffsetX || shadowOffsetY) { rect.width += shadowSize * 2 + Math.abs(shadowOffsetX); rect.height += shadowSize * 2 + Math.abs(shadowOffsetY); rect.x = Math.min(rect.x, rect.x + shadowOffsetX - shadowSize); rect.y = Math.min(rect.y, rect.y + shadowOffsetY - shadowSize); } // For the accuracy tolerance of text height or line joint point const tolerance = this.dirtyRectTolerance; if (!rect.isZero()) { rect.x = Math.floor(rect.x - tolerance); rect.y = Math.floor(rect.y - tolerance); rect.width = Math.ceil(rect.width + 1 + tolerance * 2); rect.height = Math.ceil(rect.height + 1 + tolerance * 2); } } return rect; } setPrevPaintRect(paintRect: BoundingRect) { if (paintRect) { this._prevPaintRect = this._prevPaintRect || new BoundingRect(0, 0, 0, 0); this._prevPaintRect.copy(paintRect); } else { this._prevPaintRect = null; } } getPrevPaintRect(): BoundingRect { return this._prevPaintRect; } /** * Alias for animate('style') * @param loop */ animateStyle(loop: boolean) { return this.animate('style', loop); } // Override updateDuringAnimation updateDuringAnimation(targetKey: string) { if (targetKey === 'style') { this.dirtyStyle(); } else { this.markRedraw(); } } attrKV(key: DisplayableKey, value: DisplayablePropertyType) { if (key !== 'style') { super.attrKV(key as keyof DisplayableProps, value); } else { if (!this.style) { this.useStyle(value as Dictionary); } else { this.setStyle(value as Dictionary); } } } setStyle(obj: Props['style']): this setStyle(obj: T, value: Props['style'][T]): this setStyle(keyOrObj: keyof Props['style'] | Props['style'], value?: unknown): this { if (typeof keyOrObj === 'string') { this.style[keyOrObj] = value; } else { extend(this.style, keyOrObj as Props['style']); } this.dirtyStyle(); return this; } // getDefaultStyleValue(key: T): Props['style'][T] { // // Default value is on the prototype. // return this.style.prototype[key]; // } dirtyStyle(notRedraw?: boolean) { if (!notRedraw) { this.markRedraw(); } this.__dirty |= STYLE_CHANGED_BIT; // Clear bounding rect. if (this._rect) { this._rect = null; } } dirty() { this.dirtyStyle(); } /** * Is style changed. Used with dirtyStyle. */ styleChanged() { return !!(this.__dirty & STYLE_CHANGED_BIT); } /** * Mark style updated. Only useful when style is used for caching. Like in the text. */ styleUpdated() { this.__dirty &= ~STYLE_CHANGED_BIT; } /** * Create a style object with default values in it's prototype. */ createStyle(obj?: Props['style']) { return createObject(DEFAULT_COMMON_STYLE, obj); } /** * Replace style property. * It will create a new style if given obj is not a valid style object. */ // PENDING should not createStyle if it's an style object. useStyle(obj: Props['style']) { if (!obj[STYLE_MAGIC_KEY]) { obj = this.createStyle(obj); } if (this.__inHover) { this.__hoverStyle = obj; // Not affect exists style. } else { this.style = obj; } this.dirtyStyle(); } /** * Determine if an object is a valid style object. * Which means it is created by `createStyle.` * * A valid style object will have all default values in it's prototype. * To avoid get null/undefined values. */ isStyleObject(obj: Props['style']) { return obj[STYLE_MAGIC_KEY]; } protected _innerSaveToNormal(toState: DisplayableState) { super._innerSaveToNormal(toState); const normalState = this._normalState; if (toState.style && !normalState.style) { // Clone style object. // TODO: Only save changed style. normalState.style = this._mergeStyle(this.createStyle(), this.style); } this._savePrimaryToNormal(toState, normalState, PRIMARY_STATES_KEYS); } protected _applyStateObj( stateName: string, state: DisplayableState, normalState: DisplayableState, keepCurrentStates: boolean, transition: boolean, animationCfg: ElementAnimateConfig ) { super._applyStateObj(stateName, state, normalState, keepCurrentStates, transition, animationCfg); const needsRestoreToNormal = !(state && keepCurrentStates); let targetStyle: Props['style']; if (state && state.style) { // Only animate changed properties. if (transition) { if (keepCurrentStates) { targetStyle = state.style; } else { targetStyle = this._mergeStyle(this.createStyle(), normalState.style); this._mergeStyle(targetStyle, state.style); } } else { targetStyle = this._mergeStyle( this.createStyle(), keepCurrentStates ? this.style : normalState.style ); this._mergeStyle(targetStyle, state.style); } } else if (needsRestoreToNormal) { targetStyle = normalState.style; } if (targetStyle) { if (transition) { // Clone a new style. Not affect the original one. const sourceStyle = this.style; this.style = this.createStyle(needsRestoreToNormal ? {} : sourceStyle); // const sourceStyle = this.style = this.createStyle(this.style); if (needsRestoreToNormal) { const changedKeys = keys(sourceStyle); for (let i = 0; i < changedKeys.length; i++) { const key = changedKeys[i]; if (key in targetStyle) { // Not use `key == null` because == null may means no stroke/fill. // Pick out from prototype. Or the property won't be animated. (targetStyle as any)[key] = targetStyle[key]; // Omit the property has no default value. (this.style as any)[key] = sourceStyle[key]; } } } // If states is switched twice in ONE FRAME, for example: // one property(for example shadowBlur) changed from default value to a specifed value, // then switched back in immediately. this.style may don't set this property yet when switching back. // It won't treat it as an changed property when switching back. And it won't be animated. // So here we make sure the properties will be animated from default value to a specifed value are set. const targetKeys = keys(targetStyle); for (let i = 0; i < targetKeys.length; i++) { const key = targetKeys[i]; this.style[key] = this.style[key]; } this._transitionState(stateName, { style: targetStyle } as Props, animationCfg, this.getAnimationStyleProps() as MapToType); } else { this.useStyle(targetStyle); } } // Don't change z, z2 for element moved into hover layer. // It's not necessary and will cause paint list order changed. const statesKeys = this.__inHover ? PRIMARY_STATES_KEYS_IN_HOVER_LAYER : PRIMARY_STATES_KEYS; for (let i = 0; i < statesKeys.length; i++) { let key = statesKeys[i]; if (state && state[key] != null) { // Replace if it exist in target state (this as any)[key] = state[key]; } else if (needsRestoreToNormal) { // Restore to normal state if (normalState[key] != null) { (this as any)[key] = normalState[key]; } } } } protected _mergeStates(states: DisplayableState[]) { const mergedState = super._mergeStates(states) as DisplayableState; let mergedStyle: Props['style']; for (let i = 0; i < states.length; i++) { const state = states[i]; if (state.style) { mergedStyle = mergedStyle || {}; this._mergeStyle(mergedStyle, state.style); } } if (mergedStyle) { mergedState.style = mergedStyle; } return mergedState; } protected _mergeStyle( targetStyle: CommonStyleProps, sourceStyle: CommonStyleProps ) { extend(targetStyle, sourceStyle); return targetStyle; } getAnimationStyleProps() { return DEFAULT_COMMON_ANIMATION_PROPS; } /** * The string value of `textPosition` needs to be calculated to a real postion. * For example, `'inside'` is calculated to `[rect.width/2, rect.height/2]` * by default. See `contain/text.js#calculateTextPosition` for more details. * But some coutom shapes like "pin", "flag" have center that is not exactly * `[width/2, height/2]`. So we provide this hook to customize the calculation * for those shapes. It will be called if the `style.textPosition` is a string. * @param out Prepared out object. If not provided, this method should * be responsible for creating one. * @param style * @param rect {x, y, width, height} * @return out The same as the input out. * { * x: number. mandatory. * y: number. mandatory. * textAlign: string. optional. use style.textAlign by default. * textVerticalAlign: string. optional. use style.textVerticalAlign by default. * } */ // calculateTextPosition: (out: CalculateTextPositionResult, style: Dictionary, rect: RectLike) => CalculateTextPositionResult protected static initDefaultProps = (function () { const dispProto = Displayable.prototype; dispProto.type = 'displayable'; dispProto.invisible = false; dispProto.z = 0; dispProto.z2 = 0; dispProto.zlevel = 0; dispProto.culling = false; dispProto.cursor = 'pointer'; dispProto.rectHover = false; dispProto.incremental = false; dispProto._rect = null; dispProto.dirtyRectTolerance = 0; dispProto.__dirty = REDRAW_BIT | STYLE_CHANGED_BIT; })() } const tmpRect = new BoundingRect(0, 0, 0, 0); const viewRect = new BoundingRect(0, 0, 0, 0); function isDisplayableCulled(el: Displayable, width: number, height: number) { tmpRect.copy(el.getBoundingRect()); if (el.transform) { tmpRect.applyTransform(el.transform); } viewRect.width = width; viewRect.height = height; return !tmpRect.intersect(viewRect); } export default Displayable;