/**
|
* 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<typeof calculateTextPosition>
|
|
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<DisplayableProps, boolean> = {
|
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<any>
|
|
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<DisplayableProps, DisplayableKey>
|
|
export type DisplayableStatePropNames = ElementStatePropNames | 'style' | 'z' | 'z2' | 'invisible';
|
export type DisplayableState = Pick<DisplayableProps, DisplayableStatePropNames> & 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<Props extends DisplayableProps = DisplayableProps> {
|
animate(key?: '', loop?: boolean): Animator<this>
|
animate(key: 'style', loop?: boolean): Animator<this['style']>
|
|
getState(stateName: string): DisplayableState
|
ensureState(stateName: string): DisplayableState
|
|
states: Dictionary<DisplayableState>
|
stateProxy: (stateName: string) => DisplayableState
|
}
|
|
class Displayable<Props extends DisplayableProps = DisplayableProps> extends Element<Props> {
|
|
/**
|
* 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<any>
|
|
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<Context>(
|
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<any>);
|
}
|
else {
|
this.setStyle(value as Dictionary<any>);
|
}
|
}
|
}
|
|
setStyle(obj: Props['style']): this
|
setStyle<T extends keyof Props['style']>(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<T extends keyof Props['style']>(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<Props, boolean>);
|
}
|
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<any>, 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;
|