Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: enhance effect for typewriter #196

Merged
merged 1 commit into from
Dec 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
195 changes: 127 additions & 68 deletions packages/vstory-animate/src/customAnimates/typewirter.ts
Original file line number Diff line number Diff line change
@@ -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<string, any> {
if (this.valid === false) {
Expand All @@ -17,93 +38,131 @@ 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;
}

onUpdate(end: boolean, ratio: number, out: Record<string, any>): void {
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
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,17 @@ 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 = '';
let to = text;
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);
}
Expand Down
5 changes: 5 additions & 0 deletions packages/vstory/demo/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
| {
Expand Down Expand Up @@ -227,6 +228,10 @@ const App = () => {
{
name: 'LabelItemAnimate',
component: LabelItemAnimate
},
{
name: 'TextComponent',
component: TextComponent
}
]
},
Expand Down
88 changes: 88 additions & 0 deletions packages/vstory/demo/src/demos/component/text.tsx
Original file line number Diff line number Diff line change
@@ -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 <div style={{ width: '100%', height: '100%' }} id={id}></div>;
};
Loading