import Transformable, {TRANSFORMABLE_PROPS, TransformProp} from './core/Transformable';
|
import { AnimationEasing } from './animation/easing';
|
import Animator, {cloneValue} from './animation/Animator';
|
import { ZRenderType } from './zrender';
|
import {
|
Dictionary, ElementEventName, ZRRawEvent, BuiltinTextPosition, AllPropTypes,
|
TextVerticalAlign, TextAlign, MapToType
|
} from './core/types';
|
import Path from './graphic/Path';
|
import BoundingRect, { RectLike } from './core/BoundingRect';
|
import Eventful from './core/Eventful';
|
import ZRText, { DefaultTextStyle } from './graphic/Text';
|
import { calculateTextPosition, TextPositionCalculationResult, parsePercent } from './contain/text';
|
import {
|
guid,
|
isObject,
|
keys,
|
extend,
|
indexOf,
|
logError,
|
mixin,
|
isArrayLike,
|
isTypedArray,
|
isGradientObject,
|
filter,
|
reduce
|
} from './core/util';
|
import Polyline from './graphic/shape/Polyline';
|
import Group from './graphic/Group';
|
import Point from './core/Point';
|
import { LIGHT_LABEL_COLOR, DARK_LABEL_COLOR } from './config';
|
import { parse, stringify } from './tool/color';
|
import { REDRAW_BIT } from './graphic/constants';
|
|
export interface ElementAnimateConfig {
|
duration?: number
|
delay?: number
|
easing?: AnimationEasing
|
during?: (percent: number) => void
|
|
// `done` will be called when all of the animations of the target props are
|
// "done" or "aborted", and at least one "done" happened.
|
// Common cases: animations declared, but some of them are aborted (e.g., by state change).
|
// The calling of `animationTo` done rather than aborted if at least one done happened.
|
done?: Function
|
// `aborted` will be called when all of the animations of the target props are "aborted".
|
aborted?: Function
|
|
scope?: string
|
/**
|
* If force animate
|
* Prevent stop animation and callback
|
* immediently when target values are the same as current values.
|
*/
|
force?: boolean
|
/**
|
* If use additive animation.
|
*/
|
additive?: boolean
|
/**
|
* If set to final state before animation started.
|
* It can be useful if something you want to calcuate depends on the final state of element.
|
* Like bounding rect for text layouting.
|
*
|
* Only available in animateTo
|
*/
|
setToFinal?: boolean
|
}
|
|
export interface ElementTextConfig {
|
/**
|
* Position relative to the element bounding rect
|
* @default 'inside'
|
*/
|
position?: BuiltinTextPosition | (number | string)[]
|
|
/**
|
* Rotation of the label.
|
*/
|
rotation?: number
|
|
/**
|
* Rect that text will be positioned.
|
* Default to be the rect of element.
|
*/
|
layoutRect?: RectLike
|
|
/**
|
* Offset of the label.
|
* The difference of offset and position is that it will be applied
|
* in the rotation
|
*/
|
offset?: number[]
|
|
/**
|
* Origin or rotation. Which is relative to the bounding box of the attached element.
|
* Can be percent value. Relative to the bounding box.
|
* If specified center. It will be center of the bounding box.
|
*
|
* Only available when position and rotation are both set.
|
*/
|
origin?: (number | string)[] | 'center'
|
|
/**
|
* Distance to the rect
|
* @default 5
|
*/
|
distance?: number
|
|
/**
|
* If use local user space. Which will apply host's transform
|
* @default false
|
*/
|
local?: boolean
|
|
/**
|
* `insideFill` is a color string or left empty.
|
* If a `textContent` is "inside", its final `fill` will be picked by this priority:
|
* `textContent.style.fill` > `textConfig.insideFill` > "auto-calculated-fill"
|
* In most cases, "auto-calculated-fill" is white.
|
*/
|
insideFill?: string
|
|
/**
|
* `insideStroke` is a color string or left empty.
|
* If a `textContent` is "inside", its final `stroke` will be picked by this priority:
|
* `textContent.style.stroke` > `textConfig.insideStroke` > "auto-calculated-stroke"
|
*
|
* The rule of getting "auto-calculated-stroke":
|
* If (A) the `fill` is specified in style (either in `textContent.style` or `textContent.style.rich`)
|
* or (B) needed to draw text background (either defined in `textContent.style` or `textContent.style.rich`)
|
* "auto-calculated-stroke" will be null.
|
* Otherwise, "auto-calculated-stroke" will be the same as `fill` of this element if possible, or null.
|
*
|
* The reason of (A) is not decisive:
|
* 1. If users specify `fill` in style and still use "auto-calculated-stroke", the effect
|
* is not good and unexpected in some cases. It not easy and seams uncessary to auto calculate
|
* a proper `stroke` for the given `fill`, since they can specify `stroke` themselve.
|
* 2. Backward compat.
|
*/
|
insideStroke?: string
|
|
/**
|
* `outsideFill` is a color string or left empty.
|
* If a `textContent` is "inside", its final `fill` will be picked by this priority:
|
* `textContent.style.fill` > `textConfig.outsideFill` > #000
|
*/
|
outsideFill?: string
|
|
/**
|
* `outsideStroke` is a color string or left empth.
|
* If a `textContent` is not "inside", its final `stroke` will be picked by this priority:
|
* `textContent.style.stroke` > `textConfig.outsideStroke` > "auto-calculated-stroke"
|
*
|
* The rule of getting "auto-calculated-stroke":
|
* If (A) the `fill` is specified in style (either in `textContent.style` or `textContent.style.rich`)
|
* or (B) needed to draw text background (either defined in `textContent.style` or `textContent.style.rich`)
|
* "auto-calculated-stroke" will be null.
|
* Otherwise, "auto-calculated-stroke" will be a neer white color to distinguish "front end"
|
* label with messy background (like other text label, line or other graphic).
|
*/
|
outsideStroke?: string
|
|
/**
|
* Tell zrender I can sure this text is inside or not.
|
* In case position is not using builtin `inside` hints.
|
*/
|
inside?: boolean
|
}
|
export interface ElementTextGuideLineConfig {
|
/**
|
* Anchor for text guide line.
|
* Notice: Won't work
|
*/
|
anchor?: Point
|
|
/**
|
* If above the target element.
|
*/
|
showAbove?: boolean
|
|
/**
|
* Candidates of connectors. Used when autoCalculate is true and anchor is not specified.
|
*/
|
candidates?: ('left' | 'top' | 'right' | 'bottom')[]
|
}
|
|
export interface ElementEvent {
|
type: ElementEventName,
|
event: ZRRawEvent,
|
// target can only be an element that is not silent.
|
target: Element,
|
// topTarget can be a silent element.
|
topTarget: Element,
|
cancelBubble: boolean,
|
offsetX: number,
|
offsetY: number,
|
gestureEvent: string,
|
pinchX: number,
|
pinchY: number,
|
pinchScale: number,
|
wheelDelta: number,
|
zrByTouch: boolean,
|
which: number,
|
stop: (this: ElementEvent) => void
|
}
|
|
export type ElementEventCallback<Ctx, Impl> = (
|
this: CbThis<Ctx, Impl>, e: ElementEvent
|
) => boolean | void
|
type CbThis<Ctx, Impl> = unknown extends Ctx ? Impl : Ctx;
|
|
interface ElementEventHandlerProps {
|
// Events
|
onclick: ElementEventCallback<unknown, unknown>
|
ondblclick: ElementEventCallback<unknown, unknown>
|
onmouseover: ElementEventCallback<unknown, unknown>
|
onmouseout: ElementEventCallback<unknown, unknown>
|
onmousemove: ElementEventCallback<unknown, unknown>
|
onmousewheel: ElementEventCallback<unknown, unknown>
|
onmousedown: ElementEventCallback<unknown, unknown>
|
onmouseup: ElementEventCallback<unknown, unknown>
|
oncontextmenu: ElementEventCallback<unknown, unknown>
|
|
ondrag: ElementEventCallback<unknown, unknown>
|
ondragstart: ElementEventCallback<unknown, unknown>
|
ondragend: ElementEventCallback<unknown, unknown>
|
ondragenter: ElementEventCallback<unknown, unknown>
|
ondragleave: ElementEventCallback<unknown, unknown>
|
ondragover: ElementEventCallback<unknown, unknown>
|
ondrop: ElementEventCallback<unknown, unknown>
|
}
|
|
export interface ElementProps extends Partial<ElementEventHandlerProps>, Partial<Pick<Transformable, TransformProp>> {
|
name?: string
|
ignore?: boolean
|
isGroup?: boolean
|
draggable?: boolean | 'horizontal' | 'vertical'
|
|
silent?: boolean
|
|
ignoreClip?: boolean
|
globalScaleRatio?: number
|
|
textConfig?: ElementTextConfig
|
textContent?: ZRText
|
|
clipPath?: Path
|
drift?: Element['drift']
|
|
extra?: Dictionary<unknown>
|
|
// For echarts animation.
|
anid?: string
|
}
|
|
// Properties can be used in state.
|
export const PRESERVED_NORMAL_STATE = '__zr_normal__';
|
// export const PRESERVED_MERGED_STATE = '__zr_merged__';
|
|
const PRIMARY_STATES_KEYS = (TRANSFORMABLE_PROPS as any).concat(['ignore']) as [TransformProp, 'ignore'];
|
const DEFAULT_ANIMATABLE_MAP = reduce(TRANSFORMABLE_PROPS, (obj, key) => {
|
obj[key] = true;
|
return obj;
|
}, {ignore: false} as Partial<Record<ElementStatePropNames, boolean>>);
|
|
export type ElementStatePropNames = (typeof PRIMARY_STATES_KEYS)[number] | 'textConfig';
|
export type ElementState = Pick<ElementProps, ElementStatePropNames> & ElementCommonState
|
|
export type ElementCommonState = {
|
hoverLayer?: boolean
|
}
|
|
export type ElementCalculateTextPosition = (
|
out: TextPositionCalculationResult,
|
style: ElementTextConfig,
|
rect: RectLike
|
) => TextPositionCalculationResult;
|
|
let tmpTextPosCalcRes = {} as TextPositionCalculationResult;
|
let tmpBoundingRect = new BoundingRect(0, 0, 0, 0);
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
interface Element<Props extends ElementProps = ElementProps> extends Transformable,
|
Eventful<{
|
[key in ElementEventName]: (e: ElementEvent) => void | boolean
|
} & {
|
[key in string]: (...args: any) => void | boolean
|
}>,
|
ElementEventHandlerProps {
|
}
|
|
class Element<Props extends ElementProps = ElementProps> {
|
|
id: number = guid()
|
/**
|
* Element type
|
*/
|
type: string
|
|
/**
|
* Element name
|
*/
|
name: string
|
|
/**
|
* If ignore drawing and events of the element object
|
*/
|
ignore: boolean
|
|
/**
|
* Whether to respond to mouse events.
|
*/
|
silent: boolean
|
|
/**
|
* 是否是 Group
|
*/
|
isGroup: boolean
|
|
/**
|
* Whether it can be dragged.
|
*/
|
draggable: boolean | 'horizontal' | 'vertical'
|
|
/**
|
* Whether is it dragging.
|
*/
|
dragging: boolean
|
|
parent: Group
|
|
animators: Animator<any>[] = []
|
|
/**
|
* If ignore clip from it's parent or hosts.
|
* Applied on itself and all it's children.
|
*
|
* NOTE: It won't affect the clipPath set on the children.
|
*/
|
ignoreClip: boolean
|
|
/**
|
* If element is used as a component of other element.
|
*/
|
__hostTarget: Element
|
|
/**
|
* ZRender instance will be assigned when element is associated with zrender
|
*/
|
__zr: ZRenderType
|
|
/**
|
* Dirty bits.
|
* From which painter will determine if this displayable object needs brush.
|
*/
|
__dirty: number
|
|
/**
|
* If element was painted on the screen
|
*/
|
__isRendered: boolean;
|
|
/**
|
* If element has been moved to the hover layer.
|
*
|
* If so, dirty will only trigger the zrender refresh hover layer
|
*/
|
__inHover: boolean
|
|
/**
|
* path to clip the elements and its children, if it is a group.
|
* @see http://www.w3.org/TR/2dcontext/#clipping-region
|
*/
|
private _clipPath?: Path
|
|
/**
|
* Attached text element.
|
* `position`, `style.textAlign`, `style.textVerticalAlign`
|
* of element will be ignored if textContent.position is set
|
*/
|
private _textContent?: ZRText
|
|
/**
|
* Text guide line.
|
*/
|
private _textGuide?: Polyline
|
|
/**
|
* Config of textContent. Inlcuding layout, color, ...etc.
|
*/
|
textConfig?: ElementTextConfig
|
|
/**
|
* Config for guide line calculating.
|
*
|
* NOTE: This is just a property signature. READ and WRITE are all done in echarts.
|
*/
|
textGuideLineConfig?: ElementTextGuideLineConfig
|
|
// FOR ECHARTS
|
/**
|
* Id for mapping animation
|
*/
|
anid: string
|
|
extra: Dictionary<unknown>
|
|
currentStates?: string[] = []
|
// prevStates is for storager in echarts.
|
prevStates?: string[]
|
/**
|
* Store of element state.
|
* '__normal__' key is preserved for default properties.
|
*/
|
states: Dictionary<ElementState> = {}
|
|
/**
|
* Animation config applied on state switching.
|
*/
|
stateTransition: ElementAnimateConfig
|
|
/**
|
* Proxy function for getting state with given stateName.
|
* ZRender will first try to get with stateProxy. Then find from states if stateProxy returns nothing
|
*
|
* targetStates will be given in useStates
|
*/
|
stateProxy?: (stateName: string, targetStates?: string[]) => ElementState
|
|
protected _normalState: ElementState
|
|
// Temporary storage for inside text color configuration.
|
private _innerTextDefaultStyle: DefaultTextStyle
|
|
constructor(props?: Props) {
|
this._init(props);
|
}
|
|
protected _init(props?: Props) {
|
// Init default properties
|
this.attr(props);
|
}
|
|
/**
|
* Drift element
|
* @param {number} dx dx on the global space
|
* @param {number} dy dy on the global space
|
*/
|
drift(dx: number, dy: number, e?: ElementEvent) {
|
switch (this.draggable) {
|
case 'horizontal':
|
dy = 0;
|
break;
|
case 'vertical':
|
dx = 0;
|
break;
|
}
|
|
let m = this.transform;
|
if (!m) {
|
m = this.transform = [1, 0, 0, 1, 0, 0];
|
}
|
m[4] += dx;
|
m[5] += dy;
|
|
this.decomposeTransform();
|
this.markRedraw();
|
}
|
|
/**
|
* Hook before update
|
*/
|
beforeUpdate() {}
|
/**
|
* Hook after update
|
*/
|
afterUpdate() {}
|
/**
|
* Update each frame
|
*/
|
update() {
|
this.updateTransform();
|
|
if (this.__dirty) {
|
this.updateInnerText();
|
}
|
}
|
|
updateInnerText(forceUpdate?: boolean) {
|
// Update textContent
|
const textEl = this._textContent;
|
if (textEl && (!textEl.ignore || forceUpdate)) {
|
if (!this.textConfig) {
|
this.textConfig = {};
|
}
|
const textConfig = this.textConfig;
|
const isLocal = textConfig.local;
|
const innerTransformable = textEl.innerTransformable;
|
|
let textAlign: TextAlign;
|
let textVerticalAlign: TextVerticalAlign;
|
|
let textStyleChanged = false;
|
|
// Apply host's transform.
|
innerTransformable.parent = isLocal ? this as unknown as Group : null;
|
|
let innerOrigin = false;
|
|
// Reset x/y/rotation
|
innerTransformable.copyTransform(textEl);
|
|
// Force set attached text's position if `position` is in config.
|
if (textConfig.position != null) {
|
let layoutRect = tmpBoundingRect;
|
if (textConfig.layoutRect) {
|
layoutRect.copy(textConfig.layoutRect);
|
}
|
else {
|
layoutRect.copy(this.getBoundingRect());
|
}
|
if (!isLocal) {
|
layoutRect.applyTransform(this.transform);
|
}
|
|
if (this.calculateTextPosition) {
|
this.calculateTextPosition(tmpTextPosCalcRes, textConfig, layoutRect);
|
}
|
else {
|
calculateTextPosition(tmpTextPosCalcRes, textConfig, layoutRect);
|
}
|
|
// TODO Should modify back if textConfig.position is set to null again.
|
// Or textContent is detached.
|
innerTransformable.x = tmpTextPosCalcRes.x;
|
innerTransformable.y = tmpTextPosCalcRes.y;
|
|
// User specified align/verticalAlign has higher priority, which is
|
// useful in the case that attached text is rotated 90 degree.
|
textAlign = tmpTextPosCalcRes.align;
|
textVerticalAlign = tmpTextPosCalcRes.verticalAlign;
|
|
const textOrigin = textConfig.origin;
|
if (textOrigin && textConfig.rotation != null) {
|
let relOriginX;
|
let relOriginY;
|
if (textOrigin === 'center') {
|
relOriginX = layoutRect.width * 0.5;
|
relOriginY = layoutRect.height * 0.5;
|
}
|
else {
|
relOriginX = parsePercent(textOrigin[0], layoutRect.width);
|
relOriginY = parsePercent(textOrigin[1], layoutRect.height);
|
}
|
|
innerOrigin = true;
|
innerTransformable.originX = -innerTransformable.x + relOriginX + (isLocal ? 0 : layoutRect.x);
|
innerTransformable.originY = -innerTransformable.y + relOriginY + (isLocal ? 0 : layoutRect.y);
|
}
|
}
|
|
|
if (textConfig.rotation != null) {
|
innerTransformable.rotation = textConfig.rotation;
|
}
|
|
// TODO
|
const textOffset = textConfig.offset;
|
if (textOffset) {
|
innerTransformable.x += textOffset[0];
|
innerTransformable.y += textOffset[1];
|
|
// Not change the user set origin.
|
if (!innerOrigin) {
|
innerTransformable.originX = -textOffset[0];
|
innerTransformable.originY = -textOffset[1];
|
}
|
}
|
|
// Calculate text color
|
const isInside = textConfig.inside == null // Force to be inside or not.
|
? (typeof textConfig.position === 'string' && textConfig.position.indexOf('inside') >= 0)
|
: textConfig.inside;
|
const innerTextDefaultStyle = this._innerTextDefaultStyle || (this._innerTextDefaultStyle = {});
|
|
let textFill;
|
let textStroke;
|
let autoStroke;
|
if (isInside && this.canBeInsideText()) {
|
// In most cases `textContent` need this "auto" strategy.
|
// So by default be 'auto'. Otherwise users need to literally
|
// set `insideFill: 'auto', insideStroke: 'auto'` each time.
|
textFill = textConfig.insideFill;
|
textStroke = textConfig.insideStroke;
|
|
if (textFill == null || textFill === 'auto') {
|
textFill = this.getInsideTextFill();
|
}
|
if (textStroke == null || textStroke === 'auto') {
|
textStroke = this.getInsideTextStroke(textFill);
|
autoStroke = true;
|
}
|
}
|
else {
|
textFill = textConfig.outsideFill;
|
textStroke = textConfig.outsideStroke;
|
|
if (textFill == null || textFill === 'auto') {
|
textFill = this.getOutsideFill();
|
}
|
// By default give a stroke to distinguish "front end" label with
|
// messy background (like other text label, line or other graphic).
|
// If textContent.style.fill specified, this auto stroke will not be used.
|
if (textStroke == null || textStroke === 'auto') {
|
// If some time need to customize the default stroke getter,
|
// add some kind of override method.
|
textStroke = this.getOutsideStroke(textFill);
|
autoStroke = true;
|
}
|
}
|
// Default `textFill` should must have a value to ensure text can be displayed.
|
textFill = textFill || '#000';
|
|
if (textFill !== innerTextDefaultStyle.fill
|
|| textStroke !== innerTextDefaultStyle.stroke
|
|| autoStroke !== innerTextDefaultStyle.autoStroke
|
|| textAlign !== innerTextDefaultStyle.align
|
|| textVerticalAlign !== innerTextDefaultStyle.verticalAlign
|
) {
|
|
textStyleChanged = true;
|
|
innerTextDefaultStyle.fill = textFill;
|
innerTextDefaultStyle.stroke = textStroke;
|
innerTextDefaultStyle.autoStroke = autoStroke;
|
innerTextDefaultStyle.align = textAlign;
|
innerTextDefaultStyle.verticalAlign = textVerticalAlign;
|
|
textEl.setDefaultTextStyle(innerTextDefaultStyle);
|
}
|
|
// Mark textEl to update transform.
|
// DON'T use markRedraw. It will cause Element itself to dirty again.
|
textEl.__dirty |= REDRAW_BIT;
|
|
if (textStyleChanged) {
|
// Only mark style dirty if necessary. Update ZRText is costly.
|
textEl.dirtyStyle(true);
|
}
|
}
|
}
|
|
protected canBeInsideText() {
|
return true;
|
}
|
|
protected getInsideTextFill(): string | undefined {
|
return '#fff';
|
}
|
|
protected getInsideTextStroke(textFill: string): string | undefined {
|
return '#000';
|
}
|
|
protected getOutsideFill(): string | undefined {
|
return this.__zr && this.__zr.isDarkMode() ? LIGHT_LABEL_COLOR : DARK_LABEL_COLOR;
|
}
|
|
protected getOutsideStroke(textFill: string): string {
|
const backgroundColor = this.__zr && this.__zr.getBackgroundColor();
|
let colorArr = typeof backgroundColor === 'string' && parse(backgroundColor as string);
|
if (!colorArr) {
|
colorArr = [255, 255, 255, 1];
|
}
|
// Assume blending on a white / black(dark) background.
|
const alpha = colorArr[3];
|
const isDark = this.__zr.isDarkMode();
|
for (let i = 0; i < 3; i++) {
|
colorArr[i] = colorArr[i] * alpha + (isDark ? 0 : 255) * (1 - alpha);
|
}
|
colorArr[3] = 1;
|
return stringify(colorArr, 'rgba');
|
}
|
|
traverse<Context>(
|
cb: (this: Context, el: Element<Props>) => void,
|
context?: Context
|
) {}
|
|
protected attrKV(key: string, value: unknown) {
|
if (key === 'textConfig') {
|
this.setTextConfig(value as ElementTextConfig);
|
}
|
else if (key === 'textContent') {
|
this.setTextContent(value as ZRText);
|
}
|
else if (key === 'clipPath') {
|
this.setClipPath(value as Path);
|
}
|
else if (key === 'extra') {
|
this.extra = this.extra || {};
|
extend(this.extra, value);
|
}
|
else {
|
(this as any)[key] = value;
|
}
|
}
|
|
/**
|
* Hide the element
|
*/
|
hide() {
|
this.ignore = true;
|
this.markRedraw();
|
}
|
|
/**
|
* Show the element
|
*/
|
show() {
|
this.ignore = false;
|
this.markRedraw();
|
}
|
|
attr(keyOrObj: Props): this
|
attr<T extends keyof Props>(keyOrObj: T, value: Props[T]): this
|
attr(keyOrObj: keyof Props | Props, value?: unknown): this {
|
if (typeof keyOrObj === 'string') {
|
this.attrKV(keyOrObj as keyof ElementProps, value as AllPropTypes<ElementProps>);
|
}
|
else if (isObject(keyOrObj)) {
|
let obj = keyOrObj as object;
|
let keysArr = keys(obj);
|
for (let i = 0; i < keysArr.length; i++) {
|
let key = keysArr[i];
|
this.attrKV(key as keyof ElementProps, keyOrObj[key]);
|
}
|
}
|
this.markRedraw();
|
return this;
|
}
|
|
// Save current state to normal
|
saveCurrentToNormalState(toState: ElementState) {
|
this._innerSaveToNormal(toState);
|
|
// If we are switching from normal to other state during animation.
|
// We need to save final value of animation to the normal state. Not interpolated value.
|
const normalState = this._normalState;
|
for (let i = 0; i < this.animators.length; i++) {
|
const animator = this.animators[i];
|
const fromStateTransition = animator.__fromStateTransition;
|
// Ignore animation from state transition(except normal).
|
// Ignore loop animation.
|
if (animator.getLoop() || fromStateTransition && fromStateTransition !== PRESERVED_NORMAL_STATE) {
|
continue;
|
}
|
|
const targetName = animator.targetName;
|
// Respecting the order of animation if multiple animator is
|
// animating on the same property(If additive animation is used)
|
const target = targetName
|
? (normalState as any)[targetName] : normalState;
|
// Only save keys that are changed by the states.
|
animator.saveTo(target);
|
}
|
}
|
|
protected _innerSaveToNormal(toState: ElementState) {
|
let normalState = this._normalState;
|
if (!normalState) {
|
// Clear previous stored normal states when switching from normalState to otherState.
|
normalState = this._normalState = {};
|
}
|
if (toState.textConfig && !normalState.textConfig) {
|
normalState.textConfig = this.textConfig;
|
}
|
|
this._savePrimaryToNormal(toState, normalState, PRIMARY_STATES_KEYS);
|
}
|
|
protected _savePrimaryToNormal(
|
toState: Dictionary<any>, normalState: Dictionary<any>, primaryKeys: readonly string[]
|
) {
|
for (let i = 0; i < primaryKeys.length; i++) {
|
let key = primaryKeys[i];
|
// Only save property that will be changed by toState
|
// and has not been saved to normalState yet.
|
if (toState[key] != null && !(key in normalState)) {
|
(normalState as any)[key] = (this as any)[key];
|
}
|
}
|
}
|
|
/**
|
* If has any state.
|
*/
|
hasState() {
|
return this.currentStates.length > 0;
|
}
|
|
/**
|
* Get state object
|
*/
|
getState(name: string) {
|
return this.states[name];
|
}
|
|
|
/**
|
* Ensure state exists. If not, will create one and return.
|
*/
|
ensureState(name: string) {
|
const states = this.states;
|
if (!states[name]) {
|
states[name] = {};
|
}
|
return states[name];
|
}
|
|
/**
|
* Clear all states.
|
*/
|
clearStates(noAnimation?: boolean) {
|
this.useState(PRESERVED_NORMAL_STATE, false, noAnimation);
|
// TODO set _normalState to null?
|
}
|
/**
|
* Use state. State is a collection of properties.
|
* Will return current state object if state exists and stateName has been changed.
|
*
|
* @param stateName State name to be switched to
|
* @param keepCurrentState If keep current states.
|
* If not, it will inherit from the normal state.
|
*/
|
useState(stateName: string, keepCurrentStates?: boolean, noAnimation?: boolean, forceUseHoverLayer?: boolean) {
|
// Use preserved word __normal__
|
// TODO: Only restore changed properties when restore to normal???
|
const toNormalState = stateName === PRESERVED_NORMAL_STATE;
|
const hasStates = this.hasState();
|
|
if (!hasStates && toNormalState) {
|
// If switched from normal to normal.
|
return;
|
}
|
|
const currentStates = this.currentStates;
|
const animationCfg = this.stateTransition;
|
|
// No need to change in following cases:
|
// 1. Keep current states. and already being applied before.
|
// 2. Don't keep current states. And new state is same with the only one exists state.
|
if (indexOf(currentStates, stateName) >= 0 && (keepCurrentStates || currentStates.length === 1)) {
|
return;
|
}
|
|
let state;
|
if (this.stateProxy && !toNormalState) {
|
state = this.stateProxy(stateName);
|
}
|
|
if (!state) {
|
state = (this.states && this.states[stateName]);
|
}
|
|
if (!state && !toNormalState) {
|
logError(`State ${stateName} not exists.`);
|
return;
|
}
|
|
if (!toNormalState) {
|
this.saveCurrentToNormalState(state);
|
}
|
|
const useHoverLayer = !!((state && state.hoverLayer) || forceUseHoverLayer);
|
|
if (useHoverLayer) {
|
// Enter hover layer before states update.
|
this._toggleHoverLayerFlag(true);
|
}
|
|
this._applyStateObj(
|
stateName,
|
state,
|
this._normalState,
|
keepCurrentStates,
|
!noAnimation && !this.__inHover && animationCfg && animationCfg.duration > 0,
|
animationCfg
|
);
|
|
// Also set text content.
|
const textContent = this._textContent;
|
const textGuide = this._textGuide;
|
if (textContent) {
|
// Force textContent use hover layer if self is using it.
|
textContent.useState(stateName, keepCurrentStates, noAnimation, useHoverLayer);
|
}
|
if (textGuide) {
|
textGuide.useState(stateName, keepCurrentStates, noAnimation, useHoverLayer);
|
}
|
|
if (toNormalState) {
|
// Clear state
|
this.currentStates = [];
|
// Reset normal state.
|
this._normalState = {};
|
}
|
else {
|
if (!keepCurrentStates) {
|
this.currentStates = [stateName];
|
}
|
else {
|
this.currentStates.push(stateName);
|
}
|
}
|
|
// Update animating target to the new object after state changed.
|
this._updateAnimationTargets();
|
|
this.markRedraw();
|
|
if (!useHoverLayer && this.__inHover) {
|
// Leave hover layer after states update and markRedraw.
|
this._toggleHoverLayerFlag(false);
|
// NOTE: avoid unexpected refresh when moving out from hover layer!!
|
// Only clear from hover layer.
|
this.__dirty &= ~REDRAW_BIT;
|
}
|
|
// Return used state.
|
return state;
|
}
|
|
/**
|
* Apply multiple states.
|
* @param states States list.
|
*/
|
useStates(states: string[], noAnimation?: boolean, forceUseHoverLayer?: boolean) {
|
if (!states.length) {
|
this.clearStates();
|
}
|
else {
|
const stateObjects: ElementState[] = [];
|
const currentStates = this.currentStates;
|
const len = states.length;
|
let notChange = len === currentStates.length;
|
if (notChange) {
|
for (let i = 0; i < len; i++) {
|
if (states[i] !== currentStates[i]) {
|
notChange = false;
|
break;
|
}
|
}
|
}
|
if (notChange) {
|
return;
|
}
|
|
for (let i = 0; i < len; i++) {
|
const stateName = states[i];
|
let stateObj: ElementState;
|
if (this.stateProxy) {
|
stateObj = this.stateProxy(stateName, states);
|
}
|
if (!stateObj) {
|
stateObj = this.states[stateName];
|
}
|
if (stateObj) {
|
stateObjects.push(stateObj);
|
}
|
}
|
|
const lastStateObj = stateObjects[len - 1];
|
const useHoverLayer = !!((lastStateObj && lastStateObj.hoverLayer) || forceUseHoverLayer);
|
if (useHoverLayer) {
|
// Enter hover layer before states update.
|
this._toggleHoverLayerFlag(true);
|
}
|
|
const mergedState = this._mergeStates(stateObjects);
|
const animationCfg = this.stateTransition;
|
|
this.saveCurrentToNormalState(mergedState);
|
|
this._applyStateObj(
|
states.join(','),
|
mergedState,
|
this._normalState,
|
false,
|
!noAnimation && !this.__inHover && animationCfg && animationCfg.duration > 0,
|
animationCfg
|
);
|
|
const textContent = this._textContent;
|
const textGuide = this._textGuide;
|
if (textContent) {
|
textContent.useStates(states, noAnimation, useHoverLayer);
|
}
|
if (textGuide) {
|
textGuide.useStates(states, noAnimation, useHoverLayer);
|
}
|
|
this._updateAnimationTargets();
|
|
// Create a copy
|
this.currentStates = states.slice();
|
this.markRedraw();
|
|
if (!useHoverLayer && this.__inHover) {
|
// Leave hover layer after states update and markRedraw.
|
this._toggleHoverLayerFlag(false);
|
// NOTE: avoid unexpected refresh when moving out from hover layer!!
|
// Only clear from hover layer.
|
this.__dirty &= ~REDRAW_BIT;
|
}
|
}
|
}
|
|
/**
|
* Return if el.silent or any ancestor element has silent true.
|
*/
|
isSilent() {
|
let isSilent = this.silent;
|
let ancestor = this.parent;
|
while (!isSilent && ancestor) {
|
if (ancestor.silent) {
|
isSilent = true;
|
break;
|
}
|
ancestor = ancestor.parent;
|
}
|
return isSilent;
|
}
|
|
/**
|
* Update animation targets when reference is changed.
|
*/
|
private _updateAnimationTargets() {
|
for (let i = 0; i < this.animators.length; i++) {
|
const animator = this.animators[i];
|
if (animator.targetName) {
|
animator.changeTarget((this as any)[animator.targetName]);
|
}
|
}
|
}
|
|
/**
|
* Remove state
|
* @param state State to remove
|
*/
|
removeState(state: string) {
|
const idx = indexOf(this.currentStates, state);
|
if (idx >= 0) {
|
const currentStates = this.currentStates.slice();
|
currentStates.splice(idx, 1);
|
this.useStates(currentStates);
|
}
|
}
|
|
/**
|
* Replace exists state.
|
* @param oldState
|
* @param newState
|
* @param forceAdd If still add when even if replaced target not exists.
|
*/
|
replaceState(oldState: string, newState: string, forceAdd: boolean) {
|
const currentStates = this.currentStates.slice();
|
const idx = indexOf(currentStates, oldState);
|
const newStateExists = indexOf(currentStates, newState) >= 0;
|
if (idx >= 0) {
|
if (!newStateExists) {
|
// Replace the old with the new one.
|
currentStates[idx] = newState;
|
}
|
else {
|
// Only remove the old one.
|
currentStates.splice(idx, 1);
|
}
|
}
|
else if (forceAdd && !newStateExists) {
|
currentStates.push(newState);
|
}
|
this.useStates(currentStates);
|
}
|
|
/**
|
* Toogle state.
|
*/
|
toggleState(state: string, enable: boolean) {
|
if (enable) {
|
this.useState(state, true);
|
}
|
else {
|
this.removeState(state);
|
}
|
}
|
|
protected _mergeStates(states: ElementState[]) {
|
const mergedState: ElementState = {};
|
let mergedTextConfig: ElementTextConfig;
|
for (let i = 0; i < states.length; i++) {
|
const state = states[i];
|
extend(mergedState, state);
|
|
if (state.textConfig) {
|
mergedTextConfig = mergedTextConfig || {};
|
extend(mergedTextConfig, state.textConfig);
|
}
|
}
|
if (mergedTextConfig) {
|
mergedState.textConfig = mergedTextConfig;
|
}
|
|
return mergedState;
|
}
|
|
protected _applyStateObj(
|
stateName: string,
|
state: ElementState,
|
normalState: ElementState,
|
keepCurrentStates: boolean,
|
transition: boolean,
|
animationCfg: ElementAnimateConfig
|
) {
|
const needsRestoreToNormal = !(state && keepCurrentStates);
|
|
// TODO: Save current state to normal?
|
// TODO: Animation
|
if (state && state.textConfig) {
|
// Inherit from current state or normal state.
|
this.textConfig = extend(
|
{},
|
keepCurrentStates ? this.textConfig : normalState.textConfig
|
);
|
extend(this.textConfig, state.textConfig);
|
}
|
else if (needsRestoreToNormal) {
|
if (normalState.textConfig) { // Only restore if changed and saved.
|
this.textConfig = normalState.textConfig;
|
}
|
}
|
|
const transitionTarget: Dictionary<any> = {};
|
let hasTransition = false;
|
|
for (let i = 0; i < PRIMARY_STATES_KEYS.length; i++) {
|
const key = PRIMARY_STATES_KEYS[i];
|
const propNeedsTransition = transition && DEFAULT_ANIMATABLE_MAP[key];
|
|
if (state && state[key] != null) {
|
if (propNeedsTransition) {
|
hasTransition = true;
|
transitionTarget[key] = state[key];
|
}
|
else {
|
// Replace if it exist in target state
|
(this as any)[key] = state[key];
|
}
|
}
|
else if (needsRestoreToNormal) {
|
if (normalState[key] != null) {
|
if (propNeedsTransition) {
|
hasTransition = true;
|
transitionTarget[key] = normalState[key];
|
}
|
else {
|
// Restore to normal state
|
(this as any)[key] = normalState[key];
|
}
|
}
|
}
|
}
|
|
if (!transition) {
|
// Keep the running animation to the new values after states changed.
|
// Not simply stop animation. Or it may have jump effect.
|
for (let i = 0; i < this.animators.length; i++) {
|
const animator = this.animators[i];
|
const targetName = animator.targetName;
|
// Ignore loop animation
|
if (!animator.getLoop()) {
|
animator.__changeFinalValue(targetName
|
? ((state || normalState) as any)[targetName]
|
: (state || normalState)
|
);
|
}
|
}
|
}
|
|
if (hasTransition) {
|
this._transitionState(
|
stateName,
|
transitionTarget as Props,
|
animationCfg
|
);
|
}
|
}
|
|
/**
|
* Component is some elements attached on this element for specific purpose.
|
* Like clipPath, textContent
|
*/
|
private _attachComponent(componentEl: Element) {
|
if (componentEl.__zr && !componentEl.__hostTarget) {
|
if (process.env.NODE_ENV !== 'production') {
|
throw new Error('Text element has been added to zrender.');
|
}
|
return;
|
}
|
|
if (componentEl === this) {
|
if (process.env.NODE_ENV !== 'production') {
|
throw new Error('Recursive component attachment.');
|
}
|
return;
|
}
|
|
const zr = this.__zr;
|
if (zr) {
|
// Needs to add self to zrender. For rerender triggering, or animation.
|
componentEl.addSelfToZr(zr);
|
}
|
|
componentEl.__zr = zr;
|
componentEl.__hostTarget = this as unknown as Element;
|
}
|
|
private _detachComponent(componentEl: Element) {
|
if (componentEl.__zr) {
|
componentEl.removeSelfFromZr(componentEl.__zr);
|
}
|
|
componentEl.__zr = null;
|
componentEl.__hostTarget = null;
|
}
|
|
/**
|
* Get clip path
|
*/
|
getClipPath() {
|
return this._clipPath;
|
}
|
|
/**
|
* Set clip path
|
*
|
* clipPath can't be shared between two elements.
|
*/
|
setClipPath(clipPath: Path) {
|
// Remove previous clip path
|
if (this._clipPath && this._clipPath !== clipPath) {
|
this.removeClipPath();
|
}
|
|
this._attachComponent(clipPath);
|
|
this._clipPath = clipPath;
|
this.markRedraw();
|
}
|
|
/**
|
* Remove clip path
|
*/
|
removeClipPath() {
|
const clipPath = this._clipPath;
|
if (clipPath) {
|
this._detachComponent(clipPath);
|
this._clipPath = null;
|
this.markRedraw();
|
}
|
}
|
|
/**
|
* Get attached text content.
|
*/
|
getTextContent(): ZRText {
|
return this._textContent;
|
}
|
|
/**
|
* Attach text on element
|
*/
|
setTextContent(textEl: ZRText) {
|
const previousTextContent = this._textContent;
|
if (previousTextContent === textEl) {
|
return;
|
}
|
// Remove previous textContent
|
if (previousTextContent && previousTextContent !== textEl) {
|
this.removeTextContent();
|
}
|
if (process.env.NODE_ENV !== 'production') {
|
if (textEl.__zr && !textEl.__hostTarget) {
|
throw new Error('Text element has been added to zrender.');
|
}
|
}
|
|
textEl.innerTransformable = new Transformable();
|
|
this._attachComponent(textEl);
|
|
this._textContent = textEl;
|
|
this.markRedraw();
|
}
|
|
/**
|
* Set layout of attached text. Will merge with the previous.
|
*/
|
setTextConfig(cfg: ElementTextConfig) {
|
// TODO hide cfg property?
|
if (!this.textConfig) {
|
this.textConfig = {};
|
}
|
extend(this.textConfig, cfg);
|
this.markRedraw();
|
}
|
|
/**
|
* Remove text config
|
*/
|
removeTextConfig() {
|
this.textConfig = null;
|
this.markRedraw();
|
}
|
|
/**
|
* Remove attached text element.
|
*/
|
removeTextContent() {
|
const textEl = this._textContent;
|
if (textEl) {
|
textEl.innerTransformable = null;
|
this._detachComponent(textEl);
|
this._textContent = null;
|
this._innerTextDefaultStyle = null;
|
this.markRedraw();
|
}
|
}
|
|
getTextGuideLine(): Polyline {
|
return this._textGuide;
|
}
|
|
setTextGuideLine(guideLine: Polyline) {
|
// Remove previous clip path
|
if (this._textGuide && this._textGuide !== guideLine) {
|
this.removeTextGuideLine();
|
}
|
|
this._attachComponent(guideLine);
|
|
this._textGuide = guideLine;
|
|
this.markRedraw();
|
}
|
|
removeTextGuideLine() {
|
const textGuide = this._textGuide;
|
if (textGuide) {
|
this._detachComponent(textGuide);
|
this._textGuide = null;
|
this.markRedraw();
|
}
|
}
|
/**
|
* Mark element needs to be repainted
|
*/
|
markRedraw() {
|
this.__dirty |= REDRAW_BIT;
|
const zr = this.__zr;
|
if (zr) {
|
if (this.__inHover) {
|
zr.refreshHover();
|
}
|
else {
|
zr.refresh();
|
}
|
}
|
|
// Used as a clipPath or textContent
|
if (this.__hostTarget) {
|
this.__hostTarget.markRedraw();
|
}
|
}
|
|
/**
|
* Besides marking elements to be refreshed.
|
* It will also invalid all cache and doing recalculate next frame.
|
*/
|
dirty() {
|
this.markRedraw();
|
}
|
|
private _toggleHoverLayerFlag(inHover: boolean) {
|
this.__inHover = inHover;
|
const textContent = this._textContent;
|
const textGuide = this._textGuide;
|
if (textContent) {
|
textContent.__inHover = inHover;
|
}
|
if (textGuide) {
|
textGuide.__inHover = inHover;
|
}
|
}
|
|
/**
|
* Add self from zrender instance.
|
* Not recursively because it will be invoked when element added to storage.
|
*/
|
addSelfToZr(zr: ZRenderType) {
|
if (this.__zr === zr) {
|
return;
|
}
|
|
this.__zr = zr;
|
// 添加动画
|
const animators = this.animators;
|
if (animators) {
|
for (let i = 0; i < animators.length; i++) {
|
zr.animation.addAnimator(animators[i]);
|
}
|
}
|
|
if (this._clipPath) {
|
this._clipPath.addSelfToZr(zr);
|
}
|
if (this._textContent) {
|
this._textContent.addSelfToZr(zr);
|
}
|
if (this._textGuide) {
|
this._textGuide.addSelfToZr(zr);
|
}
|
}
|
|
/**
|
* Remove self from zrender instance.
|
* Not recursively because it will be invoked when element added to storage.
|
*/
|
removeSelfFromZr(zr: ZRenderType) {
|
if (!this.__zr) {
|
return;
|
}
|
|
this.__zr = null;
|
// Remove animation
|
const animators = this.animators;
|
if (animators) {
|
for (let i = 0; i < animators.length; i++) {
|
zr.animation.removeAnimator(animators[i]);
|
}
|
}
|
|
if (this._clipPath) {
|
this._clipPath.removeSelfFromZr(zr);
|
}
|
if (this._textContent) {
|
this._textContent.removeSelfFromZr(zr);
|
}
|
if (this._textGuide) {
|
this._textGuide.removeSelfFromZr(zr);
|
}
|
}
|
|
/**
|
* 动画
|
*
|
* @param path The key to fetch value from object. Mostly style or shape.
|
* @param loop Whether to loop animation.
|
* @param allowDiscreteAnimation Whether to allow discrete animation
|
* @example:
|
* el.animate('style', false)
|
* .when(1000, {x: 10} )
|
* .done(function(){ // Animation done })
|
* .start()
|
*/
|
animate(key?: string, loop?: boolean, allowDiscreteAnimation?: boolean) {
|
let target = key ? (this as any)[key] : this;
|
|
if (process.env.NODE_ENV !== 'production') {
|
if (!target) {
|
logError(
|
'Property "'
|
+ key
|
+ '" is not existed in element '
|
+ this.id
|
);
|
return;
|
}
|
}
|
|
const animator = new Animator(target, loop, allowDiscreteAnimation);
|
key && (animator.targetName = key);
|
this.addAnimator(animator, key);
|
return animator;
|
}
|
|
addAnimator(animator: Animator<any>, key: string): void {
|
const zr = this.__zr;
|
|
const el = this;
|
|
animator.during(function () {
|
el.updateDuringAnimation(key as string);
|
}).done(function () {
|
const animators = el.animators;
|
// FIXME Animator will not be removed if use `Animator#stop` to stop animation
|
const idx = indexOf(animators, animator);
|
if (idx >= 0) {
|
animators.splice(idx, 1);
|
}
|
});
|
|
this.animators.push(animator);
|
|
// If animate after added to the zrender
|
if (zr) {
|
zr.animation.addAnimator(animator);
|
}
|
|
// Wake up zrender to start the animation loop.
|
zr && zr.wakeUp();
|
}
|
|
updateDuringAnimation(key: string) {
|
this.markRedraw();
|
}
|
|
/**
|
* 停止动画
|
* @param {boolean} forwardToLast If move to last frame before stop
|
*/
|
stopAnimation(scope?: string, forwardToLast?: boolean) {
|
const animators = this.animators;
|
const len = animators.length;
|
const leftAnimators: Animator<any>[] = [];
|
for (let i = 0; i < len; i++) {
|
const animator = animators[i];
|
if (!scope || scope === animator.scope) {
|
animator.stop(forwardToLast);
|
}
|
else {
|
leftAnimators.push(animator);
|
}
|
}
|
this.animators = leftAnimators;
|
|
return this;
|
}
|
|
/**
|
* @param animationProps A map to specify which property to animate. If not specified, will animate all.
|
* @example
|
* // Animate position
|
* el.animateTo({
|
* position: [10, 10]
|
* }, { done: () => { // done } })
|
*
|
* // Animate shape, style and position in 100ms, delayed 100ms, with cubicOut easing
|
* el.animateTo({
|
* shape: {
|
* width: 500
|
* },
|
* style: {
|
* fill: 'red'
|
* }
|
* position: [10, 10]
|
* }, {
|
* duration: 100,
|
* delay: 100,
|
* easing: 'cubicOut',
|
* done: () => { // done }
|
* })
|
*/
|
animateTo(target: Props, cfg?: ElementAnimateConfig, animationProps?: MapToType<Props, boolean>) {
|
animateTo(this, target, cfg, animationProps);
|
}
|
|
/**
|
* Animate from the target state to current state.
|
* The params and the value are the same as `this.animateTo`.
|
*/
|
|
// Overload definitions
|
animateFrom(
|
target: Props, cfg: ElementAnimateConfig, animationProps?: MapToType<Props, boolean>
|
) {
|
animateTo(this, target, cfg, animationProps, true);
|
}
|
|
protected _transitionState(
|
stateName: string, target: Props, cfg?: ElementAnimateConfig, animationProps?: MapToType<Props, boolean>
|
) {
|
const animators = animateTo(this, target, cfg, animationProps);
|
for (let i = 0; i < animators.length; i++) {
|
animators[i].__fromStateTransition = stateName;
|
}
|
}
|
|
/**
|
* Interface of getting the minimum bounding box.
|
*/
|
getBoundingRect(): BoundingRect {
|
return null;
|
}
|
|
getPaintRect(): BoundingRect {
|
return null;
|
}
|
|
/**
|
* 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 {Obejct} [out] Prepared out object. If not provided, this method should
|
* be responsible for creating one.
|
* @param {module:zrender/graphic/Style} style
|
* @param {Object} rect {x, y, width, height}
|
* @return {Obejct} out The same as the input out.
|
* {
|
* x: number. mandatory.
|
* y: number. mandatory.
|
* align: string. optional. use style.textAlign by default.
|
* verticalAlign: string. optional. use style.textVerticalAlign by default.
|
* }
|
*/
|
calculateTextPosition: ElementCalculateTextPosition;
|
|
protected static initDefaultProps = (function () {
|
const elProto = Element.prototype;
|
elProto.type = 'element';
|
elProto.name = '';
|
|
elProto.ignore =
|
elProto.silent =
|
elProto.isGroup =
|
elProto.draggable =
|
elProto.dragging =
|
elProto.ignoreClip =
|
elProto.__inHover = false;
|
|
elProto.__dirty = REDRAW_BIT;
|
|
|
const logs: Dictionary<boolean> = {};
|
function logDeprecatedError(key: string, xKey: string, yKey: string) {
|
if (!logs[key + xKey + yKey]) {
|
console.warn(`DEPRECATED: '${key}' has been deprecated. use '${xKey}', '${yKey}' instead`);
|
logs[key + xKey + yKey] = true;
|
}
|
}
|
// Legacy transform properties. position and scale
|
function createLegacyProperty(
|
key: string,
|
privateKey: string,
|
xKey: string,
|
yKey: string
|
) {
|
Object.defineProperty(elProto, key, {
|
get() {
|
if (process.env.NODE_ENV !== 'production') {
|
logDeprecatedError(key, xKey, yKey);
|
}
|
if (!this[privateKey]) {
|
const pos: number[] = this[privateKey] = [];
|
enhanceArray(this, pos);
|
}
|
return this[privateKey];
|
},
|
set(pos: number[]) {
|
if (process.env.NODE_ENV !== 'production') {
|
logDeprecatedError(key, xKey, yKey);
|
}
|
this[xKey] = pos[0];
|
this[yKey] = pos[1];
|
this[privateKey] = pos;
|
enhanceArray(this, pos);
|
}
|
});
|
function enhanceArray(self: any, pos: number[]) {
|
Object.defineProperty(pos, 0, {
|
get() {
|
return self[xKey];
|
},
|
set(val: number) {
|
self[xKey] = val;
|
}
|
});
|
Object.defineProperty(pos, 1, {
|
get() {
|
return self[yKey];
|
},
|
set(val: number) {
|
self[yKey] = val;
|
}
|
});
|
}
|
}
|
if (Object.defineProperty
|
// Just don't support ie8
|
// && (!(env as any).browser.ie || (env as any).browser.version > 8)
|
) {
|
createLegacyProperty('position', '_legacyPos', 'x', 'y');
|
createLegacyProperty('scale', '_legacyScale', 'scaleX', 'scaleY');
|
createLegacyProperty('origin', '_legacyOrigin', 'originX', 'originY');
|
}
|
})()
|
}
|
|
mixin(Element, Eventful);
|
mixin(Element, Transformable);
|
|
function animateTo<T>(
|
animatable: Element<T>,
|
target: Dictionary<any>,
|
cfg: ElementAnimateConfig,
|
animationProps: Dictionary<any>,
|
reverse?: boolean
|
) {
|
cfg = cfg || {};
|
const animators: Animator<any>[] = [];
|
animateToShallow(
|
animatable,
|
'',
|
animatable,
|
target,
|
cfg,
|
animationProps,
|
animators,
|
reverse
|
);
|
|
let finishCount = animators.length;
|
let doneHappened = false;
|
const cfgDone = cfg.done;
|
const cfgAborted = cfg.aborted;
|
|
const doneCb = () => {
|
doneHappened = true;
|
finishCount--;
|
if (finishCount <= 0) {
|
doneHappened
|
? (cfgDone && cfgDone())
|
: (cfgAborted && cfgAborted());
|
}
|
};
|
|
const abortedCb = () => {
|
finishCount--;
|
if (finishCount <= 0) {
|
doneHappened
|
? (cfgDone && cfgDone())
|
: (cfgAborted && cfgAborted());
|
}
|
};
|
|
// No animators. This should be checked before animators[i].start(),
|
// because 'done' may be executed immediately if no need to animate.
|
if (!finishCount) {
|
cfgDone && cfgDone();
|
}
|
|
// Adding during callback to the first animator
|
if (animators.length > 0 && cfg.during) {
|
// TODO If there are two animators in animateTo, and the first one is stopped by other animator.
|
animators[0].during((target, percent) => {
|
cfg.during(percent);
|
});
|
}
|
|
// Start after all animators created
|
// Incase any animator is done immediately when all animation properties are not changed
|
for (let i = 0; i < animators.length; i++) {
|
const animator = animators[i];
|
if (doneCb) {
|
animator.done(doneCb);
|
}
|
if (abortedCb) {
|
animator.aborted(abortedCb);
|
}
|
if (cfg.force) {
|
animator.duration(cfg.duration);
|
}
|
animator.start(cfg.easing);
|
}
|
|
return animators;
|
}
|
|
function copyArrShallow(source: number[], target: number[], len: number) {
|
for (let i = 0; i < len; i++) {
|
source[i] = target[i];
|
}
|
}
|
|
function is2DArray(value: any[]): value is number[][] {
|
return isArrayLike(value[0]);
|
}
|
|
function copyValue(target: Dictionary<any>, source: Dictionary<any>, key: string) {
|
if (isArrayLike(source[key])) {
|
if (!isArrayLike(target[key])) {
|
target[key] = [];
|
}
|
|
if (isTypedArray(source[key])) {
|
const len = source[key].length;
|
if (target[key].length !== len) {
|
target[key] = new (source[key].constructor)(len);
|
copyArrShallow(target[key], source[key], len);
|
}
|
}
|
else {
|
const sourceArr = source[key] as any[];
|
const targetArr = target[key] as any[];
|
|
const len0 = sourceArr.length;
|
if (is2DArray(sourceArr)) {
|
// NOTE: each item should have same length
|
const len1 = sourceArr[0].length;
|
|
for (let i = 0; i < len0; i++) {
|
if (!targetArr[i]) {
|
targetArr[i] = Array.prototype.slice.call(sourceArr[i]);
|
}
|
else {
|
copyArrShallow(targetArr[i], sourceArr[i], len1);
|
}
|
}
|
}
|
else {
|
copyArrShallow(targetArr, sourceArr, len0);
|
}
|
|
targetArr.length = sourceArr.length;
|
}
|
}
|
else {
|
target[key] = source[key];
|
}
|
}
|
|
function isValueSame(val1: any, val2: any) {
|
return val1 === val2
|
// Only check 1 dimension array
|
|| isArrayLike(val1) && isArrayLike(val2) && is1DArraySame(val1, val2);
|
}
|
|
function is1DArraySame(arr0: ArrayLike<number>, arr1: ArrayLike<number>) {
|
const len = arr0.length;
|
if (len !== arr1.length) {
|
return false;
|
}
|
for (let i = 0; i < len; i++) {
|
if (arr0[i] !== arr1[i]) {
|
return false;
|
}
|
}
|
return true;
|
}
|
|
function animateToShallow<T>(
|
animatable: Element<T>,
|
topKey: string,
|
animateObj: Dictionary<any>,
|
target: Dictionary<any>,
|
cfg: ElementAnimateConfig,
|
animationProps: Dictionary<any> | true,
|
animators: Animator<any>[],
|
reverse: boolean // If `true`, animate from the `target` to current state.
|
) {
|
const targetKeys = keys(target);
|
const duration = cfg.duration;
|
const delay = cfg.delay;
|
const additive = cfg.additive;
|
const setToFinal = cfg.setToFinal;
|
const animateAll = !isObject(animationProps);
|
// Find last animator animating same prop.
|
const existsAnimators = animatable.animators;
|
|
let animationKeys: string[] = [];
|
for (let k = 0; k < targetKeys.length; k++) {
|
const innerKey = targetKeys[k] as string;
|
const targetVal = target[innerKey];
|
|
if (
|
targetVal != null && animateObj[innerKey] != null
|
&& (animateAll || (animationProps as Dictionary<any>)[innerKey])
|
) {
|
if (isObject(targetVal)
|
&& !isArrayLike(targetVal)
|
&& !isGradientObject(targetVal)
|
) {
|
if (topKey) {
|
// logError('Only support 1 depth nest object animation.');
|
// Assign directly.
|
// TODO richText?
|
if (!reverse) {
|
animateObj[innerKey] = targetVal;
|
animatable.updateDuringAnimation(topKey);
|
}
|
continue;
|
}
|
animateToShallow(
|
animatable,
|
innerKey,
|
animateObj[innerKey],
|
targetVal,
|
cfg,
|
animationProps && (animationProps as Dictionary<any>)[innerKey],
|
animators,
|
reverse
|
);
|
}
|
else {
|
animationKeys.push(innerKey);
|
}
|
}
|
else if (!reverse) {
|
// Assign target value directly.
|
animateObj[innerKey] = targetVal;
|
animatable.updateDuringAnimation(topKey);
|
// Previous animation will be stopped on the changed keys.
|
// So direct assign is also included.
|
animationKeys.push(innerKey);
|
}
|
}
|
|
let keyLen = animationKeys.length;
|
// Stop previous animations on the same property.
|
if (!additive && keyLen) {
|
// Stop exists animation on specific tracks. Only one animator available for each property.
|
// TODO Should invoke previous animation callback?
|
for (let i = 0; i < existsAnimators.length; i++) {
|
const animator = existsAnimators[i];
|
if (animator.targetName === topKey) {
|
const allAborted = animator.stopTracks(animationKeys);
|
if (allAborted) { // This animator can't be used.
|
const idx = indexOf(existsAnimators, animator);
|
existsAnimators.splice(idx, 1);
|
}
|
}
|
}
|
}
|
|
// Ignore values not changed.
|
// NOTE: Must filter it after previous animation stopped
|
// and make sure the value to compare is using initial frame if animation is not started yet when setToFinal is used.
|
if (!cfg.force) {
|
animationKeys = filter(animationKeys, key => !isValueSame(target[key], animateObj[key]));
|
keyLen = animationKeys.length;
|
}
|
|
if (keyLen > 0
|
// cfg.force is mainly for keep invoking onframe and ondone callback even if animation is not necessary.
|
// So if there is already has animators. There is no need to create another animator if not necessary.
|
// Or it will always add one more with empty target.
|
|| (cfg.force && !animators.length)
|
) {
|
let revertedSource: Dictionary<any>;
|
let reversedTarget: Dictionary<any>;
|
let sourceClone: Dictionary<any>;
|
if (reverse) {
|
reversedTarget = {};
|
if (setToFinal) {
|
revertedSource = {};
|
}
|
for (let i = 0; i < keyLen; i++) {
|
const innerKey = animationKeys[i];
|
reversedTarget[innerKey] = animateObj[innerKey];
|
if (setToFinal) {
|
revertedSource[innerKey] = target[innerKey];
|
}
|
else {
|
// The usage of "animateFrom" expects that the element props has been updated dirctly to
|
// "final" values outside, and input the "from" values here (i.e., in variable `target` here).
|
// So here we assign the "from" values directly to element here (rather that in the next frame)
|
// to prevent the "final" values from being read in any other places (like other running
|
// animator during callbacks).
|
// But if `setToFinal: true` this feature can not be satisfied.
|
animateObj[innerKey] = target[innerKey];
|
}
|
}
|
}
|
else if (setToFinal) {
|
sourceClone = {};
|
for (let i = 0; i < keyLen; i++) {
|
const innerKey = animationKeys[i];
|
// NOTE: Must clone source after the stopTracks. The property may be modified in stopTracks.
|
sourceClone[innerKey] = cloneValue(animateObj[innerKey]);
|
// Use copy, not change the original reference
|
// Copy from target to source.
|
copyValue(animateObj, target, innerKey);
|
}
|
}
|
|
const animator = new Animator(animateObj, false, false, additive ? filter(
|
// Use key string instead object reference because ref may be changed.
|
existsAnimators, animator => animator.targetName === topKey
|
) : null);
|
|
animator.targetName = topKey;
|
if (cfg.scope) {
|
animator.scope = cfg.scope;
|
}
|
|
if (setToFinal && revertedSource) {
|
animator.whenWithKeys(0, revertedSource, animationKeys);
|
}
|
if (sourceClone) {
|
animator.whenWithKeys(0, sourceClone, animationKeys);
|
}
|
|
animator.whenWithKeys(
|
duration == null ? 500 : duration,
|
reverse ? reversedTarget : target,
|
animationKeys
|
).delay(delay || 0);
|
|
animatable.addAnimator(animator, topKey);
|
animators.push(animator);
|
}
|
}
|
|
|
export default Element;
|