diff --git a/packages/vstory-animate/src/customAnimates/typewirter.ts b/packages/vstory-animate/src/customAnimates/typewirter.ts index e32c8ec9..113428ec 100644 --- a/packages/vstory-animate/src/customAnimates/typewirter.ts +++ b/packages/vstory-animate/src/customAnimates/typewirter.ts @@ -1,11 +1,32 @@ import { ACustomAnimate, createLine, getTextBounds, registerShadowRootGraphic } from '@visactor/vrender'; -import type { IGraphic, IRichText, IRichTextCharacter, ITextGraphicAttribute } from '@visactor/vrender'; +import type { EasingType, IGraphic, IRichText, IRichTextCharacter, ITextGraphicAttribute } from '@visactor/vrender'; import { clone, cloneDeep, isArray } from '@visactor/vutils'; +import { Easing } from '@visactor/vrender'; registerShadowRootGraphic(); + +type ITypeWriterParams = { + text: string; + effect: 'default' | 'blur' | 'scale'; + blur: number; + scale: number; + delta: number; +}; + export class TypeWriter extends ACustomAnimate<{ text: string }> { declare valid: boolean; declare target: IRichText; declare targetTextConfig: IRichTextCharacter[]; + declare originTextConfig: IRichTextCharacter[]; + + constructor( + from: { text: string }, + to: { text: string }, + duration: number, + easing: EasingType, + params: ITypeWriterParams + ) { + super(from, to, duration, easing, params); + } getEndProps(): Record { if (this.valid === false) { @@ -17,28 +38,25 @@ export class TypeWriter extends ACustomAnimate<{ text: string }> { } onBind(): void { - const root = this.target.attachShadow(); - const fontSize = this.target.getComputedAttribute('fontSize'); - this.target.attribute.textConfig; - const line = createLine({ - x: 0, - y: 0, - dy: -fontSize / 2, - points: [ - { x: 0, y: 0 }, - { x: 0, y: fontSize } - ], - stroke: 'black', - // TODO 有bug,不展示 - opacity: 0, - lineWidth: 1 + this.targetTextConfig = []; + (this.target.attribute.textConfig || []).forEach(config => { + if (!(config as any).text) { + this.targetTextConfig.push(config); + } else { + Array.from((config as any).text).forEach(str => { + this.targetTextConfig.push({ + ...config, + text: str, + _opacity: (config as any).opacity + } as any); + }); + } }); - root.add(line); - this.targetTextConfig = cloneDeep(this.target.attribute.textConfig || []); + this.originTextConfig = cloneDeep(this.target.attribute.textConfig || []); } onEnd(): void { - this.target.detachShadow(); + this.target.setAttributes({ textConfig: this.originTextConfig }); return; } @@ -46,64 +64,105 @@ export class TypeWriter extends ACustomAnimate<{ text: string }> { if (this.valid === false) { return; } - // update text - // const { textConfig = [] } = this.target.attribute; - const totalLength = this.targetTextConfig.reduce( - (a, b) => a + ((b as any).text ? (b as any).text.toString().length : 1), - 0 - ); - const nextLength = totalLength * ratio; - const nextTextConfig: IRichTextCharacter[] = []; - let curLen = 0; - this.targetTextConfig.forEach(config => { - if (curLen >= nextLength) { - return; - } - const len = (config as any).text ? (config as any).text.toString().length : 1; - if (curLen + len < nextLength) { - nextTextConfig.push(config); - curLen += len; + const delta = this.params.delta ?? 0.3; + const totalLength = this.targetTextConfig.length; + const delayStep = (1 - delta) / (totalLength - 1); + + for (let i = 0; i < this.targetTextConfig.length; i++) { + const config = this.targetTextConfig[i]; + const opacity = (config as any)._opacity ?? 1; + const delay = i * delayStep; + if (ratio > delay) { + (config as any).opacity = opacity; } else { - nextTextConfig.push({ - ...config, - text: (config as any).text.substr(0, nextLength - curLen) - }); - curLen = nextLength; + (config as any).opacity = 0; } + } + const { effect = 'default' } = this.params; + if (effect === 'default') { + this.onUpdateDefault(ratio, delta, this.params.characterEasing); + } else if (effect === 'blur') { + this.onUpdateBlur(ratio, delta, this.params.characterEasing); + } else if (effect === 'scale') { + this.onUpdateScale(ratio, delta, this.params.characterEasing); + } + } + + onUpdateDefault(ratio: number, delta: number, easing: string = 'linear') { + const nextTextConfig = [...this.targetTextConfig]; + + this.target.setAttributes({ + textConfig: nextTextConfig }); + } + + onUpdateBlur(ratio: number, delta: number, easing: string = 'linear') { + const totalLength = this.targetTextConfig.length; + const delayStep = (1 - delta) / (totalLength - 1); + + // TODO 后续使用blur代替,暂时基于opacity实现 + const easingFunc = (Easing as any)[easing] ?? Easing.linear; + for (let i = 0; i < this.targetTextConfig.length; i++) { + const config = this.targetTextConfig[i]; + const opacity = (config as any)._opacity ?? 1; + const delay = i * delayStep; + if (ratio > delay) { + (config as any).opacity = opacity * easingFunc(Math.min((ratio - delay) / delta, 1)); + } else { + (config as any).opacity = 0; + } + } + + const nextTextConfig = [...this.targetTextConfig]; + this.target.setAttributes({ textConfig: nextTextConfig }); + } + onUpdateFadeUp(ratio: number, delta: number, easing: string = 'linear') { + const totalLength = this.targetTextConfig.length; + const delayStep = (1 - delta) / (totalLength - 1); - const cache = this.target.getFrameCache(); - if (!(cache.lines && cache.lines.length)) { - return; + // TODO 暂不支持 + const deltaY = this.params.dy ?? 20; + const easingFunc = (Easing as any)[easing] ?? Easing.linear; + for (let i = 0; i < this.targetTextConfig.length; i++) { + const config = this.targetTextConfig[i]; + const opacity = (config as any)._opacity ?? 1; + const delay = i * delayStep; + if (ratio > delay) { + (config as any).opacity = opacity * easingFunc(Math.min((ratio - delay) / delta, 1)); + } else { + (config as any).opacity = 0; + } } - const lastLine = cache.lines[cache.lines.length - 1]; - const x = cache.left + lastLine.left + lastLine.actualWidth; - const y = cache.top + lastLine.top; - const h = lastLine.paragraphs?.[lastLine.paragraphs.length - 1]?.fontSize || lastLine.height; - const line = this.target.shadowRoot?.at(0) as IGraphic; - - // console.log(x, y, h, line); - // const attr = { ...this.target.attribute, ...out }; - // const width = getTextBounds(attr).width(); - // const { textAlign } = attr as ITextGraphicAttribute; - // let x = width; - let dx = 0; - if (lastLine.textAlign === 'center') { - dx = -lastLine.actualWidth / 2; - } else if (lastLine.textAlign === 'right') { - dx = -lastLine.actualWidth; + + const nextTextConfig = [...this.targetTextConfig]; + + this.target.setAttributes({ + textConfig: nextTextConfig + }); + } + onUpdateScale(ratio: number, delta: number, easing: string = 'linear') { + const totalLength = this.targetTextConfig.length; + const delayStep = (1 - delta) / (totalLength - 1); + + // blur + const easingFunc = (Easing as any)[easing] ?? Easing.linear; + for (let i = 0; i < this.targetTextConfig.length; i++) { + const config = this.targetTextConfig[i]; + const fontSize = (config as any)._fontSize ?? this.target.attribute.fontSize ?? 12; + (config as any)._fontSize = fontSize; + const delay = i * delayStep; + if (ratio > delay) { + (config as any).fontSize = fontSize * easingFunc(Math.min((ratio - delay) / delta, 1)); + } } - line.setAttributes({ - x: x + dx, - y, - points: [ - { x: 0, y: 0 }, - { x: 0, y: h } - ] - } as any); + const nextTextConfig = [...this.targetTextConfig]; + + this.target.setAttributes({ + textConfig: nextTextConfig + }); } } diff --git a/packages/vstory-player/src/processor/component/text/text-visibility.ts b/packages/vstory-player/src/processor/component/text/text-visibility.ts index 4db28830..beb54e5f 100644 --- a/packages/vstory-player/src/processor/component/text/text-visibility.ts +++ b/packages/vstory-player/src/processor/component/text/text-visibility.ts @@ -26,7 +26,7 @@ export class TypeWriterVisibility extends BaseVisibility { } protected _run(graphic: IGraphic, params: ITypeWriterParams, appear: boolean) { if (graphic && (graphic.type === 'text' || graphic.type === 'richtext')) { - const { duration, easing } = params; + const { duration, easing, params: typewriterParams } = params; const { text } = graphic.attribute as any; if (isString(text)) { let from = ''; @@ -34,7 +34,9 @@ export class TypeWriterVisibility extends BaseVisibility { if (!appear) { [from, to] = [to, from]; } - const a = graphic.animate().play(new TypeWriter({ text: from }, { text: to }, duration, easing as EasingType)); + const a = graphic + .animate() + .play(new TypeWriter({ text: from }, { text: to }, duration, easing as EasingType, typewriterParams)); if (!appear) { a.reversed(true); } diff --git a/packages/vstory/demo/src/App.tsx b/packages/vstory/demo/src/App.tsx index 43656697..2ab2dbac 100644 --- a/packages/vstory/demo/src/App.tsx +++ b/packages/vstory/demo/src/App.tsx @@ -57,6 +57,7 @@ import { CellStyle } from './demos/table/runtime/cell-style'; import { ColWidth } from './demos/table/runtime/col-width'; import { RowHeight } from './demos/table/runtime/row-height'; import { PivotChartBase } from './demos/table/runtime/pivot-chart-base'; +import { TextComponent } from './demos/component/text'; type MenusType = ( | { @@ -227,6 +228,10 @@ const App = () => { { name: 'LabelItemAnimate', component: LabelItemAnimate + }, + { + name: 'TextComponent', + component: TextComponent } ] }, diff --git a/packages/vstory/demo/src/demos/component/text.tsx b/packages/vstory/demo/src/demos/component/text.tsx new file mode 100644 index 00000000..c5644e20 --- /dev/null +++ b/packages/vstory/demo/src/demos/component/text.tsx @@ -0,0 +1,88 @@ +import React, { createRef, useEffect } from 'react'; +import { Player, Story } from '../../../../../vstory-core/src'; +import { registerAll } from '../../../../src'; + +registerAll(); + +export const TextComponent = () => { + const id = 'TextComponent'; + useEffect(() => { + const container = document.getElementById(id); + const canvas = document.createElement('canvas'); + container?.appendChild(canvas); + + const story = new Story(null, { canvas, width: 800, height: 500, background: 'pink', scaleX: 0.5, scaleY: 0.5 }); + const player = new Player(story); + story.init(player); + + ['这是普通的一大段内容', '这是Blur的一大段内容', '这是缩放的一大段内容', '这是FadeUp的一大段内容'].forEach( + (text, index) => { + story.addCharacter( + { + type: 'Text', + id: 'title' + index, + zIndex: 1, + position: { + top: 100 + index * 100, + left: 100 + }, + options: { + graphic: { + text: text, + textAlign: 'left', + fontSize: 36, + fontWeight: 'bold', + fill: 'red' + } + } + }, + { + sceneId: 'defaultScene', + actions: [ + { + action: 'appear', + startTime: 1000 * index, + payload: [ + { + animation: { + duration: 1000, + easing: 'linear', + effect: 'typewriter', + params: + index === 0 + ? {} + : index === 1 + ? { + effect: 'blur', + delta: 0.5, + characterEasing: 'cubicOut' + } + : index === 2 + ? { + effect: 'scale', + characterEasing: 'cubicOut' + } + : { + effect: 'fadeUp', + characterEasing: 'cubicOut', + dy: 10 + } + } as any + } + ] + } + ] + } + ); + } + ); + + player.play(-1); + + return () => { + story.release(); + }; + }, []); + + return
; +};