From 24ee1613ed9bc2bb8a8ef05cbc4e0f9570bcdd7a Mon Sep 17 00:00:00 2001 From: unanmed <1319491857@qq.com> Date: Thu, 27 Feb 2025 16:03:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=BB=9A=E5=8A=A8=E6=96=87=E5=AD=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/fx/canvas2d.ts | 12 +- src/module/render/components/misc.tsx | 128 ++++++++++++++++++- src/module/render/components/scroll.tsx | 26 +++- src/module/render/components/textbox.tsx | 46 +++++-- src/module/render/components/textboxTyper.ts | 35 +++-- src/module/render/ui/main.tsx | 3 +- 6 files changed, 218 insertions(+), 32 deletions(-) diff --git a/src/core/fx/canvas2d.ts b/src/core/fx/canvas2d.ts index ac4dd25..b9faa27 100644 --- a/src/core/fx/canvas2d.ts +++ b/src/core/fx/canvas2d.ts @@ -64,22 +64,24 @@ export class MotaOffscreenCanvas2D extends EventEmitter { logger.warn(33); return; } + const w = Math.max(width, 1); + const h = Math.max(height, 1); let ratio = this.highResolution ? devicePixelRatio : 1; const scale = core.domStyle.scale; if (this.autoScale) { ratio *= scale; } this.scale = ratio; - this.canvas.width = width * ratio; - this.canvas.height = height * ratio; - this.width = width; + this.canvas.width = w * ratio; + this.canvas.height = h * ratio; + this.width = w; this.height = height; this.ctx.setTransform(1, 0, 0, 1, 0, 0); this.ctx.scale(ratio, ratio); this.ctx.imageSmoothingEnabled = this.antiAliasing; if (this.canvas.isConnected) { - this.canvas.style.width = `${width * scale}px`; - this.canvas.style.height = `${height * scale}px`; + this.canvas.style.width = `${w * scale}px`; + this.canvas.style.height = `${h * scale}px`; } } diff --git a/src/module/render/components/misc.tsx b/src/module/render/components/misc.tsx index af0b49a..78a5278 100644 --- a/src/module/render/components/misc.tsx +++ b/src/module/render/components/misc.tsx @@ -1,7 +1,15 @@ -import { DefaultProps, ElementLocator, PathProps, Sprite } from '@/core/render'; +import { + DefaultProps, + ElementLocator, + onTick, + PathProps, + Sprite +} from '@/core/render'; import { computed, defineComponent, ref, watch } from 'vue'; import { SetupComponentOptions } from './types'; import { MotaOffscreenCanvas2D } from '@/core/fx/canvas2d'; +import { TextboxProps, TextContent } from './textbox'; +import { Scroll, ScrollExpose, ScrollProps } from './scroll'; interface ProgressProps extends DefaultProps { /** 进度条的位置 */ @@ -107,3 +115,121 @@ export const Arrow = defineComponent(props => { /> ); }, arrowProps); + +export interface ScrollTextProps extends TextboxProps, ScrollProps { + /** 自动滚动的速度,每秒多少像素 */ + speed: number; + /** 文字的最大宽度 */ + width: number; + /** 自动滚动组件的定位 */ + loc: ElementLocator; + /** 文字滚动入元素之前要先滚动多少像素,默认16像素 */ + pad?: number; +} + +export type ScrollTextEmits = { + /** + * 当滚动完毕时触发 + */ + scrollEnd: () => void; +}; + +export interface ScrollTextExpose { + /** + * 暂停滚动 + */ + pause(): void; + + /** + * 继续滚动 + */ + resume(): void; + + /** + * 设置滚动速度 + */ + setSpeed(speed: number): void; + + /** + * 立刻重新滚动 + */ + rescroll(): void; +} + +const scrollProps = { + props: ['speed', 'loc', 'pad', 'width'], + emits: ['scrollEnd'] +} satisfies SetupComponentOptions< + ScrollTextProps, + ScrollTextEmits, + keyof ScrollTextEmits +>; + +export const ScrollText = defineComponent< + ScrollTextProps, + ScrollTextEmits, + keyof ScrollTextEmits +>((props, { emit, expose, attrs }) => { + const scroll = ref(); + const speed = ref(props.speed); + + const eleHeight = computed(() => props.loc[3] ?? props.height ?? 200); + const pad = computed(() => props.pad ?? 16); + + let lastFixedTime = Date.now(); + let lastFixedPos = 0; + let paused = false; + let nowScroll = 0; + + onTick(() => { + if (paused || !scroll.value) return; + const now = Date.now(); + const dt = now - lastFixedTime; + nowScroll = (dt / 1000) * speed.value + lastFixedPos; + scroll.value.scrollTo(nowScroll, 0); + if (nowScroll >= scroll.value.getScrollLength()) { + emit('scrollEnd'); + paused = true; + } + }); + + const pause = () => { + paused = true; + }; + + const resume = () => { + paused = false; + lastFixedPos = nowScroll; + lastFixedTime = Date.now(); + }; + + const setSpeed = (value: number) => { + lastFixedPos = nowScroll; + lastFixedTime = Date.now(); + speed.value = value; + }; + + const rescroll = () => { + nowScroll = 0; + lastFixedTime = Date.now(); + lastFixedPos = 0; + }; + + expose({ pause, resume, setSpeed, rescroll }); + + return () => ( + + + + ); +}, scrollProps); diff --git a/src/module/render/components/scroll.tsx b/src/module/render/components/scroll.tsx index 1780258..5c94833 100644 --- a/src/module/render/components/scroll.tsx +++ b/src/module/render/components/scroll.tsx @@ -37,6 +37,11 @@ export interface ScrollExpose { * @param time 滚动的动画时长,默认为无动画 */ scrollTo(y: number, time?: number): void; + + /** + * 获取这个滚动条组件最多可以滚动多长 + */ + getScrollLength(): number; } export interface ScrollProps extends DefaultProps { @@ -47,7 +52,7 @@ export interface ScrollProps extends DefaultProps { * 滚动到最下方(最右方)时的填充大小,如果默认的高度计算方式有误, * 那么可以调整此参数来修复错误 */ - pad?: number; + padEnd?: number; } type ScrollSlots = SlotsType<{ @@ -55,7 +60,7 @@ type ScrollSlots = SlotsType<{ }>; const scrollProps = { - props: ['hor', 'noscroll', 'loc', 'pad'] + props: ['hor', 'noscroll', 'loc', 'padEnd'] } satisfies SetupComponentOptions; /** 滚动条图示的最短长度 */ @@ -112,6 +117,7 @@ export const Scroll = defineComponent( const scrollColor = computed( () => `rgba(${SCROLL_COLOR},${scrollAlpha.ref.value ?? 0.5})` ); + const padEnd = computed(() => props.padEnd ?? 0); watch(scrollColor, () => { scroll.value?.update(); @@ -145,7 +151,6 @@ export const Scroll = defineComponent( if (contentPos !== contentTarget) { contentPos = transition.value.showScroll; checkAllItem(); - updatePosition(); content.value?.update(); } }); @@ -211,7 +216,7 @@ export const Scroll = defineComponent( */ const onTransform = (item: RenderItem) => { const rect = item.getBoundingRect(); - const pad = props.pad ?? 0; + const pad = props.padEnd ?? 0; if (item.parent === content.value) { if (direction.value === ScrollDirection.Horizontal) { if (rect.right > maxLength - pad) { @@ -291,7 +296,7 @@ export const Scroll = defineComponent( } checkItem(v); }); - maxLength = Math.max(max + (props.pad ?? 0), 10); + maxLength = Math.max(max + padEnd.value, 10); updatePosition(); scroll.value?.update(); }; @@ -504,10 +509,19 @@ export const Scroll = defineComponent( transition.ticker.destroy(); }); + //#region expose 函数 + + const getScrollLength = () => { + return maxLength - height.value; + }; + expose({ - scrollTo + scrollTo, + getScrollLength }); + //#endregion + return () => { return ( diff --git a/src/module/render/components/textbox.tsx b/src/module/render/components/textbox.tsx index 490d67c..6f8abd4 100644 --- a/src/module/render/components/textbox.tsx +++ b/src/module/render/components/textbox.tsx @@ -26,6 +26,7 @@ import { WordBreak, TextAlign } from './textboxTyper'; +import { ElementLocator } from '@/core/render'; export interface TextContentProps extends DefaultProps, @@ -36,6 +37,10 @@ export interface TextContentProps fill?: boolean; /** 是否描边 */ stroke?: boolean; + /** 是否自适应高度 */ + autoHeight?: boolean; + /** 文字的最大宽度 */ + width: number; } export type TextContentEmits = { @@ -53,6 +58,11 @@ export interface TextContentExpose { * 立刻显示所有文字 */ showAll(): void; + + /** + * 获取这段 TextContent 的总高度 + */ + getHeight(): number; } const textContentOptions = { @@ -76,7 +86,8 @@ const textContentOptions = { 'strokeWidth', 'stroke', 'loc', - 'width' + 'width', + 'autoHeight' ], emits: ['typeEnd', 'typeStart'] } satisfies SetupComponentOptions< @@ -90,8 +101,11 @@ export const TextContent = defineComponent< TextContentEmits, keyof TextContentEmits >((props, { emit, expose }) => { - const width = computed(() => props.width ?? props.loc?.[2] ?? 200); - if (width.value < 0) { + const loc = ref( + (props.loc?.slice() as ElementLocator) ?? [] + ); + + if (props.width < 0) { logger.warn(41, String(props.width)); } @@ -112,6 +126,7 @@ export const TextContent = defineComponent< typer.setText(props.text ?? ''); typer.type(); needUpdate = false; + updateLoc(); }); }; @@ -119,12 +134,23 @@ export const TextContent = defineComponent< typer.typeAll(); }; - watch(props, value => { - typer.setConfig(value); + watch(props, () => { + typer.setConfig(props); retype(); }); - expose({ retype, showAll }); + const getHeight = () => { + return typer.getHeight(); + }; + + const updateLoc = () => { + if (props.autoHeight) { + const [x = 0, y = 0, width = 200, , ax = 0, ay = 0] = loc.value; + loc.value = [x, y, width, getHeight(), ax, ay]; + } + }; + + expose({ retype, showAll, getHeight }); const spriteElement = shallowRef(); const renderContent = (canvas: MotaOffscreenCanvas2D) => { @@ -175,7 +201,7 @@ export const TextContent = defineComponent< return () => { return ( @@ -200,6 +226,8 @@ export interface TextboxProps extends TextContentProps, DefaultProps { titleStroke?: CanvasStyle; /** 标题文字与边框间的距离,默认为4 */ titlePadding?: number; + /** 最大宽度 */ + width: number; } export interface TextboxExpose { @@ -257,8 +285,8 @@ export const Textbox = defineComponent< keyof TextboxEmits, TextboxSlots >((props, { slots, expose }) => { - const contentData = shallowReactive({}); - const data = shallowReactive({}); + const contentData = shallowReactive({ width: 200 }); + const data = shallowReactive({ width: 200 }); const setContentData = () => { contentData.breakChars = props.breakChars ?? ''; diff --git a/src/module/render/components/textboxTyper.ts b/src/module/render/components/textboxTyper.ts index 362922f..fb9b930 100644 --- a/src/module/render/components/textboxTyper.ts +++ b/src/module/render/components/textboxTyper.ts @@ -124,6 +124,8 @@ export interface TyperTextRenderable { strokeStyle: CanvasStyle; /** 文字画到哪个索引 */ pointer: number; + /** 这段文字的总高度 */ + height: number; } export interface TyperIconRenderable { @@ -231,6 +233,15 @@ export class TextContentTyper extends EventEmitter { onTick(() => this.tick()); } + /** + * 获取这段文字的总高度 + */ + getHeight() { + const heights = this.renderObject.lineHeights; + const lines = heights.reduce((prev, curr) => prev + curr, 0); + return lines + this.config.lineHeight * heights.length; + } + /** * 设置打字机的配置属性 * @param config 配置信息 @@ -285,19 +296,21 @@ export class TextContentTyper extends EventEmitter { if (line < 0 || line > renderable.splitLines.length) { return false; } - const start = renderable.splitLines[line - 1] ?? -1; + const start = renderable.splitLines[line - 1] ?? 0; const end = - renderable.splitLines[line] ?? renderable.text.length - 1; + renderable.splitLines[line] ?? renderable.text.length; + const lineHeight = this.renderObject.lineHeights[this.nowLine]; const data: TyperTextRenderable = { type: TextContentType.Text, x: this.x, y: this.y, - text: renderable.text.slice(start + 1, end + 1), + text: renderable.text.slice(start, end), font: renderable.font, fillStyle: renderable.fillStyle, strokeStyle: this.config.strokeStyle, - pointer: 0 + pointer: 0, + height: lineHeight + this.config.lineHeight }; this.processingData = data; this.renderData.push(data); @@ -926,7 +939,7 @@ export class TextContentParser { const rest = width - this.lineWidth; const guessRest = guess * (rest / width) * this.guessGain; const length = pointer - this.lineStart + 1; - if (length < guessRest) { + if (length <= guessRest) { return false; } this.guessGain = 1; @@ -941,7 +954,7 @@ export class TextContentParser { if (height > this.lineHeight) { this.lineHeight = height; } - if (metrics.width < rest) { + if (metrics.width <= rest) { // 实际宽度小于剩余宽度时,将猜测增益乘以剩余总宽度与当前宽度的比值的若干倍 this.guessGain *= (rest / metrics.width) * (1.1 + 1 / length); this.bsStart = breakIndex; @@ -956,7 +969,7 @@ export class TextContentParser { this.lineHeights.push(this.lineHeight); this.bsStart = index; const text = data.text.slice( - this.wordBreak[index], + this.wordBreak[index] + 1, pointer + 1 ); if (text.length < guessRest / 4) { @@ -995,7 +1008,7 @@ export class TextContentParser { return start; } const text = data.text.slice( - wordBreak[this.bsStart], + wordBreak[this.bsStart] + 1, wordBreak[mid] + 1 ); const metrics = ctx.measureText(text); @@ -1028,7 +1041,7 @@ export class TextContentParser { const wordBreak = data.wordBreak; const lastLine = data.splitLines.at(-1); const lastIndex = isNil(lastLine) ? 0 : lastLine; - const restText = data.text.slice(lastIndex); + const restText = data.text.slice(lastIndex + 1); const ctx = this.testCanvas.ctx; ctx.font = data.font; const metrics = ctx.measureText(restText); @@ -1052,7 +1065,7 @@ export class TextContentParser { data.splitLines.push(this.wordBreak[index]); this.lineHeights.push(this.lineHeight); this.bsStart = index; - const text = data.text.slice(this.wordBreak[index]); + const text = data.text.slice(this.wordBreak[index] + 1); if (!isLast && text.length < guess / 4) { // 如果剩余文字很少,几乎不可能会单独成一行时,直接结束循环 this.lastBreakIndex = index; @@ -1169,6 +1182,8 @@ export class TextContentParser { this.checkRestLine(width, guess, i); } + this.lineHeights.push(this.lineHeight); + return { lineHeights: this.lineHeights, data: this.renderable diff --git a/src/module/render/ui/main.tsx b/src/module/render/ui/main.tsx index 0758634..d25e89c 100644 --- a/src/module/render/ui/main.tsx +++ b/src/module/render/ui/main.tsx @@ -66,7 +66,8 @@ const MainScene = defineComponent(() => { titleFont: '700 20px normal', winskin: 'winskin2.png', interval: 100, - lineHeight: 4 + lineHeight: 4, + width: 480 }; const map = ref();